diff --git a/src/agents/pi-auth-json.test.ts b/src/agents/pi-auth-json.test.ts index e07a2840dc6..5f24ec32033 100644 --- a/src/agents/pi-auth-json.test.ts +++ b/src/agents/pi-auth-json.test.ts @@ -39,4 +39,153 @@ describe("ensurePiAuthJsonFromAuthProfiles", () => { const second = await ensurePiAuthJsonFromAuthProfiles(agentDir); expect(second.wrote).toBe(false); }); + + it("writes api_key credentials into auth.json", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + + saveAuthProfileStore( + { + version: 1, + profiles: { + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "sk-or-v1-test-key", + }, + }, + }, + agentDir, + ); + + const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + expect(result.wrote).toBe(true); + + const authPath = path.join(agentDir, "auth.json"); + const auth = JSON.parse(await fs.readFile(authPath, "utf8")) as Record; + expect(auth["openrouter"]).toMatchObject({ + type: "api_key", + key: "sk-or-v1-test-key", + }); + }); + + it("writes token credentials as api_key into auth.json", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + + saveAuthProfileStore( + { + version: 1, + profiles: { + "anthropic:default": { + type: "token", + provider: "anthropic", + token: "sk-ant-test-token", + }, + }, + }, + agentDir, + ); + + const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + expect(result.wrote).toBe(true); + + const authPath = path.join(agentDir, "auth.json"); + const auth = JSON.parse(await fs.readFile(authPath, "utf8")) as Record; + expect(auth["anthropic"]).toMatchObject({ + type: "api_key", + key: "sk-ant-test-token", + }); + }); + + it("syncs multiple providers at once", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + + saveAuthProfileStore( + { + version: 1, + profiles: { + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "sk-or-key", + }, + "anthropic:default": { + type: "token", + provider: "anthropic", + token: "sk-ant-token", + }, + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "access", + refresh: "refresh", + expires: Date.now() + 60_000, + }, + }, + }, + agentDir, + ); + + const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + expect(result.wrote).toBe(true); + + const authPath = path.join(agentDir, "auth.json"); + const auth = JSON.parse(await fs.readFile(authPath, "utf8")) as Record; + + expect(auth["openrouter"]).toMatchObject({ type: "api_key", key: "sk-or-key" }); + expect(auth["anthropic"]).toMatchObject({ type: "api_key", key: "sk-ant-token" }); + expect(auth["openai-codex"]).toMatchObject({ type: "oauth", access: "access" }); + }); + + it("skips profiles with empty keys", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + + saveAuthProfileStore( + { + version: 1, + profiles: { + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "", + }, + }, + }, + agentDir, + ); + + const result = await ensurePiAuthJsonFromAuthProfiles(agentDir); + expect(result.wrote).toBe(false); + }); + + it("preserves existing auth.json entries not in auth-profiles", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + const authPath = path.join(agentDir, "auth.json"); + + // Pre-populate auth.json with an entry + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + authPath, + JSON.stringify({ "legacy-provider": { type: "api_key", key: "legacy-key" } }), + ); + + saveAuthProfileStore( + { + version: 1, + profiles: { + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "new-key", + }, + }, + }, + agentDir, + ); + + await ensurePiAuthJsonFromAuthProfiles(agentDir); + + const auth = JSON.parse(await fs.readFile(authPath, "utf8")) as Record; + expect(auth["legacy-provider"]).toMatchObject({ type: "api_key", key: "legacy-key" }); + expect(auth["openrouter"]).toMatchObject({ type: "api_key", key: "new-key" }); + }); }); diff --git a/src/agents/pi-auth-json.ts b/src/agents/pi-auth-json.ts index c32abff1863..794042d2ece 100644 --- a/src/agents/pi-auth-json.ts +++ b/src/agents/pi-auth-json.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; +import type { AuthProfileCredential } from "./auth-profiles/types.js"; +import { ensureAuthProfileStore } from "./auth-profiles.js"; type AuthJsonCredential = | { @@ -31,70 +32,119 @@ async function readAuthJson(filePath: string): Promise { } /** - * pi-coding-agent's ModelRegistry/AuthStorage expects OAuth credentials in auth.json. + * Convert an OpenClaw auth-profiles credential to pi-coding-agent auth.json format. + * Returns null if the credential cannot be converted. + */ +function convertCredential(cred: AuthProfileCredential): AuthJsonCredential | null { + if (cred.type === "api_key") { + const key = typeof cred.key === "string" ? cred.key.trim() : ""; + if (!key) { + return null; + } + return { type: "api_key", key }; + } + + if (cred.type === "token") { + // pi-coding-agent treats static tokens as api_key type + const token = typeof cred.token === "string" ? cred.token.trim() : ""; + if (!token) { + return null; + } + return { type: "api_key", key: token }; + } + + if (cred.type === "oauth") { + const accessRaw = (cred as { access?: unknown }).access; + const refreshRaw = (cred as { refresh?: unknown }).refresh; + const expiresRaw = (cred as { expires?: unknown }).expires; + + const access = typeof accessRaw === "string" ? accessRaw.trim() : ""; + const refresh = typeof refreshRaw === "string" ? refreshRaw.trim() : ""; + const expires = typeof expiresRaw === "number" ? expiresRaw : Number.NaN; + + if (!access || !refresh || !Number.isFinite(expires) || expires <= 0) { + return null; + } + return { type: "oauth", access, refresh, expires }; + } + + return null; +} + +/** + * Check if two auth.json credentials are equivalent. + */ +function credentialsEqual(a: AuthJsonCredential | undefined, b: AuthJsonCredential): boolean { + if (!a || typeof a !== "object") { + return false; + } + if (a.type !== b.type) { + return false; + } + + if (a.type === "api_key" && b.type === "api_key") { + return a.key === b.key; + } + + if (a.type === "oauth" && b.type === "oauth") { + return a.access === b.access && a.refresh === b.refresh && a.expires === b.expires; + } + + return false; +} + +/** + * pi-coding-agent's ModelRegistry/AuthStorage expects credentials in auth.json. * - * OpenClaw stores OAuth credentials in auth-profiles.json instead. This helper - * bridges a subset of credentials into agentDir/auth.json so pi-coding-agent can - * (a) consider the provider authenticated and (b) include built-in models in its + * OpenClaw stores credentials in auth-profiles.json instead. This helper + * bridges all credentials into agentDir/auth.json so pi-coding-agent can + * (a) consider providers authenticated and (b) include built-in models in its * registry/catalog output. * - * Currently used for openai-codex. + * Syncs all credential types: api_key, token (as api_key), and oauth. */ export async function ensurePiAuthJsonFromAuthProfiles(agentDir: string): Promise<{ wrote: boolean; authPath: string; }> { const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); - const codexProfiles = listProfilesForProvider(store, "openai-codex"); - if (codexProfiles.length === 0) { - return { wrote: false, authPath: path.join(agentDir, "auth.json") }; - } - - const profileId = codexProfiles[0]; - const cred = profileId ? store.profiles[profileId] : undefined; - if (!cred || cred.type !== "oauth") { - return { wrote: false, authPath: path.join(agentDir, "auth.json") }; - } - - const accessRaw = (cred as { access?: unknown }).access; - const refreshRaw = (cred as { refresh?: unknown }).refresh; - const expiresRaw = (cred as { expires?: unknown }).expires; - - const access = typeof accessRaw === "string" ? accessRaw.trim() : ""; - const refresh = typeof refreshRaw === "string" ? refreshRaw.trim() : ""; - const expires = typeof expiresRaw === "number" ? expiresRaw : Number.NaN; - - if (!access || !refresh || !Number.isFinite(expires) || expires <= 0) { - return { wrote: false, authPath: path.join(agentDir, "auth.json") }; - } - const authPath = path.join(agentDir, "auth.json"); - const next = await readAuthJson(authPath); - const existing = next["openai-codex"]; - const desired: AuthJsonCredential = { - type: "oauth", - access, - refresh, - expires, - }; + // Group profiles by provider, taking the first valid profile for each + const providerCredentials = new Map(); - const isSame = - existing && - typeof existing === "object" && - (existing as { type?: unknown }).type === "oauth" && - (existing as { access?: unknown }).access === access && - (existing as { refresh?: unknown }).refresh === refresh && - (existing as { expires?: unknown }).expires === expires; + for (const [, cred] of Object.entries(store.profiles)) { + const provider = cred.provider; + if (!provider || providerCredentials.has(provider)) { + continue; + } - if (isSame) { + const converted = convertCredential(cred); + if (converted) { + providerCredentials.set(provider, converted); + } + } + + if (providerCredentials.size === 0) { return { wrote: false, authPath }; } - next["openai-codex"] = desired; + const existing = await readAuthJson(authPath); + let changed = false; + + for (const [provider, cred] of providerCredentials) { + if (!credentialsEqual(existing[provider], cred)) { + existing[provider] = cred; + changed = true; + } + } + + if (!changed) { + return { wrote: false, authPath }; + } await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); - await fs.writeFile(authPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 }); + await fs.writeFile(authPath, `${JSON.stringify(existing, null, 2)}\n`, { mode: 0o600 }); return { wrote: true, authPath }; }