feat(pi-ai): delegate google-gemini-cli auth + project to cli-core

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) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 01:47:48 +02:00
parent ed85252fc5
commit ed47951960
5 changed files with 108 additions and 739 deletions

View file

@ -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<typeof CodeAssistServer>[0],
projectId,
{ headers: {} },
);
async function getCodeAssistServer(): Promise<CodeAssistServer> {
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<ThinkingLevel, "xhigh">;
/**
* 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) {

View file

@ -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<void> {
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<string, string>,
onProgress?: (message: string) => void,
): Promise<LongRunningOperationResponse> {
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<string> {
// 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<string, unknown> = {
tierId,
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
};
if (tierId !== TIER_FREE && envProjectId) {
onboardBody.cloudaicompanionProject = envProjectId;
(onboardBody.metadata as Record<string, unknown>).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<OAuthCredentials> {
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<string>,
): Promise<OAuthCredentials> {
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<OAuthCredentials> {
return loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
},
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
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 });
},
};

View file

@ -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<void> | 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<typeof import("node:http").createServer> {
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<CallbackServerInfo> {
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(
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
);
return;
}
if (code && state) {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
);
result = { code, state };
} else {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
);
}
} 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<string | undefined> {
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<string, unknown>,
): Promise<OAuthCredentials> {
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,
};
}

View file

@ -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<string, OAuthProviderInterface>(
);
/**
* 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,

View file

@ -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;