diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index fd6df33e3c3..3d25670d6c0 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -149,7 +149,8 @@ "open-sse/mcp-server/schemas/tools.ts": 1497, "open-sse/mcp-server/server.ts": 1555, "open-sse/mcp-server/tools/advancedTools.ts": 1118, - "open-sse/services/accountFallback.ts": 1773, + "_rebaseline_2026_06_27_5193_antigravity_basered": "Base-red (pre-existing release drift, fast-gate PR->release skips check:file-size): accountFallback.ts 1773->1777 and src/app/api/providers/[id]/test/route.ts 924->940 were already over their frozen caps on release/v3.8.39 independent of any antigravity change. Owner chose to rebaseline (keep the documented issue-reference comments #1846/#1449/#347 etc.) rather than accept the contributor comment-stripping in #5200/#5198. Reverted #5200 to restore the comments; bumped these two frozen caps to the actual base sizes. No logic change.", + "open-sse/services/accountFallback.ts": 1777, "open-sse/services/batchProcessor.ts": 828, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, @@ -206,7 +207,7 @@ "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1121, "src/app/api/oauth/[provider]/[action]/route.ts": 924, "src/app/api/providers/[id]/models/route.ts": 2593, - "src/app/api/providers/[id]/test/route.ts": 924, + "src/app/api/providers/[id]/test/route.ts": 940, "src/app/api/usage/analytics/route.ts": 941, "src/app/api/v1/models/catalog.ts": 1615, "src/lib/cloudflaredTunnel.ts": 934, @@ -227,7 +228,8 @@ "src/lib/usage/callLogs.ts": 975, "src/lib/usage/providerLimits.ts": 955, "src/lib/usage/usageHistory.ts": 983, - "src/shared/components/OAuthModal.tsx": 960, + "_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.", + "src/shared/components/OAuthModal.tsx": 969, "src/shared/components/RequestLoggerV2.tsx": 1316, "src/shared/components/analytics/charts.tsx": 1558, "src/shared/constants/cliTools.ts": 875, @@ -270,7 +272,8 @@ "tests/unit/models-catalog-route.test.ts": 1507, "_rebaseline_pr4561_qwen_oauth_url": "Reconcile #4561 (port decolua/9router#683) already-merged growth: oauth-providers-config.test.ts 855->867 (+12, qwen.ai URL regression-pin test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", "_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').", - "tests/unit/oauth-providers-config.test.ts": 870, + "_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).", + "tests/unit/oauth-providers-config.test.ts": 873, "tests/unit/perplexity-web.test.ts": 959, "tests/unit/provider-models-route.test.ts": 1618, "tests/unit/provider-validation-specialty.test.ts": 2801, diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 851e6112847..c6a7bd437e1 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -183,8 +183,10 @@ export const ANTIGRAVITY_CONFIG = { authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo", + // No "openid" scope — the working 9router flow requests only the Cloud Code / + // userinfo scopes below. "openid" (with PKCE) routed Google into the hanging + // `firstparty/nativeapp` consent. Match 9router exactly (antigravity login fix). scopes: [ - "openid", "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index 86277ae3fb0..5c8ac0c3ced 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -6,11 +6,21 @@ import { } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts"; -async function fetchFirstOk(endpoints: string[], init: RequestInit) { +// Bound every Antigravity post-exchange call. Without this an unreachable/slow +// upstream made the `/exchange` request (and therefore the whole OAuth login) +// hang forever — the dashboard "just spins". Mirrors the AbortSignal.timeout +// pattern already used by antigravityProjectBootstrap.ts. +const POSTEXCHANGE_TIMEOUT_MS = 8_000; + +async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) { let lastError: unknown = null; + // One shared deadline for the WHOLE fallback list — a stalled set of endpoints + // must bound to a single timeout, not timeoutMs × endpoints (that re-introduced + // a ~40s login wait). A reachable endpoint still returns immediately. + const signal = timeoutMs ? AbortSignal.timeout(timeoutMs) : init.signal; for (const endpoint of endpoints) { try { - const response = await fetch(endpoint, init); + const response = await fetch(endpoint, { ...init, signal }); if (response.ok) return response; lastError = new Error(`${response.status} ${await response.text()}`); } catch (error) { @@ -22,7 +32,13 @@ async function fetchFirstOk(endpoints: string[], init: RequestInit) { export const antigravity = { config: ANTIGRAVITY_CONFIG, - flowType: "authorization_code_pkce", + // NO PKCE. The embedded Antigravity client is a Google "Desktop/native" OAuth client; + // sending a PKCE code_challenge (combined with the openid scope) pushed Google into the + // `signin/oauth/firstparty/nativeapp` consent flow that hangs and never redirects back + // (operator report 2026-06-27). The working 9router flow uses a plain authorization_code + // grant with client_secret and no code_challenge — match it exactly. The token exchange + // already sends client_secret (ANTIGRAVITY_OAUTH_CLIENT_SECRET) and omits code_verifier. + flowType: "authorization_code", buildAuthUrl: (config, redirectUri, state, codeChallenge) => { const params = new URLSearchParams({ client_id: config.clientId, @@ -39,7 +55,13 @@ export const antigravity = { } return `${config.authorizeUrl}?${params.toString()}`; }, - exchangeToken: async (config, code, redirectUri, codeVerifier) => { + // NOTE: no PKCE. Antigravity is a plain authorization_code grant now (see flowType + // above). The shared generateAuthData() still mints a codeVerifier for every flow, but + // we MUST NOT forward it here — the authorize URL carries no code_challenge, so sending + // a code_verifier makes Google reject the exchange with invalid_grant ("code_verifier + // provided but code_challenge was not"), surfacing as a 500 on /exchange. Ignore it and + // authenticate with client_secret only, exactly like the working 9router flow. + exchangeToken: async (config, code, redirectUri) => { const bodyParams: Record = { grant_type: "authorization_code", client_id: config.clientId, @@ -51,10 +73,6 @@ export const antigravity = { bodyParams.client_secret = config.clientSecret; } - if (codeVerifier) { - bodyParams.code_verifier = codeVerifier; - } - const response = await fetch(config.tokenUrl, { method: "POST", headers: { @@ -76,19 +94,21 @@ export const antigravity = { const headers = getAntigravityHeaders("loadCodeAssist", tokens.access_token); const metadata = getAntigravityLoadCodeAssistMetadata(); + // Best-effort + bounded: a slow userinfo endpoint must never hang the login. const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { headers: { Authorization: `Bearer ${tokens.access_token}` }, - }); - const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; + signal: AbortSignal.timeout(POSTEXCHANGE_TIMEOUT_MS), + }).catch(() => null); + const userInfo = userInfoRes?.ok ? await userInfoRes.json() : {}; let projectId = ""; let tierId = "legacy-tier"; try { - const loadRes = await fetchFirstOk(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints, { - method: "POST", - headers, - body: JSON.stringify({ metadata }), - }); + const loadRes = await fetchFirstOk( + ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints, + { method: "POST", headers, body: JSON.stringify({ metadata }) }, + POSTEXCHANGE_TIMEOUT_MS + ); const data = await loadRes.json(); projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; tierId = extractCodeAssistOnboardTierId(data); @@ -96,28 +116,30 @@ export const antigravity = { console.log("Failed to load code assist:", e); } + // Fire-and-forget onboarding — it must NOT block the OAuth login response. + // The previous inline `await` loop (up to 10×5s, each fetch un-timed) made the + // `/exchange` request hang forever when an upstream was slow/unreachable, so the + // dashboard "just spun". Onboarding is also performed lazily at request time by + // antigravityProjectBootstrap.ts, so backgrounding it here is safe. Matches the + // 9router web flow. (#5180-followup / antigravity login hang) if (projectId) { - try { + const onboardInBackground = async () => { for (let i = 0; i < 10; i++) { - const onboardRes = await fetchFirstOk(ANTIGRAVITY_CONFIG.onboardUserEndpoints, { - method: "POST", - headers, - body: JSON.stringify({ tier_id: tierId, metadata }), - }); - const result = await onboardRes.json(); - if (result.done === true) { - if (result.response?.cloudaicompanionProject) { - const respProject = result.response.cloudaicompanionProject; - projectId = - typeof respProject === "string" ? respProject.trim() : respProject.id || projectId; - } + try { + const onboardRes = await fetchFirstOk( + ANTIGRAVITY_CONFIG.onboardUserEndpoints, + { method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) }, + POSTEXCHANGE_TIMEOUT_MS + ); + const result = await onboardRes.json(); + if (result.done === true) break; + } catch { break; } await new Promise((resolve) => setTimeout(resolve, 5000)); } - } catch (e) { - console.log("Failed to onboard user:", e); - } + }; + void onboardInBackground().catch(() => {}); } return { userInfo, projectId, tierId }; diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 025a3e2b21c..455938aaa8b 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -849,8 +849,16 @@ export default function OAuthModal({ )} - {/* Generic remote info for other providers */} - {!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.has(provider) && ( + {/* Actionable remote paste instruction — shown for ALL remote providers, + including Google OAuth (antigravity/agy/gemini-cli). The Google + loopback creds redirect to 127.0.0.1:/callback, which on a + remotely-accessed dashboard lands on the operator's own machine and + shows a "can't reach this page" error. That is expected: the URL bar + still carries ?code=…, and pasting it below completes the login. Before + this, Google providers only saw the discouraging loopback warning and + never the "copy the URL and paste it" step, so remote login appeared to + hang. */} + {!isTrueLocalhost && (
info diff --git a/tests/unit/antigravity-oauth-no-pkce-no-openid.test.ts b/tests/unit/antigravity-oauth-no-pkce-no-openid.test.ts new file mode 100644 index 00000000000..c43cd3becc9 --- /dev/null +++ b/tests/unit/antigravity-oauth-no-pkce-no-openid.test.ts @@ -0,0 +1,83 @@ +// Regression guard for the Antigravity OAuth login hang on Google's consent page. +// +// The embedded Antigravity client is a Google "Desktop/native" OAuth client. +// Sending a PKCE code_challenge AND the `openid` scope pushed Google into the +// `signin/oauth/firstparty/nativeapp` consent flow, which hung and never redirected +// back (operator report 2026-06-27). The working 9router flow uses a plain +// authorization_code grant (client_secret, no code_challenge) and does NOT request +// `openid`. This test pins our antigravity (and the `agy` alias) to that shape. +// +// Flip-proof: set flowType back to "authorization_code_pkce" → generateAuthData emits +// code_challenge → first assertion fails. Re-add "openid" → scope assertion fails. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { generateAuthData } from "../../src/lib/oauth/providers.ts"; +import PROVIDERS from "../../src/lib/oauth/providers/index.ts"; + +const REDIRECT = "http://127.0.0.1:20128/callback"; + +for (const providerId of ["antigravity", "agy"]) { + test(`${providerId}: no PKCE + no openid in the auth URL (matches working 9router flow)`, () => { + assert.equal( + PROVIDERS[providerId].flowType, + "authorization_code", + `${providerId} must use a plain authorization_code grant (no PKCE) for the Google native client` + ); + + const authData = generateAuthData(providerId, REDIRECT); + assert.ok(authData.authUrl, `${providerId} must produce an auth URL`); + + const url = new URL(authData.authUrl); + assert.equal(url.origin, "https://accounts.google.com"); + + // No PKCE challenge — its presence triggers the hanging nativeapp consent. + assert.equal( + url.searchParams.get("code_challenge"), + null, + `${providerId} auth URL must NOT carry a PKCE code_challenge` + ); + assert.equal(url.searchParams.get("code_challenge_method"), null); + + // No openid scope — only the Cloud Code / userinfo scopes 9router requests. + const scopes = (url.searchParams.get("scope") || "").split(" "); + assert.ok(!scopes.includes("openid"), `${providerId} must not request the openid scope`); + assert.ok( + scopes.includes("https://www.googleapis.com/auth/cloud-platform"), + `${providerId} must still request the cloud-platform scope` + ); + }); +} + +test("antigravity.exchangeToken never forwards code_verifier (no PKCE → no invalid_grant 500)", async () => { + const origFetch = globalThis.fetch; + let sentBody = ""; + globalThis.fetch = (async (_url: unknown, init: { body?: unknown } = {}) => { + sentBody = String(init.body ?? ""); + return new Response( + JSON.stringify({ access_token: "t", refresh_token: "r", expires_in: 3600 }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + try { + // Pass a codeVerifier (as the modal does — generateAuthData always mints one). + // It MUST be ignored: the authorize URL had no code_challenge, so forwarding a + // code_verifier makes Google reject the exchange (invalid_grant → 500). + await PROVIDERS.antigravity.exchangeToken( + { + clientId: "cid", + clientSecret: "sec", + tokenUrl: "https://oauth2.googleapis.com/token", + }, + "the-code", + "http://127.0.0.1:20128/callback", + "should-be-ignored-verifier" + ); + } finally { + globalThis.fetch = origFetch; + } + const params = new URLSearchParams(sentBody); + assert.equal(params.get("code_verifier"), null, "must NOT forward code_verifier (no PKCE)"); + assert.equal(params.get("client_secret"), "sec", "must authenticate via client_secret"); + assert.equal(params.get("grant_type"), "authorization_code"); +}); diff --git a/tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts b/tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts new file mode 100644 index 00000000000..dc01cf53b4b --- /dev/null +++ b/tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts @@ -0,0 +1,104 @@ +// Regression guard for the Antigravity OAuth login hang. +// +// The dashboard login "just spun forever" because postExchange `await`ed the +// onboardUser retry loop (up to 10×5s, each fetch un-timed) inline, so a slow/ +// unreachable Antigravity upstream blocked the /exchange response indefinitely. +// +// Fix: onboarding is fire-and-forget (matches the 9router web flow) and every +// blocking call is AbortSignal.timeout-bounded. This test proves postExchange +// returns promptly regardless of onboarding, and never hangs when an upstream +// stalls. +// +// Flip-proof: revert onboarding to an inline `await` loop and test 1 hangs on the +// onboard gate → times out → fails. Drop the AbortSignal.timeout and test 2 +// hangs → fails. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { antigravity } from "../../src/lib/oauth/providers/antigravity.ts"; + +const originalFetch = globalThis.fetch; + +function jsonRes(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// A fetch that rejects when its AbortSignal fires, and otherwise never resolves. +// Mirrors real fetch: an already-aborted signal rejects immediately (so a shared +// deadline reused across fallback endpoints fails fast after the first abort). +function stalledFetch(init?: { signal?: AbortSignal }): Promise { + return new Promise((_resolve, reject) => { + const abortErr = () => new DOMException("The operation was aborted.", "AbortError"); + const signal = init?.signal; + if (signal?.aborted) { + reject(abortErr()); + return; + } + signal?.addEventListener("abort", () => reject(abortErr())); + }); +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("postExchange returns before onboarding finishes (fire-and-forget — never blocks login)", async () => { + // The onboard call is gated: it does not resolve until we release it AFTER + // postExchange has already returned. With the old inline `await` loop, + // postExchange would block on this gate forever → the test times out. With the + // fire-and-forget fix it returns immediately. + let releaseOnboard: () => void = () => {}; + const onboardGate = new Promise((r) => { + releaseOnboard = r; + }); + let onboardStarted = false; + + globalThis.fetch = (async (url: unknown) => { + const u = String(url); + if (u.includes("userinfo")) return jsonRes({ email: "user@example.com" }); + if (u.includes("loadCodeAssist")) { + return jsonRes({ + cloudaicompanionProject: "proj-123", + allowedTiers: [{ id: "legacy-tier", isDefault: true }], + }); + } + if (u.includes("onboardUser")) { + onboardStarted = true; + await onboardGate; + return jsonRes({ done: true }); + } + return jsonRes({}); + }) as typeof fetch; + + const start = Date.now(); + const result = await antigravity.postExchange({ access_token: "tok" } as never); + const elapsed = Date.now() - start; + + assert.ok(elapsed < 3000, `postExchange must not block on onboarding; took ${elapsed}ms`); + assert.equal(result.projectId, "proj-123", "projectId still resolved from loadCodeAssist"); + + // Let the backgrounded onboarding complete cleanly (no lingering work). + releaseOnboard(); + await new Promise((r) => setTimeout(r, 50)); + assert.ok(onboardStarted, "onboarding still runs — in the background, after the response"); +}); + +test("postExchange stays timeout-bounded when loadCodeAssist/userinfo stall (no infinite hang)", async () => { + globalThis.fetch = (async (url: unknown, init?: { signal?: AbortSignal }) => { + const u = String(url); + if (u.includes("userinfo") || u.includes("loadCodeAssist")) return stalledFetch(init); + return jsonRes({}); + }) as typeof fetch; + + const start = Date.now(); + const result = await antigravity.postExchange({ access_token: "tok" } as never); + const elapsed = Date.now() - start; + + // userInfo + loadCodeAssist are AbortSignal.timeout(8s)-bounded (one shared + // deadline each), so the worst case is ~16s — never an infinite hang. + assert.ok(elapsed < 22000, `postExchange must be timeout-bounded; took ${elapsed}ms`); + assert.equal(result.projectId, "", "no project when loadCodeAssist times out"); +}); diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index 9b7e9191ece..1d4b6a5b93c 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -637,11 +637,19 @@ test("Gemini and Antigravity run mocked browser OAuth exchanges and post-exchang ); const antigravityExtra = await PROVIDERS.antigravity.postExchange(antigravityTokens); const antigravityMapped = PROVIDERS.antigravity.mapTokens(antigravityTokens, antigravityExtra); + // postExchange runs onboarding fire-and-forget now (it must never block the OAuth + // login response); give the background onboard call a tick to consume its mocked + // fetch so the sequence drains deterministically. + await new Promise((r) => setTimeout(r, 50)); assert.equal(geminiMapped.email, "gemini@example.com"); assert.equal(geminiMapped.projectId, "gemini-project"); assert.equal(antigravityMapped.email, "anti@example.com"); - assert.equal(antigravityMapped.projectId, "anti-project-final"); + // projectId comes from loadCodeAssist ("anti-project"), NOT the backgrounded + // onboardUser response ("anti-project-final"). Onboarding is fire-and-forget, so it + // no longer updates the returned projectId synchronously — matching the 9router web + // flow, which also returns the loadCodeAssist project id. + assert.equal(antigravityMapped.projectId, "anti-project"); }); test("Qoder enabled mode exchanges tokens and loads profile metadata through mocked endpoints", async () => {