diff --git a/package.json b/package.json index 07b658e..90b5353 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "build": "rm -rf dist && tsc && cp src/anthropic-prompt.txt dist/", "test": "node --test --experimental-strip-types src/**/*.test.ts scripts/**/*.test.ts", "test:models": "pnpm run build && node scripts/test-models.ts", + "test:headless": "pnpm run build && node --experimental-strip-types scripts/test-headless.ts", "intercept": "pnpm run build && node --experimental-strip-types scripts/intercept-claude.ts", "intercept:all": "pnpm run build && node --experimental-strip-types scripts/intercept-claude.ts --all", "intercept:update": "pnpm run build && node --experimental-strip-types scripts/intercept-claude.ts --all --update", diff --git a/scripts/test-headless.ts b/scripts/test-headless.ts new file mode 100644 index 0000000..7d3d652 --- /dev/null +++ b/scripts/test-headless.ts @@ -0,0 +1,549 @@ +/** + * Headless end-to-end test for the credential refresh flows. + * + * Runs real `opencode run` invocations against the locally built plugin with + * a sandboxed fake keychain (PATH shims for `security` and `claude`) and + * asserts on the structured debug log the plugin writes. + * + * Scenarios: + * 1. Happy path — fresh primary credentials, no refresh expected. + * 2. Expired primary — OAuth refresh fails (bogus refresh token), CLI shim + * "refreshes" the primary entry, request succeeds. + * 3. Bug 1 — active account is a stale *suffixed* keychain entry; the CLI + * shim writes fresh credentials to the primary entry only (replicating + * the real Claude CLI behaviour), and the plugin must fall back to the + * primary entry. + * + * Safety: + * - The real keychain is only ever *read* (once, to obtain a valid access + * token so the final API call returns 200). + * - The real refresh token is never placed in a stale entry, so the OAuth + * refresh path fails at the real endpoint with a 4xx and nothing is + * rotated. + * - User state (`claude-account-source.txt`) is backed up and restored. + * + * Requires: macOS, `opencode` on PATH, valid Claude Code credentials. + * Run with: pnpm test:headless + */ +import { execFileSync, spawnSync } from "node:child_process" +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs" +import { homedir, tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { keychainSuffixForDir } from "../dist/keychain.js" + +const PRIMARY_SERVICE = "Claude Code-credentials" +const MODEL = "anthropic/claude-haiku-4-5" +const SENTINEL = "HEADLESSOK" +const PROMPT = `Reply with exactly the word: ${SENTINEL}` +const RUN_TIMEOUT_MS = 180_000 +const STALE_EXPIRES_AT = 1_000_000_000_000 // 2001, definitively stale + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const accountSourcePath = join( + homedir(), + ".local", + "share", + "opencode", + "claude-account-source.txt", +) + +interface LogEvent { + event: string + [key: string]: unknown +} + +interface ExpectedEvent { + event: string + fields?: Record +} + +function fail(msg: string): never { + console.error(`\n✗ ${msg}`) + process.exit(1) +} + +// --- preflight ------------------------------------------------------------- + +function preflight(): { realBlob: string } { + if (process.platform !== "darwin") { + console.log( + "test:headless requires macOS (keychain simulation) — skipping.", + ) + process.exit(0) + } + const version = spawnSync("opencode", ["--version"], { encoding: "utf-8" }) + if (version.status !== 0) { + fail("`opencode` not found on PATH — install it to run this test.") + } + let realBlob: string + try { + realBlob = execFileSync( + "/usr/bin/security", + ["find-generic-password", "-s", PRIMARY_SERVICE, "-w"], + { encoding: "utf-8", timeout: 5000 }, + ).trim() + } catch { + fail( + `No readable "${PRIMARY_SERVICE}" keychain entry. Log in with the Claude CLI first.`, + ) + } + const expiresAt = blobExpiresAt(realBlob) + if (!expiresAt || expiresAt < Date.now() + 5 * 60_000) { + fail( + "Real Claude credentials are missing an expiry or expire within 5 minutes. Run `claude` to refresh them, then retry.", + ) + } + return { realBlob } +} + +function blobExpiresAt(raw: string): number | null { + try { + const parsed = JSON.parse(raw) as Record + const target = (parsed.claudeAiOauth ?? parsed) as Record + return typeof target.expiresAt === "number" ? target.expiresAt : null + } catch { + return null + } +} + +// --- sandbox --------------------------------------------------------------- + +interface Sandbox { + root: string + binDir: string + stateDir: string + workDir: string + xdgDir: string + fakeConfigDir: string + suffix: string + suffixedService: string +} + +function createSandbox(): Sandbox { + const root = mkdtempSync(join(tmpdir(), "claude-auth-headless-")) + const binDir = join(root, "bin") + const stateDir = join(root, "state") + const workDir = join(root, "work") + const xdgDir = join(root, "xdg") + mkdirSync(binDir, { recursive: true }) + mkdirSync(stateDir, { recursive: true }) + mkdirSync(workDir, { recursive: true }) + mkdirSync(join(xdgDir, "opencode"), { recursive: true }) + + // Isolated opencode config: load only the locally built plugin. + writeFileSync( + join(xdgDir, "opencode", "opencode.json"), + JSON.stringify( + { $schema: "https://opencode.ai/config.json", plugin: [repoRoot] }, + null, + 2, + ), + ) + + // The suffix-to-dir scan only walks dot-directories directly under $HOME, + // so the fake config dir must live there. Removed in cleanup. + const fakeConfigDir = join(homedir(), `.claude-simtest-${process.pid}`) + mkdirSync(fakeConfigDir, { recursive: true }) + writeFileSync( + join(fakeConfigDir, ".claude.json"), + JSON.stringify({ oauthAccount: { emailAddress: "sim@headless.test" } }), + ) + const suffix = keychainSuffixForDir(fakeConfigDir) + const suffixedService = `${PRIMARY_SERVICE}-${suffix}` + + // Both shims append to a single shim.log so the interleaved order of + // keychain reads and CLI refresh invocations is recorded reliably. The + // plugin's own debug log cannot serve this purpose: the plugin initialises + // more than once per `opencode run` and each init truncates that log, so + // early refresh events are racily lost. + const securityShim = `#!/bin/sh +STATE_DIR="${stateDir}" +printf 'security %s\\n' "$*" >> "$STATE_DIR/shim.log" +if [ "$1" = "dump-keychain" ]; then + cat "$STATE_DIR/dump.txt" 2>/dev/null || true + exit 0 +fi +if [ "$1" = "find-generic-password" ]; then + svc="" + prev="" + for a in "$@"; do + [ "$prev" = "-s" ] && svc="$a" + prev="$a" + done + case "$svc" in + "Claude Code-credentials"*) + f="$STATE_DIR/$svc.json" + if [ -f "$f" ]; then + cat "$f" + exit 0 + fi + exit 44 + ;; + esac +fi +exec /usr/bin/security "$@" +` + // Replicates the real Claude CLI bug: a refresh triggered for a suffixed + // account writes the new token to the PRIMARY keychain entry. + const claudeShim = `#!/bin/sh +STATE_DIR="${stateDir}" +printf 'claude %s CLAUDE_CONFIG_DIR=%s\\n' "$*" "\${CLAUDE_CONFIG_DIR:-}" >> "$STATE_DIR/shim.log" +cp "$STATE_DIR/fresh-primary.json" "$STATE_DIR/Claude Code-credentials.json" +printf 'ok\\n' +exit 0 +` + writeFileSync(join(binDir, "security"), securityShim) + writeFileSync(join(binDir, "claude"), claudeShim) + chmodSync(join(binDir, "security"), 0o755) + chmodSync(join(binDir, "claude"), 0o755) + + return { + root, + binDir, + stateDir, + workDir, + xdgDir, + fakeConfigDir, + suffix, + suffixedService, + } +} + +function staleBlob(): string { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "sim-stale-access-token", + refreshToken: "sim-bogus-refresh-token", + expiresAt: STALE_EXPIRES_AT, + scopes: ["user:inference"], + subscriptionType: "pro", + }, + }) +} + +function writeDump(sandbox: Sandbox, services: string[]): void { + const lines = services.map((s) => ` "svce"="${s}"`).join("\n") + writeFileSync( + join(sandbox.stateDir, "dump.txt"), + `keychain: "login"\n${lines}\n`, + ) +} + +function resetState(sandbox: Sandbox): void { + rmSync(sandbox.stateDir, { recursive: true, force: true }) + mkdirSync(sandbox.stateDir, { recursive: true }) +} + +// --- account source backup/restore ------------------------------------------ + +function backupAccountSource(): string | null { + try { + return readFileSync(accountSourcePath, "utf-8") + } catch { + return null + } +} + +function setAccountSource(source: string | null): void { + if (source === null) { + rmSync(accountSourcePath, { force: true }) + return + } + mkdirSync(dirname(accountSourcePath), { recursive: true }) + writeFileSync(accountSourcePath, source, "utf-8") +} + +// --- runner & assertions ----------------------------------------------------- + +interface RunResult { + stdout: string + stderr: string + status: number | null + events: LogEvent[] + logPath: string +} + +function runOpencode(sandbox: Sandbox, scenarioName: string): RunResult { + const logPath = join(sandbox.root, `${scenarioName}.log`) + const env = { ...process.env } + delete env.CLAUDE_CONFIG_DIR + env.PATH = `${sandbox.binDir}:${env.PATH}` + env.CLAUDE_AUTH_DEBUG = logPath + env.XDG_CONFIG_HOME = sandbox.xdgDir + + const result = spawnSync("opencode", ["run", "--model", MODEL, PROMPT], { + cwd: sandbox.workDir, + env, + encoding: "utf-8", + timeout: RUN_TIMEOUT_MS, + }) + + let events: LogEvent[] = [] + try { + events = readFileSync(logPath, "utf-8") + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l) as LogEvent) + } catch { + // missing log handled by assertions + } + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + status: result.status, + events, + logPath, + } +} + +function matchesFields( + ev: LogEvent, + fields?: Record, +): boolean { + if (!fields) return true + return Object.entries(fields).every(([k, v]) => ev[k] === v) +} + +function assertEventSubsequence( + run: RunResult, + expected: ExpectedEvent[], +): string | null { + let i = 0 + for (const ev of run.events) { + const want = expected[i] + if (!want) break + if (ev.event === want.event && matchesFields(ev, want.fields)) i++ + } + if (i >= expected.length) return null + const want = expected[i] + return `debug log missing event #${i}: ${want.event}${want.fields ? " " + JSON.stringify(want.fields) : ""}` +} + +function readShimLog(sandbox: Sandbox): string[] { + try { + return readFileSync(join(sandbox.stateDir, "shim.log"), "utf-8") + .split("\n") + .filter((l) => l.length > 0) + } catch { + return [] + } +} + +/** Ordered subsequence match over shim.log lines using substring predicates. */ +function assertShimSubsequence( + lines: string[], + expected: string[], +): string | null { + let i = 0 + for (const line of lines) { + const want = expected[i] + if (!want) break + if (line.includes(want)) i++ + } + if (i >= expected.length) return null + return `shim log missing step #${i}: ${JSON.stringify(expected[i])}` +} + +const READ_PRIMARY = `find-generic-password -s ${PRIMARY_SERVICE} -w` + +interface Scenario { + name: string + setup: (sandbox: Sandbox, realBlob: string) => void + /** Ordered substrings expected in shim.log — append-only ground truth. */ + shimExpected: (sandbox: Sandbox) => string[] + /** Events asserted on the plugin debug log (late events only; see shims). */ + expected: (sandbox: Sandbox) => ExpectedEvent[] + extraChecks?: ( + sandbox: Sandbox, + run: RunResult, + shimLog: string[], + ) => string | null +} + +const scenarios: Scenario[] = [ + { + name: "happy-path", + setup: (sandbox, realBlob) => { + writeDump(sandbox, [PRIMARY_SERVICE]) + writeFileSync(join(sandbox.stateDir, `${PRIMARY_SERVICE}.json`), realBlob) + setAccountSource(null) + }, + shimExpected: () => [READ_PRIMARY], + expected: () => [ + { event: "plugin_init" }, + { event: "fetch_response", fields: { status: 200 } }, + ], + extraChecks: (_sandbox, _run, shimLog) => { + if (shimLog.some((l) => l.startsWith("claude "))) { + return "CLI refresh was invoked despite fresh credentials" + } + return null + }, + }, + { + // The CLI shim is only ever invoked by refreshViaCli, which itself only + // runs after the OAuth refresh attempt fails (bogus refresh token), so + // its presence in shim.log proves the full expired → refresh flow. + name: "expired-cli-refresh", + setup: (sandbox, realBlob) => { + writeDump(sandbox, [PRIMARY_SERVICE]) + writeFileSync( + join(sandbox.stateDir, `${PRIMARY_SERVICE}.json`), + staleBlob(), + ) + writeFileSync(join(sandbox.stateDir, "fresh-primary.json"), realBlob) + setAccountSource(null) + }, + shimExpected: () => [ + READ_PRIMARY, // initial read: stale + "claude -p", // CLI refresh triggered + READ_PRIMARY, // post-refresh re-read: fresh + ], + expected: () => [ + { event: "plugin_init" }, + { event: "fetch_response", fields: { status: 200 } }, + ], + }, + { + name: "bug1-suffixed-fallback", + setup: (sandbox, realBlob) => { + writeDump(sandbox, [PRIMARY_SERVICE, sandbox.suffixedService]) + writeFileSync( + join(sandbox.stateDir, `${PRIMARY_SERVICE}.json`), + staleBlob(), + ) + writeFileSync( + join(sandbox.stateDir, `${sandbox.suffixedService}.json`), + staleBlob(), + ) + writeFileSync(join(sandbox.stateDir, "fresh-primary.json"), realBlob) + setAccountSource(sandbox.suffixedService) + }, + shimExpected: (sandbox) => [ + `find-generic-password -s ${sandbox.suffixedService} -w`, // initial read: stale + `claude -p . --model haiku CLAUDE_CONFIG_DIR=${sandbox.fakeConfigDir}`, // CLI refresh, correct config dir threaded through + `find-generic-password -s ${sandbox.suffixedService} -w`, // re-read suffixed: still stale (CLI wrote to primary) + READ_PRIMARY, // Bug 1 fix: fall back to the primary entry + ], + expected: () => [ + { event: "plugin_init" }, + { event: "fetch_response", fields: { status: 200 } }, + ], + }, +] + +// --- cleanup ----------------------------------------------------------------- + +function cleanup( + sandbox: Sandbox | null, + savedAccountSource: string | null, +): void { + setAccountSource(savedAccountSource) + if (!sandbox) return + rmSync(sandbox.fakeConfigDir, { recursive: true, force: true }) + if (process.env.HEADLESS_KEEP) { + console.log(`HEADLESS_KEEP set — sandbox preserved at ${sandbox.root}`) + } else { + rmSync(sandbox.root, { recursive: true, force: true }) + } + // The tested code paths never write to the real keychain, but if a future + // regression adds a writeback, it would land on the fake suffixed service + // name. Detect and remove it. + const probe = spawnSync( + "/usr/bin/security", + ["find-generic-password", "-s", sandbox.suffixedService], + { encoding: "utf-8" }, + ) + if (probe.status === 0) { + console.warn( + `warning: junk keychain entry "${sandbox.suffixedService}" was created during the run — deleting it.`, + ) + spawnSync("/usr/bin/security", [ + "delete-generic-password", + "-s", + sandbox.suffixedService, + ]) + } +} + +// --- main ---------------------------------------------------------------------- + +function main(): void { + const { realBlob } = preflight() + const savedAccountSource = backupAccountSource() + let sandbox: Sandbox | null = null + const failures: string[] = [] + + try { + sandbox = createSandbox() + for (const scenario of scenarios) { + process.stdout.write(`▶ ${scenario.name} ... `) + resetState(sandbox) + scenario.setup(sandbox, realBlob) + const run = runOpencode(sandbox, scenario.name) + + const shimLog = readShimLog(sandbox) + + // Preserve the shim log for post-mortem before the next scenario + // resets the state dir. + writeFileSync( + join(sandbox.root, `${scenario.name}-shim.log`), + shimLog.length > 0 ? shimLog.join("\n") + "\n" : "", + ) + + const problems: string[] = [] + const shimError = assertShimSubsequence( + shimLog, + scenario.shimExpected(sandbox), + ) + if (shimError) problems.push(shimError) + const seqError = assertEventSubsequence(run, scenario.expected(sandbox)) + if (seqError) problems.push(seqError) + const extraError = scenario.extraChecks?.(sandbox, run, shimLog) + if (extraError) problems.push(extraError) + if (!run.stdout.includes(SENTINEL)) { + problems.push(`stdout did not contain ${SENTINEL}`) + } + + if (problems.length === 0) { + console.log("ok") + } else { + console.log("FAIL") + for (const p of problems) console.log(` ${p}`) + console.log(` exit status: ${run.status}`) + console.log( + ` stdout tail: ${JSON.stringify(run.stdout.slice(-300))}`, + ) + console.log( + ` stderr tail: ${JSON.stringify(run.stderr.slice(-300))}`, + ) + console.log( + ` debug events: ${run.events.map((e) => e.event).join(" → ")}`, + ) + console.log( + ` shim log:\n${shimLog.map((l) => ` ${l}`).join("\n")}`, + ) + failures.push(scenario.name) + } + } + } finally { + cleanup(sandbox, savedAccountSource) + } + + if (failures.length > 0) { + fail( + `${failures.length}/${scenarios.length} scenario(s) failed: ${failures.join(", ")}`, + ) + } + console.log(`\n✓ all ${scenarios.length} scenarios passed`) +} + +main() diff --git a/src/credentials.test.ts b/src/credentials.test.ts index ffb9d40..24d1b99 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -51,16 +51,19 @@ async function loadCredentialsWithCountingKeychain( const tempDir = await mkdtemp(join(tmpdir(), "opencode-claude-auth-creds-")) const tempKeychain = join(tempDir, "keychain.ts") const tempBetas = join(tempDir, "betas.ts") + const tempChildProcess = join(tempDir, "child-process.ts") const tempLogger = join(tempDir, "logger.ts") const tempCredentials = join(tempDir, "credentials.ts") const sourceCredentials = await readFile( new URL("./credentials.ts", import.meta.url), "utf8", ) - const rewritten = sourceCredentials.replace( - /from\s+["']\.\/(\w+)\.js["']/g, - 'from "./$1.ts"', - ) + const rewritten = sourceCredentials + .replace(/from\s+["']\.\/(\w+)\.js["']/g, 'from "./$1.ts"') + .replace( + 'import { execFileSync, execSync } from "node:child_process"', + 'import { execFileSync, execSync } from "./child-process.ts"', + ) await writeFile( tempLogger, @@ -68,6 +71,19 @@ async function loadCredentialsWithCountingKeychain( "utf8", ) + await writeFile( + tempChildProcess, + `export function execFileSync() { + throw new Error("oauth disabled in test harness") +} + +export function execSync() { + return "" +} +`, + "utf8", + ) + await writeFile( tempKeychain, `let readCount = 0 @@ -81,6 +97,8 @@ let credentials = { expiresAt: ${initialExpiresAt} } +export const PRIMARY_SERVICE = "Claude Code-credentials" + export function readAllClaudeAccounts() { readCount += 1 if (accounts !== null) return accounts @@ -568,6 +586,103 @@ describe("credential caching", () => { ) }) + it("refreshIfNeeded borrows a fallback account whose keychain entry was refreshed externally", async () => { + const originalNow = Date.now + const now = 1_700_000_000_000 + Date.now = () => now + + try { + const { credentialsModule, keychainModule } = + await loadCredentialsWithCountingKeychain(now - 1_000) + + // Active account: suffixed keychain entry with an unknown configDir, + // so the CLI refresh is skipped (requireConfigDir) and OAuth is + // disabled by the harness. Fallback account: its in-memory expiry is + // stale too, but the live keychain read returns credentials that were + // refreshed externally (e.g. by the Claude CLI in another terminal). + credentialsModule.initAccounts([ + { + label: "Account 1", + source: "Claude Code-credentials-aabbccdd", + credentials: { + accessToken: "stale-suffixed", + refreshToken: "rt-suffixed", + expiresAt: now - 1_000, + }, + }, + { + label: "Account 2", + source: "Claude Code-credentials", + credentials: { + accessToken: "stale-primary", + refreshToken: "rt-primary", + expiresAt: now - 1_000, + }, + }, + ]) + + keychainModule.__setCredentials({ + accessToken: "externally-refreshed", + refreshToken: "rt-new", + expiresAt: now + 8 * 60 * 60_000, + }) + + const result = credentialsModule.refreshIfNeeded() + + assert.equal( + result?.accessToken, + "externally-refreshed", + "stale in-memory expiry must not prevent a live fallback keychain read", + ) + } finally { + Date.now = originalNow + } + }) + + it("fallback uses a valid in-memory account without a keychain read", async () => { + const originalNow = Date.now + const now = 1_700_000_000_000 + Date.now = () => now + + try { + const { credentialsModule, keychainModule } = + await loadCredentialsWithCountingKeychain(now - 1_000) + + credentialsModule.initAccounts([ + { + label: "Account 1", + source: "Claude Code-credentials-aabbccdd", + credentials: { + accessToken: "stale-suffixed", + refreshToken: "rt-suffixed", + expiresAt: now - 1_000, + }, + }, + { + label: "Account 2", + source: "Claude Code-credentials", + credentials: { + accessToken: "fresh-in-memory", + refreshToken: "rt-primary", + expiresAt: now + 8 * 60 * 60_000, + }, + }, + ]) + + const readsBefore = keychainModule.__getReadCount() + const result = credentialsModule.refreshIfNeeded() + + assert.equal(result?.accessToken, "fresh-in-memory") + assert.equal( + keychainModule.__getReadCount(), + readsBefore, + "valid in-memory fallback credentials must not trigger a keychain read", + ) + } finally { + Date.now = originalNow + } + }) + it("reloadActiveAccount picks up rotated keychain credentials in place", async () => { const now = Date.now() const { credentialsModule, keychainModule } = @@ -791,7 +906,8 @@ describe("syncAuthJson file permissions", () => { await writeFile( tempKeychain, - `export function readAllClaudeAccounts() { return [] } + `export const PRIMARY_SERVICE = "Claude Code-credentials" +export function readAllClaudeAccounts() { return [] } export function refreshAccount() { return null } export function writeBackCredentials() { return true } export function buildAccountLabels(creds) { return creds.map((_, i) => \`Account \${i + 1}\`) }`, @@ -875,7 +991,8 @@ export function buildAccountLabels(creds) { return creds.map((_, i) => \`Account await writeFile( tempKeychain, - `export function readAllClaudeAccounts() { return [] } + `export const PRIMARY_SERVICE = "Claude Code-credentials" +export function readAllClaudeAccounts() { return [] } export function refreshAccount() { return null } export function writeBackCredentials() { return true } export function buildAccountLabels(creds) { return creds.map((_, i) => \`Account \${i + 1}\`) }`, diff --git a/src/credentials.ts b/src/credentials.ts index 84f5f80..4f833c9 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -9,17 +9,18 @@ import { import { homedir, tmpdir } from "node:os" import { dirname, join } from "node:path" import { + PRIMARY_SERVICE, readAllClaudeAccounts, refreshAccount, writeBackCredentials, - type ClaudeCredentials, type ClaudeAccount, + type ClaudeCredentials, } from "./keychain.ts" import { resetExcludedBetas } from "./betas.ts" import { log } from "./logger.ts" -export type { ClaudeCredentials } from "./keychain.ts" export type { ClaudeAccount } from "./keychain.ts" +export type { ClaudeCredentials } from "./keychain.ts" const CREDENTIAL_CACHE_TTL_MS = 30_000 @@ -159,11 +160,6 @@ export function syncAuthJson(creds: ClaudeCredentials): void { export const OAUTH_TOKEN_URL = "https://claude.ai/v1/oauth/token" export const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" -/** - * Parse a raw OAuth token response into ClaudeCredentials. - * Returns null if the response is missing a valid access_token. - * Defaults expires_in to 36000s (10h) to match observed Claude token lifetime. - */ export function parseOAuthResponse( raw: string, currentRefreshToken: string, @@ -193,8 +189,6 @@ export function parseOAuthResponse( export function refreshViaOAuth( refreshToken: string, ): ClaudeCredentials | null { - // Use a Node subprocess to perform the HTTP request synchronously. - // The refresh token is passed via stdin to avoid exposure in process args. const script = ` process.stdin.resume(); let input = ''; @@ -245,29 +239,44 @@ export function refreshViaOAuth( } } -function refreshViaCli(): void { +function refreshViaCli(configDir?: string, requireConfigDir = false): boolean { + if (requireConfigDir && !configDir) { + log("refresh_cli_skipped", { + source: "cli", + reason: "configDir unknown for suffixed account", + }) + return false + } + + const env = { + ...process.env, + TERM: "dumb", + ...(configDir ? { CLAUDE_CONFIG_DIR: configDir } : {}), + } + const maxAttempts = 2 for (let i = 0; i < maxAttempts; i++) { - log("refresh_started", { source: "cli", attempt: i + 1 }) + log("refresh_started", { source: "cli", attempt: i + 1, configDir }) try { execSync("claude -p . --model haiku", { timeout: 60_000, encoding: "utf-8", - env: { ...process.env, TERM: "dumb" }, + env, stdio: "ignore", cwd: tmpdir(), }) log("refresh_success", { source: "cli" }) - return + return true } catch (err) { log("refresh_failed", { source: "cli", attempt: i + 1, error: err instanceof Error ? err.message : String(err), }) - // Non-fatal: retry once, then give up } } + log("refresh_cli_exhausted", { source: "cli", configDir }) + return false } export function refreshIfNeeded( @@ -295,20 +304,46 @@ export function refreshIfNeeded( expiresIn: creds.expiresAt - Date.now(), }) - // Try direct OAuth refresh first (zero LLM tokens consumed) if (creds.refreshToken) { const oauthCreds = refreshViaOAuth(creds.refreshToken) if (oauthCreds && oauthCreds.expiresAt > Date.now() + 60_000) { target.credentials = oauthCreds - writeBackCredentials(target.source, oauthCreds) + writeBackCredentials(target.source, oauthCreds, target.configDir) return oauthCreds } } - // Fall back to CLI-based refresh (consumes Haiku tokens) log("refresh_fallback_cli", { source: target.source }) - refreshViaCli() - const refreshed = refreshAccount(target.source) + const isSuffixedAccount = + target.source !== PRIMARY_SERVICE && + target.source.startsWith(PRIMARY_SERVICE + "-") + const cliSucceeded = refreshViaCli(target.configDir, isSuffixedAccount) + if (!cliSucceeded) { + const fallback = tryFallbackAccount(target.source) + if (fallback) { + target.credentials = fallback + return fallback + } + + log("refresh_exhausted", { + source: target.source, + hadCredentials: false, + expiresAt: undefined, + }) + return null + } + + let refreshed = refreshAccount(target.source, target.configDir) + if ( + (!refreshed || refreshed.expiresAt <= Date.now() + 60_000) && + isSuffixedAccount + ) { + const primaryRefreshed = refreshAccount(PRIMARY_SERVICE) + if (primaryRefreshed && primaryRefreshed.expiresAt > Date.now() + 60_000) { + refreshed = primaryRefreshed + } + } + if (refreshed && refreshed.expiresAt > Date.now() + 60_000) { target.credentials = refreshed return refreshed @@ -322,12 +357,45 @@ export function refreshIfNeeded( return null } -/** - * Returns the active account's credentials for auth.json sync purposes. - * Unlike getCachedCredentials(), this does NOT trigger a refresh. - * It returns the account's current in-memory credentials if they're still valid. - * Returns null if no account or credentials are expired. - */ +function tryFallbackAccount(excludeSource: string): ClaudeCredentials | null { + const now = Date.now() + const candidates = allAccounts.filter((a) => a.source !== excludeSource) + + // Accounts whose in-memory credentials are still valid can be borrowed + // directly — no keychain read needed. A 401 on a borrowed token is + // handled by the existing reload-and-retry fetch path. + for (const account of candidates) { + if (account.credentials.expiresAt > now + 60_000) { + log("refresh_fallback_account", { + failedSource: excludeSource, + usedSource: account.source, + }) + return account.credentials + } + } + + // Last resort: live-read the stale-looking ones too — another process + // (e.g. the Claude CLI in a different terminal) may have refreshed their + // keychain entry since we last read it. + for (const account of candidates) { + let fresh: ClaudeCredentials | null = null + try { + fresh = refreshAccount(account.source, account.configDir) + } catch { + continue + } + if (fresh && fresh.expiresAt > now + 60_000) { + account.credentials = fresh + log("refresh_fallback_account", { + failedSource: excludeSource, + usedSource: account.source, + }) + return fresh + } + } + return null +} + export function getCredentialsForSync(): ClaudeCredentials | null { const account = getActiveAccount() if (!account) return null @@ -337,7 +405,6 @@ export function getCredentialsForSync(): ClaudeCredentials | null { return creds } - // Credentials are near expiry -- don't refresh here, let the per-request path handle it return null } diff --git a/src/index.test.ts b/src/index.test.ts index 37ad8e5..92e4f12 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -49,11 +49,11 @@ function resolveAccount( function buildSelectOptions( accounts: Account[], activeSource: string, -): Array<{ label: string; value: string; hint: string }> { +): Array<{ label: string; value: string; hint?: string }> { return accounts.map((a) => ({ label: a.label, value: a.source, - hint: a.source === activeSource ? `${a.source} (active)` : a.source, + hint: a.source === activeSource ? "active" : undefined, })) } @@ -190,6 +190,8 @@ let credentials = { expiresAt: ${initialExpiresAt} } +export const PRIMARY_SERVICE = "Claude Code-credentials" + export function readAllClaudeAccounts() { readCount += 1 return [{ label: "Account 1", source: "Claude Code-credentials", credentials }] @@ -285,7 +287,8 @@ describe("exported helpers", () => { await copySourceFiles(tempDir) await writeFile( tempKeychain, - `export function readAllClaudeAccounts() { return [{ label: "Account 1", source: "Claude Code-credentials", credentials: { accessToken: "token", refreshToken: "refresh", expiresAt: 1 } }] } + `export const PRIMARY_SERVICE = "Claude Code-credentials" +export function readAllClaudeAccounts() { return [{ label: "Account 1", source: "Claude Code-credentials", credentials: { accessToken: "token", refreshToken: "refresh", expiresAt: 1 } }] } export function refreshAccount() { return null } export function writeBackCredentials() { return true } export function buildAccountLabels(creds) { return creds.map((_, i) => \`Account \${i + 1}\`) } @@ -1195,14 +1198,14 @@ describe("auth hook — select prompt options", () => { assert.equal(options[1].value, "Claude Code-credentials-b28bbb7c") }) - it("marks the active account with (active) in its hint", () => { + it("marks the active account in its hint", () => { const options = buildSelectOptions( accounts, "Claude Code-credentials-b28bbb7c", ) - assert.ok(options[1].hint.includes("(active)")) - assert.ok(!options[0].hint.includes("(active)")) - assert.ok(!options[2].hint.includes("(active)")) + assert.equal(options[1].hint, "active") + assert.equal(options[0].hint, undefined) + assert.equal(options[2].hint, undefined) }) it("shows no prompts when only one account exists", () => { diff --git a/src/index.ts b/src/index.ts index 881e221..40b0d8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -360,7 +360,9 @@ const plugin: Plugin = async () => { const body = transformBody(requestInit.body) const headerKeys: string[] = [] - headers.forEach((_, key) => headerKeys.push(key)) + headers.forEach((_, key) => { + headerKeys.push(key) + }) const betas = (headers.get("anthropic-beta") ?? "") .split(",") .filter(Boolean) @@ -496,10 +498,7 @@ const plugin: Plugin = async () => { options: currentAccounts.map((a) => ({ label: a.label, value: a.source, - hint: - a.source === currentSource - ? `${a.source} (active)` - : a.source, + hint: a.source === currentSource ? "active" : undefined, })), }, ] @@ -524,8 +523,8 @@ const plugin: Plugin = async () => { const sourceDescription = chosen.source === "file" - ? "credentials file (~/.claude/.credentials.json)" - : "macOS Keychain" + ? `credentials file (${chosen.configDir ?? "~/.claude"}/.credentials.json)` + : `macOS Keychain (${chosen.source})` return { url: "", diff --git a/src/keychain.test.ts b/src/keychain.test.ts index c0d9d00..b742fff 100644 --- a/src/keychain.test.ts +++ b/src/keychain.test.ts @@ -1,19 +1,120 @@ import assert from "node:assert/strict" -import { describe, it } from "node:test" -import { writeFileSync, mkdirSync, rmSync } from "node:fs" -import { readFileSync } from "node:fs" +import { afterEach, describe, it } from "node:test" +import { + chmodSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs" +import { mkdtemp, readFile, writeFile } from "node:fs/promises" +import { homedir, tmpdir } from "node:os" import { join } from "node:path" -import { tmpdir } from "node:os" +import { pathToFileURL } from "node:url" import { buildAccountLabels, + keychainSuffixForDir, parseCredentials, + readCredentialsFile, updateCredentialBlob, writeBackCredentials, } from "./keychain.ts" -import { chmodSync, statSync } from "node:fs" -import { mkdtemp } from "node:fs/promises" // Mirrors listClaudeKeychainServices regex logic for unit testing +async function loadKeychainWithMockedSecurity( + securityDump: string, + keychainEntries: Record, +): Promise<{ + readAllClaudeAccounts: () => Array<{ + label: string + source: string + configDir?: string + credentials: { + accessToken: string + refreshToken: string + expiresAt: number + subscriptionType?: string + } + }> +}> { + const tempDir = await mkdtemp( + join(tmpdir(), "opencode-claude-auth-keychain-"), + ) + const tempKeychain = join(tempDir, "keychain.ts") + const tempLogger = join(tempDir, "logger.ts") + const tempChildProcess = join(tempDir, "child-process.ts") + const sourceKeychain = await readFile( + new URL("./keychain.ts", import.meta.url), + "utf8", + ) + const rewritten = sourceKeychain + .replace(/from\s+["']\.\/(\w+)\.js["']/g, 'from "./$1.ts"') + .replace(/from\s+["']node:child_process["']/, 'from "./child-process.ts"') + .replace(/process\.platform/g, '"darwin"') + + await writeFile( + tempLogger, + `export function log() {}\nexport function initLogger() {}\nexport function closeLogger() {}\n`, + "utf8", + ) + + await writeFile( + tempChildProcess, + `const securityDump = ${JSON.stringify(securityDump)} +const keychainEntries = ${JSON.stringify(keychainEntries)} + +export function execSync(command) { + if (command.includes("dump-keychain")) return securityDump + if (command.includes("find-generic-password")) { + const match = command.match(/-s "([^"]+)"/) + const service = match ? match[1] : undefined + const raw = service ? keychainEntries[service] : undefined + if (raw === undefined) { + const error = new Error("The specified item could not be found in the keychain.") + error.status = 44 + throw error + } + return raw + } + throw new Error("unexpected execSync call: " + command) +} + +export function execFileSync(file, args) { + if (file !== "/usr/bin/security") { + throw new Error("unexpected execFileSync file: " + file) + } + const service = args[args.indexOf("-s") + 1] + const raw = keychainEntries[service] + if (raw === undefined) { + const error = new Error("The specified item could not be found in the keychain.") + error.status = 44 + error.stderr = "The specified item could not be found in the keychain." + throw error + } + return raw +} +`, + "utf8", + ) + + await writeFile(tempKeychain, rewritten, "utf8") + const keychainModule = await import(pathToFileURL(tempKeychain).href) + return keychainModule as { + readAllClaudeAccounts: () => Array<{ + label: string + source: string + configDir?: string + credentials: { + accessToken: string + refreshToken: string + expiresAt: number + subscriptionType?: string + } + }> + } +} + function extractServicesFromDump(output: string): string[] { const PRIMARY = "Claude Code-credentials" const services: string[] = [] @@ -38,19 +139,6 @@ function extractServicesFromDump(output: string): string[] { return ordered } -function readCredentialsFile(credPath: string): { - accessToken: string - refreshToken: string - expiresAt: number -} | null { - try { - const raw = readFileSync(credPath, "utf-8") - return parseCredentials(raw) - } catch { - return null - } -} - describe("parseCredentials", () => { it("parses credentials with claudeAiOauth wrapper", () => { const raw = JSON.stringify({ @@ -58,9 +146,7 @@ describe("parseCredentials", () => { accessToken: "at-123", refreshToken: "rt-456", expiresAt: 1700000000000, - scopes: ["user:inference"], subscriptionType: "pro", - rateLimitTier: "default_claude_ai", }, }) const result = parseCredentials(raw) @@ -113,163 +199,49 @@ describe("parseCredentials", () => { it("returns null for MCP-only entries", () => { const raw = JSON.stringify({ - mcpOAuth: { - "neon|abc123": { - serverName: "neon", - accessToken: "some-token", - expiresAt: 1700000000000, - }, - }, + mcpOAuth: { "neon|abc123": { serverName: "neon" } }, }) assert.equal(parseCredentials(raw), null) }) - it("returns null for missing accessToken", () => { - assert.equal( - parseCredentials(JSON.stringify({ refreshToken: "rt", expiresAt: 123 })), - null, - ) - }) - - it("returns null for missing refreshToken", () => { - assert.equal( - parseCredentials(JSON.stringify({ accessToken: "at", expiresAt: 123 })), - null, - ) - }) - - it("returns null for missing expiresAt", () => { - assert.equal( - parseCredentials( - JSON.stringify({ accessToken: "at", refreshToken: "rt" }), - ), - null, - ) - }) - - it("returns null for wrong types", () => { - assert.equal( - parseCredentials( - JSON.stringify({ - accessToken: 123, - refreshToken: "rt", - expiresAt: 456, - }), - ), - null, - ) - }) - it("returns null for invalid JSON", () => { assert.equal(parseCredentials("not json {{{"), null) }) - - it("returns null for empty string", () => { - assert.equal(parseCredentials(""), null) - }) }) describe("keychain service discovery", () => { - const SAMPLE_DUMP = ` -keychain: "/Users/test/Library/Keychains/login.keychain-db" -version: 512 -class: "genp" -attributes: - 0x00000007 ="Claude Code-credentials-e8dc196c" - "svce"="Claude Code-credentials-e8dc196c" -keychain: "/Users/test/Library/Keychains/login.keychain-db" -version: 512 -class: "genp" -attributes: - 0x00000007 ="Claude Code-credentials-b28bbb7c" + it("discovers primary and suffixed services", () => { + const dump = ` "svce"="Claude Code-credentials-b28bbb7c" -keychain: "/Users/test/Library/Keychains/login.keychain-db" -version: 512 -class: "genp" -attributes: - 0x00000007 ="Claude Code-credentials" "svce"="Claude Code-credentials" - ` - - it("discovers all Claude Code-credentials* services", () => { - const services = extractServicesFromDump(SAMPLE_DUMP) - assert.ok(services.includes("Claude Code-credentials")) - assert.ok(services.includes("Claude Code-credentials-e8dc196c")) - assert.ok(services.includes("Claude Code-credentials-b28bbb7c")) - assert.equal(services.length, 3) - }) - - it("puts the primary service first", () => { - assert.equal( - extractServicesFromDump(SAMPLE_DUMP)[0], - "Claude Code-credentials", - ) - }) - - it("deduplicates entries that appear twice (svce and blob line)", () => { - const services = extractServicesFromDump(SAMPLE_DUMP) - assert.equal( - services.filter((s) => s === "Claude Code-credentials").length, - 1, - ) - assert.equal( - services.filter((s) => s === "Claude Code-credentials-b28bbb7c").length, - 1, - ) - }) - - it("ignores non-Claude-Code keychain entries", () => { - const dump = ` - 0x00000007 ="Some Other Service" - "svce"="Some Other Service" - 0x00000007 ="Claude Code-credentials" ` - assert.deepEqual(extractServicesFromDump(dump), ["Claude Code-credentials"]) - }) - - it("returns empty array for a dump with no Claude Code entries", () => { - assert.deepEqual(extractServicesFromDump("no relevant entries here"), []) - }) - - it("does not match uppercase hex suffixes", () => { - assert.deepEqual( - extractServicesFromDump( - `"svce"="Claude Code-credentials-B28BBB7C"`, - ), - [], - ) + assert.deepEqual(extractServicesFromDump(dump), [ + "Claude Code-credentials", + "Claude Code-credentials-b28bbb7c", + ]) }) - it("does not match arbitrary word suffixes", () => { + it("does not match uppercase or arbitrary suffixes", () => { assert.deepEqual( extractServicesFromDump( - `"svce"="Claude Code-credentials-myaccount"`, + ` + "svce"="Claude Code-credentials-B28BBB7C" + "svce"="Claude Code-credentials-myaccount" + `, ), [], ) }) - it("handles a dump where primary service appears after suffixed ones", () => { + it("discovers legacy hex suffixes that are not exactly 8 chars", () => { const dump = ` - "svce"="Claude Code-credentials-b28bbb7c" - "svce"="Claude Code-credentials" + "svce"="Claude Code-credentials-abc" + "svce"="Claude Code-credentials-deadbeefcafebabe" ` - const services = extractServicesFromDump(dump) - assert.equal(services[0], "Claude Code-credentials") - assert.equal(services[1], "Claude Code-credentials-b28bbb7c") - }) - - it("handles all five real-world suffixes from a populated keychain", () => { - const dump = ` - "svce"="Claude Code-credentials" - "svce"="Claude Code-credentials-e8dc196c" - "svce"="Claude Code-credentials-3519e293" - "svce"="Claude Code-credentials-b3d57fec" - "svce"="Claude Code-credentials-b28bbb7c" - ` - const services = extractServicesFromDump(dump) - assert.equal(services.length, 5) - assert.equal(services[0], "Claude Code-credentials") + assert.deepEqual(extractServicesFromDump(dump), [ + "Claude Code-credentials-abc", + "Claude Code-credentials-deadbeefcafebabe", + ]) }) }) @@ -288,70 +260,231 @@ const makeAccountCreds = ( }) describe("account labelling", () => { - it("uses subscription type as label when available", () => { - assert.equal(buildAccountLabels([makeAccountCreds("pro")])[0], "Claude Pro") - assert.equal(buildAccountLabels([makeAccountCreds("max")])[0], "Claude Max") - assert.equal( - buildAccountLabels([makeAccountCreds("free")])[0], - "Claude Free", + it("uses subscription type and deduplicates tiers", () => { + assert.deepEqual( + buildAccountLabels([ + makeAccountCreds("pro"), + makeAccountCreds("pro"), + makeAccountCreds("max"), + ]), + ["Claude Pro 1", "Claude Pro 2", "Claude Max"], ) }) - it("capitalises the subscription tier", () => { - assert.equal(buildAccountLabels([makeAccountCreds("pro")])[0], "Claude Pro") - }) - - it("falls back to 'Claude' when no subscription type", () => { + it("falls back to Claude when no subscription type", () => { assert.equal(buildAccountLabels([makeAccountCreds()])[0], "Claude") }) - it("deduplicates labels with counter when multiple accounts share a tier", () => { - const labels = buildAccountLabels([ - makeAccountCreds("pro"), - makeAccountCreds("pro"), - makeAccountCreds("max"), - ]) - assert.deepEqual(labels, ["Claude Pro 1", "Claude Pro 2", "Claude Max"]) - }) - - it("keeps single account of each tier un-numbered", () => { + it("appends email when provided", () => { assert.deepEqual( - buildAccountLabels([makeAccountCreds("pro"), makeAccountCreds("max")]), - ["Claude Pro", "Claude Max"], + buildAccountLabels( + [makeAccountCreds("pro"), makeAccountCreds("pro")], + ["a@example.com", "b@example.com"], + ), + ["Claude Pro 1: a@example.com", "Claude Pro 2: b@example.com"], ) }) - it("handles three accounts of the same tier", () => { + it("skips email when absent", () => { assert.deepEqual( - buildAccountLabels([ - makeAccountCreds("pro"), - makeAccountCreds("pro"), - makeAccountCreds("pro"), - ]), - ["Claude Pro 1", "Claude Pro 2", "Claude Pro 3"], + buildAccountLabels( + [makeAccountCreds("pro"), makeAccountCreds("team")], + [null, "bob@example.com"], + ["Claude Code-credentials", "Claude Code-credentials-b28bbb7c"], + ), + ["Claude Pro: Claude Code-credentials", "Claude Team: bob@example.com"], ) }) +}) - it("handles mixed known and unknown subscription types", () => { - assert.deepEqual( - buildAccountLabels([ - makeAccountCreds(), - makeAccountCreds("pro"), - makeAccountCreds(), - ]), - ["Claude 1", "Claude Pro", "Claude 2"], +describe("readAllClaudeAccounts", () => { + it("resolves suffixed keychain services back to config dirs and emails", async () => { + const originalHome = process.env.HOME + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-home-")) + const primaryDir = join(tempHome, ".claude") + const workDir = join(tempHome, ".work") + const workSuffix = keychainSuffixForDir(workDir) + + mkdirSync(primaryDir, { recursive: true }) + mkdirSync(workDir, { recursive: true }) + + writeFileSync( + join(primaryDir, ".claude.json"), + JSON.stringify({ + oauthAccount: { emailAddress: "primary@example.com" }, + }), + ) + writeFileSync( + join(workDir, ".claude.json"), + JSON.stringify({ + oauthAccount: { emailAddress: "work@example.com" }, + }), ) + + const dump = ` + "svce"="Claude Code-credentials-${workSuffix}" + "svce"="Claude Code-credentials" + ` + const primaryCreds = JSON.stringify({ + claudeAiOauth: { + accessToken: "primary-at", + refreshToken: "primary-rt", + expiresAt: 1_700_000_000_000, + subscriptionType: "pro", + }, + }) + const workCreds = JSON.stringify({ + claudeAiOauth: { + accessToken: "work-at", + refreshToken: "work-rt", + expiresAt: 1_700_000_000_001, + subscriptionType: "pro", + }, + }) + + process.env.HOME = tempHome + + try { + const { readAllClaudeAccounts } = await loadKeychainWithMockedSecurity( + dump, + { + "Claude Code-credentials": primaryCreds, + [`Claude Code-credentials-${workSuffix}`]: workCreds, + }, + ) + + assert.deepEqual(readAllClaudeAccounts(), [ + { + label: "Claude Pro 1: primary@example.com", + source: "Claude Code-credentials", + configDir: primaryDir, + credentials: { + accessToken: "primary-at", + refreshToken: "primary-rt", + expiresAt: 1_700_000_000_000, + subscriptionType: "pro", + }, + }, + { + label: "Claude Pro 2: work@example.com", + source: `Claude Code-credentials-${workSuffix}`, + configDir: workDir, + credentials: { + accessToken: "work-at", + refreshToken: "work-rt", + expiresAt: 1_700_000_000_001, + subscriptionType: "pro", + }, + }, + ]) + } finally { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + rmSync(tempHome, { recursive: true, force: true }) + } + }) + + it("keeps keychain source visible when email lookup fails", async () => { + const originalHome = process.env.HOME + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-home-")) + const primaryDir = join(tempHome, ".claude") + const workDir = join(tempHome, ".work") + const workSuffix = keychainSuffixForDir(workDir) + + mkdirSync(primaryDir, { recursive: true }) + mkdirSync(workDir, { recursive: true }) + writeFileSync(join(primaryDir, ".claude.json"), JSON.stringify({})) + writeFileSync(join(workDir, ".claude.json"), JSON.stringify({})) + + process.env.HOME = tempHome + + try { + const { readAllClaudeAccounts } = await loadKeychainWithMockedSecurity( + `"svce"="Claude Code-credentials-${workSuffix}"`, + { + [`Claude Code-credentials-${workSuffix}`]: JSON.stringify({ + claudeAiOauth: { + accessToken: "work-at", + refreshToken: "work-rt", + expiresAt: 1_700_000_000_001, + }, + }), + }, + ) + + const [account] = readAllClaudeAccounts() + assert.equal(account.source, `Claude Code-credentials-${workSuffix}`) + assert.equal( + account.label, + `Claude: Claude Code-credentials-${workSuffix}`, + ) + assert.equal(account.configDir, workDir) + } finally { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + rmSync(tempHome, { recursive: true, force: true }) + } + }) + + it("keeps legacy hex suffixes that are not exactly 8 chars discoverable", async () => { + const originalHome = process.env.HOME + const tempHome = await mkdtemp(join(tmpdir(), "opencode-claude-auth-home-")) + const primaryDir = join(tempHome, ".claude") + mkdirSync(primaryDir, { recursive: true }) + writeFileSync(join(primaryDir, ".claude.json"), JSON.stringify({})) + + process.env.HOME = tempHome + + try { + const { readAllClaudeAccounts } = await loadKeychainWithMockedSecurity( + `"svce"="Claude Code-credentials-abc"`, + { + "Claude Code-credentials-abc": JSON.stringify({ + claudeAiOauth: { + accessToken: "legacy-at", + refreshToken: "legacy-rt", + expiresAt: 1_700_000_000_000, + }, + }), + }, + ) + + const accounts = readAllClaudeAccounts() + assert.equal(accounts.length, 1) + assert.equal(accounts[0].source, "Claude Code-credentials-abc") + // A non-8-char suffix cannot be mapped back to a config dir hash, so + // the entry falls back to the primary config dir — matching the CLI + // refresh behaviour these legacy entries had before suffix mapping + // existed. + assert.equal(accounts[0].configDir, primaryDir) + } finally { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + rmSync(tempHome, { recursive: true, force: true }) + } }) }) describe("credentials file fallback", () => { const tmpDir = join(tmpdir(), `claude-test-${process.pid}`) - it("reads valid credentials from a JSON file", () => { + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it("reads valid credentials from a config dir", () => { mkdirSync(tmpDir, { recursive: true }) - const credPath = join(tmpDir, ".credentials.json") writeFileSync( - credPath, + join(tmpDir, ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "file-at", @@ -360,40 +493,23 @@ describe("credentials file fallback", () => { }, }), ) - const result = readCredentialsFile(credPath) - assert.deepEqual(result, { + + assert.deepEqual(readCredentialsFile(tmpDir), { accessToken: "file-at", refreshToken: "file-rt", expiresAt: 1700000000000, subscriptionType: undefined, }) - rmSync(tmpDir, { recursive: true, force: true }) }) it("returns null when the file does not exist", () => { - assert.equal( - readCredentialsFile(join(tmpDir, "nonexistent", ".credentials.json")), - null, - ) + assert.equal(readCredentialsFile(join(tmpDir, "missing")), null) }) it("returns null when the file contains invalid JSON", () => { mkdirSync(tmpDir, { recursive: true }) - const credPath = join(tmpDir, ".credentials.json") - writeFileSync(credPath, "{ broken json") - assert.equal(readCredentialsFile(credPath), null) - rmSync(tmpDir, { recursive: true, force: true }) - }) - - it("returns null when the file is valid JSON but missing required fields", () => { - mkdirSync(tmpDir, { recursive: true }) - const credPath = join(tmpDir, ".credentials.json") - writeFileSync( - credPath, - JSON.stringify({ claudeAiOauth: { accessToken: "only-this" } }), - ) - assert.equal(readCredentialsFile(credPath), null) - rmSync(tmpDir, { recursive: true, force: true }) + writeFileSync(join(tmpDir, ".credentials.json"), "{ broken json") + assert.equal(readCredentialsFile(tmpDir), null) }) }) @@ -404,7 +520,6 @@ describe("updateCredentialBlob", () => { accessToken: "old-at", refreshToken: "old-rt", expiresAt: 1000, - scopes: ["user:inference"], subscriptionType: "pro", }, }) @@ -415,9 +530,6 @@ describe("updateCredentialBlob", () => { } const result = JSON.parse(updateCredentialBlob(existing, newCreds)!) assert.equal(result.claudeAiOauth.accessToken, "new-at") - assert.equal(result.claudeAiOauth.refreshToken, "new-rt") - assert.equal(result.claudeAiOauth.expiresAt, 2000) - assert.deepEqual(result.claudeAiOauth.scopes, ["user:inference"]) assert.equal(result.claudeAiOauth.subscriptionType, "pro") }) @@ -501,13 +613,7 @@ describe("writeBackCredentials (file source)", () => { assert.equal(result, true) const written = JSON.parse(readFileSync(credPath, "utf-8")) assert.equal(written.claudeAiOauth.accessToken, "new-at") - assert.equal(written.claudeAiOauth.refreshToken, "new-rt") - assert.equal(written.claudeAiOauth.expiresAt, 2000) - assert.equal( - written.claudeAiOauth.subscriptionType, - "pro", - "should preserve other fields", - ) + assert.equal(written.claudeAiOauth.subscriptionType, "pro") } finally { if (typeof originalHome === "string") { process.env.HOME = originalHome @@ -545,7 +651,7 @@ describe("writeBackCredentials (file source)", () => { }) const mode = statSync(credPath).mode & 0o777 - assert.equal(mode, 0o600, `Expected 0o600, got 0o${mode.toString(8)}`) + assert.equal(mode, 0o600) } finally { if (typeof originalHome === "string") { process.env.HOME = originalHome @@ -608,3 +714,110 @@ describe("writeBackCredentials (file source)", () => { } }) }) + +function makeCreds(accessToken: string) { + return JSON.stringify({ + claudeAiOauth: { + accessToken, + refreshToken: "rt", + expiresAt: Date.now() + 3_600_000, + }, + }) +} + +describe("CLAUDE_CONFIG_DIR support", () => { + const savedEnv = process.env.CLAUDE_CONFIG_DIR + + afterEach(() => { + if (savedEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = savedEnv + } + }) + + it("uses ~/.claude by default when CLAUDE_CONFIG_DIR is unset", async () => { + const originalHome = process.env.HOME + delete process.env.CLAUDE_CONFIG_DIR + const fakeHome = await mkdtemp(join(tmpdir(), "claude-home-")) + const defaultDir = join(fakeHome, ".claude") + mkdirSync(defaultDir, { recursive: true }) + writeFileSync( + join(defaultDir, ".credentials.json"), + makeCreds("default-token"), + ) + + process.env.HOME = fakeHome + + try { + const creds = readCredentialsFile() + assert.ok(creds) + assert.equal(creds.accessToken, "default-token") + } finally { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + rmSync(fakeHome, { recursive: true, force: true }) + } + }) + + it("uses CLAUDE_CONFIG_DIR when set to a custom path", async () => { + const customDir = await mkdtemp(join(tmpdir(), "claude-custom-")) + mkdirSync(customDir, { recursive: true }) + writeFileSync( + join(customDir, ".credentials.json"), + makeCreds("custom-token"), + ) + + process.env.CLAUDE_CONFIG_DIR = customDir + + const creds = readCredentialsFile() + assert.ok(creds) + assert.equal(creds.accessToken, "custom-token") + + rmSync(customDir, { recursive: true, force: true }) + }) + + it("works with arbitrary custom directory names", async () => { + const arbitraryDir = await mkdtemp(join(tmpdir(), "claude-arbitrary-")) + writeFileSync( + join(arbitraryDir, ".credentials.json"), + makeCreds("arbitrary-token"), + ) + + process.env.CLAUDE_CONFIG_DIR = arbitraryDir + + const creds = readCredentialsFile() + assert.ok(creds) + assert.equal(creds.accessToken, "arbitrary-token") + + rmSync(arbitraryDir, { recursive: true, force: true }) + }) +}) + +describe("keychainSuffixForDir", () => { + it("derives the expected suffix for a known path", () => { + assert.equal(keychainSuffixForDir("/Users/example/.work"), "d4b84687") + }) + + it("produces different suffixes for different dirs", () => { + const a = keychainSuffixForDir("/Users/example/.claude") + const b = keychainSuffixForDir("/Users/example/.work") + const c = keychainSuffixForDir("/Users/example/.personal") + assert.notEqual(a, b) + assert.notEqual(b, c) + assert.notEqual(a, c) + }) + + it("produces 8-character hex strings", () => { + const suffix = keychainSuffixForDir("/Users/example/.claude") + assert.match(suffix, /^[0-9a-f]{8}$/) + }) + + it("is consistent for the same input", () => { + const dir = join(homedir(), ".someconfig") + assert.equal(keychainSuffixForDir(dir), keychainSuffixForDir(dir)) + }) +}) diff --git a/src/keychain.ts b/src/keychain.ts index c862a53..24c7d99 100644 --- a/src/keychain.ts +++ b/src/keychain.ts @@ -1,5 +1,12 @@ import { execFileSync, execSync } from "node:child_process" -import { chmodSync, readFileSync, writeFileSync } from "node:fs" +import { createHash } from "node:crypto" +import { + chmodSync, + existsSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs" import { homedir } from "node:os" import { join } from "node:path" import { log } from "./logger.ts" @@ -14,10 +21,11 @@ export interface ClaudeCredentials { export interface ClaudeAccount { label: string source: string + configDir?: string credentials: ClaudeCredentials } -const PRIMARY_SERVICE = "Claude Code-credentials" +export const PRIMARY_SERVICE = "Claude Code-credentials" export function parseCredentials(raw: string): ClaudeCredentials | null { let parsed: unknown @@ -36,7 +44,6 @@ export function parseCredentials(raw: string): ClaudeCredentials | null { mcpOAuth?: unknown } - // Entries that only contain mcpOAuth are MCP server credentials, not user accounts if ((parsed as { mcpOAuth?: unknown }).mcpOAuth && !creds.accessToken) { return null } @@ -122,7 +129,7 @@ function readKeychainService(serviceName: string): string | null { service: serviceName, errorType: "not_found", }) - return null // item not found + return null } log("keychain_read_error", { service: serviceName, @@ -146,6 +153,9 @@ function listClaudeKeychainServices(): string[] { const services: string[] = [] const seen = new Set() + // Any-length hex suffix so legacy entries stay discoverable. The + // suffix-to-config-dir mapping below still only applies to the + // 8-char hashes the Claude CLI generates. const re = /"Claude Code-credentials(?:-[0-9a-f]+)?"/g let m = re.exec(dump) while (m !== null) { @@ -173,20 +183,109 @@ function listClaudeKeychainServices(): string[] { } } -function readCredentialsFile(): ClaudeCredentials | null { +function readEmailFromConfigDir(configDir: string): string | null { + const primaryConfigDir = join(homedir(), ".claude") + const candidates = [ + join(configDir, ".claude.json"), + ...(configDir === primaryConfigDir + ? [join(homedir(), ".claude.json")] + : []), + ] + + for (const path of candidates) { + try { + const raw = readFileSync(path, "utf-8") + const data = JSON.parse(raw) as { + oauthAccount?: { emailAddress?: string } + } + const email = data.oauthAccount?.emailAddress + if (email) return email + } catch { + // try next candidate + } + } + + return null +} + +export function readCredentialsFile( + configDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), +): ClaudeCredentials | null { try { - const credPath = join(homedir(), ".claude", ".credentials.json") + const credPath = join(configDir, ".credentials.json") const raw = readFileSync(credPath, "utf-8") const creds = parseCredentials(raw) - log("credentials_file_read", { success: creds !== null }) + log("credentials_file_read", { success: creds !== null, configDir }) return creds } catch { - log("credentials_file_read", { success: false }) + log("credentials_file_read", { success: false, configDir }) return null } } -export function buildAccountLabels(credsList: ClaudeCredentials[]): string[] { +export function keychainSuffixForDir(dir: string): string { + return createHash("sha256").update(dir).digest("hex").slice(0, 8) +} + +let suffixToDirCache: Map | null = null + +function buildSuffixToDirCache(needed: Set): Map { + const hasAllNeeded = (cache: Map) => + [...needed].every((suffix) => cache.has(suffix)) + + if (suffixToDirCache && hasAllNeeded(suffixToDirCache)) + return suffixToDirCache + + const cache = suffixToDirCache ?? new Map() + + const tryDir = (dir: string) => { + const suffix = keychainSuffixForDir(dir) + if (needed.has(suffix) && !cache.has(suffix)) cache.set(suffix, dir) + } + + if (process.env.CLAUDE_CONFIG_DIR) { + tryDir(process.env.CLAUDE_CONFIG_DIR) + } + + const home = homedir() + try { + const entries = readdirSync(home, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith(".")) continue + const dir = join(home, entry.name) + if (!existsSync(join(dir, ".claude.json"))) continue + tryDir(dir) + if (hasAllNeeded(cache)) break + } + } catch { + // + } + + suffixToDirCache = cache + return cache +} + +export function clearSuffixToDirCache(): void { + suffixToDirCache = null +} + +function discoverConfigDirsForKeychain( + keychainSuffixes: Set, +): Map { + const cache = buildSuffixToDirCache(keychainSuffixes) + const result = new Map() + for (const suffix of keychainSuffixes) { + const dir = cache.get(suffix) + if (dir) result.set(suffix, dir) + } + return result +} + +export function buildAccountLabels( + credsList: ClaudeCredentials[], + emails?: (string | null)[], + sources?: (string | null)[], +): string[] { const baseLabels = credsList.map((c) => { if (c.subscriptionType) { const tier = @@ -200,45 +299,102 @@ export function buildAccountLabels(credsList: ClaudeCredentials[]): string[] { for (const l of baseLabels) counts.set(l, (counts.get(l) ?? 0) + 1) const seen = new Map() - return baseLabels.map((base) => { - if ((counts.get(base) ?? 0) <= 1) return base - const n = (seen.get(base) ?? 0) + 1 - seen.set(base, n) - return `${base} ${n}` + return baseLabels.map((base, i) => { + let label: string + if ((counts.get(base) ?? 0) <= 1) { + label = base + } else { + const n = (seen.get(base) ?? 0) + 1 + seen.set(base, n) + label = `${base} ${n}` + } + const email = emails?.[i] + if (email) return `${label}: ${email}` + const source = sources?.[i] + return source ? `${label}: ${source}` : label }) } export function readAllClaudeAccounts(): ClaudeAccount[] { if (process.platform !== "darwin") { - const creds = readCredentialsFile() + const configDir = + process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude") + const creds = readCredentialsFile(configDir) if (!creds) return [] - const [label] = buildAccountLabels([creds]) - return [{ label, source: "file", credentials: creds }] + const email = readEmailFromConfigDir(configDir) + const [label] = buildAccountLabels([creds], [email]) + return [{ label, source: "file", configDir, credentials: creds }] } const services = listClaudeKeychainServices() - const rawAccounts: Array<{ source: string; credentials: ClaudeCredentials }> = - [] + const keychainAccounts: Array<{ + source: string + suffix: string | null + credentials: ClaudeCredentials + }> = [] for (const svc of services) { const raw = readKeychainService(svc) if (!raw) continue const creds = parseCredentials(raw) if (!creds) continue - rawAccounts.push({ source: svc, credentials: creds }) + const suffixMatch = svc.match(/-([0-9a-f]{8})$/) + keychainAccounts.push({ + source: svc, + suffix: suffixMatch ? suffixMatch[1] : null, + credentials: creds, + }) } - if (rawAccounts.length === 0) { - const creds = readCredentialsFile() - if (creds) rawAccounts.push({ source: "file", credentials: creds }) + if (keychainAccounts.length === 0) { + const configDir = + process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude") + const creds = readCredentialsFile(configDir) + if (!creds) return [] + const email = readEmailFromConfigDir(configDir) + const [label] = buildAccountLabels([creds], [email]) + return [{ label, source: "file", configDir, credentials: creds }] } - const labels = buildAccountLabels(rawAccounts.map((a) => a.credentials)) - return rawAccounts.map((a, i) => ({ - label: labels[i], - source: a.source, - credentials: a.credentials, - })) + const suffixToDir = discoverConfigDirsForKeychain( + new Set( + keychainAccounts + .map((a) => a.suffix) + .filter((s): s is string => s !== null), + ), + ) + + const resolved = keychainAccounts.map((a) => { + const configDir = + a.suffix === null ? join(homedir(), ".claude") : suffixToDir.get(a.suffix) + const email = configDir ? readEmailFromConfigDir(configDir) : null + log("account_config_dir", { + source: a.source, + configDir: configDir ?? null, + }) + return { + source: a.source, + credentials: a.credentials, + configDir, + email, + } + }) + + const labels = buildAccountLabels( + resolved.map((a) => a.credentials), + resolved.map((a) => a.email), + resolved.map((a) => a.source), + ) + + return resolved.map((a, i) => { + const account: ClaudeAccount = { + label: labels[i], + source: a.source, + credentials: a.credentials, + } + if (a.configDir) account.configDir = a.configDir + return account + }) } export function updateCredentialBlob( @@ -286,6 +442,7 @@ function getKeychainAccountName(serviceName: string): string | null { export function writeBackCredentials( source: string, creds: ClaudeCredentials, + configDir?: string, ): boolean { const newCreds = { accessToken: creds.accessToken, @@ -295,7 +452,9 @@ export function writeBackCredentials( if (source === "file") { try { - const credPath = join(homedir(), ".claude", ".credentials.json") + const dir = + configDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude") + const credPath = join(dir, ".credentials.json") const raw = readFileSync(credPath, "utf-8") const updated = updateCredentialBlob(raw, newCreds) if (!updated) return false @@ -303,10 +462,10 @@ export function writeBackCredentials( if (process.platform !== "win32") { chmodSync(credPath, 0o600) } - log("writeback_success", { source }) + log("writeback_success", { source, configDir: dir }) return true } catch { - log("writeback_failed", { source }) + log("writeback_failed", { source, configDir: configDir ?? null }) return false } } @@ -317,9 +476,6 @@ export function writeBackCredentials( if (!raw) return false const updated = updateCredentialBlob(raw, newCreds) if (!updated) return false - // Discover the actual account name from the existing Keychain entry. - // Claude CLI uses the macOS username (e.g. "gmartin"), not the service name. - // Using the wrong account name creates a duplicate entry instead of updating. const accountName = getKeychainAccountName(source) ?? source execFileSync( "/usr/bin/security", @@ -346,9 +502,12 @@ export function writeBackCredentials( return false } -export function refreshAccount(source: string): ClaudeCredentials | null { +export function refreshAccount( + source: string, + configDir?: string, +): ClaudeCredentials | null { if (source === "file") { - return readCredentialsFile() + return readCredentialsFile(configDir) } const raw = readKeychainService(source) if (!raw) return null