diff --git a/CHANGELOG.md b/CHANGELOG.md index 0460c3d2d72..6faaaa56cd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ Docs: https://docs.openclaw.ai - Auth/Claude CLI: sync refreshed Claude CLI OAuth credentials into the managed auth profile so long-running Claude CLI runs stop falling back to stale OpenClaw snapshots. (#70902) Thanks @starvex. - Sessions: make `sessions_spawn(mode="session")` errors name usable alternatives when the current channel cannot bind subagent threads. Fixes #67400. (#67790) Thanks @stainlu. - Agents/Claude CLI: pass the OpenClaw system prompt through Claude's prompt-file flag so Windows runs avoid argv length failures without changing system prompt semantics. Fixes #69158. (#69211) Thanks @skylee-01, @cassioanorte, @Syu0, and @Stache73. +- Agents/CLI sessions: bind `google-gemini-cli` session auth-epoch to the Google account identity in `~/.gemini/oauth_creds.json`, so Gemini-backed agents resume their conversation after gateway restart instead of minting a fresh session, and stale bindings are invalidated when the authenticated Google account changes. Fixes #70973. (#71076) Thanks @openperf. ## 2026.4.25 (Unreleased) diff --git a/src/agents/cli-auth-epoch.test.ts b/src/agents/cli-auth-epoch.test.ts index 882ae366543..9cdf0ceb4c8 100644 --- a/src/agents/cli-auth-epoch.test.ts +++ b/src/agents/cli-auth-epoch.test.ts @@ -15,6 +15,7 @@ describe("resolveCliAuthEpoch", () => { setCliAuthEpochTestDeps({ readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: () => null, + readGeminiCliCredentialsCached: () => null, loadAuthProfileStoreForRuntime: () => ({ version: 1, profiles: {}, @@ -74,6 +75,70 @@ describe("resolveCliAuthEpoch", () => { expect(second).not.toBe(first); }); + it("keeps gemini cli oauth epochs stable through token rotation and flips on account change", async () => { + let access = "gemini-access-a"; + let refresh = "gemini-refresh-a"; + let expires = 1; + let accountId: string | undefined = "google-account-1"; + let email: string | undefined = "user-a@example.com"; + setCliAuthEpochTestDeps({ + readGeminiCliCredentialsCached: () => ({ + type: "oauth", + provider: "google-gemini-cli", + access, + refresh, + expires, + ...(accountId ? { accountId } : {}), + ...(email ? { email } : {}), + }), + }); + + const first = await resolveCliAuthEpoch({ provider: "google-gemini-cli" }); + access = "gemini-access-b"; + refresh = "gemini-refresh-b"; + expires = 2; + const second = await resolveCliAuthEpoch({ provider: "google-gemini-cli" }); + + expect(first).toBeDefined(); + // Access and refresh rotation must not shift the epoch while the lifted + // Google-account identity is stable. + expect(second).toBe(first); + + email = "user-b@example.com"; + const third = await resolveCliAuthEpoch({ provider: "google-gemini-cli" }); + + expect(third).toBeDefined(); + expect(third).not.toBe(second); + + accountId = "google-account-2"; + const fourth = await resolveCliAuthEpoch({ provider: "google-gemini-cli" }); + + expect(fourth).toBeDefined(); + expect(fourth).not.toBe(third); + }); + + it("falls back to the identity-less oauth epoch when gemini id_token is absent", async () => { + let refresh = "gemini-refresh-a"; + setCliAuthEpochTestDeps({ + readGeminiCliCredentialsCached: () => ({ + type: "oauth", + provider: "google-gemini-cli", + access: "gemini-access", + refresh, + expires: 1, + }), + }); + + const first = await resolveCliAuthEpoch({ provider: "google-gemini-cli" }); + refresh = "gemini-refresh-b"; + const second = await resolveCliAuthEpoch({ provider: "google-gemini-cli" }); + + expect(first).toBeDefined(); + // Without lifted identity, the epoch is a provider-keyed constant that + // survives token rotation — same fallback as the Claude CLI OAuth branch. + expect(second).toBe(first); + }); + it("keeps oauth auth-profile epochs stable across token refreshes", async () => { let store: AuthProfileStore = { version: 1, @@ -89,6 +154,7 @@ describe("resolveCliAuthEpoch", () => { }, }; setCliAuthEpochTestDeps({ + readGeminiCliCredentialsCached: () => null, loadAuthProfileStoreForRuntime: () => store, }); @@ -133,6 +199,7 @@ describe("resolveCliAuthEpoch", () => { }, }; setCliAuthEpochTestDeps({ + readGeminiCliCredentialsCached: () => null, loadAuthProfileStoreForRuntime: () => store, }); diff --git a/src/agents/cli-auth-epoch.ts b/src/agents/cli-auth-epoch.ts index 9edb2c97e27..bdc7668bc4c 100644 --- a/src/agents/cli-auth-epoch.ts +++ b/src/agents/cli-auth-epoch.ts @@ -5,25 +5,29 @@ import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/ty import { readClaudeCliCredentialsCached, readCodexCliCredentialsCached, + readGeminiCliCredentialsCached, type ClaudeCliCredential, type CodexCliCredential, + type GeminiCliCredential, } from "./cli-credentials.js"; type CliAuthEpochDeps = { readClaudeCliCredentialsCached: typeof readClaudeCliCredentialsCached; readCodexCliCredentialsCached: typeof readCodexCliCredentialsCached; + readGeminiCliCredentialsCached: typeof readGeminiCliCredentialsCached; loadAuthProfileStoreForRuntime: typeof loadAuthProfileStoreForRuntime; }; const defaultCliAuthEpochDeps: CliAuthEpochDeps = { readClaudeCliCredentialsCached, readCodexCliCredentialsCached, + readGeminiCliCredentialsCached, loadAuthProfileStoreForRuntime, }; const cliAuthEpochDeps: CliAuthEpochDeps = { ...defaultCliAuthEpochDeps }; -export const CLI_AUTH_EPOCH_VERSION = 3; +export const CLI_AUTH_EPOCH_VERSION = 4; export function setCliAuthEpochTestDeps(overrides: Partial): void { Object.assign(cliAuthEpochDeps, overrides); @@ -72,6 +76,17 @@ function encodeCodexCredential(credential: CodexCliCredential): string { return encodeOAuthIdentity(credential); } +function encodeGeminiCredential(credential: GeminiCliCredential): string { + // Delegate to the shared OAuth-identity encoder. The Gemini CLI reader + // lifts the Google-account identity (sub, email) off the openid id_token + // onto the credential, so the encoder fingerprints the user through stable, + // non-secret identity fields — matching the Claude/Codex OAuth contract. + // When the id_token is absent (older logins, scope omitted), the encoder + // falls back to a provider-keyed constant, the same identity-less behavior + // the Claude CLI OAuth branch tolerates. + return encodeOAuthIdentity(credential); +} + function encodeAuthProfileCredential(credential: AuthProfileCredential): string { switch (credential.type) { case "api_key": @@ -114,6 +129,12 @@ function getLocalCliCredentialFingerprint(provider: string): string | undefined }); return credential ? hashCliAuthEpochPart(encodeCodexCredential(credential)) : undefined; } + case "google-gemini-cli": { + const credential = cliAuthEpochDeps.readGeminiCliCredentialsCached({ + ttlMs: 5000, + }); + return credential ? hashCliAuthEpochPart(encodeGeminiCredential(credential)) : undefined; + } default: return undefined; } diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index e2b37e058cd..64437d1d6f2 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -12,6 +12,7 @@ let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").reset let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials; let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials; let readCodexCliCredentials: typeof import("./cli-credentials.js").readCodexCliCredentials; +let readGeminiCliCredentialsCached: typeof import("./cli-credentials.js").readGeminiCliCredentialsCached; function mockExistingClaudeKeychainItem() { execFileSyncMock.mockImplementation((file: unknown, args: unknown) => { @@ -74,6 +75,7 @@ describe("cli credentials", () => { writeClaudeCliKeychainCredentials, writeClaudeCliCredentials, readCodexCliCredentials, + readGeminiCliCredentialsCached, } = await import("./cli-credentials.js")); }); @@ -362,4 +364,69 @@ describe("cli credentials", () => { fs.rmSync(tempHome, { recursive: true, force: true }); } }); + + it("lifts Google account identity from the Gemini id_token", () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gemini-")); + try { + const credPath = path.join(tempHome, ".gemini", "oauth_creds.json"); + fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 }); + const idTokenPayload = Buffer.from( + JSON.stringify({ sub: "google-account-42", email: "user@example.com" }), + ).toString("base64url"); + const idToken = `header.${idTokenPayload}.signature`; + fs.writeFileSync( + credPath, + JSON.stringify({ + access_token: "gemini-access", + refresh_token: "gemini-refresh", + id_token: idToken, + expiry_date: Date.parse("2026-04-25T12:00:00Z"), + }), + "utf8", + ); + + const creds = readGeminiCliCredentialsCached({ homeDir: tempHome, ttlMs: 0 }); + + expect(creds).toMatchObject({ + type: "oauth", + provider: "google-gemini-cli", + access: "gemini-access", + refresh: "gemini-refresh", + accountId: "google-account-42", + email: "user@example.com", + }); + } finally { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it("reads Gemini credentials without identity fields when id_token is absent", () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gemini-noid-")); + try { + const credPath = path.join(tempHome, ".gemini", "oauth_creds.json"); + fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + credPath, + JSON.stringify({ + access_token: "gemini-access", + refresh_token: "gemini-refresh", + expiry_date: Date.parse("2026-04-25T12:00:00Z"), + }), + "utf8", + ); + + const creds = readGeminiCliCredentialsCached({ homeDir: tempHome, ttlMs: 0 }); + + expect(creds).toMatchObject({ + type: "oauth", + provider: "google-gemini-cli", + access: "gemini-access", + refresh: "gemini-refresh", + }); + expect(creds?.accountId).toBeUndefined(); + expect(creds?.email).toBeUndefined(); + } finally { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index f954b7fe386..cbe09c65286 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -13,6 +13,7 @@ const log = createSubsystemLogger("agents/auth-profiles"); const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json"; const CODEX_CLI_AUTH_FILENAME = "auth.json"; const MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH = ".minimax/oauth_creds.json"; +const GEMINI_CLI_CREDENTIALS_RELATIVE_PATH = ".gemini/oauth_creds.json"; const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials"; const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code"; @@ -27,11 +28,13 @@ type CachedValue = { let claudeCliCache: CachedValue | null = null; let codexCliCache: CachedValue | null = null; let minimaxCliCache: CachedValue | null = null; +let geminiCliCache: CachedValue | null = null; export function resetCliCredentialCachesForTest(): void { claudeCliCache = null; codexCliCache = null; minimaxCliCache = null; + geminiCliCache = null; } export type ClaudeCliCredential = @@ -67,6 +70,16 @@ export type MiniMaxCliCredential = { expires: number; }; +export type GeminiCliCredential = { + type: "oauth"; + provider: "google-gemini-cli"; + access: string; + refresh: string; + expires: number; + accountId?: string; + email?: string; +}; + type ClaudeCliFileOptions = { homeDir?: string; }; @@ -131,6 +144,11 @@ function resolveMiniMaxCliCredentialsPath(homeDir?: string) { return path.join(baseDir, MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH); } +function resolveGeminiCliCredentialsPath(homeDir?: string) { + const baseDir = homeDir ?? resolveUserPath("~"); + return path.join(baseDir, GEMINI_CLI_CREDENTIALS_RELATIVE_PATH); +} + function readFileMtimeMs(filePath: string): number | null { try { return fs.statSync(filePath).mtimeMs; @@ -211,6 +229,22 @@ function decodeJwtExpiryMs(token: string): number | null { } } +function decodeJwtIdentityClaims(token: string): { sub?: string; email?: string } { + const parts = token.split("."); + if (parts.length < 2) { + return {}; + } + try { + const payloadRaw = Buffer.from(parts[1], "base64url").toString("utf8"); + const payload = JSON.parse(payloadRaw) as { sub?: unknown; email?: unknown }; + const sub = typeof payload.sub === "string" && payload.sub ? payload.sub : undefined; + const email = typeof payload.email === "string" && payload.email ? payload.email : undefined; + return { sub, email }; + } catch { + return {}; + } +} + function readCodexKeychainAuthRecord(options?: { codexHome?: string; platform?: NodeJS.Platform; @@ -328,6 +362,49 @@ function readMiniMaxCliCredentials(options?: { homeDir?: string }): MiniMaxCliCr return readPortalCliOauthCredentials(credPath, "minimax-portal"); } +function readGeminiCliCredentials(options?: { homeDir?: string }): GeminiCliCredential | null { + const credPath = resolveGeminiCliCredentialsPath(options?.homeDir); + const raw = loadJsonFile(credPath); + if (!raw || typeof raw !== "object") { + return null; + } + const data = raw as Record; + const accessToken = data.access_token; + const refreshToken = data.refresh_token; + const expiresAt = data.expiry_date; + + if (typeof accessToken !== "string" || !accessToken) { + return null; + } + if (typeof refreshToken !== "string" || !refreshToken) { + return null; + } + if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) { + return null; + } + + // Gemini CLI's login flow stores the openid id_token alongside the OAuth + // tokens. Decode it once here to lift the Google account identity (sub, + // email) onto the credential so the shared OAuth-identity encoder can key + // the auth epoch on stable, non-secret identity material — matching the + // Claude/Codex contract that #70132 codifies. Without this lift the encoder + // collapses to a provider-keyed constant and stale bindings can survive a + // re-login under a different Google account. + const idTokenRaw = data.id_token; + const identity = + typeof idTokenRaw === "string" && idTokenRaw ? decodeJwtIdentityClaims(idTokenRaw) : {}; + + return { + type: "oauth", + provider: "google-gemini-cli", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + ...(identity.email ? { email: identity.email } : {}), + ...(identity.sub ? { accountId: identity.sub } : {}), + }; +} + function readClaudeCliKeychainCredentials( execSyncImpl: ExecSyncFn = execSync, ): ClaudeCliCredential | null { @@ -609,3 +686,20 @@ export function readMiniMaxCliCredentialsCached(options?: { readSourceFingerprint: () => readFileMtimeMs(credPath), }); } + +export function readGeminiCliCredentialsCached(options?: { + ttlMs?: number; + homeDir?: string; +}): GeminiCliCredential | null { + const credPath = resolveGeminiCliCredentialsPath(options?.homeDir); + return readCachedCliCredential({ + ttlMs: options?.ttlMs ?? 0, + cache: geminiCliCache, + cacheKey: credPath, + read: () => readGeminiCliCredentials({ homeDir: options?.homeDir }), + setCache: (next) => { + geminiCliCache = next; + }, + readSourceFingerprint: () => readFileMtimeMs(credPath), + }); +} diff --git a/src/agents/cli-session.test.ts b/src/agents/cli-session.test.ts index 1af4c564fc0..c90b63492e9 100644 --- a/src/agents/cli-session.test.ts +++ b/src/agents/cli-session.test.ts @@ -182,6 +182,33 @@ describe("cli-session helpers", () => { ).toEqual({ sessionId: "cli-session-1" }); }); + it("accepts v3 bindings without authEpoch as binding upgrades to v4", () => { + // Pre-v4 google-gemini-cli sessions persisted with authEpochVersion: 3 + // and no authEpoch (the local credential fingerprint returned undefined + // before id_token identity lifting). The version-gate must skip the + // epoch comparison for these so the next request after upgrade reuses + // the stored session instead of forcing a one-time invalidation. + const binding = { + sessionId: "cli-session-1", + authProfileId: undefined, + // authEpoch deliberately absent + authEpochVersion: 3, + extraSystemPromptHash: "prompt-a", + mcpConfigHash: "mcp-a", + }; + + expect( + resolveCliSessionReuse({ + binding, + authProfileId: undefined, + authEpoch: "v4-identity-hash", + authEpochVersion: 4, + extraSystemPromptHash: "prompt-a", + mcpConfigHash: "mcp-a", + }), + ).toEqual({ sessionId: "cli-session-1" }); + }); + it("does not treat model changes as a session mismatch", () => { const binding = { sessionId: "cli-session-1",