mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-30 17:11:12 +08:00
Prefer live Claude Code OAuth credentials
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
Reference in New Issue
Block a user