diff --git a/extensions/google-antigravity-auth/index.ts b/extensions/google-antigravity-auth/index.ts index bd4bf9862..81d6ee9f7 100644 --- a/extensions/google-antigravity-auth/index.ts +++ b/extensions/google-antigravity-auth/index.ts @@ -435,3 +435,6 @@ const antigravityPlugin = { }; export default antigravityPlugin; + +// Re-export refresh function +export { refreshGoogleAntigravityCredentials } from "./refresh.js"; diff --git a/extensions/google-antigravity-auth/refresh.ts b/extensions/google-antigravity-auth/refresh.ts new file mode 100644 index 000000000..e012e2d7b --- /dev/null +++ b/extensions/google-antigravity-auth/refresh.ts @@ -0,0 +1,61 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + +// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync +const decode = (s: string) => Buffer.from(s, "base64").toString(); +const CLIENT_ID = decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==", +); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); + +export type GoogleAntigravityCredentials = { + access: string; + refresh: string; + expires: number; +}; + +export async function refreshGoogleAntigravityCredentials( + credentials: GoogleAntigravityCredentials, +): Promise { + if (!credentials.refresh?.trim()) { + throw new Error("Google Antigravity OAuth refresh token missing; re-authenticate."); + } + + const response = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: credentials.refresh, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }), + }); + + if (!response.ok) { + const text = await response.text(); + if (response.status === 400 || response.status === 401) { + throw new Error( + `Google Antigravity OAuth refresh token expired or invalid. Re-authenticate with \`clawdbot models auth login --provider google-antigravity\`.`, + ); + } + throw new Error(`Google Antigravity OAuth refresh failed: ${text || response.statusText}`); + } + + const payload = await response.json(); + if (!payload.access_token || !payload.expires_in) { + throw new Error("Google Antigravity OAuth refresh response missing access token."); + } + + return { + ...credentials, + access: payload.access_token, + refresh: payload.refresh_token || credentials.refresh, + expires: Date.now() + payload.expires_in * 1000, + }; +} diff --git a/extensions/google-gemini-cli-auth/oauth.ts b/extensions/google-gemini-cli-auth/oauth.ts index 2d923631a..2842c2d38 100644 --- a/extensions/google-gemini-cli-auth/oauth.ts +++ b/extensions/google-gemini-cli-auth/oauth.ts @@ -508,6 +508,9 @@ async function pollOperation( throw new Error("Operation polling timeout"); } +// Re-export refresh function +export { refreshGoogleGeminiCliCredentials } from "./refresh.js"; + export async function loginGeminiCliOAuth(ctx: GeminiCliOAuthContext): Promise { const needsManual = shouldUseManualOAuthFlow(ctx.isRemote); await ctx.note( diff --git a/extensions/google-gemini-cli-auth/refresh.ts b/extensions/google-gemini-cli-auth/refresh.ts new file mode 100644 index 000000000..7cfc0b2b5 --- /dev/null +++ b/extensions/google-gemini-cli-auth/refresh.ts @@ -0,0 +1,125 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { delimiter, dirname, join } from "node:path"; + +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + +const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; +const CLIENT_SECRET_KEYS = [ + "CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET", + "GEMINI_CLI_OAUTH_CLIENT_SECRET", +]; + +export type GeminiCliOAuthCredentials = { + access: string; + refresh: string; + expires: number; + email?: string; + projectId: string; +}; + +function resolveEnv(keys: string[]): string | undefined { + for (const key of keys) { + const value = process.env[key]?.trim(); + if (value) return value; + } + return undefined; +} + +function findInPath(name: string): string | null { + const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; + const paths = process.env.PATH?.split(delimiter) || []; + for (const dir of paths) { + for (const ext of exts) { + const full = join(dir, name + ext); + if (existsSync(full)) return full; + } + } + return null; +} + +function resolveCredentials(): { clientId: string; clientSecret: string | undefined } { + const envClientId = resolveEnv(CLIENT_ID_KEYS); + const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); + if (envClientId) { + return { clientId: envClientId, clientSecret: envClientSecret }; + } + + // Try to extract from Gemini CLI + try { + const geminiPath = findInPath("gemini"); + if (geminiPath) { + const resolvedPath = realpathSync(geminiPath); + const geminiCliDir = dirname(dirname(resolvedPath)); + const searchPaths = [ + join(geminiCliDir, "node_modules", "@google", "gemini-cli-core", "dist", "src", "code_assist", "oauth2.js"), + join(geminiCliDir, "node_modules", "@google", "gemini-cli-core", "dist", "code_assist", "oauth2.js"), + ]; + for (const p of searchPaths) { + if (existsSync(p)) { + const content = readFileSync(p, "utf8"); + const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); + const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); + if (idMatch && secretMatch) { + return { clientId: idMatch[1], clientSecret: secretMatch[1] }; + } + } + } + } + } catch { + // Extraction failed + } + + throw new Error( + "Gemini CLI OAuth credentials not found. Set GEMINI_CLI_OAUTH_CLIENT_ID and GEMINI_CLI_OAUTH_CLIENT_SECRET, or ensure gemini-cli is installed.", + ); +} + +export async function refreshGoogleGeminiCliCredentials( + credentials: GeminiCliOAuthCredentials, +): Promise { + if (!credentials.refresh?.trim()) { + throw new Error("Google Gemini CLI OAuth refresh token missing; re-authenticate."); + } + + const { clientId, clientSecret } = resolveCredentials(); + + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: credentials.refresh, + client_id: clientId, + }); + if (clientSecret) { + body.set("client_secret", clientSecret); + } + + const response = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body, + }); + + if (!response.ok) { + const text = await response.text(); + if (response.status === 400 || response.status === 401) { + throw new Error( + `Google Gemini CLI OAuth refresh token expired or invalid. Re-authenticate with \`clawdbot models auth login --provider google-gemini-cli\`.`, + ); + } + throw new Error(`Google Gemini CLI OAuth refresh failed: ${text || response.statusText}`); + } + + const payload = await response.json(); + if (!payload.access_token || !payload.expires_in) { + throw new Error("Google Gemini CLI OAuth refresh response missing access token."); + } + + return { + ...credentials, + access: payload.access_token, + refresh: payload.refresh_token || credentials.refresh, + expires: Date.now() + payload.expires_in * 1000, + }; +}