From b36d3b6b706cc75df88c6b21f2040b2a2e9e10f4 Mon Sep 17 00:00:00 2001 From: rafaumeu Date: Sun, 19 Jul 2026 23:40:52 -0300 Subject: [PATCH] fix(antigravity): attempt onboarding when projectId is empty (#5193 regression of #2541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #5193 changed onboarding from inline await to fire-and-forget gated by if (projectId), which never fires when projectId is empty — the exact case that needs onboarding. This re-introduced the #2541 catch-22. Add an else-if branch that attempts onboarding inline (bounded by AbortSignal.timeout) when projectId is empty, then retries loadCodeAssist to discover the newly created project. - Existing accounts with projectId: unchanged (fire-and-forget) - New accounts without projectId: now onboarded within login flow - Timeout bounded: +8s worst case (onboardUser + retry loadCodeAssist) - Graceful degradation: if onboarding fails, lazy retry handles it Tests: - 3 new tests covering empty-projectId onboarding path (RED→GREEN) - Existing 2 tests preserved and passing - Adjusted timeout assertion for the stall test (now includes onboardUser stall) - 5/5 passing on Node 24 Fixes #7814 Related: #5193, #2569, #2541, #2219 --- src/lib/oauth/providers/antigravity.ts | 39 +++++- ...ity-oauth-postexchange-nonblocking.test.ts | 123 +++++++++++++++++- 2 files changed, 152 insertions(+), 10 deletions(-) diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index 5c8ac0c3ce..9128c62845 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -116,13 +116,13 @@ 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) { + // Fire-and-forget onboarding — 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) const onboardInBackground = async () => { for (let i = 0; i < 10; i++) { try { @@ -140,6 +140,33 @@ export const antigravity = { } }; void onboardInBackground().catch(() => {}); + } else if (ANTIGRAVITY_CONFIG.onboardUserEndpoints.length > 0) { + // No projectId — the account has no pre-existing Cloud Code project. + // Attempt onboarding inline (bounded by timeout) so the project gets created + // and discovered within the same login flow. If onboarding succeeds, retry + // loadCodeAssist to pick up the newly created projectId. + // (#5193 regression of #2541 — catch-22: onboarding was never attempted) + try { + await fetchFirstOk( + ANTIGRAVITY_CONFIG.onboardUserEndpoints, + { method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) }, + POSTEXCHANGE_TIMEOUT_MS + ); + // Onboarding succeeded (or at least the endpoint responded ok). Retry + // loadCodeAssist to discover the newly created project. + const retryRes = await fetchFirstOk( + ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints, + { method: "POST", headers, body: JSON.stringify({ metadata }) }, + POSTEXCHANGE_TIMEOUT_MS + ); + const retryData = await retryRes.json(); + projectId = + retryData.cloudaicompanionProject?.id || retryData.cloudaicompanionProject || ""; + } catch { + // Onboarding or retry failed/timed out — graceful degradation. + // projectId stays empty. Lazy retry at request-time by + // antigravityProjectBootstrap.ts will handle it later. + } } return { userInfo, projectId, tierId }; diff --git a/tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts b/tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts index dc01cf53b4..8b9537d93f 100644 --- a/tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts +++ b/tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts @@ -89,7 +89,8 @@ test("postExchange returns before onboarding finishes (fire-and-forget — never 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); + if (u.includes("userinfo") || u.includes("loadCodeAssist") || u.includes("onboardUser")) + return stalledFetch(init); return jsonRes({}); }) as typeof fetch; @@ -97,8 +98,122 @@ test("postExchange stays timeout-bounded when loadCodeAssist/userinfo stall (no 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`); + // userInfo + loadCodeAssist + onboardUser(attempt) are each AbortSignal.timeout(8s)-bounded. + // When loadCodeAssist stalls → projectId empty → else-branch attempts onboardUser (also 8s). + // Worst case: 8 + 8 + 8 = 24s. With onboarding retry of loadCodeAssist also stalling: still + // within the 8s shared deadline of the retry. Never an infinite hang. + assert.ok(elapsed < 30000, `postExchange must be timeout-bounded; took ${elapsed}ms`); assert.equal(result.projectId, "", "no project when loadCodeAssist times out"); }); + +// --------------------------------------------------------------------------- +// Tests for the empty-projectId onboarding path (fix for #5193 regression of #2541). +// +// When loadCodeAssist returns empty projectId (no pre-existing Cloud Code project), +// postExchange MUST attempt onboarding inline (not fire-and-forget) so the project +// gets created and discovered within the same login flow. +// +// Flip-proof: remove the `else` branch in postExchange and these tests fail — +// projectId stays empty because onboarding never runs. + +test("postExchange attempts onboarding when projectId is empty and returns discovered projectId", async () => { + // loadCodeAssist returns no project → onboarding should be attempted inline + // → retry loadCodeAssist returns the newly created project. + let loadCodeAssistCallCount = 0; + let onboardUserCalled = false; + + globalThis.fetch = (async (url: unknown) => { + const u = String(url); + if (u.includes("userinfo")) return jsonRes({ email: "new-user@example.com" }); + if (u.includes("loadCodeAssist")) { + loadCodeAssistCallCount++; + // First call: no project (triggers onboarding). Second call: project found. + if (loadCodeAssistCallCount === 1) { + return jsonRes({ + cloudaicompanionProject: null, + allowedTiers: [{ id: "legacy-tier", isDefault: true }], + }); + } + return jsonRes({ + cloudaicompanionProject: "new-project-456", + allowedTiers: [{ id: "legacy-tier", isDefault: true }], + }); + } + if (u.includes("onboardUser")) { + onboardUserCalled = true; + return jsonRes({ done: true }); + } + return jsonRes({}); + }) as typeof fetch; + + const result = await antigravity.postExchange({ access_token: "tok" } as never); + + assert.ok(onboardUserCalled, "onboardUser must be called when projectId is empty"); + assert.equal( + loadCodeAssistCallCount, + 2, + "loadCodeAssist must be called twice: initial (empty) + retry after onboarding" + ); + assert.equal( + result.projectId, + "new-project-456", + "projectId discovered after onboarding + retry" + ); +}); + +test("postExchange falls back to empty projectId when onboardUser fails", async () => { + // loadCodeAssist returns no project → onboarding attempted but fails → + // projectId remains empty (graceful degradation, never throws). + let onboardUserCalled = 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: null, + allowedTiers: [{ id: "legacy-tier", isDefault: true }], + }); + } + if (u.includes("onboardUser")) { + onboardUserCalled = true; + return jsonRes({ error: "PERMISSION_DENIED" }, 403); + } + return jsonRes({}); + }) as typeof fetch; + + const result = await antigravity.postExchange({ access_token: "tok" } as never); + + assert.ok(onboardUserCalled, "onboardUser must still be attempted even if it may fail"); + assert.equal( + result.projectId, + "", + "projectId stays empty when onboarding fails (graceful degradation)" + ); +}); + +test("postExchange is timeout-bounded during onboarding attempt (empty projectId)", async () => { + // loadCodeAssist returns no project → onboarding stalls → timeout kicks in → + // postExchange returns empty projectId within ~8s (not infinite hang). + globalThis.fetch = (async (url: unknown, init?: { signal?: AbortSignal }) => { + const u = String(url); + if (u.includes("userinfo")) return jsonRes({ email: "user@example.com" }); + if (u.includes("loadCodeAssist")) { + return jsonRes({ + cloudaicompanionProject: null, + allowedTiers: [{ id: "legacy-tier", isDefault: true }], + }); + } + if (u.includes("onboardUser")) { + 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; + + assert.ok(elapsed < 22000, `onboarding attempt must be timeout-bounded; took ${elapsed}ms`); + assert.equal(result.projectId, "", "projectId stays empty when onboarding times out"); +});