summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorMatt Silverlock <[email protected]>2026-03-03 13:35:49 -0500
committerGitHub <[email protected]>2026-03-03 13:35:49 -0500
commit74ebb4147fd4385cc2d37b46799faddcddd8ba9c (patch)
tree9b0a5e6393eb8760dc850a2a10bab2f739d20775
parent1663c11f40d0c99395e3a10bfb3f250f4d6d2a9e (diff)
downloadopencode-74ebb4147fd4385cc2d37b46799faddcddd8ba9c.tar.gz
opencode-74ebb4147fd4385cc2d37b46799faddcddd8ba9c.zip
fix(auth): normalize trailing slashes in auth login URLs (#15874)
-rw-r--r--packages/opencode/src/auth/index.ts7
-rw-r--r--packages/opencode/src/cli/cmd/auth.ts7
-rw-r--r--packages/opencode/src/config/config.ts13
-rw-r--r--packages/opencode/test/auth/auth.test.ts58
-rw-r--r--packages/opencode/test/config/config.test.ts65
5 files changed, 140 insertions, 10 deletions
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")