mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 00:52:18 +03:00
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:
@@ -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))
|
||||
@@ -52,8 +52,23 @@ export function preferAntigravityConnectionsWithStoredProject<T extends Record<s
|
||||
const projectId = (psd as Record<string, unknown>).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(
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
61
src/lib/oauth/antigravityProjectGate.ts
Normal file
61
src/lib/oauth/antigravityProjectGate.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -96,7 +96,13 @@ export function findExistingOAuthConnectionMatch(
|
||||
export function buildOAuthConnectionCreatePayload(
|
||||
provider: string,
|
||||
tokenData: Record<string, any>,
|
||||
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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>);
|
||||
} 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<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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
64
tests/unit/antigravity-empty-project-selection.test.ts
Normal file
64
tests/unit/antigravity-empty-project-selection.test.ts
Normal 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);
|
||||
});
|
||||
90
tests/unit/antigravity-missing-project-autodisable.test.ts
Normal file
90
tests/unit/antigravity-missing-project-autodisable.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
197
tests/unit/antigravity-oauth-empty-project-rejection.test.ts
Normal file
197
tests/unit/antigravity-oauth-empty-project-rejection.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
50
tests/unit/oauth-route-antigravity-project-gate.test.ts
Normal file
50
tests/unit/oauth-route-antigravity-project-gate.test.ts
Normal 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"/);
|
||||
});
|
||||
Reference in New Issue
Block a user