diff --git a/extensions/google/oauth.credentials.ts b/extensions/google/oauth.credentials.ts new file mode 100644 index 00000000000..1c1e88db042 --- /dev/null +++ b/extensions/google/oauth.credentials.ts @@ -0,0 +1,163 @@ +import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import { delimiter, dirname, join } from "node:path"; +import { CLIENT_ID_KEYS, CLIENT_SECRET_KEYS } from "./oauth.shared.js"; + +function resolveEnv(keys: string[]): string | undefined { + for (const key of keys) { + const value = process.env[key]?.trim(); + if (value) { + return value; + } + } + return undefined; +} + +let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; + +export function clearCredentialsCache(): void { + cachedGeminiCliCredentials = null; +} + +export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { + if (cachedGeminiCliCredentials) { + return cachedGeminiCliCredentials; + } + + try { + const geminiPath = findInPath("gemini"); + if (!geminiPath) { + return null; + } + + const resolvedPath = realpathSync(geminiPath); + const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath); + + let content: string | null = null; + for (const geminiCliDir of geminiCliDirs) { + 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 path of searchPaths) { + if (existsSync(path)) { + content = readFileSync(path, "utf8"); + break; + } + } + if (content) { + break; + } + const found = findFile(geminiCliDir, "oauth2.js", 10); + if (found) { + content = readFileSync(found, "utf8"); + break; + } + } + if (!content) { + return null; + } + + const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); + const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); + if (idMatch && secretMatch) { + cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] }; + return cachedGeminiCliCredentials; + } + } catch { + // Gemini CLI not installed or extraction failed + } + return null; +} + +function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[] { + const binDir = dirname(geminiPath); + const candidates = [ + dirname(dirname(resolvedPath)), + join(dirname(resolvedPath), "node_modules", "@google", "gemini-cli"), + join(binDir, "node_modules", "@google", "gemini-cli"), + join(dirname(binDir), "node_modules", "@google", "gemini-cli"), + join(dirname(binDir), "lib", "node_modules", "@google", "gemini-cli"), + ]; + + const deduped: string[] = []; + const seen = new Set(); + for (const candidate of candidates) { + const key = + process.platform === "win32" ? candidate.replace(/\\/g, "/").toLowerCase() : candidate; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(candidate); + } + return deduped; +} + +function findInPath(name: string): string | null { + const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + for (const ext of exts) { + const path = join(dir, name + ext); + if (existsSync(path)) { + return path; + } + } + } + return null; +} + +function findFile(dir: string, name: string, depth: number): string | null { + if (depth <= 0) { + return null; + } + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isFile() && entry.name === name) { + return path; + } + if (entry.isDirectory() && !entry.name.startsWith(".")) { + const found = findFile(path, name, depth - 1); + if (found) { + return found; + } + } + } + } catch {} + return null; +} + +export function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { + const envClientId = resolveEnv(CLIENT_ID_KEYS); + const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); + if (envClientId) { + return { clientId: envClientId, clientSecret: envClientSecret }; + } + + const extracted = extractGeminiCliCredentials(); + if (extracted) { + return extracted; + } + + throw new Error( + "Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.", + ); +} diff --git a/extensions/google/oauth.flow.ts b/extensions/google/oauth.flow.ts new file mode 100644 index 00000000000..00cab07dc68 --- /dev/null +++ b/extensions/google/oauth.flow.ts @@ -0,0 +1,152 @@ +import { createHash, randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import { isWSL2Sync } from "../../src/infra/wsl.js"; +import { resolveOAuthClientConfig } from "./oauth.credentials.js"; +import { AUTH_URL, REDIRECT_URI, SCOPES } from "./oauth.shared.js"; + +export function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} + +export function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +export function buildAuthUrl(challenge: string, verifier: string): string { + const { clientId } = resolveOAuthClientConfig(); + const params = new URLSearchParams({ + client_id: clientId, + response_type: "code", + redirect_uri: REDIRECT_URI, + scope: SCOPES.join(" "), + code_challenge: challenge, + code_challenge_method: "S256", + state: verifier, + access_type: "offline", + prompt: "consent", + }); + return `${AUTH_URL}?${params.toString()}`; +} + +export function parseCallbackInput( + input: string, + expectedState: string, +): { code: string; state: string } | { error: string } { + const trimmed = input.trim(); + if (!trimmed) { + return { error: "No input provided" }; + } + + try { + const url = new URL(trimmed); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state") ?? expectedState; + if (!code) { + return { error: "Missing 'code' parameter in URL" }; + } + if (!state) { + return { error: "Missing 'state' parameter. Paste the full URL." }; + } + return { code, state }; + } catch { + if (!expectedState) { + return { error: "Paste the full redirect URL, not just the code." }; + } + return { code: trimmed, state: expectedState }; + } +} + +export async function waitForLocalCallback(params: { + expectedState: string; + timeoutMs: number; + onProgress?: (message: string) => void; +}): Promise<{ code: string; state: string }> { + const port = 8085; + const hostname = "localhost"; + const expectedPath = "/oauth2callback"; + + return new Promise<{ code: string; state: string }>((resolve, reject) => { + let timeout: NodeJS.Timeout | null = null; + const server = createServer((req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`); + if (requestUrl.pathname !== expectedPath) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain"); + res.end("Not found"); + return; + } + + const error = requestUrl.searchParams.get("error"); + const code = requestUrl.searchParams.get("code")?.trim(); + const state = requestUrl.searchParams.get("state")?.trim(); + + if (error) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end(`Authentication failed: ${error}`); + finish(new Error(`OAuth error: ${error}`)); + return; + } + + if (!code || !state) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end("Missing code or state"); + finish(new Error("Missing OAuth code or state")); + return; + } + + if (state !== params.expectedState) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end("Invalid state"); + finish(new Error("OAuth state mismatch")); + return; + } + + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end( + "" + + "

Gemini CLI OAuth complete

" + + "

You can close this window and return to OpenClaw.

", + ); + + finish(undefined, { code, state }); + } catch (err) { + finish(err instanceof Error ? err : new Error("OAuth callback failed")); + } + }); + + const finish = (err?: Error, result?: { code: string; state: string }) => { + if (timeout) { + clearTimeout(timeout); + } + try { + server.close(); + } catch { + // ignore close errors + } + if (err) { + reject(err); + } else if (result) { + resolve(result); + } + }; + + server.once("error", (err) => { + finish(err instanceof Error ? err : new Error("OAuth callback server error")); + }); + + server.listen(port, hostname, () => { + params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`); + }); + + timeout = setTimeout(() => { + finish(new Error("OAuth callback timeout")); + }, params.timeoutMs); + }); +} diff --git a/extensions/google/oauth.http.ts b/extensions/google/oauth.http.ts new file mode 100644 index 00000000000..6c07c447143 --- /dev/null +++ b/extensions/google/oauth.http.ts @@ -0,0 +1,24 @@ +import { fetchWithSsrFGuard } from "../../src/infra/net/fetch-guard.js"; +import { DEFAULT_FETCH_TIMEOUT_MS } from "./oauth.shared.js"; + +export async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, +): Promise { + const { response, release } = await fetchWithSsrFGuard({ + url, + init, + timeoutMs, + }); + try { + const body = await response.arrayBuffer(); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } finally { + await release(); + } +} diff --git a/extensions/google/oauth.project.ts b/extensions/google/oauth.project.ts new file mode 100644 index 00000000000..fa163b12f19 --- /dev/null +++ b/extensions/google/oauth.project.ts @@ -0,0 +1,235 @@ +import { fetchWithTimeout } from "./oauth.http.js"; +import { + CODE_ASSIST_ENDPOINT_PROD, + LOAD_CODE_ASSIST_ENDPOINTS, + TIER_FREE, + TIER_LEGACY, + TIER_STANDARD, + USERINFO_URL, +} from "./oauth.shared.js"; + +function resolvePlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" { + if (process.platform === "win32") { + return "WINDOWS"; + } + if (process.platform === "darwin") { + return "MACOS"; + } + return "PLATFORM_UNSPECIFIED"; +} + +async function getUserEmail(accessToken: string): Promise { + try { + const response = await fetchWithTimeout(USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (response.ok) { + const data = (await response.json()) as { email?: string }; + return data.email; + } + } catch { + // ignore + } + return undefined; +} + +function isVpcScAffected(payload: unknown): boolean { + if (!payload || typeof payload !== "object") { + return false; + } + const error = (payload as { error?: unknown }).error; + if (!error || typeof error !== "object") { + return false; + } + const details = (error as { details?: unknown[] }).details; + if (!Array.isArray(details)) { + return false; + } + return details.some( + (item) => + typeof item === "object" && + item && + (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED", + ); +} + +function getDefaultTier( + allowedTiers?: Array<{ id?: string; isDefault?: boolean }>, +): { id?: string } | undefined { + if (!allowedTiers?.length) { + return { id: TIER_LEGACY }; + } + return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY }; +} + +async function pollOperation( + endpoint: string, + operationName: string, + headers: Record, +): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> { + for (let attempt = 0; attempt < 24; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + const response = await fetchWithTimeout(`${endpoint}/v1internal/${operationName}`, { + headers, + }); + if (!response.ok) { + continue; + } + const data = (await response.json()) as { + done?: boolean; + response?: { cloudaicompanionProject?: { id?: string } }; + }; + if (data.done) { + return data; + } + } + throw new Error("Operation polling timeout"); +} + +export async function resolveGoogleOAuthIdentity(accessToken: string): Promise<{ + email?: string; + projectId: string; +}> { + const email = await getUserEmail(accessToken); + const projectId = await discoverProject(accessToken); + return { email, projectId }; +} + +async function discoverProject(accessToken: string): Promise { + const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; + const platform = resolvePlatform(); + const metadata = { + ideType: "ANTIGRAVITY", + platform, + pluginType: "GEMINI", + }; + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": `gl-node/${process.versions.node}`, + "Client-Metadata": JSON.stringify(metadata), + }; + + const loadBody = { + ...(envProject ? { cloudaicompanionProject: envProject } : {}), + metadata: { + ...metadata, + ...(envProject ? { duetProject: envProject } : {}), + }, + }; + + let data: { + currentTier?: { id?: string }; + cloudaicompanionProject?: string | { id?: string }; + allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; + } = {}; + let activeEndpoint = CODE_ASSIST_ENDPOINT_PROD; + let loadError: Error | undefined; + for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { + try { + const response = await fetchWithTimeout(`${endpoint}/v1internal:loadCodeAssist`, { + method: "POST", + headers, + body: JSON.stringify(loadBody), + }); + + if (!response.ok) { + const errorPayload = await response.json().catch(() => null); + if (isVpcScAffected(errorPayload)) { + data = { currentTier: { id: TIER_STANDARD } }; + activeEndpoint = endpoint; + loadError = undefined; + break; + } + loadError = new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); + continue; + } + + data = (await response.json()) as typeof data; + activeEndpoint = endpoint; + loadError = undefined; + break; + } catch (err) { + loadError = err instanceof Error ? err : new Error("loadCodeAssist failed", { cause: err }); + } + } + + const hasLoadCodeAssistData = + Boolean(data.currentTier) || + Boolean(data.cloudaicompanionProject) || + Boolean(data.allowedTiers?.length); + if (!hasLoadCodeAssistData && loadError) { + if (envProject) { + return envProject; + } + throw loadError; + } + + if (data.currentTier) { + const project = data.cloudaicompanionProject; + if (typeof project === "string" && project) { + return project; + } + if (typeof project === "object" && project?.id) { + return project.id; + } + if (envProject) { + return envProject; + } + throw new Error( + "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", + ); + } + + const tier = getDefaultTier(data.allowedTiers); + const tierId = tier?.id || TIER_FREE; + if (tierId !== TIER_FREE && !envProject) { + throw new Error( + "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", + ); + } + + const onboardBody: Record = { + tierId, + metadata: { + ...metadata, + }, + }; + if (tierId !== TIER_FREE && envProject) { + onboardBody.cloudaicompanionProject = envProject; + (onboardBody.metadata as Record).duetProject = envProject; + } + + const onboardResponse = await fetchWithTimeout(`${activeEndpoint}/v1internal:onboardUser`, { + method: "POST", + headers, + body: JSON.stringify(onboardBody), + }); + + if (!onboardResponse.ok) { + throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`); + } + + let lro = (await onboardResponse.json()) as { + done?: boolean; + name?: string; + response?: { cloudaicompanionProject?: { id?: string } }; + }; + + if (!lro.done && lro.name) { + lro = await pollOperation(activeEndpoint, lro.name, headers); + } + + const projectId = lro.response?.cloudaicompanionProject?.id; + if (projectId) { + return projectId; + } + if (envProject) { + return envProject; + } + + throw new Error( + "Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.", + ); +} diff --git a/extensions/google/oauth.shared.ts b/extensions/google/oauth.shared.ts new file mode 100644 index 00000000000..2b8186737a2 --- /dev/null +++ b/extensions/google/oauth.shared.ts @@ -0,0 +1,44 @@ +export const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; +export const CLIENT_SECRET_KEYS = [ + "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", + "GEMINI_CLI_OAUTH_CLIENT_SECRET", +]; +export const REDIRECT_URI = "http://localhost:8085/oauth2callback"; +export const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +export const TOKEN_URL = "https://oauth2.googleapis.com/token"; +export const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"; +export const CODE_ASSIST_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"; +export const CODE_ASSIST_ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com"; +export const CODE_ASSIST_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com"; +export const LOAD_CODE_ASSIST_ENDPOINTS = [ + CODE_ASSIST_ENDPOINT_PROD, + CODE_ASSIST_ENDPOINT_DAILY, + CODE_ASSIST_ENDPOINT_AUTOPUSH, +]; +export const DEFAULT_FETCH_TIMEOUT_MS = 10_000; +export const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +]; + +export const TIER_FREE = "free-tier"; +export const TIER_LEGACY = "legacy-tier"; +export const TIER_STANDARD = "standard-tier"; + +export type GeminiCliOAuthCredentials = { + access: string; + refresh: string; + expires: number; + email?: string; + projectId: string; +}; + +export type GeminiCliOAuthContext = { + isRemote: boolean; + openUrl: (url: string) => Promise; + log: (msg: string) => void; + note: (message: string, title?: string) => Promise; + prompt: (message: string) => Promise; + progress: { update: (msg: string) => void; stop: (msg?: string) => void }; +}; diff --git a/extensions/google/oauth.token.ts b/extensions/google/oauth.token.ts new file mode 100644 index 00000000000..6e2b68c4403 --- /dev/null +++ b/extensions/google/oauth.token.ts @@ -0,0 +1,57 @@ +import { resolveOAuthClientConfig } from "./oauth.credentials.js"; +import { fetchWithTimeout } from "./oauth.http.js"; +import { resolveGoogleOAuthIdentity } from "./oauth.project.js"; +import { REDIRECT_URI, TOKEN_URL, type GeminiCliOAuthCredentials } from "./oauth.shared.js"; + +export async function exchangeCodeForTokens( + code: string, + verifier: string, +): Promise { + const { clientId, clientSecret } = resolveOAuthClientConfig(); + const body = new URLSearchParams({ + client_id: clientId, + code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }); + if (clientSecret) { + body.set("client_secret", clientSecret); + } + + const response = await fetchWithTimeout(TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + Accept: "*/*", + "User-Agent": "google-api-nodejs-client/9.15.1", + }, + body, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token exchange failed: ${errorText}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + if (!data.refresh_token) { + throw new Error("No refresh token received. Please try again."); + } + + const identity = await resolveGoogleOAuthIdentity(data.access_token); + const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; + + return { + refresh: data.refresh_token, + access: data.access_token, + expires: expiresAt, + projectId: identity.projectId, + email: identity.email, + }; +} diff --git a/extensions/google/oauth.ts b/extensions/google/oauth.ts index 5932b3a237b..be12c64a4e1 100644 --- a/extensions/google/oauth.ts +++ b/extensions/google/oauth.ts @@ -1,661 +1,16 @@ -import { createHash, randomBytes } from "node:crypto"; -import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; -import { createServer } from "node:http"; -import { delimiter, dirname, join } from "node:path"; -import { fetchWithSsrFGuard } from "../../src/infra/net/fetch-guard.js"; -import { isWSL2Sync } from "../../src/infra/wsl.js"; - -const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; -const CLIENT_SECRET_KEYS = [ - "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", - "GEMINI_CLI_OAUTH_CLIENT_SECRET", -]; -const REDIRECT_URI = "http://localhost:8085/oauth2callback"; -const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const TOKEN_URL = "https://oauth2.googleapis.com/token"; -const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"; -const CODE_ASSIST_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"; -const CODE_ASSIST_ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com"; -const CODE_ASSIST_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com"; -const LOAD_CODE_ASSIST_ENDPOINTS = [ - CODE_ASSIST_ENDPOINT_PROD, - CODE_ASSIST_ENDPOINT_DAILY, - CODE_ASSIST_ENDPOINT_AUTOPUSH, -]; -const DEFAULT_FETCH_TIMEOUT_MS = 10_000; -const SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", -]; - -const TIER_FREE = "free-tier"; -const TIER_LEGACY = "legacy-tier"; -const TIER_STANDARD = "standard-tier"; - -export type GeminiCliOAuthCredentials = { - access: string; - refresh: string; - expires: number; - email?: string; - projectId: string; -}; - -export type GeminiCliOAuthContext = { - isRemote: boolean; - openUrl: (url: string) => Promise; - log: (msg: string) => void; - note: (message: string, title?: string) => Promise; - prompt: (message: string) => Promise; - progress: { update: (msg: string) => void; stop: (msg?: string) => void }; -}; - -function resolveEnv(keys: string[]): string | undefined { - for (const key of keys) { - const value = process.env[key]?.trim(); - if (value) { - return value; - } - } - return undefined; -} - -let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; - -/** @internal */ -export function clearCredentialsCache(): void { - cachedGeminiCliCredentials = null; -} - -/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */ -export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { - if (cachedGeminiCliCredentials) { - return cachedGeminiCliCredentials; - } - - try { - const geminiPath = findInPath("gemini"); - if (!geminiPath) { - return null; - } - - const resolvedPath = realpathSync(geminiPath); - const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath); - - let content: string | null = null; - for (const geminiCliDir of geminiCliDirs) { - 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)) { - content = readFileSync(p, "utf8"); - break; - } - } - if (content) { - break; - } - const found = findFile(geminiCliDir, "oauth2.js", 10); - if (found) { - content = readFileSync(found, "utf8"); - break; - } - } - if (!content) { - return null; - } - - const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); - const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); - if (idMatch && secretMatch) { - cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] }; - return cachedGeminiCliCredentials; - } - } catch { - // Gemini CLI not installed or extraction failed - } - return null; -} - -function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[] { - const binDir = dirname(geminiPath); - const candidates = [ - dirname(dirname(resolvedPath)), - join(dirname(resolvedPath), "node_modules", "@google", "gemini-cli"), - join(binDir, "node_modules", "@google", "gemini-cli"), - join(dirname(binDir), "node_modules", "@google", "gemini-cli"), - join(dirname(binDir), "lib", "node_modules", "@google", "gemini-cli"), - ]; - - const deduped: string[] = []; - const seen = new Set(); - for (const candidate of candidates) { - const key = - process.platform === "win32" ? candidate.replace(/\\/g, "/").toLowerCase() : candidate; - if (seen.has(key)) { - continue; - } - seen.add(key); - deduped.push(candidate); - } - return deduped; -} - -function findInPath(name: string): string | null { - const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; - for (const dir of (process.env.PATH ?? "").split(delimiter)) { - for (const ext of exts) { - const p = join(dir, name + ext); - if (existsSync(p)) { - return p; - } - } - } - return null; -} - -function findFile(dir: string, name: string, depth: number): string | null { - if (depth <= 0) { - return null; - } - try { - for (const e of readdirSync(dir, { withFileTypes: true })) { - const p = join(dir, e.name); - if (e.isFile() && e.name === name) { - return p; - } - if (e.isDirectory() && !e.name.startsWith(".")) { - const found = findFile(p, name, depth - 1); - if (found) { - return found; - } - } - } - } catch {} - return null; -} - -function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { - // 1. Check env vars first (user override) - const envClientId = resolveEnv(CLIENT_ID_KEYS); - const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); - if (envClientId) { - return { clientId: envClientId, clientSecret: envClientSecret }; - } - - // 2. Try to extract from installed Gemini CLI - const extracted = extractGeminiCliCredentials(); - if (extracted) { - return extracted; - } - - // 3. No credentials available - throw new Error( - "Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.", - ); -} - -function shouldUseManualOAuthFlow(isRemote: boolean): boolean { - return isRemote || isWSL2Sync(); -} - -function generatePkce(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("hex"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - -function resolvePlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" { - if (process.platform === "win32") { - return "WINDOWS"; - } - if (process.platform === "darwin") { - return "MACOS"; - } - // Google's loadCodeAssist API rejects "LINUX" as an invalid Platform enum value. - // Use "PLATFORM_UNSPECIFIED" for Linux and other platforms to match the pi-ai runtime. - return "PLATFORM_UNSPECIFIED"; -} - -async function fetchWithTimeout( - url: string, - init: RequestInit, - timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, -): Promise { - const { response, release } = await fetchWithSsrFGuard({ - url, - init, - timeoutMs, - }); - try { - const body = await response.arrayBuffer(); - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - } finally { - await release(); - } -} - -function buildAuthUrl(challenge: string, verifier: string): string { - const { clientId } = resolveOAuthClientConfig(); - const params = new URLSearchParams({ - client_id: clientId, - response_type: "code", - redirect_uri: REDIRECT_URI, - scope: SCOPES.join(" "), - code_challenge: challenge, - code_challenge_method: "S256", - state: verifier, - access_type: "offline", - prompt: "consent", - }); - return `${AUTH_URL}?${params.toString()}`; -} - -function parseCallbackInput( - input: string, - expectedState: string, -): { code: string; state: string } | { error: string } { - const trimmed = input.trim(); - if (!trimmed) { - return { error: "No input provided" }; - } - - try { - const url = new URL(trimmed); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state") ?? expectedState; - if (!code) { - return { error: "Missing 'code' parameter in URL" }; - } - if (!state) { - return { error: "Missing 'state' parameter. Paste the full URL." }; - } - return { code, state }; - } catch { - if (!expectedState) { - return { error: "Paste the full redirect URL, not just the code." }; - } - return { code: trimmed, state: expectedState }; - } -} - -async function waitForLocalCallback(params: { - expectedState: string; - timeoutMs: number; - onProgress?: (message: string) => void; -}): Promise<{ code: string; state: string }> { - const port = 8085; - const hostname = "localhost"; - const expectedPath = "/oauth2callback"; - - return new Promise<{ code: string; state: string }>((resolve, reject) => { - let timeout: NodeJS.Timeout | null = null; - const server = createServer((req, res) => { - try { - const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`); - if (requestUrl.pathname !== expectedPath) { - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain"); - res.end("Not found"); - return; - } - - const error = requestUrl.searchParams.get("error"); - const code = requestUrl.searchParams.get("code")?.trim(); - const state = requestUrl.searchParams.get("state")?.trim(); - - if (error) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain"); - res.end(`Authentication failed: ${error}`); - finish(new Error(`OAuth error: ${error}`)); - return; - } - - if (!code || !state) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain"); - res.end("Missing code or state"); - finish(new Error("Missing OAuth code or state")); - return; - } - - if (state !== params.expectedState) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain"); - res.end("Invalid state"); - finish(new Error("OAuth state mismatch")); - return; - } - - res.statusCode = 200; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end( - "" + - "

Gemini CLI OAuth complete

" + - "

You can close this window and return to OpenClaw.

", - ); - - finish(undefined, { code, state }); - } catch (err) { - finish(err instanceof Error ? err : new Error("OAuth callback failed")); - } - }); - - const finish = (err?: Error, result?: { code: string; state: string }) => { - if (timeout) { - clearTimeout(timeout); - } - try { - server.close(); - } catch { - // ignore close errors - } - if (err) { - reject(err); - } else if (result) { - resolve(result); - } - }; - - server.once("error", (err) => { - finish(err instanceof Error ? err : new Error("OAuth callback server error")); - }); - - server.listen(port, hostname, () => { - params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`); - }); - - timeout = setTimeout(() => { - finish(new Error("OAuth callback timeout")); - }, params.timeoutMs); - }); -} - -async function exchangeCodeForTokens( - code: string, - verifier: string, -): Promise { - const { clientId, clientSecret } = resolveOAuthClientConfig(); - const body = new URLSearchParams({ - client_id: clientId, - code, - grant_type: "authorization_code", - redirect_uri: REDIRECT_URI, - code_verifier: verifier, - }); - if (clientSecret) { - body.set("client_secret", clientSecret); - } - - const response = await fetchWithTimeout(TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - Accept: "*/*", - "User-Agent": "google-api-nodejs-client/9.15.1", - }, - body, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Token exchange failed: ${errorText}`); - } - - const data = (await response.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - }; - - if (!data.refresh_token) { - throw new Error("No refresh token received. Please try again."); - } - - const email = await getUserEmail(data.access_token); - const projectId = await discoverProject(data.access_token); - const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; - - return { - refresh: data.refresh_token, - access: data.access_token, - expires: expiresAt, - projectId, - email, - }; -} - -async function getUserEmail(accessToken: string): Promise { - try { - const response = await fetchWithTimeout(USERINFO_URL, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (response.ok) { - const data = (await response.json()) as { email?: string }; - return data.email; - } - } catch { - // ignore - } - return undefined; -} - -async function discoverProject(accessToken: string): Promise { - const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; - const platform = resolvePlatform(); - const metadata = { - ideType: "ANTIGRAVITY", - platform, - pluginType: "GEMINI", - }; - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": `gl-node/${process.versions.node}`, - "Client-Metadata": JSON.stringify(metadata), - }; - - const loadBody = { - ...(envProject ? { cloudaicompanionProject: envProject } : {}), - metadata: { - ...metadata, - ...(envProject ? { duetProject: envProject } : {}), - }, - }; - - let data: { - currentTier?: { id?: string }; - cloudaicompanionProject?: string | { id?: string }; - allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; - } = {}; - let activeEndpoint = CODE_ASSIST_ENDPOINT_PROD; - let loadError: Error | undefined; - for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { - try { - const response = await fetchWithTimeout(`${endpoint}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify(loadBody), - }); - - if (!response.ok) { - const errorPayload = await response.json().catch(() => null); - if (isVpcScAffected(errorPayload)) { - data = { currentTier: { id: TIER_STANDARD } }; - activeEndpoint = endpoint; - loadError = undefined; - break; - } - loadError = new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); - continue; - } - - data = (await response.json()) as typeof data; - activeEndpoint = endpoint; - loadError = undefined; - break; - } catch (err) { - loadError = err instanceof Error ? err : new Error("loadCodeAssist failed", { cause: err }); - } - } - - const hasLoadCodeAssistData = - Boolean(data.currentTier) || - Boolean(data.cloudaicompanionProject) || - Boolean(data.allowedTiers?.length); - if (!hasLoadCodeAssistData && loadError) { - if (envProject) { - return envProject; - } - throw loadError; - } - - if (data.currentTier) { - const project = data.cloudaicompanionProject; - if (typeof project === "string" && project) { - return project; - } - if (typeof project === "object" && project?.id) { - return project.id; - } - if (envProject) { - return envProject; - } - throw new Error( - "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", - ); - } - - const tier = getDefaultTier(data.allowedTiers); - const tierId = tier?.id || TIER_FREE; - if (tierId !== TIER_FREE && !envProject) { - throw new Error( - "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", - ); - } - - const onboardBody: Record = { - tierId, - metadata: { - ...metadata, - }, - }; - if (tierId !== TIER_FREE && envProject) { - onboardBody.cloudaicompanionProject = envProject; - (onboardBody.metadata as Record).duetProject = envProject; - } - - const onboardResponse = await fetchWithTimeout(`${activeEndpoint}/v1internal:onboardUser`, { - method: "POST", - headers, - body: JSON.stringify(onboardBody), - }); - - if (!onboardResponse.ok) { - throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`); - } - - let lro = (await onboardResponse.json()) as { - done?: boolean; - name?: string; - response?: { cloudaicompanionProject?: { id?: string } }; - }; - - if (!lro.done && lro.name) { - lro = await pollOperation(activeEndpoint, lro.name, headers); - } - - const projectId = lro.response?.cloudaicompanionProject?.id; - if (projectId) { - return projectId; - } - if (envProject) { - return envProject; - } - - throw new Error( - "Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.", - ); -} - -function isVpcScAffected(payload: unknown): boolean { - if (!payload || typeof payload !== "object") { - return false; - } - const error = (payload as { error?: unknown }).error; - if (!error || typeof error !== "object") { - return false; - } - const details = (error as { details?: unknown[] }).details; - if (!Array.isArray(details)) { - return false; - } - return details.some( - (item) => - typeof item === "object" && - item && - (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED", - ); -} - -function getDefaultTier( - allowedTiers?: Array<{ id?: string; isDefault?: boolean }>, -): { id?: string } | undefined { - if (!allowedTiers?.length) { - return { id: TIER_LEGACY }; - } - return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY }; -} - -async function pollOperation( - endpoint: string, - operationName: string, - headers: Record, -): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> { - for (let attempt = 0; attempt < 24; attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, 5000)); - const response = await fetchWithTimeout(`${endpoint}/v1internal/${operationName}`, { - headers, - }); - if (!response.ok) { - continue; - } - const data = (await response.json()) as { - done?: boolean; - response?: { cloudaicompanionProject?: { id?: string } }; - }; - if (data.done) { - return data; - } - } - throw new Error("Operation polling timeout"); -} +import { clearCredentialsCache, extractGeminiCliCredentials } from "./oauth.credentials.js"; +import { + buildAuthUrl, + generatePkce, + parseCallbackInput, + shouldUseManualOAuthFlow, + waitForLocalCallback, +} from "./oauth.flow.js"; +import type { GeminiCliOAuthContext, GeminiCliOAuthCredentials } from "./oauth.shared.js"; +import { exchangeCodeForTokens } from "./oauth.token.js"; + +export { clearCredentialsCache, extractGeminiCliCredentials }; +export type { GeminiCliOAuthContext, GeminiCliOAuthCredentials }; export async function loginGeminiCliOAuth( ctx: GeminiCliOAuthContext,