mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-30 17:11:12 +08:00
fix(claude-code): look up the keychain item by account
Claude Code >= 2.1 writes its credential item under the current $USER and
leaves any pre-2.1 item (account "unknown") in place on the *same* service
name, `Claude Code-credentials`. `security find-generic-password -s <svc> -w`
with no `-a` matches an arbitrary one of them, so CCR read the stale legacy
item: the read exits 0, the JSON parses, and it carries only `mcpOAuth`.
`findOauthTokenSet()` then correctly returns undefined and the candidate is
silently dropped by the `status !== "missing"` filter in service.ts.
Resolve the item through tiered candidates, evaluated lazily so the normal
case stays at one `security` call:
1. the expected service name, derived the way Claude Code derives it
(`Claude Code${oauthSuffix}-credentials${configSuffix}`, where
configSuffix is `-${sha256(NFC(configDir)).slice(0, 8)}` once
CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR is set), read under
the current account;
2. `security dump-keychain` enumeration, newest-modified first, for items
under another account or a config dir this process cannot reconstruct.
Without `-d` that prints item metadata only: it never decrypts a
password and never prompts;
3. the previous accountless read, as a last resort.
Prefer an explicit top-level `claudeAiOauth` across every candidate and fall
back to the recursive search only if none has one. `mcpOAuth` holds
per-plugin OAuth records that also carry `accessToken`, and key order puts
it first, so a recursive match could import an MCP plugin token as an
Anthropic provider that then 401s on every request.
Surface diagnostics instead of swallowing them: capture `security` stderr
rather than discarding it via `stdio: ["ignore","pipe","ignore"]`, and
return a `locked` candidate naming the reason when login state exists but
yields no token, so it survives the `status !== "missing"` filter. Treat
errSecItemNotFound (exit 44) as absence rather than an error, so a machine
with no Claude Code login still reports `missing`.
Also fix the file fallback: continue past a credentials file that holds no
token instead of returning it, and check the secure-storage config dir,
which the env vars above can relocate away from ~/.claude.
Refs #1601, #1567
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
@@ -21,7 +22,23 @@ import {
|
||||
} from "@ccr/core/agents/local-providers/shared";
|
||||
|
||||
const claudeDefaultModels = ["claude-sonnet-5"];
|
||||
const claudeCodeKeychainService = "Claude Code-credentials";
|
||||
const claudeCodeKeychainServiceBase = "Claude Code-credentials";
|
||||
const claudeCodeKeychainAccountPattern = /^[a-zA-Z0-9._-]+$/;
|
||||
const claudeCodeKeychainServicePattern = /^Claude Code(?:-[a-z]+-oauth)?-credentials(?:-[0-9a-f]{8})?$/;
|
||||
// `security` exit code for errSecItemNotFound.
|
||||
const keychainItemNotFoundStatus = 44;
|
||||
|
||||
type ClaudeCodeKeychainCandidate = { account?: string; service: string };
|
||||
|
||||
export type ClaudeCodeLoginScan = {
|
||||
/** Reads that failed, with the `security` stderr that explains why. */
|
||||
errors: string[];
|
||||
/** Locations that parsed successfully. */
|
||||
inspected: string[];
|
||||
oauth?: OAuthTokenSet;
|
||||
/** Locations that parsed but carry no OAuth token. */
|
||||
tokenless: string[];
|
||||
};
|
||||
|
||||
const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({
|
||||
id,
|
||||
@@ -65,7 +82,8 @@ const claudeCodeAccountMapping: ProviderAccountMappingConfig = {
|
||||
};
|
||||
|
||||
export function claudeCodeCandidate(): LocalAgentProviderCandidate {
|
||||
const oauth = readClaudeCodeOauth();
|
||||
const scan = scanClaudeCodeLogin();
|
||||
const oauth = scan.oauth;
|
||||
if (oauth?.accessToken) {
|
||||
return {
|
||||
detail: "Claude Code login detected. Click Import to add it as a gateway provider.",
|
||||
@@ -92,9 +110,36 @@ export function claudeCodeCandidate(): LocalAgentProviderCandidate {
|
||||
status: "locked"
|
||||
};
|
||||
}
|
||||
const detail = claudeCodeScanDiagnostic(scan);
|
||||
if (detail) {
|
||||
return {
|
||||
detail,
|
||||
id: "claude-code-api",
|
||||
importable: false,
|
||||
kind: "claude-code",
|
||||
models: claudeDefaultModels,
|
||||
name: "Claude Code API",
|
||||
protocol: "anthropic_messages",
|
||||
sourceFile: scan.inspected[0],
|
||||
status: "locked"
|
||||
};
|
||||
}
|
||||
return missingCandidate("claude-code", "claude-code-api", "Claude Code API", "anthropic_messages", claudeDefaultModels);
|
||||
}
|
||||
|
||||
// Distinguishes "found login state but no token" and "could not read the store"
|
||||
// from "no login at all", so the Add Provider list can say why an import is
|
||||
// unavailable instead of silently omitting the candidate.
|
||||
function claudeCodeScanDiagnostic(scan: ClaudeCodeLoginScan): string | undefined {
|
||||
if (scan.tokenless.length > 0) {
|
||||
return `Claude Code login state was found but contains no OAuth token (${scan.tokenless.join("; ")}). Run \`claude /login\`, then retry.`;
|
||||
}
|
||||
if (scan.errors.length > 0) {
|
||||
return `Claude Code login state could not be read: ${scan.errors.join("; ")}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function importClaudeCodeProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): LocalAgentProviderImportResult {
|
||||
const oauth = readClaudeCodeOauth();
|
||||
const token = oauth?.accessToken;
|
||||
@@ -139,9 +184,15 @@ function claudeCodeProviderAccountConfig(): ProviderAccountConfig {
|
||||
}
|
||||
|
||||
export function readClaudeCodeOauth(): OAuthTokenSet | undefined {
|
||||
const keychainOauth = readClaudeCodeKeychainOauth();
|
||||
return scanClaudeCodeLogin().oauth;
|
||||
}
|
||||
|
||||
export function scanClaudeCodeLogin(): ClaudeCodeLoginScan {
|
||||
const scan: ClaudeCodeLoginScan = { errors: [], inspected: [], tokenless: [] };
|
||||
const keychainOauth = scanClaudeCodeKeychain(scan);
|
||||
if (keychainOauth) {
|
||||
return keychainOauth;
|
||||
scan.oauth = keychainOauth;
|
||||
return scan;
|
||||
}
|
||||
|
||||
for (const sourceFile of claudeCredentialFiles()) {
|
||||
@@ -149,58 +200,232 @@ export function readClaudeCodeOauth(): OAuthTokenSet | undefined {
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
const credential = findOauthTokenSet(record);
|
||||
return {
|
||||
accessToken: credential?.accessToken,
|
||||
refreshToken: credential?.refreshToken,
|
||||
scan.inspected.push(sourceFile);
|
||||
const credential = findOauthTokenSet(record.claudeAiOauth) ?? findOauthTokenSet(record);
|
||||
if (!credential?.accessToken && !credential?.refreshToken) {
|
||||
scan.tokenless.push(`${sourceFile} (keys: ${Object.keys(record).join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
scan.oauth = {
|
||||
accessToken: credential.accessToken,
|
||||
refreshToken: credential.refreshToken,
|
||||
sourceFile
|
||||
};
|
||||
return scan;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return scan;
|
||||
}
|
||||
|
||||
function claudeCredentialFiles(): string[] {
|
||||
return uniqueStrings([
|
||||
path.join(claudeCodeStorageDir(), ".credentials.json"),
|
||||
path.join(os.homedir(), ".claude", ".credentials.json"),
|
||||
path.join(os.homedir(), ".claude", "credentials.json"),
|
||||
path.join(os.homedir(), ".config", "claude", "credentials.json")
|
||||
]);
|
||||
}
|
||||
|
||||
// Newer macOS builds of the Claude Code CLI store credentials in the
|
||||
// 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}`
|
||||
};
|
||||
// Claude Code's plaintext fallback lives in its secure-storage config dir,
|
||||
// which CLAUDE_SECURESTORAGE_CONFIG_DIR / CLAUDE_CONFIG_DIR can relocate.
|
||||
function claudeCodeStorageDir(): string {
|
||||
const secureStorageDir = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
|
||||
const dir = secureStorageDir !== undefined
|
||||
? secureStorageDir || path.join(os.homedir(), ".claude")
|
||||
: process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
||||
return dir.normalize("NFC");
|
||||
}
|
||||
|
||||
function readClaudeCodeKeychainRecord(): Record<string, unknown> | undefined {
|
||||
// Claude Code >= 2.1 derives the keychain service name from its config dir:
|
||||
// `Claude Code${oauthSuffix}-credentials${configSuffix}`
|
||||
// `configSuffix` is empty for a default config dir and
|
||||
// `-${sha256(NFC(configDir)).slice(0, 8)}` once CLAUDE_CONFIG_DIR or
|
||||
// CLAUDE_SECURESTORAGE_CONFIG_DIR is set. Verified against 2.1.220.
|
||||
function claudeCodeExpectedKeychainServices(): string[] {
|
||||
const secureStorageDir = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
|
||||
const usesDefaultDir = secureStorageDir !== undefined ? !secureStorageDir : !process.env.CLAUDE_CONFIG_DIR;
|
||||
const configSuffix = usesDefaultDir
|
||||
? ""
|
||||
: `-${createHash("sha256").update(claudeCodeStorageDir()).digest("hex").slice(0, 8)}`;
|
||||
const oauthSuffix = process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL ? "-custom-oauth" : "";
|
||||
return uniqueStrings([`Claude Code${oauthSuffix}-credentials${configSuffix}`, claudeCodeKeychainServiceBase]);
|
||||
}
|
||||
|
||||
// Claude Code writes the item under the current `$USER`; a login from an older
|
||||
// build (or one that could not resolve a username) sits under a different
|
||||
// account on the *same* service name, so the account has to be explicit.
|
||||
function claudeCodeKeychainAccount(): string {
|
||||
let user: string | undefined;
|
||||
try {
|
||||
user = process.env.USER || os.userInfo().username;
|
||||
} catch {
|
||||
user = undefined;
|
||||
}
|
||||
return user && claudeCodeKeychainAccountPattern.test(user) ? user : "claude-code-user";
|
||||
}
|
||||
|
||||
// Newer macOS builds of the Claude Code CLI store credentials in the Keychain
|
||||
// instead of ~/.claude/.credentials.json. Reading one 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 scanClaudeCodeKeychain(scan: ClaudeCodeLoginScan): OAuthTokenSet | undefined {
|
||||
if (process.platform !== "darwin") {
|
||||
return undefined;
|
||||
}
|
||||
const expectedServices = claudeCodeExpectedKeychainServices();
|
||||
const account = claudeCodeKeychainAccount();
|
||||
// Ordered by cost: the enumeration tier shells out to `security dump-keychain`,
|
||||
// so it is only built once the expected item fails to produce a token.
|
||||
const tiers: Array<() => ClaudeCodeKeychainCandidate[]> = [
|
||||
() => expectedServices.map(service => ({ account, service })),
|
||||
// An item written under a different account, or under a config dir this
|
||||
// process cannot reconstruct, only turns up by enumeration.
|
||||
discoverClaudeCodeKeychainItems,
|
||||
// Pre-2.1 lookup: no `-a`, so the Keychain picks an arbitrary account when
|
||||
// several items share the service name.
|
||||
() => expectedServices.map(service => ({ service }))
|
||||
];
|
||||
|
||||
const attempted = new Set<string>();
|
||||
const servicesRead = new Set<string>();
|
||||
let nestedMatch: OAuthTokenSet | undefined;
|
||||
for (const buildTier of tiers) {
|
||||
for (const candidate of buildTier()) {
|
||||
const key = `${candidate.service}\u0000${candidate.account ?? ""}`;
|
||||
// An accountless read of a service already read by account returns one of
|
||||
// those same items, so it would only duplicate the diagnostics.
|
||||
if (attempted.has(key) || (candidate.account === undefined && servicesRead.has(candidate.service))) {
|
||||
continue;
|
||||
}
|
||||
attempted.add(key);
|
||||
const label = candidate.account === undefined ? candidate.service : `${candidate.service} (${candidate.account})`;
|
||||
const record = readClaudeCodeKeychainRecord(candidate, scan, label);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
servicesRead.add(candidate.service);
|
||||
scan.inspected.push(`keychain:${label}`);
|
||||
// Only `claudeAiOauth` is an Anthropic API credential. A recursive search
|
||||
// over the whole record also matches the per-plugin tokens under
|
||||
// `mcpOAuth`, which import cleanly and then 401 on every request.
|
||||
const claudeAiOauth = findOauthTokenSet(record.claudeAiOauth);
|
||||
if (claudeAiOauth?.accessToken || claudeAiOauth?.refreshToken) {
|
||||
return {
|
||||
accessToken: claudeAiOauth.accessToken,
|
||||
refreshToken: claudeAiOauth.refreshToken,
|
||||
sourceFile: `keychain:${label}`
|
||||
};
|
||||
}
|
||||
const nested = findOauthTokenSet(record);
|
||||
if (nested?.accessToken || nested?.refreshToken) {
|
||||
nestedMatch ??= {
|
||||
accessToken: nested.accessToken,
|
||||
refreshToken: nested.refreshToken,
|
||||
sourceFile: `keychain:${label}`
|
||||
};
|
||||
continue;
|
||||
}
|
||||
scan.tokenless.push(`keychain:${label} (keys: ${Object.keys(record).join(", ")})`);
|
||||
}
|
||||
}
|
||||
// Every tier is exhausted before falling back to a nested match, so an
|
||||
// explicit `claudeAiOauth` on any item always wins.
|
||||
return nestedMatch;
|
||||
}
|
||||
|
||||
// `security dump-keychain` without `-d` prints item *metadata* only: it never
|
||||
// decrypts a password and never prompts. Finds login items this environment
|
||||
// cannot name, newest-modified first.
|
||||
function discoverClaudeCodeKeychainItems(): ClaudeCodeKeychainCandidate[] {
|
||||
let dump: string;
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"security",
|
||||
["find-generic-password", "-s", claudeCodeKeychainService, "-w"],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
||||
);
|
||||
const parsed = JSON.parse(output.trim()) as unknown;
|
||||
return isRecord(parsed) ? parsed : undefined;
|
||||
dump = execFileSync("security", ["dump-keychain"], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const found: Array<ClaudeCodeKeychainCandidate & { modified: string }> = [];
|
||||
let account: string | undefined;
|
||||
let modified = "";
|
||||
let service: string | undefined;
|
||||
const flush = () => {
|
||||
if (service && claudeCodeKeychainServicePattern.test(service)) {
|
||||
found.push({ account, modified, service });
|
||||
}
|
||||
account = undefined;
|
||||
modified = "";
|
||||
service = undefined;
|
||||
};
|
||||
for (const line of dump.split("\n")) {
|
||||
if (line.startsWith("keychain:")) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
const serviceMatch = /^\s*"svce"<blob>="(.*)"$/.exec(line);
|
||||
if (serviceMatch) {
|
||||
service = serviceMatch[1];
|
||||
continue;
|
||||
}
|
||||
const accountMatch = /^\s*"acct"<blob>="(.*)"$/.exec(line);
|
||||
if (accountMatch) {
|
||||
account = accountMatch[1];
|
||||
continue;
|
||||
}
|
||||
const modifiedMatch = /^\s*"mdat"<timedate>=.*"(\d{14})Z/.exec(line);
|
||||
if (modifiedMatch) {
|
||||
modified = modifiedMatch[1];
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return found
|
||||
.sort((left, right) => right.modified.localeCompare(left.modified))
|
||||
.map(({ account: itemAccount, service: itemService }) => ({ account: itemAccount, service: itemService }));
|
||||
}
|
||||
|
||||
function readClaudeCodeKeychainRecord(
|
||||
candidate: ClaudeCodeKeychainCandidate,
|
||||
scan: ClaudeCodeLoginScan,
|
||||
label: string
|
||||
): Record<string, unknown> | undefined {
|
||||
const args = ["find-generic-password", "-s", candidate.service, "-w"];
|
||||
if (candidate.account !== undefined) {
|
||||
args.splice(1, 0, "-a", candidate.account);
|
||||
}
|
||||
try {
|
||||
const output = execFileSync("security", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
const parsed = JSON.parse(output.trim()) as unknown;
|
||||
if (isRecord(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
scan.errors.push(`keychain:${label}: item is not a JSON object`);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
const message = keychainErrorMessage(error);
|
||||
// errSecItemNotFound is the ordinary "no such login" answer, not a fault:
|
||||
// recording it would turn a logged-out machine into a `locked` candidate.
|
||||
if (message !== undefined) {
|
||||
scan.errors.push(`keychain:${label}: ${message}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns undefined when the item simply does not exist.
|
||||
function keychainErrorMessage(error: unknown): string | undefined {
|
||||
if (error instanceof SyntaxError) {
|
||||
return `item is not valid JSON (${error.message})`;
|
||||
}
|
||||
const failure = error as { status?: number; stderr?: Buffer | string } | undefined;
|
||||
if (failure?.status === keychainItemNotFoundStatus) {
|
||||
return undefined;
|
||||
}
|
||||
const stderr = typeof failure?.stderr === "string" ? failure.stderr : failure?.stderr?.toString("utf8");
|
||||
const message = stderr?.trim().replace(/\s*\n\s*/g, "; ");
|
||||
if (message) {
|
||||
return /could not be found/i.test(message) ? undefined : message;
|
||||
}
|
||||
return failure?.status === undefined ? String(error) : `security exited ${failure.status}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -24,7 +25,7 @@ test("Claude Code local provider prefers macOS Keychain credentials over stale f
|
||||
const candidate = claudeCodeCandidate();
|
||||
assert.equal(candidate.status, "available");
|
||||
assert.equal(candidate.importable, true);
|
||||
assert.equal(candidate.sourceFile, "keychain:Claude Code-credentials");
|
||||
assert.equal(candidate.sourceFile, `keychain:Claude Code-credentials (${keychainAccount})`);
|
||||
|
||||
const result = importClaudeCodeProvider(candidate, []);
|
||||
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer keychain-access-token");
|
||||
@@ -100,6 +101,151 @@ test("Core gateway config replaces imported Claude Code OAuth token with live ma
|
||||
});
|
||||
});
|
||||
|
||||
// Claude Code >= 2.1 writes the credential item under the current $USER and
|
||||
// leaves any pre-2.1 item (account "unknown") in place on the same service
|
||||
// name. A lookup without `-a` matches the stale one, which only carries MCP
|
||||
// plugin OAuth. See musistudio/claude-code-router#1601.
|
||||
test("Claude Code local provider reads the credential item stored under the current account", { skip: process.platform === "win32" }, async () => {
|
||||
await withClaudeCodeHome(async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
await withFakeSecurityKeychain([
|
||||
{
|
||||
account: "unknown",
|
||||
modified: "20260604091803",
|
||||
record: { mcpOAuth: { "plugin:github|1": { expiresAt: 1 } } },
|
||||
service: "Claude Code-credentials"
|
||||
},
|
||||
{
|
||||
account: keychainAccount,
|
||||
modified: "20260729042837",
|
||||
record: { claudeAiOauth: { accessToken: "live-access-token", refreshToken: "live-refresh-token" } },
|
||||
service: "Claude Code-credentials"
|
||||
}
|
||||
], async () => {
|
||||
const candidate = claudeCodeCandidate();
|
||||
assert.equal(candidate.status, "available");
|
||||
assert.equal(candidate.importable, true);
|
||||
assert.equal(candidate.sourceFile, `keychain:Claude Code-credentials (${keychainAccount})`);
|
||||
|
||||
const result = importClaudeCodeProvider(candidate, []);
|
||||
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer live-access-token");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The only path to an item written under a different account (a login made as
|
||||
// another user, or a $USER that has since changed) is `security dump-keychain`
|
||||
// enumeration; the expected-name lookup cannot reach it.
|
||||
test("Claude Code local provider enumerates the keychain to find an item under another account", { skip: process.platform === "win32" }, async () => {
|
||||
await withClaudeCodeHome(async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
await withFakeSecurityKeychain([
|
||||
{
|
||||
account: "other-user",
|
||||
modified: "20260729042837",
|
||||
record: { claudeAiOauth: { accessToken: "enumerated-access-token" } },
|
||||
service: "Claude Code-credentials",
|
||||
accountlessLookup: false
|
||||
}
|
||||
], async () => {
|
||||
const candidate = claudeCodeCandidate();
|
||||
assert.equal(candidate.status, "available");
|
||||
assert.equal(candidate.importable, true);
|
||||
assert.equal(candidate.sourceFile, "keychain:Claude Code-credentials (other-user)");
|
||||
|
||||
const result = importClaudeCodeProvider(candidate, []);
|
||||
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer enumerated-access-token");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// mcpOAuth holds per-plugin tokens that are not Anthropic API credentials;
|
||||
// importing one yields a provider that 401s on every request.
|
||||
test("Claude Code local provider ignores mcpOAuth plugin tokens in favour of claudeAiOauth", { skip: process.platform === "win32" }, async () => {
|
||||
await withClaudeCodeHome(async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
await withFakeSecurityKeychain([
|
||||
{
|
||||
account: keychainAccount,
|
||||
modified: "20260729042837",
|
||||
record: {
|
||||
mcpOAuth: { "plugin:github|1": { accessToken: "mcp-plugin-token", refreshToken: "mcp-plugin-refresh" } },
|
||||
claudeAiOauth: { accessToken: "live-access-token", refreshToken: "live-refresh-token" }
|
||||
},
|
||||
service: "Claude Code-credentials"
|
||||
}
|
||||
], async () => {
|
||||
const result = importClaudeCodeProvider(claudeCodeCandidate(), []);
|
||||
assert.equal(result.providerPlugins[0].auth.headers.authorization, "Bearer live-access-token");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// With CLAUDE_CONFIG_DIR set, Claude Code appends the first 8 hex of
|
||||
// sha256(NFC(configDir)) to the service name.
|
||||
test("Claude Code local provider reads the config-dir-suffixed keychain service", { skip: process.platform === "win32" }, async () => {
|
||||
await withClaudeCodeHome(async (home) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const configDir = path.join(home, "alt-claude");
|
||||
const suffix = createHash("sha256").update(configDir.normalize("NFC")).digest("hex").slice(0, 8);
|
||||
const service = `Claude Code-credentials-${suffix}`;
|
||||
await withEnv("CLAUDE_CONFIG_DIR", configDir, async () => {
|
||||
await withFakeSecurityKeychain([
|
||||
{
|
||||
account: keychainAccount,
|
||||
modified: "20260729042837",
|
||||
record: { claudeAiOauth: { accessToken: "suffixed-access-token" } },
|
||||
service
|
||||
}
|
||||
], async () => {
|
||||
const candidate = claudeCodeCandidate();
|
||||
assert.equal(candidate.status, "available");
|
||||
assert.equal(candidate.sourceFile, `keychain:${service} (${keychainAccount})`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Claude Code local provider explains login state that carries no OAuth token", { skip: process.platform === "win32" }, async () => {
|
||||
await withClaudeCodeHome(async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
await withFakeSecurityKeychain([
|
||||
{
|
||||
account: keychainAccount,
|
||||
modified: "20260729042837",
|
||||
record: { mcpOAuth: { "plugin:github|1": { expiresAt: 1 } } },
|
||||
service: "Claude Code-credentials"
|
||||
}
|
||||
], async () => {
|
||||
const candidate = claudeCodeCandidate();
|
||||
assert.equal(candidate.status, "locked");
|
||||
assert.equal(candidate.importable, false);
|
||||
assert.match(candidate.detail, /no OAuth token/);
|
||||
assert.match(candidate.detail, /mcpOAuth/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// errSecItemNotFound is the ordinary logged-out answer: it must not be reported
|
||||
// as an unreadable store.
|
||||
test("Claude Code local provider reports a missing candidate when no keychain item exists", { skip: process.platform === "win32" }, async () => {
|
||||
await withClaudeCodeHome(async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
await withFakeSecurityKeychain([], async () => {
|
||||
const candidate = claudeCodeCandidate();
|
||||
assert.equal(candidate.status, "missing");
|
||||
assert.equal(candidate.importable, false);
|
||||
assert.equal(candidate.detail, "No local login state was found for this agent.");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withClaudeCodeHome(run) {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-code-provider-"));
|
||||
const previousHome = process.env.HOME;
|
||||
@@ -133,21 +279,109 @@ async function withFakeSecurityFailure(run) {
|
||||
await withFakeSecurityScript("exit 44\n", run);
|
||||
}
|
||||
|
||||
// Pins $USER as well as PATH: the provider looks the keychain item up under the
|
||||
// current account, so the expected item name has to be deterministic.
|
||||
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;
|
||||
const previousUser = process.env.USER;
|
||||
writeFileSync(securityPath, `#!/bin/sh\n${body}`);
|
||||
chmodSync(securityPath, 0o755);
|
||||
process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`;
|
||||
process.env.USER = keychainAccount;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
restoreEnv("PATH", previousPath);
|
||||
restoreEnv("USER", previousUser);
|
||||
rmSync(binDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
const keychainAccount = "ccr-test-user";
|
||||
|
||||
// Stands in for the macOS keychain: `find-generic-password` matches on
|
||||
// service+account, an omitted `-a` matches the first item registered for the
|
||||
// service (as the real Keychain picks an arbitrary one), and `dump-keychain`
|
||||
// prints the metadata-only listing the provider parses.
|
||||
async function withFakeSecurityKeychain(items, run) {
|
||||
await withFakeSecurityScript(fakeSecurityBody(items), run);
|
||||
}
|
||||
|
||||
function fakeSecurityBody(items) {
|
||||
const dump = [
|
||||
...items.map(item => [
|
||||
`keychain: "/tmp/login.keychain-db"`,
|
||||
"version: 512",
|
||||
"class: \"genp\"",
|
||||
"attributes:",
|
||||
` "acct"<blob>="${item.account}"`,
|
||||
` "mdat"<timedate>=0x00 "${item.modified}Z\\000"`,
|
||||
` "svce"<blob>="${item.service}"`
|
||||
].join("\n")),
|
||||
[
|
||||
`keychain: "/tmp/login.keychain-db"`,
|
||||
"attributes:",
|
||||
` "acct"<blob>="unrelated"`,
|
||||
` "svce"<blob>="Bitwarden Safe Storage"`
|
||||
].join("\n")
|
||||
].join("\n");
|
||||
|
||||
// `accountlessLookup: false` models an item the Keychain will not return for
|
||||
// a `-s`-only query, so only enumeration can reach it.
|
||||
const firstForService = new Map();
|
||||
for (const item of items) {
|
||||
if (item.accountlessLookup !== false && !firstForService.has(item.service)) {
|
||||
firstForService.set(item.service, item);
|
||||
}
|
||||
}
|
||||
const branches = [
|
||||
...items.map((item, index) => ({ key: `${item.service}|${item.account}`, record: item.record, tag: `CCR_J${index}` })),
|
||||
...[...firstForService.values()].map((item, index) => ({ key: `${item.service}|`, record: item.record, tag: `CCR_A${index}` }))
|
||||
];
|
||||
|
||||
return [
|
||||
`if [ "$1" = "dump-keychain" ]; then`,
|
||||
`cat <<'CCR_DUMP'`,
|
||||
dump,
|
||||
"CCR_DUMP",
|
||||
"exit 0",
|
||||
"fi",
|
||||
"shift",
|
||||
"acct=''",
|
||||
"svc=''",
|
||||
"while [ $# -gt 0 ]; do",
|
||||
` case "$1" in`,
|
||||
` -a) acct="$2"; shift 2 ;;`,
|
||||
` -s) svc="$2"; shift 2 ;;`,
|
||||
" *) shift ;;",
|
||||
" esac",
|
||||
"done",
|
||||
`case "$svc|$acct" in`,
|
||||
...branches.map(branch => [
|
||||
`"${branch.key}")`,
|
||||
`cat <<'${branch.tag}'`,
|
||||
JSON.stringify(branch.record),
|
||||
branch.tag,
|
||||
"exit 0 ;;"
|
||||
].join("\n")),
|
||||
"esac",
|
||||
`echo 'security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.' >&2`,
|
||||
"exit 44"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function withEnv(name, value, run) {
|
||||
const previous = process.env[name];
|
||||
process.env[name] = value;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
restoreEnv(name, previous);
|
||||
}
|
||||
}
|
||||
|
||||
function writeClaudeCredentials(home, credentials) {
|
||||
const directory = path.join(home, ".claude");
|
||||
const credentialFile = path.join(directory, ".credentials.json");
|
||||
|
||||
Reference in New Issue
Block a user