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:
parent
ed85252fc5
commit
ed47951960
5 changed files with 108 additions and 739 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue