diff --git a/packages/ai/src/providers/google-cloud-code-assist.ts b/packages/ai/src/providers/google-cloud-code-assist.ts index dd1de439..e53f9a98 100644 --- a/packages/ai/src/providers/google-cloud-code-assist.ts +++ b/packages/ai/src/providers/google-cloud-code-assist.ts @@ -119,14 +119,25 @@ export const streamGoogleCloudCodeAssist: StreamFunction<"google-cloud-code-assi }; try { - const apiKey = options?.apiKey; - if (!apiKey) { - throw new Error("Google Cloud Code Assist requires an OAuth access token"); + // 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."); } - const projectId = options?.projectId; - if (!projectId) { - throw new Error("Google Cloud Code Assist requires a project ID"); + 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 requestBody = buildRequest(model, context, projectId, options); @@ -135,7 +146,7 @@ export const streamGoogleCloudCodeAssist: StreamFunction<"google-cloud-code-assi const response = await fetch(url, { method: "POST", headers: { - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", Accept: "text/event-stream", ...HEADERS, diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 35790586..6b8fc58b 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -4,8 +4,8 @@ import AjvModule from "ajv"; import { existsSync, readFileSync } from "fs"; import { getModelsPath } from "../config.js"; import { getGitHubCopilotBaseUrl, normalizeDomain, refreshGitHubCopilotToken } from "./oauth/github-copilot.js"; -import { getOAuthToken, type SupportedOAuthProvider } from "./oauth/index.js"; -import { loadOAuthCredentials, saveOAuthCredentials } from "./oauth/storage.js"; +import { getOAuthToken, refreshToken, type SupportedOAuthProvider } from "./oauth/index.js"; +import { loadOAuthCredentials, removeOAuthCredentials, saveOAuthCredentials } from "./oauth/storage.js"; // Handle both default and named exports const Ajv = (AjvModule as any).default || AjvModule; @@ -312,6 +312,33 @@ export async function getApiKeyForModel(model: Model): Promise= credentials.expires) { + try { + await refreshToken("google-cloud-code-assist"); + const refreshedCreds = loadOAuthCredentials("google-cloud-code-assist"); + if (refreshedCreds?.projectId) { + return JSON.stringify({ token: refreshedCreds.access, projectId: refreshedCreds.projectId }); + } + } catch { + removeOAuthCredentials("google-cloud-code-assist"); + return undefined; + } + } + + if (credentials.projectId) { + return JSON.stringify({ token: credentials.access, projectId: credentials.projectId }); + } + return undefined; + } + // For built-in providers, use getApiKey from @mariozechner/pi-ai return getApiKey(model.provider as KnownProvider); } @@ -371,6 +398,7 @@ export function findModel(provider: string, modelId: string): { model: Model = { anthropic: "anthropic", "github-copilot": "github-copilot", + "google-cloud-code-assist": "google-cloud-code-assist", }; // Cache for OAuth status per provider (avoids file reads on every render) diff --git a/packages/coding-agent/src/core/oauth/google-cloud.ts b/packages/coding-agent/src/core/oauth/google-cloud.ts new file mode 100644 index 00000000..f5428ed6 --- /dev/null +++ b/packages/coding-agent/src/core/oauth/google-cloud.ts @@ -0,0 +1,316 @@ +import { createHash, randomBytes } from "crypto"; +import { createServer, type Server } from "http"; +import { type OAuthCredentials, saveOAuthCredentials } from "./storage.js"; + +const CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"; +const CLIENT_SECRET = "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"; +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"; + +export interface GoogleCloudCredentials extends OAuthCredentials { + projectId: string; + email?: string; +} + +/** + * Generate PKCE code verifier and challenge + */ +function generatePKCE(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +/** + * Start a local HTTP server to receive the OAuth callback + */ +function startCallbackServer(): Promise<{ server: Server; getCode: () => Promise<{ code: string; state: string }> }> { + return new Promise((resolve, reject) => { + let codeResolve: (value: { code: string; state: string }) => void; + let codeReject: (error: Error) => void; + + const codePromise = new Promise<{ code: string; state: string }>((res, rej) => { + codeResolve = res; + codeReject = rej; + }); + + const server = createServer((req, res) => { + const url = new URL(req.url || "", `http://localhost:8085`); + + if (url.pathname === "/oauth2callback") { + 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.

`, + ); + codeReject(new Error(`OAuth error: ${error}`)); + 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.

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

Authentication Failed

Missing code or state parameter.

`, + ); + codeReject(new Error("Missing code or state in callback")); + } + } else { + res.writeHead(404); + res.end(); + } + }); + + server.on("error", (err) => { + reject(err); + }); + + server.listen(8085, "127.0.0.1", () => { + resolve({ + server, + getCode: () => codePromise, + }); + }); + }); +} + +/** + * Discover or provision a Google Cloud project for the user + */ +async function discoverProject(accessToken: string): Promise { + // Try to load existing projects via loadCodeAssist + const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, { + method: "POST", + 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", + }, + body: JSON.stringify({}), + }); + + if (response.ok) { + const data = (await response.json()) as { projects?: Array<{ projectId: string }> }; + if (data.projects && data.projects.length > 0) { + return data.projects[0].projectId; + } + } + + // Try to onboard the user if no projects found + const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { + method: "POST", + 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", + }, + body: JSON.stringify({}), + }); + + if (onboardResponse.ok) { + const data = (await onboardResponse.json()) as { projectId?: string }; + if (data.projectId) { + return data.projectId; + } + } + + throw new Error( + "Could not discover or provision a Google Cloud project. Please ensure you have access to Google Cloud Code Assist.", + ); +} + +/** + * Get user email from the access token + */ +async function getUserEmail(accessToken: string): Promise { + try { + const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (response.ok) { + const data = (await response.json()) as { email?: string }; + return data.email; + } + } catch { + // Ignore errors, email is optional + } + return undefined; +} + +/** + * Login with Google Cloud OAuth + */ +export async function loginGoogleCloud( + onAuth: (info: { url: string; instructions?: string }) => void, + onProgress?: (message: string) => void, +): Promise { + const { verifier, challenge } = generatePKCE(); + + // Start local server for callback + onProgress?.("Starting local server for OAuth callback..."); + const { server, getCode } = await startCallbackServer(); + + 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. The callback will be captured automatically.", + }); + + // Wait for the callback + onProgress?.("Waiting for OAuth callback..."); + const { code, state } = await getCode(); + + // Verify state matches + if (state !== verifier) { + throw new Error("OAuth state mismatch - possible CSRF attack"); + } + + // 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, + }), + }); + + 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 getUserEmail(tokenData.access_token); + + // Discover project + onProgress?.("Discovering Google Cloud project..."); + const projectId = await discoverProject(tokenData.access_token); + + // Calculate expiry time (current time + expires_in seconds - 5 min buffer) + const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000; + + const credentials: GoogleCloudCredentials = { + type: "oauth", + refresh: tokenData.refresh_token, + access: tokenData.access_token, + expires: expiresAt, + projectId, + email, + }; + + saveOAuthCredentials("google-cloud-code-assist", credentials); + + return credentials; + } finally { + server.close(); + } +} + +/** + * Refresh Google Cloud OAuth token using refresh token + */ +export async function refreshGoogleCloudToken( + refreshToken: string, + existingProjectId?: string, +): Promise { + 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, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.text(); + throw new Error(`Token refresh failed: ${error}`); + } + + const tokenData = (await tokenResponse.json()) as { + access_token: string; + expires_in: number; + refresh_token?: string; // May or may not be returned + }; + + // Calculate expiry time (current time + expires_in seconds - 5 min buffer) + const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000; + + // Get user email + const email = await getUserEmail(tokenData.access_token); + + // Use existing project ID or discover new one + let projectId = existingProjectId; + if (!projectId) { + projectId = await discoverProject(tokenData.access_token); + } + + return { + type: "oauth", + refresh: tokenData.refresh_token || refreshToken, // Use new refresh token if provided, otherwise keep existing + access: tokenData.access_token, + expires: expiresAt, + projectId, + email, + }; +} diff --git a/packages/coding-agent/src/core/oauth/index.ts b/packages/coding-agent/src/core/oauth/index.ts index a5951bc3..815001bb 100644 --- a/packages/coding-agent/src/core/oauth/index.ts +++ b/packages/coding-agent/src/core/oauth/index.ts @@ -1,5 +1,6 @@ import { loginAnthropic, refreshAnthropicToken } from "./anthropic.js"; import { loginGitHubCopilot, refreshGitHubCopilotToken } from "./github-copilot.js"; +import { loginGoogleCloud, refreshGoogleCloudToken } from "./google-cloud.js"; import { listOAuthProviders as listOAuthProvidersFromStorage, loadOAuthCredentials, @@ -11,7 +12,7 @@ import { // Re-export for convenience export { listOAuthProvidersFromStorage as listOAuthProviders }; -export type SupportedOAuthProvider = "anthropic" | "github-copilot"; +export type SupportedOAuthProvider = "anthropic" | "github-copilot" | "google-cloud-code-assist"; export interface OAuthProviderInfo { id: SupportedOAuthProvider; @@ -45,6 +46,11 @@ export function getOAuthProviders(): OAuthProviderInfo[] { name: "GitHub Copilot", available: true, }, + { + id: "google-cloud-code-assist", + name: "Google Cloud Code Assist (Gemini CLI)", + available: true, + }, ]; } @@ -73,6 +79,10 @@ export async function login( saveOAuthCredentials("github-copilot", creds); break; } + case "google-cloud-code-assist": { + await loginGoogleCloud(onAuth, onProgress); + break; + } default: throw new Error(`Unknown OAuth provider: ${provider}`); } @@ -103,6 +113,9 @@ export async function refreshToken(provider: SupportedOAuthProvider): Promise