fix(antigravity): heal empty-projectId accounts via retryable auto-onboarding (#10424)

* fix(antigravity): heal empty-projectId accounts via retryable auto-onboarding

Accounts with an empty Cloud Code projectId get a permanent 422 "Missing
Google projectId" when loadCodeAssist returns no project. The 3.8.50
bootstrap attempts to CREATE the project via onboardUser, but a single failed
attempt (transient network/upstream error) was memoized forever in
onboardAttemptedCache: every later request in the process skipped onboarding
and 422'd, even though a retry would succeed.

Replace the permanent per-token Set with a failure-backoff map: failed onboard
attempts are retried after a 5-minute backoff (bounded, self-healing), the
in-flight lock still dedupes concurrent calls, and success clears the failure
marker and memoizes the project as before. Accounts that CAN be onboarded now
heal automatically on a later request or token refresh — no user action.

Tests: the existing "does not retry" case is now framed as the backoff window;
a new case proves the account heals (retries onboarding and recovers the
project) once the backoff expires.

* chore(changelog): fragment for #10424 antigravity project autocreate

* feat(antigravity): BYOP fast-fail + manual GCP project-id override

Port decolua/9router#2934 + VansRouter 802a859:
- tryOnboardUser now returns a three-way status; a 200 onboardUser response
  WITHOUT cloudaicompanionProject means Google deprecated automatic project
  creation for standard-tier (personal) accounts (BYOP). Such accounts are
  cached permanently (no pointless ~18s re-onboard) and the executor fails
  fast with 403 GCP_PROJECT_REQUIRED + actionable 'enter your project id'
  message instead of the generic 422 or a delayed 429.
- Transient onboard failures keep the existing 5-min backoff heal.
- Manual project-id override: the EditConnectionModal now stamps
  providerSpecificData.isProjectIdManual when the operator enters a project
  id, and tokenRefresh skips auto-discovery for flagged accounts so the
  manual value is never overwritten.

* chore(changelog): cover BYOP fast-fail + manual override in #10424 fragment

* test(antigravity): expect fast 403 GCP_PROJECT_REQUIRED when loadCodeAssist finds no project (#10424)

Google now marks accounts without an onboarded project as BYOP (automatic
project creation deprecated for standard-tier accounts, #2934). The PR's
BYOP fast-fail path returns 403 gcp_project_required instead of the old
generic 422 missing_project_id; align the #2334 executor test with that
contract so CI unit-test shard 2/4 passes.

* fix(antigravity): persist isProjectIdManual, fix BYOP citation, dodge refresh-retry

Review follow-up on #10424:

1. EditConnectionModal: isProjectIdManual was set on
   updates.providerSpecificData right after the project-id field, then the
   OAuth path (Antigravity is always OAuth) rebuilt providerSpecificData from
   connection.providerSpecificData before the request went out, discarding the
   flag — tokenRefresh.ts was guarding a field never actually persisted. The
   flag now lands in the single surviving antigravity merge, with a jsdom
   regression test (modeled on edit-connection-modal-openai-store-toggle).

2. The '#2934' citation for the Google BYOP claim pointed at an unrelated
   closed issue. Swapped for the real tracking issue #8491 (empty Google
   projectId -> 422 class) across bootstrap/executor/test comments.

3. BYOP fast-fail now returns 422 instead of 403: chatCore's generic
   401/403 -> refresh-and-retry path was hitting Google's OAuth token
   endpoint on every request from an affected account (pointless — refreshing
   cannot create a GCP project), and 422 matches the sibling
   missing_project_id error the client already maps to an action-needed
   prompt.

Also: eslint-disable-next-line for the pre-existing
react-hooks/set-state-in-effect baseline noise in the modal (repo
convention, same pattern as 11 other dashboard files).

* chore(ci): drop unused eslint-disable in EditConnectionModal form hydration

The react-hooks/set-state-in-effect disable added in the previous commit is
unused under the repo's pinned eslint-plugin-react-hooks (7.0.1) — the rule
does not fire on this line at that version, so the unused directive tripped
the whole-repo 'No new ESLint warnings' gate (max-warnings 0). Verified with
the lockfile-pinned plugin: lint:json is clean (0 errors, 0 warnings).

* fix(build): bound and retry the opencode-plugin npm install in prepublish

The plugin's node_modules is gitignored, so every fresh CI checkout runs a
full npm install inside @omniroute/opencode-plugin during build:cli. npm's
unbounded fetch retries turn a stalled registry CDN connection (the recurring
onnxruntime-class ETIMEDOUT flake) into a 20-30 minute hang — the DAST
'Build CLI bundle' step has been cancelled at the 30m cap repeatedly.

- Bound npm fetch: --fetch-timeout 60s, 2 retries with capped backoff — a
  stalled connection now fails fast instead of hanging the job.
- Retry the install up to 3 times with a 10s pause between attempts, so
  transient CDN failures recover in-build.

Net effect: the step either completes (network OK) or fails quickly with a
clear error (network down) — it can no longer eat the whole job budget.

* ci(quality): use the npm-ci-retry action on every install step

Fast Quality Gates failed on the recurring onnxruntime-node postinstall
ETIMEDOUT (Microsoft CDN 150.171.x.x) - the same transient flake that has
hit Vitest and dast-smoke today. Only the Build job used the retry action;
the other five jobs (Docs, Fast Quality Gates, Vitest, Unit Tests,
changelog) still ran a bare install and die on any CDN hiccup. Use the
existing retry action (3 attempts, exponential backoff) on every install
step for consistency.

* Merge branch 'release/v3.8.50' into fix/antigravity-project-autocreate

* test(fix): refresh expired alibaba quota sample validity and onnxruntime pin for v3.8.50 base

- alibaba-free-tier-quota-fetcher.test.ts: sample quotaValidityPeriod
  (2026-08-16 16:00 UTC) is in the past, making every quota entry classify
  as expired/not_capable; bump to 2028-01-01 UTC so the text/merge
  classification tests exercise the intended path again.
- optional-transformers-dependency.test.ts: onnxruntime-node pin assertion
  updated from ~1.24.3 to ~1.27.0 to match package.json (bumped by #10403);
  the regular-not-optional intent is unchanged.

* test(fix): widen modelsDevSync lastSync wait from 200ms default to 2000ms

The truthy-spellings loop asserted each enabled case completes its first
fetch within waitFor's 200ms default timeout, which trips under CI runner
load (observed on PR 10424 shard 2/4). Match the file's other lastSync
waits (2000ms) so the sync-completion assertion is load-tolerant.

---------

Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
This commit is contained in:
Rouzbeh†
2026-08-17 14:53:41 +03:30
committed by GitHub
parent 24ef1dc3d4
commit 6ff2e7b2c2
12 changed files with 489 additions and 48 deletions

View File

@@ -41,7 +41,7 @@ const SAMPLE_CONSOLE_RESPONSE = {
freeTierQuotas: [
{
quotaInitTotal: 1000000,
quotaValidityPeriod: 1786896000000,
quotaValidityPeriod: 1830297600000,
freeTierOnly: true,
quotaTotalPercentage: 99.98,
model: "qwen3.6-plus",

View File

@@ -21,8 +21,10 @@ import assert from "node:assert/strict";
import {
ensureAntigravityProjectAssigned,
clearAntigravityProjectCache,
clearAntigravityOnboardBackoff,
getAntigravityProjectFromCache,
getAntigravityLoadCodeAssistUrls,
ANTIGRAVITY_REQUIRES_MANUAL_PROJECT,
} from "../../open-sse/services/antigravityProjectBootstrap.ts";
// Reset the module-level memoization cache between tests.
@@ -261,10 +263,15 @@ describe("onboardUser fallback", () => {
}
if (url.endsWith(":onboardUser")) {
onboardCalls++;
return new Response(JSON.stringify({ done: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
// Google's LRO returns the created project inside the response — a body
// WITHOUT cloudaicompanionProject means BYOP (manual project required).
return new Response(
JSON.stringify({ done: true, cloudaicompanionProject: "proj-onboarded" }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
return new Response("Not Found", { status: 404 });
};
@@ -294,7 +301,7 @@ describe("onboardUser fallback", () => {
assert.equal(projectId, undefined, "must return undefined when both fail");
});
test("does not retry onboardUser for the same token", async () => {
test("does not re-attempt onboardUser within the failure backoff window", async () => {
let onboardCalls = 0;
const mockFetch = async (url: string, _init?: RequestInit): Promise<Response> => {
@@ -306,8 +313,10 @@ describe("onboardUser fallback", () => {
}
if (url.endsWith(":onboardUser")) {
onboardCalls++;
return new Response(JSON.stringify({ done: true }), {
status: 200,
// Transient upstream failure (500) — NOT the BYOP signal, so the
// failure-backoff semantics are what is under test here.
return new Response("Upstream error", {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
@@ -317,7 +326,94 @@ describe("onboardUser fallback", () => {
await ensureAntigravityProjectAssigned("dedup-token", mockFetch);
await ensureAntigravityProjectAssigned("dedup-token", mockFetch);
assert.equal(onboardCalls, 1, "onboardUser must be called only once per token");
assert.equal(onboardCalls, 1, "onboardUser must be attempted once within the backoff window");
});
test("retries onboardUser after the failure backoff expires (account heals itself)", async () => {
let onboardCalls = 0;
const mockFetch = async (url: string, _init?: RequestInit): Promise<Response> => {
if (url.endsWith(":loadCodeAssist")) {
// Only the retry AFTER the second (healed) onboard attempt yields a project.
if (onboardCalls >= 2) {
return new Response(JSON.stringify({ cloudaicompanionProject: "proj-healed" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url.endsWith(":onboardUser")) {
onboardCalls++;
if (onboardCalls === 1) {
// First attempt: transient upstream failure -> failure backoff.
return new Response("Upstream error", {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
// Second (healed) attempt: Google returns the created project.
return new Response(
JSON.stringify({ done: true, cloudaicompanionProject: "proj-healed-onboard" }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
return new Response("Not Found", { status: 404 });
};
// First attempt: onboard fails transiently -> failure recorded.
const first = await ensureAntigravityProjectAssigned("heal-token", mockFetch);
assert.equal(first, undefined);
assert.equal(onboardCalls, 1);
// Immediately after: backoff blocks a re-attempt.
const second = await ensureAntigravityProjectAssigned("heal-token", mockFetch);
assert.equal(second, undefined);
assert.equal(onboardCalls, 1, "no re-attempt inside the backoff window");
// Simulate the backoff expiring: the next request heals the account.
clearAntigravityOnboardBackoff();
const healed = await ensureAntigravityProjectAssigned("heal-token", mockFetch);
assert.equal(healed, "proj-healed");
assert.equal(onboardCalls, 2, "onboardUser must be retried after backoff expiry");
});
test("returns the BYOP sentinel when onboardUser completes without a project (Google #8491)", async () => {
let onboardCalls = 0;
const mockFetch = async (url: string, _init?: RequestInit): Promise<Response> => {
if (url.endsWith(":loadCodeAssist")) {
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url.endsWith(":onboardUser")) {
onboardCalls++;
// 200 done WITHOUT cloudaicompanionProject = BYOP: Google deprecated
// automatic project creation for standard-tier personal accounts.
return new Response(JSON.stringify({ done: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("Not Found", { status: 404 });
};
const first = await ensureAntigravityProjectAssigned("byop-token", mockFetch);
assert.equal(first, ANTIGRAVITY_REQUIRES_MANUAL_PROJECT);
// The account is cached as BYOP — a second call must NOT re-run the
// pointless ~18s onboard round-trip (no extra fetch, same sentinel).
const second = await ensureAntigravityProjectAssigned("byop-token", mockFetch);
assert.equal(second, ANTIGRAVITY_REQUIRES_MANUAL_PROJECT);
assert.equal(onboardCalls, 1, "onboardUser must not be re-attempted for a cached BYOP account");
});
test("skips onboardUser when loadCodeAssist succeeds on first try", async () => {

View File

@@ -84,3 +84,71 @@ test("Antigravity missing-project 422 stays fail-closed without account cooldown
assert.equal(persisted?.lastErrorType, "oauth_missing_project_id");
assert.match(String(persisted?.lastError), /Missing Google projectId/);
});
test("Antigravity BYOP account (onboardUser done, no project) returns fast 422 GCP_PROJECT_REQUIRED", async () => {
const connection = await providersDb.createProviderConnection({
provider: "antigravity",
authType: "oauth",
name: "antigravity-byop",
email: "antigravity-byop@example.test",
accessToken: "fake-antigravity-byop-token",
refreshToken: "fake-antigravity-byop-refresh",
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
providerSpecificData: {},
isActive: true,
testStatus: "active",
});
assert(connection && typeof connection.id === "string");
let onboardCalls = 0;
globalThis.fetch = async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init);
if (request.url.startsWith("https://oauth2.googleapis.com/token")) {
// Token refresh during the attempt — answer it so the test focuses on BYOP.
return new Response(
JSON.stringify({ access_token: "fake-antigravity-byop-token", expires_in: 3600 }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
if (request.url.endsWith(":loadCodeAssist")) {
// Empty loadCodeAssist — account never onboarded.
return new Response("{}", {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (request.url.endsWith(":onboardUser")) {
onboardCalls += 1;
// 200 done WITHOUT cloudaicompanionProject — Google BYOP (#8491):
// no automatic project creation for standard-tier accounts.
return new Response(JSON.stringify({ done: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
throw new Error(`Unexpected external fetch: ${request.url}`);
};
const response = await handleChat(
buildRequest({
body: {
model: "antigravity/gemini-2.5-flash",
stream: false,
messages: [{ role: "user", content: "BYOP account must fail fast with a clear 422" }],
},
})
);
const payload = (await response.json()) as {
error?: { code?: string; type?: string; message?: string };
};
assert.equal(response.status, 422);
// 422 is outside chatCore's 401/403 refresh-retry set, so the executor's
// error body passes through untouched — the actionable message must survive.
assert.match(String(payload.error?.message), /GCP_PROJECT_REQUIRED/);
assert.match(String(payload.error?.message), /console\.cloud\.google\.com/);
assert.equal(onboardCalls, 1, "onboardUser must be attempted exactly once (BYOP is cached)");
});

View File

@@ -15,7 +15,7 @@ test("@huggingface/transformers is a regular dependency so npm ci never skips it
// pin dragged onnxruntime-node@1.21.0 whose NAN build no longer compiles), which
// broke `npm ci`/`next build` with "Can't resolve @huggingface/transformers"
// (lazy import in src/lib/memory/embedding/transformersLocal.ts). As a regular
// dep with onnxruntime-node@~1.24.3 (napi prebuilds, no node-gyp) it stays
// dep with onnxruntime-node@~1.27.0 (napi prebuilds, no node-gyp) it stays
// installable and the memory embedding path requires() cleanly.
const pkg = readJson<{
dependencies?: Record<string, string>;
@@ -38,7 +38,7 @@ test("transformers + onnxruntime-node are regular dependencies (not optional)",
assert.equal(
pkg.dependencies?.["onnxruntime-node"],
"~1.24.3",
"~1.27.0",
"onnxruntime-node is a regular dep (napi prebuilds, installable on Node 24/26)"
);
assert.equal(pkg.optionalDependencies?.["onnxruntime-node"], undefined);

View File

@@ -0,0 +1,132 @@
// @vitest-environment jsdom
//
// Regression guard for the review on #10424: EditConnectionModal set
// providerSpecificData.isProjectIdManual right after the project-id field,
// but the OAuth connection path (Antigravity is always OAuth) rebuilt
// providerSpecificData from connection.providerSpecificData before the save
// request went out, discarding the flag. tokenRefresh.ts guards auto-discovery
// with `!credentials.providerSpecificData?.isProjectIdManual`, so without this
// fix a manually-entered GCP Project ID was silently overwritten on the next
// token refresh.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/store/notificationStore", () => ({
useNotificationStore: () => ({ notify: vi.fn() }),
}));
vi.mock("@/store/emailPrivacyStore", () => ({
default: () => ({ hidden: false, toggle: vi.fn() }),
}));
const { default: EditConnectionModal } =
await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx");
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
function renderModal(connection: Record<string, unknown>, onSave = vi.fn()) {
act(() => {
root.render(
<EditConnectionModal
isOpen={true}
connection={connection}
providerId={connection.provider as string}
onSave={onSave}
onClose={vi.fn()}
/>
);
});
}
function findProjectIdInput(): HTMLInputElement | null {
return container.querySelector('input[placeholder="antigravityProjectIdPlaceholder"]');
}
function clickSave() {
const button = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent === "save"
);
expect(button).toBeTruthy();
button!.click();
}
describe("EditConnectionModal — antigravity isProjectIdManual persistence (#10424 review)", () => {
it("persists isProjectIdManual=true on save when a GCP Project ID is entered manually", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
renderModal(
{
id: "conn-ag-1",
provider: "antigravity",
authType: "oauth",
name: "Antigravity account",
providerSpecificData: {},
},
onSave
);
const input = findProjectIdInput();
expect(input).not.toBeNull();
// React controlled input: use the native setter so the value change is
// seen by the onChange handler, then dispatch an input event.
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)!.set!;
await act(async () => {
setter.call(input, "gcp-proj-10424");
input!.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
clickSave();
});
expect(onSave).toHaveBeenCalledTimes(1);
const updates = onSave.mock.calls[0][0] as {
providerSpecificData: Record<string, unknown>;
};
expect(updates.providerSpecificData?.isProjectIdManual).toBe(true);
});
it("persists isProjectIdManual=false when the project id field is left empty", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
renderModal(
{
id: "conn-ag-2",
provider: "antigravity",
authType: "oauth",
name: "Antigravity account 2",
providerSpecificData: {},
},
onSave
);
await act(async () => {
clickSave();
});
expect(onSave).toHaveBeenCalledTimes(1);
const updates = onSave.mock.calls[0][0] as {
providerSpecificData: Record<string, unknown>;
};
expect(updates.providerSpecificData?.isProjectIdManual).toBe(false);
});
});

View File

@@ -281,9 +281,11 @@ test("AntigravityExecutor.transformRequest auto-discovers a missing projectId vi
}
});
// #2334: when loadCodeAssist also finds no project (truly un-onboarded account), the
// structured 422 must still be returned so the dashboard can prompt a reconnect.
test("AntigravityExecutor.transformRequest still 422s when loadCodeAssist finds no project (#2334)", async () => {
// #8491: when loadCodeAssist also finds no project and Google marks the
// account BYOP (no automatic project creation for standard-tier accounts),
// the fast 422 GCP_PROJECT_REQUIRED must be returned so the dashboard can
// prompt the user to enter a GCP Project ID.
test("AntigravityExecutor.transformRequest fast-422s with GCP_PROJECT_REQUIRED when loadCodeAssist finds no project (#8491)", async () => {
clearAntigravityProjectCache();
seedAntigravityIdeVersionCache("2.1.1");
const executor = new AntigravityExecutor();
@@ -305,7 +307,8 @@ test("AntigravityExecutor.transformRequest still 422s when loadCodeAssist finds
if (!(result instanceof Response)) throw new Error("Expected a 422 Response");
assert.equal(result.status, 422);
const payload = (await result.json()) as ErrorPayload;
assert.equal(payload.error.code, "missing_project_id");
assert.equal(payload.error.code, "gcp_project_required");
assert.match(payload.error.message, /GCP_PROJECT_REQUIRED/);
} finally {
globalThis.fetch = originalFetch;
clearAntigravityProjectCache();

View File

@@ -671,7 +671,7 @@ test("the usual truthy spellings all start the sync, and nothing else does", asy
// then never fetched anything; pin the fetch actually having run
// for each truthy spelling, not just the first one.
assert.ok(
await waitFor(() => modelsDev.getSyncStatus().lastSync !== null),
await waitFor(() => modelsDev.getSyncStatus().lastSync !== null, 2000),
`MODELS_DEV_SYNC_ENABLED=${JSON.stringify(value)} should have completed a sync`
);
}