From 6de542b9b6b25a80c988b3abd47a17eb223393eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:42:33 +0330 Subject: [PATCH] fix(providers): mark Antigravity connects with no Cloud Code projectId as degraded (#11284) (#11358) Merged via consolidated batch validation. Production evidence (VPS docker instance): Antigravity OAuth connects ending without a Cloud Code projectId were persisted as silently active while every model call failed; now persisted as degraded. Own tests pass. --- ...4-antigravity-empty-projectid-rejection.md | 1 + .../services/antigravityProjectPersist.ts | 19 +- .../services/antigravityProjectPersistence.ts | 12 ++ .../api/oauth/[provider]/[action]/route.ts | 23 +- src/lib/oauth/antigravityProjectGate.ts | 61 ++++++ src/lib/oauth/connectionPersistence.ts | 20 +- src/lib/oauth/providers/antigravity.ts | 70 ++++++- ...ntigravity-empty-project-selection.test.ts | 64 ++++++ ...ravity-missing-project-autodisable.test.ts | 90 ++++++++ .../antigravity-missing-project-chat.test.ts | 6 +- ...vity-oauth-empty-project-rejection.test.ts | 197 ++++++++++++++++++ ...uth-route-antigravity-project-gate.test.ts | 50 +++++ 12 files changed, 595 insertions(+), 18 deletions(-) create mode 100644 changelog.d/fixes/11284-antigravity-empty-projectid-rejection.md create mode 100644 src/lib/oauth/antigravityProjectGate.ts create mode 100644 tests/unit/antigravity-empty-project-selection.test.ts create mode 100644 tests/unit/antigravity-missing-project-autodisable.test.ts create mode 100644 tests/unit/antigravity-oauth-empty-project-rejection.test.ts create mode 100644 tests/unit/oauth-route-antigravity-project-gate.test.ts diff --git a/changelog.d/fixes/11284-antigravity-empty-projectid-rejection.md b/changelog.d/fixes/11284-antigravity-empty-projectid-rejection.md new file mode 100644 index 0000000000..f86208d9a7 --- /dev/null +++ b/changelog.d/fixes/11284-antigravity-empty-projectid-rejection.md @@ -0,0 +1 @@ +- **fix(providers):** Antigravity OAuth marks connects with no Cloud Code projectId as degraded instead of a false "Connected"; BYOP detection at connect time, auto-disable of confirmed-missing accounts, and selection-side rotation ([#11284](https://github.com/diegosouzapw/OmniRoute/issues/11284)) diff --git a/open-sse/services/antigravityProjectPersist.ts b/open-sse/services/antigravityProjectPersist.ts index b7f55343c2..a4985e816a 100644 --- a/open-sse/services/antigravityProjectPersist.ts +++ b/open-sse/services/antigravityProjectPersist.ts @@ -52,8 +52,23 @@ export function preferAntigravityConnectionsWithStoredProject).projectId; return typeof projectId === "string" && projectId.trim().length > 0; }; - const withStoredProject = connections.filter(hasStoredProject); - return withStoredProject.length > 0 ? withStoredProject : connections; + // #11284: rows whose missing Cloud Code project was CONFIRMED at request + // time (errorCode="missing_project_id") are dead weight — drop them when a + // healthier sibling exists. When every row is confirmed missing, keep the + // pool so the typed 422 (not an empty-selection 404) explains what to fix. + const hasHealthySibling = (connection: T): boolean => + connections.some( + (other) => other !== connection && other.errorCode !== "missing_project_id" + ); + const candidates = connections.filter( + (connection) => + connection.errorCode !== "missing_project_id" || + !hasHealthySibling(connection) || + !hasStoredProject(connection) + ); + const withStoredProject = candidates.filter(hasStoredProject); + if (withStoredProject.length > 0) return withStoredProject; + return candidates.length > 0 ? candidates : connections; } export async function persistDiscoveredAntigravityProjectId( diff --git a/open-sse/services/antigravityProjectPersistence.ts b/open-sse/services/antigravityProjectPersistence.ts index 5e9426c1e9..1013fefdee 100644 --- a/open-sse/services/antigravityProjectPersistence.ts +++ b/open-sse/services/antigravityProjectPersistence.ts @@ -64,6 +64,11 @@ export function persistDiscoveredAntigravityProjectId( errorCode: null, lastError: null, lastErrorType: null, + // #11284: a discovered project proves the account is usable again — + // re-enable it (markAntigravityMissingCloudCodeProject may have disabled + // it after a confirmed-missing 422). + isActive: true, + testStatus: "active", providerSpecificData, }) .catch(() => {}) @@ -77,7 +82,14 @@ export function markAntigravityMissingCloudCodeProject( ): void { if (!connectionId) return; + // #11284: a CONFIRMED missing Cloud Code project is not transient — disable + // the row so selection rotates to healthy siblings instead of re-dispatching + // into the same 422 every request. "unavailable" is deliberately NOT a + // terminal status: persistDiscoveredAntigravityProjectId() re-enables the + // account the moment a project shows up at request time. void updateProviderConnection(connectionId, { + isActive: false, + testStatus: "unavailable", errorCode: "missing_project_id", lastError: "Missing Google projectId for Antigravity account. Reconnect OAuth after completing Gemini Code Assist onboarding.", diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index b52bae4201..9082360278 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -25,6 +25,7 @@ import { import { getConsistentMachineId } from "@/shared/utils/machineId"; import { isValidGheUrl } from "@/shared/validation/providerSpecificData"; import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth"; +import { antigravityDegradedProjectState } from "@/lib/oauth/antigravityProjectGate"; import { syncToCloud } from "@/lib/cloudSync"; import { startLocalServer } from "@/lib/oauth/utils/server"; import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts"; @@ -520,6 +521,12 @@ export async function POST( exchangeTokens(provider, code, redirectUri, codeVerifier, normalizedState) ); + // #11284: when Cloud Code projectId discovery failed at connect time, + // SAVE the connection but mark it degraded (maintainer direction on + // #11284) — the refresh token stays stored and request-time bootstrap + // self-heals the row once Google assigns a project. + const degradedProject = antigravityDegradedProjectState(provider, tokenData); + // Normalize: if name is missing, use email or displayName as fallback so accounts // always show a real label (e.g. user@gmail.com) instead of "Account #abc123" if (!tokenData.name && (tokenData.email || tokenData.displayName)) { @@ -542,14 +549,15 @@ export async function POST( connection = await updateProviderConnection(matchId, { ...tokenData, expiresAt, - testStatus: "active", + testStatus: degradedProject?.testStatus ?? "active", + ...(degradedProject ?? {}), isActive: true, }); } } if (!connection) { connection = await createProviderConnection( - buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt) + buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt, degradedProject) ); } @@ -558,6 +566,7 @@ export async function POST( return NextResponse.json({ success: true, + ...(degradedProject ? { warning: degradedProject.warning } : {}), connection: { id: connection.id, provider: connection.provider, @@ -739,6 +748,10 @@ export async function POST( exchangeTokens(provider, params.code, redirectUri, codeVerifier, params.state) ); + // #11284: when Cloud Code projectId discovery failed at connect time, + // SAVE the connection but mark it degraded (maintainer direction). + const degradedProject = antigravityDegradedProjectState(provider, tokenData); + // Normalize: if name is missing, use email as fallback display label if (!tokenData.name && (tokenData.email || tokenData.displayName)) { tokenData.name = tokenData.email || tokenData.displayName; @@ -765,14 +778,15 @@ export async function POST( connection = await updateProviderConnection(matchId, { ...tokenData, expiresAt, - testStatus: "active", + testStatus: degradedProject?.testStatus ?? "active", + ...(degradedProject ?? {}), isActive: true, }); } } if (!connection) { connection = await createProviderConnection( - buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt) + buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt, degradedProject) ); } @@ -780,6 +794,7 @@ export async function POST( return NextResponse.json({ success: true, + ...(degradedProject ? { warning: degradedProject.warning } : {}), connection: { id: connection.id, provider: connection.provider, diff --git a/src/lib/oauth/antigravityProjectGate.ts b/src/lib/oauth/antigravityProjectGate.ts new file mode 100644 index 0000000000..8eaa54c6a2 --- /dev/null +++ b/src/lib/oauth/antigravityProjectGate.ts @@ -0,0 +1,61 @@ +/** + * #11284 — Antigravity OAuth connect-time DEGRADE marking for accounts without + * a Cloud Code projectId. Shared helper used by the OAuth route's `exchange`, + * `poll-callback`, and the shared persistOAuthConnection path. + * + * Maintainer direction on #11284: do NOT reject the connect — SAVE the + * connection but mark it degraded, so the refresh token stays stored and the + * request-time bootstrap can self-heal it (persistDiscoveredAntigravityProjectId + * flips the row back to active). Confirmed-BYOP accounts get disabled by + * markAntigravityMissingCloudCodeProject() on the first dispatch instead. + */ + +export type AntigravityDegradedProjectState = { + /** Persist with this status instead of "active". */ + testStatus: "degraded"; + errorCode: string; + lastErrorType: string; + lastError: string; + /** Non-fatal warning surfaced in the connect response for the dashboard. */ + warning: string; +}; + +/** Providers whose Cloud Code projectId is expected at connect time. */ +const PROJECT_EXPECTED_PROVIDERS = new Set(["antigravity", "agy"]); + +const BYOP_WARNING = + "Connected, but Google did not assign a Cloud Code project to this account (BYOP). " + + "Create a GCP Project at console.cloud.google.com and complete Gemini Code Assist onboarding; " + + "the account is marked degraded until then and cannot serve requests."; + +const DISCOVERY_FAILED_WARNING = + "Connected, but the Google Cloud Code projectId could not be discovered during login " + + "(loadCodeAssist/onboardUser failed). The account is marked degraded; discovery retries " + + "automatically on the first request."; + +/** + * #11284: when projectId discovery failed at connect time, return the degrade + * fields to persist (testStatus:"degraded" + typed error markers) instead of + * silently saving a false "active". Returns null for healthy payloads. + */ +export function antigravityDegradedProjectState( + provider: string, + tokenData: Record | null | undefined +): AntigravityDegradedProjectState | null { + if (!PROJECT_EXPECTED_PROVIDERS.has(provider)) return null; + const outcome = tokenData?.projectDiscoveryOutcome; + if (!outcome) return null; + console.warn( + `[oauth] ${provider}: marking connection degraded — no Cloud Code projectId (${String(outcome)}) (#11284)` + ); + return { + testStatus: "degraded", + errorCode: "missing_project_id", + lastErrorType: "oauth_missing_project_id", + lastError: + outcome === "requires_manual_project" + ? BYOP_WARNING + : DISCOVERY_FAILED_WARNING, + warning: outcome === "requires_manual_project" ? BYOP_WARNING : DISCOVERY_FAILED_WARNING, + }; +} diff --git a/src/lib/oauth/connectionPersistence.ts b/src/lib/oauth/connectionPersistence.ts index 4b4ad753fc..00a7ff2994 100644 --- a/src/lib/oauth/connectionPersistence.ts +++ b/src/lib/oauth/connectionPersistence.ts @@ -96,7 +96,13 @@ export function findExistingOAuthConnectionMatch( export function buildOAuthConnectionCreatePayload( provider: string, tokenData: Record, - expiresAt: string | null + expiresAt: string | null, + degradedProject?: { + testStatus: "degraded"; + errorCode: string; + lastErrorType: string; + lastError: string; + } | null ) { return { provider, @@ -104,7 +110,17 @@ export function buildOAuthConnectionCreatePayload( ...tokenData, expiresAt, tokenExpiresAt: expiresAt, - testStatus: "active" as const, + // #11284: degraded when Cloud Code projectId discovery failed at connect + // time — the row is saved (refresh token stored, request-time bootstrap + // can self-heal) but visibly NOT active. + testStatus: degradedProject?.testStatus ?? ("active" as const), + ...(degradedProject + ? { + errorCode: degradedProject.errorCode, + lastErrorType: degradedProject.lastErrorType, + lastError: degradedProject.lastError, + } + : {}), }; } diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index f3790141c6..dbeab90947 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -17,10 +17,20 @@ type AntigravityTokenPayload = { refresh_token?: string; scope?: string; }; +/** + * Why no Cloud Code projectId was discovered at connect time (#11284). + * - "requires_manual_project": Google answered onboardUser with 200 but no + * cloudaicompanionProject in the body — the account must bring its own GCP + * project (BYOP, #8491). Retrying can never succeed. + * - "discovery_failed": loadCodeAssist/onboardUser errored, timed out, or + * still returned empty after a successful onboarding round-trip. + */ +type AntigravityProjectDiscoveryOutcome = "requires_manual_project" | "discovery_failed"; type AntigravityPostExchange = { projectId: string; tierId: string; userInfo: { email?: string }; + projectDiscoveryOutcome?: AntigravityProjectDiscoveryOutcome; }; async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) { @@ -150,6 +160,8 @@ async function postExchangeAntigravity( let projectId = ""; let tierId = "legacy-tier"; + // #11284: classify WHY discovery fails instead of silently swallowing it. + let loadFailed = false; try { const response = await fetchFirstOk( config.loadCodeAssistEndpoints, @@ -160,6 +172,7 @@ async function postExchangeAntigravity( projectId = extractProjectId(data); tierId = extractCodeAssistOnboardTierId(data); } catch (error) { + loadFailed = true; console.log("Failed to load code assist:", error); } @@ -168,21 +181,57 @@ async function postExchangeAntigravity( } else if (config.onboardUserEndpoints.length > 0) { // Accounts without an existing Cloud Code project need one bounded inline // onboarding attempt before loadCodeAssist can discover their project. + let onboardedWithoutProject = false; try { - await fetchFirstOk( + const response = await fetchFirstOk( config.onboardUserEndpoints, { method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) }, POSTEXCHANGE_TIMEOUT_MS ); - const retryResponse = await fetchFirstOk( - config.loadCodeAssistEndpoints, - { method: "POST", headers, body: JSON.stringify({ metadata }) }, - POSTEXCHANGE_TIMEOUT_MS - ); - projectId = extractProjectId((await retryResponse.json()) as Record); - } catch { - // Lazy request-time bootstrap retries if onboarding or discovery is unavailable. + // Google BYOP (#8491): a 200 WITHOUT cloudaicompanionProject in the + // onboardUser body means no project was created and none ever will be — + // standard-tier/personal accounts must bring their own GCP project. + // A body that DOES carry one (string or {id}) is a real onboarding + // success; the retry loadCodeAssist below picks the id up (it can lag). + const bodyText = await response.text().catch(() => ""); + if (bodyText && !bodyText.includes("cloudaicompanionProject")) { + console.log( + "[oauth] antigravity onboardUser succeeded without creating a project — Google BYOP (user-defined GCP project) required" + ); + onboardedWithoutProject = true; + } + if (!onboardedWithoutProject) { + const retryResponse = await fetchFirstOk( + config.loadCodeAssistEndpoints, + { method: "POST", headers, body: JSON.stringify({ metadata }) }, + POSTEXCHANGE_TIMEOUT_MS + ); + projectId = extractProjectId((await retryResponse.json()) as Record); + // Prefer the id straight from the onboarding response when discovery + // lags behind server-side project creation. + if (!projectId) { + projectId = extractProjectId( + (await new Response(bodyText).json().catch(() => ({}))) as Record + ); + } + } + } catch (error) { + console.log("[oauth] antigravity inline onboarding/discovery failed:", error); } + if (!projectId) { + return { + userInfo, + projectId, + tierId, + projectDiscoveryOutcome: onboardedWithoutProject + ? "requires_manual_project" + : "discovery_failed", + }; + } + } else if (loadFailed) { + // No onboarding path configured and discovery hard-failed — do not report + // this account as healthy-with-no-project (#11284). + return { userInfo, projectId, tierId, projectDiscoveryOutcome: "discovery_failed" }; } return { userInfo, projectId, tierId }; } @@ -199,6 +248,9 @@ function mapAntigravityTokens( scope: tokens.scope, email: extra?.userInfo?.email, projectId: extra?.projectId, + // #11284: let the OAuth route reject connects that ended without a Cloud + // Code project instead of persisting a dead "active" row. + projectDiscoveryOutcome: extra?.projectDiscoveryOutcome, providerSpecificData: { clientProfile, projectId: extra?.projectId, diff --git a/tests/unit/antigravity-empty-project-selection.test.ts b/tests/unit/antigravity-empty-project-selection.test.ts new file mode 100644 index 0000000000..21040bc037 --- /dev/null +++ b/tests/unit/antigravity-empty-project-selection.test.ts @@ -0,0 +1,64 @@ +/** + * #11284 — Selection-side safety net for Antigravity accounts with no stored + * Cloud Code projectId. + * + * Production evidence (VPS docker `omniroute`, 2026-08-24): a pool can hold + * healthy accounts WITH projectIds alongside accounts whose projectId is + * empty and which were never confirmed missing (no errorCode) — those + * empty-but-unconfirmed rows still win round-robin slots, burn the request on + * loadCodeAssist discovery + 422, and drag the whole combo circuit down. + * + * Contract pinned here (`antigravityProjectPersist.ts`, quota-strategy copy): + * - connections with an EMPTY stored projectId are skipped whenever at + * least one sibling carries one; + * - when NO connection has a stored project the pool passes through + * unchanged (fresh installs keep their lazy-discovery path — #2334); + * - confirmed-missing rows (errorCode="missing_project_id") stay excluded + * even when they carry a stale stored id (regression guard for the + * persistence-module twin `antigravityProjectPersistence.ts`). + * + * Run: node --import tsx/esm --test tests/unit/antigravity-empty-project-selection.test.ts + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { preferAntigravityConnectionsWithStoredProject } from "../../open-sse/services/antigravityProjectPersist.ts"; + +const withProject = { id: "a", projectId: "proj-1" }; +const withoutProject = { id: "d", projectId: null, providerSpecificData: {} }; +const confirmedMissingWithStaleId = { + id: "f", + errorCode: "missing_project_id", + projectId: "stale-proj", +}; + +test("#11284: skips empty-projectId siblings when a healthier account exists", () => { + const pool = [withoutProject, withProject]; + assert.deepEqual( + preferAntigravityConnectionsWithStoredProject(pool).map((c) => c.id), + ["a"] + ); +}); + +test("#11284: skips confirmed-missing rows even with a stale stored id", () => { + const pool = [confirmedMissingWithStaleId, withProject]; + assert.deepEqual( + preferAntigravityConnectionsWithStoredProject(pool).map((c) => c.id), + ["a"] + ); +}); + +test("#11284: keeps the full pool when ONLY confirmed-missing rows exist (never empty)", () => { + const pool = [confirmedMissingWithStaleId]; + assert.deepEqual(preferAntigravityConnectionsWithStoredProject(pool), pool); +}); + +test("#11284: never empties the pool when every row lacks a projectId", () => { + const pool = [withoutProject, { id: "e", providerSpecificData: {} }]; + assert.deepEqual(preferAntigravityConnectionsWithStoredProject(pool), pool); +}); + +test("#11284: single connection passes through untouched (lazy discovery still applies)", () => { + const pool = [withoutProject]; + assert.deepEqual(preferAntigravityConnectionsWithStoredProject(pool), pool); +}); diff --git a/tests/unit/antigravity-missing-project-autodisable.test.ts b/tests/unit/antigravity-missing-project-autodisable.test.ts new file mode 100644 index 0000000000..aff0172790 --- /dev/null +++ b/tests/unit/antigravity-missing-project-autodisable.test.ts @@ -0,0 +1,90 @@ +/** + * #11284 — Auto-disable Antigravity connections whose Cloud Code project is + * confirmed missing, so credential selection rotates to healthy siblings + * instead of re-dispatching into a guaranteed 422 on every request. + * + * Production evidence (VPS docker `omniroute`, 2026-08-24): five rows carried + * project_id="" with NO missing-project marker — nothing excluded them from + * selection, so each dispatch paid the discovery round-trip and failed. + * + * Contract: `markAntigravityMissingCloudCodeProject()` must persist the + * typed marker (errorCode/lastErrorType) AND `isActive: false` + + * `testStatus: "unavailable"` (recoverable — NOT a terminal status), while + * `persistDiscoveredAntigravityProjectId()` re-enables the row when a project + * is later discovered at request time. + * + * Run: node --import tsx/esm --test tests/unit/antigravity-missing-project-autodisable.test.ts + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ag-11284-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "ag-11284-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { + markAntigravityMissingCloudCodeProject, + persistDiscoveredAntigravityProjectId, +} = await import("../../open-sse/services/antigravityProjectPersistence.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(async () => { + await resetStorage(); +}); + +async function createConnection() { + return providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "autodisable-test", + email: `autodisable-${Date.now()}@example.test`, + accessToken: "token", + refreshToken: "refresh", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + providerSpecificData: { tier: "g1-pro-tier" }, + isActive: true, + testStatus: "active", + }) as Promise<{ id: string; providerSpecificData: Record }>; +} + +test("confirmed-missing project disables the connection for selection", async () => { + const connection = await createConnection(); + + markAntigravityMissingCloudCodeProject(connection.id); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const updated = await providersDb.getProviderConnectionById(connection.id); + assert.equal(updated?.isActive, false, "selection must skip disabled accounts"); + assert.equal(updated?.testStatus, "unavailable"); + assert.equal(updated?.errorCode, "missing_project_id"); + assert.equal(updated?.lastErrorType, "oauth_missing_project_id"); +}); + +test("discovery of a projectId later re-enables the connection", async () => { + const connection = await createConnection(); + + markAntigravityMissingCloudCodeProject(connection.id); + await new Promise((resolve) => setTimeout(resolve, 50)); + persistDiscoveredAntigravityProjectId( + connection.id, + "recovered-project-99", + connection.providerSpecificData as Record + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const healed = await providersDb.getProviderConnectionById(connection.id); + assert.equal(healed?.projectId, "recovered-project-99"); + assert.equal(healed?.isActive, true, "healthy accounts return to rotation"); + assert.equal(healed?.testStatus, "active"); + assert.ok(!healed?.errorCode); +}); diff --git a/tests/unit/antigravity-missing-project-chat.test.ts b/tests/unit/antigravity-missing-project-chat.test.ts index 5238427a65..d049e12393 100644 --- a/tests/unit/antigravity-missing-project-chat.test.ts +++ b/tests/unit/antigravity-missing-project-chat.test.ts @@ -78,7 +78,11 @@ test("Antigravity missing-project 422 stays fail-closed without account cooldown assert.equal(payload.error?.code, "missing_project_id"); assert.equal(payload.error?.type, "oauth_missing_project_id"); assert.equal(bootstrapCalls, 1); - assert.equal(persisted?.testStatus, "active"); + // #11284: a CONFIRMED missing project disables the account (recoverable, + // not terminal) so selection rotates to healthy siblings — and + // persistDiscoveredAntigravityProjectId re-enables it on recovery. + assert.equal(persisted?.isActive, false); + assert.equal(persisted?.testStatus, "unavailable"); assert.equal(persisted?.rateLimitedUntil, undefined); assert.equal(persisted?.errorCode, "missing_project_id"); assert.equal(persisted?.lastErrorType, "oauth_missing_project_id"); diff --git a/tests/unit/antigravity-oauth-empty-project-rejection.test.ts b/tests/unit/antigravity-oauth-empty-project-rejection.test.ts new file mode 100644 index 0000000000..a3549186df --- /dev/null +++ b/tests/unit/antigravity-oauth-empty-project-rejection.test.ts @@ -0,0 +1,197 @@ +/** + * #11284 — Antigravity OAuth must never persist a connection without a Cloud + * Code projectId, and the connect-time post-exchange must detect Google's + * BYOP ("bring your own project") behavior instead of silently swallowing it. + * + * Production evidence (VPS docker `omniroute`, 2026-08-24): five antigravity + * connections were persisted with project_id="" and + * providerSpecificData.projectId="" while tier/subscriptionTier were fully + * populated (g1-pro-tier / "Google AI Pro") — proof the token exchange and + * loadCodeAssist round-trips SUCCEEDED but Google returned no + * cloudaicompanionProject (BYOP accounts, #8491). The old postExchange + * swallowed that outcome and the route marked the rows testStatus="active", + * so the dashboard showed "Connected" while every model call failed. + * + * Contract pinned here: + * 1. postExchange reports WHY no project was found: + * - "requires_manual_project" → onboardUser answered 200 without a + * cloudaicompanionProject in the body (Google BYOP). + * - "discovery_failed" → loadCodeAssist/onboardUser errored or timed out. + * - absent/undefined → projectId discovered normally. + * 2. mapTokens surfaces that outcome as tokenData.projectDiscoveryOutcome so + * the OAuth route can mark the connection degraded (saved, not active) + * instead of silently persisting a false "Connected" row. + * + * Run: node --import tsx/esm --test tests/unit/antigravity-oauth-empty-project-rejection.test.ts + */ +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" }, + }); +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("postExchange reports requires_manual_project when onboardUser answers 200 without a project (Google BYOP)", async () => { + // Fresh account: loadCodeAssist has no project; onboardUser "succeeds" (200) + // but its body carries NO cloudaicompanionProject — Google now expects the + // user to bring their own GCP project (#8491). The retry loadCodeAssist + // still finds nothing. Outcome must be surfaced, not swallowed. + let onboardCalls = 0; + globalThis.fetch = (async (url: unknown) => { + const u = String(url); + if (u.includes("userinfo")) return jsonRes({ email: "byop@example.com" }); + if (u.includes("loadCodeAssist")) { + return jsonRes({ + allowedTiers: [{ id: "g1-pro-tier", isDefault: true }], + }); + } + if (u.includes("onboardUser")) { + onboardCalls++; + // BYOP shape: 200 OK, body without cloudaicompanionProject. + return jsonRes({ done: true }); + } + return jsonRes({}); + }) as typeof fetch; + + const result = await antigravity.postExchange({ access_token: "tok" } as never); + + assert.ok(onboardCalls >= 1, "onboarding attempt must run"); + assert.equal(result.projectId, "", "no project exists for BYOP accounts"); + assert.equal( + result.projectDiscoveryOutcome, + "requires_manual_project", + "BYOP outcome must be reported so the route marks the connection degraded" + ); +}); + +test("postExchange reports discovery_failed when loadCodeAssist errors (was silently swallowed)", async () => { + // Upstream hard-fails: previously this collapsed to console.log + empty + // projectId with zero signal. Now it must be classified discovery_failed. + globalThis.fetch = (async (url: unknown) => { + const u = String(url); + if (u.includes("userinfo")) return jsonRes({ email: "err@example.com" }); + if (u.includes("loadCodeAssist")) return jsonRes({ error: "boom" }, 500); + if (u.includes("onboardUser")) return jsonRes({ error: "boom" }, 500); + return jsonRes({}); + }) as typeof fetch; + + const result = await antigravity.postExchange({ access_token: "tok" } as never); + + assert.equal(result.projectId, ""); + assert.equal( + result.projectDiscoveryOutcome, + "discovery_failed", + "upstream failures must be classified instead of silently dropped" + ); +}); + +test("postExchange omits projectDiscoveryOutcome when a project is discovered (happy path unchanged)", async () => { + globalThis.fetch = (async (url: unknown) => { + const u = String(url); + if (u.includes("userinfo")) return jsonRes({ email: "ok@example.com" }); + if (u.includes("loadCodeAssist")) { + return jsonRes({ + cloudaicompanionProject: "happy-path-project", + allowedTiers: [{ id: "legacy-tier", isDefault: true }], + }); + } + if (u.includes("onboardUser")) return jsonRes({ done: true }); + return jsonRes({}); + }) as typeof fetch; + + const result = await antigravity.postExchange({ access_token: "tok" } as never); + + assert.equal(result.projectId, "happy-path-project"); + assert.equal( + result.projectDiscoveryOutcome, + undefined, + "successful discovery must not carry an outcome flag" + ); +}); + +test("postExchange reports discovery_failed when onboarding succeeds but retry still finds nothing (propagation/transient)", async () => { + // onboardUser returns 200 WITHOUT cloudaicompanionProject in the body but + // the retry loadCodeAssist eventually surfaces it — recovery wins, no + // outcome flag. (The pure-lag case is covered by the onboard-body fallback.) + let lcaCalls = 0; + globalThis.fetch = (async (url: unknown) => { + const u = String(url); + if (u.includes("userinfo")) return jsonRes({ email: "lag@example.com" }); + if (u.includes("loadCodeAssist")) { + lcaCalls++; + return jsonRes({ + allowedTiers: [{ id: "legacy-tier", isDefault: true }], + }); + } + if (u.includes("onboardUser")) { + // Real onboarding success shape: project id present in body. + return jsonRes({ done: true, cloudaicompanionProject: { id: "late-project" } }); + } + return jsonRes({}); + }) as typeof fetch; + + const result = await antigravity.postExchange({ access_token: "tok" } as never); + + assert.equal(result.projectId, "late-project"); + assert.equal( + result.projectDiscoveryOutcome, + undefined, + "recovered projectId means healthy connection" + ); + void lcaCalls; +}); + +test("postExchange still fails when onboarding carries a project but every discovery path stays empty", async () => { + // Degenerate upstream: onboardUser body has a project but retry loadCodeAssist + // errors — must NOT persist as silently-empty; classify discovery_failed. + globalThis.fetch = (async (url: unknown) => { + const u = String(url); + if (u.includes("userinfo")) return jsonRes({ email: "lag2@example.com" }); + if (u.includes("loadCodeAssist")) return jsonRes({ error: "boom" }, 500); + if (u.includes("onboardUser")) { + return new Response(null, { status: 500 }); + } + return jsonRes({}); + }) as typeof fetch; + + const result = await antigravity.postExchange({ access_token: "tok" } as never); + + assert.equal(result.projectId, ""); + assert.equal(result.projectDiscoveryOutcome, "discovery_failed"); +}); + +test("mapTokens surfaces projectDiscoveryOutcome for the OAuth route degrade gate", async () => { + // The route can only act on what mapTokens hands it — the outcome must + // survive into tokenData. + globalThis.fetch = (async (url: unknown) => { + const u = String(url); + if (u.includes("userinfo")) return jsonRes({ email: "map@example.com" }); + if (u.includes("loadCodeAssist")) { + return jsonRes({ allowedTiers: [{ id: "legacy-tier", isDefault: true }] }); + } + if (u.includes("onboardUser")) return jsonRes({ done: true }); + return jsonRes({}); + }) as typeof fetch; + + const tokens = { access_token: "tok" } as never; + const extra = await antigravity.postExchange(tokens); + const mapped = antigravity.mapTokens(tokens, extra); + + assert.equal(mapped.projectId, ""); + assert.equal( + mapped.projectDiscoveryOutcome, + "requires_manual_project", + "degrade gate needs the outcome on the mapped payload" + ); +}); diff --git a/tests/unit/oauth-route-antigravity-project-gate.test.ts b/tests/unit/oauth-route-antigravity-project-gate.test.ts new file mode 100644 index 0000000000..34b2f93fc9 --- /dev/null +++ b/tests/unit/oauth-route-antigravity-project-gate.test.ts @@ -0,0 +1,50 @@ +/** + * #11284 — Antigravity OAuth connect-time DEGRADE marking (maintainer + * direction): when Cloud Code projectId discovery failed, the connection is + * still saved but with testStatus:"degraded" + typed error markers, so the + * dashboard never shows a false "Connected" while request-time bootstrap can + * self-heal the row. + * + * Run: node --import tsx/esm --test tests/unit/oauth-route-antigravity-project-gate.test.ts + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const routeSource = fs.readFileSync( + path.join(here, "../../src/app/api/oauth/[provider]/[action]/route.ts"), + "utf8" +); +const persistenceSource = fs.readFileSync( + path.join(here, "../../src/lib/oauth/connectionPersistence.ts"), + "utf8" +); + +test("degrade gate is wired into both exchange and poll-callback branches", () => { + const callSites = + routeSource.match(/antigravityDegradedProjectState\(provider, tokenData\)/g) || []; + assert.equal(callSites.length, 2, "gate must run in exchange AND poll-callback"); +}); + +test("connects are SAVED with degraded status, not rejected", () => { + // No 422 rejection in the antigravity project path: the upsert proceeds and + // the degraded fields flow into both the update and create payloads. + assert.match(routeSource, /testStatus: degradedProject\?\.testStatus \?\? "active"/); + assert.match(persistenceSource, /degradedProject\?\.testStatus \?\? \("active" as const\)/); + assert.match(routeSource, /warning: degradedProject\.warning/); +}); + +test("gate only applies to antigravity and agy, marks typed error fields", () => { + const gateSource = fs.readFileSync( + path.join(here, "../../src/lib/oauth/antigravityProjectGate.ts"), + "utf8" + ); + assert.match(gateSource, /"antigravity"/); + assert.match(gateSource, /"agy"/); + assert.match(gateSource, /testStatus: "degraded"/); + assert.match(gateSource, /errorCode: "missing_project_id"/); + assert.match(gateSource, /lastErrorType: "oauth_missing_project_id"/); +});