From 4b1e5b79435c2eac9c333ba53b187931cc205010 Mon Sep 17 00:00:00 2001 From: stain lu <109842185+stainlu@users.noreply.github.com> Date: Sun, 31 May 2026 18:19:42 +0800 Subject: [PATCH] fix(cli): stabilize claude auth epochs on token rotation Stabilizes Claude CLI reusable sessions when Claude token rotation causes transient token-shaped credential reads. Local Claude CLI OAuth and token credential encodings now share the same identity-only auth-epoch, while ref-backed token auth profiles ignore refreshed token material and plaintext token profiles remain epoch-sensitive on manual token replacement. Fixes #74312. Proof: focused local Vitest, autoreview, Testbox-through-Crabbox tbx_01ksyrcknbt743x32x6k1s95qw, and GitHub CI run 26709864094 all passed. Co-authored-by: stainlu --- src/agents/cli-auth-epoch.test.ts | 207 +++++++++++++++++++++++++++++- src/agents/cli-auth-epoch.ts | 35 ++++- 2 files changed, 233 insertions(+), 9 deletions(-) diff --git a/src/agents/cli-auth-epoch.test.ts b/src/agents/cli-auth-epoch.test.ts index 29d7955b2c1f..9ee631db5c7e 100644 --- a/src/agents/cli-auth-epoch.test.ts +++ b/src/agents/cli-auth-epoch.test.ts @@ -30,7 +30,12 @@ describe("resolveCliAuthEpoch", () => { }), }); - await expect(resolveCliAuthEpoch({ provider: "claude-cli" })).resolves.toBeUndefined(); + await expect( + resolveCliAuthEpoch({ + provider: "claude-cli", + authProfileId: "anthropic:work", + }), + ).resolves.toBeUndefined(); await expect( resolveCliAuthEpoch({ provider: "google-gemini-cli", @@ -63,7 +68,7 @@ describe("resolveCliAuthEpoch", () => { expect(second).toBe(first); }); - it("changes claude cli token epochs when the static token changes", async () => { + it("keeps claude cli token epochs stable across token rotation", async () => { let token = "token-a"; setCliAuthEpochTestDeps({ readClaudeCliCredentialsCached: () => ({ @@ -79,8 +84,65 @@ describe("resolveCliAuthEpoch", () => { const second = await resolveCliAuthEpoch({ provider: "claude-cli" }); expectCliAuthEpoch(first); - expectCliAuthEpoch(second); - expect(second).not.toBe(first); + // Static-token rotation is an authorized credential refresh, not an + // identity change. After #74312 the hash is identity-only for both + // OAuth and token branches, so rotation does not invalidate the epoch. + expect(second).toBe(first); + }); + + it("matches claude cli token and oauth epochs so partial keychain reads do not flip", async () => { + setCliAuthEpochTestDeps({ + readClaudeCliCredentialsCached: () => ({ + type: "oauth", + provider: "anthropic", + access: "access", + refresh: "refresh", + expires: 1, + }), + }); + const oauthEpoch = await resolveCliAuthEpoch({ provider: "claude-cli" }); + + setCliAuthEpochTestDeps({ + readClaudeCliCredentialsCached: () => ({ + type: "token", + provider: "anthropic", + token: "access", + expires: 1, + }), + }); + const tokenEpoch = await resolveCliAuthEpoch({ provider: "claude-cli" }); + + expectCliAuthEpoch(oauthEpoch); + expectCliAuthEpoch(tokenEpoch); + // The macOS Claude keychain rewrite is not atomic. A transient read with + // `refreshToken` missing falls into the parser's token branch; the OAuth + // and token encodings must produce the same hash so the auth-epoch does + // not flip during a token rotation. Regression for #74312. + expect(tokenEpoch).toBe(oauthEpoch); + }); + + it("drops the claude cli epoch when the credential read is absent", async () => { + setCliAuthEpochTestDeps({ + readClaudeCliCredentialsCached: () => ({ + type: "oauth", + provider: "anthropic", + access: "access", + refresh: "refresh", + expires: 1, + }), + }); + const successfulRead = await resolveCliAuthEpoch({ provider: "claude-cli" }); + + // A null read can mean the credential was removed or logout left no + // readable auth state. Keep that absence visible so reusable sessions do + // not survive a true auth-state loss. + setCliAuthEpochTestDeps({ + readClaudeCliCredentialsCached: () => null, + }); + const nullRead = await resolveCliAuthEpoch({ provider: "claude-cli" }); + + expectCliAuthEpoch(successfulRead); + expect(nullRead).toBeUndefined(); }); it("keeps gemini cli oauth epochs stable through token rotation and flips on account change", async () => { @@ -271,6 +333,143 @@ describe("resolveCliAuthEpoch", () => { expect(second).not.toBe(first); }); + it("keeps token auth-profile epochs stable across credential.token rotation when identity is present", async () => { + let store: AuthProfileStore = { + version: 1, + profiles: { + "anthropic:work": { + type: "token", + provider: "anthropic", + token: "token-a", + tokenRef: { source: "env", provider: "default", id: "ANTHROPIC_TOKEN" }, + email: "user@example.com", + displayName: "Work", + }, + }, + }; + setCliAuthEpochTestDeps({ + readGeminiCliCredentialsCached: () => null, + loadAuthProfileStoreForRuntime: () => store, + }); + + const first = await resolveCliAuthEpoch({ + provider: "google-gemini-cli", + authProfileId: "anthropic:work", + }); + store = { + version: 1, + profiles: { + "anthropic:work": { + type: "token", + provider: "anthropic", + token: "token-b", + tokenRef: { source: "env", provider: "default", id: "ANTHROPIC_TOKEN" }, + email: "user@example.com", + displayName: "Work", + }, + }, + }; + const second = await resolveCliAuthEpoch({ + provider: "google-gemini-cli", + authProfileId: "anthropic:work", + }); + + expectCliAuthEpoch(first); + // Ref-backed token rotation must not flip the epoch; the token material is + // only a refreshable secret when the profile has a stable secret owner. + expect(second).toBe(first); + }); + + it("changes token auth-profile epochs when token-only credentials change", async () => { + let store: AuthProfileStore = { + version: 1, + profiles: { + "anthropic:token-only": { + type: "token", + provider: "anthropic", + token: "token-a", + displayName: "Manual token", + }, + }, + }; + setCliAuthEpochTestDeps({ + readGeminiCliCredentialsCached: () => null, + loadAuthProfileStoreForRuntime: () => store, + }); + + const first = await resolveCliAuthEpoch({ + provider: "google-gemini-cli", + authProfileId: "anthropic:token-only", + }); + store = { + version: 1, + profiles: { + "anthropic:token-only": { + type: "token", + provider: "anthropic", + token: "token-b", + displayName: "Manual token", + }, + }, + }; + const second = await resolveCliAuthEpoch({ + provider: "google-gemini-cli", + authProfileId: "anthropic:token-only", + }); + + expectCliAuthEpoch(first); + expectCliAuthEpoch(second); + // Token-only profiles have no stable account/ref identity, so the token + // remains the session owner and manual replacement still invalidates. + expect(second).not.toBe(first); + }); + + it("changes token auth-profile epochs when the email identity changes", async () => { + let store: AuthProfileStore = { + version: 1, + profiles: { + "anthropic:work": { + type: "token", + provider: "anthropic", + token: "token", + email: "user-a@example.com", + displayName: "Work", + }, + }, + }; + setCliAuthEpochTestDeps({ + readGeminiCliCredentialsCached: () => null, + loadAuthProfileStoreForRuntime: () => store, + }); + + const first = await resolveCliAuthEpoch({ + provider: "google-gemini-cli", + authProfileId: "anthropic:work", + }); + store = { + version: 1, + profiles: { + "anthropic:work": { + type: "token", + provider: "anthropic", + token: "token", + email: "user-b@example.com", + displayName: "Work", + }, + }, + }; + const second = await resolveCliAuthEpoch({ + provider: "google-gemini-cli", + authProfileId: "anthropic:work", + }); + + expectCliAuthEpoch(first); + expectCliAuthEpoch(second); + // A real account switch on a static-token profile must still invalidate + // the epoch so reusable CLI sessions don't outlive the identity change. + expect(second).not.toBe(first); + }); + it("changes oauth auth-profile epochs when the account identity changes", async () => { let store: AuthProfileStore = { version: 1, diff --git a/src/agents/cli-auth-epoch.ts b/src/agents/cli-auth-epoch.ts index 86e41c3e670f..4967071ca6c1 100644 --- a/src/agents/cli-auth-epoch.ts +++ b/src/agents/cli-auth-epoch.ts @@ -27,7 +27,7 @@ const defaultCliAuthEpochDeps: CliAuthEpochDeps = { const cliAuthEpochDeps: CliAuthEpochDeps = { ...defaultCliAuthEpochDeps }; -export const CLI_AUTH_EPOCH_VERSION = 4; +export const CLI_AUTH_EPOCH_VERSION = 5; export function setCliAuthEpochTestDeps(overrides: Partial): void { Object.assign(cliAuthEpochDeps, overrides); @@ -69,10 +69,19 @@ function encodeOAuthIdentity(credential: { } function encodeClaudeCredential(credential: ClaudeCliCredential): string { - if (credential.type === "oauth") { - return encodeOAuthIdentity(credential); - } - return JSON.stringify(["token", credential.provider, credential.token]); + // Identity-only hashing for both OAuth and token Claude CLI credentials. + // The Claude CLI keychain rewrite is not atomic: a token rotation can + // briefly produce a partial read where `refreshToken` is missing, and the + // parser falls back to a token-shaped credential. With the previous + // token-inclusive hash, that transient race flipped the auth-epoch and + // forced a session reset on every rotation. Routing both branches through + // `encodeOAuthIdentity` collapses partial reads and rotations onto the + // same provider-keyed identity hash, while a real account switch would + // still surface as different identity fields. Fixes #74312. + return encodeOAuthIdentity({ + type: "oauth", + provider: credential.provider, + }); } function encodeCodexCredential(credential: CodexCliCredential): string { @@ -103,6 +112,19 @@ function encodeAuthProfileCredential(credential: AuthProfileCredential): string encodeUnknown(credential.metadata), ]); case "token": + if (credential.tokenRef !== undefined) { + // When a token profile has a stable account/ref identity, token + // material is a refreshable secret rather than the session owner. + // Plain token-only profiles still hash the token below so manual token + // replacement keeps invalidating reusable sessions. + return JSON.stringify([ + "token-identity", + credential.provider, + encodeUnknown(credential.tokenRef), + credential.email ?? null, + credential.displayName ?? null, + ]); + } return JSON.stringify([ "token", credential.provider, @@ -143,6 +165,9 @@ function getLocalCliCredentialFingerprint(provider: string): string | undefined ttlMs: 5000, allowKeychainPrompt: false, }); + // Keep true credential absence absent so logout/removal invalidates + // reusable sessions. The 5s credential cache still masks transient + // null reads immediately after a successful read. return credential ? hashCliAuthEpochPart(encodeClaudeCredential(credential)) : undefined; } case "codex-cli": {