From 54acd2fc158c8379b4a7e3c43f78f2e16fcfb847 Mon Sep 17 00:00:00 2001 From: musistudio Date: Wed, 22 Jul 2026 10:01:08 +0800 Subject: [PATCH] Prefer live Claude Code OAuth credentials --- .../src/agents/local-providers/claude-code.ts | 35 ++-- .../src/agents/local-providers/service.ts | 1 + .../gateway/core-runtime/config-compiler.ts | 33 +++- .../core/src/providers/account-service.ts | 13 +- .../local-agent-provider-claude-code.test.mjs | 165 ++++++++++++++++++ .../provider-account-service.test.mjs | 81 ++++++++- 6 files changed, 310 insertions(+), 18 deletions(-) create mode 100644 packages/core/test/unit/agents/local-agent-provider-claude-code.test.mjs diff --git a/packages/core/src/agents/local-providers/claude-code.ts b/packages/core/src/agents/local-providers/claude-code.ts index bdae4e26..95731be1 100644 --- a/packages/core/src/agents/local-providers/claude-code.ts +++ b/packages/core/src/agents/local-providers/claude-code.ts @@ -138,7 +138,12 @@ function claudeCodeProviderAccountConfig(): ProviderAccountConfig { }; } -function readClaudeCodeOauth(): OAuthTokenSet | undefined { +export function readClaudeCodeOauth(): OAuthTokenSet | undefined { + const keychainOauth = readClaudeCodeKeychainOauth(); + if (keychainOauth) { + return keychainOauth; + } + for (const sourceFile of claudeCredentialFiles()) { const record = readJsonRecord(sourceFile); if (!record) { @@ -152,18 +157,6 @@ function readClaudeCodeOauth(): OAuthTokenSet | undefined { }; } - const keychainRecord = readClaudeCodeKeychainRecord(); - if (keychainRecord) { - const credential = findOauthTokenSet(keychainRecord); - if (credential) { - return { - accessToken: credential.accessToken, - refreshToken: credential.refreshToken, - sourceFile: `keychain:${claudeCodeKeychainService}` - }; - } - } - return undefined; } @@ -179,6 +172,22 @@ function claudeCredentialFiles(): string[] { // Keychain instead of ~/.claude/.credentials.json. Reading it triggers the // standard macOS keychain access prompt (Allow / Always Allow); the user // declining or the item not existing both surface as a non-zero exit here. +function readClaudeCodeKeychainOauth(): OAuthTokenSet | undefined { + const keychainRecord = readClaudeCodeKeychainRecord(); + if (!keychainRecord) { + return undefined; + } + const credential = findOauthTokenSet(keychainRecord); + if (!credential) { + return undefined; + } + return { + accessToken: credential.accessToken, + refreshToken: credential.refreshToken, + sourceFile: `keychain:${claudeCodeKeychainService}` + }; +} + function readClaudeCodeKeychainRecord(): Record | undefined { if (process.platform !== "darwin") { return undefined; diff --git a/packages/core/src/agents/local-providers/service.ts b/packages/core/src/agents/local-providers/service.ts index d22c40ab..429513d0 100644 --- a/packages/core/src/agents/local-providers/service.ts +++ b/packages/core/src/agents/local-providers/service.ts @@ -13,6 +13,7 @@ import { importOpenCodeProvider, opencodeCandidates } from "@ccr/core/agents/loc import { importZcodeProvider, zcodeCandidate } from "@ccr/core/agents/local-providers/zcode"; export { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/codex"; +export { readClaudeCodeOauth } from "@ccr/core/agents/local-providers/claude-code"; export { grokDefaultBaseUrl, readGrokAuth, resolveGrokAuth } from "@ccr/core/agents/local-providers/grok"; export { kimiAccessTokenExpired, kimiIdentityHeaders, readKimiAuth, resolveKimiAuth } from "@ccr/core/agents/local-providers/kimi"; export { readZcodeLocalProviderCredential, zcodeDefaultBaseUrl } from "@ccr/core/agents/local-providers/zcode"; diff --git a/packages/core/src/gateway/core-runtime/config-compiler.ts b/packages/core/src/gateway/core-runtime/config-compiler.ts index 12bdcf63..74fc2b7f 100644 --- a/packages/core/src/gateway/core-runtime/config-compiler.ts +++ b/packages/core/src/gateway/core-runtime/config-compiler.ts @@ -3,7 +3,7 @@ */ import { join as pathJoin } from "node:path"; import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, VirtualModelProfileConfig } from "@ccr/core/contracts/app"; -import { codexDefaultBaseUrl, kimiAccessTokenExpired, kimiIdentityHeaders, readCodexAuth, readGrokAuth, readKimiAuth, resolveGrokAuth, resolveKimiAuth } from "@ccr/core/agents/local-providers/service"; +import { codexDefaultBaseUrl, kimiAccessTokenExpired, kimiIdentityHeaders, readClaudeCodeOauth, readCodexAuth, readGrokAuth, readKimiAuth, resolveGrokAuth, resolveKimiAuth } from "@ccr/core/agents/local-providers/service"; import { grokAccessTokenExpired, grokClientVersion } from "@ccr/core/agents/local-providers/grok"; import { pluginService } from "@ccr/core/plugins/service"; import { normalizeRouteSelector, providerRuntimeId } from "@ccr/core/routing/model-registry"; @@ -45,7 +45,7 @@ export async function compileCoreGatewayConfig( ...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled) ]); const providerPluginsWithRuntimeDefaults = await withKimiOauthRuntimeDefaults( - await withGrokOauthRuntimeDefaults(withCodexOauthRuntimeDefaults(configuredProviderPlugins)) + await withGrokOauthRuntimeDefaults(withClaudeCodeOauthRuntimeDefaults(withCodexOauthRuntimeDefaults(configuredProviderPlugins))) ); const codexOauthProviderNames = codexOauthLocalProviderNames(providerPluginsWithRuntimeDefaults); const providerPlugins = normalizeCoreProviderPluginNames(providerPluginsWithRuntimeDefaults, config.Providers); @@ -387,6 +387,35 @@ function withCodexOauthRuntimeDefaults(providerPlugins: unknown[]): unknown[] { } +function withClaudeCodeOauthRuntimeDefaults(providerPlugins: unknown[]): unknown[] { + if (!providerPlugins.some(isLocalClaudeCodeOauthProviderPlugin)) { + return providerPlugins; + } + const oauth = readClaudeCodeOauth(); + if (!oauth?.accessToken) { + return providerPlugins; + } + + return providerPlugins.map((plugin) => { + if (!isLocalClaudeCodeOauthProviderPlugin(plugin)) { + return plugin; + } + const currentAuth = isRecord(plugin.auth) ? plugin.auth : {}; + const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {}; + return { + ...plugin, + auth: { + ...currentAuth, + headers: { + ...currentHeaders, + authorization: `Bearer ${oauth.accessToken}` + } + } + }; + }); +} + + async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise { const grokAuth = await resolveGrokAuth().catch(() => readGrokAuth()); if (!grokAuth?.accessToken || grokAccessTokenExpired(grokAuth)) { diff --git a/packages/core/src/providers/account-service.ts b/packages/core/src/providers/account-service.ts index 86961b05..e8fa9fef 100644 --- a/packages/core/src/providers/account-service.ts +++ b/packages/core/src/providers/account-service.ts @@ -6,6 +6,7 @@ import { localAgentProviderApiKey, kimiAccessTokenExpired, kimiIdentityHeaders, + readClaudeCodeOauth, readCodexAuth, readGrokAuth, readKimiAuth, @@ -1380,7 +1381,7 @@ async function localAgentProviderAccountCredential( return await localCodexAccountCredential(plugin); } if (key.includes("claude-code-oauth")) { - return localBearerAccountCredential(plugin); + return localClaudeCodeAccountCredential(plugin); } if (key.includes("grok-cli-oauth")) { return await localGrokAccountCredential(plugin); @@ -1712,6 +1713,16 @@ function localBearerAccountCredential(plugin: Record): { apiKey }; } +function localClaudeCodeAccountCredential(plugin: Record): { apiKey?: string; headers?: Record } { + const headers = localProviderPluginAuthHeaders(plugin); + const oauth = readClaudeCodeOauth(); + const apiKey = oauth?.accessToken || readBearerToken(headers.authorization || headers.Authorization); + return { + apiKey, + headers: withoutHeader(headers, "authorization") + }; +} + async function localGrokAccountCredential(plugin: Record): Promise<{ apiKey?: string; headers?: Record }> { const headers = localProviderPluginAuthHeaders(plugin); const auth = await resolveGrokAuth().catch(() => readGrokAuth()); diff --git a/packages/core/test/unit/agents/local-agent-provider-claude-code.test.mjs b/packages/core/test/unit/agents/local-agent-provider-claude-code.test.mjs new file mode 100644 index 00000000..404bf74f --- /dev/null +++ b/packages/core/test/unit/agents/local-agent-provider-claude-code.test.mjs @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { claudeCodeCandidate, importClaudeCodeProvider } from "@ccr/core/agents/local-providers/claude-code.ts"; +import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; +import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler.ts"; + +test("Claude Code local provider prefers macOS Keychain credentials over stale file credentials", { skip: process.platform === "win32" }, async () => { + await withClaudeCodeHome(async (home) => { + await withPlatform("darwin", async () => { + await withFakeSecurityOutput({ + claudeAiOauth: { + accessToken: "keychain-access-token", + refreshToken: "keychain-refresh-token" + } + }, async () => { + writeClaudeCredentials(home, { + access_token: "stale-file-access-token", + refresh_token: "stale-file-refresh-token" + }); + + const candidate = claudeCodeCandidate(); + assert.equal(candidate.status, "available"); + assert.equal(candidate.importable, true); + assert.equal(candidate.sourceFile, "keychain:Claude Code-credentials"); + + const result = importClaudeCodeProvider(candidate, []); + assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer keychain-access-token"); + assert.equal(result.providerPlugins[1].auth.headers.authorization, "Bearer keychain-access-token"); + }); + }); + }); +}); + +test("Claude Code local provider falls back to file credentials when Keychain is unavailable", { skip: process.platform === "win32" }, async () => { + await withClaudeCodeHome(async (home) => { + await withPlatform("darwin", async () => { + await withFakeSecurityFailure(async () => { + const credentialFile = writeClaudeCredentials(home, { + accessToken: "file-access-token", + refreshToken: "file-refresh-token" + }); + + const candidate = claudeCodeCandidate(); + assert.equal(candidate.status, "available"); + assert.equal(candidate.importable, true); + assert.equal(candidate.sourceFile, credentialFile); + + const result = importClaudeCodeProvider(candidate, []); + assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer file-access-token"); + }); + }); + }); +}); + +test("Core gateway config replaces imported Claude Code OAuth token with live macOS Keychain token", { skip: process.platform === "win32" }, async () => { + await withClaudeCodeHome(async (home) => { + await withPlatform("darwin", async () => { + await withFakeSecurityOutput({ + access_token: "keychain-runtime-token", + refresh_token: "keychain-refresh-token" + }, async () => { + const config = createDefaultAppConfig({ generatedConfigFile: path.join(home, "config.json") }); + config.providerPlugins = [ + { + auth: { + headers: { + authorization: "Bearer imported-stale-token", + "anthropic-beta": "oauth-2025-04-20" + }, + removeHeaders: ["x-api-key"], + strict: true + }, + key: "ccr-local-agent-claude-code-api-claude-code-oauth", + providerName: "Claude Code API" + } + ]; + config.Providers = [ + { + api_base_url: "https://api.anthropic.com", + id: "claude-code-api", + models: ["claude-sonnet-5"], + name: "Claude Code API", + type: "anthropic_messages" + } + ]; + + const compiled = await compileCoreGatewayConfig(config, "raw-trace-token", "billing-usage-token", "core-auth-token"); + const plugin = compiled.providerPlugins.find((item) => item.key === "ccr-local-agent-claude-code-api-claude-code-oauth"); + + assert.equal(plugin.auth.headers.authorization, "Bearer keychain-runtime-token"); + assert.deepEqual(plugin.auth.headers["anthropic-beta"], { + default: "oauth-2025-04-20", + from: "request.headers.anthropic-beta" + }); + }); + }); + }); +}); + +async function withClaudeCodeHome(run) { + const home = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-code-provider-")); + const previousHome = process.env.HOME; + process.env.HOME = home; + try { + await run(home); + } finally { + restoreEnv("HOME", previousHome); + rmSync(home, { force: true, recursive: true }); + } +} + +async function withPlatform(platform, run) { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { + configurable: true, + value: platform + }); + try { + await run(); + } finally { + Object.defineProperty(process, "platform", descriptor); + } +} + +async function withFakeSecurityOutput(output, run) { + await withFakeSecurityScript(`cat <<'CCR_KEYCHAIN_JSON'\n${JSON.stringify(output)}\nCCR_KEYCHAIN_JSON\n`, run); +} + +async function withFakeSecurityFailure(run) { + await withFakeSecurityScript("exit 44\n", run); +} + +async function withFakeSecurityScript(body, run) { + const binDir = mkdtempSync(path.join(os.tmpdir(), "ccr-security-bin-")); + const securityPath = path.join(binDir, "security"); + const previousPath = process.env.PATH; + writeFileSync(securityPath, `#!/bin/sh\n${body}`); + chmodSync(securityPath, 0o755); + process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`; + try { + await run(); + } finally { + restoreEnv("PATH", previousPath); + rmSync(binDir, { force: true, recursive: true }); + } +} + +function writeClaudeCredentials(home, credentials) { + const directory = path.join(home, ".claude"); + const credentialFile = path.join(directory, ".credentials.json"); + mkdirSync(directory, { recursive: true }); + writeFileSync(credentialFile, JSON.stringify(credentials, null, 2)); + return credentialFile; +} + +function restoreEnv(name, value) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} diff --git a/packages/core/test/unit/providers/provider-account-service.test.mjs b/packages/core/test/unit/providers/provider-account-service.test.mjs index 497ff37e..b1ab8821 100644 --- a/packages/core/test/unit/providers/provider-account-service.test.mjs +++ b/packages/core/test/unit/providers/provider-account-service.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -226,6 +226,43 @@ test("Codex local account credential falls back to the live auth file when plugi assert.equal(credential?.headers?.["ChatGPT-Account-Id"], "acct-live"); }); +test("Claude Code local account credential prefers live macOS Keychain token", { skip: process.platform === "win32" }, async (t) => { + const home = useTemporaryHome(t, "ccr-claude-code-account-live-keychain-"); + usePlatform(t, "darwin"); + useFakeSecurityOutput(t, { + access_token: "keychain-account-token", + refresh_token: "keychain-refresh-token" + }); + process.env.HOME = home; + + const credential = await localAgentProviderAccountCredentialForTest({ + providerPlugins: [ + { + auth: { + headers: { + authorization: "Bearer imported-stale-token", + "anthropic-beta": "oauth-2025-04-20" + }, + strict: true + }, + key: "ccr-local-agent-claude-code-api-claude-code-oauth-internal", + providerName: "claude-code-api::anthropic_messages" + } + ] + }, { + api_base_url: "https://api.anthropic.com", + api_key: localAgentProviderApiKey, + id: "claude-code-api", + models: ["claude-sonnet-5"], + name: "Renamed Claude Code API", + type: "anthropic_messages" + }); + + assert.equal(credential?.apiKey, "keychain-account-token"); + assert.equal(credential?.headers?.authorization, undefined); + assert.equal(credential?.headers?.["anthropic-beta"], "oauth-2025-04-20"); +}); + test("Kimi local account credential carries its API key and CLI identity", async (t) => { const home = useTemporaryCodexHome(t, "ccr-kimi-account-plugin-"); const previousVersion = process.env.KIMI_CODE_VERSION; @@ -323,11 +360,17 @@ test("ZCode local account credential falls back to the live config when plugin i }); function useTemporaryCodexHome(t, prefix) { + const home = useTemporaryHome(t, prefix); + mkdirSync(path.join(home, ".codex"), { recursive: true }); + return home; +} + +function useTemporaryHome(t, prefix) { const previousHome = process.env.CCR_INTERNAL_HOME_DIR; + const previousOsHome = process.env.HOME; const previousZcodeHome = process.env.ZCODE_HOME; const previousZcodeStorageDir = process.env.ZCODE_STORAGE_DIR; const home = mkdtempSync(path.join(os.tmpdir(), prefix)); - mkdirSync(path.join(home, ".codex"), { recursive: true }); process.env.CCR_INTERNAL_HOME_DIR = home; delete process.env.ZCODE_HOME; delete process.env.ZCODE_STORAGE_DIR; @@ -337,6 +380,11 @@ function useTemporaryCodexHome(t, prefix) { } else { process.env.CCR_INTERNAL_HOME_DIR = previousHome; } + if (previousOsHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousOsHome; + } if (previousZcodeHome === undefined) { delete process.env.ZCODE_HOME; } else { @@ -347,10 +395,39 @@ function useTemporaryCodexHome(t, prefix) { } else { process.env.ZCODE_STORAGE_DIR = previousZcodeStorageDir; } + rmSync(home, { force: true, recursive: true }); }); return home; } +function usePlatform(t, platform) { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { + configurable: true, + value: platform + }); + t.after(() => { + Object.defineProperty(process, "platform", descriptor); + }); +} + +function useFakeSecurityOutput(t, output) { + const binDir = mkdtempSync(path.join(os.tmpdir(), "ccr-security-bin-")); + const securityPath = path.join(binDir, "security"); + const previousPath = process.env.PATH; + writeFileSync(securityPath, `#!/bin/sh\ncat <<'CCR_KEYCHAIN_JSON'\n${JSON.stringify(output)}\nCCR_KEYCHAIN_JSON\n`); + chmodSync(securityPath, 0o755); + process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`; + t.after(() => { + if (previousPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = previousPath; + } + rmSync(binDir, { force: true, recursive: true }); + }); +} + function jwt(payload) { return [ base64url({ alg: "none", typ: "JWT" }),