mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-19 23:24:42 +00:00
fix(auth): normalize trailing slashes in auth login URLs (#15874)
This commit is contained in:
@@ -56,13 +56,18 @@ export namespace Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function set(key: string, info: Info) {
|
export async function set(key: string, info: Info) {
|
||||||
|
const normalized = key.replace(/\/+$/, "")
|
||||||
const data = await all()
|
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) {
|
export async function remove(key: string) {
|
||||||
|
const normalized = key.replace(/\/+$/, "")
|
||||||
const data = await all()
|
const data = await all()
|
||||||
delete data[key]
|
delete data[key]
|
||||||
|
delete data[normalized]
|
||||||
await Filesystem.writeJson(filepath, data, 0o600)
|
await Filesystem.writeJson(filepath, data, 0o600)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ export const AuthLoginCommand = cmd({
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Add credential")
|
prompts.intro("Add credential")
|
||||||
if (args.url) {
|
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(" ")}\``)
|
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||||
const proc = Process.spawn(wellknown.auth.command, {
|
const proc = Process.spawn(wellknown.auth.command, {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
@@ -279,12 +280,12 @@ export const AuthLoginCommand = cmd({
|
|||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await Auth.set(args.url, {
|
await Auth.set(url, {
|
||||||
type: "wellknown",
|
type: "wellknown",
|
||||||
key: wellknown.auth.env,
|
key: wellknown.auth.env,
|
||||||
token: token.trim(),
|
token: token.trim(),
|
||||||
})
|
})
|
||||||
prompts.log.success("Logged into " + args.url)
|
prompts.log.success("Logged into " + url)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,11 +86,12 @@ export namespace Config {
|
|||||||
let result: Info = {}
|
let result: Info = {}
|
||||||
for (const [key, value] of Object.entries(auth)) {
|
for (const [key, value] of Object.entries(auth)) {
|
||||||
if (value.type === "wellknown") {
|
if (value.type === "wellknown") {
|
||||||
|
const url = key.replace(/\/+$/, "")
|
||||||
process.env[value.key] = value.token
|
process.env[value.key] = value.token
|
||||||
log.debug("fetching remote config", { url: `${key}/.well-known/opencode` })
|
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
||||||
const response = await fetch(`${key}/.well-known/opencode`)
|
const response = await fetch(`${url}/.well-known/opencode`)
|
||||||
if (!response.ok) {
|
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 wellknown = (await response.json()) as any
|
||||||
const remoteConfig = wellknown.config ?? {}
|
const remoteConfig = wellknown.config ?? {}
|
||||||
@@ -99,11 +100,11 @@ export namespace Config {
|
|||||||
result = mergeConfigConcatArrays(
|
result = mergeConfigConcatArrays(
|
||||||
result,
|
result,
|
||||||
await load(JSON.stringify(remoteConfig), {
|
await load(JSON.stringify(remoteConfig), {
|
||||||
dir: path.dirname(`${key}/.well-known/opencode`),
|
dir: path.dirname(`${url}/.well-known/opencode`),
|
||||||
source: `${key}/.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
packages/opencode/test/auth/auth.test.ts
Normal file
58
packages/opencode/test/auth/auth.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
@@ -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", () => {
|
describe("getPluginName", () => {
|
||||||
test("extracts name from file:// URL", () => {
|
test("extracts name from file:// URL", () => {
|
||||||
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
|
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
|
||||||
|
|||||||
Reference in New Issue
Block a user