diff --git a/packages/adapters/claude-local/src/server/index.ts b/packages/adapters/claude-local/src/server/index.ts index 7450244b2d..bfba059d18 100644 --- a/packages/adapters/claude-local/src/server/index.ts +++ b/packages/adapters/claude-local/src/server/index.ts @@ -20,6 +20,7 @@ export { getQuotaWindows, readClaudeAuthStatus, readClaudeToken, + readIsolatedClaudeKeychainToken, fetchClaudeQuota, fetchClaudeCliQuota, captureClaudeCliUsageText, diff --git a/packages/adapters/claude-local/src/server/quota-keychain.test.ts b/packages/adapters/claude-local/src/server/quota-keychain.test.ts index 145eacc1d5..f6a336d873 100644 --- a/packages/adapters/claude-local/src/server/quota-keychain.test.ts +++ b/packages/adapters/claude-local/src/server/quota-keychain.test.ts @@ -1,5 +1,8 @@ +import { createHash } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { readClaudeToken } from "./quota.js"; +import { readClaudeToken, readIsolatedClaudeKeychainToken } from "./quota.js"; + +const suffixedService = (dir: string) => `Claude Code-credentials-${createHash("sha256").update(dir).digest("hex").slice(0, 8)}`; const mocks = vi.hoisted(() => ({ read: vi.fn(), exec: vi.fn() })); vi.mock("node:fs/promises", () => ({ default: { readFile: mocks.read } })); vi.mock("node:child_process", () => ({ execFile: Object.assign(vi.fn(), { [Symbol.for("nodejs.util.promisify.custom")]: mocks.exec }) })); @@ -18,11 +21,36 @@ describe("explicit Claude Keychain import", () => { await expect(readClaudeToken({ allowKeychain: true })).resolves.toBe("fixture"); expect(mocks.exec).toHaveBeenCalledWith("/usr/bin/security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], expect.any(Object)); }); - it("never substitutes Keychain credentials for a custom auth home", async () => { + it("reads only the custom auth home's own suffixed Keychain item", async () => { + // Claude Code stores a custom CLAUDE_CONFIG_DIR login in a per-directory + // suffixed item; the unsuffixed item belongs to a different account and + // must never be substituted. vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); vi.stubEnv("CLAUDE_CONFIG_DIR", "/isolated/auth"); mocks.read.mockRejectedValue(new Error("missing")); + mocks.exec.mockResolvedValue({ stdout: JSON.stringify({ claudeAiOauth: { accessToken: "isolated" } }) }); + await expect(readClaudeToken({ allowKeychain: true })).resolves.toBe("isolated"); + expect(mocks.exec).toHaveBeenCalledTimes(1); + expect(mocks.exec).toHaveBeenCalledWith("/usr/bin/security", ["find-generic-password", "-s", suffixedService("/isolated/auth"), "-w"], expect.any(Object)); + }); + it("returns null for a custom auth home whose suffixed item is absent, without touching the unsuffixed item", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + vi.stubEnv("CLAUDE_CONFIG_DIR", "/isolated/auth"); + mocks.read.mockRejectedValue(new Error("missing")); + mocks.exec.mockRejectedValue(new Error("The specified item could not be found in the keychain.")); await expect(readClaudeToken({ allowKeychain: true })).resolves.toBeNull(); + expect(mocks.exec).toHaveBeenCalledTimes(1); + expect(mocks.exec).toHaveBeenCalledWith("/usr/bin/security", ["find-generic-password", "-s", suffixedService("/isolated/auth"), "-w"], expect.any(Object)); + }); + it("readIsolatedClaudeKeychainToken reads the login home's suffixed item on macOS", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + mocks.exec.mockResolvedValue({ stdout: JSON.stringify({ claudeAiOauth: { accessToken: "isolated-keychain" } }) }); + await expect(readIsolatedClaudeKeychainToken("/data/ai-local-logins/abc")).resolves.toBe("isolated-keychain"); + expect(mocks.exec).toHaveBeenCalledWith("/usr/bin/security", ["find-generic-password", "-s", suffixedService("/data/ai-local-logins/abc"), "-w"], expect.any(Object)); + }); + it("readIsolatedClaudeKeychainToken returns null off macOS", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + await expect(readIsolatedClaudeKeychainToken("/data/ai-local-logins/abc")).resolves.toBeNull(); expect(mocks.exec).not.toHaveBeenCalled(); }); it("skips an expired credentials file and falls through to Keychain", async () => { diff --git a/packages/adapters/claude-local/src/server/quota.ts b/packages/adapters/claude-local/src/server/quota.ts index 62241ecb21..44d4cc633f 100644 --- a/packages/adapters/claude-local/src/server/quota.ts +++ b/packages/adapters/claude-local/src/server/quota.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -159,19 +160,50 @@ function describeClaudeSubscriptionAuth(status: ClaudeAuthStatus | null): string : "Claude is logged in via claude.ai"; } +// Claude Code on macOS stores the OAuth credential for a custom +// CLAUDE_CONFIG_DIR in a per-directory Keychain item named +// "Claude Code-credentials-" instead of a +// credentials file in the directory. The suffix binds the item to exactly one +// auth home, so reading it can only ever surface the login performed inside +// that home — none of the cross-account risk of the unsuffixed operator item. +function isolatedKeychainService(configDir: string): string { + return `Claude Code-credentials-${createHash("sha256").update(configDir).digest("hex").slice(0, 8)}`; +} + +async function readClaudeTokenFromKeychain(service: string): Promise { + try { + const { stdout } = await execFileAsync("/usr/bin/security", ["find-generic-password", "-s", service, "-w"], { timeout: 10000, maxBuffer: 1024 * 1024 }); + return parseClaudeCredentialToken(stdout); + } catch { return null; } +} + +/** + * Read the credential that a `claude` login performed inside an isolated auth + * home left in the macOS Keychain. Only that home's own suffixed item is + * consulted — never the unsuffixed item that holds the server operator's + * machine-level login. Returns null off macOS. + */ +export async function readIsolatedClaudeKeychainToken(loginHome: string): Promise { + if (process.platform !== "darwin") return null; + return readClaudeTokenFromKeychain(isolatedKeychainService(loginHome)); +} + export async function readClaudeToken(options: { allowKeychain?: boolean } = {}): Promise { const configDir = claudeConfigDir(); for (const filename of [".credentials.json", "credentials.json"]) { const token = await readClaudeTokenFromFile(path.join(configDir, filename)); if (token) return token; } + if (process.platform !== "darwin") return null; + // A custom auth home owns exactly one Keychain item: the suffixed one the + // CLI created for that directory. It must never fall through to the + // unsuffixed item, which belongs to a different account. + if (process.env.CLAUDE_CONFIG_DIR?.trim()) { + return readClaudeTokenFromKeychain(isolatedKeychainService(configDir)); + } // Only an explicit local-account import may consult the user's Keychain. - // A custom auth home must never fall through to a different account. - if (options.allowKeychain && process.platform === "darwin" && !process.env.CLAUDE_CONFIG_DIR?.trim()) { - try { - const { stdout } = await execFileAsync("/usr/bin/security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { timeout: 10000, maxBuffer: 1024 * 1024 }); - return parseClaudeCredentialToken(stdout); - } catch { return null; } + if (options.allowKeychain) { + return readClaudeTokenFromKeychain("Claude Code-credentials"); } return null; } diff --git a/server/src/__tests__/local-ai-credentials.test.ts b/server/src/__tests__/local-ai-credentials.test.ts index 642ac5e7ae..be138c306c 100644 --- a/server/src/__tests__/local-ai-credentials.test.ts +++ b/server/src/__tests__/local-ai-credentials.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js"; -const mocks = vi.hoisted(() => ({ claude: vi.fn(), claudeQuota: vi.fn(), codex: vi.fn(), codexQuota: vi.fn(), readFile: vi.fn(), credentialFile: vi.fn() })); -vi.mock("@paperclipai/adapter-claude-local/server", () => ({ readClaudeToken: mocks.claude, fetchClaudeQuota: mocks.claudeQuota })); +const mocks = vi.hoisted(() => ({ claude: vi.fn(), claudeIsolatedKeychain: vi.fn(), claudeQuota: vi.fn(), codex: vi.fn(), codexQuota: vi.fn(), readFile: vi.fn(), credentialFile: vi.fn() })); +vi.mock("@paperclipai/adapter-claude-local/server", () => ({ readClaudeToken: mocks.claude, readIsolatedClaudeKeychainToken: mocks.claudeIsolatedKeychain, fetchClaudeQuota: mocks.claudeQuota })); vi.mock("@paperclipai/adapter-codex-local/server", () => ({ readCodexAuthInfo: mocks.codex, fetchCodexQuota: mocks.codexQuota })); vi.mock("../services/local-ai-credential-file.js", () => ({ readLocalAiCredentialFile: mocks.credentialFile })); vi.mock("node:fs/promises", () => ({ default: { readFile: mocks.readFile } })); @@ -16,12 +16,31 @@ describe("explicit local subscription import", () => { }); it("does not fall back to ambient Claude auth when an isolated login is absent or invalid", async () => { mocks.claude.mockResolvedValue("server-operator-token"); + mocks.claudeIsolatedKeychain.mockResolvedValue(null); mocks.credentialFile.mockRejectedValue(new Error("No file")); await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown"); mocks.credentialFile.mockResolvedValue("malformed"); await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown"); expect(mocks.claude).not.toHaveBeenCalled(); expect(mocks.claudeQuota).not.toHaveBeenCalled(); + expect(mocks.claudeIsolatedKeychain).toHaveBeenCalledWith("/isolated/claude"); + }); + it("verifies a macOS isolated login from the home's suffixed Keychain item when no credentials file exists", async () => { + // Claude Code on macOS stores an isolated login in the auth home's own + // Keychain item, not a credentials file — the live onboarding failure + // this covers. The ambient reader must stay untouched. + mocks.credentialFile.mockRejectedValue(new Error("No file")); + mocks.claudeIsolatedKeychain.mockResolvedValue("isolated-keychain-claude"); + await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("isolated-keychain-claude"); + expect(mocks.claudeIsolatedKeychain).toHaveBeenCalledWith("/isolated/claude"); + expect(mocks.claudeQuota).toHaveBeenCalledWith("isolated-keychain-claude"); + expect(mocks.claude).not.toHaveBeenCalled(); + }); + it("prefers the credentials file over the Keychain for an isolated login", async () => { + mocks.credentialFile.mockResolvedValue(JSON.stringify({ claudeAiOauth: { accessToken: "file-token" } })); + mocks.claudeIsolatedKeychain.mockResolvedValue("keychain-token"); + await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("file-token"); + expect(mocks.claudeIsolatedKeychain).not.toHaveBeenCalled(); }); it("tries the alternate Claude filename after malformed JSON", async () => { mocks.credentialFile.mockResolvedValueOnce("malformed").mockResolvedValueOnce(JSON.stringify({ claudeAiOauth: { accessToken: "alternate-token" } })); diff --git a/server/src/services/local-ai-credentials.ts b/server/src/services/local-ai-credentials.ts index ece03e792e..88466b5398 100644 --- a/server/src/services/local-ai-credentials.ts +++ b/server/src/services/local-ai-credentials.ts @@ -1,7 +1,7 @@ import { readLocalAiCredentialFile } from "./local-ai-credential-file.js"; import fs from "node:fs/promises"; import path from "node:path"; -import { readClaudeToken, fetchClaudeQuota } from "@paperclipai/adapter-claude-local/server"; +import { readClaudeToken, readIsolatedClaudeKeychainToken, fetchClaudeQuota } from "@paperclipai/adapter-claude-local/server"; import { readCodexAuthInfo, fetchCodexQuota } from "@paperclipai/adapter-codex-local/server"; import { parseGrokAuthPayload, hasUsableGrokAuthValue } from "@paperclipai/adapter-grok-local/server"; import type { AiProvider } from "@paperclipai/shared"; @@ -26,6 +26,10 @@ export async function readVerifiedLocalAiCredential(provider: AiProvider, loginH const value = parsed?.claudeAiOauth?.accessToken; if (typeof value === "string" && value.length) { token = value; break; } } + // On macOS the CLI stores the isolated login in the auth home's own + // suffixed Keychain item rather than a credentials file. The helper + // never consults the unsuffixed operator item. + if (!token) token = await readIsolatedClaudeKeychainToken(loginHome); } else { token = await readClaudeToken({ allowKeychain: true }); }