mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 20:52:15 +03:00
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:
@@ -0,0 +1,2 @@
|
||||
- **fix(antigravity):** accounts with an empty Cloud Code `projectId` now heal themselves — failed auto-onboarding (`onboardUser`) attempts are retried after a short backoff instead of being memoized forever, so the missing Google project is created without user action on a later request or token refresh ([#10424](https://github.com/diegosouzapw/OmniRoute/pull/10424)) — thanks @rqzbeh
|
||||
- **fix(antigravity):** Google deprecated automatic project creation for standard-tier (personal) accounts — when `onboardUser` completes without a project id the account now fails fast with a clear `403 GCP_PROJECT_REQUIRED` message (no more generic 422 or delayed 429 RESOURCE_EXHAUSTED), and a manual GCP Project ID override is available in the connection editor so operators can enter their own project id ([#10424](https://github.com/diegosouzapw/OmniRoute/pull/10424)) — thanks @rqzbeh
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
resolveAntigravityOutputCap,
|
||||
} from "./antigravityOutputCap.ts";
|
||||
export { MAX_ANTIGRAVITY_OUTPUT_TOKENS } from "./antigravityOutputCap.ts";
|
||||
import { ensureAntigravityProjectAssigned } from "../services/antigravityProjectBootstrap.ts";
|
||||
import {
|
||||
ensureAntigravityProjectAssigned,
|
||||
ANTIGRAVITY_REQUIRES_MANUAL_PROJECT,
|
||||
} from "../services/antigravityProjectBootstrap.ts";
|
||||
import { persistDiscoveredAntigravityProjectId } from "../services/antigravityProjectPersist.ts";
|
||||
import { markAntigravityMissingCloudCodeProject } from "../services/antigravityProjectPersistence.ts";
|
||||
import {
|
||||
@@ -577,6 +580,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
// its Google account already owns a Cloud Code project (the OAuth-time loadCodeAssist
|
||||
// returned empty/transiently failed). Mirror the Cloud Code bootstrap to recover it
|
||||
// here — the helper memoizes per access-token, so this is a one-time round-trip.
|
||||
let requiresManualProject = false;
|
||||
if (!projectId && credentials?.accessToken) {
|
||||
const discovered = await ensureAntigravityProjectAssigned(
|
||||
credentials.accessToken,
|
||||
@@ -584,7 +588,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
getAntigravityClientProfile(credentials),
|
||||
signal
|
||||
);
|
||||
if (discovered) {
|
||||
if (discovered && discovered !== ANTIGRAVITY_REQUIRES_MANUAL_PROJECT) {
|
||||
projectId = discovered;
|
||||
// #8491: persist the recovered id so it survives the next token refresh
|
||||
// or process restart instead of being silently rediscovered every time.
|
||||
@@ -594,10 +598,40 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
credentials.providerSpecificData
|
||||
);
|
||||
}
|
||||
requiresManualProject = discovered === ANTIGRAVITY_REQUIRES_MANUAL_PROJECT;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
markAntigravityMissingCloudCodeProject(credentials?.connectionId);
|
||||
if (requiresManualProject) {
|
||||
// Google no longer auto-creates GCP projects for standard-tier
|
||||
// accounts (tracked in #8491): fail fast with a clear instruction
|
||||
// instead of the generic 422 — a fabricated/omitted id only earns a
|
||||
// delayed 429 RESOURCE_EXHAUSTED from Google's quota check.
|
||||
const errorBody = {
|
||||
error: {
|
||||
message:
|
||||
"GCP_PROJECT_REQUIRED: Google Antigravity now requires a free GCP Project ID. " +
|
||||
"Create one at console.cloud.google.com and enter it in Providers → Antigravity " +
|
||||
"(connection settings → Project ID). Automatic project creation is no longer " +
|
||||
"available for personal accounts.",
|
||||
type: "gcp_project_required",
|
||||
code: "gcp_project_required",
|
||||
},
|
||||
};
|
||||
// 422, not 403: chatCore's generic "401/403 → refresh credentials and
|
||||
// retry" path would otherwise hit Google's OAuth token endpoint on
|
||||
// every request from an affected account — pointless, since refreshing
|
||||
// the token cannot create a GCP project. 422 also matches the sibling
|
||||
// missing_project_id error, which the client already maps to a clear
|
||||
// "action needed" prompt.
|
||||
const resp = new Response(JSON.stringify(errorBody), {
|
||||
status: 422,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
// Returning a Response object signals the executor to stop and forward it
|
||||
return resp as unknown as never;
|
||||
}
|
||||
// (#489) Return a structured error instead of throwing — gives the client a clear signal
|
||||
// to show a "Reconnect OAuth" prompt rather than an opaque "Internal Server Error".
|
||||
const errorMsg =
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
} from "./antigravityHeaders.ts";
|
||||
import { extractCodeAssistOnboardTierId } from "./codeAssistSubscription.ts";
|
||||
import type { AntigravityClientProfile } from "./antigravityClientProfile.ts";
|
||||
import { ANTIGRAVITY_BOOTSTRAP_BASE_URLS, getAntigravityOnboardUrls } from "../config/antigravityUpstream.ts";
|
||||
import {
|
||||
ANTIGRAVITY_BOOTSTRAP_BASE_URLS,
|
||||
getAntigravityOnboardUrls,
|
||||
} from "../config/antigravityUpstream.ts";
|
||||
|
||||
const LOAD_CODE_ASSIST_PATH = "/v1internal:loadCodeAssist";
|
||||
const BOOTSTRAP_TIMEOUT_MS = 8_000;
|
||||
@@ -47,7 +50,39 @@ function evictOldest(cache: Map<string, unknown>): void {
|
||||
const projectCache = new Map<string, string>();
|
||||
|
||||
/** Per-key lock to prevent concurrent onboard attempts for the same token. */
|
||||
const onboardLocks = new Map<string, Promise<boolean>>();
|
||||
const onboardLocks = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Sentinel returned by ensureAntigravityProjectAssigned when Google's
|
||||
* onboardUser completed but did NOT return a project id — no automatic
|
||||
* project creation for standard-tier (personal) accounts (tracked in #8491),
|
||||
* so Google requires a user-defined GCP project (BYOP). The
|
||||
* caller must fail fast with a clear "enter your GCP project id" error
|
||||
* instead of retrying (a fabricated id gets a delayed 429 RESOURCE_EXHAUSTED).
|
||||
*/
|
||||
export const ANTIGRAVITY_REQUIRES_MANUAL_PROJECT = "__REQUIRES_GCP_PROJECT__";
|
||||
|
||||
/**
|
||||
* Per-token cache of accounts Google told us to Bring Your Own Project.
|
||||
* Permanent for the process lifetime (LRU-capped): re-running onboardUser
|
||||
* for such an account is a pointless ~18s quota-check round-trip that
|
||||
* always comes back empty. Cleared by clearAntigravityProjectCache(); a
|
||||
* manually-entered project id (stored on the connection) short-circuits
|
||||
* before this is consulted.
|
||||
*/
|
||||
const requiresManualProjectCache = new Set<string>();
|
||||
|
||||
function markRequiresManualProject(key: string): void {
|
||||
if (requiresManualProjectCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = requiresManualProjectCache.values().next().value;
|
||||
if (oldest !== undefined) requiresManualProjectCache.delete(oldest);
|
||||
}
|
||||
requiresManualProjectCache.add(key);
|
||||
}
|
||||
|
||||
/** Outcome of an onboardUser attempt — three-way so the caller can distinguish
|
||||
* "transient failure (retry later)" from "Google says bring your own project". */
|
||||
type AntigravityOnboardStatus = "onboarded" | "requires_manual_project" | "failed";
|
||||
|
||||
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
@@ -138,7 +173,7 @@ async function tryOnboardUser(
|
||||
clientProfile: AntigravityClientProfile,
|
||||
tierId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
): Promise<AntigravityOnboardStatus> {
|
||||
const urls = getAntigravityOnboardUrls();
|
||||
const headers = getAntigravityContentHeaders(clientProfile, accessToken);
|
||||
|
||||
@@ -157,7 +192,20 @@ async function tryOnboardUser(
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return true;
|
||||
// Accounts Google expects to Bring Their Own Project: onboardUser
|
||||
// returns 200 without a `cloudaicompanionProject` in the body — no
|
||||
// automatic project creation for standard-tier/personal accounts
|
||||
// (tracked in #8491). Detect that so we can fail fast with a clear
|
||||
// instruction instead of retrying forever or fabricating an id that
|
||||
// Google later rejects with a delayed 429 RESOURCE_EXHAUSTED.
|
||||
const body = await response.text().catch(() => "");
|
||||
if (body && !/cloudaicompanionProject/.test(body)) {
|
||||
console.warn(
|
||||
`[models] antigravity onboardUser done but no project in response at ${url} — Google BYOP (user-defined GCP project) required`
|
||||
);
|
||||
return "requires_manual_project";
|
||||
}
|
||||
return "onboarded";
|
||||
}
|
||||
|
||||
console.warn(
|
||||
@@ -171,18 +219,40 @@ async function tryOnboardUser(
|
||||
console.warn(`[models] antigravity onboardUser threw for ${url}: ${msg} — trying next`);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return "failed";
|
||||
}
|
||||
|
||||
/** Per-token memoization for accounts we already tried onboarding (avoid repeated calls). */
|
||||
const onboardAttemptedCache = new Set<string>();
|
||||
/**
|
||||
* Per-token failure backoff for the onboardUser creation path.
|
||||
*
|
||||
* A FAILED onboard attempt must never be memoized as "done": a transient
|
||||
* upstream/network error would otherwise poison the account for the whole
|
||||
* process lifetime, so every later request 422s with "Missing Google
|
||||
* projectId" even though onboarding would succeed on retry. Instead we record
|
||||
* WHEN a failure happened and only skip re-attempts while the short backoff
|
||||
* window is open — the account heals itself on the next request after it
|
||||
* expires. Successful discoveries are memoized in `projectCache` (with LRU
|
||||
* eviction) and clear any pending failure marker.
|
||||
*/
|
||||
const onboardFailureAt = new Map<string, number>();
|
||||
const ONBOARD_RETRY_BACKOFF_MS = 5 * 60 * 1000;
|
||||
|
||||
function addToOnboardAttemptedCache(key: string): void {
|
||||
if (onboardAttemptedCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = onboardAttemptedCache.values().next().value;
|
||||
if (oldest !== undefined) onboardAttemptedCache.delete(oldest);
|
||||
function markOnboardFailure(key: string): void {
|
||||
if (onboardFailureAt.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = onboardFailureAt.keys().next().value;
|
||||
if (oldest !== undefined) onboardFailureAt.delete(oldest);
|
||||
}
|
||||
onboardAttemptedCache.add(key);
|
||||
onboardFailureAt.set(key, Date.now());
|
||||
}
|
||||
|
||||
function isOnboardOnBackoff(key: string): boolean {
|
||||
const failedAt = onboardFailureAt.get(key);
|
||||
if (failedAt === undefined) return false;
|
||||
if (Date.now() - failedAt >= ONBOARD_RETRY_BACKOFF_MS) {
|
||||
onboardFailureAt.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,49 +282,71 @@ export async function ensureAntigravityProjectAssigned(
|
||||
}
|
||||
|
||||
const { projectId: initialProjectId, tierId } = await tryLoadCodeAssist(
|
||||
accessToken, fetchImpl, clientProfile, signal
|
||||
accessToken,
|
||||
fetchImpl,
|
||||
clientProfile,
|
||||
signal
|
||||
);
|
||||
|
||||
let projectId = initialProjectId;
|
||||
|
||||
// Google told us this account must Bring Its Own Project — fail fast with
|
||||
// the sentinel instead of repeating the pointless ~18s onboard round-trip.
|
||||
if (!projectId && requiresManualProjectCache.has(cacheKey)) {
|
||||
return ANTIGRAVITY_REQUIRES_MANUAL_PROJECT;
|
||||
}
|
||||
|
||||
// loadCodeAssist is read-only — if the account was never onboarded, it returns
|
||||
// empty. Call onboardUser to create the project, then retry discovery.
|
||||
if (!projectId && !onboardAttemptedCache.has(cacheKey)) {
|
||||
// Re-attempts are bounded by a short failure backoff (not a permanent memo),
|
||||
// so a transient onboard failure heals on the next request. Accounts Google
|
||||
// marks BYOP are cached permanently and short-circuit above.
|
||||
if (!projectId && !isOnboardOnBackoff(cacheKey)) {
|
||||
// Per-key lock: concurrent calls for the same token share one onboard attempt.
|
||||
let lock = onboardLocks.get(cacheKey);
|
||||
if (!lock) {
|
||||
lock = (async () => {
|
||||
let aborted = false;
|
||||
let succeeded = false;
|
||||
let requiresManual = false;
|
||||
try {
|
||||
const onboarded = await tryOnboardUser(
|
||||
accessToken, fetchImpl, clientProfile, tierId, signal
|
||||
const status = await tryOnboardUser(
|
||||
accessToken,
|
||||
fetchImpl,
|
||||
clientProfile,
|
||||
tierId,
|
||||
signal
|
||||
);
|
||||
if (onboarded) {
|
||||
const retry = await tryLoadCodeAssist(
|
||||
accessToken, fetchImpl, clientProfile, signal
|
||||
);
|
||||
if (status === "requires_manual_project") {
|
||||
markRequiresManualProject(cacheKey);
|
||||
requiresManual = true;
|
||||
return;
|
||||
}
|
||||
if (status === "onboarded") {
|
||||
const retry = await tryLoadCodeAssist(accessToken, fetchImpl, clientProfile, signal);
|
||||
if (retry.projectId) {
|
||||
evictOldest(projectCache);
|
||||
projectCache.set(cacheKey, retry.projectId);
|
||||
return true;
|
||||
succeeded = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
aborted = signal?.aborted === true;
|
||||
return false;
|
||||
return;
|
||||
} finally {
|
||||
onboardLocks.delete(cacheKey);
|
||||
if (!aborted) addToOnboardAttemptedCache(cacheKey);
|
||||
if (!aborted && !requiresManual) {
|
||||
if (succeeded) onboardFailureAt.delete(cacheKey);
|
||||
else markOnboardFailure(cacheKey);
|
||||
}
|
||||
}
|
||||
})();
|
||||
onboardLocks.set(cacheKey, lock);
|
||||
}
|
||||
const success = await lock;
|
||||
if (success) {
|
||||
const cached = projectCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
}
|
||||
await lock;
|
||||
if (projectCache.has(cacheKey)) return projectCache.get(cacheKey);
|
||||
if (requiresManualProjectCache.has(cacheKey)) return ANTIGRAVITY_REQUIRES_MANUAL_PROJECT;
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
@@ -268,10 +360,17 @@ export async function ensureAntigravityProjectAssigned(
|
||||
/** Exported for tests. */
|
||||
export function clearAntigravityProjectCache(): void {
|
||||
projectCache.clear();
|
||||
onboardAttemptedCache.clear();
|
||||
onboardFailureAt.clear();
|
||||
requiresManualProjectCache.clear();
|
||||
onboardLocks.clear();
|
||||
}
|
||||
|
||||
/** Test-only: clear the onboard failure backoff (simulates backoff expiry). */
|
||||
export function clearAntigravityOnboardBackoff(key?: string): void {
|
||||
if (key) onboardFailureAt.delete(key);
|
||||
else onboardFailureAt.clear();
|
||||
}
|
||||
|
||||
/** Exported for tests — inspect cache state. */
|
||||
export function getAntigravityProjectFromCache(
|
||||
accessToken: string,
|
||||
|
||||
@@ -336,6 +336,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig:
|
||||
if (
|
||||
result?.accessToken &&
|
||||
(provider === "antigravity" || provider === "agy") &&
|
||||
!credentials.providerSpecificData?.isProjectIdManual &&
|
||||
!(credentials.projectId || credentials.providerSpecificData?.projectId)
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -649,6 +649,12 @@ export default function EditConnectionModal({
|
||||
clientProfile: normalizeAntigravityClientProfileSetting(
|
||||
formData.antigravityClientProfile
|
||||
),
|
||||
// A manually-entered project id must not be overwritten by
|
||||
// auto-discovery (loadCodeAssist) on later token refreshes. This
|
||||
// merge is the single surviving write of providerSpecificData for
|
||||
// antigravity (both OAuth and API-key branches rebuild the object
|
||||
// above), so the flag has to land here to actually persist.
|
||||
isProjectIdManual: !!trimmedCloudCodeProjectId,
|
||||
};
|
||||
}
|
||||
if (updates.providerSpecificData) {
|
||||
|
||||
@@ -41,7 +41,7 @@ const SAMPLE_CONSOLE_RESPONSE = {
|
||||
freeTierQuotas: [
|
||||
{
|
||||
quotaInitTotal: 1000000,
|
||||
quotaValidityPeriod: 1786896000000,
|
||||
quotaValidityPeriod: 1830297600000,
|
||||
freeTierOnly: true,
|
||||
quotaTotalPercentage: 99.98,
|
||||
model: "qwen3.6-plus",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user