diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 776cc99b4..80253a665 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -56,13 +56,18 @@ export namespace Auth { } export async function set(key: string, info: Info) { + const normalized = key.replace(/\/+$/, "") const data = await all() - await Filesystem.writeJson(filepath, { ...data, [key]: info }, 0o600) + if (normalized !== key) delete data[key] + delete data[normalized + "/"] + await Filesystem.writeJson(filepath, { ...data, [normalized]: info }, 0o600) } export async function remove(key: string) { + const normalized = key.replace(/\/+$/, "") const data = await all() delete data[key] + delete data[normalized] await Filesystem.writeJson(filepath, data, 0o600) } } diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 956359164..4afe7a822 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -263,7 +263,8 @@ export const AuthLoginCommand = cmd({ UI.empty() prompts.intro("Add credential") if (args.url) { - const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any) + const url = args.url.replace(/\/+$/, "") + const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any) prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", @@ -279,12 +280,12 @@ export const AuthLoginCommand = cmd({ prompts.outro("Done") return } - await Auth.set(args.url, { + await Auth.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim(), }) - prompts.log.success("Logged into " + args.url) + prompts.log.success("Logged into " + url) prompts.outro("Done") return } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 141f61569..28c5b239a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -86,11 +86,12 @@ export namespace Config { let result: Info = {} for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { + const url = key.replace(/\/+$/, "") process.env[value.key] = value.token - log.debug("fetching remote config", { url: `${key}/.well-known/opencode` }) - const response = await fetch(`${key}/.well-known/opencode`) + log.debug("fetching remote config", { url: `${url}/.well-known/opencode` }) + const response = await fetch(`${url}/.well-known/opencode`) if (!response.ok) { - throw new Error(`failed to fetch remote config from ${key}: ${response.status}`) + throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) } const wellknown = (await response.json()) as any const remoteConfig = wellknown.config ?? {} @@ -99,11 +100,11 @@ export namespace Config { result = mergeConfigConcatArrays( result, await load(JSON.stringify(remoteConfig), { - dir: path.dirname(`${key}/.well-known/opencode`), - source: `${key}/.well-known/opencode`, + dir: path.dirname(`${url}/.well-known/opencode`), + source: `${url}/.well-known/opencode`, }), ) - log.debug("loaded remote config from well-known", { url: key }) + log.debug("loaded remote config from well-known", { url }) } } diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts new file mode 100644 index 000000000..a569c7113 --- /dev/null +++ b/packages/opencode/test/auth/auth.test.ts @@ -0,0 +1,58 @@ +import { test, expect } from "bun:test" +import { Auth } from "../../src/auth" + +test("set normalizes trailing slashes in keys", async () => { + await Auth.set("https://example.com/", { + type: "wellknown", + key: "TOKEN", + token: "abc", + }) + const data = await Auth.all() + expect(data["https://example.com"]).toBeDefined() + expect(data["https://example.com/"]).toBeUndefined() +}) + +test("set cleans up pre-existing trailing-slash entry", async () => { + // Simulate a pre-fix entry with trailing slash + await Auth.set("https://example.com/", { + type: "wellknown", + key: "TOKEN", + token: "old", + }) + // Re-login with normalized key (as the CLI does post-fix) + await Auth.set("https://example.com", { + type: "wellknown", + key: "TOKEN", + token: "new", + }) + const data = await Auth.all() + const keys = Object.keys(data).filter((k) => k.includes("example.com")) + expect(keys).toEqual(["https://example.com"]) + const entry = data["https://example.com"]! + expect(entry.type).toBe("wellknown") + if (entry.type === "wellknown") expect(entry.token).toBe("new") +}) + +test("remove deletes both trailing-slash and normalized keys", async () => { + await Auth.set("https://example.com", { + type: "wellknown", + key: "TOKEN", + token: "abc", + }) + await Auth.remove("https://example.com/") + const data = await Auth.all() + expect(data["https://example.com"]).toBeUndefined() + expect(data["https://example.com/"]).toBeUndefined() +}) + +test("set and remove are no-ops on keys without trailing slashes", async () => { + await Auth.set("anthropic", { + type: "api", + key: "sk-test", + }) + const data = await Auth.all() + expect(data["anthropic"]).toBeDefined() + await Auth.remove("anthropic") + const after = await Auth.all() + expect(after["anthropic"]).toBeUndefined() +}) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f245dc349..40ab97449 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1535,6 +1535,71 @@ test("project config overrides remote well-known config", async () => { } }) +test("wellknown URL with trailing slash is normalized", async () => { + const originalFetch = globalThis.fetch + let fetchedUrl: string | undefined + const mockFetch = mock((url: string | URL | Request) => { + const urlStr = url.toString() + if (urlStr.includes(".well-known/opencode")) { + fetchedUrl = urlStr + return Promise.resolve( + new Response( + JSON.stringify({ + config: { + mcp: { + slack: { + type: "remote", + url: "https://slack.example.com/mcp", + enabled: true, + }, + }, + }, + }), + { status: 200 }, + ), + ) + } + return originalFetch(url) + }) + globalThis.fetch = mockFetch as unknown as typeof fetch + + const originalAuthAll = Auth.all + Auth.all = mock(() => + Promise.resolve({ + "https://example.com/": { + type: "wellknown" as const, + key: "TEST_TOKEN", + token: "test-token", + }, + }), + ) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + // Trailing slash should be stripped — no double slash in the fetch URL + expect(fetchedUrl).toBe("https://example.com/.well-known/opencode") + }, + }) + } finally { + globalThis.fetch = originalFetch + Auth.all = originalAuthAll + } +}) + describe("getPluginName", () => { test("extracts name from file:// URL", () => { expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")