diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index 7f94b92..fd27f9d 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -129,6 +129,121 @@ describe('oauth routes', { timeout: 15_000 }, () => { }); }); + it('discovers the Antigravity project via onboardUser polling when loadCodeAssist does not return one', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'antigravity-access-token', + refresh_token: 'antigravity-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'cloud-platform', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ email: 'antigravity-user@example.com' }), + text: async () => JSON.stringify({ email: 'antigravity-user@example.com' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + allowedTiers: [ + { id: 'legacy-tier', isDefault: true }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: false, + }), + text: async () => JSON.stringify({ done: false }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: true, + response: { + cloudaicompanionProject: { + id: 'antigravity-auto-project', + }, + }, + }), + text: async () => JSON.stringify({ done: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: { + 'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' }, + }, + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/antigravity/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:51121/oauth-callback?state=${encodeURIComponent(startBody.state)}&code=antigravity-oauth-code-123`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.json()).toEqual({ success: true }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'antigravity', + status: 'success', + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + oauthProvider: 'antigravity', + oauthProjectId: 'antigravity-auto-project', + username: 'antigravity-user@example.com', + accessToken: 'antigravity-access-token', + }); + + const parsed = JSON.parse(accounts[0]?.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + provider: 'antigravity', + email: 'antigravity-user@example.com', + projectId: 'antigravity-auto-project', + refreshToken: 'antigravity-refresh-token', + }); + + expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist'); + expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser'); + expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/v1internal:onboardUser'); + }); + it('starts a codex oauth session and exposes pending status', async () => { const startResponse = await app.inject({ method: 'POST', @@ -461,6 +576,32 @@ describe('oauth routes', { timeout: 15_000 }, () => { }), text: async () => JSON.stringify({ ok: true }), }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + cloudaicompanionProject: { + id: 'first-project-id', + }, + allowedTiers: [ + { id: 'legacy-tier', isDefault: true }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: true, + response: { + cloudaicompanionProject: { + id: 'first-project-id', + }, + }, + }), + text: async () => JSON.stringify({ done: true }), + }) .mockResolvedValueOnce({ ok: true, status: 200, @@ -529,8 +670,123 @@ describe('oauth routes', { timeout: 15_000 }, () => { }); expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('cloudresourcemanager.googleapis.com/v1/projects'); - expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com'); + expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist'); + expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser'); expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com'); + expect(String(fetchMock.mock.calls[6]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com'); + }); + + it('onboards Gemini CLI into the backend project and auto-enables Cloud AI API when Google returns a free-tier project remap', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'gemini-access-token', + refresh_token: 'gemini-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'cloud-platform', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + projects: [ + { projectId: 'gen-lang-client-source-project' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + allowedTiers: [ + { id: 'legacy-tier', isDefault: true }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: true, + response: { + cloudaicompanionProject: { + id: 'gen-lang-client-0123456789', + }, + }, + }), + text: async () => JSON.stringify({ done: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'DISABLED' }), + text: async () => JSON.stringify({ state: 'DISABLED' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ email: 'gemini-free-user@example.com' }), + text: async () => JSON.stringify({ email: 'gemini-free-user@example.com' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'ENABLED' }), + text: async () => JSON.stringify({ state: 'ENABLED' }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/gemini-cli/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-free-tier`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.json()).toEqual({ success: true }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + oauthProvider: 'gemini-cli', + oauthProjectId: 'gen-lang-client-0123456789', + username: 'gemini-free-user@example.com', + }); + + const parsed = JSON.parse(accounts[0]?.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + projectId: 'gen-lang-client-0123456789', + }); + + expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist'); + expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser'); + expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com'); + expect(String(fetchMock.mock.calls[5]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com:enable'); + expect(String(fetchMock.mock.calls[7]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com'); }); it('lists oauth connection health metadata and supports deleting the connection', async () => { diff --git a/src/server/services/oauth/antigravityProvider.ts b/src/server/services/oauth/antigravityProvider.ts index bf1ab7c..db5bd72 100644 --- a/src/server/services/oauth/antigravityProvider.ts +++ b/src/server/services/oauth/antigravityProvider.ts @@ -16,6 +16,9 @@ export const ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudc export const ANTIGRAVITY_GOOGLE_API_CLIENT = 'google-cloud-sdk vscode_cloudshelleditor/0.1'; export const ANTIGRAVITY_USER_AGENT = 'google-api-nodejs-client/9.15.1'; export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}'; +export const ANTIGRAVITY_INTERNAL_API_VERSION = 'v1internal'; +export const ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS = 2_000; +export const ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS = 5; const ANTIGRAVITY_SCOPES = [ 'https://www.googleapis.com/auth/cloud-platform', @@ -34,6 +37,16 @@ type AntigravityOAuthTokenPayload = { expiry?: unknown; }; +type AntigravityLoadCodeAssistPayload = { + cloudaicompanionProject?: unknown; + allowedTiers?: unknown; +}; + +type AntigravityOnboardUserPayload = { + done?: unknown; + response?: unknown; +}; + function asTrimmedString(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const trimmed = value.trim(); @@ -57,6 +70,65 @@ function parseExpiresAt(payload: AntigravityOAuthTokenPayload): number | undefin return undefined; } +function buildAntigravityMetadata() { + return { + ideType: 'ANTIGRAVITY', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + }; +} + +function extractAntigravityProjectId(value: unknown): string | undefined { + if (typeof value === 'string') { + return asTrimmedString(value); + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + return asTrimmedString((value as { id?: unknown }).id); + } + return undefined; +} + +function extractAntigravityDefaultTierId(payload: AntigravityLoadCodeAssistPayload): string { + const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : []; + for (const rawTier of allowedTiers) { + if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue; + const tier = rawTier as { id?: unknown; isDefault?: unknown }; + if (tier.isDefault === true) { + const tierId = asTrimmedString(tier.id); + if (tierId) return tierId; + } + } + return 'legacy-tier'; +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function callAntigravityInternalApi( + accessToken: string, + method: 'loadCodeAssist' | 'onboardUser', + body: Record, +): Promise { + const response = await fetch( + `${ANTIGRAVITY_UPSTREAM_BASE_URL}/${ANTIGRAVITY_INTERNAL_API_VERSION}:${method}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': ANTIGRAVITY_USER_AGENT, + 'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT, + 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, + }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) return undefined; + return response.json() as Promise; +} + async function postAntigravityToken(body: URLSearchParams) { const response = await fetch(ANTIGRAVITY_TOKEN_URL, { method: 'POST', @@ -99,34 +171,45 @@ async function fetchAntigravityUserEmail(accessToken: string): Promise { - const response = await fetch(`${ANTIGRAVITY_UPSTREAM_BASE_URL}/v1internal:loadCodeAssist`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - 'User-Agent': ANTIGRAVITY_USER_AGENT, - 'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT, - 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, - }, - body: JSON.stringify({ - metadata: { - ideType: 'ANTIGRAVITY', - platform: 'PLATFORM_UNSPECIFIED', - pluginType: 'GEMINI', + const metadata = buildAntigravityMetadata(); + const payload = await callAntigravityInternalApi( + accessToken, + 'loadCodeAssist', + { metadata }, + ); + if (!payload) return undefined; + + const discoveredFromLoad = extractAntigravityProjectId(payload.cloudaicompanionProject); + if (discoveredFromLoad) { + return discoveredFromLoad; + } + + const tierId = extractAntigravityDefaultTierId(payload); + for (let attempt = 0; attempt < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS; attempt += 1) { + const onboardPayload = await callAntigravityInternalApi( + accessToken, + 'onboardUser', + { + tierId, + metadata, }, - }), - }); - if (!response.ok) return undefined; - const payload = await response.json() as { - cloudaicompanionProject?: unknown; - }; - if (typeof payload.cloudaicompanionProject === 'string') { - return asTrimmedString(payload.cloudaicompanionProject); - } - if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject === 'object') { - return asTrimmedString((payload.cloudaicompanionProject as { id?: unknown }).id); + ); + if (!onboardPayload) return undefined; + if (onboardPayload.done === true) { + const response = ( + onboardPayload.response + && typeof onboardPayload.response === 'object' + && !Array.isArray(onboardPayload.response) + ) + ? onboardPayload.response as { cloudaicompanionProject?: unknown } + : undefined; + return extractAntigravityProjectId(response?.cloudaicompanionProject); + } + if ((attempt + 1) < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS) { + await sleep(ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS); + } } + return undefined; } diff --git a/src/server/services/oauth/geminiCliProvider.ts b/src/server/services/oauth/geminiCliProvider.ts index e08bf79..a56d82c 100644 --- a/src/server/services/oauth/geminiCliProvider.ts +++ b/src/server/services/oauth/geminiCliProvider.ts @@ -17,6 +17,11 @@ export const GEMINI_CLI_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com export const GEMINI_CLI_GOOGLE_API_CLIENT = 'google-genai-sdk/1.41.0 gl-node/v22.19.0'; export const GEMINI_CLI_USER_AGENT = 'GeminiCLI/0.31.0/unknown (win32; x64)'; export const GEMINI_CLI_REQUIRED_SERVICE = 'cloudaicompanion.googleapis.com'; +export const GEMINI_CLI_INTERNAL_API_VERSION = 'v1internal'; +export const GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS = 2_000; +export const GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS = 15; +export const GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS = 5_000; +export const GEMINI_CLI_ONBOARD_MAX_ATTEMPTS = 6; function requireGeminiCliOAuthConfig() { if (!GEMINI_CLI_CLIENT_ID) { @@ -46,6 +51,16 @@ type GeminiOAuthTokenPayload = { expiry?: unknown; }; +type GeminiLoadCodeAssistPayload = { + cloudaicompanionProject?: unknown; + allowedTiers?: unknown; +}; + +type GeminiOnboardUserPayload = { + done?: unknown; + response?: unknown; +}; + function asTrimmedString(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const trimmed = value.trim(); @@ -69,6 +84,69 @@ function parseExpiresAt(payload: GeminiOAuthTokenPayload): number | undefined { return undefined; } +function buildGeminiCliMetadata() { + return { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + }; +} + +function extractGeminiProjectId(value: unknown): string | undefined { + if (typeof value === 'string') { + return asTrimmedString(value); + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + return asTrimmedString((value as { id?: unknown }).id); + } + return undefined; +} + +function extractGeminiDefaultTierId(payload: GeminiLoadCodeAssistPayload): string { + const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : []; + for (const rawTier of allowedTiers) { + if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue; + const tier = rawTier as { id?: unknown; isDefault?: unknown }; + if (tier.isDefault === true) { + const tierId = asTrimmedString(tier.id); + if (tierId) return tierId; + } + } + return 'legacy-tier'; +} + +function isGeminiFreeUserProject(input: { + requestedProjectId?: string; + tierId: string; +}) { + const projectId = (input.requestedProjectId || '').trim(); + const tierId = input.tierId.trim(); + return projectId.startsWith('gen-lang-client-') + || tierId.toUpperCase() === 'FREE' + || tierId.toUpperCase() === 'LEGACY'; +} + +function isSameGeminiProjectId(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +function extractGeminiServiceErrorMessage(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ''; + try { + const parsed = JSON.parse(trimmed) as { + error?: { message?: unknown }; + }; + return asTrimmedString(parsed.error?.message) || trimmed; + } catch { + return trimmed; + } +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + async function postGeminiToken(body: URLSearchParams) { const response = await fetch(GEMINI_CLI_TOKEN_URL, { method: 'POST', @@ -110,6 +188,32 @@ async function fetchGeminiUserEmail(accessToken: string): Promise( + accessToken: string, + method: 'loadCodeAssist' | 'onboardUser', + body: Record, +): Promise { + const response = await fetch( + `${GEMINI_CLI_UPSTREAM_BASE_URL}/${GEMINI_CLI_INTERNAL_API_VERSION}:${method}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `api request failed with status ${response.status}`); + } + return response.json() as Promise; +} + async function fetchGcpProjects(accessToken: string): Promise { const response = await fetch(GEMINI_CLI_PROJECTS_URL, { headers: { @@ -127,45 +231,159 @@ async function fetchGcpProjects(accessToken: string): Promise { .filter((projectId): projectId is string => !!projectId); } -async function ensureGeminiProjectEnabled(accessToken: string, projectId: string): Promise { - const response = await fetch( +async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): Promise { + const checkResponse = await fetch( `${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, }, }, ); - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(text || `service usage lookup failed with status ${response.status}`); + if (checkResponse.ok) { + const payload = await checkResponse.json() as { state?: unknown }; + const state = asTrimmedString(payload.state); + if ((state || '').toUpperCase() === 'ENABLED') { + return; + } } - const payload = await response.json() as { state?: unknown }; - const state = asTrimmedString(payload.state); - if ((state || '').toUpperCase() !== 'ENABLED') { - throw new Error(`Cloud AI API not enabled for project ${projectId}`); + + const response = await fetch( + `${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}:enable`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }, + body: '{}', + }, + ); + const text = await response.text().catch(() => ''); + if (response.ok) { + return; } + if (response.status === 400 && extractGeminiServiceErrorMessage(text).toLowerCase().includes('already enabled')) { + return; + } + throw new Error(`project activation required: ${extractGeminiServiceErrorMessage(text) || `HTTP ${response.status}`}`); } -async function resolveGeminiProjectId(accessToken: string, requestedProjectId?: string): Promise { +async function performGeminiCliSetup( + accessToken: string, + requestedProjectId: string, +): Promise { + const trimmedRequest = requestedProjectId.trim(); + const explicitProject = !!trimmedRequest; + const metadata = buildGeminiCliMetadata(); + const loadResponse = await callGeminiCliInternalApi( + accessToken, + 'loadCodeAssist', + explicitProject + ? { + metadata, + cloudaicompanionProject: trimmedRequest, + } + : { metadata }, + ); + + const tierId = extractGeminiDefaultTierId(loadResponse); + let projectId = trimmedRequest || extractGeminiProjectId(loadResponse.cloudaicompanionProject) || ''; + + if (!projectId) { + for (let attempt = 0; attempt < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS; attempt += 1) { + const onboardResponse = await callGeminiCliInternalApi( + accessToken, + 'onboardUser', + { + tierId, + metadata, + }, + ); + if (onboardResponse.done === true) { + const response = ( + onboardResponse.response + && typeof onboardResponse.response === 'object' + && !Array.isArray(onboardResponse.response) + ) + ? onboardResponse.response as { cloudaicompanionProject?: unknown } + : undefined; + projectId = extractGeminiProjectId(response?.cloudaicompanionProject) || ''; + break; + } + if ((attempt + 1) < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS) { + await sleep(GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS); + } + } + } + + if (!projectId) { + throw new Error('gemini cli: project selection required'); + } + + let finalProjectId = projectId; + for (let attempt = 0; attempt < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS; attempt += 1) { + const onboardResponse = await callGeminiCliInternalApi( + accessToken, + 'onboardUser', + { + tierId, + metadata, + cloudaicompanionProject: projectId, + }, + ); + if (onboardResponse.done === true) { + const response = ( + onboardResponse.response + && typeof onboardResponse.response === 'object' + && !Array.isArray(onboardResponse.response) + ) + ? onboardResponse.response as { cloudaicompanionProject?: unknown } + : undefined; + const responseProjectId = extractGeminiProjectId(response?.cloudaicompanionProject) || ''; + if (responseProjectId) { + if (explicitProject && !isSameGeminiProjectId(responseProjectId, projectId)) { + finalProjectId = isGeminiFreeUserProject({ requestedProjectId: projectId, tierId }) + ? responseProjectId + : projectId; + } else { + finalProjectId = responseProjectId; + } + } + return finalProjectId || projectId; + } + if ((attempt + 1) < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS) { + await sleep(GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS); + } + } + + if (finalProjectId) { + return finalProjectId; + } + throw new Error('gemini cli: onboarding timed out'); +} + +async function ensureGeminiProjectAndOnboard( + accessToken: string, + requestedProjectId?: string, +): Promise { const explicitProject = asTrimmedString(requestedProjectId); if (explicitProject) { - await ensureGeminiProjectEnabled(accessToken, explicitProject); - return explicitProject; + return performGeminiCliSetup(accessToken, explicitProject); } const projects = await fetchGcpProjects(accessToken); - if (projects.length <= 0) { - throw new Error('no Google Cloud projects available for this account'); - } - const firstProject = projects[0]; if (!firstProject) { throw new Error('no Google Cloud projects available for this account'); } - await ensureGeminiProjectEnabled(accessToken, firstProject); - return firstProject; + return performGeminiCliSetup(accessToken, firstProject); } export const geminiCliOauthProvider: OAuthProviderDefinition = { @@ -213,7 +431,8 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = { redirect_uri: redirectUri, grant_type: 'authorization_code', })); - const resolvedProjectId = await resolveGeminiProjectId(token.accessToken, projectId); + const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId); + await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId); const email = await fetchGeminiUserEmail(token.accessToken); return { ...token, @@ -231,16 +450,17 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = { grant_type: 'refresh_token', refresh_token: refreshToken, })); - if (oauth?.projectId) { - await ensureGeminiProjectEnabled(token.accessToken, oauth.projectId); - } + const nextProjectId = oauth?.projectId + ? oauth.projectId + : await ensureGeminiProjectAndOnboard(token.accessToken); + await checkCloudAIAPIEnabled(token.accessToken, nextProjectId); const email = await fetchGeminiUserEmail(token.accessToken); return { ...token, email, accountKey: email, accountId: email, - projectId: oauth?.projectId, + projectId: nextProjectId, }; }, buildProxyHeaders: () => ({