Prefer live Claude Code OAuth credentials

This commit is contained in:
musistudio
2026-07-22 10:01:08 +08:00
parent e11bb8bb40
commit 54acd2fc15
6 changed files with 310 additions and 18 deletions
@@ -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<string, unknown> | undefined {
if (process.platform !== "darwin") {
return undefined;
@@ -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";
@@ -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<unknown[]> {
const grokAuth = await resolveGrokAuth().catch(() => readGrokAuth());
if (!grokAuth?.accessToken || grokAccessTokenExpired(grokAuth)) {
+12 -1
View File
@@ -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<string, unknown>): { apiKey
};
}
function localClaudeCodeAccountCredential(plugin: Record<string, unknown>): { apiKey?: string; headers?: Record<string, string> } {
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<string, unknown>): Promise<{ apiKey?: string; headers?: Record<string, string> }> {
const headers = localProviderPluginAuthHeaders(plugin);
const auth = await resolveGrokAuth().catch(() => readGrokAuth());
@@ -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;
}
}
@@ -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" }),