Skip to content

Commit bc73141

Browse files
authored
fix(cli): key gemini cli auth epoch on google account identity (#71076)
Fixes #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.
1 parent ab1d1a5 commit bc73141

6 files changed

Lines changed: 278 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Docs: https://docs.openclaw.ai
9090
- 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.
9191
- Sessions: make `sessions_spawn(mode="session")` errors name usable alternatives when the current channel cannot bind subagent threads. Fixes #67400. (#67790) Thanks @stainlu.
9292
- 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.
93+
- 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.
9394

9495
## 2026.4.25 (Unreleased)
9596

src/agents/cli-auth-epoch.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe("resolveCliAuthEpoch", () => {
1515
setCliAuthEpochTestDeps({
1616
readClaudeCliCredentialsCached: () => null,
1717
readCodexCliCredentialsCached: () => null,
18+
readGeminiCliCredentialsCached: () => null,
1819
loadAuthProfileStoreForRuntime: () => ({
1920
version: 1,
2021
profiles: {},
@@ -74,6 +75,70 @@ describe("resolveCliAuthEpoch", () => {
7475
expect(second).not.toBe(first);
7576
});
7677

78+
it("keeps gemini cli oauth epochs stable through token rotation and flips on account change", async () => {
79+
let access = "gemini-access-a";
80+
let refresh = "gemini-refresh-a";
81+
let expires = 1;
82+
let accountId: string | undefined = "google-account-1";
83+
let email: string | undefined = "[email protected]";
84+
setCliAuthEpochTestDeps({
85+
readGeminiCliCredentialsCached: () => ({
86+
type: "oauth",
87+
provider: "google-gemini-cli",
88+
access,
89+
refresh,
90+
expires,
91+
...(accountId ? { accountId } : {}),
92+
...(email ? { email } : {}),
93+
}),
94+
});
95+
96+
const first = await resolveCliAuthEpoch({ provider: "google-gemini-cli" });
97+
access = "gemini-access-b";
98+
refresh = "gemini-refresh-b";
99+
expires = 2;
100+
const second = await resolveCliAuthEpoch({ provider: "google-gemini-cli" });
101+
102+
expect(first).toBeDefined();
103+
// Access and refresh rotation must not shift the epoch while the lifted
104+
// Google-account identity is stable.
105+
expect(second).toBe(first);
106+
107+
email = "[email protected]";
108+
const third = await resolveCliAuthEpoch({ provider: "google-gemini-cli" });
109+
110+
expect(third).toBeDefined();
111+
expect(third).not.toBe(second);
112+
113+
accountId = "google-account-2";
114+
const fourth = await resolveCliAuthEpoch({ provider: "google-gemini-cli" });
115+
116+
expect(fourth).toBeDefined();
117+
expect(fourth).not.toBe(third);
118+
});
119+
120+
it("falls back to the identity-less oauth epoch when gemini id_token is absent", async () => {
121+
let refresh = "gemini-refresh-a";
122+
setCliAuthEpochTestDeps({
123+
readGeminiCliCredentialsCached: () => ({
124+
type: "oauth",
125+
provider: "google-gemini-cli",
126+
access: "gemini-access",
127+
refresh,
128+
expires: 1,
129+
}),
130+
});
131+
132+
const first = await resolveCliAuthEpoch({ provider: "google-gemini-cli" });
133+
refresh = "gemini-refresh-b";
134+
const second = await resolveCliAuthEpoch({ provider: "google-gemini-cli" });
135+
136+
expect(first).toBeDefined();
137+
// Without lifted identity, the epoch is a provider-keyed constant that
138+
// survives token rotation — same fallback as the Claude CLI OAuth branch.
139+
expect(second).toBe(first);
140+
});
141+
77142
it("keeps oauth auth-profile epochs stable across token refreshes", async () => {
78143
let store: AuthProfileStore = {
79144
version: 1,
@@ -89,6 +154,7 @@ describe("resolveCliAuthEpoch", () => {
89154
},
90155
};
91156
setCliAuthEpochTestDeps({
157+
readGeminiCliCredentialsCached: () => null,
92158
loadAuthProfileStoreForRuntime: () => store,
93159
});
94160

@@ -133,6 +199,7 @@ describe("resolveCliAuthEpoch", () => {
133199
},
134200
};
135201
setCliAuthEpochTestDeps({
202+
readGeminiCliCredentialsCached: () => null,
136203
loadAuthProfileStoreForRuntime: () => store,
137204
});
138205

src/agents/cli-auth-epoch.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,29 @@ import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/ty
55
import {
66
readClaudeCliCredentialsCached,
77
readCodexCliCredentialsCached,
8+
readGeminiCliCredentialsCached,
89
type ClaudeCliCredential,
910
type CodexCliCredential,
11+
type GeminiCliCredential,
1012
} from "./cli-credentials.js";
1113

1214
type CliAuthEpochDeps = {
1315
readClaudeCliCredentialsCached: typeof readClaudeCliCredentialsCached;
1416
readCodexCliCredentialsCached: typeof readCodexCliCredentialsCached;
17+
readGeminiCliCredentialsCached: typeof readGeminiCliCredentialsCached;
1518
loadAuthProfileStoreForRuntime: typeof loadAuthProfileStoreForRuntime;
1619
};
1720

1821
const defaultCliAuthEpochDeps: CliAuthEpochDeps = {
1922
readClaudeCliCredentialsCached,
2023
readCodexCliCredentialsCached,
24+
readGeminiCliCredentialsCached,
2125
loadAuthProfileStoreForRuntime,
2226
};
2327

2428
const cliAuthEpochDeps: CliAuthEpochDeps = { ...defaultCliAuthEpochDeps };
2529

26-
export const CLI_AUTH_EPOCH_VERSION = 3;
30+
export const CLI_AUTH_EPOCH_VERSION = 4;
2731

2832
export function setCliAuthEpochTestDeps(overrides: Partial<CliAuthEpochDeps>): void {
2933
Object.assign(cliAuthEpochDeps, overrides);
@@ -72,6 +76,17 @@ function encodeCodexCredential(credential: CodexCliCredential): string {
7276
return encodeOAuthIdentity(credential);
7377
}
7478

79+
function encodeGeminiCredential(credential: GeminiCliCredential): string {
80+
// Delegate to the shared OAuth-identity encoder. The Gemini CLI reader
81+
// lifts the Google-account identity (sub, email) off the openid id_token
82+
// onto the credential, so the encoder fingerprints the user through stable,
83+
// non-secret identity fields — matching the Claude/Codex OAuth contract.
84+
// When the id_token is absent (older logins, scope omitted), the encoder
85+
// falls back to a provider-keyed constant, the same identity-less behavior
86+
// the Claude CLI OAuth branch tolerates.
87+
return encodeOAuthIdentity(credential);
88+
}
89+
7590
function encodeAuthProfileCredential(credential: AuthProfileCredential): string {
7691
switch (credential.type) {
7792
case "api_key":
@@ -114,6 +129,12 @@ function getLocalCliCredentialFingerprint(provider: string): string | undefined
114129
});
115130
return credential ? hashCliAuthEpochPart(encodeCodexCredential(credential)) : undefined;
116131
}
132+
case "google-gemini-cli": {
133+
const credential = cliAuthEpochDeps.readGeminiCliCredentialsCached({
134+
ttlMs: 5000,
135+
});
136+
return credential ? hashCliAuthEpochPart(encodeGeminiCredential(credential)) : undefined;
137+
}
117138
default:
118139
return undefined;
119140
}

src/agents/cli-credentials.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").reset
1212
let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials;
1313
let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials;
1414
let readCodexCliCredentials: typeof import("./cli-credentials.js").readCodexCliCredentials;
15+
let readGeminiCliCredentialsCached: typeof import("./cli-credentials.js").readGeminiCliCredentialsCached;
1516

1617
function mockExistingClaudeKeychainItem() {
1718
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
@@ -74,6 +75,7 @@ describe("cli credentials", () => {
7475
writeClaudeCliKeychainCredentials,
7576
writeClaudeCliCredentials,
7677
readCodexCliCredentials,
78+
readGeminiCliCredentialsCached,
7779
} = await import("./cli-credentials.js"));
7880
});
7981

@@ -362,4 +364,69 @@ describe("cli credentials", () => {
362364
fs.rmSync(tempHome, { recursive: true, force: true });
363365
}
364366
});
367+
368+
it("lifts Google account identity from the Gemini id_token", () => {
369+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gemini-"));
370+
try {
371+
const credPath = path.join(tempHome, ".gemini", "oauth_creds.json");
372+
fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 });
373+
const idTokenPayload = Buffer.from(
374+
JSON.stringify({ sub: "google-account-42", email: "[email protected]" }),
375+
).toString("base64url");
376+
const idToken = `header.${idTokenPayload}.signature`;
377+
fs.writeFileSync(
378+
credPath,
379+
JSON.stringify({
380+
access_token: "gemini-access",
381+
refresh_token: "gemini-refresh",
382+
id_token: idToken,
383+
expiry_date: Date.parse("2026-04-25T12:00:00Z"),
384+
}),
385+
"utf8",
386+
);
387+
388+
const creds = readGeminiCliCredentialsCached({ homeDir: tempHome, ttlMs: 0 });
389+
390+
expect(creds).toMatchObject({
391+
type: "oauth",
392+
provider: "google-gemini-cli",
393+
access: "gemini-access",
394+
refresh: "gemini-refresh",
395+
accountId: "google-account-42",
396+
397+
});
398+
} finally {
399+
fs.rmSync(tempHome, { recursive: true, force: true });
400+
}
401+
});
402+
403+
it("reads Gemini credentials without identity fields when id_token is absent", () => {
404+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gemini-noid-"));
405+
try {
406+
const credPath = path.join(tempHome, ".gemini", "oauth_creds.json");
407+
fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 });
408+
fs.writeFileSync(
409+
credPath,
410+
JSON.stringify({
411+
access_token: "gemini-access",
412+
refresh_token: "gemini-refresh",
413+
expiry_date: Date.parse("2026-04-25T12:00:00Z"),
414+
}),
415+
"utf8",
416+
);
417+
418+
const creds = readGeminiCliCredentialsCached({ homeDir: tempHome, ttlMs: 0 });
419+
420+
expect(creds).toMatchObject({
421+
type: "oauth",
422+
provider: "google-gemini-cli",
423+
access: "gemini-access",
424+
refresh: "gemini-refresh",
425+
});
426+
expect(creds?.accountId).toBeUndefined();
427+
expect(creds?.email).toBeUndefined();
428+
} finally {
429+
fs.rmSync(tempHome, { recursive: true, force: true });
430+
}
431+
});
365432
});

src/agents/cli-credentials.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const log = createSubsystemLogger("agents/auth-profiles");
1313
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
1414
const CODEX_CLI_AUTH_FILENAME = "auth.json";
1515
const MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH = ".minimax/oauth_creds.json";
16+
const GEMINI_CLI_CREDENTIALS_RELATIVE_PATH = ".gemini/oauth_creds.json";
1617

1718
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
1819
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
@@ -27,11 +28,13 @@ type CachedValue<T> = {
2728
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
2829
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
2930
let minimaxCliCache: CachedValue<MiniMaxCliCredential> | null = null;
31+
let geminiCliCache: CachedValue<GeminiCliCredential> | null = null;
3032

3133
export function resetCliCredentialCachesForTest(): void {
3234
claudeCliCache = null;
3335
codexCliCache = null;
3436
minimaxCliCache = null;
37+
geminiCliCache = null;
3538
}
3639

3740
export type ClaudeCliCredential =
@@ -67,6 +70,16 @@ export type MiniMaxCliCredential = {
6770
expires: number;
6871
};
6972

73+
export type GeminiCliCredential = {
74+
type: "oauth";
75+
provider: "google-gemini-cli";
76+
access: string;
77+
refresh: string;
78+
expires: number;
79+
accountId?: string;
80+
email?: string;
81+
};
82+
7083
type ClaudeCliFileOptions = {
7184
homeDir?: string;
7285
};
@@ -131,6 +144,11 @@ function resolveMiniMaxCliCredentialsPath(homeDir?: string) {
131144
return path.join(baseDir, MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH);
132145
}
133146

147+
function resolveGeminiCliCredentialsPath(homeDir?: string) {
148+
const baseDir = homeDir ?? resolveUserPath("~");
149+
return path.join(baseDir, GEMINI_CLI_CREDENTIALS_RELATIVE_PATH);
150+
}
151+
134152
function readFileMtimeMs(filePath: string): number | null {
135153
try {
136154
return fs.statSync(filePath).mtimeMs;
@@ -211,6 +229,22 @@ function decodeJwtExpiryMs(token: string): number | null {
211229
}
212230
}
213231

232+
function decodeJwtIdentityClaims(token: string): { sub?: string; email?: string } {
233+
const parts = token.split(".");
234+
if (parts.length < 2) {
235+
return {};
236+
}
237+
try {
238+
const payloadRaw = Buffer.from(parts[1], "base64url").toString("utf8");
239+
const payload = JSON.parse(payloadRaw) as { sub?: unknown; email?: unknown };
240+
const sub = typeof payload.sub === "string" && payload.sub ? payload.sub : undefined;
241+
const email = typeof payload.email === "string" && payload.email ? payload.email : undefined;
242+
return { sub, email };
243+
} catch {
244+
return {};
245+
}
246+
}
247+
214248
function readCodexKeychainAuthRecord(options?: {
215249
codexHome?: string;
216250
platform?: NodeJS.Platform;
@@ -328,6 +362,49 @@ function readMiniMaxCliCredentials(options?: { homeDir?: string }): MiniMaxCliCr
328362
return readPortalCliOauthCredentials(credPath, "minimax-portal");
329363
}
330364

365+
function readGeminiCliCredentials(options?: { homeDir?: string }): GeminiCliCredential | null {
366+
const credPath = resolveGeminiCliCredentialsPath(options?.homeDir);
367+
const raw = loadJsonFile(credPath);
368+
if (!raw || typeof raw !== "object") {
369+
return null;
370+
}
371+
const data = raw as Record<string, unknown>;
372+
const accessToken = data.access_token;
373+
const refreshToken = data.refresh_token;
374+
const expiresAt = data.expiry_date;
375+
376+
if (typeof accessToken !== "string" || !accessToken) {
377+
return null;
378+
}
379+
if (typeof refreshToken !== "string" || !refreshToken) {
380+
return null;
381+
}
382+
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) {
383+
return null;
384+
}
385+
386+
// Gemini CLI's login flow stores the openid id_token alongside the OAuth
387+
// tokens. Decode it once here to lift the Google account identity (sub,
388+
// email) onto the credential so the shared OAuth-identity encoder can key
389+
// the auth epoch on stable, non-secret identity material — matching the
390+
// Claude/Codex contract that #70132 codifies. Without this lift the encoder
391+
// collapses to a provider-keyed constant and stale bindings can survive a
392+
// re-login under a different Google account.
393+
const idTokenRaw = data.id_token;
394+
const identity =
395+
typeof idTokenRaw === "string" && idTokenRaw ? decodeJwtIdentityClaims(idTokenRaw) : {};
396+
397+
return {
398+
type: "oauth",
399+
provider: "google-gemini-cli",
400+
access: accessToken,
401+
refresh: refreshToken,
402+
expires: expiresAt,
403+
...(identity.email ? { email: identity.email } : {}),
404+
...(identity.sub ? { accountId: identity.sub } : {}),
405+
};
406+
}
407+
331408
function readClaudeCliKeychainCredentials(
332409
execSyncImpl: ExecSyncFn = execSync,
333410
): ClaudeCliCredential | null {
@@ -609,3 +686,20 @@ export function readMiniMaxCliCredentialsCached(options?: {
609686
readSourceFingerprint: () => readFileMtimeMs(credPath),
610687
});
611688
}
689+
690+
export function readGeminiCliCredentialsCached(options?: {
691+
ttlMs?: number;
692+
homeDir?: string;
693+
}): GeminiCliCredential | null {
694+
const credPath = resolveGeminiCliCredentialsPath(options?.homeDir);
695+
return readCachedCliCredential({
696+
ttlMs: options?.ttlMs ?? 0,
697+
cache: geminiCliCache,
698+
cacheKey: credPath,
699+
read: () => readGeminiCliCredentials({ homeDir: options?.homeDir }),
700+
setCache: (next) => {
701+
geminiCliCache = next;
702+
},
703+
readSourceFingerprint: () => readFileMtimeMs(credPath),
704+
});
705+
}

0 commit comments

Comments
 (0)