浏览代码

fix(auth): normalize trailing slashes in auth login URLs (#15874)

Matt Silverlock 1 月之前
父节点
当前提交
74ebb4147f

+ 6 - 1
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)
   }
 }

+ 4 - 3
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
         }

+ 7 - 6
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 })
       }
     }
 

+ 58 - 0
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()
+})

+ 65 - 0
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")