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
This commit is contained in:
Devin Foley authored and GitHub committed 2026-09-16 11:45:28 -07:00
1 parent d08abcba15
commit dcb04a8062
5 files changed
+95 -11

No files matched your search

@@ -20,6 +20,7 @@ export {
getQuotaWindows,
readClaudeAuthStatus,
readClaudeToken,
readIsolatedClaudeKeychainToken,
fetchClaudeQuota,
fetchClaudeCliQuota,
captureClaudeCliUsageText,
@@ -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 () => {
@@ -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-<first 8 hex chars of sha256(dir)>" 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<string | null> {
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<string | null> {
if (process.platform !== "darwin") return null;
return readClaudeTokenFromKeychain(isolatedKeychainService(loginHome));
}
export async function readClaudeToken(options: { allowKeychain?: boolean } = {}): Promise<string | null> {
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;
}
@@ -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" } }));
+5 -1
View File
@@ -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 });
}