committed by
GitHub
Unverified
parent
3d804a9f5e
commit
2c5c82bb74
738
electron/utils/gemini-cli-oauth.ts
Normal file
738
electron/utils/gemini-cli-oauth.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { delimiter, dirname, join } from 'node:path';
|
||||
import { getClawXConfigDir } from './paths';
|
||||
|
||||
const CLIENT_ID_KEYS = ['OPENCLAW_GEMINI_OAUTH_CLIENT_ID', 'GEMINI_CLI_OAUTH_CLIENT_ID'];
|
||||
const CLIENT_SECRET_KEYS = [
|
||||
'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET',
|
||||
'GEMINI_CLI_OAUTH_CLIENT_SECRET',
|
||||
];
|
||||
const REDIRECT_URI = 'http://127.0.0.1:8085/oauth2callback';
|
||||
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
const USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
|
||||
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
|
||||
const SCOPES = [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
];
|
||||
const TIER_FREE = 'free-tier';
|
||||
const TIER_LEGACY = 'legacy-tier';
|
||||
const TIER_STANDARD = 'standard-tier';
|
||||
const LOCAL_GEMINI_DIR = join(getClawXConfigDir(), 'gemini-cli');
|
||||
|
||||
export type GeminiCliOAuthCredentials = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
email?: string;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
export type GeminiCliOAuthContext = {
|
||||
isRemote: boolean;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
log: (msg: string) => void;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
prompt: (message: string) => Promise<string>;
|
||||
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
||||
};
|
||||
|
||||
export class DetailedError extends Error {
|
||||
detail: string;
|
||||
|
||||
constructor(message: string, detail: string) {
|
||||
super(message);
|
||||
this.name = 'DetailedError';
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
|
||||
|
||||
function resolveEnv(keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]?.trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findInPath(name: string): string | null {
|
||||
const exts = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : [''];
|
||||
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
|
||||
if (!dir) continue;
|
||||
for (const ext of exts) {
|
||||
const p = join(dir, name + ext);
|
||||
if (existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFile(dir: string, name: string, depth: number): string | null {
|
||||
if (depth <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const next = join(dir, entry.name);
|
||||
if (entry.isFile() && entry.name === name) {
|
||||
return next;
|
||||
}
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
||||
const found = findFile(next, name, depth - 1);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
|
||||
if (cachedGeminiCliCredentials) {
|
||||
return cachedGeminiCliCredentials;
|
||||
}
|
||||
|
||||
try {
|
||||
const geminiPath = findInPath('gemini');
|
||||
if (!geminiPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedPath = realpathSync(geminiPath);
|
||||
const geminiCliDir = dirname(dirname(resolvedPath));
|
||||
const searchPaths = [
|
||||
join(
|
||||
geminiCliDir,
|
||||
'node_modules',
|
||||
'@google',
|
||||
'gemini-cli-core',
|
||||
'dist',
|
||||
'src',
|
||||
'code_assist',
|
||||
'oauth2.js',
|
||||
),
|
||||
join(
|
||||
geminiCliDir,
|
||||
'node_modules',
|
||||
'@google',
|
||||
'gemini-cli-core',
|
||||
'dist',
|
||||
'code_assist',
|
||||
'oauth2.js',
|
||||
),
|
||||
];
|
||||
|
||||
let content: string | null = null;
|
||||
for (const p of searchPaths) {
|
||||
if (existsSync(p)) {
|
||||
content = readFileSync(p, 'utf8');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
const found = findFile(geminiCliDir, 'oauth2.js', 10);
|
||||
if (found) {
|
||||
content = readFileSync(found, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
|
||||
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
|
||||
if (idMatch && secretMatch) {
|
||||
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] };
|
||||
return cachedGeminiCliCredentials;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractFromLocalInstall(): { clientId: string; clientSecret: string } | null {
|
||||
const coreDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core');
|
||||
if (!existsSync(coreDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchPaths = [
|
||||
join(coreDir, 'dist', 'src', 'code_assist', 'oauth2.js'),
|
||||
join(coreDir, 'dist', 'code_assist', 'oauth2.js'),
|
||||
];
|
||||
|
||||
let content: string | null = null;
|
||||
for (const p of searchPaths) {
|
||||
if (existsSync(p)) {
|
||||
content = readFileSync(p, 'utf8');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
const found = findFile(coreDir, 'oauth2.js', 10);
|
||||
if (found) {
|
||||
content = readFileSync(found, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
|
||||
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
|
||||
if (idMatch && secretMatch) {
|
||||
return { clientId: idMatch[1], clientSecret: secretMatch[1] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function installViaNpm(onProgress?: (msg: string) => void): Promise<boolean> {
|
||||
const npmBin = findInPath('npm');
|
||||
if (!npmBin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
onProgress?.('Installing Gemini OAuth helper...');
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const useShell = process.platform === 'win32';
|
||||
const child = execFile(
|
||||
npmBin,
|
||||
['install', '--prefix', LOCAL_GEMINI_DIR, '@google/gemini-cli'],
|
||||
{ timeout: 120_000, shell: useShell, env: { ...process.env, NODE_ENV: '' } },
|
||||
(err) => {
|
||||
if (err) {
|
||||
onProgress?.(`Gemini helper install failed, falling back to direct download...`);
|
||||
resolve(false);
|
||||
} else {
|
||||
cachedGeminiCliCredentials = null;
|
||||
onProgress?.('Gemini OAuth helper installed');
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
child.stderr?.on('data', () => {
|
||||
// Suppress npm noise.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function installViaDirectDownload(onProgress?: (msg: string) => void): Promise<boolean> {
|
||||
try {
|
||||
onProgress?.('Downloading Gemini OAuth helper...');
|
||||
const metaRes = await fetch('https://registry.npmjs.org/@google/gemini-cli-core/latest');
|
||||
if (!metaRes.ok) {
|
||||
onProgress?.(`Failed to fetch Gemini package metadata: ${metaRes.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = (await metaRes.json()) as { dist?: { tarball?: string } };
|
||||
const tarballUrl = meta.dist?.tarball;
|
||||
if (!tarballUrl) {
|
||||
onProgress?.('Gemini package tarball URL missing');
|
||||
return false;
|
||||
}
|
||||
|
||||
const tarRes = await fetch(tarballUrl);
|
||||
if (!tarRes.ok) {
|
||||
onProgress?.(`Failed to download Gemini package: ${tarRes.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await tarRes.arrayBuffer());
|
||||
const targetDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core');
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
const tmpFile = join(LOCAL_GEMINI_DIR, '_tmp_gemini-cli-core.tgz');
|
||||
writeFileSync(tmpFile, buffer);
|
||||
try {
|
||||
execFileSync('tar', ['xzf', tmpFile, '-C', targetDir, '--strip-components=1'], {
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
cachedGeminiCliCredentials = null;
|
||||
onProgress?.('Gemini OAuth helper ready');
|
||||
return true;
|
||||
} catch (err) {
|
||||
onProgress?.(`Direct Gemini helper download failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureOAuthClientConfig(
|
||||
onProgress?: (msg: string) => void,
|
||||
): Promise<{ clientId: string; clientSecret?: string }> {
|
||||
const envClientId = resolveEnv(CLIENT_ID_KEYS);
|
||||
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
|
||||
if (envClientId) {
|
||||
return { clientId: envClientId, clientSecret: envClientSecret };
|
||||
}
|
||||
|
||||
const extracted = extractGeminiCliCredentials();
|
||||
if (extracted) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
const localExtracted = extractFromLocalInstall();
|
||||
if (localExtracted) {
|
||||
return localExtracted;
|
||||
}
|
||||
|
||||
mkdirSync(LOCAL_GEMINI_DIR, { recursive: true });
|
||||
const installed = await installViaNpm(onProgress) || await installViaDirectDownload(onProgress);
|
||||
if (installed) {
|
||||
const installedExtracted = extractFromLocalInstall();
|
||||
if (installedExtracted) {
|
||||
return installedExtracted;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Unable to prepare Gemini OAuth credentials automatically. Set GEMINI_CLI_OAUTH_CLIENT_ID or try again later.',
|
||||
);
|
||||
}
|
||||
|
||||
function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString('hex');
|
||||
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function buildAuthUrl(clientId: string, challenge: string, verifier: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
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()}`;
|
||||
}
|
||||
|
||||
async function waitForLocalCallback(params: {
|
||||
expectedState: string;
|
||||
timeoutMs: number;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ code: string; state: string }> {
|
||||
const port = 8085;
|
||||
const hostname = '127.0.0.1';
|
||||
const expectedPath = '/oauth2callback';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? '/', `http://${hostname}:${port}`);
|
||||
if (requestUrl.pathname !== expectedPath) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const error = requestUrl.searchParams.get('error');
|
||||
const code = requestUrl.searchParams.get('code')?.trim();
|
||||
const state = requestUrl.searchParams.get('state')?.trim();
|
||||
|
||||
if (error) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end(`Authentication failed: ${error}`);
|
||||
finish(new Error(`OAuth error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end('Missing code or state');
|
||||
finish(new Error('Missing OAuth code or state'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== params.expectedState) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(
|
||||
"<!doctype html><html><head><meta charset='utf-8'/></head><body><h2>Session expired</h2><p>This authorization link is from a previous attempt. Please go back to ClawX and try again.</p></body></html>",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(
|
||||
"<!doctype html><html><head><meta charset='utf-8'/></head><body><h2>Gemini CLI OAuth complete</h2><p>You can close this window and return to ClawX.</p></body></html>",
|
||||
);
|
||||
|
||||
finish(undefined, { code, state });
|
||||
} catch (err) {
|
||||
finish(err instanceof Error ? err : new Error('OAuth callback failed'));
|
||||
}
|
||||
});
|
||||
|
||||
const finish = (err?: Error, result?: { code: string; state: string }) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (result) {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
server.once('error', (err) => {
|
||||
finish(err instanceof Error ? err : new Error('OAuth callback server error'));
|
||||
});
|
||||
|
||||
server.listen(port, hostname, () => {
|
||||
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}...`);
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
finish(new DetailedError(
|
||||
'OAuth login timed out. The browser did not redirect back. Check if localhost:8085 is blocked.',
|
||||
`Waited ${params.timeoutMs / 1000}s for callback on ${hostname}:${port}`,
|
||||
));
|
||||
}, params.timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(USERINFO_URL, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { email?: string };
|
||||
return data.email;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getDefaultTier(
|
||||
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
|
||||
): { id?: string } | undefined {
|
||||
if (!allowedTiers?.length) {
|
||||
return { id: TIER_LEGACY };
|
||||
}
|
||||
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
|
||||
}
|
||||
|
||||
function isVpcScAffected(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const error = (payload as { error?: unknown }).error;
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const details = (error as { details?: unknown[] }).details;
|
||||
if (!Array.isArray(details)) {
|
||||
return false;
|
||||
}
|
||||
return details.some(
|
||||
(item) =>
|
||||
typeof item === 'object'
|
||||
&& item
|
||||
&& (item as { reason?: string }).reason === 'SECURITY_POLICY_VIOLATED',
|
||||
);
|
||||
}
|
||||
|
||||
async function pollOperation(
|
||||
operationName: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
|
||||
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers });
|
||||
if (!response.ok) {
|
||||
continue;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
done?: boolean;
|
||||
response?: { cloudaicompanionProject?: { id?: string } };
|
||||
};
|
||||
if (data.done) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Operation polling timeout');
|
||||
}
|
||||
|
||||
async function discoverProject(accessToken: string): Promise<string> {
|
||||
const envProject = 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/clawx',
|
||||
};
|
||||
|
||||
const loadBody = {
|
||||
cloudaicompanionProject: envProject,
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
duetProject: envProject,
|
||||
},
|
||||
};
|
||||
|
||||
let data: {
|
||||
currentTier?: { id?: string };
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
|
||||
} = {};
|
||||
|
||||
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(loadBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => null);
|
||||
if (isVpcScAffected(errorPayload)) {
|
||||
data = { currentTier: { id: TIER_STANDARD } };
|
||||
} else {
|
||||
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} else {
|
||||
data = (await response.json()) as typeof data;
|
||||
}
|
||||
|
||||
if (data.currentTier) {
|
||||
const project = data.cloudaicompanionProject;
|
||||
if (typeof project === 'string' && project) {
|
||||
return project;
|
||||
}
|
||||
if (typeof project === 'object' && project?.id) {
|
||||
return project.id;
|
||||
}
|
||||
if (envProject) {
|
||||
return envProject;
|
||||
}
|
||||
}
|
||||
|
||||
const hasExistingTierButNoProject = !!data.currentTier;
|
||||
const tier = hasExistingTierButNoProject ? { id: TIER_FREE } : getDefaultTier(data.allowedTiers);
|
||||
const tierId = tier?.id || TIER_FREE;
|
||||
if (tierId !== TIER_FREE && !envProject) {
|
||||
throw new DetailedError(
|
||||
'Your Google account requires a Cloud project. Please create one and set GOOGLE_CLOUD_PROJECT.',
|
||||
`tierId=${tierId}, currentTier=${JSON.stringify(data.currentTier ?? null)}, allowedTiers=${JSON.stringify(data.allowedTiers)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const onboardBody: Record<string, unknown> = {
|
||||
tierId,
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
},
|
||||
};
|
||||
if (tierId !== TIER_FREE && envProject) {
|
||||
onboardBody.cloudaicompanionProject = envProject;
|
||||
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
|
||||
}
|
||||
|
||||
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(onboardBody),
|
||||
});
|
||||
|
||||
if (!onboardResponse.ok) {
|
||||
const respText = await onboardResponse.text().catch(() => '');
|
||||
throw new DetailedError(
|
||||
'Google project provisioning failed. Please try again later.',
|
||||
`onboardUser ${onboardResponse.status} ${onboardResponse.statusText}: ${respText}`,
|
||||
);
|
||||
}
|
||||
|
||||
let lro = (await onboardResponse.json()) as {
|
||||
done?: boolean;
|
||||
name?: string;
|
||||
response?: { cloudaicompanionProject?: { id?: string } };
|
||||
};
|
||||
|
||||
if (!lro.done && lro.name) {
|
||||
lro = await pollOperation(lro.name, headers);
|
||||
}
|
||||
|
||||
const projectId = lro.response?.cloudaicompanionProject?.id;
|
||||
if (projectId) {
|
||||
return projectId;
|
||||
}
|
||||
if (envProject) {
|
||||
return envProject;
|
||||
}
|
||||
|
||||
throw new DetailedError(
|
||||
'Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.',
|
||||
`tierId=${tierId}, onboardResponse=${JSON.stringify(lro)}, currentTier=${JSON.stringify(data.currentTier ?? null)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
verifier: string,
|
||||
clientConfig: { clientId: string; clientSecret?: string },
|
||||
): Promise<GeminiCliOAuthCredentials> {
|
||||
const { clientId, clientSecret } = clientConfig;
|
||||
const body = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
if (clientSecret) {
|
||||
body.set('client_secret', clientSecret);
|
||||
}
|
||||
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
const email = await getUserEmail(data.access_token);
|
||||
const projectId = await discoverProject(data.access_token);
|
||||
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
|
||||
|
||||
return {
|
||||
refresh: data.refresh_token,
|
||||
access: data.access_token,
|
||||
expires: expiresAt,
|
||||
projectId,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loginGeminiCliOAuth(
|
||||
ctx: GeminiCliOAuthContext,
|
||||
): Promise<GeminiCliOAuthCredentials> {
|
||||
if (ctx.isRemote) {
|
||||
throw new Error('Remote/manual Gemini OAuth is not implemented in ClawX yet.');
|
||||
}
|
||||
|
||||
await ctx.note(
|
||||
[
|
||||
'Browser will open for Google authentication.',
|
||||
'Sign in with your Google account for Gemini CLI access.',
|
||||
'The callback will be captured automatically on 127.0.0.1:8085.',
|
||||
].join('\n'),
|
||||
'Gemini CLI OAuth',
|
||||
);
|
||||
|
||||
ctx.progress.update('Preparing Google OAuth...');
|
||||
const clientConfig = await ensureOAuthClientConfig((msg) => ctx.progress.update(msg));
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const authUrl = buildAuthUrl(clientConfig.clientId, challenge, verifier);
|
||||
ctx.progress.update('Complete sign-in in browser...');
|
||||
|
||||
try {
|
||||
await ctx.openUrl(authUrl);
|
||||
} catch {
|
||||
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { code } = await waitForLocalCallback({
|
||||
expectedState: verifier,
|
||||
timeoutMs: 5 * 60 * 1000,
|
||||
onProgress: (msg) => ctx.progress.update(msg),
|
||||
});
|
||||
ctx.progress.update('Exchanging authorization code for tokens...');
|
||||
return await exchangeCodeForTokens(code, verifier, clientConfig);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error
|
||||
&& (err.message.includes('EADDRINUSE')
|
||||
|| err.message.includes('port')
|
||||
|| err.message.includes('listen'))
|
||||
) {
|
||||
throw new Error(
|
||||
'Port 8085 is in use by another process. Close the other application using port 8085 and try again.',
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort check to help with diagnostics if the user claims gemini is installed but PATH is stale.
|
||||
export function detectGeminiCliVersion(): string | null {
|
||||
try {
|
||||
const geminiPath = findInPath('gemini');
|
||||
if (!geminiPath) {
|
||||
return null;
|
||||
}
|
||||
return execFileSync(geminiPath, ['--version'], { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user