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.
This commit is contained in:
Rouzbeh†
2026-08-24 18:42:33 +03:30
committed by GitHub
parent 315b0a94e1
commit 6de542b9b6
12 changed files with 595 additions and 18 deletions

View File

@@ -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))

View File

@@ -52,8 +52,23 @@ export function preferAntigravityConnectionsWithStoredProject<T extends Record<s
const projectId = (psd as Record<string, unknown>).projectId; const projectId = (psd as Record<string, unknown>).projectId;
return typeof projectId === "string" && projectId.trim().length > 0; return typeof projectId === "string" && projectId.trim().length > 0;
}; };
const withStoredProject = connections.filter(hasStoredProject); // #11284: rows whose missing Cloud Code project was CONFIRMED at request
return withStoredProject.length > 0 ? withStoredProject : connections; // 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( export async function persistDiscoveredAntigravityProjectId(

View File

@@ -64,6 +64,11 @@ export function persistDiscoveredAntigravityProjectId(
errorCode: null, errorCode: null,
lastError: null, lastError: null,
lastErrorType: 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, providerSpecificData,
}) })
.catch(() => {}) .catch(() => {})
@@ -77,7 +82,14 @@ export function markAntigravityMissingCloudCodeProject(
): void { ): void {
if (!connectionId) return; 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, { void updateProviderConnection(connectionId, {
isActive: false,
testStatus: "unavailable",
errorCode: "missing_project_id", errorCode: "missing_project_id",
lastError: lastError:
"Missing Google projectId for Antigravity account. Reconnect OAuth after completing Gemini Code Assist onboarding.", "Missing Google projectId for Antigravity account. Reconnect OAuth after completing Gemini Code Assist onboarding.",

View File

@@ -25,6 +25,7 @@ import {
import { getConsistentMachineId } from "@/shared/utils/machineId"; import { getConsistentMachineId } from "@/shared/utils/machineId";
import { isValidGheUrl } from "@/shared/validation/providerSpecificData"; import { isValidGheUrl } from "@/shared/validation/providerSpecificData";
import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth"; import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth";
import { antigravityDegradedProjectState } from "@/lib/oauth/antigravityProjectGate";
import { syncToCloud } from "@/lib/cloudSync"; import { syncToCloud } from "@/lib/cloudSync";
import { startLocalServer } from "@/lib/oauth/utils/server"; import { startLocalServer } from "@/lib/oauth/utils/server";
import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
@@ -520,6 +521,12 @@ export async function POST(
exchangeTokens(provider, code, redirectUri, codeVerifier, normalizedState) 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 // 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" // always show a real label (e.g. user@gmail.com) instead of "Account #abc123"
if (!tokenData.name && (tokenData.email || tokenData.displayName)) { if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
@@ -542,14 +549,15 @@ export async function POST(
connection = await updateProviderConnection(matchId, { connection = await updateProviderConnection(matchId, {
...tokenData, ...tokenData,
expiresAt, expiresAt,
testStatus: "active", testStatus: degradedProject?.testStatus ?? "active",
...(degradedProject ?? {}),
isActive: true, isActive: true,
}); });
} }
} }
if (!connection) { if (!connection) {
connection = await createProviderConnection( connection = await createProviderConnection(
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt) buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt, degradedProject)
); );
} }
@@ -558,6 +566,7 @@ export async function POST(
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
...(degradedProject ? { warning: degradedProject.warning } : {}),
connection: { connection: {
id: connection.id, id: connection.id,
provider: connection.provider, provider: connection.provider,
@@ -739,6 +748,10 @@ export async function POST(
exchangeTokens(provider, params.code, redirectUri, codeVerifier, params.state) 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 // Normalize: if name is missing, use email as fallback display label
if (!tokenData.name && (tokenData.email || tokenData.displayName)) { if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
tokenData.name = tokenData.email || tokenData.displayName; tokenData.name = tokenData.email || tokenData.displayName;
@@ -765,14 +778,15 @@ export async function POST(
connection = await updateProviderConnection(matchId, { connection = await updateProviderConnection(matchId, {
...tokenData, ...tokenData,
expiresAt, expiresAt,
testStatus: "active", testStatus: degradedProject?.testStatus ?? "active",
...(degradedProject ?? {}),
isActive: true, isActive: true,
}); });
} }
} }
if (!connection) { if (!connection) {
connection = await createProviderConnection( connection = await createProviderConnection(
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt) buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt, degradedProject)
); );
} }
@@ -780,6 +794,7 @@ export async function POST(
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
...(degradedProject ? { warning: degradedProject.warning } : {}),
connection: { connection: {
id: connection.id, id: connection.id,
provider: connection.provider, provider: connection.provider,

View File

@@ -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<string, unknown> | 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,
};
}

View File

@@ -96,7 +96,13 @@ export function findExistingOAuthConnectionMatch(
export function buildOAuthConnectionCreatePayload( export function buildOAuthConnectionCreatePayload(
provider: string, provider: string,
tokenData: Record<string, any>, tokenData: Record<string, any>,
expiresAt: string | null expiresAt: string | null,
degradedProject?: {
testStatus: "degraded";
errorCode: string;
lastErrorType: string;
lastError: string;
} | null
) { ) {
return { return {
provider, provider,
@@ -104,7 +110,17 @@ export function buildOAuthConnectionCreatePayload(
...tokenData, ...tokenData,
expiresAt, expiresAt,
tokenExpiresAt: 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,
}
: {}),
}; };
} }

View File

@@ -17,10 +17,20 @@ type AntigravityTokenPayload = {
refresh_token?: string; refresh_token?: string;
scope?: 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 = { type AntigravityPostExchange = {
projectId: string; projectId: string;
tierId: string; tierId: string;
userInfo: { email?: string }; userInfo: { email?: string };
projectDiscoveryOutcome?: AntigravityProjectDiscoveryOutcome;
}; };
async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) { async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) {
@@ -150,6 +160,8 @@ async function postExchangeAntigravity(
let projectId = ""; let projectId = "";
let tierId = "legacy-tier"; let tierId = "legacy-tier";
// #11284: classify WHY discovery fails instead of silently swallowing it.
let loadFailed = false;
try { try {
const response = await fetchFirstOk( const response = await fetchFirstOk(
config.loadCodeAssistEndpoints, config.loadCodeAssistEndpoints,
@@ -160,6 +172,7 @@ async function postExchangeAntigravity(
projectId = extractProjectId(data); projectId = extractProjectId(data);
tierId = extractCodeAssistOnboardTierId(data); tierId = extractCodeAssistOnboardTierId(data);
} catch (error) { } catch (error) {
loadFailed = true;
console.log("Failed to load code assist:", error); console.log("Failed to load code assist:", error);
} }
@@ -168,21 +181,57 @@ async function postExchangeAntigravity(
} else if (config.onboardUserEndpoints.length > 0) { } else if (config.onboardUserEndpoints.length > 0) {
// Accounts without an existing Cloud Code project need one bounded inline // Accounts without an existing Cloud Code project need one bounded inline
// onboarding attempt before loadCodeAssist can discover their project. // onboarding attempt before loadCodeAssist can discover their project.
let onboardedWithoutProject = false;
try { try {
await fetchFirstOk( const response = await fetchFirstOk(
config.onboardUserEndpoints, config.onboardUserEndpoints,
{ method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) }, { method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) },
POSTEXCHANGE_TIMEOUT_MS POSTEXCHANGE_TIMEOUT_MS
); );
const retryResponse = await fetchFirstOk( // Google BYOP (#8491): a 200 WITHOUT cloudaicompanionProject in the
config.loadCodeAssistEndpoints, // onboardUser body means no project was created and none ever will be —
{ method: "POST", headers, body: JSON.stringify({ metadata }) }, // standard-tier/personal accounts must bring their own GCP project.
POSTEXCHANGE_TIMEOUT_MS // 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).
projectId = extractProjectId((await retryResponse.json()) as Record<string, unknown>); const bodyText = await response.text().catch(() => "");
} catch { if (bodyText && !bodyText.includes("cloudaicompanionProject")) {
// Lazy request-time bootstrap retries if onboarding or discovery is unavailable. 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<string, unknown>);
// 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<string, unknown>
);
}
}
} 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 }; return { userInfo, projectId, tierId };
} }
@@ -199,6 +248,9 @@ function mapAntigravityTokens(
scope: tokens.scope, scope: tokens.scope,
email: extra?.userInfo?.email, email: extra?.userInfo?.email,
projectId: extra?.projectId, 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: { providerSpecificData: {
clientProfile, clientProfile,
projectId: extra?.projectId, projectId: extra?.projectId,

View File

@@ -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);
});

View File

@@ -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<string, unknown> }>;
}
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<string, unknown>
);
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);
});

View File

@@ -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?.code, "missing_project_id");
assert.equal(payload.error?.type, "oauth_missing_project_id"); assert.equal(payload.error?.type, "oauth_missing_project_id");
assert.equal(bootstrapCalls, 1); 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?.rateLimitedUntil, undefined);
assert.equal(persisted?.errorCode, "missing_project_id"); assert.equal(persisted?.errorCode, "missing_project_id");
assert.equal(persisted?.lastErrorType, "oauth_missing_project_id"); assert.equal(persisted?.lastErrorType, "oauth_missing_project_id");

View File

@@ -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"
);
});

View File

@@ -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"/);
});