Skip to content

Commit dcb04a8

Browse files
authored
fix(claude-local): read a macOS isolated login from its suffixed Keychain item (#13519)
## 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-<first 8 hex chars of sha256(dir)>` > - 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-<suffix>"` 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
1 parent d08abcb commit dcb04a8

5 files changed

Lines changed: 95 additions & 11 deletions

File tree

packages/adapters/claude-local/src/server/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export {
2020
getQuotaWindows,
2121
readClaudeAuthStatus,
2222
readClaudeToken,
23+
readIsolatedClaudeKeychainToken,
2324
fetchClaudeQuota,
2425
fetchClaudeCliQuota,
2526
captureClaudeCliUsageText,

packages/adapters/claude-local/src/server/quota-keychain.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { createHash } from "node:crypto";
12
import { afterEach, describe, expect, it, vi } from "vitest";
2-
import { readClaudeToken } from "./quota.js";
3+
import { readClaudeToken, readIsolatedClaudeKeychainToken } from "./quota.js";
4+
5+
const suffixedService = (dir: string) => `Claude Code-credentials-${createHash("sha256").update(dir).digest("hex").slice(0, 8)}`;
36
const mocks = vi.hoisted(() => ({ read: vi.fn(), exec: vi.fn() }));
47
vi.mock("node:fs/promises", () => ({ default: { readFile: mocks.read } }));
58
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", () => {
1821
await expect(readClaudeToken({ allowKeychain: true })).resolves.toBe("fixture");
1922
expect(mocks.exec).toHaveBeenCalledWith("/usr/bin/security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], expect.any(Object));
2023
});
21-
it("never substitutes Keychain credentials for a custom auth home", async () => {
24+
it("reads only the custom auth home's own suffixed Keychain item", async () => {
25+
// Claude Code stores a custom CLAUDE_CONFIG_DIR login in a per-directory
26+
// suffixed item; the unsuffixed item belongs to a different account and
27+
// must never be substituted.
2228
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
2329
vi.stubEnv("CLAUDE_CONFIG_DIR", "/isolated/auth");
2430
mocks.read.mockRejectedValue(new Error("missing"));
31+
mocks.exec.mockResolvedValue({ stdout: JSON.stringify({ claudeAiOauth: { accessToken: "isolated" } }) });
32+
await expect(readClaudeToken({ allowKeychain: true })).resolves.toBe("isolated");
33+
expect(mocks.exec).toHaveBeenCalledTimes(1);
34+
expect(mocks.exec).toHaveBeenCalledWith("/usr/bin/security", ["find-generic-password", "-s", suffixedService("/isolated/auth"), "-w"], expect.any(Object));
35+
});
36+
it("returns null for a custom auth home whose suffixed item is absent, without touching the unsuffixed item", async () => {
37+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
38+
vi.stubEnv("CLAUDE_CONFIG_DIR", "/isolated/auth");
39+
mocks.read.mockRejectedValue(new Error("missing"));
40+
mocks.exec.mockRejectedValue(new Error("The specified item could not be found in the keychain."));
2541
await expect(readClaudeToken({ allowKeychain: true })).resolves.toBeNull();
42+
expect(mocks.exec).toHaveBeenCalledTimes(1);
43+
expect(mocks.exec).toHaveBeenCalledWith("/usr/bin/security", ["find-generic-password", "-s", suffixedService("/isolated/auth"), "-w"], expect.any(Object));
44+
});
45+
it("readIsolatedClaudeKeychainToken reads the login home's suffixed item on macOS", async () => {
46+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
47+
mocks.exec.mockResolvedValue({ stdout: JSON.stringify({ claudeAiOauth: { accessToken: "isolated-keychain" } }) });
48+
await expect(readIsolatedClaudeKeychainToken("/data/ai-local-logins/abc")).resolves.toBe("isolated-keychain");
49+
expect(mocks.exec).toHaveBeenCalledWith("/usr/bin/security", ["find-generic-password", "-s", suffixedService("/data/ai-local-logins/abc"), "-w"], expect.any(Object));
50+
});
51+
it("readIsolatedClaudeKeychainToken returns null off macOS", async () => {
52+
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
53+
await expect(readIsolatedClaudeKeychainToken("/data/ai-local-logins/abc")).resolves.toBeNull();
2654
expect(mocks.exec).not.toHaveBeenCalled();
2755
});
2856
it("skips an expired credentials file and falls through to Keychain", async () => {

packages/adapters/claude-local/src/server/quota.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { execFile } from "node:child_process";
2+
import { createHash } from "node:crypto";
23
import fs from "node:fs/promises";
34
import os from "node:os";
45
import path from "node:path";
@@ -159,19 +160,50 @@ function describeClaudeSubscriptionAuth(status: ClaudeAuthStatus | null): string
159160
: "Claude is logged in via claude.ai";
160161
}
161162

163+
// Claude Code on macOS stores the OAuth credential for a custom
164+
// CLAUDE_CONFIG_DIR in a per-directory Keychain item named
165+
// "Claude Code-credentials-<first 8 hex chars of sha256(dir)>" instead of a
166+
// credentials file in the directory. The suffix binds the item to exactly one
167+
// auth home, so reading it can only ever surface the login performed inside
168+
// that home — none of the cross-account risk of the unsuffixed operator item.
169+
function isolatedKeychainService(configDir: string): string {
170+
return `Claude Code-credentials-${createHash("sha256").update(configDir).digest("hex").slice(0, 8)}`;
171+
}
172+
173+
async function readClaudeTokenFromKeychain(service: string): Promise<string | null> {
174+
try {
175+
const { stdout } = await execFileAsync("/usr/bin/security", ["find-generic-password", "-s", service, "-w"], { timeout: 10000, maxBuffer: 1024 * 1024 });
176+
return parseClaudeCredentialToken(stdout);
177+
} catch { return null; }
178+
}
179+
180+
/**
181+
* Read the credential that a `claude` login performed inside an isolated auth
182+
* home left in the macOS Keychain. Only that home's own suffixed item is
183+
* consulted — never the unsuffixed item that holds the server operator's
184+
* machine-level login. Returns null off macOS.
185+
*/
186+
export async function readIsolatedClaudeKeychainToken(loginHome: string): Promise<string | null> {
187+
if (process.platform !== "darwin") return null;
188+
return readClaudeTokenFromKeychain(isolatedKeychainService(loginHome));
189+
}
190+
162191
export async function readClaudeToken(options: { allowKeychain?: boolean } = {}): Promise<string | null> {
163192
const configDir = claudeConfigDir();
164193
for (const filename of [".credentials.json", "credentials.json"]) {
165194
const token = await readClaudeTokenFromFile(path.join(configDir, filename));
166195
if (token) return token;
167196
}
197+
if (process.platform !== "darwin") return null;
198+
// A custom auth home owns exactly one Keychain item: the suffixed one the
199+
// CLI created for that directory. It must never fall through to the
200+
// unsuffixed item, which belongs to a different account.
201+
if (process.env.CLAUDE_CONFIG_DIR?.trim()) {
202+
return readClaudeTokenFromKeychain(isolatedKeychainService(configDir));
203+
}
168204
// Only an explicit local-account import may consult the user's Keychain.
169-
// A custom auth home must never fall through to a different account.
170-
if (options.allowKeychain && process.platform === "darwin" && !process.env.CLAUDE_CONFIG_DIR?.trim()) {
171-
try {
172-
const { stdout } = await execFileAsync("/usr/bin/security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { timeout: 10000, maxBuffer: 1024 * 1024 });
173-
return parseClaudeCredentialToken(stdout);
174-
} catch { return null; }
205+
if (options.allowKeychain) {
206+
return readClaudeTokenFromKeychain("Claude Code-credentials");
175207
}
176208
return null;
177209
}

server/src/__tests__/local-ai-credentials.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
22
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
3-
const mocks = vi.hoisted(() => ({ claude: vi.fn(), claudeQuota: vi.fn(), codex: vi.fn(), codexQuota: vi.fn(), readFile: vi.fn(), credentialFile: vi.fn() }));
4-
vi.mock("@paperclipai/adapter-claude-local/server", () => ({ readClaudeToken: mocks.claude, fetchClaudeQuota: mocks.claudeQuota }));
3+
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() }));
4+
vi.mock("@paperclipai/adapter-claude-local/server", () => ({ readClaudeToken: mocks.claude, readIsolatedClaudeKeychainToken: mocks.claudeIsolatedKeychain, fetchClaudeQuota: mocks.claudeQuota }));
55
vi.mock("@paperclipai/adapter-codex-local/server", () => ({ readCodexAuthInfo: mocks.codex, fetchCodexQuota: mocks.codexQuota }));
66
vi.mock("../services/local-ai-credential-file.js", () => ({ readLocalAiCredentialFile: mocks.credentialFile }));
77
vi.mock("node:fs/promises", () => ({ default: { readFile: mocks.readFile } }));
@@ -16,12 +16,31 @@ describe("explicit local subscription import", () => {
1616
});
1717
it("does not fall back to ambient Claude auth when an isolated login is absent or invalid", async () => {
1818
mocks.claude.mockResolvedValue("server-operator-token");
19+
mocks.claudeIsolatedKeychain.mockResolvedValue(null);
1920
mocks.credentialFile.mockRejectedValue(new Error("No file"));
2021
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown");
2122
mocks.credentialFile.mockResolvedValue("malformed");
2223
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown");
2324
expect(mocks.claude).not.toHaveBeenCalled();
2425
expect(mocks.claudeQuota).not.toHaveBeenCalled();
26+
expect(mocks.claudeIsolatedKeychain).toHaveBeenCalledWith("/isolated/claude");
27+
});
28+
it("verifies a macOS isolated login from the home's suffixed Keychain item when no credentials file exists", async () => {
29+
// Claude Code on macOS stores an isolated login in the auth home's own
30+
// Keychain item, not a credentials file — the live onboarding failure
31+
// this covers. The ambient reader must stay untouched.
32+
mocks.credentialFile.mockRejectedValue(new Error("No file"));
33+
mocks.claudeIsolatedKeychain.mockResolvedValue("isolated-keychain-claude");
34+
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("isolated-keychain-claude");
35+
expect(mocks.claudeIsolatedKeychain).toHaveBeenCalledWith("/isolated/claude");
36+
expect(mocks.claudeQuota).toHaveBeenCalledWith("isolated-keychain-claude");
37+
expect(mocks.claude).not.toHaveBeenCalled();
38+
});
39+
it("prefers the credentials file over the Keychain for an isolated login", async () => {
40+
mocks.credentialFile.mockResolvedValue(JSON.stringify({ claudeAiOauth: { accessToken: "file-token" } }));
41+
mocks.claudeIsolatedKeychain.mockResolvedValue("keychain-token");
42+
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("file-token");
43+
expect(mocks.claudeIsolatedKeychain).not.toHaveBeenCalled();
2544
});
2645
it("tries the alternate Claude filename after malformed JSON", async () => {
2746
mocks.credentialFile.mockResolvedValueOnce("malformed").mockResolvedValueOnce(JSON.stringify({ claudeAiOauth: { accessToken: "alternate-token" } }));

server/src/services/local-ai-credentials.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { readLocalAiCredentialFile } from "./local-ai-credential-file.js";
22
import fs from "node:fs/promises";
33
import path from "node:path";
4-
import { readClaudeToken, fetchClaudeQuota } from "@paperclipai/adapter-claude-local/server";
4+
import { readClaudeToken, readIsolatedClaudeKeychainToken, fetchClaudeQuota } from "@paperclipai/adapter-claude-local/server";
55
import { readCodexAuthInfo, fetchCodexQuota } from "@paperclipai/adapter-codex-local/server";
66
import { parseGrokAuthPayload, hasUsableGrokAuthValue } from "@paperclipai/adapter-grok-local/server";
77
import type { AiProvider } from "@paperclipai/shared";
@@ -26,6 +26,10 @@ export async function readVerifiedLocalAiCredential(provider: AiProvider, loginH
2626
const value = parsed?.claudeAiOauth?.accessToken;
2727
if (typeof value === "string" && value.length) { token = value; break; }
2828
}
29+
// On macOS the CLI stores the isolated login in the auth home's own
30+
// suffixed Keychain item rather than a credentials file. The helper
31+
// never consults the unsuffixed operator item.
32+
if (!token) token = await readIsolatedClaudeKeychainToken(loginHome);
2933
} else {
3034
token = await readClaudeToken({ allowKeychain: true });
3135
}

0 commit comments

Comments
 (0)