mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
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:
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" } }));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user