diff --git a/src/commands/antigravity-oauth.ts b/src/commands/antigravity-oauth.ts new file mode 100644 index 000000000..3e26b5f15 --- /dev/null +++ b/src/commands/antigravity-oauth.ts @@ -0,0 +1,394 @@ +/** + * VPS-aware Antigravity OAuth flow. + * + * On local machines: Uses the standard pi-ai loginAntigravity with local server callback. + * On VPS/SSH/headless: Shows URL and prompts user to paste the callback URL manually. + */ + +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { readFileSync } from "node:fs"; +import { randomBytes, createHash } from "node:crypto"; +import { loginAntigravity, type OAuthCredentials } from "@mariozechner/pi-ai"; + +// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync +const decode = (s: string) => Buffer.from(s, "base64").toString(); +const CLIENT_ID = decode("MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ=="); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); +const REDIRECT_URI = "http://localhost:51121/oauth-callback"; +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_URL = "https://oauth2.googleapis.com/token"; +// Antigravity requires these additional scopes +const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +]; +// Fallback project ID when discovery fails (same as pi-ai) +const DEFAULT_PROJECT_ID = "rising-fact-p41fc"; + +/** + * Detect if running in WSL (Windows Subsystem for Linux). + */ +function isWSL(): boolean { + if (process.platform !== "linux") return false; + try { + const release = readFileSync("/proc/version", "utf8").toLowerCase(); + return release.includes("microsoft") || release.includes("wsl"); + } catch { + return false; + } +} + +/** + * Detect if running in WSL2 specifically. + */ +function isWSL2(): boolean { + if (!isWSL()) return false; + try { + const version = readFileSync("/proc/version", "utf8").toLowerCase(); + return version.includes("wsl2") || version.includes("microsoft-standard"); + } catch { + return false; + } +} + +/** + * Detect if running in a remote/headless environment where localhost callback won't work. + */ +export function isRemoteEnvironment(): boolean { + // SSH session indicators + if ( + process.env.SSH_CLIENT || + process.env.SSH_TTY || + process.env.SSH_CONNECTION + ) { + return true; + } + + // Container/cloud environments + if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) { + return true; + } + + // Linux without display (and not WSL which can use wslview) + if ( + process.platform === "linux" && + !process.env.DISPLAY && + !process.env.WAYLAND_DISPLAY && + !isWSL() + ) { + return true; + } + + return false; +} + +/** + * Whether to skip the local OAuth callback server. + */ +export function shouldUseManualOAuthFlow(): boolean { + return isWSL2() || isRemoteEnvironment(); +} + +/** + * Generate PKCE verifier and challenge using Node.js crypto. + */ +function generatePKCESync(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256") + .update(verifier) + .digest("base64url"); + return { verifier, challenge }; +} + +/** + * Build the Antigravity OAuth authorization URL. + */ +function buildAuthUrl(challenge: string, verifier: string): string { + const params = 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", + }); + return `${AUTH_URL}?${params.toString()}`; +} + +/** + * Parse the OAuth callback URL or code input. + */ +function parseCallbackInput( + input: string, + expectedState: string, +): { code: string; state: string } | { error: string } { + const trimmed = input.trim(); + if (!trimmed) { + return { error: "No input provided" }; + } + + try { + // Try parsing as full URL + const url = new URL(trimmed); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state") ?? expectedState; + + if (!code) { + return { error: "Missing 'code' parameter in URL" }; + } + if (!state) { + return { error: "Missing 'state' parameter. Paste the full URL." }; + } + + return { code, state }; + } catch { + // Not a URL - treat as raw code (need state from original request) + if (!expectedState) { + return { error: "Paste the full redirect URL, not just the code." }; + } + return { code: trimmed, state: expectedState }; + } +} + +/** + * Exchange authorization code for tokens. + */ +async function exchangeCodeForTokens( + code: string, + verifier: string, +): Promise { + const response = 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 (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token exchange failed: ${errorText}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + if (!data.refresh_token) { + throw new Error("No refresh token received. Please try again."); + } + + // Fetch user email + const email = await getUserEmail(data.access_token); + + // Fetch project ID + const projectId = await fetchProjectId(data.access_token); + + // Calculate expiry time (same as pi-ai: current time + expires_in - 5 min buffer) + const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; + + return { + refresh: data.refresh_token, + access: data.access_token, + expires: expiresAt, + projectId, + email, + }; +} + +/** + * Get user email from 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; +} + +/** + * Fetch the Antigravity project ID using the access token. + */ +async function fetchProjectId(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; + + // Try endpoints in order: prod first, then sandbox + const endpoints = [ + "https://cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.sandbox.googleapis.com", + ]; + + for (const endpoint of endpoints) { + try { + const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { + method: "POST", + headers, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + }); + + if (!response.ok) continue; + + const data = (await response.json()) as { + cloudaicompanionProject?: string | { id?: string }; + }; + + if (typeof data.cloudaicompanionProject === "string") { + return data.cloudaicompanionProject; + } + if ( + data.cloudaicompanionProject && + typeof data.cloudaicompanionProject === "object" && + data.cloudaicompanionProject.id + ) { + return data.cloudaicompanionProject.id; + } + } catch { + continue; + } + } + + // Use fallback project ID + return DEFAULT_PROJECT_ID; +} + +/** + * Prompt user for input via readline. + */ +async function promptInput(message: string): Promise { + const rl = createInterface({ input: stdin, output: stdout }); + try { + return (await rl.question(message)).trim(); + } finally { + rl.close(); + } +} + +/** + * VPS-aware Antigravity OAuth login. + * + * On local machines: Uses the standard pi-ai flow with automatic localhost callback. + * On VPS/SSH: Shows URL and prompts user to paste the callback URL manually. + */ +export async function loginAntigravityVpsAware( + onUrl: (url: string) => void | Promise, + onProgress?: (message: string) => void, +): Promise { + // Check if we're in a remote environment + if (shouldUseManualOAuthFlow()) { + return loginAntigravityManual(onUrl, onProgress); + } + + // Use the standard pi-ai flow for local environments + try { + return await loginAntigravity( + async ({ url, instructions }) => { + await onUrl(url); + onProgress?.(instructions ?? "Complete sign-in in browser..."); + }, + (msg) => onProgress?.(msg), + ); + } catch (err) { + // If the local server fails (e.g., port in use), fall back to manual + if ( + err instanceof Error && + (err.message.includes("EADDRINUSE") || + err.message.includes("port") || + err.message.includes("listen")) + ) { + onProgress?.("Local callback server failed. Switching to manual mode..."); + return loginAntigravityManual(onUrl, onProgress); + } + throw err; + } +} + +/** + * Manual Antigravity OAuth login for VPS/headless environments. + * + * Shows the OAuth URL and prompts user to paste the callback URL. + */ +export async function loginAntigravityManual( + onUrl: (url: string) => void | Promise, + onProgress?: (message: string) => void, +): Promise { + const { verifier, challenge } = generatePKCESync(); + const authUrl = buildAuthUrl(challenge, verifier); + + // Show the URL to the user + await onUrl(authUrl); + + onProgress?.("Waiting for you to paste the callback URL..."); + + console.log("\n"); + console.log("=".repeat(60)); + console.log("VPS/Remote Mode - Manual OAuth"); + console.log("=".repeat(60)); + console.log("\n1. Open the URL above in your LOCAL browser"); + console.log("2. Complete the Google sign-in"); + console.log("3. Your browser will redirect to a localhost URL that won't load"); + console.log("4. Copy the ENTIRE URL from your browser's address bar"); + console.log("5. Paste it below\n"); + console.log("The URL will look like:"); + console.log("http://localhost:51121/oauth-callback?code=xxx&state=yyy\n"); + + const callbackInput = await promptInput("Paste the redirect URL here: "); + + const parsed = parseCallbackInput(callbackInput, verifier); + if ("error" in parsed) { + throw new Error(parsed.error); + } + + // Verify state matches + if (parsed.state !== verifier) { + throw new Error("OAuth state mismatch - please try again"); + } + + onProgress?.("Exchanging authorization code for tokens..."); + + return exchangeCodeForTokens(parsed.code, verifier); +} diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 929a2b94f..8f37093f5 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -12,10 +12,14 @@ import { } from "@clack/prompts"; import { loginAnthropic, - loginAntigravity, type OAuthCredentials, } from "@mariozechner/pi-ai"; +import { + loginAntigravityVpsAware, + isRemoteEnvironment, +} from "./antigravity-oauth.js"; + import type { ClawdisConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDIS, @@ -275,23 +279,35 @@ async function promptAuthConfig( runtime.error(String(err)); } } else if (authChoice === "antigravity") { + const isRemote = isRemoteEnvironment(); note( - [ - "Browser will open for Google authentication.", - "Sign in with your Google account that has Antigravity access.", - "The callback will be captured automatically on localhost:51121.", - ].join("\n"), + isRemote + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, copy the redirect URL and paste it back here.", + ].join("\n") + : [ + "Browser will open for Google authentication.", + "Sign in with your Google account that has Antigravity access.", + "The callback will be captured automatically on localhost:51121.", + ].join("\n"), "Google Antigravity OAuth", ); const spin = spinner(); spin.start("Starting OAuth flow…"); let oauthCreds: OAuthCredentials | null = null; try { - oauthCreds = await loginAntigravity( - async ({ url, instructions }) => { - spin.message(instructions ?? "Complete sign-in in browser…"); - await openUrl(url); - runtime.log(`Open: ${url}`); + oauthCreds = await loginAntigravityVpsAware( + async (url) => { + if (isRemote) { + spin.stop("OAuth URL ready"); + runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); + } else { + spin.message("Complete sign-in in browser…"); + await openUrl(url); + runtime.log(`Open: ${url}`); + } }, (msg) => spin.message(msg), ); diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index c3bb910ea..2a1f82d68 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -11,10 +11,14 @@ import { } from "@clack/prompts"; import { loginAnthropic, - loginAntigravity, type OAuthCredentials, } from "@mariozechner/pi-ai"; +import { + loginAntigravityVpsAware, + isRemoteEnvironment, +} from "./antigravity-oauth.js"; + import type { ClawdisConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDIS, @@ -248,23 +252,35 @@ export async function runInteractiveOnboarding( runtime.error(String(err)); } } else if (authChoice === "antigravity") { + const isRemote = isRemoteEnvironment(); note( - [ - "Browser will open for Google authentication.", - "Sign in with your Google account that has Antigravity access.", - "The callback will be captured automatically on localhost:51121.", - ].join("\n"), + isRemote + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, copy the redirect URL and paste it back here.", + ].join("\n") + : [ + "Browser will open for Google authentication.", + "Sign in with your Google account that has Antigravity access.", + "The callback will be captured automatically on localhost:51121.", + ].join("\n"), "Google Antigravity OAuth", ); const spin = spinner(); spin.start("Starting OAuth flow…"); let oauthCreds: OAuthCredentials | null = null; try { - oauthCreds = await loginAntigravity( - async ({ url, instructions }) => { - spin.message(instructions ?? "Complete sign-in in browser…"); - await openUrl(url); - runtime.log(`Open: ${url}`); + oauthCreds = await loginAntigravityVpsAware( + async (url) => { + if (isRemote) { + spin.stop("OAuth URL ready"); + runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); + } else { + spin.message("Complete sign-in in browser…"); + await openUrl(url); + runtime.log(`Open: ${url}`); + } }, (msg) => spin.message(msg), );