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 {