From dcb04a8062f8e7e474cc1cd2d60b76e1b6249e5e Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 16 Sep 2026 11:45:28 -0700 Subject: [PATCH] fix(claude-local): read a macOS isolated login from its suffixed Keychain item (#13519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Connecting a Claude subscription during onboarding uses an isolated login: the wizard points `claude` at a per-connection `CLAUDE_CONFIG_DIR` and then verifies the credential before saving the connection > - The verifier reads `.credentials.json` from that directory — but on macOS, Claude Code does not write a credentials file at all: it stores the OAuth credential for a custom config dir in a per-directory Keychain item named `Claude Code-credentials-` > - So on macOS the connect step can never verify a successful sign-in, and onboarding dead-ends at "Could not verify the local subscription" (Linux works because the CLI falls back to writing the file there, which is why the Docker-based smokes pass) > - This pull request teaches the credential readers to consult the login home's own suffixed Keychain item when the file is missing > - The benefit is that macOS self-hosted users can connect a Claude subscription during onboarding, while the standing isolation invariant — an isolated login must never fall through to the machine-level operator login — is preserved, because only the per-directory suffixed item is ever read ## Linked Issues or Issue Description No existing issue. Description follows the bug-report template: **What happened?** On macOS, connecting a Claude subscription during onboarding (or from Connections) always fails with "Could not verify the local subscription. Run the sign-in command shown for this connection, finish signing in, then try Connect again" — even after `claude auth login` completes successfully in the isolated `CLAUDE_CONFIG_DIR`. **Expected behavior** After finishing the browser sign-in for the printed command, clicking Connect verifies the subscription and saves the connection. **Steps to reproduce** 1. On macOS, run onboarding on a fresh instance and reach "Connect a model" → Claude → Subscription. 2. Run the printed `export CLAUDE_CONFIG_DIR=… && claude auth login` command in a terminal on the same machine and complete the browser sign-in. 3. Return and click Connect. Verification fails every time. Inspecting the isolated directory shows `.claude.json` with a fully populated `oauthAccount` but no `.credentials.json`; `security find-generic-password -s "Claude Code-credentials-"` shows the credential landed in the Keychain, where the verifier never looks. **Paperclip version or commit** Reproduced on `2026.915.0-canary.11` (`dffc2b3ca`) with Claude Code 2.1.231. **Deployment mode** Self-hosted, authenticated instance on macOS. **Installation method** `npx paperclipai onboard` (also affects any macOS install; Linux is unaffected). ## What Changed - `packages/adapters/claude-local/src/server/quota.ts`: - New exported helper `readIsolatedClaudeKeychainToken(loginHome)` — computes the suffixed service name (`Claude Code-credentials-` + first 8 hex chars of `sha256(loginHome)`) and reads only that item via `/usr/bin/security`; returns null off macOS - `readClaudeToken` with a custom `CLAUDE_CONFIG_DIR` now consults that directory's suffixed item after the file reads miss (previously it refused the Keychain entirely for custom homes). The unsuffixed operator item is still gated behind the explicit `allowKeychain` opt-in with no custom home, unchanged - `server/src/services/local-ai-credentials.ts`: for anthropic isolated logins, fall back to the suffixed Keychain item after the hardened credentials-file reads miss. The file path is untouched and still preferred; the hardened file reader (`readLocalAiCredentialFile` with its uid/mode/symlink checks) is not bypassed - Tests: adapter keychain suite extended (suffixed lookup for custom homes, no unsuffixed fallback when the suffixed item is absent, off-macOS null); server verifier suite extended (keychain fallback when the file is missing, file preferred over keychain, absent-login failure still never touches the ambient reader) Security note: the suffix binds each Keychain item to exactly one auth home, so reading it can only surface the login performed inside that home. The account-isolation invariant the old code enforced by refusing the Keychain outright ("never substitute the server operator's login for a user's isolated login") is preserved — the unsuffixed item is never consulted for an isolated login, and a new test pins that. The suffix derivation was confirmed against a live login on macOS: a real `claude auth login` into an isolated home left no credentials file, wrote the full `oauthAccount` to `.claude.json`, and created a Keychain item whose suffix equals the first 8 sha256 hex chars of the exact `CLAUDE_CONFIG_DIR` string; reading it back with the same `security` invocation returned the live token, which the new code path then verifies via the existing quota probe. ## Verification - `pnpm exec vitest run src/server/quota-keychain.test.ts` (claude-local): 10 tests pass; full claude-local suite: 287 passed, 1 skipped - `pnpm exec vitest run src/__tests__/local-ai-credentials.test.ts` (server): 11 tests pass - Reverting only the verifier change makes the two new server tests fail — the suite reproduces the live bug - End-to-end on macOS: a dev server built from this branch, fresh data dir, full onboarding walk with a real `claude auth login` into the printed isolated dir — the connect step verifies and saves the connection ## Risks - Low. The change is additive and fail-closed: when the suffixed item is absent (Linux, older Claude Code versions, no login performed), behavior is byte-identical to today — the file reads run first and the failure message is unchanged - The `security` call runs with the existing 10s timeout and swallowed errors, matching the established unsuffixed-item code path - No migrations, no API surface changes ## Model Used Claude Fable 5 (`claude-fable-5`), extended thinking with tool use (Claude Code). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../adapters/claude-local/src/server/index.ts | 1 + .../src/server/quota-keychain.test.ts | 32 +++++++++++++- .../adapters/claude-local/src/server/quota.ts | 44 ++++++++++++++++--- .../__tests__/local-ai-credentials.test.ts | 23 +++++++++- server/src/services/local-ai-credentials.ts | 6 ++- 5 files changed, 95 insertions(+), 11 deletions(-) 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 }); }