diff --git a/packages/server/src/services/quota-fetcher/providers/claude-keychain.test.ts b/packages/server/src/services/quota-fetcher/providers/claude-keychain.test.ts new file mode 100644 index 000000000..0f6be01f3 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/providers/claude-keychain.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { claudeKeychainAccount, readClaudeKeychainCredentials } from "./claude.js"; + +const SERVICE = "Claude Code-credentials"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("claudeKeychainAccount", () => { + it("uses a supported username verbatim", () => { + expect(claudeKeychainAccount("thomas.benoit")).toBe("thomas.benoit"); + }); + + it("falls back to claude-code-user when the username carries a rejected character", () => { + expect(claudeKeychainAccount("first.last@example.com")).toBe("claude-code-user"); + }); + + it("reads the username from $USER when no account is given", () => { + vi.stubEnv("USER", "ci-runner"); + expect(claudeKeychainAccount()).toBe("ci-runner"); + + vi.stubEnv("USER", "first.last@example.com"); + expect(claudeKeychainAccount()).toBe("claude-code-user"); + }); +}); + +describe("readClaudeKeychainCredentials", () => { + it("looks the item up by account and stops there when it exists", async () => { + const run = vi.fn(async () => JSON.stringify({ claudeAiOauth: { accessToken: "at_fresh" } })); + + const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); + + expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_fresh" } }); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith([ + "find-generic-password", + "-a", + "claude-code-user", + "-w", + "-s", + SERVICE, + ]); + }); + + it("falls back to the account-less lookup when no item matches the account", async () => { + const run = vi.fn(async (args: string[]) => + args.includes("-a") ? null : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), + ); + + const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); + + expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } }); + expect(run).toHaveBeenNthCalledWith(2, ["find-generic-password", "-w", "-s", SERVICE]); + }); + + it("falls through when the account item carries no access token", async () => { + const run = vi.fn(async (args: string[]) => + args.includes("-a") + ? JSON.stringify({ claudeAiOauth: { subscriptionType: "team" } }) + : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), + ); + + const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); + + expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } }); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("is null when the Keychain holds no item at all", async () => { + const run = vi.fn(async () => null); + + expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toBeNull(); + }); + + it("tries the next lookup when the item does not parse", async () => { + const run = vi.fn(async (args: string[]) => + args.includes("-a") + ? "not-json" + : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), + ); + + expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toEqual({ + claudeAiOauth: { accessToken: "at_legacy" }, + }); + }); +}); diff --git a/packages/server/src/services/quota-fetcher/providers/claude.ts b/packages/server/src/services/quota-fetcher/providers/claude.ts index 0ff4e9b8b..be922d085 100644 --- a/packages/server/src/services/quota-fetcher/providers/claude.ts +++ b/packages/server/src/services/quota-fetcher/providers/claude.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { existsSync, promises as fs } from "node:fs"; -import { homedir } from "node:os"; +import { homedir, userInfo } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import type { Logger } from "pino"; @@ -297,21 +297,54 @@ function scopedWindows(limits: ScopedLimit[]): ProviderUsageWindow[] { }); } -async function readClaudeKeychainCredentials(): Promise { +type ClaudeKeychainCommandRunner = (args: string[]) => Promise; + +// Keep this in sync with Claude Code's Keychain account derivation. +const CLAUDE_KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/; +const CLAUDE_KEYCHAIN_FALLBACK_ACCOUNT = "claude-code-user"; + +export function claudeKeychainAccount( + user: string = process.env["USER"] || userInfo().username, +): string { + return CLAUDE_KEYCHAIN_ACCOUNT_PATTERN.test(user) ? user : CLAUDE_KEYCHAIN_FALLBACK_ACCOUNT; +} + +async function runSecurityCommand(args: string[]): Promise { try { - const { stdout } = await execFileAsync( - "security", - ["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"], - { timeout: CLAUDE_KEYCHAIN_TIMEOUT_MS }, - ); - const raw = stdout.trim(); - if (!raw) return null; - return JSON.parse(raw); + const { stdout } = await execFileAsync("security", args, { + timeout: CLAUDE_KEYCHAIN_TIMEOUT_MS, + }); + return stdout.trim() || null; } catch { return null; } } +/** Read Claude Code's account-specific Keychain item, then try the legacy lookup. */ +export async function readClaudeKeychainCredentials( + run: ClaudeKeychainCommandRunner = runSecurityCommand, + account: string = claudeKeychainAccount(), +): Promise { + const lookups = [ + ["find-generic-password", "-a", account, "-w", "-s", CLAUDE_KEYCHAIN_SERVICE], + ["find-generic-password", "-w", "-s", CLAUDE_KEYCHAIN_SERVICE], + ]; + + for (const args of lookups) { + const raw = await run(args); + if (!raw) continue; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + continue; + } + const creds = ClaudeCredentialsSchema.safeParse(parsed); + if (creds.success && creds.data.claudeAiOauth?.accessToken) return parsed; + } + return null; +} + export class ClaudeQuotaProvider implements ProviderUsageFetcher { readonly providerId = "claude"; readonly displayName = "Claude";