mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix(cli): key gemini cli auth epoch on google account identity (#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.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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<CliAuthEpochDeps>): 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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T> = {
|
||||
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
|
||||
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
|
||||
let minimaxCliCache: CachedValue<MiniMaxCliCredential> | null = null;
|
||||
let geminiCliCache: CachedValue<GeminiCliCredential> | 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<string, unknown>;
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user