From dffc2b3ca1b9e88fa21cb17493083e682dffd1ca Mon Sep 17 00:00:00 2001 From: scotttong Date: Tue, 15 Sep 2026 16:48:07 -0700 Subject: [PATCH] fix(claude-local): skip expired credentials file when reading the Claude token (#13505) 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 > - Agents on the Claude Local adapter run on a local Claude Code subscription. A user connects that subscription from Agent → Harness / Runtime → "Connect account". > - The server verifies the login with `readClaudeToken` in `packages/adapters/claude-local/src/server/quota.ts`. It reads `~/.claude/.credentials.json` first and consults the macOS Keychain only when no file is present. > - On macOS the Claude CLI refreshes the Keychain item, not the file. A leftover credentials file keeps an expired token forever, and the reader ignores `claudeAiOauth.expiresAt`. > - The stale file shadows the live Keychain login. The usage check fails and the user sees "Could not verify the local subscription" although `claude auth status` reports a valid login. Running `claude auth login` again does not help. > - This pull request skips a credentials file whose token has expired, so the reader falls through to the Keychain or returns null. > - The benefit is that a valid local Claude login connects on the first try, and a dead token is never sent upstream. ## Linked Issues or Issue Description No public issue exists for this bug. Description follows `bug_report.yml`. ### What happened? Agent → Harness / Runtime → "Connect account" → Claude (Subscription) → Connect failed with: > Could not verify the local subscription. Run claude auth login in a terminal on the machine running Paperclip, then try Connect again. `claude auth status` on the same machine reported `loggedIn: true`, `authMethod: claude.ai`, `subscriptionType: max`. The Keychain item `Claude Code-credentials` held a fresh token. `POST /api/companies/:id/ai-connections/local/check` returned `{"status":"sign_in_required"}`. A leftover `~/.claude/.credentials.json` (written weeks earlier) held an access token that expired the same day it was written. `readClaudeToken` returned that token. `fetchClaudeQuota` got a non-OK response from `/api/oauth/usage`, and the route threw the generic 422. ### Expected behavior An expired credentials file must not block a valid login. The reader skips the dead token and falls through to the Keychain. Connect succeeds. ### Steps to reproduce 1. On macOS, sign in with `claude auth login` (credentials land in the Keychain). 2. Place a `~/.claude/.credentials.json` with `claudeAiOauth.accessToken` set and `claudeAiOauth.expiresAt` in the past. 3. Open an agent → Harness / Runtime → Connect account → Claude (Subscription) → Connect. 4. Before this change: the "Could not verify the local subscription" error appears. After: the connection is created. ### Agent adapter(s) involved Claude Code (`@paperclipai/adapter-claude-local`) ### Operating system macOS (Keychain-backed credentials). On Linux the file is the live store; an expired file token now returns null instead of a failing request, so the user-facing message is unchanged. ## What Changed - `packages/adapters/claude-local/src/server/quota.ts`: `parseClaudeCredential` now returns the token plus `expiresAt` (epoch ms) when the file records one. `readClaudeTokenFromFile` returns `null` for a token whose `expiresAt` is in the past, so `readClaudeToken` moves on to the next candidate (second file name, then Keychain when `allowKeychain` is set). Files without an `expiresAt` keep the old behavior. `parseClaudeCredentialToken` (used for the Keychain payload) is unchanged in behavior. - `packages/adapters/claude-local/src/server/quota-keychain.test.ts`: three new cases — expired file falls through to Keychain; expired file with no Keychain access returns `null`; a file with no expiry is still accepted. ## Verification - `pnpm --filter @paperclipai/adapter-claude-local exec vitest run src/server/quota-keychain.test.ts` → 7 passed (4 existing + 3 new). - `pnpm --filter @paperclipai/adapter-claude-local exec tsc --noEmit` → clean. - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/local-ai-credentials.test.ts` → 9 passed. - Manual, on the affected machine: with the stale file in place, `POST /api/companies/:id/ai-connections/local/check` (provider `anthropic`, method `subscription`) returned `sign_in_required`; with the stale file removed it returned `ready`. This change makes the first case behave like the second without touching the file. ## Risks - Low risk. The only behavior change is for a credentials file that carries a numeric `expiresAt` in the past. Such a token is already rejected upstream, so the change removes a guaranteed failure rather than a working path. - Clock skew: a machine clock that runs ahead of real time could treat a token as expired slightly early. The fall-through then reads the Keychain (macOS) or returns null, which triggers the same "sign in" message the user already sees for an expired token. - Keychain payloads are not expiry-checked in this PR. The CLI refreshes that item itself, and `getQuotaWindows` already falls back to the CLI `/usage` probe when the OAuth call fails. ## Model Used - Claude — `claude-fable-5-1` (Claude Fable 5.1) via Claude Code, with extended thinking and tool use (shell, file edit). Root cause found by reproducing the server's read path against the local credential file and Keychain. ## 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 - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5.1 --- .../src/server/quota-keychain.test.ts | 20 ++++++++++++++++ .../adapters/claude-local/src/server/quota.ts | 24 ++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) 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 9cc07437d0..145eacc1d5 100644 --- a/packages/adapters/claude-local/src/server/quota-keychain.test.ts +++ b/packages/adapters/claude-local/src/server/quota-keychain.test.ts @@ -25,6 +25,26 @@ describe("explicit Claude Keychain import", () => { await expect(readClaudeToken({ allowKeychain: true })).resolves.toBeNull(); expect(mocks.exec).not.toHaveBeenCalled(); }); + it("skips an expired credentials file and falls through to Keychain", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + vi.stubEnv("CLAUDE_CONFIG_DIR", ""); + mocks.read.mockResolvedValue(JSON.stringify({ claudeAiOauth: { accessToken: "stale", expiresAt: Date.now() - 60_000 } })); + mocks.exec.mockResolvedValue({ stdout: JSON.stringify({ claudeAiOauth: { accessToken: "fresh", expiresAt: Date.now() + 60_000 } }) }); + await expect(readClaudeToken({ allowKeychain: true })).resolves.toBe("fresh"); + expect(mocks.exec).toHaveBeenCalledTimes(1); + }); + it("returns null for an expired credentials file without Keychain access", async () => { + mocks.read.mockResolvedValue(JSON.stringify({ claudeAiOauth: { accessToken: "stale", expiresAt: Date.now() - 60_000 } })); + await expect(readClaudeToken()).resolves.toBeNull(); + expect(mocks.exec).not.toHaveBeenCalled(); + }); + it("still accepts a credentials file that records no expiry", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + vi.stubEnv("CLAUDE_CONFIG_DIR", ""); + mocks.read.mockResolvedValue(JSON.stringify({ claudeAiOauth: { accessToken: "file" } })); + await expect(readClaudeToken({ allowKeychain: true })).resolves.toBe("file"); + expect(mocks.exec).not.toHaveBeenCalled(); + }); it("does not surface a credential-bearing subprocess error", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); vi.stubEnv("CLAUDE_CONFIG_DIR", ""); diff --git a/packages/adapters/claude-local/src/server/quota.ts b/packages/adapters/claude-local/src/server/quota.ts index 9b5a264af8..62241ecb21 100644 --- a/packages/adapters/claude-local/src/server/quota.ts +++ b/packages/adapters/claude-local/src/server/quota.ts @@ -92,10 +92,22 @@ async function readClaudeTokenFromFile(credPath: string): Promise } catch { return null; } - return parseClaudeCredentialToken(raw); + const credential = parseClaudeCredential(raw); + if (!credential) return null; + // On macOS the CLI refreshes the Keychain item, not this file, so a file + // whose token has expired is a stale leftover. Skip it so the caller can + // fall through to a live credential instead of failing with a dead token. + if (credential.expiresAt != null && credential.expiresAt <= Date.now()) return null; + return credential.token; } -function parseClaudeCredentialToken(raw: string): string | null { +interface ClaudeCredential { + token: string; + /** Epoch milliseconds, when the credential file records one. */ + expiresAt: number | null; +} + +function parseClaudeCredential(raw: string): ClaudeCredential | null { let parsed: unknown; try { parsed = JSON.parse(raw); @@ -107,7 +119,13 @@ function parseClaudeCredentialToken(raw: string): string | null { const oauth = obj["claudeAiOauth"]; if (typeof oauth !== "object" || oauth === null) return null; const token = (oauth as Record)["accessToken"]; - return typeof token === "string" && token.length > 0 ? token : null; + if (typeof token !== "string" || token.length === 0) return null; + const expiresAt = (oauth as Record)["expiresAt"]; + return { token, expiresAt: typeof expiresAt === "number" && Number.isFinite(expiresAt) ? expiresAt : null }; +} + +function parseClaudeCredentialToken(raw: string): string | null { + return parseClaudeCredential(raw)?.token ?? null; } interface ClaudeAuthStatus {