fix(cli): key gemini cli auth epoch on google account identity#71076
Conversation
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟡 Unverified JWT claim decoding used for session/account binding (Gemini CLI credentials)
Description
This identity material is then used by Impact:
Vulnerable code: const payloadRaw = Buffer.from(parts[1], "base64url").toString("utf8");
const payload = JSON.parse(payloadRaw) as { sub?: unknown; email?: unknown };
...
const identity = typeof idTokenRaw === "string" && idTokenRaw
? decodeJwtIdentityClaims(idTokenRaw)
: {};RecommendationDo not trust unverified JWT payloads for identity/session binding. Preferred fixes (choose one):
Example (conceptual) using an ID-token verifier: import { createRemoteJWKSet, jwtVerify } from 'jose';
const jwks = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs'));
async function verifyAndExtract(token: string, clientId: string) {
const { payload } = await jwtVerify(token, jwks, {
issuer: ['https://accounts.google.com', 'accounts.google.com'],
audience: clientId,
});
return {
sub: typeof payload.sub === 'string' ? payload.sub : undefined,
email: typeof payload.email === 'string' ? payload.email : undefined,
};
}Additionally, consider keying session invalidation on a hash of the refresh/access token (or a server-issued session identifier) rather than unauthenticated claims. 2. 🟡 Potential DoS via unbounded read/parse of Gemini CLI credentials and JWT payload
Description
Vulnerable code: const raw = fs.readFileSync(pathname, "utf8");
return JSON.parse(raw) as T;
const payloadRaw = Buffer.from(parts[1], "base64url").toString("utf8");
const payload = JSON.parse(payloadRaw);RecommendationAdd explicit size limits before reading/parsing JSON files and before decoding/parsing JWT payloads. Option A (preferred): enforce a maximum JSON file size in export function loadJsonFile<T = unknown>(pathname: string, opts?: { maxBytes?: number }): T | undefined {
const maxBytes = opts?.maxBytes ?? 1024 * 1024; // 1 MiB, tune as needed
try {
const stat = fs.statSync(pathname);
if (stat.size > maxBytes) return undefined;
const raw = fs.readFileSync(pathname, "utf8");
return JSON.parse(raw) as T;
} catch {
return undefined;
}
}Option B: additionally bound function decodeJwtIdentityClaims(token: string): { sub?: string; email?: string } {
const parts = token.split(".");
if (parts.length < 2) return {};
// bound base64url segment length
if (parts[1].length > 16_384) return {};
try {
const payloadRaw = Buffer.from(parts[1], "base64url").toString("utf8");
if (payloadRaw.length > 16_384) return {};
const payload = JSON.parse(payloadRaw) as { sub?: unknown; email?: unknown };
...
} catch {
return {};
}
}This prevents a crafted/oversized credentials file or JWT from consuming excessive resources. Analyzed PR: #71076 at commit Last updated on: 2026-04-25T12:48:00Z |
Greptile SummaryThis PR adds a Confidence Score: 5/5Safe to merge — targeted bug fix following established patterns with no behavior change for existing providers. The change is minimal, strictly additive, and mirrors the already-tested Codex/Claude code paths. All remaining observations are P2 style notes. No correctness, data-integrity, or security issues found. No files require special attention. Reviews (1): Last reviewed commit: "fix(cli): fingerprint gemini cli credent..." | Re-trigger Greptile |
2a4d5fe to
4fa14b2
Compare
4fa14b2 to
3b2809a
Compare
3b2809a to
4a8d811
Compare
4a8d811 to
f0399a2
Compare
afdbc98 to
941270f
Compare
getLocalCliCredentialFingerprint only switched on claude-cli and codex-cli, so google-gemini-cli fell through to undefined and the stored session's authEpoch never bound to the user's local Gemini OAuth identity. Read ~/.gemini/oauth_creds.json through the same cached-reader pattern as Codex/MiniMax, and encode a stable identity fingerprint from the refresh token (stable across Google's access-token rotation, flips on re-authentication), matching openclaw#70132's stability contract. Adds: readGeminiCliCredentials(Cached) in cli-credentials.ts, a 'google-gemini-cli' case in getLocalCliCredentialFingerprint, and parallel test coverage alongside the existing claude/codex cases.
941270f to
3e1eeb7
Compare
|
Landed as bc73141. |
…law#71076) Fixes openclaw#70973. Adds a \`google-gemini-cli\` branch to \`getLocalCliCredentialFingerprint\` that lifts OpenID \`id_token\` \`sub\`/\`email\` claims from \`~/.gemini/oauth_creds.json\` onto \`GeminiCliCredential\` so the shared \`encodeOAuthIdentity\` produces an identity-keyed auth-epoch matching the Claude/Codex contract, plus bumps \`CLI_AUTH_EPOCH_VERSION\` from 3 to 4 so existing v3 Gemini bindings without an \`authEpoch\` ride the existing \`cli-session.ts\` version-gate instead of forcing a one-time invalidation.
…law#71076) Fixes openclaw#70973. Adds a \`google-gemini-cli\` branch to \`getLocalCliCredentialFingerprint\` that lifts OpenID \`id_token\` \`sub\`/\`email\` claims from \`~/.gemini/oauth_creds.json\` onto \`GeminiCliCredential\` so the shared \`encodeOAuthIdentity\` produces an identity-keyed auth-epoch matching the Claude/Codex contract, plus bumps \`CLI_AUTH_EPOCH_VERSION\` from 3 to 4 so existing v3 Gemini bindings without an \`authEpoch\` ride the existing \`cli-session.ts\` version-gate instead of forcing a one-time invalidation.
…law#71076) Fixes openclaw#70973. Adds a \`google-gemini-cli\` branch to \`getLocalCliCredentialFingerprint\` that lifts OpenID \`id_token\` \`sub\`/\`email\` claims from \`~/.gemini/oauth_creds.json\` onto \`GeminiCliCredential\` so the shared \`encodeOAuthIdentity\` produces an identity-keyed auth-epoch matching the Claude/Codex contract, plus bumps \`CLI_AUTH_EPOCH_VERSION\` from 3 to 4 so existing v3 Gemini bindings without an \`authEpoch\` ride the existing \`cli-session.ts\` version-gate instead of forcing a one-time invalidation.
…nclaw#74312) Extend Ayaan's OAuth identity-only hashing pattern (1ff461f, 3eb6edc) and Chunyue's Gemini extension (openclaw#71076) to the static-token credential branch. Token rotation and transient partial keychain reads no longer flip the claude-cli auth-epoch. Before: encodeClaudeCredential token branch hashed credential.token, and encodeAuthProfileCredential token case did the same. Either rotation or a partial keychain read (where parseClaudeCliOauthCredential sees accessToken without refreshToken and falls into the token branch) flipped the auth-epoch and forced cli session reset: reason=auth-epoch on every active claude-cli conversation. macOS keychain-only setups hit the auth-profile path as the dominant trigger. After: encodeClaudeCredential routes both branches through encodeOAuthIdentity, collapsing partial reads onto the same provider-keyed identity hash. encodeAuthProfileCredential token case drops credential.token from the hash; identity fields (provider, tokenRef, email, displayName) are the discriminator. CLI_AUTH_EPOCH_VERSION bumped 4 to 5. Test updates flip the existing token-flips assertion to stability, add a regression case proving Claude CLI OAuth and token epochs match (closing the partial-read race), and add auth-profile token tests for both rotation stability and identity-change invalidation.
…nclaw#74312) Extend Ayaan's OAuth identity-only hashing pattern (1ff461f, 3eb6edc) and Chunyue's Gemini extension (openclaw#71076) to the static-token credential branch. Token rotation and transient partial keychain reads no longer flip the claude-cli auth-epoch. Before: encodeClaudeCredential token branch hashed credential.token, and encodeAuthProfileCredential token case did the same. Either rotation or a partial keychain read (where parseClaudeCliOauthCredential sees accessToken without refreshToken and falls into the token branch) flipped the auth-epoch and forced cli session reset: reason=auth-epoch on every active claude-cli conversation. macOS keychain-only setups hit the auth-profile path as the dominant trigger. After: encodeClaudeCredential routes both branches through encodeOAuthIdentity, collapsing partial reads onto the same provider-keyed identity hash. encodeAuthProfileCredential token case drops credential.token from the hash; identity fields (provider, tokenRef, email, displayName) are the discriminator. CLI_AUTH_EPOCH_VERSION bumped 4 to 5. Test updates flip the existing token-flips assertion to stability, add a regression case proving Claude CLI OAuth and token epochs match (closing the partial-read race), and add auth-profile token tests for both rotation stability and identity-change invalidation.
…law#71076) Fixes openclaw#70973. Adds a \`google-gemini-cli\` branch to \`getLocalCliCredentialFingerprint\` that lifts OpenID \`id_token\` \`sub\`/\`email\` claims from \`~/.gemini/oauth_creds.json\` onto \`GeminiCliCredential\` so the shared \`encodeOAuthIdentity\` produces an identity-keyed auth-epoch matching the Claude/Codex contract, plus bumps \`CLI_AUTH_EPOCH_VERSION\` from 3 to 4 so existing v3 Gemini bindings without an \`authEpoch\` ride the existing \`cli-session.ts\` version-gate instead of forcing a one-time invalidation.
…nclaw#74312) Extend Ayaan's OAuth identity-only hashing pattern (1ff461f, 3eb6edc) and Chunyue's Gemini extension (openclaw#71076) to the static-token credential branch. Token rotation and transient partial keychain reads no longer flip the claude-cli auth-epoch. Before: encodeClaudeCredential token branch hashed credential.token, and encodeAuthProfileCredential token case did the same. Either rotation or a partial keychain read (where parseClaudeCliOauthCredential sees accessToken without refreshToken and falls into the token branch) flipped the auth-epoch and forced cli session reset: reason=auth-epoch on every active claude-cli conversation. macOS keychain-only setups hit the auth-profile path as the dominant trigger. After: encodeClaudeCredential routes both branches through encodeOAuthIdentity, collapsing partial reads onto the same provider-keyed identity hash. encodeAuthProfileCredential token case drops credential.token from the hash; identity fields (provider, tokenRef, email, displayName) are the discriminator. CLI_AUTH_EPOCH_VERSION bumped 4 to 5. Test updates flip the existing token-flips assertion to stability, add a regression case proving Claude CLI OAuth and token epochs match (closing the partial-read race), and add auth-profile token tests for both rotation stability and identity-change invalidation.
…nclaw#74312) Extend Ayaan's OAuth identity-only hashing pattern (1ff461f, 3eb6edc) and Chunyue's Gemini extension (openclaw#71076) to the static-token credential branch. Token rotation and transient partial keychain reads no longer flip the claude-cli auth-epoch. Before: encodeClaudeCredential token branch hashed credential.token, and encodeAuthProfileCredential token case did the same. Either rotation or a partial keychain read (where parseClaudeCliOauthCredential sees accessToken without refreshToken and falls into the token branch) flipped the auth-epoch and forced cli session reset: reason=auth-epoch on every active claude-cli conversation. macOS keychain-only setups hit the auth-profile path as the dominant trigger. After: encodeClaudeCredential routes both branches through encodeOAuthIdentity, collapsing partial reads onto the same provider-keyed identity hash. encodeAuthProfileCredential token case drops credential.token from the hash; identity fields (provider, tokenRef, email, displayName) are the discriminator. CLI_AUTH_EPOCH_VERSION bumped 4 to 5. Test updates flip the existing token-flips assertion to stability, add a regression case proving Claude CLI OAuth and token epochs match (closing the partial-read race), and add auth-profile token tests for both rotation stability and identity-change invalidation.
…nclaw#74312) Extend Ayaan's OAuth identity-only hashing pattern (1ff461f, 3eb6edc) and Chunyue's Gemini extension (openclaw#71076) to the static-token credential branch. Token rotation and transient partial keychain reads no longer flip the claude-cli auth-epoch. Before: encodeClaudeCredential token branch hashed credential.token, and encodeAuthProfileCredential token case did the same. Either rotation or a partial keychain read (where parseClaudeCliOauthCredential sees accessToken without refreshToken and falls into the token branch) flipped the auth-epoch and forced cli session reset: reason=auth-epoch on every active claude-cli conversation. macOS keychain-only setups hit the auth-profile path as the dominant trigger. After: encodeClaudeCredential routes both branches through encodeOAuthIdentity, collapsing partial reads onto the same provider-keyed identity hash. encodeAuthProfileCredential token case drops credential.token from the hash; identity fields (provider, tokenRef, email, displayName) are the discriminator. CLI_AUTH_EPOCH_VERSION bumped 4 to 5. Test updates flip the existing token-flips assertion to stability, add a regression case proving Claude CLI OAuth and token epochs match (closing the partial-read race), and add auth-profile token tests for both rotation stability and identity-change invalidation.
…nclaw#74312) Extend Ayaan's OAuth identity-only hashing pattern (1ff461f, 3eb6edc) and Chunyue's Gemini extension (openclaw#71076) to the static-token credential branch. Token rotation and transient partial keychain reads no longer flip the claude-cli auth-epoch. Before: encodeClaudeCredential token branch hashed credential.token, and encodeAuthProfileCredential token case did the same. Either rotation or a partial keychain read (where parseClaudeCliOauthCredential sees accessToken without refreshToken and falls into the token branch) flipped the auth-epoch and forced cli session reset: reason=auth-epoch on every active claude-cli conversation. macOS keychain-only setups hit the auth-profile path as the dominant trigger. After: encodeClaudeCredential routes both branches through encodeOAuthIdentity, collapsing partial reads onto the same provider-keyed identity hash. encodeAuthProfileCredential token case drops credential.token from the hash; identity fields (provider, tokenRef, email, displayName) are the discriminator. CLI_AUTH_EPOCH_VERSION bumped 4 to 5. Test updates flip the existing token-flips assertion to stability, add a regression case proving Claude CLI OAuth and token epochs match (closing the partial-read race), and add auth-profile token tests for both rotation stability and identity-change invalidation.
…law#71076) Fixes openclaw#70973. Adds a \`google-gemini-cli\` branch to \`getLocalCliCredentialFingerprint\` that lifts OpenID \`id_token\` \`sub\`/\`email\` claims from \`~/.gemini/oauth_creds.json\` onto \`GeminiCliCredential\` so the shared \`encodeOAuthIdentity\` produces an identity-keyed auth-epoch matching the Claude/Codex contract, plus bumps \`CLI_AUTH_EPOCH_VERSION\` from 3 to 4 so existing v3 Gemini bindings without an \`authEpoch\` ride the existing \`cli-session.ts\` version-gate instead of forcing a one-time invalidation.
…law#71076) Fixes openclaw#70973. Adds a \`google-gemini-cli\` branch to \`getLocalCliCredentialFingerprint\` that lifts OpenID \`id_token\` \`sub\`/\`email\` claims from \`~/.gemini/oauth_creds.json\` onto \`GeminiCliCredential\` so the shared \`encodeOAuthIdentity\` produces an identity-keyed auth-epoch matching the Claude/Codex contract, plus bumps \`CLI_AUTH_EPOCH_VERSION\` from 3 to 4 so existing v3 Gemini bindings without an \`authEpoch\` ride the existing \`cli-session.ts\` version-gate instead of forcing a one-time invalidation.
…nclaw#74312) Extend Ayaan's OAuth identity-only hashing pattern (1ff461f, 3eb6edc) and Chunyue's Gemini extension (openclaw#71076) to the static-token credential branch. Token rotation and transient partial keychain reads no longer flip the claude-cli auth-epoch. Before: encodeClaudeCredential token branch hashed credential.token, and encodeAuthProfileCredential token case did the same. Either rotation or a partial keychain read (where parseClaudeCliOauthCredential sees accessToken without refreshToken and falls into the token branch) flipped the auth-epoch and forced cli session reset: reason=auth-epoch on every active claude-cli conversation. macOS keychain-only setups hit the auth-profile path as the dominant trigger. After: encodeClaudeCredential routes both branches through encodeOAuthIdentity, collapsing partial reads onto the same provider-keyed identity hash. encodeAuthProfileCredential token case drops credential.token from the hash; identity fields (provider, tokenRef, email, displayName) are the discriminator. CLI_AUTH_EPOCH_VERSION bumped 4 to 5. Test updates flip the existing token-flips assertion to stability, add a regression case proving Claude CLI OAuth and token epochs match (closing the partial-read race), and add auth-profile token tests for both rotation stability and identity-change invalidation.
…law#71076) Fixes openclaw#70973. Adds a \`google-gemini-cli\` branch to \`getLocalCliCredentialFingerprint\` that lifts OpenID \`id_token\` \`sub\`/\`email\` claims from \`~/.gemini/oauth_creds.json\` onto \`GeminiCliCredential\` so the shared \`encodeOAuthIdentity\` produces an identity-keyed auth-epoch matching the Claude/Codex contract, plus bumps \`CLI_AUTH_EPOCH_VERSION\` from 3 to 4 so existing v3 Gemini bindings without an \`authEpoch\` ride the existing \`cli-session.ts\` version-gate instead of forcing a one-time invalidation.
…law#71076) Fixes openclaw#70973. Adds a \`google-gemini-cli\` branch to \`getLocalCliCredentialFingerprint\` that lifts OpenID \`id_token\` \`sub\`/\`email\` claims from \`~/.gemini/oauth_creds.json\` onto \`GeminiCliCredential\` so the shared \`encodeOAuthIdentity\` produces an identity-keyed auth-epoch matching the Claude/Codex contract, plus bumps \`CLI_AUTH_EPOCH_VERSION\` from 3 to 4 so existing v3 Gemini bindings without an \`authEpoch\` ride the existing \`cli-session.ts\` version-gate instead of forcing a one-time invalidation.
Summary
getLocalCliCredentialFingerprintinsrc/agents/cli-auth-epoch.ts:114dispatches onproviderthrough aswitchwith explicit branches forclaude-cliandcodex-cli;google-gemini-clifalls through to thedefaultbranch and returnsundefined. As a result, theauthEpochvalue stored alongside agoogle-gemini-cliCLI session binding never binds to the user's local Gemini OAuth state at~/.gemini/oauth_creds.json, and the auth-epoch contract that Claude CLI and Codex CLI rely on is unenforceable on Gemini sessions. Reported in [Bug]: CLI sessions: Gemini CLI not covered by Claude CLI session persistence fixes (#69679 / #70106 / #70132) — gateway restart still mints a fresh conversation #70973.encodeOAuthIdentitykeys the epoch on stable, non-secret OAuth identity fields (clientId/email/enterpriseUrl/projectId/accountId) so the stored binding survives access-token rotation and flips on re-authentication under a different account. Gemini CLI's~/.gemini/oauth_creds.jsoncarries the OpenIDid_tokenJWT alongside the OAuth tokens; itssubandemailclaims are the identity fields that line up with theencodeOAuthIdentityshape Claude and Codex already feed.src/agents/cli-credentials.ts, decode the openidid_tokenonce at credential-read time and liftsub→accountIdandemail→emailontoGeminiCliCredential. The existingreadCachedCliCredentialhelper continues to provide the 5000 ms TTL andreadFileMtimeMs-based source-fingerprinting.src/agents/cli-auth-epoch.ts, registergoogle-gemini-cliingetLocalCliCredentialFingerprintand delegateencodeGeminiCredentialto the sharedencodeOAuthIdentity. With identity fields populated, the encoder produces an identity-keyed hash. When theid_tokenis absent (older logins, scope omitted),encodeOAuthIdentitycollapses to a provider-keyed constant — the same identity-less fallback the Claude CLI OAuth branch produces, preserving backward compatibility for legacy login state.CLI_AUTH_EPOCH_VERSIONfrom3to4so existinggoogle-gemini-clibindings on disk (persisted withauthEpoch: undefined, since the local fingerprint returnedundefinedbefore this change) hit the existing version-gate atsrc/agents/cli-session.ts:152and skip the epoch comparison on the first request after upgrade. Once the next turn writes a fresh binding under v4 with the identity-keyed hash, all subsequent comparisons run normally. This is the same migration mechanism the file's existing"accepts older auth epoch versions for binding upgrades"test already pins.src/agents/cli-credentials.ts: addGEMINI_CLI_CREDENTIALS_RELATIVE_PATH, aGeminiCliCredentialtype with optionalaccountId/emailidentity fields, ageminiCliCacheslot, aresolveGeminiCliCredentialsPathhelper, andreadGeminiCliCredentials/readGeminiCliCredentialsCachedthat read the OAuth file directly, decode theid_tokenJWT through a new privatedecodeJwtIdentityClaimshelper (mirroring the existingdecodeJwtExpiryMs), and liftsub/emailonto the credential. ExtendresetCliCredentialCachesForTestto clear the new cache.src/agents/cli-auth-epoch.ts: importreadGeminiCliCredentialsCachedandGeminiCliCredential; register the reader onCliAuthEpochDepsand its defaults for test injection; addencodeGeminiCredentialthat delegates to the sharedencodeOAuthIdentity; add thecase "google-gemini-cli"branch mirroring the existing Codex and Claude branches; bumpCLI_AUTH_EPOCH_VERSIONfrom3to4.src/agents/cli-credentials.test.ts: add two tests —id_tokenlift producesaccountId/emailon the credential, and credentials read without anid_tokenround-trip without identity fields (legacy fallback).src/agents/cli-auth-epoch.test.ts: extend the existing "no local or auth-profile credentials" case to stub the new dep; assert that the Gemini local-credential epoch (a) stays stable across access/refresh rotation when identity is unchanged, (b) flips onemailchange, (c) flips onaccountIdchange; add a separate test for theid_token-absent case asserting identity-less fallback stability.src/agents/cli-session.test.ts: add"accepts v3 bindings without authEpoch as binding upgrades to v4"mirroring the existing"accepts older auth epoch versions for binding upgrades"case, pinning the version-gate behavior for the v3 → v4 migration.resolveCliAuthEpoch's public signature, the overall parts ordering, or the hash construction. Existing Claude / Codex callers compute bit-for-bit identical epochs.resolveCliSessionReuseand thecliSessionBindingsschema. No new fields, no migrations. Existing bindings on disk keep working through the file's existingauthEpochVersionupgrade semantics.encodeOAuthIdentityhelper and the Claude / Codex encoders — they keep returning the exact same strings for pre-existing inputs.readPortalCliOauthCredentialsand the MiniMax reader — untouched. Gemini gets a dedicated reader because, unlike MiniMax, it carries anid_token.extensions/google/— no extension code changes. The new reader lives alongside the existing Claude and Codex readers to stay consistent with the current core pattern.claude-live-session.ts— not applicable to the Gemini transport (output: "json",input: "arg").subandemailare non-secret OpenID claims, the same identity shape Claude / Codex already feed intoencodeOAuthIdentity.Reproduction
google-gemini-clivia Gemini CLI's owngemini auth loginso~/.gemini/oauth_creds.jsonexists with realaccess_token/refresh_token/expiry_date/id_token.google-gemini-cli/*default model and have a multi-turn conversation. AcliSessionBindings["google-gemini-cli"]entry is persisted with an identity-keyedauthEpoch.authEpochmatches the stored one as long as the Gemini credential file still belongs to the same account, and the session is reused. When the user logs out and re-logs under a different Google account,id_token.subchanges, the epoch flips,resolveCliSessionReusereturns{ invalidatedReason: "auth-epoch" }, and OpenClaw mints a fresh binding — matching the behavior Claude and Codex already have.google-gemini-clisession persisted under a prior version storesauthEpochVersion: 3with noauthEpochfield. After upgrading to a build with this PR, the first request hits the version-gate (3 !== 4) and skips the epoch comparison, so the stored session is reused without churn. The turn then rewrites the binding under v4 with the identity-keyed hash, and all subsequent comparisons run normally.Risk / Mitigation
~/.gemini/oauth_creds.json) on an auth-epoch hot path could regress startup / latency.readCachedCliCredentialhelper with a 5000 ms TTL andreadFileMtimeMs-based source-fingerprinting, identical to the Codex reader. When the file is absent the read returnsnullin a singlestatcall. No new network I/O. The addedid_tokendecoding is a single base64url + JSON parse on a value the file already contains, mirroring the existingdecodeJwtExpiryMspattern in the same file.id_tokencould cause the reader to throw on every auth-epoch computation.decodeJwtIdentityClaimswraps the parse in a try/catch and returns{}on any error — the credential round-trips without identity fields and the encoder falls back to the identity-less constant. The credential reader itself is unaffected byid_tokenmalformation.id_tokenpayload contents (e.g., Google adding new claims) shifts the epoch.decodeJwtIdentityClaimsonly reads two specific string fields (sub,email); unknown additional claims are ignored. The encoder consumes onlyaccountId/emailfrom the lifted shape. Stable.openid emailscope on their Gemini login (older flows) sees a constant epoch.3→4) is global; on the first request after upgrade, every CLI provider's existing bindings (Claude / Codex / Gemini) skip the auth-epoch check via the version-gate. A user who switched OAuth accounts in this exact deploy window could reuse a stale binding for one request before the new binding is written under v4."accepts v3 bindings without authEpoch as binding upgrades to v4"test alongside the file's existing version-upgrade coverage.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Fixes #70973