From ed47951960f68481962559bb60fcc5803c6097fc Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 01:47:48 +0200 Subject: [PATCH] feat(pi-ai): delegate google-gemini-cli auth + project to cli-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ~700 LOC of hand-rolled OAuth and onboarding with cli-core's own getOauthClient + setupUser. The provider now reads ~/.gemini/oauth_creds.json itself (via cli-core), refreshes tokens, and discovers the Code Assist project + tier server-side — exactly like the real gemini CLI does. - provider/google-gemini-cli.ts: drop apiKey={token,projectId} JSON plumbing; getCodeAssistServer() uses cli-core for everything - delete utils/oauth/google-gemini-cli.ts (457 LOC: hand-rolled login, PKCE, callback server, discoverProject, onboardUser, tier handling) - delete utils/oauth/google-oauth-utils.ts (201 LOC: only consumed by the deleted gemini-cli helper) - oauth/index.ts: remove gemini-cli from BUILT_IN_OAUTH_PROVIDERS registry; google-gemini-cli is no longer SF-managed - auth-storage.ts: update 3 error messages to direct users to the real gemini CLI for authentication instead of the removed /login command Login UX: users authenticate with the real gemini CLI; we just consume ~/.gemini/oauth_creds.json. Whole-provider disable goes through manual settings.json edit (per-model toggle still works in interactive UI). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pi-ai/src/providers/google-gemini-cli.ts | 143 +++--- .../src/utils/oauth/google-gemini-cli.ts | 457 ------------------ .../src/utils/oauth/google-oauth-utils.ts | 201 -------- packages/pi-ai/src/utils/oauth/index.ts | 37 +- .../pi-coding-agent/src/core/auth-storage.ts | 9 +- 5 files changed, 108 insertions(+), 739 deletions(-) delete mode 100644 packages/pi-ai/src/utils/oauth/google-gemini-cli.ts delete mode 100644 packages/pi-ai/src/utils/oauth/google-oauth-utils.ts diff --git a/packages/pi-ai/src/providers/google-gemini-cli.ts b/packages/pi-ai/src/providers/google-gemini-cli.ts index e13618d4b..f07ba91a0 100644 --- a/packages/pi-ai/src/providers/google-gemini-cli.ts +++ b/packages/pi-ai/src/providers/google-gemini-cli.ts @@ -1,15 +1,15 @@ /** * Google Gemini CLI provider. - * Uses the Cloud Code Assist API endpoint (cloudcode-pa.googleapis.com) to - * access Gemini models with the user's OAuth credentials from the real - * `gemini` CLI. Follow-up work will re-platform this on top of - * @google/gemini-cli-core so our requests are indistinguishable from the - * official CLI's. See google-gemini-cli-core-plan.md. + * + * Delegates auth, project discovery, and the Code Assist transport to + * @google/gemini-cli-core — the same library the real `gemini` CLI uses. + * cli-core reads ~/.gemini/oauth_creds.json itself, refreshes tokens, + * discovers the project (free-tier or whatever's onboarded server-side) + * via setupUser(), and handles all the User-Agent / retry / 429 details. */ import type { Content, GenerateContentParameters, GenerateContentResponse, ThinkingConfig } from "@google/genai"; -import { CodeAssistServer, makeFakeConfig } from "@google/gemini-cli-core"; -import { OAuth2Client } from "google-auth-library"; +import { AuthType, CodeAssistServer, getOauthClient, makeFakeConfig, setupUser } from "@google/gemini-cli-core"; import { calculateCost } from "../models.js"; import type { Api, @@ -39,10 +39,21 @@ import { buildBaseOptions, clampReasoning, isAutoReasoning, resolveReasoningLeve /** * Thinking level for Gemini 3 models. - * Mirrors Google's ThinkingLevel enum values. + * + * Gemini 3 Pro supports LOW/HIGH; Gemini 3 Flash supports MINIMAL/LOW/MEDIUM/HIGH. + * These are the wire format values for `ThinkingConfig.thinkingLevel` sent to cli-core's + * `CodeAssistServer.generateContentStream()`. */ export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; +/** + * Options for `streamGoogleGeminiCli()`. + * + * Delegates auth to cli-core (reads ~/.gemini/oauth_creds.json via `getOauthClient()`); + * `projectId` is auto-discovered and not used by this provider (apiKey is ignored). + * Thinking is configured separately from base `StreamOptions` because Gemini 2 and 3 + * models use incompatible enum formats (budgetTokens vs. level). + */ export interface GoogleGeminiCliOptions extends StreamOptions { toolChoice?: "auto" | "none" | "any"; /** @@ -66,45 +77,60 @@ export interface GoogleGeminiCliOptions extends StreamOptions { let toolCallCounter = 0; /** - * Build a CodeAssistServer from the SF-stored auth blob. + * Build a CodeAssistServer using cli-core's own auth + project discovery. * - * SF caches Google OAuth as `apiKey` = JSON-encoded `{ token, projectId }`. - * We unpack it and hand the access token to a google-auth-library - * OAuth2Client, which is what cli-core's CodeAssistServer wants. + * - getOauthClient() reads ~/.gemini/oauth_creds.json, refreshes if expired, + * and returns an authenticated AuthClient. Triggers the browser OAuth flow + * on cache miss. + * - setupUser() asks the Code Assist API for the project + tier tied to this + * identity (free-tier auto-provisioned if needed; otherwise whatever the + * user has been onboarded to server-side). * - * The transport itself (User-Agent, Client-Metadata, request path, - * retry/backoff on 429/5xx) is cli-core's job from here — byte-identical - * to what the real `gemini` CLI sends, so subject to the same free-tier - * treatment Google gives its own client. + * Both calls memoize internally inside cli-core — repeat invocations are + * cheap. */ -function buildCodeAssistServer(token: string, projectId: string): CodeAssistServer { - const authClient = new OAuth2Client(); - authClient.setCredentials({ access_token: token }); - // httpOptions is an empty-headers pass-through — cli-core sets the - // correct User-Agent / Client-Metadata / X-Goog-Api-Client itself. - // cli-core vendors its own google-auth-library copy, so TypeScript sees - // a package-identity mismatch even though the runtime object shape is - // compatible with the constructor's AuthClient contract. - return new CodeAssistServer( - authClient as unknown as ConstructorParameters[0], - projectId, - { headers: {} }, - ); +async function getCodeAssistServer(): Promise { + const config = makeFakeConfig(); + const authClient = await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, config); + const userData = await setupUser(authClient, config); + return new CodeAssistServer(authClient, userData.projectId, { headers: {} }); } +/** + * Check if the model is a Gemini 3 Pro variant (gemini-3*-pro). + * Used to determine which thinking config enum to use (thinkingLevel vs. budgetTokens). + */ function isGemini3ProModel(modelId: string): boolean { return /gemini-3(?:\.1)?-pro/.test(modelId.toLowerCase()); } +/** + * Check if the model is a Gemini 3 Flash variant (gemini-3*-flash). + * Used to determine which thinking config enum to use (thinkingLevel vs. budgetTokens). + */ function isGemini3FlashModel(modelId: string): boolean { return /gemini-3(?:\.1)?-flash/.test(modelId.toLowerCase()); } +/** + * Check if the model is any Gemini 3 variant (Pro or Flash). + * Determines whether to use thinkingLevel enum (Gemini 3) vs. budgetTokens (Gemini 2.x). + */ function isGemini3Model(modelId: string): boolean { return isGemini3ProModel(modelId) || isGemini3FlashModel(modelId); } +/** + * Stream a chat completion from Google Gemini via the cli-core transport. + * + * Auth is handled transparently by cli-core (`getCodeAssistServer()` reads OAuth creds from + * ~/.gemini/oauth_creds.json and triggers browser OAuth on first run). Project ID is auto-discovered + * from the Code Assist API; `apiKey` is ignored. Casting the request as `any` works around the fact + * that cli-core bundles its own nested `@google/genai` copy (nominal type split at packaging time; + * runtime shapes are byte-identical). Returns a real-time stream emitting start, delta, end, and + * error events that accumulate into an `AssistantMessage`. + */ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli", GoogleGeminiCliOptions> = ( model: Model<"google-gemini-cli">, context: Context, @@ -132,25 +158,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli", GoogleGe }; try { - // apiKey is JSON-encoded: { token, projectId } - const apiKeyRaw = options?.apiKey; - if (!apiKeyRaw) { - throw new Error("Google Cloud Code Assist requires OAuth authentication. Use /login to authenticate."); - } - let accessToken: string; - let projectId: string; - try { - const parsed = JSON.parse(apiKeyRaw) as { token: string; projectId: string }; - accessToken = parsed.token; - projectId = parsed.projectId; - } catch { - throw new Error("Invalid Google Cloud Code Assist credentials. Use /login to re-authenticate."); - } - if (!accessToken || !projectId) { - throw new Error("Missing token or projectId in Google Cloud credentials. Use /login to re-authenticate."); - } - - const server = buildCodeAssistServer(accessToken, projectId); + // cli-core handles auth + project discovery. If ~/.gemini/oauth_creds.json + // is missing the user needs to run the real `gemini` CLI to authenticate. + const server = await getCodeAssistServer(); let req = buildRequest(model, context, options); const nextReq = await options?.onPayload?.(req, model); if (nextReq !== undefined) { @@ -376,17 +386,22 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli", GoogleGe return stream; }; +/** + * Simplified stream wrapper that auto-configures thinking based on model and reasoning level. + * + * Reasoning intent is resolved via `buildBaseOptions()` and the `reasoning` flag in `SimpleStreamOptions`. + * For Gemini 3 models, uses the thinkingLevel enum (LOW/HIGH for Pro, MINIMAL/LOW/MEDIUM/HIGH for Flash). + * For Gemini 2.x, maps the requested level to token budgets (default: minimal=1K, low=2K, medium=8K, high=16K). + * Auth is still handled by cli-core (apiKey is ignored). Returns the same `AssistantMessageEventStream` + * as `streamGoogleGeminiCli()` after delegating with appropriate `thinking` config. + */ export const streamSimpleGoogleGeminiCli: StreamFunction<"google-gemini-cli", SimpleStreamOptions> = ( model: Model<"google-gemini-cli">, context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey; - if (!apiKey) { - throw new Error("Google Cloud Code Assist requires OAuth authentication. Use /login to authenticate."); - } - - const base = buildBaseOptions(model, options, apiKey); + // cli-core sources auth from ~/.gemini/ — apiKey not required. + const base = buildBaseOptions(model, options, options?.apiKey ?? ""); if (!options?.reasoning) { return streamGoogleGeminiCli(model, context, { ...base, @@ -452,14 +467,13 @@ export const streamSimpleGoogleGeminiCli: StreamFunction<"google-gemini-cli", Si }; /** - * Build a `GenerateContentParameters` payload for cli-core's - * `CodeAssistServer.generateContentStream()`. This is the shape - * `@google/genai` defines — different from the old Cloud-Code-Assist - * request envelope (cli-core does the envelope wrapping itself). + * Build a `GenerateContentParameters` payload for cli-core's `CodeAssistServer.generateContentStream()`. * - * Note: the old handwritten envelope included `project`, `requestId`, - * `userAgent` fields. cli-core manages all of those — we only supply - * the content shape. + * This is the raw genai Content/Config shape (`@google/genai`), not the legacy Cloud Code Assist envelope. + * cli-core wraps it with project, requestId, User-Agent, and retry logic; we only provide content/tools/config. + * Unlike the old path, we do NOT need to set `project` or `requestId` — cli-core infers project from `setupUser()`. + * Returns the exact shape the server's `generateContentStream()` method expects (casting through `any` + * at the call site handles the vendored `@google/genai` type split). */ function buildRequest( model: Model<"google-gemini-cli">, @@ -514,6 +528,13 @@ function buildRequest( type ClampedThinkingLevel = Exclude; +/** + * Map a normalized thinking level (minimal/low/medium/high) to the Gemini 3 wire format. + * + * Gemini 3 Pro only supports LOW/HIGH (maps minimal/low -> LOW, medium/high -> HIGH). + * Gemini 3 Flash supports all four (MINIMAL/LOW/MEDIUM/HIGH one-to-one). + * Used when `options.thinking.level` is set for Gemini 3 models. + */ function getGeminiCliThinkingLevel(effort: ClampedThinkingLevel, modelId: string): GoogleThinkingLevel { if (isGemini3ProModel(modelId)) { switch (effort) { diff --git a/packages/pi-ai/src/utils/oauth/google-gemini-cli.ts b/packages/pi-ai/src/utils/oauth/google-gemini-cli.ts deleted file mode 100644 index b3669b107..000000000 --- a/packages/pi-ai/src/utils/oauth/google-gemini-cli.ts +++ /dev/null @@ -1,457 +0,0 @@ -/** - * Gemini CLI OAuth flow (Google Cloud Code Assist) - * Standard Gemini models only (gemini-2.0-flash, gemini-2.5-*) - * - * NOTE: This module uses Node.js http.createServer for the OAuth callback. - * It is only intended for CLI use, not browser environments. - */ - -import { - type CallbackServerInfo, - getGoogleUserEmail, - parseRedirectUrl, - refreshGoogleOAuthToken, - startCallbackServer, -} from "./google-oauth-utils.js"; -import { generatePKCE } from "./pkce.js"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; - -type GeminiCredentials = OAuthCredentials & { - projectId: string; -}; - -const decode = (s: string) => atob(s); -const CLIENT_ID = decode( - "NjgxMjU1ODA5Mzk1LW9vOGZ0Mm9wcmRybnA5ZTNhcWY2YXYzaG1kaWIxMzVqLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t", -); -const CLIENT_SECRET = decode("R09DU1BYLTR1SGdNUG0tMW83U2stZ2VWNkN1NWNsWEZzeGw="); -const REDIRECT_URI = "http://localhost:8085/oauth2callback"; -const SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", -]; -const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const TOKEN_URL = "https://oauth2.googleapis.com/token"; -const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"; - -// Callback server configuration -const CALLBACK_PORT = 8085; -const CALLBACK_PATH = "/oauth2callback"; - -interface LoadCodeAssistPayload { - cloudaicompanionProject?: string; - currentTier?: { id?: string }; - allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; -} - -/** - * Long-running operation response from onboardUser - */ -interface LongRunningOperationResponse { - name?: string; - done?: boolean; - response?: { - cloudaicompanionProject?: { id?: string }; - }; -} - -// Tier IDs as used by the Cloud Code API -const TIER_FREE = "free-tier"; -const TIER_LEGACY = "legacy-tier"; -const TIER_STANDARD = "standard-tier"; - -interface GoogleRpcErrorResponse { - error?: { - details?: Array<{ reason?: string }>; - }; -} - -/** - * Wait helper for onboarding retries - */ -function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Get default tier from allowed tiers - */ -function getDefaultTier(allowedTiers?: Array<{ id?: string; isDefault?: boolean }>): { id?: string } { - if (!allowedTiers || allowedTiers.length === 0) return { id: TIER_LEGACY }; - const defaultTier = allowedTiers.find((t) => t.isDefault); - return defaultTier ?? { id: TIER_LEGACY }; -} - -function isVpcScAffectedUser(payload: unknown): boolean { - if (!payload || typeof payload !== "object") return false; - if (!("error" in payload)) return false; - const error = (payload as GoogleRpcErrorResponse).error; - if (!error?.details || !Array.isArray(error.details)) return false; - return error.details.some((detail) => detail.reason === "SECURITY_POLICY_VIOLATED"); -} - -/** - * Poll a long-running operation until completion - */ -async function pollOperation( - operationName: string, - headers: Record, - onProgress?: (message: string) => void, -): Promise { - let attempt = 0; - while (true) { - if (attempt > 0) { - onProgress?.(`Waiting for project provisioning (attempt ${attempt + 1})...`); - await wait(5000); - } - - const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { - method: "GET", - headers, - signal: AbortSignal.timeout(30_000), - }); - - if (!response.ok) { - throw new Error(`Failed to poll operation: ${response.status} ${response.statusText}`); - } - - const data = (await response.json()) as LongRunningOperationResponse; - if (data.done) { - return data; - } - - attempt += 1; - } -} - -/** - * Discover or provision a Google Cloud project for the user - */ -async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise { - // Check for user-provided project ID via environment variable - const envProjectId = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; - - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": "gl-node/22.17.0", - }; - - // Try to load existing project via loadCodeAssist - onProgress?.("Checking for existing Cloud Code Assist project..."); - const loadResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify({ - cloudaicompanionProject: envProjectId, - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - duetProject: envProjectId, - }, - }), - signal: AbortSignal.timeout(30_000), - }); - - let data: LoadCodeAssistPayload; - - if (!loadResponse.ok) { - let errorPayload: unknown; - try { - errorPayload = await loadResponse.clone().json(); - } catch { - errorPayload = undefined; - } - - if (isVpcScAffectedUser(errorPayload)) { - data = { currentTier: { id: TIER_STANDARD } }; - } else { - const errorText = await loadResponse.text(); - throw new Error(`loadCodeAssist failed: ${loadResponse.status} ${loadResponse.statusText}: ${errorText}`); - } - } else { - data = (await loadResponse.json()) as LoadCodeAssistPayload; - } - - // If user already has a current tier and project, use it - if (data.currentTier) { - if (data.cloudaicompanionProject) { - return data.cloudaicompanionProject; - } - // User has a tier but no managed project - they need to provide one via env var - if (envProjectId) { - return envProjectId; - } - throw new Error( - "This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID environment variable. " + - "See https://goo.gle/gemini-cli-auth-docs#workspace-gca", - ); - } - - // User needs to be onboarded - get the default tier - const tier = getDefaultTier(data.allowedTiers); - const tierId = tier?.id ?? TIER_FREE; - - if (tierId !== TIER_FREE && !envProjectId) { - throw new Error( - "This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID environment variable. " + - "See https://goo.gle/gemini-cli-auth-docs#workspace-gca", - ); - } - - onProgress?.("Provisioning Cloud Code Assist project (this may take a moment)..."); - - // Build onboard request - for free tier, don't include project ID (Google provisions one) - // For other tiers, include the user's project ID if available - const onboardBody: Record = { - tierId, - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }, - }; - - if (tierId !== TIER_FREE && envProjectId) { - onboardBody.cloudaicompanionProject = envProjectId; - (onboardBody.metadata as Record).duetProject = envProjectId; - } - - // Start onboarding - this returns a long-running operation - const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { - method: "POST", - headers, - body: JSON.stringify(onboardBody), - signal: AbortSignal.timeout(30_000), - }); - - if (!onboardResponse.ok) { - const errorText = await onboardResponse.text(); - throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}: ${errorText}`); - } - - let lroData = (await onboardResponse.json()) as LongRunningOperationResponse; - - // If the operation isn't done yet, poll until completion - if (!lroData.done && lroData.name) { - lroData = await pollOperation(lroData.name, headers, onProgress); - } - - // Try to get project ID from the response - const projectId = lroData.response?.cloudaicompanionProject?.id; - if (projectId) { - return projectId; - } - - // If no project ID from onboarding, fall back to env var - if (envProjectId) { - return envProjectId; - } - - throw new Error( - "Could not discover or provision a Google Cloud project. " + - "Try setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID environment variable. " + - "See https://goo.gle/gemini-cli-auth-docs#workspace-gca", - ); -} - -/** - * Refresh Google Cloud Code Assist token - */ -export async function refreshGoogleCloudToken(refreshToken: string, projectId: string): Promise { - return refreshGoogleOAuthToken(refreshToken, CLIENT_ID, CLIENT_SECRET, "Google Cloud", { projectId }); -} - -/** - * Login with Gemini CLI (Google Cloud Code Assist) OAuth - * - * @param onAuth - Callback with URL and optional instructions - * @param onProgress - Optional progress callback - * @param onManualCodeInput - Optional promise that resolves with user-pasted redirect URL. - * Races with browser callback - whichever completes first wins. - */ -export async function loginGeminiCli( - onAuth: (info: { url: string; instructions?: string }) => void, - onProgress?: (message: string) => void, - onManualCodeInput?: () => Promise, -): Promise { - const { verifier, challenge } = await generatePKCE(); - - // Start local server for callback - onProgress?.("Starting local server for OAuth callback..."); - const server: CallbackServerInfo = await startCallbackServer(CALLBACK_PORT, CALLBACK_PATH, "Gemini CLI"); - - let code: string | undefined; - - try { - // Build authorization URL - const authParams = new URLSearchParams({ - client_id: CLIENT_ID, - response_type: "code", - redirect_uri: REDIRECT_URI, - scope: SCOPES.join(" "), - code_challenge: challenge, - code_challenge_method: "S256", - state: verifier, - access_type: "offline", - prompt: "consent", - }); - - const authUrl = `${AUTH_URL}?${authParams.toString()}`; - - // Notify caller with URL to open - onAuth({ - url: authUrl, - instructions: "Complete the sign-in in your browser.", - }); - - // Wait for the callback, racing with manual input if provided - onProgress?.("Waiting for OAuth callback..."); - - if (onManualCodeInput) { - // Race between browser callback and manual input - let manualInput: string | undefined; - let manualError: Error | undefined; - const manualPromise = onManualCodeInput() - .then((input) => { - manualInput = input; - server.cancelWait(); - }) - .catch((err) => { - manualError = err instanceof Error ? err : new Error(String(err)); - server.cancelWait(); - }); - - const result = await server.waitForCode(); - - // If manual input was cancelled, throw that error - if (manualError) { - throw manualError; - } - - if (result?.code) { - // Browser callback won - verify state - if (result.state !== verifier) { - throw new Error("OAuth state mismatch - possible CSRF attack"); - } - code = result.code; - } else if (manualInput) { - // Manual input won - const parsed = parseRedirectUrl(manualInput); - if (parsed.state && parsed.state !== verifier) { - throw new Error("OAuth state mismatch - possible CSRF attack"); - } - code = parsed.code; - } - - // If still no code, wait for manual promise and try that - if (!code) { - await manualPromise; - if (manualError) { - throw manualError; - } - if (manualInput) { - const parsed = parseRedirectUrl(manualInput); - if (parsed.state && parsed.state !== verifier) { - throw new Error("OAuth state mismatch - possible CSRF attack"); - } - code = parsed.code; - } - } - } else { - // Original flow: just wait for callback - const result = await server.waitForCode(); - if (result?.code) { - if (result.state !== verifier) { - throw new Error("OAuth state mismatch - possible CSRF attack"); - } - code = result.code; - } - } - - if (!code) { - throw new Error("No authorization code received"); - } - - // Exchange code for tokens - onProgress?.("Exchanging authorization code for tokens..."); - const tokenResponse = await fetch(TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - code, - grant_type: "authorization_code", - redirect_uri: REDIRECT_URI, - code_verifier: verifier, - }), - signal: AbortSignal.timeout(30_000), - }); - - if (!tokenResponse.ok) { - const error = await tokenResponse.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - const tokenData = (await tokenResponse.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - }; - - if (!tokenData.refresh_token) { - throw new Error("No refresh token received. Please try again."); - } - - // Get user email - onProgress?.("Getting user info..."); - const email = await getGoogleUserEmail(tokenData.access_token); - - // Discover project - const projectId = await discoverProject(tokenData.access_token, onProgress); - - // Calculate expiry time (current time + expires_in seconds - 5 min buffer) - const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000; - - const credentials: OAuthCredentials = { - refresh: tokenData.refresh_token, - access: tokenData.access_token, - expires: expiresAt, - projectId, - email, - }; - - return credentials; - } finally { - server.server.close(); - } -} - -export const geminiCliOAuthProvider: OAuthProviderInterface = { - id: "google-gemini-cli", - name: "Google Cloud Code Assist (Gemini CLI)", - usesCallbackServer: true, - - async login(callbacks: OAuthLoginCallbacks): Promise { - return loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput); - }, - - async refreshToken(credentials: OAuthCredentials): Promise { - const creds = credentials as GeminiCredentials; - if (!creds.projectId) { - throw new Error("Google Cloud credentials missing projectId"); - } - return refreshGoogleCloudToken(creds.refresh, creds.projectId); - }, - - getApiKey(credentials: OAuthCredentials): string { - const creds = credentials as GeminiCredentials; - return JSON.stringify({ token: creds.access, projectId: creds.projectId }); - }, -}; diff --git a/packages/pi-ai/src/utils/oauth/google-oauth-utils.ts b/packages/pi-ai/src/utils/oauth/google-oauth-utils.ts deleted file mode 100644 index 1bf1dcc8a..000000000 --- a/packages/pi-ai/src/utils/oauth/google-oauth-utils.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Shared utilities for Google OAuth providers (Gemini CLI). - * - * NOTE: This module uses Node.js http.createServer for the OAuth callback. - * It is only intended for CLI use, not browser environments. - */ - -import type { Server } from "node:http"; -import type { OAuthCredentials } from "./types.js"; - -// Lazy-loaded http.createServer for Node.js environments -let _createServer: typeof import("node:http").createServer | null = null; -let _httpImportPromise: Promise | null = null; -if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { - _httpImportPromise = import("node:http").then((m) => { - _createServer = m.createServer; - }); -} - -export type CallbackServerInfo = { - server: Server; - cancelWait: () => void; - waitForCode: () => Promise<{ code: string; state: string } | null>; -}; - -/** - * Get the lazily imported Node.js createServer function. - * Throws if not running in a Node.js environment. - */ -async function getNodeCreateServer( - providerName: string, -): Promise { - if (_createServer) return _createServer; - if (_httpImportPromise) { - await _httpImportPromise; - } - if (_createServer) return _createServer; - throw new Error(`${providerName} OAuth is only available in Node.js environments`); -} - -/** - * Start a local HTTP server to receive the OAuth callback. - * - * @param port - The port to listen on (e.g. 8085, 51121) - * @param callbackPath - The URL path for the callback (e.g. "/oauth2callback", "/oauth-callback") - * @param providerName - Human-readable provider name for error messages - */ -export async function startCallbackServer( - port: number, - callbackPath: string, - providerName: string, -): Promise { - const createServer = await getNodeCreateServer(providerName); - - return new Promise((resolve, reject) => { - let result: { code: string; state: string } | null = null; - let cancelled = false; - - const server = createServer((req, res) => { - const url = new URL(req.url || "", `http://localhost:${port}`); - - if (url.pathname === callbackPath) { - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const error = url.searchParams.get("error"); - - if (error) { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end( - `

Authentication Failed

Error: ${error}

You can close this window.

`, - ); - return; - } - - if (code && state) { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end( - `

Authentication Successful

You can close this window and return to the terminal.

`, - ); - result = { code, state }; - } else { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end( - `

Authentication Failed

Missing code or state parameter.

`, - ); - } - } else { - res.writeHead(404); - res.end(); - } - }); - - server.on("error", (err) => { - reject(err); - }); - - server.listen(port, "127.0.0.1", () => { - resolve({ - server, - cancelWait: () => { - cancelled = true; - }, - waitForCode: async () => { - const sleep = () => new Promise((r) => setTimeout(r, 100)); - while (!result && !cancelled) { - await sleep(); - } - return result; - }, - }); - }); - }); -} - -/** - * Parse a redirect URL to extract the authorization code and state parameters. - */ -export function parseRedirectUrl(input: string): { code?: string; state?: string } { - const value = input.trim(); - if (!value) return {}; - - try { - const url = new URL(value); - return { - code: url.searchParams.get("code") ?? undefined, - state: url.searchParams.get("state") ?? undefined, - }; - } catch { - // Not a URL, return empty - return {}; - } -} - -/** - * Get the user's email address from a Google OAuth access token. - */ -export async function getGoogleUserEmail(accessToken: string): Promise { - try { - const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - signal: AbortSignal.timeout(30_000), - }); - - if (response.ok) { - const data = (await response.json()) as { email?: string }; - return data.email; - } - } catch { - // Ignore errors, email is optional - } - return undefined; -} - -/** - * Refresh a Google OAuth token using the standard Google token endpoint. - * - * @param refreshToken - The refresh token - * @param clientId - The OAuth client ID - * @param clientSecret - The OAuth client secret - * @param providerName - Human-readable provider name for error messages - * @param extraFields - Additional fields to include in the returned credentials - */ -export async function refreshGoogleOAuthToken( - refreshToken: string, - clientId: string, - clientSecret: string, - providerName: string, - extraFields?: Record, -): Promise { - const response = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - grant_type: "refresh_token", - }), - signal: AbortSignal.timeout(30_000), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`${providerName} token refresh failed: ${error}`); - } - - const data = (await response.json()) as { - access_token: string; - expires_in: number; - refresh_token?: string; - }; - - return { - refresh: data.refresh_token || refreshToken, - access: data.access_token, - expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, - ...extraFields, - }; -} diff --git a/packages/pi-ai/src/utils/oauth/index.ts b/packages/pi-ai/src/utils/oauth/index.ts index a30198153..18bb09e0e 100644 --- a/packages/pi-ai/src/utils/oauth/index.ts +++ b/packages/pi-ai/src/utils/oauth/index.ts @@ -4,16 +4,15 @@ * This module handles login, token refresh, and credential storage * for OAuth-based providers: * - GitHub Copilot - * - Google Cloud Code Assist (Gemini CLI) + * - OpenAI Codex (ChatGPT) * * Note: Anthropic OAuth was removed per TOS compliance (see docs/user-docs/claude-code-auth-compliance.md). * Use API keys or the local Claude Code CLI for Anthropic access. * - * Note: Antigravity OAuth was removed because Google does not publish a - * vendor core library for it and hand-rolled OAuth against its endpoints - * is at risk of ban per Google's third-party-tool policy. Users wanting - * Gemini access should authenticate via the real `gemini` CLI - * (google-gemini-cli provider) or use the google API key. + * Note: Google Cloud Code Assist (google-gemini-cli) is not handled here. + * The provider delegates to @google/gemini-cli-core, which reads + * ~/.gemini/oauth_creds.json directly. Users authenticate via the real + * `gemini` CLI; we just consume the credentials. */ // GitHub Copilot @@ -24,8 +23,6 @@ export { normalizeDomain, refreshGitHubCopilotToken, } from "./github-copilot.js"; -// Google Gemini CLI -export { geminiCliOAuthProvider, loginGeminiCli, refreshGoogleCloudToken } from "./google-gemini-cli.js"; // OpenAI Codex (ChatGPT OAuth) export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex.js"; @@ -36,13 +33,11 @@ export * from "./types.js"; // ============================================================================ import { githubCopilotOAuthProvider } from "./github-copilot.js"; -import { geminiCliOAuthProvider } from "./google-gemini-cli.js"; import { openaiCodexOAuthProvider } from "./openai-codex.js"; import type { OAuthCredentials, OAuthProviderId, OAuthProviderInterface } from "./types.js"; const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [ githubCopilotOAuthProvider, - geminiCliOAuthProvider, openaiCodexOAuthProvider, ]; @@ -51,14 +46,19 @@ const oauthProviderRegistry = new Map( ); /** - * Get an OAuth provider by ID + * Get an OAuth provider by ID. + * + * Returns the provider if registered (built-in or custom), otherwise undefined. */ export function getOAuthProvider(id: OAuthProviderId): OAuthProviderInterface | undefined { return oauthProviderRegistry.get(id); } /** - * Register a custom OAuth provider + * Register a custom OAuth provider. + * + * Custom providers override built-ins with the same ID during the session. + * Use `resetOAuthProviders` to restore built-ins. */ export function registerOAuthProvider(provider: OAuthProviderInterface): void { oauthProviderRegistry.set(provider.id, provider); @@ -81,6 +81,8 @@ export function unregisterOAuthProvider(id: string): void { /** * Reset OAuth providers to built-ins. + * + * Clears custom providers and restores only GitHub Copilot and OpenAI Codex. */ export function resetOAuthProviders(): void { oauthProviderRegistry.clear(); @@ -90,7 +92,9 @@ export function resetOAuthProviders(): void { } /** - * Get all registered OAuth providers + * Get all registered OAuth providers. + * + * Returns both built-in and custom providers currently in the registry. */ export function getOAuthProviders(): OAuthProviderInterface[] { return Array.from(oauthProviderRegistry.values()); @@ -101,11 +105,10 @@ export function getOAuthProviders(): OAuthProviderInterface[] { // ============================================================================ /** - * Get API key for a provider from OAuth credentials. - * Automatically refreshes expired tokens. + * Get API key for a provider from OAuth credentials, refreshing if expired. * - * @returns API key string and updated credentials, or null if no credentials - * @throws Error if refresh fails + * Returns the API key along with updated credentials (if refreshed), or null if no credentials exist. + * Throws if the provider is unknown or token refresh fails. */ export async function getOAuthApiKey( providerId: OAuthProviderId, diff --git a/packages/pi-coding-agent/src/core/auth-storage.ts b/packages/pi-coding-agent/src/core/auth-storage.ts index 981aaec7d..c508b5414 100644 --- a/packages/pi-coding-agent/src/core/auth-storage.ts +++ b/packages/pi-coding-agent/src/core/auth-storage.ts @@ -69,7 +69,8 @@ function validateNotGoogleOAuthToken(provider: string, key: string): void { `\n\nIf you're using Google's Gemini CLI, its OAuth tokens are not compatible. ` + `Either:\n` + ` 1. Get an API key from https://aistudio.google.com/apikey and set GEMINI_API_KEY\n` + - ` 2. Use '/login google-gemini-cli' to authenticate via Cloud Code Assist`, + ` 2. Authenticate with the real \`gemini\` CLI; the google-gemini-cli ` + + `provider reads ~/.gemini/oauth_creds.json automatically`, ); } } @@ -875,7 +876,8 @@ export class AuthStorage { this.recordError( new Error( `Blocked Google OAuth access token (ya29.*) for provider "${providerId}". ` + - `Use an API key from https://aistudio.google.com/apikey or '/login google-gemini-cli'.`, + `Use an API key from https://aistudio.google.com/apikey, or authenticate with ` + + `the real \`gemini\` CLI to use the google-gemini-cli provider.`, ), ); return undefined; @@ -904,7 +906,8 @@ export class AuthStorage { this.recordError( new Error( `GEMINI_API_KEY contains a Google OAuth access token (ya29.*), not an API key. ` + - `Get an API key from https://aistudio.google.com/apikey or use '/login google-gemini-cli'.`, + `Get an API key from https://aistudio.google.com/apikey, or authenticate with ` + + `the real \`gemini\` CLI to use the google-gemini-cli provider.`, ), ); return undefined;