From 6c8ff4d5eded2a6d915f12236aa6c6f6e5d48634 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:44:31 -0600 Subject: [PATCH 01/29] feat: add antigravity pacing safeguards --- .../provider-workspace/ProviderSettings.tsx | 28 +++++-- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/provider-workspace/catalog.ts | 3 +- .../provider-settings-request-pacing.test.tsx | 7 +- src/config.ts | 2 + src/oauth/index.ts | 1 + src/providers/derive.ts | 2 + src/providers/registry.ts | 3 +- src/providers/request-pacing.ts | 30 +++++++- src/types/provider.ts | 2 + tests/chat-completions-endpoint.test.ts | 1 + tests/empty-completion-core.test.ts | 1 + tests/request-pacing.test.ts | 75 ++++++++++++++++++- 21 files changed, 148 insertions(+), 16 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index 847fe7e712..3f50feb8e1 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -25,7 +25,7 @@ const ADAPTERS = ["openai-responses", "openai-chat", "anthropic", "google", "azu const EMPTY_MODELS: string[] = []; type ChoicesStatus = "idle" | "loading" | "ready" | "error"; -type PacingRule = { requestsPerMinute?: number; minIntervalMs?: number }; +type PacingRule = { requestsPerMinute?: number; minIntervalMs?: number; jitterMs?: number }; type PacingStatus = { enabled: boolean; queued: number; nextSlotInMs: number; lastStartedAt?: number; lastModelId?: string }; type CursorHttpVersion = "http2" | "http1.1"; @@ -44,14 +44,20 @@ function positiveInteger(value: string): number | undefined { const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; } +function jitterInteger(value: string): number | undefined { + if (!value.trim()) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 && parsed <= 60_000 ? parsed : undefined; +} function pacingSignature(value: WorkspaceItem["requestPacing"] | undefined): string { const models = Object.entries(value?.models ?? {}) .sort(([left], [right]) => left.localeCompare(right)) - .map(([model, rule]) => [model, rule.requestsPerMinute ?? null, rule.minIntervalMs ?? null]); + .map(([model, rule]) => [model, rule.requestsPerMinute ?? null, rule.minIntervalMs ?? null, rule.jitterMs ?? null]); return JSON.stringify([ value?.enabled === true, value?.requestsPerMinute ?? null, value?.minIntervalMs ?? null, + value?.jitterMs ?? null, models, ]); } @@ -93,10 +99,12 @@ export default function ProviderSettings({ const [pacingEnabled, setPacingEnabled] = useState(item.requestPacing?.enabled === true); const [pacingRpm, setPacingRpm] = useState(() => numberDraft(item.requestPacing?.requestsPerMinute)); const [pacingDelay, setPacingDelay] = useState(() => numberDraft(item.requestPacing?.minIntervalMs)); + const [pacingJitter, setPacingJitter] = useState(() => numberDraft(item.requestPacing?.jitterMs)); const [pacingModels, setPacingModels] = useState>(() => ({ ...(item.requestPacing?.models ?? {}) })); const [pacingModelId, setPacingModelId] = useState(""); const [pacingModelRpm, setPacingModelRpm] = useState(""); const [pacingModelDelay, setPacingModelDelay] = useState(""); + const [pacingModelJitter, setPacingModelJitter] = useState(""); const [pacingStatus, setPacingStatus] = useState(null); /* eslint-disable react-hooks/set-state-in-effect -- intentional form reset when saved provider fields change */ @@ -113,6 +121,7 @@ export default function ProviderSettings({ setPacingEnabled(item.requestPacing?.enabled === true); setPacingRpm(numberDraft(item.requestPacing?.requestsPerMinute)); setPacingDelay(numberDraft(item.requestPacing?.minIntervalMs)); + setPacingJitter(numberDraft(item.requestPacing?.jitterMs)); setPacingModels({ ...(item.requestPacing?.models ?? {}) }); setMsg(null); setModeMsg(null); @@ -183,8 +192,9 @@ export default function ProviderSettings({ enabled: pacingEnabled, ...(positiveRpm(pacingRpm) !== undefined ? { requestsPerMinute: positiveRpm(pacingRpm) } : {}), ...(positiveInteger(pacingDelay) !== undefined ? { minIntervalMs: positiveInteger(pacingDelay) } : {}), + ...(jitterInteger(pacingJitter) !== undefined ? { jitterMs: jitterInteger(pacingJitter) } : {}), ...(Object.keys(pacingModels).length > 0 ? { models: pacingModels } : {}), - }), [pacingDelay, pacingEnabled, pacingModels, pacingRpm]); + }), [pacingDelay, pacingEnabled, pacingJitter, pacingModels, pacingRpm]); const dirty = adapter.trim() !== item.adapter || baseUrl.trim() !== item.baseUrl @@ -303,6 +313,7 @@ export default function ProviderSettings({ setCursorHttpVersion(savedCursorHttpVersion); setMsg(null); setPacingEnabled(item.requestPacing?.enabled === true); setPacingRpm(numberDraft(item.requestPacing?.requestsPerMinute)); setPacingDelay(numberDraft(item.requestPacing?.minIntervalMs)); setPacingModels({ ...(item.requestPacing?.models ?? {}) }); + setPacingJitter(numberDraft(item.requestPacing?.jitterMs)); setPacingModelJitter(""); setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl)); }; @@ -319,9 +330,10 @@ export default function ProviderSettings({ const modelId = pacingModelId.trim(); const rpm = positiveRpm(pacingModelRpm); const delay = positiveInteger(pacingModelDelay); - if (!modelId || (rpm === undefined && delay === undefined)) return; - setPacingModels(current => ({ ...current, [modelId]: { ...(rpm !== undefined ? { requestsPerMinute: rpm } : {}), ...(delay !== undefined ? { minIntervalMs: delay } : {}) } })); - setPacingModelId(""); setPacingModelRpm(""); setPacingModelDelay(""); + const jitter = jitterInteger(pacingModelJitter); + if (!modelId || (rpm === undefined && delay === undefined && jitter === undefined)) return; + setPacingModels(current => ({ ...current, [modelId]: { ...(rpm !== undefined ? { requestsPerMinute: rpm } : {}), ...(delay !== undefined ? { minIntervalMs: delay } : {}), ...(jitter !== undefined ? { jitterMs: jitter } : {}) } })); + setPacingModelId(""); setPacingModelRpm(""); setPacingModelDelay(""); setPacingModelJitter(""); }; return ( @@ -479,6 +491,7 @@ export default function ProviderSettings({
+

{t("pws.pacingSlowerWins")}

@@ -491,9 +504,10 @@ export default function ProviderSettings({ +
- {Object.entries(pacingModels).length > 0 &&
{Object.entries(pacingModels).map(([model, rule]) =>
{model}{rule.requestsPerMinute !== undefined ? `${rule.requestsPerMinute} ${t("pws.pacingRpmUnit")}` : ""}{rule.requestsPerMinute !== undefined && rule.minIntervalMs !== undefined ? " · " : ""}{rule.minIntervalMs !== undefined ? `${rule.minIntervalMs} ms` : ""}
)}
} + {Object.entries(pacingModels).length > 0 &&
{Object.entries(pacingModels).map(([model, rule]) =>
{model}{rule.requestsPerMinute !== undefined ? `${rule.requestsPerMinute} ${t("pws.pacingRpmUnit")}` : ""}{rule.requestsPerMinute !== undefined && rule.minIntervalMs !== undefined ? " · " : ""}{rule.minIntervalMs !== undefined ? `${rule.minIntervalMs} ms` : ""}{rule.jitterMs !== undefined ? ` · ${rule.jitterMs} ms` : ""}
)}
} {formDirty && (
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 12fa962020..b96e508e53 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1733,6 +1733,7 @@ export const de: Record = { "pws.pacingRpm": "Anfragen pro Minute", "pws.pacingRpmUnit": "RPM", "pws.pacingDelay": "Mindestintervall (ms)", + "pws.pacingJitter": "Zufällige Streuung (ms)", "pws.pacingSlowerWins": "Das langsamere Anbieterlimit gilt. Modellregeln können nur stärker verzögern.", "pws.pacingQueued": "in Warteschlange", "pws.pacingNextSlot": "bis zum nächsten Slot", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d0acc18dde..67b2b79479 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1231,6 +1231,7 @@ export const en = { "pws.pacingRpm": "Requests per minute", "pws.pacingRpmUnit": "RPM", "pws.pacingDelay": "Minimum interval (ms)", + "pws.pacingJitter": "Random jitter (ms)", "pws.pacingSlowerWins": "The slower provider limit wins. Model overrides can only add more delay.", "pws.pacingQueued": "queued", "pws.pacingNextSlot": "until next slot", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 983ac2a23b..f914651381 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1180,6 +1180,7 @@ export const fr: Record = { "pws.pacingRpm": "Requêtes par minute", "pws.pacingRpmUnit": "RPM", "pws.pacingDelay": "Intervalle minimal (ms)", + "pws.pacingJitter": "Variation aléatoire (ms)", "pws.pacingSlowerWins": "La limite la plus lente du fournisseur s’applique. Les remplacements par modèle ne peuvent qu’ajouter un délai.", "pws.pacingQueued": "en attente", "pws.pacingNextSlot": "avant le prochain créneau", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 05215462be..7e35a4391b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1164,6 +1164,7 @@ export const ja: Record = { "pws.pacingRpm": "1分あたりのリクエスト数", "pws.pacingRpmUnit": "RPM", "pws.pacingDelay": "最小間隔 (ms)", + "pws.pacingJitter": "ランダムジッター (ms)", "pws.pacingSlowerWins": "より遅いプロバイダー制限が優先され、モデル設定は遅延を増やす場合のみ適用されます。", "pws.pacingQueued": "待機中", "pws.pacingNextSlot": "次のスロットまで", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f3cd6fe9ec..f406733c87 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1760,6 +1760,7 @@ export const ko: Record = { "pws.pacingRpm": "분당 요청 수", "pws.pacingRpmUnit": "RPM", "pws.pacingDelay": "최소 시작 간격(ms)", + "pws.pacingJitter": "무작위 지터(ms)", "pws.pacingSlowerWins": "프로바이더의 더 느린 제한이 우선합니다. 모델별 설정은 지연을 더 늘릴 때만 적용됩니다.", "pws.pacingQueued": "대기 중", "pws.pacingNextSlot": "다음 슬롯까지", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2cf73ba065..b3d40b4be9 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1215,6 +1215,7 @@ export const ru: Record = { "pws.pacingRpm": "Запросов в минуту", "pws.pacingRpmUnit": "RPM", "pws.pacingDelay": "Минимальный интервал (мс)", + "pws.pacingJitter": "Случайная задержка (мс)", "pws.pacingSlowerWins": "Действует более медленный лимит провайдера. Правила моделей могут только увеличить задержку.", "pws.pacingQueued": "в очереди", "pws.pacingNextSlot": "до следующего слота", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4b84ddeaf5..8769de04a5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1222,6 +1222,7 @@ export const tr: Record = { "pws.pacingRpm": "Dakikadaki istek", "pws.pacingRpmUnit": "RPM", "pws.pacingDelay": "En kısa aralık (ms)", + "pws.pacingJitter": "Rastgele titreşim (ms)", "pws.pacingSlowerWins": "Daha yavaş sağlayıcı sınırı geçerlidir. Model kuralları yalnızca ek gecikme getirir.", "pws.pacingQueued": "kuyrukta", "pws.pacingNextSlot": "sonraki aralığa", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f4d226c4dd..dbaaedead5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1010,6 +1010,7 @@ export const zhTW: Record = { "pws.pacingRpm": "每分鐘請求數", "pws.pacingRpmUnit": "次/分鐘", "pws.pacingDelay": "最小間隔(毫秒)", + "pws.pacingJitter": "隨機抖動(毫秒)", "pws.pacingSlowerWins": "以較慢的供應商限制為準,模型規則只能增加延遲。", "pws.pacingQueued": "排隊中", "pws.pacingNextSlot": "距下個時段", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 02c774fc16..598bb74908 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1753,6 +1753,7 @@ export const zh: Record = { "pws.pacingRpm": "每分钟请求数", "pws.pacingRpmUnit": "RPM", "pws.pacingDelay": "最小间隔(毫秒)", + "pws.pacingJitter": "随机抖动(毫秒)", "pws.pacingSlowerWins": "以较慢的提供商限制为准,模型规则只能增加延迟。", "pws.pacingQueued": "排队中", "pws.pacingNextSlot": "距下个时隙", diff --git a/gui/src/provider-workspace/catalog.ts b/gui/src/provider-workspace/catalog.ts index 522aa32857..3df03bda95 100644 --- a/gui/src/provider-workspace/catalog.ts +++ b/gui/src/provider-workspace/catalog.ts @@ -51,7 +51,8 @@ export interface WorkspaceProvider { enabled?: boolean; requestsPerMinute?: number; minIntervalMs?: number; - models?: Record; + jitterMs?: number; + models?: Record; }; /** Codex account routing mode for the canonical `openai` forward provider. */ codexAccountMode?: "direct" | "pool"; diff --git a/gui/tests/provider-settings-request-pacing.test.tsx b/gui/tests/provider-settings-request-pacing.test.tsx index 1d12a1d07d..d76d7d7df6 100644 --- a/gui/tests/provider-settings-request-pacing.test.tsx +++ b/gui/tests/provider-settings-request-pacing.test.tsx @@ -60,9 +60,11 @@ test("settings saves provider pacing and a slower model override", async () => { await act(async () => { container.querySelector(".pwi-pacing-toggle input")!.click(); }); const numbers = container.querySelectorAll('.pwi-pacing-card input[type="number"]'); await setInput(numbers[0]!, "38"); + await setInput(numbers[2]!, "500"); const modelInput = container.querySelector('.pwi-pacing-grid--model input[list]')!; await setInput(modelInput, "deepseek-ai/deepseek-v4-flash-0731"); - await setInput(numbers[2]!, "10"); + await setInput(numbers[3]!, "10"); + await setInput(numbers[5]!, "20"); await act(async () => { container.querySelector(".pwi-pacing-grid--model button")!.click(); }); const save = container.querySelector(".pwi-settings-sticky-bar .btn-primary")!; await act(async () => { save.click(); await Promise.resolve(); }); @@ -71,7 +73,8 @@ test("settings saves provider pacing and a slower model override", async () => { requestPacing: { enabled: true, requestsPerMinute: 38, - models: { "deepseek-ai/deepseek-v4-flash-0731": { requestsPerMinute: 10 } }, + jitterMs: 500, + models: { "deepseek-ai/deepseek-v4-flash-0731": { requestsPerMinute: 10, jitterMs: 20 } }, }, }]); expect(container.textContent).toContain("deepseek-ai/deepseek-v4-flash-0731"); diff --git a/src/config.ts b/src/config.ts index 10032fcbcf..a9d868a561 100644 --- a/src/config.ts +++ b/src/config.ts @@ -438,6 +438,7 @@ const requestPacingRuleSchema = z.object({ // Keep the RPM-derived timer within the same one-hour bound as minIntervalMs. requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), + jitterMs: z.number().int().min(0).max(60_000).optional(), }).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, { message: "request pacing rules need requestsPerMinute or minIntervalMs", }); @@ -446,6 +447,7 @@ const requestPacingSchema = z.object({ enabled: z.boolean(), requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), + jitterMs: z.number().int().min(0).max(60_000).optional(), models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(), }).strict().refine(value => value.enabled === false || value.requestsPerMinute !== undefined diff --git a/src/oauth/index.ts b/src/oauth/index.ts index fe3abe7656..c94a228e97 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -222,6 +222,7 @@ export const OAUTH_PROVIDERS: Record = { refresh: refreshAntigravityToken, providerConfig: oauthConfig("google-antigravity"), defaultModel: oauthDefaultModel("google-antigravity"), + defaultRefreshPolicy: "lazy-only", }, cursor: { login: (ctrl, opts) => loginCursor(ctrl, undefined, { forceLogin: opts?.forceLogin }), diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 63cd1c9388..8b2f535dd4 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -226,6 +226,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.modelSuffixBracketStrip !== undefined ? { modelSuffixBracketStrip: entry.modelSuffixBracketStrip } : {}), ...(entry.staticHeaders ? { headers: { ...entry.staticHeaders } } : {}), ...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}), + ...(entry.requestPacing ? { requestPacing: structuredClone(entry.requestPacing) } : {}), ...(entry.models ? { models: [...entry.models] } : {}), ...(liveModels !== undefined ? { liveModels } : {}), ...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}), @@ -438,6 +439,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig repairStaticModelCatalogProvider(name, prov); if (prov.apiKeyTransport === undefined && seed.apiKeyTransport !== undefined) prov.apiKeyTransport = seed.apiKeyTransport; if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel; + if (prov.requestPacing === undefined && seed.requestPacing) prov.requestPacing = structuredClone(seed.requestPacing); if (prov.responsesPath === undefined && seed.responsesPath !== undefined) prov.responsesPath = seed.responsesPath; // Fill mode only when absent: an explicit persisted `direct` must never be overwritten. if (prov.codexAccountMode === undefined && seed.codexAccountMode !== undefined) prov.codexAccountMode = seed.codexAccountMode; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 9fda85ba63..ee55399564 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -161,6 +161,7 @@ export interface ProviderRegistryEntry { note?: string; dashboardUrl?: string; defaultModel?: string; + requestPacing?: OcxProviderConfig["requestPacing"]; models?: string[]; liveModels?: boolean; /** @@ -1597,7 +1598,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API // evidence from ai.google.dev does not establish Vertex publisher availability. { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, - { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.7-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, + { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.7-flash", requestPacing: { enabled: true, requestsPerMinute: 30, minIntervalMs: 2_000, jitterMs: 500 }, modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, diff --git a/src/providers/request-pacing.ts b/src/providers/request-pacing.ts index e38c62f3c0..326f354a70 100644 --- a/src/providers/request-pacing.ts +++ b/src/providers/request-pacing.ts @@ -33,6 +33,8 @@ interface Waiter { modelId?: string; providerIntervalMs: number; modelIntervalMs: number; + providerJitterMs: number; + modelJitterMs: number; queuedAt: number; signal?: AbortSignal; resolve: () => void; @@ -54,6 +56,7 @@ export interface RequestPacingRuntime { setTimer: (callback: () => void, delayMs: number) => unknown; clearTimer: (handle: unknown) => void; enqueueMicrotask: (callback: () => void) => void; + random: () => number; } export interface ProviderRequestPacingStatus { @@ -71,6 +74,7 @@ const defaultRuntime: RequestPacingRuntime = { setTimer: (callback, delayMs) => setTimeout(callback, delayMs), clearTimer: handle => clearTimeout(handle as ReturnType), enqueueMicrotask: queueMicrotask, + random: Math.random, }; let runtime = defaultRuntime; let lastReconciledGeneration = 0; @@ -90,6 +94,19 @@ function normalizedInterval(rule: RequestPacingRule | undefined): number { return Math.max(rpmInterval, fixedInterval); } +function normalizedJitter(rule: RequestPacingRule | undefined): number { + return rule?.jitterMs !== undefined && Number.isInteger(rule.jitterMs) + ? Math.min(60_000, Math.max(0, rule.jitterMs)) + : 0; +} + +function jitterDelay(jitterMs: number): number { + if (jitterMs <= 0) return 0; + const sample = runtime.random(); + if (!Number.isFinite(sample)) return 0; + return Math.floor(Math.min(1, Math.max(0, sample)) * (jitterMs + 1)); +} + export function requestPacingIntervalMs(provider: OcxProviderConfig, modelId?: string): number { const policy = provider.requestPacing; if (!policy?.enabled) return 0; @@ -100,12 +117,17 @@ export function requestPacingIntervalMs(provider: OcxProviderConfig, modelId?: s function requestPacingIntervals(provider: OcxProviderConfig, modelId?: string): { providerIntervalMs: number; modelIntervalMs: number; + providerJitterMs: number; + modelJitterMs: number; } { const policy = provider.requestPacing; - if (!policy?.enabled) return { providerIntervalMs: 0, modelIntervalMs: 0 }; + if (!policy?.enabled) return { providerIntervalMs: 0, modelIntervalMs: 0, providerJitterMs: 0, modelJitterMs: 0 }; + const model = modelId ? policy.models?.[modelId] : undefined; return { providerIntervalMs: normalizedInterval(policy), - modelIntervalMs: modelId ? normalizedInterval(policy.models?.[modelId]) : 0, + modelIntervalMs: normalizedInterval(model), + providerJitterMs: normalizedJitter(policy), + modelJitterMs: normalizedJitter(model), }; } @@ -186,9 +208,9 @@ function runQueue(providerName: string, state: ProviderPacer): void { const startedAt = runtime.now(); state.lastStartedAt = startedAt; state.lastModelId = waiter.modelId; - state.providerNextStartAt = startedAt + waiter.providerIntervalMs; + state.providerNextStartAt = startedAt + waiter.providerIntervalMs + jitterDelay(waiter.providerJitterMs); if (waiter.modelId && waiter.modelIntervalMs > 0) { - state.modelNextStartAt.set(waiter.modelId, startedAt + waiter.modelIntervalMs); + state.modelNextStartAt.set(waiter.modelId, startedAt + waiter.modelIntervalMs + jitterDelay(waiter.modelJitterMs)); } waiter.resolve(); runtime.enqueueMicrotask(() => runQueue(providerName, state)); diff --git a/src/types/provider.ts b/src/types/provider.ts index 3dfca58ddc..4d6381c7ba 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -68,6 +68,8 @@ export interface RequestPacingRule { requestsPerMinute?: number; /** Minimum delay between request starts. The slower configured value wins. */ minIntervalMs?: number; + /** Positive-only random delay added to each request-start slot. */ + jitterMs?: number; } export interface ProviderRequestPacingConfig extends RequestPacingRule { diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index 1f4009c9a5..5023a48c43 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -865,6 +865,7 @@ test("chat-native consumes pacing before the response-header timeout starts", as }, clearTimer: () => { pacingTimer = undefined; }, enqueueMicrotask: callback => callback(), + random: () => 0, }); let starts = 0; diff --git a/tests/empty-completion-core.test.ts b/tests/empty-completion-core.test.ts index 6d538bf54c..2e3ef66989 100644 --- a/tests/empty-completion-core.test.ts +++ b/tests/empty-completion-core.test.ts @@ -224,6 +224,7 @@ describe("empty-completion core integration", () => { }, clearTimer() {}, enqueueMicrotask: callback => callback(), + random: () => 0, }); attemptEvents = [ [{ type: "done" }], diff --git a/tests/request-pacing.test.ts b/tests/request-pacing.test.ts index bf8cfbb106..5703a48cd5 100644 --- a/tests/request-pacing.test.ts +++ b/tests/request-pacing.test.ts @@ -14,6 +14,10 @@ import { providerFetch } from "../src/server/responses/fetch-helpers"; import { fetchWithHeaderTimeout } from "../src/server/responses/fetch-helpers"; import { requestPacingOverloadResponse } from "../src/server/responses/pacing-overload"; import type { OcxProviderConfig } from "../src/types"; +import { requestPacingConfigError } from "../src/config"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { OAUTH_PROVIDERS, resolveRefreshPolicy } from "../src/oauth"; afterEach(() => resetProviderRequestPacingForTest()); @@ -21,7 +25,7 @@ function provider(requestPacing: OcxProviderConfig["requestPacing"]): OcxProvide return { adapter: "openai-chat", baseUrl: "https://example.test/v1", requestPacing }; } -function fakePacingClock(): { +function fakePacingClock(random = 0.5): { runtime: RequestPacingRuntime; now: () => number; pendingTimerCount: () => number; @@ -33,6 +37,7 @@ function fakePacingClock(): { return { runtime: { now: () => now, + random: () => random, setTimer: (callback, delayMs) => { const id = nextId++; timers.set(id, { at: now + delayMs, callback }); @@ -81,6 +86,74 @@ describe("requestPacingIntervalMs", () => { expect(requestPacingIntervalMs(configured, "slow")).toBe(900); expect(requestPacingIntervalMs(configured, "other")).toBe(0); }); + + test("validates jitter and keeps model jitter as an additional delay", async () => { + expect(requestPacingConfigError({ enabled: true, minIntervalMs: 100, jitterMs: 60_001 })).not.toBeNull(); + expect(requestPacingConfigError({ enabled: true, minIntervalMs: 100, jitterMs: 25 })).toBeNull(); + + const clock = fakePacingClock(); + setProviderRequestPacingRuntimeForTest(clock.runtime); + const configured = provider({ + enabled: true, + minIntervalMs: 100, + models: { slow: { minIntervalMs: 200, jitterMs: 50 } }, + }); + const first = waitForProviderRequestSlot("jitter", configured, "slow"); + await first; + const second = waitForProviderRequestSlot("jitter", configured, "slow"); + let settled = false; + void second.then(() => { settled = true; }); + clock.advanceBy(224); + await Promise.resolve(); + expect(settled).toBe(false); + clock.advanceBy(1); + await second; + expect(clock.now()).toBe(225); + }); + + test("clamps injected randomness so jitter never accelerates a slot", async () => { + const clock = fakePacingClock(-1); + setProviderRequestPacingRuntimeForTest(clock.runtime); + const configured = provider({ enabled: true, minIntervalMs: 100, jitterMs: 50 }); + await waitForProviderRequestSlot("jitter-negative", configured, "model"); + const second = waitForProviderRequestSlot("jitter-negative", configured, "model"); + let settled = false; + void second.then(() => { settled = true; }); + clock.advanceBy(99); + await Promise.resolve(); + expect(settled).toBe(false); + clock.advanceBy(1); + await second; + expect(clock.now()).toBe(100); + }); + + test("Antigravity pacing defaults fill only absent settings", () => { + const entry = PROVIDER_REGISTRY.find(row => row.id === "google-antigravity")!; + expect(providerConfigSeed(entry).requestPacing).toEqual({ + enabled: true, + requestsPerMinute: 30, + minIntervalMs: 2_000, + jitterMs: 500, + }); + + const legacy = { adapter: "google", baseUrl: entry.baseUrl } as OcxProviderConfig; + enrichProviderFromRegistry("google-antigravity", legacy); + expect(legacy.requestPacing).toEqual({ + enabled: true, + requestsPerMinute: 30, + minIntervalMs: 2_000, + jitterMs: 500, + }); + + const explicit = { adapter: "google", baseUrl: entry.baseUrl, requestPacing: { enabled: false, minIntervalMs: 1 } } as OcxProviderConfig; + enrichProviderFromRegistry("google-antigravity", explicit); + expect(explicit.requestPacing).toEqual({ enabled: false, minIntervalMs: 1 }); + }); + + test("Antigravity explicitly opts into lazy-only refresh", () => { + expect((OAUTH_PROVIDERS["google-antigravity"] as unknown as { defaultRefreshPolicy?: string }).defaultRefreshPolicy).toBe("lazy-only"); + expect(resolveRefreshPolicy("google-antigravity", { providers: {} } as never)).toBe("lazy-only"); + }); }); describe("provider request pacing queue", () => { From 6a21376348f2cde96a60b3cc2045f7957209be25 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:52:00 -0600 Subject: [PATCH 02/29] fix: tighten antigravity pacing safeguards --- src/config.ts | 4 +-- src/providers/request-pacing.ts | 4 +-- tests/request-pacing.test.ts | 43 +++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index a9d868a561..04c1fbaa91 100644 --- a/src/config.ts +++ b/src/config.ts @@ -439,8 +439,8 @@ const requestPacingRuleSchema = z.object({ requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), jitterMs: z.number().int().min(0).max(60_000).optional(), -}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, { - message: "request pacing rules need requestsPerMinute or minIntervalMs", +}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined || value.jitterMs !== undefined, { + message: "request pacing rules need requestsPerMinute, minIntervalMs, or jitterMs", }); const requestPacingSchema = z.object({ diff --git a/src/providers/request-pacing.ts b/src/providers/request-pacing.ts index 326f354a70..d066dbf45e 100644 --- a/src/providers/request-pacing.ts +++ b/src/providers/request-pacing.ts @@ -104,7 +104,7 @@ function jitterDelay(jitterMs: number): number { if (jitterMs <= 0) return 0; const sample = runtime.random(); if (!Number.isFinite(sample)) return 0; - return Math.floor(Math.min(1, Math.max(0, sample)) * (jitterMs + 1)); + return Math.min(jitterMs, Math.floor(Math.max(0, sample) * jitterMs)); } export function requestPacingIntervalMs(provider: OcxProviderConfig, modelId?: string): number { @@ -209,7 +209,7 @@ function runQueue(providerName: string, state: ProviderPacer): void { state.lastStartedAt = startedAt; state.lastModelId = waiter.modelId; state.providerNextStartAt = startedAt + waiter.providerIntervalMs + jitterDelay(waiter.providerJitterMs); - if (waiter.modelId && waiter.modelIntervalMs > 0) { + if (waiter.modelId && (waiter.modelIntervalMs > 0 || waiter.modelJitterMs > 0)) { state.modelNextStartAt.set(waiter.modelId, startedAt + waiter.modelIntervalMs + jitterDelay(waiter.modelJitterMs)); } waiter.resolve(); diff --git a/tests/request-pacing.test.ts b/tests/request-pacing.test.ts index 5703a48cd5..e056b580c2 100644 --- a/tests/request-pacing.test.ts +++ b/tests/request-pacing.test.ts @@ -90,6 +90,11 @@ describe("requestPacingIntervalMs", () => { test("validates jitter and keeps model jitter as an additional delay", async () => { expect(requestPacingConfigError({ enabled: true, minIntervalMs: 100, jitterMs: 60_001 })).not.toBeNull(); expect(requestPacingConfigError({ enabled: true, minIntervalMs: 100, jitterMs: 25 })).toBeNull(); + expect(requestPacingConfigError({ + enabled: true, + minIntervalMs: 100, + models: { slow: { jitterMs: 25 } }, + })).toBeNull(); const clock = fakePacingClock(); setProviderRequestPacingRuntimeForTest(clock.runtime); @@ -111,6 +116,37 @@ describe("requestPacingIntervalMs", () => { expect(clock.now()).toBe(225); }); + test("tracks a model-only jitter lane without weakening the provider interval", async () => { + const clock = fakePacingClock(); + setProviderRequestPacingRuntimeForTest(clock.runtime); + const configured = provider({ + enabled: true, + minIntervalMs: 100, + models: { slow: { jitterMs: 50 } }, + }); + await waitForProviderRequestSlot("model-jitter", configured, "slow"); + const second = waitForProviderRequestSlot("model-jitter", configured, "slow"); + let settled = false; + void second.then(() => { settled = true; }); + clock.advanceBy(124); + await Promise.resolve(); + expect(settled).toBe(false); + clock.advanceBy(1); + await second; + expect(clock.now()).toBe(125); + }); + + test.each([1, 2])("never exceeds jitterMs for injected random boundary %s", async random => { + const clock = fakePacingClock(random); + setProviderRequestPacingRuntimeForTest(clock.runtime); + const configured = provider({ enabled: true, minIntervalMs: 100, jitterMs: 50 }); + await waitForProviderRequestSlot(`jitter-boundary-${random}`, configured, "model"); + const second = waitForProviderRequestSlot(`jitter-boundary-${random}`, configured, "model"); + clock.advanceBy(150); + await second; + expect(clock.now()).toBe(150); + }); + test("clamps injected randomness so jitter never accelerates a slot", async () => { const clock = fakePacingClock(-1); setProviderRequestPacingRuntimeForTest(clock.runtime); @@ -148,11 +184,18 @@ describe("requestPacingIntervalMs", () => { const explicit = { adapter: "google", baseUrl: entry.baseUrl, requestPacing: { enabled: false, minIntervalMs: 1 } } as OcxProviderConfig; enrichProviderFromRegistry("google-antigravity", explicit); expect(explicit.requestPacing).toEqual({ enabled: false, minIntervalMs: 1 }); + + const custom = { adapter: "google", baseUrl: entry.baseUrl, requestPacing: { enabled: true, minIntervalMs: 9_000, jitterMs: 7 } } as OcxProviderConfig; + enrichProviderFromRegistry("google-antigravity", custom); + expect(custom.requestPacing).toEqual({ enabled: true, minIntervalMs: 9_000, jitterMs: 7 }); }); test("Antigravity explicitly opts into lazy-only refresh", () => { expect((OAUTH_PROVIDERS["google-antigravity"] as unknown as { defaultRefreshPolicy?: string }).defaultRefreshPolicy).toBe("lazy-only"); expect(resolveRefreshPolicy("google-antigravity", { providers: {} } as never)).toBe("lazy-only"); + expect(resolveRefreshPolicy("google-antigravity", { + providers: { "google-antigravity": { refreshPolicy: "proactive" } }, + } as never)).toBe("proactive"); }); }); From 353b43a2074784b4addd7f36e982ea906b06acff Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:55:27 -0600 Subject: [PATCH 03/29] fix: honor model-only pacing jitter --- src/providers/request-pacing.ts | 7 ++++++- tests/request-pacing.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/providers/request-pacing.ts b/src/providers/request-pacing.ts index d066dbf45e..1e3eac81fc 100644 --- a/src/providers/request-pacing.ts +++ b/src/providers/request-pacing.ts @@ -223,7 +223,12 @@ export async function waitForProviderRequestSlot( signal?: AbortSignal, ): Promise { const intervals = requestPacingIntervals(provider, modelId); - if (Math.max(intervals.providerIntervalMs, intervals.modelIntervalMs) <= 0) return; + if (Math.max( + intervals.providerIntervalMs, + intervals.modelIntervalMs, + intervals.providerJitterMs, + intervals.modelJitterMs, + ) <= 0) return; if (signal?.aborted) throw abortReason(signal); const state = pacers.get(providerName) ?? { diff --git a/tests/request-pacing.test.ts b/tests/request-pacing.test.ts index e056b580c2..b936c603c1 100644 --- a/tests/request-pacing.test.ts +++ b/tests/request-pacing.test.ts @@ -136,6 +136,25 @@ describe("requestPacingIntervalMs", () => { expect(clock.now()).toBe(125); }); + test("applies model-only jitter without a provider interval and leaves unrelated models unpaced", async () => { + const clock = fakePacingClock(1); + setProviderRequestPacingRuntimeForTest(clock.runtime); + const configured = provider({ enabled: true, models: { slow: { jitterMs: 50 } } }); + await waitForProviderRequestSlot("model-only-jitter", configured, "slow"); + const second = waitForProviderRequestSlot("model-only-jitter", configured, "slow"); + let secondSettled = false; + void second.then(() => { secondSettled = true; }); + clock.advanceBy(49); + await Promise.resolve(); + expect(secondSettled).toBe(false); + clock.advanceBy(1); + await second; + expect(clock.now()).toBe(50); + const unrelatedAt = clock.now(); + await waitForProviderRequestSlot("model-only-jitter", configured, "other"); + expect(clock.now()).toBe(unrelatedAt); + }); + test.each([1, 2])("never exceeds jitterMs for injected random boundary %s", async random => { const clock = fakePacingClock(random); setProviderRequestPacingRuntimeForTest(clock.runtime); From 04f9a865170ef798c62f6ddb90aa6caeed82792b Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:23:45 -0600 Subject: [PATCH 04/29] fix: harden antigravity account routing --- src/oauth/antigravity-routing.ts | 220 ++++++++++++++++++ src/oauth/google-antigravity.ts | 12 +- src/oauth/index.ts | 10 +- src/server/management/oauth-account-routes.ts | 9 + src/server/responses/core.ts | 124 +++++++++- tests/antigravity-routing.test.ts | 71 ++++++ tests/antigravity-session-affinity.test.ts | 21 ++ tests/google-antigravity-oauth.test.ts | 19 ++ 8 files changed, 480 insertions(+), 6 deletions(-) create mode 100644 src/oauth/antigravity-routing.ts create mode 100644 tests/antigravity-routing.test.ts create mode 100644 tests/antigravity-session-affinity.test.ts diff --git a/src/oauth/antigravity-routing.ts b/src/oauth/antigravity-routing.ts new file mode 100644 index 0000000000..1d9be2c110 --- /dev/null +++ b/src/oauth/antigravity-routing.ts @@ -0,0 +1,220 @@ +import { createHash } from "node:crypto"; +import { getAccountSet } from "./store"; +import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; + +const PROVIDER = "google-antigravity"; +const DEFAULT_COOLDOWN_MS = 60_000; +const MAX_COOLDOWN_MS = 15 * 60_000; +const MAX_SHORT_RETRY_MS = 5_000; +const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000; +const MAX_AFFINITY_ENTRIES = 2_000; +const MAX_AFFINITY_COMPONENT_LENGTH = 128; + +type CooldownSource = "retry-after" | "default" | "synthetic"; +export type AntigravitySyntheticFailure = "rate-limit" | "quota" | "geoblock"; + +interface AccountHealth { + cooldownUntil: number; + cooldownSource: CooldownSource; +} + +interface AffinityEntry { + accountId: string; + lastUsedAt: number; +} + +const accountHealth = new Map(); +const sessionAffinity = new Map(); + +function trimmed(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +function affinityKey(value: string | null | undefined): string { + const candidate = trimmed(value); + if (!candidate) return ""; + return candidate.length <= MAX_AFFINITY_COMPONENT_LENGTH + ? candidate + : createHash("sha256").update(candidate).digest("hex"); +} + +function parseRetryAfterMs(value: string | null | undefined, now: number): number | undefined { + const text = value?.trim(); + if (!text) return undefined; + if (/^\d+(?:\.\d+)?$/.test(text)) { + const seconds = Number(text); + if (Number.isFinite(seconds) && seconds > 0) return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS); + } + const timestamp = Date.parse(text); + if (!Number.isFinite(timestamp)) return undefined; + const delay = timestamp - now; + return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined; +} + +export function antigravitySessionKeyFromParts(input: { + clientThreadId?: string | null; + sessionIdHeader?: string | null; + threadIdHeader?: string | null; + promptCacheKey?: string | null; +}): string | null { + const preferred = [input.clientThreadId, input.sessionIdHeader, input.threadIdHeader, input.promptCacheKey] + .map(trimmed) + .find(Boolean); + if (!preferred) return null; + return affinityKey(preferred); +} + +function pruneExpiredAffinity(now: number): void { + for (const [key, entry] of sessionAffinity) { + if (now - entry.lastUsedAt > AFFINITY_IDLE_TTL_MS) sessionAffinity.delete(key); + } + if (sessionAffinity.size <= MAX_AFFINITY_ENTRIES) return; + const entries = [...sessionAffinity.entries()].sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt); + for (let i = 0; i < sessionAffinity.size - MAX_AFFINITY_ENTRIES; i += 1) { + sessionAffinity.delete(entries[i]![0]); + } +} + +export function clearAntigravityRoutingState(): void { + accountHealth.clear(); + sessionAffinity.clear(); +} + +export function antigravitySessionAffinitySizeForTests(): number { + return sessionAffinity.size; +} + +export function bindAntigravitySessionAffinity( + sessionKey: string | null | undefined, + accountId: string, + now = Date.now(), +): void { + const key = affinityKey(sessionKey); + const account = trimmed(accountId); + if (!key || !account) return; + sessionAffinity.set(key, { accountId: account, lastUsedAt: now }); + pruneExpiredAffinity(now); +} + +export function clearAntigravitySessionAffinityForAccount(accountId: string): void { + for (const [key, entry] of sessionAffinity) { + if (entry.accountId === accountId) sessionAffinity.delete(key); + } +} + +export function getAntigravityAccountHealthSnapshot( + accountId: string, + now = Date.now(), +): { cooldownUntil: number; cooldownSource: CooldownSource } | null { + const health = accountHealth.get(accountId); + if (!health) return null; + if (health.cooldownUntil <= now) { + accountHealth.delete(accountId); + return null; + } + return { ...health }; +} + +export function recordAntigravityCooldown( + accountId: string, + retryAfterHeader?: string | null, + now = Date.now(), +): number { + const delay = parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS; + accountHealth.set(accountId, { + cooldownUntil: now + delay, + cooldownSource: retryAfterHeader?.trim() ? "retry-after" : "default", + }); + sweepExpiredOnWrite(now); + return now + delay; +} + +export function clearAntigravityAccountCooldown(accountId: string): boolean { + return accountHealth.delete(accountId); +} + +export function retryableAntigravity429DelayMs( + retryAfterHeader: string | null | undefined, + now = Date.now(), +): number | null { + const delay = parseRetryAfterMs(retryAfterHeader, now); + return delay !== undefined && delay <= MAX_SHORT_RETRY_MS ? delay : null; +} + +export type AntigravityAccountSelectionReason = + | "affinity" + | "active" + | "active-cooled" + | "active-needs-reauth" + | "missing-affinity" + | "none"; + +export interface AntigravityAccountSelection { + accountId: string | null; + reason: AntigravityAccountSelectionReason; + cooldownUntil?: number; +} + +export function resolveAntigravityAccountForSession( + sessionKey: string | null | undefined, + now = Date.now(), +): AntigravityAccountSelection { + pruneExpiredAffinity(now); + const key = affinityKey(sessionKey); + const set = getAccountSet(PROVIDER); + if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" }; + + if (key) { + const bound = sessionAffinity.get(key); + if (bound) { + const account = set.accounts.find(candidate => candidate.id === bound.accountId); + if (!account) { + sessionAffinity.delete(key); + return { accountId: null, reason: "missing-affinity" }; + } + bound.lastUsedAt = now; + if (account.needsReauth === true) return { accountId: account.id, reason: "active-needs-reauth" }; + return { accountId: account.id, reason: "affinity", ...(getAntigravityAccountHealthSnapshot(account.id, now) ?? {}) }; + } + } + + const account = set.accounts.find(candidate => candidate.id === set.activeAccountId); + if (!account) return { accountId: null, reason: "none" }; + if (account.needsReauth === true) return { accountId: account.id, reason: "active-needs-reauth" }; + const health = getAntigravityAccountHealthSnapshot(account.id, now); + if (key) bindAntigravitySessionAffinity(key, account.id, now); + return { + accountId: account.id, + reason: health ? "active-cooled" : "active", + ...(health ?? {}), + }; +} + +function failureText(payload: unknown): string { + try { return JSON.stringify(payload).toLowerCase(); } catch { return ""; } +} + +export function recordAntigravitySyntheticFailure( + accountId: string, + payload: unknown, + now = Date.now(), +): AntigravitySyntheticFailure | null { + const text = failureText(payload); + const geo = text.includes("geoblock") + || text.includes("location") + || text.includes("country") + || text.includes("region is not supported") + || text.includes("not available in your region"); + if (geo) { + accountHealth.set(accountId, { cooldownUntil: now + MAX_COOLDOWN_MS, cooldownSource: "synthetic" }); + return "geoblock"; + } + const rateLimit = text.includes("rate limit") || text.includes("ratelimit") || text.includes("too many requests"); + const quota = text.includes("quota") || text.includes("resource_exhausted") || text.includes("resource exhausted"); + if (!rateLimit && !quota) return null; + accountHealth.set(accountId, { + cooldownUntil: now + DEFAULT_COOLDOWN_MS, + cooldownSource: "synthetic", + }); + return rateLimit ? "rate-limit" : "quota"; +} diff --git a/src/oauth/google-antigravity.ts b/src/oauth/google-antigravity.ts index be81b55de5..7343525778 100644 --- a/src/oauth/google-antigravity.ts +++ b/src/oauth/google-antigravity.ts @@ -221,7 +221,11 @@ export async function loginAntigravity(ctrl: OAuthController, opts?: { forceAcco return new AntigravityOAuthFlow(ctrl, opts).login(); } -export async function refreshAntigravityToken(refreshToken: string, signal?: AbortSignal): Promise { +export async function refreshAntigravityToken( + refreshToken: string, + signal?: AbortSignal, + previousCredential?: OAuthCredentials, +): Promise { if (!refreshToken) throw new Error("Antigravity credentials are expired and do not include a refresh token"); const payload = await postToken({ grant_type: "refresh_token", @@ -230,8 +234,10 @@ export async function refreshAntigravityToken(refreshToken: string, signal?: Abo refresh_token: refreshToken, }, signal); const creds = credentialsFromPayload(payload, refreshToken); - // Re-discover the project on refresh so a newly-onboarded account fills in projectId. - const projectId = await discoverAntigravityProject(creds.access, signal).catch(() => undefined); + // Project discovery is onboarding work, not ordinary token renewal. Preserve the existing + // account-scoped project id and avoid an extra authenticated CCA call when it is present. + const projectId = previousCredential?.projectId + ?? await discoverAntigravityProject(creds.access, signal).catch(() => undefined); return projectId ? { ...creds, projectId } : creds; } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index c94a228e97..cb7218a285 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -467,8 +467,16 @@ export async function getValidAccessTokenSnapshot(provider: string): Promise { + return resolveAccessSnapshotForAccount(provider, accountId); +} + /** Providers whose upstream-401 replay path may force a snapshot refresh. */ -const FORCE_REFRESH_PROVIDERS = new Set(["xai", "github-copilot", "kiro"]); +const FORCE_REFRESH_PROVIDERS = new Set(["xai", "github-copilot", "kiro", "google-antigravity"]); export async function forceRefreshOAuthAccessSnapshot( rejected: OAuthAccessSnapshot, diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index d9a20e37f2..f114a25b77 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -223,6 +223,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); await removeCredential(provider); + if (provider === "google-antigravity") { + const { clearAntigravityRoutingState } = await import("../../oauth/antigravity-routing"); + clearAntigravityRoutingState(); + } reconcileLiveStateStores(); clearLoginState(provider); const { clearModelCache } = await import("../../codex/model-cache"); @@ -475,6 +479,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< clearAnthropicAccountCooldown(id); clearAnthropicSessionAffinityForAccount(id); } + if (provider === "google-antigravity") { + const { clearAntigravityAccountCooldown, clearAntigravitySessionAffinityForAccount } = await import("../../oauth/antigravity-routing"); + clearAntigravityAccountCooldown(id); + clearAntigravitySessionAffinityForAccount(id); + } if (!getAccountSet(provider)) clearLoginState(provider); const { clearModelCache } = await import("../../codex/model-cache"); const { clearGatherRoutedModelsInflight } = await import("../../codex/catalog"); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 67005be579..71549c5099 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -90,6 +90,7 @@ import { forceRefreshOAuthAccessSnapshot, getOAuthCredentialApiBaseUrl, getValidAccessTokenForAccount, + getValidAccessTokenSnapshotForAccount, getValidAccessTokenSnapshot, publicOAuthAuthenticationErrorMessage, type OAuthAccessSnapshot, @@ -107,6 +108,14 @@ import { resolveAnthropicAccountForSession, rotateAnthropicAccountOn429, } from "../../oauth/anthropic-routing"; +import { + antigravitySessionKeyFromParts, + bindAntigravitySessionAffinity, + recordAntigravityCooldown, + recordAntigravitySyntheticFailure, + resolveAntigravityAccountForSession, + retryableAntigravity429DelayMs, +} from "../../oauth/antigravity-routing"; import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; @@ -150,6 +159,7 @@ import { fetchWithResetRetry, fetchWithTransientRetry, prepareSameTarget429Wait, + sleepWithAbort, } from "../../lib/upstream-retry"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors"; import type { DataPlaneAdmission } from "../auth-cors"; @@ -2633,12 +2643,18 @@ async function handleResponsesInner( // OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the // existing openai-chat / anthropic adapters authenticate with no change. - const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro") + const isAntigravityOAuth = route.providerName === "google-antigravity" + && route.provider.authMode === "oauth" + && route.provider.googleMode === "cloud-code-assist"; + const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro" || isAntigravityOAuth) && route.provider.authMode === "oauth"; let sentOAuthSnapshot: OAuthAccessSnapshot | undefined; let replayOAuthCredentialSnapshot: Pick | undefined; let anthropicPoolAccountId: string | null = null; let anthropicPoolFailovers = 0; + let antigravityAccountId: string | null = null; + let antigravitySessionKey: string | null = null; + let antigravity429RetryAttempted = false; const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth" ? anthropicSessionKeyFromParts({ sessionIdHeader: sessionIdHeaderFromRequest(req.headers), @@ -2648,6 +2664,14 @@ async function handleResponsesInner( promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort === true, }) : null; + if (isAntigravityOAuth) { + antigravitySessionKey = antigravitySessionKeyFromParts({ + sessionIdHeader: sessionIdHeaderFromRequest(req.headers), + threadIdHeader: req.headers.get("thread-id"), + clientThreadId: typeof parsed._clientThreadId === "string" ? parsed._clientThreadId : null, + promptCacheKey: typeof parsed.options.promptCacheKey === "string" ? parsed.options.promptCacheKey : null, + }); + } if (route.provider.authMode === "oauth") { try { if (route.providerName === "anthropic" && isAnthropicAccountPoolEnabled(config)) { @@ -2671,7 +2695,25 @@ async function handleResponsesInner( route.provider = { ...route.provider, apiKey: accessToken }; logCtx.provider = formatAnthropicProviderForLog("anthropic", selection.accountId, config); } else { - const resolved = await getValidAccessTokenSnapshot(route.providerName); + let resolved: OAuthAccessSnapshot; + if (isAntigravityOAuth) { + const selection = resolveAntigravityAccountForSession(antigravitySessionKey); + if (!selection.accountId) { + return formatErrorResponse(401, "authentication_error", "Selected Antigravity OAuth account is unavailable"); + } + if (selection.cooldownUntil !== undefined) { + const retryAfter = Math.max(1, Math.ceil((selection.cooldownUntil - Date.now()) / 1000)); + return formatErrorResponse(429, "rate_limit_error", "Selected Antigravity OAuth account is temporarily rate-limited", { retryAfter: String(retryAfter) }); + } + if (selection.reason === "active-needs-reauth") { + return formatErrorResponse(401, "authentication_error", "Selected Antigravity OAuth account needs reauthentication"); + } + antigravityAccountId = selection.accountId; + resolved = await getValidAccessTokenSnapshotForAccount(route.providerName, selection.accountId); + bindAntigravitySessionAffinity(antigravitySessionKey, selection.accountId); + } else { + resolved = await getValidAccessTokenSnapshot(route.providerName); + } replayOAuthCredentialSnapshot = { accountId: resolved.accountId, generation: resolved.generation, @@ -3053,6 +3095,9 @@ async function handleResponsesInner( // rejection is sticky for the whole turn, set from every parsed payload on the inspection side. let inspectionSawUndeclaredTool = false; const noteInspectedPayload = (payload: unknown) => { + if (isAntigravityOAuth && antigravityAccountId) { + recordAntigravitySyntheticFailure(antigravityAccountId, payload); + } // Gated on the same flag as the guard itself: with no readable catalog (or a forward-auth // provider) every name looks undeclared, and flipping this would stop recording continuation // state for exactly the passthrough traffic the guard deliberately stands down for. @@ -3300,6 +3345,10 @@ async function handleResponsesInner( releaseCodexAuthContextProbeLease(authCtx); return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); } + if (isAntigravityOAuth && refreshed.accountId !== antigravityAccountId) { + upstream.abort(); + return formatErrorResponse(401, "authentication_error", "Antigravity OAuth account changed during refresh"); + } sentOAuthSnapshot = refreshed; replayOAuthCredentialSnapshot = { accountId: refreshed.accountId, @@ -3308,6 +3357,9 @@ async function handleResponsesInner( if (route.providerName === "kiro") { parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; } + if (isAntigravityOAuth && refreshed.projectId && !route.provider.project) { + route.provider = { ...route.provider, project: refreshed.projectId }; + } const refreshedProvider = resolveProviderTransport( route.providerName, { ...route.provider, apiKey: refreshed.accessToken }, @@ -4727,6 +4779,10 @@ async function handleResponsesInner( cleanupUpstreamAbort(); return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); } + if (isAntigravityOAuth && refreshed.accountId !== antigravityAccountId) { + cleanupUpstreamAbort(); + return formatErrorResponse(401, "authentication_error", "Antigravity OAuth account changed during refresh"); + } sentOAuthSnapshot = refreshed; replayOAuthCredentialSnapshot = { accountId: refreshed.accountId, @@ -4735,6 +4791,9 @@ async function handleResponsesInner( if (route.providerName === "kiro") { parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; } + if (isAntigravityOAuth && refreshed.projectId && !route.provider.project) { + route.provider = { ...route.provider, project: refreshed.projectId }; + } const refreshedProvider = resolveProviderTransport( route.providerName, { ...route.provider, apiKey: refreshed.accessToken }, @@ -4830,6 +4889,34 @@ async function handleResponsesInner( upstreamResponse = result; } + // Antigravity never rotates accounts. Record the account cooldown and allow only one + // short, abort-aware replay on the same credential; longer Retry-After values surface to + // the client so a conversation cannot churn through accounts or requests. + if (upstreamResponse.status === 429 && isAntigravityOAuth && antigravityAccountId) { + recordAntigravityCooldown(antigravityAccountId, upstreamResponse.headers.get("retry-after")); + const retryAfter = retryableAntigravity429DelayMs(upstreamResponse.headers.get("retry-after")); + if (!antigravity429RetryAttempted && retryAfter !== null) { + antigravity429RetryAttempted = true; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already closed */ } + try { + await sleepWithAbort(retryAfter, upstream.signal); + } catch { + cleanupUpstreamAbort(); + upstream.abort(); + return clientCancelledResponse(); + } + if (options.abortSignal?.aborted || upstream.signal.aborted) { + cleanupUpstreamAbort(); + upstream.abort(); + return clientCancelledResponse(); + } + const result = await rebuildAndRefetch("rate-limit-429"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue recovery; + } + } + // Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry // with another eligible OAuth account (bounded per request). Disabled by default. while ( @@ -5134,6 +5221,39 @@ async function handleResponsesInner( } } + if (response.status === 429 && isAntigravityOAuth && antigravityAccountId) { + recordAntigravityCooldown(antigravityAccountId, response.headers.get("retry-after")); + const retryAfter = retryableAntigravity429DelayMs(response.headers.get("retry-after")); + if (!antigravity429RetryAttempted && retryAfter !== null) { + antigravity429RetryAttempted = true; + try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } + try { + await sleepWithAbort(retryAfter, upstream.signal); + } catch { + if (options.abortSignal?.aborted || upstream.signal.aborted) { + yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; + } else { + yield { type: "error", message: "Provider continuation failed: retry wait interrupted" }; + } + return; + } + if (options.abortSignal?.aborted || upstream.signal.aborted) { + yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; + return; + } + try { + response = await fetchContinuation("rate-limit-429"); + } catch (error) { + if (options.abortSignal?.aborted || upstream.signal.aborted) { + yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; + } else { + yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` }; + } + return; + } + } + } + if (response.status === 429 && hasKeyPoolFailover(route.provider)) { const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, { retryAfter: response.headers.get("retry-after"), diff --git a/tests/antigravity-routing.test.ts b/tests/antigravity-routing.test.ts new file mode 100644 index 0000000000..61a9637825 --- /dev/null +++ b/tests/antigravity-routing.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + antigravitySessionKeyFromParts, + bindAntigravitySessionAffinity, + clearAntigravityRoutingState, + getAntigravityAccountHealthSnapshot, + recordAntigravityCooldown, + resolveAntigravityAccountForSession, + retryableAntigravity429DelayMs, +} from "../src/oauth/antigravity-routing"; +import { getAccountSet, saveCredential, setActiveAccount } from "../src/oauth/store"; + +let home: string; + +beforeEach(async () => { + home = join(tmpdir(), `ocx-antigravity-routing-${crypto.randomUUID()}`); + mkdirSync(home, { recursive: true }); + process.env.OPENCODEX_HOME = home; + clearAntigravityRoutingState(); + await saveCredential("google-antigravity", { access: "a", refresh: "ra", expires: Date.now() + 3600000, accountId: "account-a", email: "a@example.com" }); + await saveCredential("google-antigravity", { access: "b", refresh: "rb", expires: Date.now() + 3600000, accountId: "account-b", email: "b@example.com" }); +}); + +afterEach(() => { + clearAntigravityRoutingState(); + rmSync(home, { recursive: true, force: true }); + delete process.env.OPENCODEX_HOME; +}); + +function accountIds(): { a: string; b: string } { + const set = getAccountSet("google-antigravity"); + expect(set).toBeDefined(); + return { a: set!.accounts.find(a => a.credential.accountId === "account-a")!.id, b: set!.accounts.find(a => a.credential.accountId === "account-b")!.id }; +} + +describe("google antigravity strict account affinity", () => { + test("blank higher-priority session candidates fall through", () => { + expect(antigravitySessionKeyFromParts({ clientThreadId: " ", sessionIdHeader: "session-a", threadIdHeader: "thread-a" })).toBe("session-a"); + expect(antigravitySessionKeyFromParts({ clientThreadId: "", sessionIdHeader: "", threadIdHeader: "thread-a" })).toBe("thread-a"); + expect(antigravitySessionKeyFromParts({ clientThreadId: " ", sessionIdHeader: " ", threadIdHeader: " " })).toBeNull(); + }); + + test("established sessions stay on their account after active-account changes and cooldowns", async () => { + const ids = accountIds(); + await setActiveAccount("google-antigravity", ids.a); + bindAntigravitySessionAffinity("conversation-1", ids.a, 1000); + await setActiveAccount("google-antigravity", ids.b); + recordAntigravityCooldown(ids.a, "2", 1000); + + expect(resolveAntigravityAccountForSession("conversation-1", 2000)).toMatchObject({ accountId: ids.a, reason: "affinity" }); + expect(getAntigravityAccountHealthSnapshot(ids.a, 2000)?.cooldownUntil).toBe(3000); + expect(resolveAntigravityAccountForSession("conversation-2", 2000)).toMatchObject({ accountId: ids.b, reason: "active" }); + }); + + test("a cooled active account is surfaced as bounded unavailable instead of rotating", async () => { + const ids = accountIds(); + await setActiveAccount("google-antigravity", ids.a); + recordAntigravityCooldown(ids.a, "60", 1000); + expect(resolveAntigravityAccountForSession("new-conversation", 2000)).toMatchObject({ accountId: ids.a, reason: "active-cooled" }); + }); + + test("short retry-after is retryable once and longer cooldowns are not", () => { + expect(retryableAntigravity429DelayMs("5", 1000)).toBe(5000); + expect(retryableAntigravity429DelayMs("5.1", 1000)).toBeNull(); + expect(retryableAntigravity429DelayMs("60", 1000)).toBeNull(); + expect(retryableAntigravity429DelayMs(null, 1000)).toBeNull(); + }); +}); diff --git a/tests/antigravity-session-affinity.test.ts b/tests/antigravity-session-affinity.test.ts new file mode 100644 index 0000000000..502dda18cf --- /dev/null +++ b/tests/antigravity-session-affinity.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + antigravitySessionAffinitySizeForTests, + clearAntigravityRoutingState, + recordAntigravitySyntheticFailure, +} from "../src/oauth/antigravity-routing"; + +afterEach(() => clearAntigravityRoutingState()); + +describe("google antigravity failure health", () => { + test("HTTP-200 SSE quota and geoblock errors record account cooldowns", () => { + expect(recordAntigravitySyntheticFailure("account-a", { error: { code: "RESOURCE_EXHAUSTED", message: "quota exceeded" } }, 1000)).toBe("quota"); + expect(recordAntigravitySyntheticFailure("account-b", { error: { code: "PERMISSION_DENIED", message: "location is not supported" } }, 2000)).toBe("geoblock"); + expect(recordAntigravitySyntheticFailure("account-c", { error: { code: "RESOURCE_EXHAUSTED", message: "rate limit exceeded" } }, 3000)).toBe("rate-limit"); + }); + + test("unrelated SSE payloads do not create routing state", () => { + expect(recordAntigravitySyntheticFailure("account-a", { type: "response.output_text.delta", delta: "ok" }, 1000)).toBeNull(); + expect(antigravitySessionAffinitySizeForTests()).toBe(0); + }); +}); diff --git a/tests/google-antigravity-oauth.test.ts b/tests/google-antigravity-oauth.test.ts index 34938d5eeb..4b14a9de9d 100644 --- a/tests/google-antigravity-oauth.test.ts +++ b/tests/google-antigravity-oauth.test.ts @@ -143,6 +143,25 @@ describe("antigravity refresh", () => { expect(caught!.message).toContain("400"); expect(caught!.message).not.toContain("secret-detail"); }); + + test("refresh preserves an existing project id without another CCA discovery request", async () => { + const calls: string[] = []; + routeFetch((url) => { + calls.push(url); + if (url.includes("oauth2.googleapis.com/token")) { + return new Response(JSON.stringify({ access_token: "fresh-access", expires_in: 3600 }), { status: 200 }); + } + return new Response(JSON.stringify({ cloudaicompanionProject: "unexpected" }), { status: 200 }); + }); + const credential = await refreshAntigravityToken("refresh-tok", undefined, { + access: "old-access", + refresh: "refresh-tok", + expires: 0, + projectId: "existing-project", + }); + expect(credential.projectId).toBe("existing-project"); + expect(calls.filter(url => url.includes(":loadCodeAssist")).length).toBe(0); + }); }); describe("antigravity credential persistence (projectId survives the store)", () => { From 0953cd145832d575970b72715bc4a54da9dd1bf0 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:50:09 -0600 Subject: [PATCH 05/29] fix: enforce antigravity retry boundaries --- src/adapters/base.ts | 2 + src/adapters/google-http.ts | 8 +- src/adapters/google.ts | 28 ++++- src/oauth/antigravity-routing.ts | 54 ++++++-- src/oauth/google-antigravity.ts | 35 +++++- src/oauth/index.ts | 5 +- src/oauth/store.ts | 4 + src/server/responses/core.ts | 15 ++- tests/antigravity-responses.test.ts | 140 +++++++++++++++++++++ tests/antigravity-session-affinity.test.ts | 17 ++- tests/google-adapter.test.ts | 46 +++++++ tests/google-antigravity-oauth.test.ts | 37 +++++- tests/google-vertex-http.test.ts | 10 ++ tests/oauth-store-multi.test.ts | 13 ++ 14 files changed, 383 insertions(+), 31 deletions(-) create mode 100644 tests/antigravity-responses.test.ts diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 2cd481dfba..da5c4b9f8e 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -19,6 +19,8 @@ export interface IncomingMeta { * anthropic adapter consumes it; others ignore it. */ imageTierBias?: number; + /** Provider-scoped structured error observation; never receives ordinary model payloads. */ + onProviderError?: (error: { code?: string; status?: number; message?: string }) => void; } export interface ProviderAdapter { diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index f7b90de87e..12f3775350 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -17,6 +17,8 @@ const GOOGLE_RETRY_MAX_MS = 2_000; export interface GoogleRetryOptions { /** Repair-and-replay structurally invalid 400 bodies (Vertex/Antigravity behavior). */ repairInvalid400?: boolean; + /** Let the Responses layer own Antigravity's single same-account 429 replay. */ + retry429?: boolean; } async function normalizeFinalGoogleError(label: string, res: Response, signal?: AbortSignal): Promise { @@ -40,6 +42,7 @@ export async function fetchGoogleWithRetry( opts: GoogleRetryOptions = {}, ): Promise { const repairInvalid400 = opts.repairInvalid400 ?? true; + const retry429 = opts.retry429 ?? true; const timeoutMs = ctx.timeoutMs ?? 200_000; const executor = ctx.executor ?? globalThis.fetch; let lastError: unknown; @@ -69,6 +72,9 @@ export async function fetchGoogleWithRetry( continue; } } + if (res.status === 429 && !retry429) { + return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal); + } if (!retryableGoogleStatus(res.status) || attempt === GOOGLE_RETRY_ATTEMPTS - 1) { return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal); } @@ -124,5 +130,5 @@ export function fetchVertexWithRetry(request: AdapterRequest, ctx: AdapterFetchC /** Antigravity (Cloud Code Assist) retry wrapper. */ export function fetchAntigravityWithRetry(request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise { - return fetchGoogleWithRetry("Antigravity", request, ctx); + return fetchGoogleWithRetry("Antigravity", request, ctx, { retry429: false }); } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 92fa2a81fe..a5430ff015 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,4 +1,4 @@ -import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; +import type { AdapterFetchContext, AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import { debugDroppedFrame } from "../lib/debug"; import { createToolCallIdAllocator } from "./tool-call-id"; import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE, artifactHttpUrl } from "../images/artifacts"; @@ -34,6 +34,7 @@ import { } from "../lib/translator-budget"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort"; +import { normalizeAntigravityProviderError } from "../oauth/antigravity-routing"; // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between // tool calls. This steers them to keep the BETWEEN-STEP text to one line and reason internally @@ -604,6 +605,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // can stash the CCA model/session for parseStream's reasoning-replay observation. let antigravityModel: string | undefined; let antigravitySession: string | undefined; + let observeProviderError: ((error: { code?: string; status?: number; message?: string }) => void) | undefined; // Vertex returns the same opaque Gemini thought signatures as CCA, but its replay namespace // must stay transport-scoped: a signature minted by one Google backend must never be sent to // another merely because the public model id and first prompt happen to match. @@ -626,7 +628,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } : {}), - async buildRequest(parsed: OcxParsedRequest) { + async buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta) { + observeProviderError = provider.googleMode === "cloud-code-assist" ? incoming?.onProviderError : undefined; const routedModelId = provider.googleMode === "cloud-code-assist" ? resolveAntigravityEffortWireModel( parsed.modelId, @@ -862,7 +865,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // Inline provider error inside a 200 stream → terminal error (see openai-chat.ts). if (chunk.error) { - const err = chunk.error as { message?: string } | undefined; + const error = normalizeAntigravityProviderError(chunk.error); + if (provider.googleMode === "cloud-code-assist" && error) observeProviderError?.(error); + const err = error ?? { message: "upstream error" }; // Clear-on-invalid: a signature rejection means our replayed thoughtSignatures are stale. // Drop the cache entry so the next turn starts clean instead of re-injecting a bad sig. const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; @@ -872,7 +877,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte && /signature|invalid_argument|invalid argument/i.test(err?.message ?? "")) { clearAntigravityReplay(replayModel, replaySession); } - yield { type: "error", message: err?.message ?? "upstream error" }; + yield { + type: "error", + ...(error?.status !== undefined ? { status: error.status } : {}), + ...(error?.code ? { code: error.code } : {}), + message: err.message ?? "upstream error", + }; return "terminate"; } @@ -1174,8 +1184,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return events; }; if (raw.error) { - const err = raw.error as { message?: string }; - return finish([{ type: "error", message: err.message ?? "upstream error" }]); + const error = normalizeAntigravityProviderError(raw.error); + if (provider.googleMode === "cloud-code-assist" && error) observeProviderError?.(error); + return finish([{ + type: "error", + ...(error?.status !== undefined ? { status: error.status } : {}), + ...(error?.code ? { code: error.code } : {}), + message: error?.message ?? "upstream error", + }]); } // Antigravity (CCA) nests the standard Gemini payload under `response`; unwrap it. let json = raw; diff --git a/src/oauth/antigravity-routing.ts b/src/oauth/antigravity-routing.ts index 1d9be2c110..34231fea13 100644 --- a/src/oauth/antigravity-routing.ts +++ b/src/oauth/antigravity-routing.ts @@ -13,6 +13,12 @@ const MAX_AFFINITY_COMPONENT_LENGTH = 128; type CooldownSource = "retry-after" | "default" | "synthetic"; export type AntigravitySyntheticFailure = "rate-limit" | "quota" | "geoblock"; +export interface AntigravityProviderError { + code?: string; + status?: number; + message?: string; +} + interface AccountHealth { cooldownUntil: number; cooldownSource: CooldownSource; @@ -190,8 +196,37 @@ export function resolveAntigravityAccountForSession( }; } -function failureText(payload: unknown): string { - try { return JSON.stringify(payload).toLowerCase(); } catch { return ""; } +const RATE_LIMIT_CODES = new Set(["RATE_LIMIT_EXCEEDED", "TOO_MANY_REQUESTS"]); +const QUOTA_CODES = new Set(["QUOTA_EXCEEDED", "RESOURCE_EXHAUSTED"]); +const GEO_CODES = new Set(["LOCATION_NOT_SUPPORTED", "REGION_NOT_SUPPORTED", "GEO_BLOCKED"]); + +export function normalizeAntigravityProviderError(value: unknown): AntigravityProviderError | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const code = typeof record.code === "string" ? record.code.trim().toUpperCase() : undefined; + const status = typeof record.status === "number" && Number.isInteger(record.status) ? record.status : undefined; + const message = typeof record.message === "string" ? record.message.trim().slice(0, 512) : undefined; + if (!code && status === undefined) return null; + return { + ...(code ? { code } : {}), + ...(status !== undefined ? { status } : {}), + ...(message ? { message } : {}), + }; +} + +export function classifyAntigravityProviderError(value: unknown): AntigravitySyntheticFailure | null { + const error = normalizeAntigravityProviderError(value); + if (!error) return null; + const message = error.message?.toLowerCase() ?? ""; + if (QUOTA_CODES.has(error.code ?? "") && /quota|resource[ _-]?exhausted/.test(message)) return "quota"; + if (error.status === 429 || RATE_LIMIT_CODES.has(error.code ?? "") || (error.code === "RESOURCE_EXHAUSTED" && /rate[- ]limit|too many requests/.test(message))) { + return "rate-limit"; + } + if (GEO_CODES.has(error.code ?? "") || ( + (error.status === 403 || error.code === "PERMISSION_DENIED") + && /(?:location|country|region).{0,48}(?:not supported|unavailable|blocked|forbidden)|(?:not supported|unavailable|blocked|forbidden).{0,48}(?:location|country|region)/.test(message) + )) return "geoblock"; + return null; } export function recordAntigravitySyntheticFailure( @@ -199,22 +234,15 @@ export function recordAntigravitySyntheticFailure( payload: unknown, now = Date.now(), ): AntigravitySyntheticFailure | null { - const text = failureText(payload); - const geo = text.includes("geoblock") - || text.includes("location") - || text.includes("country") - || text.includes("region is not supported") - || text.includes("not available in your region"); - if (geo) { + const failure = classifyAntigravityProviderError(payload); + if (failure === "geoblock") { accountHealth.set(accountId, { cooldownUntil: now + MAX_COOLDOWN_MS, cooldownSource: "synthetic" }); return "geoblock"; } - const rateLimit = text.includes("rate limit") || text.includes("ratelimit") || text.includes("too many requests"); - const quota = text.includes("quota") || text.includes("resource_exhausted") || text.includes("resource exhausted"); - if (!rateLimit && !quota) return null; + if (!failure) return null; accountHealth.set(accountId, { cooldownUntil: now + DEFAULT_COOLDOWN_MS, cooldownSource: "synthetic", }); - return rateLimit ? "rate-limit" : "quota"; + return failure; } diff --git a/src/oauth/google-antigravity.ts b/src/oauth/google-antigravity.ts index 7343525778..e2e68367b8 100644 --- a/src/oauth/google-antigravity.ts +++ b/src/oauth/google-antigravity.ts @@ -51,6 +51,28 @@ interface GoogleTokenPayload { id_token?: unknown; } +const TERMINAL_OAUTH_ERRORS = new Set([ + "access_denied", + "expired_token", + "invalid_grant", + "refresh_token_reused", + "refresh_token_revoked", + "revoked", + "revoked_token", +]); + +export class AntigravityTokenRequestError extends Error { + readonly httpStatus: number; + readonly oauthError?: string; + + constructor(httpStatus: number, oauthError?: string) { + super(`Antigravity token request failed: ${httpStatus}`); + this.name = "AntigravityTokenRequestError"; + this.httpStatus = httpStatus; + if (oauthError) this.oauthError = oauthError; + } +} + function decodeJwtPayload(token: string): Record | undefined { const part = token.split(".")[1]; if (!part) return undefined; @@ -75,8 +97,17 @@ async function postToken(body: Record, signal?: AbortSignal): Pr signal: requestSignal(signal), }); if (!response.ok) { - // Status only — the body can carry grant/account details. - throw new Error(`Antigravity token request failed: ${response.status}`); + let oauthError: string | undefined; + if (response.status === 400 || response.status === 401) { + try { + const body = await response.json() as { error?: unknown }; + const candidate = typeof body.error === "string" ? body.error.trim().toLowerCase() : ""; + if (TERMINAL_OAUTH_ERRORS.has(candidate)) oauthError = candidate; + } catch { + /* Keep status-only errors for malformed or non-JSON responses. */ + } + } + throw new AntigravityTokenRequestError(response.status, oauthError); } return (await response.json()) as GoogleTokenPayload; } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index cb7218a285..f997d5080b 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -10,7 +10,7 @@ import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthr import { loginKimi, refreshKimiToken } from "./kimi"; import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous"; import { loginChatGPT, refreshChatGPTToken } from "./chatgpt"; -import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; +import { AntigravityTokenRequestError, loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { loginCommandCode, refreshCommandCodeToken } from "./command-code"; @@ -510,6 +510,9 @@ function isTerminalRefreshError(err: unknown): boolean { || msg.includes("expired_token"); } function terminal(error:unknown):boolean{ + if (error instanceof AntigravityTokenRequestError) { + return (error.httpStatus === 400 || error.httpStatus === 401) && error.oauthError !== undefined; + } if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??""); if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??""); if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined; diff --git a/src/oauth/store.ts b/src/oauth/store.ts index ed2d3bf87f..d834eff39b 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -603,6 +603,10 @@ export async function removeCredential(provider: string): Promise { } set.activeAccountId = set.accounts[0]!.id; }, [provider]); + if (provider === "google-antigravity") { + const { clearAntigravityRoutingState } = await import("./antigravity-routing"); + clearAntigravityRoutingState(); + } } // --------------------------------------------------------------------------- diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 71549c5099..ffa071778a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2655,6 +2655,11 @@ async function handleResponsesInner( let antigravityAccountId: string | null = null; let antigravitySessionKey: string | null = null; let antigravity429RetryAttempted = false; + const observeAntigravityProviderError = isAntigravityOAuth + ? (error: { code?: string; status?: number; message?: string }) => { + if (antigravityAccountId) recordAntigravitySyntheticFailure(antigravityAccountId, error); + } + : undefined; const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth" ? anthropicSessionKeyFromParts({ sessionIdHeader: sessionIdHeaderFromRequest(req.headers), @@ -3272,6 +3277,7 @@ async function handleResponsesInner( request = await retryAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget, + ...(observeAntigravityProviderError ? { onProviderError: observeAntigravityProviderError } : {}), }); refreshRoutedNamespaceToolAliases(request); recordAdapterReasoning(logCtx, request); @@ -3393,6 +3399,7 @@ async function handleResponsesInner( request = await refreshedAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget, + ...(observeAntigravityProviderError ? { onProviderError: observeAntigravityProviderError } : {}), }); refreshRoutedNamespaceToolAliases(request); recordAdapterReasoning(logCtx, request); @@ -4586,7 +4593,11 @@ async function handleResponsesInner( let initialRequest: AdapterRequest | undefined; let inputTokenEstimate: number | undefined; try { - initialRequest = await activeAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); + initialRequest = await activeAdapter.buildRequest(parsed, { + headers: selectedForwardHeaders, + translatorBudget, + ...(observeAntigravityProviderError ? { onProviderError: observeAntigravityProviderError } : {}), + }); refreshRoutedNamespaceToolAliases(initialRequest); recordAdapterReasoning(logCtx, initialRequest); recordAdapterTier(logCtx, initialRequest); @@ -4703,6 +4714,7 @@ async function handleResponsesInner( headers: selectedForwardHeaders, translatorBudget, ...(imageTierBias > 0 ? { imageTierBias } : {}), + ...(observeAntigravityProviderError ? { onProviderError: observeAntigravityProviderError } : {}), }); recordAdapterReasoning(logCtx, retryRequest); recordAdapterTier(logCtx, retryRequest); @@ -5094,6 +5106,7 @@ async function handleResponsesInner( headers: selectedForwardHeaders, translatorBudget, ...(imageTierBias > 0 ? { imageTierBias } : {}), + ...(observeAntigravityProviderError ? { onProviderError: observeAntigravityProviderError } : {}), }); recordAdapterReasoning(logCtx, continuationRequest); recordAdapterTier(logCtx, continuationRequest); diff --git a/tests/antigravity-responses.test.ts b/tests/antigravity-responses.test.ts new file mode 100644 index 0000000000..b94aa9dd81 --- /dev/null +++ b/tests/antigravity-responses.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleResponses } from "../src/server/responses"; +import type { RequestLogContext } from "../src/server/request-log"; +import { OAUTH_PROVIDERS } from "../src/oauth"; +import { saveCredential } from "../src/oauth/store"; +import { clearAntigravityRoutingState, getAntigravityAccountHealthSnapshot } from "../src/oauth/antigravity-routing"; +import { getAccountSet } from "../src/oauth/store"; +import type { OcxConfig } from "../src/types"; + +const originalFetch = globalThis.fetch; +let home = ""; + +function config(): OcxConfig { + return { + port: 0, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + ...structuredClone(OAUTH_PROVIDERS["google-antigravity"]!.providerConfig), + liveModels: false, + defaultModel: "gemini-3.7-flash", + project: "test-project", + }, + }, + } as OcxConfig; +} + +function request(stream = false, headers: Record = {}, signal?: AbortSignal): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ model: "gemini-3.7-flash", input: "hello", stream }), + signal, + }); +} + +function completed(): Response { + return Response.json({ response: { candidates: [{ content: { parts: [{ text: "hello" }] }, finishReason: "STOP" }] } }); +} + +beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), "ocx-antigravity-responses-")); + process.env.OPENCODEX_HOME = home; + clearAntigravityRoutingState(); + await saveCredential("google-antigravity", { + access: "access-a", + refresh: "refresh-a", + expires: Date.now() + 3600_000, + projectId: "project-a", + }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearAntigravityRoutingState(); + rmSync(home, { recursive: true, force: true }); + delete process.env.OPENCODEX_HOME; +}); + +describe("Antigravity Responses integration", () => { + test("selects the Google adapter and observes a CCA SSE error before returning it", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push(String(input)); + return new Response(`data: ${JSON.stringify({ error: { code: "RESOURCE_EXHAUSTED", status: 429, message: "quota exceeded" } })}\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(request(true), config(), logCtx, {}); + expect(response.status).toBe(200); + expect(seen).toHaveLength(1); + expect(await response.text()).toContain("quota exceeded"); + const accountId = getAccountSet("google-antigravity")!.activeAccountId; + expect(getAntigravityAccountHealthSnapshot(accountId)?.cooldownSource).toBe("synthetic"); + }); + + test("replays a short 429 once through the same Google adapter", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return calls === 1 + ? new Response("rate limited", { status: 429, headers: { "retry-after": "1" } }) + : completed(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(request(), config(), logCtx, {}); + expect(response.status).toBe(200); + expect(calls).toBe(2); + }, 15_000); + + test("replays one 401 with the selected account generation", async () => { + await saveCredential("google-antigravity", { + access: "access-b", + refresh: "refresh-b", + expires: Date.now() + 3600_000, + projectId: "project-b", + email: "b@example.test", + accountId: "account-b", + }); + let inferenceCalls = 0; + const bearers: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "https://oauth2.googleapis.com/token") { + return Response.json({ access_token: "fresh-b", refresh_token: "refresh-b", expires_in: 3600 }); + } + inferenceCalls += 1; + bearers.push(new Headers(init?.headers).get("authorization") ?? ""); + return inferenceCalls === 1 ? new Response("expired", { status: 401 }) : completed(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(request(), config(), logCtx, {}); + expect(response.status).toBe(200); + expect(bearers).toEqual(["Bearer access-b", "Bearer fresh-b"]); + expect(inferenceCalls).toBe(2); + }, 15_000); + + test("cancels the bounded 429 wait before dispatching a replay", async () => { + const controller = new AbortController(); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response("rate limited", { status: 429, headers: { "retry-after": "5" } }); + }) as typeof fetch; + setTimeout(() => controller.abort(), 500); + const logCtx: RequestLogContext = { model: "", provider: "" }; + const testConfig = config(); + testConfig.providers["google-antigravity"]!.requestPacing = { enabled: false }; + const response = await handleResponses(request(false, {}, controller.signal), testConfig, logCtx, { + abortSignal: controller.signal, + }); + expect(response.status).toBe(499); + expect(calls).toBe(1); + }, 10_000); +}); diff --git a/tests/antigravity-session-affinity.test.ts b/tests/antigravity-session-affinity.test.ts index 502dda18cf..16e2394cf4 100644 --- a/tests/antigravity-session-affinity.test.ts +++ b/tests/antigravity-session-affinity.test.ts @@ -9,13 +9,20 @@ afterEach(() => clearAntigravityRoutingState()); describe("google antigravity failure health", () => { test("HTTP-200 SSE quota and geoblock errors record account cooldowns", () => { - expect(recordAntigravitySyntheticFailure("account-a", { error: { code: "RESOURCE_EXHAUSTED", message: "quota exceeded" } }, 1000)).toBe("quota"); - expect(recordAntigravitySyntheticFailure("account-b", { error: { code: "PERMISSION_DENIED", message: "location is not supported" } }, 2000)).toBe("geoblock"); - expect(recordAntigravitySyntheticFailure("account-c", { error: { code: "RESOURCE_EXHAUSTED", message: "rate limit exceeded" } }, 3000)).toBe("rate-limit"); + expect(recordAntigravitySyntheticFailure("account-a", { code: "RESOURCE_EXHAUSTED", status: 429, message: "quota exceeded" }, 1000)).toBe("quota"); + expect(recordAntigravitySyntheticFailure("account-b", { code: "PERMISSION_DENIED", status: 403, message: "location is not supported" }, 2000)).toBe("geoblock"); + expect(recordAntigravitySyntheticFailure("account-c", { code: "RESOURCE_EXHAUSTED", status: 429, message: "rate limit exceeded" }, 3000)).toBe("rate-limit"); }); - test("unrelated SSE payloads do not create routing state", () => { - expect(recordAntigravitySyntheticFailure("account-a", { type: "response.output_text.delta", delta: "ok" }, 1000)).toBeNull(); + test("ordinary text mentioning failure words does not create routing state", () => { + for (const message of ["location", "country", "region", "quota", "rate limit"]) { + expect(recordAntigravitySyntheticFailure("account-a", { type: "text", message }, 1000)).toBeNull(); + } expect(antigravitySessionAffinitySizeForTests()).toBe(0); }); + + test("unstructured error text is ignored without an allowlisted code or status", () => { + expect(recordAntigravitySyntheticFailure("account-a", { message: "location is not supported" }, 1000)).toBeNull(); + expect(recordAntigravitySyntheticFailure("account-a", { code: "SOME_NEW_CODE", status: 499, message: "quota exceeded" }, 1000)).toBeNull(); + }); }); diff --git a/tests/google-adapter.test.ts b/tests/google-adapter.test.ts index 15af3a264a..efadbc69b9 100644 --- a/tests/google-adapter.test.ts +++ b/tests/google-adapter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createGoogleAdapter } from "../src/adapters/google"; import type { OcxParsedRequest } from "../src/types"; +import { createTranslatorBudget } from "../src/lib/translator-budget"; const provider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" }; @@ -82,6 +83,51 @@ describe("google adapter — tool result images", () => { }); }); +describe("google adapter — Antigravity structured stream errors", () => { + test("observes only a structured CCA error before emitting the adapter error", async () => { + const adapter = createGoogleAdapter({ + adapter: "google", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "key", + project: "project", + } as never); + const observed: unknown[] = []; + const parsed = parsedWith([{ role: "user", content: "hello" }]); + parsed.stream = true; + await adapter.buildRequest(parsed, { + headers: new Headers(), + translatorBudget: createTranslatorBudget(), + onProviderError: error => observed.push(error), + }); + const body = `data: ${JSON.stringify({ error: { code: "RESOURCE_EXHAUSTED", status: 429, message: "quota exceeded" } })}\n\n`; + const events = []; + for await (const event of adapter.parseStream(new Response(body), createTranslatorBudget())) events.push(event); + expect(observed).toEqual([{ code: "RESOURCE_EXHAUSTED", status: 429, message: "quota exceeded" }]); + expect(events[0]).toMatchObject({ type: "error", status: 429, code: "RESOURCE_EXHAUSTED" }); + }); + + test("observes a buffered CCA error before returning the adapter error", async () => { + const adapter = createGoogleAdapter({ + adapter: "google", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "key", + project: "project", + } as never); + const observed: unknown[] = []; + const parsed = parsedWith([{ role: "user", content: "hello" }]); + await adapter.buildRequest(parsed, { + headers: new Headers(), + translatorBudget: createTranslatorBudget(), + onProviderError: error => observed.push(error), + }); + const events = await adapter.parseResponse(new Response(JSON.stringify({ error: { code: "PERMISSION_DENIED", status: 403, message: "region is not supported" } })), createTranslatorBudget()); + expect(observed).toEqual([{ code: "PERMISSION_DENIED", status: 403, message: "region is not supported" }]); + expect(events[0]).toMatchObject({ type: "error", status: 403, code: "PERMISSION_DENIED" }); + }); +}); + describe("google adapter — tool-call ids on the wire", () => { test("v2 collaboration encrypted marker never reaches functionDeclarations (issue #85)", async () => { // Codex Desktop v2 stamps `encrypted: true` on collaboration message properties; CCA/Gemini diff --git a/tests/google-antigravity-oauth.test.ts b/tests/google-antigravity-oauth.test.ts index 4b14a9de9d..bc00f4cec3 100644 --- a/tests/google-antigravity-oauth.test.ts +++ b/tests/google-antigravity-oauth.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { discoverAntigravityProject, refreshAntigravityToken } from "../src/oauth/google-antigravity"; +import { AntigravityTokenRequestError, discoverAntigravityProject, refreshAntigravityToken } from "../src/oauth/google-antigravity"; import { mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getCredential, saveCredential } from "../src/oauth/store"; +import { getAccountSet, getCredential, saveCredential } from "../src/oauth/store"; +import { getValidAccessTokenForAccount } from "../src/oauth"; import { ANTIGRAVITY_IDE_VERSION } from "../src/adapters/client-fingerprint"; const realFetch = globalThis.fetch; @@ -144,6 +145,20 @@ describe("antigravity refresh", () => { expect(caught!.message).not.toContain("secret-detail"); }); + test("preserves only an allowlisted terminal OAuth code", async () => { + routeFetch((url) => url.includes("oauth2.googleapis.com/token") + ? new Response(JSON.stringify({ error: "invalid_grant", error_description: "private detail" }), { status: 400 }) + : new Response("no", { status: 404 })); + await expect(refreshAntigravityToken("refresh-tok")).rejects.toMatchObject({ + constructor: AntigravityTokenRequestError, + httpStatus: 400, + oauthError: "invalid_grant", + }); + try { await refreshAntigravityToken("refresh-tok"); } catch (error) { + expect((error as Error).message).not.toContain("private detail"); + } + }); + test("refresh preserves an existing project id without another CCA discovery request", async () => { const calls: string[] = []; routeFetch((url) => { @@ -183,4 +198,22 @@ describe("antigravity credential persistence (projectId survives the store)", () await saveCredential("google-antigravity", { access: "a", refresh: "r", expires: Date.now() + 3_600_000, projectId: "proj-persist" }); expect(getCredential("google-antigravity")?.projectId).toBe("proj-persist"); }); + + test("terminal refresh marks only the selected Antigravity account needsReauth", async () => { + tmp = join(tmpdir(), `ag-store-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(tmp, { recursive: true }); + process.env.HOME = tmp; + process.env.OPENCODEX_HOME = join(tmp, "ocx"); + await saveCredential("google-antigravity", { access: "a", refresh: "refresh-a", expires: 0, accountId: "acct-a" }); + await saveCredential("google-antigravity", { access: "b", refresh: "refresh-b", expires: Date.now() + 3_600_000, accountId: "acct-b" }); + const set = getAccountSet("google-antigravity")!; + const accountA = set.accounts.find(account => account.credential.accountId === "acct-a")!; + routeFetch(url => url.includes("oauth2.googleapis.com/token") + ? new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }) + : new Response("no", { status: 404 })); + await expect(getValidAccessTokenForAccount("google-antigravity", accountA.id)).rejects.toThrow(); + const after = getAccountSet("google-antigravity")!; + expect(after.accounts.find(account => account.credential.accountId === "acct-a")?.needsReauth).toBe(true); + expect(after.accounts.find(account => account.credential.accountId === "acct-b")?.needsReauth).not.toBe(true); + }); }); diff --git a/tests/google-vertex-http.test.ts b/tests/google-vertex-http.test.ts index 496fb56a1a..224d7144f0 100644 --- a/tests/google-vertex-http.test.ts +++ b/tests/google-vertex-http.test.ts @@ -125,6 +125,16 @@ describe("vertex retry fetch", () => { expect(rate.calls).toHaveLength(2); }); + test("Antigravity leaves the single 429 retry budget to Responses recovery", async () => { + const mock = mockFetch([ + new Response(vertexError(429, "RESOURCE_EXHAUSTED", "rate limit, try again"), { status: 429, headers: { "Retry-After": "1" } }), + new Response("unexpected replay", { status: 200 }), + ]); + const res = await fetchAntigravityWithRetry(request, { timeoutMs: 5_000 }); + expect(res.status).toBe(429); + expect(mock.calls).toHaveLength(1); + }); + test("does not retry a non-retryable 400 and classifies the body", async () => { const mock = mockFetch([new Response(vertexError(400, "INVALID_ARGUMENT", "bad model"), { status: 400 })]); const res = await fetchVertexWithRetry(request, { timeoutMs: 5_000 }); diff --git a/tests/oauth-store-multi.test.ts b/tests/oauth-store-multi.test.ts index 50893daedc..92a67f665c 100644 --- a/tests/oauth-store-multi.test.ts +++ b/tests/oauth-store-multi.test.ts @@ -26,6 +26,7 @@ import { setActiveAccount, } from "../src/oauth/store"; import type { OAuthCredentials } from "../src/oauth/types"; +import { bindAntigravitySessionAffinity, clearAntigravityRoutingState, resolveAntigravityAccountForSession } from "../src/oauth/antigravity-routing"; const TEST_DIR = join(import.meta.dir, ".tmp-oauth-store-multi-test"); let previousOpencodexHome: string | undefined; @@ -281,6 +282,18 @@ describe("multi-account auth store", () => { expect(getCredential("anthropic")?.access).toBe("access-a"); }); + test("removing the active Antigravity account clears affinity while leaving the next account available", async () => { + await saveCredential("google-antigravity", cred({ email: "a@example.com", accountId: "acct-a", access: "access-a" })); + await saveCredential("google-antigravity", cred({ email: "b@example.com", accountId: "acct-b", access: "access-b" })); + const set = getAccountSet("google-antigravity")!; + const active = set.accounts.find(account => account.credential.accountId === "acct-b")!; + bindAntigravitySessionAffinity("conversation", active.id); + await removeCredential("google-antigravity"); + expect(resolveAntigravityAccountForSession("conversation").reason).toBe("active"); + expect(resolveAntigravityAccountForSession("conversation").accountId).toBe(getAccountSet("google-antigravity")!.activeAccountId); + clearAntigravityRoutingState(); + }); + test("needsReauth flag persists and clears on fresh save", async () => { await saveCredential("xai", cred({ email: "a@example.com", accountId: "acct-a" })); const id = getAccountSet("xai")!.activeAccountId; From c312d07198c11386681e0b4e51a5a48b07d18885 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:04:17 -0600 Subject: [PATCH 06/29] fix: preserve antigravity error and affinity safety --- src/adapters/google.ts | 30 +++++- src/oauth/antigravity-routing.ts | 20 +++- src/oauth/store.ts | 8 +- src/server/management/oauth-account-routes.ts | 4 - tests/antigravity-responses.test.ts | 45 ++++++++- tests/antigravity-session-affinity.test.ts | 6 ++ tests/google-adapter.test.ts | 97 +++++++++++++++++++ tests/oauth-store-multi.test.ts | 15 +++ 8 files changed, 210 insertions(+), 15 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index a5430ff015..b21fd3819d 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -19,6 +19,7 @@ import { contentPartsToText, parseDataUrl } from "./image"; import { getVertexAccessToken } from "../lib/gcp-adc"; import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors"; +import { sanitizeUpstreamErrorText } from "./upstream-http-error"; import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-truncation"; import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire"; import { compileGoogleWireBody } from "./google-wire-compiler"; @@ -36,6 +37,15 @@ import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort"; import { normalizeAntigravityProviderError } from "../oauth/antigravity-routing"; +const INLINE_ERROR_URL_USERINFO = /https?:\/\/[^\s"'<>]*@/gi; + +function safeAntigravityInlineErrorMessage(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + return sanitizeUpstreamErrorText(value) + .replace(INLINE_ERROR_URL_USERINFO, "[REDACTED_URL]") + .slice(0, 500); +} + // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between // tool calls. This steers them to keep the BETWEEN-STEP text to one line and reason internally // while still driving tools to completion. The FINAL answer is explicitly exempt so task output is @@ -865,9 +875,15 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // Inline provider error inside a 200 stream → terminal error (see openai-chat.ts). if (chunk.error) { - const error = normalizeAntigravityProviderError(chunk.error); + const rawError = chunk.error as { code?: unknown; status?: unknown; message?: unknown }; + const safeMessage = safeAntigravityInlineErrorMessage(rawError.message); + const error = normalizeAntigravityProviderError({ + code: rawError.code, + status: rawError.status, + message: safeMessage, + }); if (provider.googleMode === "cloud-code-assist" && error) observeProviderError?.(error); - const err = error ?? { message: "upstream error" }; + const err = { ...(error ?? {}), message: error?.message ?? safeMessage ?? "upstream error" }; // Clear-on-invalid: a signature rejection means our replayed thoughtSignatures are stale. // Drop the cache entry so the next turn starts clean instead of re-injecting a bad sig. const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; @@ -1184,13 +1200,19 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return events; }; if (raw.error) { - const error = normalizeAntigravityProviderError(raw.error); + const rawError = raw.error as { code?: unknown; status?: unknown; message?: unknown }; + const safeMessage = safeAntigravityInlineErrorMessage(rawError.message); + const error = normalizeAntigravityProviderError({ + code: rawError.code, + status: rawError.status, + message: safeMessage, + }); if (provider.googleMode === "cloud-code-assist" && error) observeProviderError?.(error); return finish([{ type: "error", ...(error?.status !== undefined ? { status: error.status } : {}), ...(error?.code ? { code: error.code } : {}), - message: error?.message ?? "upstream error", + message: error?.message ?? safeMessage ?? "upstream error", }]); } // Antigravity (CCA) nests the standard Gemini payload under `response`; unwrap it. diff --git a/src/oauth/antigravity-routing.ts b/src/oauth/antigravity-routing.ts index 34231fea13..9229e490ac 100644 --- a/src/oauth/antigravity-routing.ts +++ b/src/oauth/antigravity-routing.ts @@ -108,6 +108,12 @@ export function clearAntigravitySessionAffinityForAccount(accountId: string): vo } } +/** Remove only the local routing state owned by one deleted Antigravity account. */ +export function clearAntigravityRoutingStateForAccount(accountId: string): void { + clearAntigravityAccountCooldown(accountId); + clearAntigravitySessionAffinityForAccount(accountId); +} + export function getAntigravityAccountHealthSnapshot( accountId: string, now = Date.now(), @@ -203,8 +209,15 @@ const GEO_CODES = new Set(["LOCATION_NOT_SUPPORTED", "REGION_NOT_SUPPORTED", "GE export function normalizeAntigravityProviderError(value: unknown): AntigravityProviderError | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const record = value as Record; - const code = typeof record.code === "string" ? record.code.trim().toUpperCase() : undefined; - const status = typeof record.status === "number" && Number.isInteger(record.status) ? record.status : undefined; + // Google JSON errors use `{ code: , status: , message }`. + // Keep accepting the internal `{ code: , status: }` shape for + // existing callers and fixtures, but normalize both to one observer contract. + const numericCode = typeof record.code === "number" && Number.isInteger(record.code) ? record.code : undefined; + const numericStatus = typeof record.status === "number" && Number.isInteger(record.status) ? record.status : undefined; + const enumCode = typeof record.code === "string" ? record.code.trim().toUpperCase() : undefined; + const enumStatus = typeof record.status === "string" ? record.status.trim().toUpperCase() : undefined; + const code = enumStatus ?? enumCode; + const status = numericCode ?? numericStatus; const message = typeof record.message === "string" ? record.message.trim().slice(0, 512) : undefined; if (!code && status === undefined) return null; return { @@ -218,7 +231,8 @@ export function classifyAntigravityProviderError(value: unknown): AntigravitySyn const error = normalizeAntigravityProviderError(value); if (!error) return null; const message = error.message?.toLowerCase() ?? ""; - if (QUOTA_CODES.has(error.code ?? "") && /quota|resource[ _-]?exhausted/.test(message)) return "quota"; + if ((QUOTA_CODES.has(error.code ?? "") || error.code === "RESOURCE_EXHAUSTED") + && /quota|resource[ _-]?exhausted/.test(message)) return "quota"; if (error.status === 429 || RATE_LIMIT_CODES.has(error.code ?? "") || (error.code === "RESOURCE_EXHAUSTED" && /rate[- ]limit|too many requests/.test(message))) { return "rate-limit"; } diff --git a/src/oauth/store.ts b/src/oauth/store.ts index d834eff39b..67fc804884 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -593,9 +593,11 @@ export async function upsertCredentialByIdentity( /** Remove the ACTIVE account; remaining accounts promote the first one. */ export async function removeCredential(provider: string): Promise { + let removedAccountId: string | undefined; await mutateStore(store => { const set = store[provider]; if (!set) return; + removedAccountId = set.activeAccountId; set.accounts = set.accounts.filter(a => a.id !== set.activeAccountId); if (set.accounts.length === 0) { delete store[provider]; @@ -603,9 +605,9 @@ export async function removeCredential(provider: string): Promise { } set.activeAccountId = set.accounts[0]!.id; }, [provider]); - if (provider === "google-antigravity") { - const { clearAntigravityRoutingState } = await import("./antigravity-routing"); - clearAntigravityRoutingState(); + if (provider === "google-antigravity" && removedAccountId) { + const { clearAntigravityRoutingStateForAccount } = await import("./antigravity-routing"); + clearAntigravityRoutingStateForAccount(removedAccountId); } } diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index f114a25b77..454a9b6dc5 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -223,10 +223,6 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); await removeCredential(provider); - if (provider === "google-antigravity") { - const { clearAntigravityRoutingState } = await import("../../oauth/antigravity-routing"); - clearAntigravityRoutingState(); - } reconcileLiveStateStores(); clearLoginState(provider); const { clearModelCache } = await import("../../codex/model-cache"); diff --git a/tests/antigravity-responses.test.ts b/tests/antigravity-responses.test.ts index b94aa9dd81..bccfb8c159 100644 --- a/tests/antigravity-responses.test.ts +++ b/tests/antigravity-responses.test.ts @@ -41,6 +41,12 @@ function completed(): Response { return Response.json({ response: { candidates: [{ content: { parts: [{ text: "hello" }] }, finishReason: "STOP" }] } }); } +function fastConfig(): OcxConfig { + const value = config(); + value.providers["google-antigravity"]!.requestPacing = { enabled: false }; + return value; +} + beforeEach(async () => { home = mkdtempSync(join(tmpdir(), "ocx-antigravity-responses-")); process.env.OPENCODEX_HOME = home; @@ -65,7 +71,7 @@ describe("Antigravity Responses integration", () => { const seen: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { seen.push(String(input)); - return new Response(`data: ${JSON.stringify({ error: { code: "RESOURCE_EXHAUSTED", status: 429, message: "quota exceeded" } })}\n\n`, { + return new Response(`data: ${JSON.stringify({ error: { code: 429, status: "RESOURCE_EXHAUSTED", message: "quota exceeded" } })}\n\n`, { status: 200, headers: { "content-type": "text/event-stream" }, }); @@ -79,6 +85,43 @@ describe("Antigravity Responses integration", () => { expect(getAntigravityAccountHealthSnapshot(accountId)?.cooldownSource).toBe("synthetic"); }); + test("records cooldowns for actual Google envelopes in streaming and buffered Responses", async () => { + const cases = [ + { code: 429, status: "RESOURCE_EXHAUSTED", message: "Quota exceeded", source: "quota" }, + { code: 429, status: "RESOURCE_EXHAUSTED", message: "Rate limit exceeded", source: "rate-limit" }, + { code: 403, status: "PERMISSION_DENIED", message: "Location is not supported", source: "geoblock" }, + ] as const; + for (const stream of [true, false]) { + for (const expected of cases) { + clearAntigravityRoutingState(); + globalThis.fetch = (async () => { + const payload = { error: { code: expected.code, status: expected.status, message: expected.message } }; + return stream + ? new Response(`data: ${JSON.stringify(payload)}\n\n`, { status: 200, headers: { "content-type": "text/event-stream" } }) + : Response.json(payload); + }) as typeof fetch; + const response = await handleResponses(request(stream), fastConfig(), { model: "", provider: "" }, {}); + expect(response.status).toBe(200); + const accountId = getAccountSet("google-antigravity")!.activeAccountId; + expect(getAntigravityAccountHealthSnapshot(accountId)?.cooldownSource).toBe("synthetic"); + } + } + }); + + test("serializes a redacted inline error to Responses clients", async () => { + const message = "Bearer secret-token api_key=secret-api https://user:secret-password@example.test/path?api_key=secret-url"; + globalThis.fetch = (async () => new Response( + `data: ${JSON.stringify({ error: { code: 429, status: "RESOURCE_EXHAUSTED", message } })}\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + )) as typeof fetch; + const response = await handleResponses(request(true), fastConfig(), { model: "", provider: "" }, {}); + const serialized = await response.text(); + expect(serialized).not.toContain("secret-token"); + expect(serialized).not.toContain("secret-api"); + expect(serialized).not.toContain("secret-url"); + expect(serialized).not.toContain("secret-password"); + }); + test("replays a short 429 once through the same Google adapter", async () => { let calls = 0; globalThis.fetch = (async () => { diff --git a/tests/antigravity-session-affinity.test.ts b/tests/antigravity-session-affinity.test.ts index 16e2394cf4..5683310030 100644 --- a/tests/antigravity-session-affinity.test.ts +++ b/tests/antigravity-session-affinity.test.ts @@ -25,4 +25,10 @@ describe("google antigravity failure health", () => { expect(recordAntigravitySyntheticFailure("account-a", { message: "location is not supported" }, 1000)).toBeNull(); expect(recordAntigravitySyntheticFailure("account-a", { code: "SOME_NEW_CODE", status: 499, message: "quota exceeded" }, 1000)).toBeNull(); }); + + test("classifies the actual Google numeric-code enum-status envelope", () => { + expect(recordAntigravitySyntheticFailure("account-a", { code: 429, status: "RESOURCE_EXHAUSTED", message: "Quota exceeded" }, 1000)).toBe("quota"); + expect(recordAntigravitySyntheticFailure("account-b", { code: 429, status: "RESOURCE_EXHAUSTED", message: "Rate limit exceeded" }, 1000)).toBe("rate-limit"); + expect(recordAntigravitySyntheticFailure("account-c", { code: 403, status: "PERMISSION_DENIED", message: "Location is not supported" }, 1000)).toBe("geoblock"); + }); }); diff --git a/tests/google-adapter.test.ts b/tests/google-adapter.test.ts index efadbc69b9..82d98f79ee 100644 --- a/tests/google-adapter.test.ts +++ b/tests/google-adapter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createGoogleAdapter } from "../src/adapters/google"; +import { classifyAntigravityProviderError } from "../src/oauth/antigravity-routing"; import type { OcxParsedRequest } from "../src/types"; import { createTranslatorBudget } from "../src/lib/translator-budget"; @@ -126,6 +127,102 @@ describe("google adapter — Antigravity structured stream errors", () => { expect(observed).toEqual([{ code: "PERMISSION_DENIED", status: 403, message: "region is not supported" }]); expect(events[0]).toMatchObject({ type: "error", status: 403, code: "PERMISSION_DENIED" }); }); + + test("normalizes actual Google numeric-code enum-status errors in streaming and buffered paths", async () => { + const cases = [ + { code: 429, status: "RESOURCE_EXHAUSTED", message: "Quota exceeded", failure: "quota" }, + { code: 429, status: "RESOURCE_EXHAUSTED", message: "Rate limit exceeded", failure: "rate-limit" }, + { code: 403, status: "PERMISSION_DENIED", message: "Location is not supported", failure: "geoblock" }, + ] as const; + for (const mode of ["stream", "buffer"] as const) { + for (const expected of cases) { + const adapter = createGoogleAdapter({ + adapter: "google", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "key", + project: "project", + } as never); + const observed: Array<{ code?: string; status?: number; message?: string }> = []; + const parsed = parsedWith([{ role: "user", content: "hello" }]); + parsed.stream = mode === "stream"; + await adapter.buildRequest(parsed, { + headers: new Headers(), + translatorBudget: createTranslatorBudget(), + onProviderError: error => observed.push(error), + }); + const payload = { error: { code: expected.code, status: expected.status, message: expected.message } }; + const events = mode === "stream" + ? await (async () => { + const result = []; + for await (const event of adapter.parseStream(new Response(`data: ${JSON.stringify(payload)}\n\n`), createTranslatorBudget())) result.push(event); + return result; + })() + : await adapter.parseResponse!(new Response(JSON.stringify(payload)), createTranslatorBudget()); + expect(observed).toHaveLength(1); + expect(classifyAntigravityProviderError(observed[0])).toBe(expected.failure); + expect(events[0]).toMatchObject({ type: "error", status: expected.code, code: expected.status }); + } + } + }); + + test("preserves bounded message-only errors for replay invalidation in streaming and buffered paths", async () => { + for (const mode of ["stream", "buffer"] as const) { + const adapter = createGoogleAdapter({ + adapter: "google", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "key", + project: "project", + } as never); + const parsed = parsedWith([{ role: "user", content: "hello" }]); + parsed.stream = mode === "stream"; + await adapter.buildRequest(parsed, { headers: new Headers(), translatorBudget: createTranslatorBudget() }); + const payload = { error: { message: "Invalid argument: signature rejected" } }; + const events = mode === "stream" + ? await (async () => { + const result = []; + for await (const event of adapter.parseStream(new Response(`data: ${JSON.stringify(payload)}\n\n`), createTranslatorBudget())) result.push(event); + return result; + })() + : await adapter.parseResponse!(new Response(JSON.stringify(payload)), createTranslatorBudget()); + expect(events[0]).toMatchObject({ type: "error", message: "Invalid argument: signature rejected" }); + } + }); + + test("redacts secrets from inline streaming and buffered errors before observation", async () => { + const message = "Bearer secret-token api_key=secret-api https://user:secret-password@example.test/path?api_key=secret-url"; + for (const mode of ["stream", "buffer"] as const) { + const adapter = createGoogleAdapter({ + adapter: "google", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "key", + project: "project", + } as never); + const observed: Array<{ message?: string }> = []; + const parsed = parsedWith([{ role: "user", content: "hello" }]); + parsed.stream = mode === "stream"; + await adapter.buildRequest(parsed, { + headers: new Headers(), + translatorBudget: createTranslatorBudget(), + onProviderError: error => observed.push(error), + }); + const payload = { error: { code: 429, status: "RESOURCE_EXHAUSTED", message } }; + const events = mode === "stream" + ? await (async () => { + const result = []; + for await (const event of adapter.parseStream(new Response(`data: ${JSON.stringify(payload)}\n\n`), createTranslatorBudget())) result.push(event); + return result; + })() + : await adapter.parseResponse!(new Response(JSON.stringify(payload)), createTranslatorBudget()); + const serialized = JSON.stringify({ events, observed }); + expect(serialized).not.toContain("secret-token"); + expect(serialized).not.toContain("secret-api"); + expect(serialized).not.toContain("secret-url"); + expect(serialized).not.toContain("secret-password"); + } + }); }); describe("google adapter — tool-call ids on the wire", () => { diff --git a/tests/oauth-store-multi.test.ts b/tests/oauth-store-multi.test.ts index 92a67f665c..2ce555f482 100644 --- a/tests/oauth-store-multi.test.ts +++ b/tests/oauth-store-multi.test.ts @@ -294,6 +294,21 @@ describe("multi-account auth store", () => { clearAntigravityRoutingState(); }); + test("removing an active Antigravity account preserves affinity for a surviving middle account", async () => { + await saveCredential("google-antigravity", cred({ email: "a@example.com", accountId: "acct-a", access: "access-a" })); + await saveCredential("google-antigravity", cred({ email: "b@example.com", accountId: "acct-b", access: "access-b" })); + await saveCredential("google-antigravity", cred({ email: "c@example.com", accountId: "acct-c", access: "access-c" })); + const set = getAccountSet("google-antigravity")!; + const middle = set.accounts.find(account => account.credential.accountId === "acct-b")!; + bindAntigravitySessionAffinity("conversation-middle", middle.id); + await removeCredential("google-antigravity"); // removes c; a becomes active + expect(resolveAntigravityAccountForSession("conversation-middle")).toMatchObject({ + accountId: middle.id, + reason: "affinity", + }); + clearAntigravityRoutingState(); + }); + test("needsReauth flag persists and clears on fresh save", async () => { await saveCredential("xai", cred({ email: "a@example.com", accountId: "acct-a" })); const id = getAccountSet("xai")!.activeAccountId; From 9f49af2a34822165436041ad3885484d00c6378e Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:18:40 -0600 Subject: [PATCH 07/29] feat: add optional antigravity tls profile --- bun.lock | 5 + package.json | 3 + src/config.ts | 10 + src/lib/provider-tls-profile.ts | 187 +++++++++++++++++ src/lib/proxy-env.ts | 49 +++++ src/server/responses/fetch-helpers.ts | 6 +- src/types.ts | 1 + src/types/provider.ts | 4 + structure/04_transports-and-sidecars.md | 6 +- tests/provider-tls-profile.test.ts | 197 ++++++++++++++++++ .../responses-fetch-helpers-boundary.test.ts | 1 + 11 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 src/lib/provider-tls-profile.ts create mode 100644 tests/provider-tls-profile.test.ts diff --git a/bun.lock b/bun.lock index 329084661e..6f21e45a6b 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,9 @@ "@types/bun": "1.4.0", "typescript": "7.0.2", }, + "optionalDependencies": { + "wreq-js": "2.3.1", + }, }, }, "trustedDependencies": [ @@ -309,6 +312,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "wreq-js": ["wreq-js@2.3.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-vaKasaKeskrDKEuuO5Q5uamEG9a6FrF5ZSicH7TCvYS4RxF7/gzaU/vYqwJzcs+uydyJPVWY1KCvfVCgp0tiGA=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], diff --git a/package.json b/package.json index f73ed2d0e5..8cfaab25b4 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,9 @@ "bun": "1.4.0", "zod": "4.4.3" }, + "optionalDependencies": { + "wreq-js": "2.3.1" + }, "devDependencies": { "@types/bun": "1.4.0", "typescript": "7.0.2" diff --git a/src/config.ts b/src/config.ts index 04c1fbaa91..2dd72f2c17 100644 --- a/src/config.ts +++ b/src/config.ts @@ -58,6 +58,7 @@ import { import { recordOwnedConfigPath } from "./lib/config-ownership"; import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { providerDestinationConfigError } from "./lib/destination-policy"; +import { providerTlsProfileConfigError } from "./lib/provider-tls-profile"; import { redactSecretString } from "./lib/redact"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { @@ -481,6 +482,7 @@ const providerConfigSchema = z.object({ adapter: z.string().min(1), baseUrl: z.string().min(1), requestPacing: requestPacingSchema.optional().catch(undefined), + tlsProfile: z.literal("antigravity-browser").optional(), mcpMaxTools: z.number().int().positive().optional(), mcpMaxSchemaBytes: z.number().int().positive().optional(), mcpMaxResultBytes: z.number().int().positive().optional(), @@ -1040,6 +1042,14 @@ const configSchema = z.object({ message: responsesPathError, }); } + const tlsProfileError = providerTlsProfileConfigError(name, provider); + if (tlsProfileError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "tlsProfile"], + message: tlsProfileError, + }); + } const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers); if (headersError) { ctx.addIssue({ diff --git a/src/lib/provider-tls-profile.ts b/src/lib/provider-tls-profile.ts new file mode 100644 index 0000000000..74e0b5f686 --- /dev/null +++ b/src/lib/provider-tls-profile.ts @@ -0,0 +1,187 @@ +import type { OcxProviderConfig, ProviderTlsProfile } from "../types"; +import { registerOptionalShutdownHook } from "./optional-shutdown-hooks"; +import { proxyForUrl } from "./proxy-env"; + +export const ANTIGRAVITY_TLS_HOSTS = new Set([ + "daily-cloudcode-pa.googleapis.com", + "cloudcode-pa.googleapis.com", +]); + +export type ProviderTlsProfileStatus = "disabled" | "active" | "fallback"; + +interface WreqTransport { + close(): Promise; +} + +interface WreqModule { + createTransport(options: { + browser: "chrome_142"; + os: "windows" | "macos" | "linux"; + proxy?: string; + }): Promise; + fetch(input: string | URL | Request, init?: Record): Promise; +} + +export interface ProviderTlsRuntimeForTest { + importWreq: () => Promise; +} + +const defaultRuntime: ProviderTlsRuntimeForTest = { + importWreq: async () => await import("wreq-js") as unknown as WreqModule, +}; + +let runtime = defaultRuntime; +const statusByProvider = new Map(); +const transports = new Map>(); +let shutdownDetach: (() => void) | undefined; +let fallbackWarned = false; + +function setStatus(providerName: string, status: ProviderTlsProfileStatus): void { + statusByProvider.set(providerName, status); +} + +function warnFallbackOnce(): void { + if (fallbackWarned) return; + fallbackWarned = true; + console.warn("[opencodex] Antigravity TLS profile requested → fallback to Bun fetch; native transport initialization failed"); +} + +function emulationOs(): "windows" | "macos" | "linux" { + if (process.platform === "win32") return "windows"; + if (process.platform === "darwin") return "macos"; + if (process.platform === "linux") return "linux"; + throw new Error("unsupported host operating system for Antigravity TLS profile"); +} + +function profileIsSupported(providerName: string, provider: Pick): boolean { + return providerName === "google-antigravity" + && provider.adapter === "google" + && provider.authMode === "oauth" + && provider.googleMode === "cloud-code-assist" + && provider.tlsProfile === "antigravity-browser"; +} + +export function providerTlsProfileConfigError( + providerName: string, + provider: Pick, +): string | null { + if (provider.tlsProfile === undefined) return null; + if (provider.tlsProfile !== "antigravity-browser") { + return "tlsProfile must be antigravity-browser"; + } + if (providerName !== "google-antigravity") return "tlsProfile antigravity-browser is valid only for google-antigravity"; + if (provider.adapter !== "google" || provider.authMode !== "oauth") { + return "tlsProfile antigravity-browser requires Google OAuth authentication"; + } + if (provider.googleMode !== "cloud-code-assist") { + return "tlsProfile antigravity-browser requires Google Cloud Code Assist mode"; + } + if (!isCanonicalAntigravityUrl(provider.baseUrl)) { + return "tlsProfile antigravity-browser requires a canonical Antigravity HTTPS destination"; + } + return null; +} + +export function isCanonicalAntigravityUrl(input: string | URL): boolean { + try { + const url = new URL(input); + return url.protocol === "https:" + && (url.port === "" || url.port === "443") + && ANTIGRAVITY_TLS_HOSTS.has(url.hostname.toLowerCase()); + } catch { + return false; + } +} + +export function getProviderTlsProfileStatus(providerName: string): ProviderTlsProfileStatus { + return statusByProvider.get(providerName) ?? "disabled"; +} + +function registerShutdownHook(): void { + if (shutdownDetach) return; + shutdownDetach = registerOptionalShutdownHook("provider-tls-profile", () => { + const pending = [...transports.values()]; + transports.clear(); + shutdownDetach = undefined; + for (const transport of pending) { + void transport.then(value => value?.close()).catch(() => undefined); + } + }); +} + +function transportKey(proxy: string | undefined): string { + return `${process.platform}:${proxy ?? "direct"}`; +} + +async function getTransport(proxy: string | undefined): Promise { + const key = transportKey(proxy); + const existing = transports.get(key); + if (existing) return existing; + const pending = (async () => { + try { + const module = await runtime.importWreq(); + const transport = await module.createTransport({ + browser: "chrome_142", + os: emulationOs(), + ...(proxy ? { proxy } : {}), + }); + registerShutdownHook(); + return transport; + } catch { + return undefined; + } + })(); + transports.set(key, pending); + return pending; +} + +export function providerTlsFetch( + providerName: string, + provider: Pick, + bunFetch: typeof globalThis.fetch, +): typeof globalThis.fetch { + if (provider.tlsProfile === undefined) { + setStatus(providerName, "disabled"); + return bunFetch; + } + if (!profileIsSupported(providerName, provider)) { + setStatus(providerName, "fallback"); + warnFallbackOnce(); + return bunFetch; + } + return (async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = typeof input === "string" || input instanceof URL ? input : input.url; + if (!isCanonicalAntigravityUrl(url)) return bunFetch(input, init); + const transport = await getTransport(proxyForUrl(url)); + if (!transport) { + setStatus(providerName, "fallback"); + warnFallbackOnce(); + return bunFetch(input, init); + } + setStatus(providerName, "active"); + const module = await runtime.importWreq(); + const wreqInit = { + ...init, + transport, + disableDefaultHeaders: true, + cookieMode: "ephemeral" as const, + }; + return await module.fetch(input, wreqInit) as Response; + }) as typeof globalThis.fetch; +} + +export function setProviderTlsRuntimeForTest(next: ProviderTlsRuntimeForTest | undefined): void { + runtime = next ?? defaultRuntime; +} + +export function resetProviderTlsProfileForTests(): void { + for (const pending of transports.values()) void pending.then(value => value?.close()).catch(() => undefined); + transports.clear(); + shutdownDetach?.(); + shutdownDetach = undefined; + statusByProvider.clear(); + fallbackWarned = false; + runtime = defaultRuntime; +} + +export type { ProviderTlsProfile }; diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index d34688ab95..e1b7fb58af 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -16,3 +16,52 @@ export function outboundProxyConfigured( ): boolean { return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env)); } + +function proxyValue(key: ProxyEnvKey, env: ProxyEnvMap): string | undefined { + const value = env[key]?.trim() || env[key.toLowerCase()]?.trim(); + return value || undefined; +} + +function noProxyMatches(url: URL, env: ProxyEnvMap): boolean { + const raw = env.NO_PROXY?.trim() || env.no_proxy?.trim() || ""; + const hostname = url.hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, ""); + const port = url.port || (url.protocol === "https:" ? "443" : "80"); + for (const value of raw.split(",")) { + let entry = value.trim().toLowerCase(); + if (!entry) continue; + if (entry === "*") return true; + entry = entry.replace(/^https?:\/\//, "").split("/", 1)[0]!; + let entryHost = entry; + let entryPort = ""; + const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry); + if (bracketed) { + entryHost = bracketed[1]!; + entryPort = bracketed[2] ?? ""; + } else if ((entry.match(/:/g)?.length ?? 0) === 1) { + const separator = entry.lastIndexOf(":"); + const possiblePort = entry.slice(separator + 1); + if (/^\d+$/.test(possiblePort)) { + entryHost = entry.slice(0, separator); + entryPort = possiblePort; + } + } + if (entryPort && entryPort !== port) continue; + entryHost = entryHost.replace(/^\*?\./, "").replace(/^\[|\]$/g, "").replace(/\.+$/, ""); + if (entryHost && (hostname === entryHost || hostname.endsWith(`.${entryHost}`))) return true; + } + return false; +} + +/** Select the standard proxy for a URL without exposing its credentials. */ +export function proxyForUrl(url: string | URL, env: ProxyEnvMap = process.env): string | undefined { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return undefined; + } + if (noProxyMatches(parsed, env)) return undefined; + if (parsed.protocol === "https:") return proxyValue("HTTPS_PROXY", env) ?? proxyValue("ALL_PROXY", env); + if (parsed.protocol === "http:") return proxyValue("HTTP_PROXY", env) ?? proxyValue("ALL_PROXY", env); + return undefined; +} diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index efc455797a..8fb0e54eaa 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -9,6 +9,7 @@ import type { OcxProviderConfig } from "../../types"; import type { WsData } from "../ws-bridge"; import { waitForProviderRequestSlot } from "../../providers/request-pacing"; import { withUpstreamHttpVersion } from "../../lib/upstream-http-version"; +import { providerTlsFetch } from "../../lib/provider-tls-profile"; export { withUpstreamHttpVersion }; @@ -67,9 +68,12 @@ export function providerFetch( const preconnect = (...args: Parameters): void => { base.preconnect?.(...args); }; + const transport = options.providerName + ? providerTlsFetch(options.providerName, provider, base) + : base; const httpFetch = Object.assign( (input: Parameters[0], init?: RequestInit) => - base(input, withUpstreamHttpVersion(input, init, provider)), + transport(input, withUpstreamHttpVersion(input, init, provider)), { preconnect }, ) as typeof globalThis.fetch; // ChatGPT Codex backend: streaming turns ride the responses_websockets diff --git a/src/types.ts b/src/types.ts index 559e71cbc9..39830e4910 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,6 +85,7 @@ export type { export type { RefreshPolicy, + ProviderTlsProfile, OpenRouterProviderRouting, ResponsesItemIdRepairConfig, RateLimitRetryPolicy, diff --git a/src/types/provider.ts b/src/types/provider.ts index 4d6381c7ba..f1515b023b 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -8,6 +8,8 @@ import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } */ export type RefreshPolicy = "proactive" | "lazy-only" | "disabled"; +export type ProviderTlsProfile = "antigravity-browser"; + export interface OpenRouterProviderRouting { /** OpenRouter provider slugs to try first, in priority order. */ order?: string[]; @@ -138,6 +140,8 @@ export interface OcxProviderConfig { codexToolMode?: "code_mode_only" | "shell"; /** Optional outbound request-start pacing shared by this provider and its model overrides. */ requestPacing?: ProviderRequestPacingConfig; + /** Explicitly acknowledged experimental browser-compatible transport profile. */ + tlsProfile?: ProviderTlsProfile; /** Cursor MCP compatibility bounds; positive integers when configured. */ mcpMaxTools?: number; mcpMaxSchemaBytes?: number; diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 51d0672621..111b707501 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -42,8 +42,10 @@ Responses-compatible streaming output. ### Fetch-helper import boundary `src/server/responses/fetch-helpers.ts` is a transport leaf shared by Responses, compact, and native -Chat. Its runtime imports are limited to the Codex WebSocket transport, provider request pacing, and -the upstream HTTP-version helper. Server, provider, and WebSocket data types remain type-only edges. +Chat. Its runtime imports are limited to the Codex WebSocket transport, provider request pacing, the +upstream HTTP-version helper, and the provider TLS-profile leaf. Server, provider, and WebSocket data +types remain type-only edges. The TLS leaf lazy-loads its optional native module and otherwise keeps +the default Bun executor unchanged; it must not pull OAuth, routing, or adapter code into this boundary. It must not import routing, combos, OAuth, adapters, sidecars, response parsing, logging, or relay modules merely because those imports existed in the pre-split `responses.ts` monolith. diff --git a/tests/provider-tls-profile.test.ts b/tests/provider-tls-profile.test.ts new file mode 100644 index 0000000000..4f5b0b3fb0 --- /dev/null +++ b/tests/provider-tls-profile.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { getDefaultConfig, validateConfigCandidate } from "../src/config"; +import { + ANTIGRAVITY_TLS_HOSTS, + getProviderTlsProfileStatus, + isCanonicalAntigravityUrl, + providerTlsProfileConfigError, + resetProviderTlsProfileForTests, + setProviderTlsRuntimeForTest, + providerTlsFetch, +} from "../src/lib/provider-tls-profile"; +import { proxyForUrl } from "../src/lib/proxy-env"; +import { providerFetch } from "../src/server/responses/fetch-helpers"; + +const canonicalProvider = { + adapter: "google", + authMode: "oauth", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + tlsProfile: "antigravity-browser" as const, +}; + +afterEach(() => { + resetProviderTlsProfileForTests(); + setProviderTlsRuntimeForTest(undefined); +}); + +describe("provider TLS profile validation", () => { + test("accepts only the canonical Antigravity OAuth CCA profile", () => { + expect(providerTlsProfileConfigError("google-antigravity", canonicalProvider)).toBeNull(); + expect(providerTlsProfileConfigError("google-antigravity", { + ...canonicalProvider, + baseUrl: "https://cloudcode-pa.googleapis.com", + })).toBeNull(); + expect(providerTlsProfileConfigError("google", canonicalProvider)).toContain("google-antigravity"); + expect(providerTlsProfileConfigError("google-antigravity", { + ...canonicalProvider, + authMode: "key", + })).toContain("OAuth"); + expect(providerTlsProfileConfigError("google-antigravity", { + ...canonicalProvider, + googleMode: "ai-studio", + })).toContain("Cloud Code Assist"); + expect(providerTlsProfileConfigError("google-antigravity", { + ...canonicalProvider, + tlsProfile: "raw-ja3" as never, + })).toContain("antigravity-browser"); + }); + + test("config validation rejects a profile on a noncanonical destination", () => { + const defaults = getDefaultConfig(); + const result = validateConfigCandidate({ + ...defaults, + providers: { + ...defaults.providers, + "google-antigravity": { + ...defaults.providers["google-antigravity"], + ...canonicalProvider, + baseUrl: "https://evil.example", + }, + }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("canonical"); + }); +}); + +describe("Antigravity TLS transport gate", () => { + test("recognizes only HTTPS canonical hosts", () => { + expect(ANTIGRAVITY_TLS_HOSTS).toEqual(new Set([ + "daily-cloudcode-pa.googleapis.com", + "cloudcode-pa.googleapis.com", + ])); + expect(isCanonicalAntigravityUrl("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent")).toBe(true); + expect(isCanonicalAntigravityUrl("https://cloudcode-pa.googleapis.com:443/v1internal")).toBe(true); + expect(isCanonicalAntigravityUrl("http://daily-cloudcode-pa.googleapis.com/v1internal")).toBe(false); + expect(isCanonicalAntigravityUrl("https://evil.example/v1internal")).toBe(false); + expect(isCanonicalAntigravityUrl("https://daily-cloudcode-pa.googleapis.com:8443/v1internal")).toBe(false); + }); + + test("uses the selected wreq executor and preserves request options", async () => { + const seen: { input?: unknown; init?: RequestInit } = {}; + const fakeResponse = new Response("event: done\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + const transport = { close: mock(async () => undefined) }; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => transport, + fetch: async (input: string | URL | Request, init?: RequestInit) => { + seen.input = input; + seen.init = init; + return fakeResponse; + }, + }), + }); + + const executor = providerTlsFetch("google-antigravity", canonicalProvider, globalThis.fetch); + const signal = new AbortController().signal; + const response = await executor( + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent", + { + method: "POST", + headers: { authorization: "Bearer redacted", "content-type": "application/json" }, + body: "{\"request\":1}", + signal, + redirect: "manual", + }, + ); + + expect(response).toBe(fakeResponse); + expect(seen.input).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent"); + expect(seen.init).toMatchObject({ + method: "POST", + body: "{\"request\":1}", + signal, + redirect: "manual", + disableDefaultHeaders: true, + cookieMode: "ephemeral", + transport, + }); + expect(new Headers(seen.init?.headers).get("authorization")).toBe("Bearer redacted"); + expect(getProviderTlsProfileStatus("google-antigravity")).toBe("active"); + }); + + test("providerFetch routes the opt-in profile while leaving the default executor untouched", async () => { + let wreqCalls = 0; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + wreqCalls += 1; + return new Response("wreq"); + }, + }), + }); + const configured = providerFetch(canonicalProvider, undefined, { providerName: "google-antigravity" }); + expect(await configured("https://daily-cloudcode-pa.googleapis.com/v1internal")).toEqual(expect.any(Response)); + expect(wreqCalls).toBe(1); + + let bunCalls = 0; + const bun = mock(async () => { + bunCalls += 1; + return new Response("bun"); + }) as typeof globalThis.fetch; + // The default provider fetch uses global fetch; inject it through the provider-owned seam. + const explicitDefault = providerFetch({ ...canonicalProvider, tlsProfile: undefined, fetch: bun } as never, undefined, { + providerName: "google-antigravity", + }); + await explicitDefault("https://daily-cloudcode-pa.googleapis.com/v1internal"); + expect(bunCalls).toBe(1); + }); + + test("falls back once when import or construction fails and never replays post-dispatch errors", async () => { + let bunCalls = 0; + const bunFetch = mock(async () => { + bunCalls += 1; + return new Response("bun"); + }) as typeof globalThis.fetch; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => { throw new Error("missing native prebuild"); }, + fetch: async () => { throw new Error("must not dispatch"); }, + }), + }); + const executor = providerTlsFetch("google-antigravity", canonicalProvider, bunFetch); + expect(await executor("https://daily-cloudcode-pa.googleapis.com/v1internal")).toBeInstanceOf(Response); + expect(await executor("https://daily-cloudcode-pa.googleapis.com/v1internal")).toBeInstanceOf(Response); + expect(bunCalls).toBe(2); + expect(getProviderTlsProfileStatus("google-antigravity")).toBe("fallback"); + + resetProviderTlsProfileForTests(); + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { throw new Error("post-dispatch failure"); }, + }), + }); + const noReplay = providerTlsFetch("google-antigravity", canonicalProvider, bunFetch); + await expect(noReplay("https://daily-cloudcode-pa.googleapis.com/v1internal")).rejects.toThrow("post-dispatch failure"); + expect(bunCalls).toBe(2); + }); +}); + +describe("Antigravity proxy selection", () => { + test("honors HTTPS/ALL_PROXY precedence and NO_PROXY", () => { + const env = { + HTTPS_PROXY: "http://proxy-user:secret@example.test:8080", + ALL_PROXY: "http://all.example:8080", + NO_PROXY: "daily-cloudcode-pa.googleapis.com", + }; + expect(proxyForUrl("https://daily-cloudcode-pa.googleapis.com/v1", env)).toBeUndefined(); + expect(proxyForUrl("https://other.example/v1", env)).toBe("http://proxy-user:secret@example.test:8080"); + expect(proxyForUrl("http://other.example/v1", { ALL_PROXY: "http://all.example:8080" })).toBe("http://all.example:8080"); + }); +}); diff --git a/tests/responses-fetch-helpers-boundary.test.ts b/tests/responses-fetch-helpers-boundary.test.ts index b4bc7de6bf..2f02b5442d 100644 --- a/tests/responses-fetch-helpers-boundary.test.ts +++ b/tests/responses-fetch-helpers-boundary.test.ts @@ -43,6 +43,7 @@ function expectRuntimeImportBoundary(source: string): string[] { describe("Responses fetch-helper import boundary", () => { test("loads only transport-owned runtime dependencies", () => { expect(expectRuntimeImportBoundary(readFileSync(helperPath, "utf8"))).toEqual([ + "../../lib/provider-tls-profile", "../../lib/upstream-http-version", "../../providers/request-pacing", "./ws-upstream", From f4a1078da4a750a39d9729b315e0cbf4c5b3fd4e Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:25:48 -0600 Subject: [PATCH 08/29] fix: constrain antigravity tls dispatch --- src/lib/provider-tls-profile.ts | 32 +++++++++++------ tests/provider-tls-profile.test.ts | 58 +++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/lib/provider-tls-profile.ts b/src/lib/provider-tls-profile.ts index 74e0b5f686..c76fc5b058 100644 --- a/src/lib/provider-tls-profile.ts +++ b/src/lib/provider-tls-profile.ts @@ -22,6 +22,11 @@ interface WreqModule { fetch(input: string | URL | Request, init?: Record): Promise; } +interface InitializedTransport { + module: WreqModule; + transport: WreqTransport; +} + export interface ProviderTlsRuntimeForTest { importWreq: () => Promise; } @@ -32,7 +37,7 @@ const defaultRuntime: ProviderTlsRuntimeForTest = { let runtime = defaultRuntime; const statusByProvider = new Map(); -const transports = new Map>(); +const transports = new Map>(); let shutdownDetach: (() => void) | undefined; let fallbackWarned = false; @@ -58,6 +63,7 @@ function profileIsSupported(providerName: string, provider: Pick value?.close()).catch(() => undefined); + void transport.then(value => value?.transport.close()).catch(() => undefined); } }); } @@ -113,21 +121,23 @@ function transportKey(proxy: string | undefined): string { return `${process.platform}:${proxy ?? "direct"}`; } -async function getTransport(proxy: string | undefined): Promise { +async function getTransport(proxy: string | undefined): Promise { const key = transportKey(proxy); const existing = transports.get(key); if (existing) return existing; const pending = (async () => { + let transport: WreqTransport | undefined; try { const module = await runtime.importWreq(); - const transport = await module.createTransport({ + transport = await module.createTransport({ browser: "chrome_142", os: emulationOs(), ...(proxy ? { proxy } : {}), }); registerShutdownHook(); - return transport; + return { module, transport }; } catch { + if (transport) await transport.close().catch(() => undefined); return undefined; } })(); @@ -152,21 +162,21 @@ export function providerTlsFetch( return (async (input: string | URL | Request, init?: RequestInit): Promise => { const url = typeof input === "string" || input instanceof URL ? input : input.url; if (!isCanonicalAntigravityUrl(url)) return bunFetch(input, init); - const transport = await getTransport(proxyForUrl(url)); - if (!transport) { + const initialized = await getTransport(proxyForUrl(url)); + if (!initialized) { setStatus(providerName, "fallback"); warnFallbackOnce(); return bunFetch(input, init); } setStatus(providerName, "active"); - const module = await runtime.importWreq(); const wreqInit = { ...init, - transport, + transport: initialized.transport, disableDefaultHeaders: true, cookieMode: "ephemeral" as const, + redirect: "manual" as const, }; - return await module.fetch(input, wreqInit) as Response; + return await initialized.module.fetch(input, wreqInit) as Response; }) as typeof globalThis.fetch; } @@ -175,7 +185,7 @@ export function setProviderTlsRuntimeForTest(next: ProviderTlsRuntimeForTest | u } export function resetProviderTlsProfileForTests(): void { - for (const pending of transports.values()) void pending.then(value => value?.close()).catch(() => undefined); + for (const pending of transports.values()) void pending.then(value => value?.transport.close()).catch(() => undefined); transports.clear(); shutdownDetach?.(); shutdownDetach = undefined; diff --git a/tests/provider-tls-profile.test.ts b/tests/provider-tls-profile.test.ts index 4f5b0b3fb0..8f90c80027 100644 --- a/tests/provider-tls-profile.test.ts +++ b/tests/provider-tls-profile.test.ts @@ -32,6 +32,10 @@ describe("provider TLS profile validation", () => { ...canonicalProvider, baseUrl: "https://cloudcode-pa.googleapis.com", })).toBeNull(); + expect(providerTlsProfileConfigError("google-antigravity", { + ...canonicalProvider, + baseUrl: "https://user:secret@daily-cloudcode-pa.googleapis.com", + })).toContain("canonical"); expect(providerTlsProfileConfigError("google", canonicalProvider)).toContain("google-antigravity"); expect(providerTlsProfileConfigError("google-antigravity", { ...canonicalProvider, @@ -73,6 +77,7 @@ describe("Antigravity TLS transport gate", () => { ])); expect(isCanonicalAntigravityUrl("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent")).toBe(true); expect(isCanonicalAntigravityUrl("https://cloudcode-pa.googleapis.com:443/v1internal")).toBe(true); + expect(isCanonicalAntigravityUrl("https://user:secret@daily-cloudcode-pa.googleapis.com/v1internal")).toBe(false); expect(isCanonicalAntigravityUrl("http://daily-cloudcode-pa.googleapis.com/v1internal")).toBe(false); expect(isCanonicalAntigravityUrl("https://evil.example/v1internal")).toBe(false); expect(isCanonicalAntigravityUrl("https://daily-cloudcode-pa.googleapis.com:8443/v1internal")).toBe(false); @@ -105,7 +110,7 @@ describe("Antigravity TLS transport gate", () => { headers: { authorization: "Bearer redacted", "content-type": "application/json" }, body: "{\"request\":1}", signal, - redirect: "manual", + redirect: "follow", }, ); @@ -152,6 +157,57 @@ describe("Antigravity TLS transport gate", () => { expect(bunCalls).toBe(1); }); + test("requires the configured provider base URL to be canonical", async () => { + let wreqCalls = 0; + let bunCalls = 0; + const bunFetch = mock(async () => { + bunCalls += 1; + return new Response("bun"); + }) as typeof globalThis.fetch; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + wreqCalls += 1; + return new Response("wreq"); + }, + }), + }); + const executor = providerTlsFetch("google-antigravity", { + ...canonicalProvider, + baseUrl: "https://attacker.example", + }, bunFetch); + await executor("https://daily-cloudcode-pa.googleapis.com/v1internal"); + await executor("https://user:secret@daily-cloudcode-pa.googleapis.com/v1internal"); + expect(wreqCalls).toBe(0); + expect(bunCalls).toBe(2); + }); + + test("caches one successful module initialization and falls back if a later import would fail", async () => { + let importCalls = 0; + let bunCalls = 0; + const bunFetch = mock(async () => { + bunCalls += 1; + return new Response("bun"); + }) as typeof globalThis.fetch; + const partialTransport = { close: mock(async () => undefined) }; + setProviderTlsRuntimeForTest({ + importWreq: async () => { + importCalls += 1; + if (importCalls > 1) throw new Error("hypothetical second import failure"); + return { + createTransport: async () => partialTransport, + fetch: async () => new Response("wreq"), + }; + }, + }); + const executor = providerTlsFetch("google-antigravity", canonicalProvider, bunFetch); + await expect(executor("https://daily-cloudcode-pa.googleapis.com/v1internal")).resolves.toEqual(expect.any(Response)); + expect(importCalls).toBe(1); + expect(bunCalls).toBe(0); + expect(partialTransport.close).not.toHaveBeenCalled(); + }); + test("falls back once when import or construction fails and never replays post-dispatch errors", async () => { let bunCalls = 0; const bunFetch = mock(async () => { From 4addf3412681558b64e6ef444f8829d9b0dfea2f Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:39:12 -0600 Subject: [PATCH 09/29] feat: apply antigravity tls profile to data plane --- src/lib/provider-outbound.ts | 32 +++++++++-- src/providers/quota.ts | 4 +- src/server/images.ts | 7 ++- src/server/management/provider-routes.ts | 3 ++ src/web-search/gemini-executor.ts | 6 ++- tests/gemini-web-search.test.ts | 38 +++++++++++++ tests/management-provider-validation.test.ts | 29 ++++++++++ tests/provider-outbound.test.ts | 42 +++++++++++++++ tests/provider-quota.test.ts | 49 +++++++++++++++++ tests/provider-tls-profile.test.ts | 31 +++++++++-- tests/server-images.test.ts | 57 ++++++++++++++++++++ 11 files changed, 288 insertions(+), 10 deletions(-) diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index f067aa927d..4c782f9a7a 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -9,10 +9,12 @@ import { import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; import { outboundProxyConfigured } from "./proxy-env"; import { publicProviderBaseUrl } from "./provider-url"; +import { isCanonicalAntigravityUrl, providerTlsFetch } from "./provider-tls-profile"; +import { waitForProviderRequestSlot } from "../providers/request-pacing"; type ProviderGetInit = Omit; type ProviderPostInit = ProviderGetInit & { body: string }; -type ProviderOutboundConfig = Pick & { +type ProviderOutboundConfig = Pick & Partial> & { fetch?: typeof globalThis.fetch; }; export interface ProviderOutboundDependencies { @@ -72,6 +74,28 @@ function noProxyMatches(url: URL): boolean { return false; } +function antigravityProfileFetch( + name: string, + provider: ProviderOutboundConfig, + url: string, +): typeof globalThis.fetch | undefined { + if (name !== "google-antigravity" + || provider.fetch + || provider.tlsProfile !== "antigravity-browser" + || provider.adapter !== "google" + || provider.authMode !== "oauth" + || provider.googleMode !== "cloud-code-assist" + || !isCanonicalAntigravityUrl(provider.baseUrl) + || !isCanonicalAntigravityUrl(url)) { + return undefined; + } + const nativeFetch = providerTlsFetch(name, provider as Parameters[1], globalThis.fetch); + return (async (input: Parameters[0], init?: RequestInit): Promise => { + await waitForProviderRequestSlot(name, provider as OcxProviderConfig, undefined, init?.signal ?? undefined); + return nativeFetch(input, init); + }) as typeof globalThis.fetch; +} + let proxyBoundaryWarned = false; let proxyDnsDegradationWarned = false; @@ -116,7 +140,9 @@ async function providerOutboundRequest( if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:") { throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS"); } - if (provider.fetch) { + const profiledFetch = antigravityProfileFetch(name, provider, url); + const fetchOverride = provider.fetch ?? profiledFetch; + if (fetchOverride) { // A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config // checks and redirect blocking, but does not provide the resolved-address guarantees of // the built-in transport. Main-request migration must define that executor contract first. @@ -135,7 +161,7 @@ async function providerOutboundRequest( }); if (destinationError) throw new ProviderOutboundPolicyError(destinationError); } - return provider.fetch(url, { ...init, method, redirect: "manual" }); + return fetchOverride(url, { ...init, method, redirect: "manual" }); } const parsed = postUrl ?? new URL(url); const proxyConfigured = configuredProxyFor(); diff --git a/src/providers/quota.ts b/src/providers/quota.ts index db0202161d..ed5887d8f3 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -10,6 +10,7 @@ import { codexPlanKey } from "../codex/plan"; import { resolveEnvValue } from "../config"; import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth"; import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store"; +import { providerFetch } from "../server/responses/fetch-helpers"; import { antigravityUserAgent } from "../adapters/client-fingerprint"; import { apiKeyPoolEntryId } from "./api-keys"; import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport"; @@ -2081,7 +2082,8 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig return null; } const baseUrl = (config.baseUrl || "https://daily-cloudcode-pa.googleapis.com").replace(/\/+$/, ""); - const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, { + const executor = providerFetch(config, undefined, { providerName: "google-antigravity" }); + const response = await executor(`${baseUrl}/v1internal:fetchAvailableModels`, { method: "POST", headers: { Accept: "application/json", diff --git a/src/server/images.ts b/src/server/images.ts index 5e65a1a170..83c813047b 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -40,6 +40,7 @@ import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; import { decodeValidatedImageBase64, MAX_ENCODED_BYTES_PER_IMAGE } from "../images/artifacts"; import type { AdmissionLease } from "../lib/admission"; import { codexAccountSelectionForTurn } from "./lifecycle"; +import { providerFetch } from "./responses/fetch-helpers"; export type ImagesEndpoint = "generations" | "edits"; @@ -226,7 +227,11 @@ async function tryCcaImageGeneration( let upstream: Response; try { try { - upstream = await fetch(`${baseUrl}/v1internal:generateContent`, { + const executor = providerFetch(provider, undefined, { + providerName: "google-antigravity", + modelId: CCA_IMAGE_MODEL, + }); + upstream = await executor(`${baseUrl}/v1internal:generateContent`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 47909e50f0..b1a5de2125 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -48,6 +48,7 @@ import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { clearKeyCooldowns } from "../../providers/key-failover"; import { providerRequestPacingStatus } from "../../providers/request-pacing"; +import { getProviderTlsProfileStatus } from "../../lib/provider-tls-profile"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; @@ -410,6 +411,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { export async function runGeminiWebSearch( query: string, providerName: string, - _provider: OcxProviderConfig, + provider: OcxProviderConfig, settings: SidecarSettings, abortSignal?: AbortSignal, ): Promise { @@ -67,9 +68,10 @@ export async function runGeminiWebSearch( const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal); const sidecarExit = sidecarEnter("web-search"); const t0 = Date.now(); + const executor = providerFetch(provider, undefined, { providerName, modelId: settings.model }); try { const res = await fetchWithResetRetry( - () => fetch(`${base}/v1internal:generateContent`, { + () => executor(`${base}/v1internal:generateContent`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/tests/gemini-web-search.test.ts b/tests/gemini-web-search.test.ts index 0cc71cca4c..d40278f482 100644 --- a/tests/gemini-web-search.test.ts +++ b/tests/gemini-web-search.test.ts @@ -98,6 +98,10 @@ describe("planWebSearch gemini arm (L8)", () => { }); import { runGeminiWebSearch } from "../src/web-search/gemini-executor"; +import { + resetProviderTlsProfileForTests, + setProviderTlsRuntimeForTest, +} from "../src/lib/provider-tls-profile"; import * as oauthModule from "../src/oauth"; mock.module("../src/oauth", () => ({ ...oauthModule, @@ -112,6 +116,40 @@ import { createTestTranslatorBudget } from "./helpers/translator-budget"; import type { AdapterEvent, ProviderAdapter } from "../src/adapters/base"; describe("runGeminiWebSearch request shape (review P1)", () => { + test("uses the opt-in Antigravity executor for Gemini grounding", async () => { + accountSets = { "google-antigravity": { accounts: [{ id: "a1", credential: { projectId: "proj-9" } }], activeAccountId: "a1" } }; + let nativeCalls = 0; + let bunCalls = 0; + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => { + bunCalls += 1; + return new Response(JSON.stringify({ response: { candidates: [{ content: { parts: [{ text: "bun" }] } }] } }), { status: 200 }); + }) as typeof fetch; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + nativeCalls += 1; + return new Response(JSON.stringify({ response: { candidates: [{ content: { parts: [{ text: "native" }] } }] } }), { status: 200 }); + }, + }), + }); + try { + const out = await runGeminiWebSearch("q", "google-antigravity", { + ...cca, + googleMode: "cloud-code-assist", + tlsProfile: "antigravity-browser", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + }, { model: "gemini-3.7-flash", reasoning: "low", timeoutMs: 5000 }); + expect(out.text).toBe("native"); + expect(nativeCalls).toBe(1); + expect(bunCalls).toBe(0); + } finally { + globalThis.fetch = realFetch; + resetProviderTlsProfileForTests(); + } + }); + test("malicious baseUrl ignored: registry destination, manual redirect, bearer + IDE UA, full envelope, thinkingConfig", async () => { accountSets = { "google-antigravity": { accounts: [{ id: "a1", credential: { projectId: "proj-9" } }], activeAccountId: "a1" } }; const captured: Array<{ url: string; init: RequestInit }> = []; diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 7aaaba950a..40c12c5254 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -2919,6 +2919,35 @@ describe("provider management validation", () => { expect(raw).not.toContain(sentinelName); expect(raw).not.toContain(sentinelValue); }); + + test("GET /api/providers exposes only redacted Antigravity TLS profile state", async () => { + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalDirect }, + "google-antigravity": { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + googleMode: "cloud-code-assist", + tlsProfile: "antigravity-browser", + apiKey: "must-not-leak", + }, + }, + }; + const req = new Request("http://127.0.0.1/api/providers", { method: "GET" }); + const res = await handleManagementAPI(req, new URL(req.url), liveConfig, {}); + expect(res?.status).toBe(200); + const raw = await res!.text(); + const row = (JSON.parse(raw) as { name: string; tlsProfile?: string; tlsProfileStatus?: string }[]) + .find(item => item.name === "google-antigravity"); + expect(row).toMatchObject({ tlsProfile: "antigravity-browser", tlsProfileStatus: "disabled" }); + expect(raw).not.toContain("must-not-leak"); + }); + test("provider PATCH merges headers case-insensitively", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/provider-outbound.test.ts b/tests/provider-outbound.test.ts index f43cb3587f..f92d7775e0 100644 --- a/tests/provider-outbound.test.ts +++ b/tests/provider-outbound.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ProviderOutboundDependencies } from "../src/lib/provider-outbound"; import { PROXY_ENV_KEYS } from "../src/lib/proxy-env"; +import { resetProviderTlsProfileForTests, setProviderTlsRuntimeForTest } from "../src/lib/provider-tls-profile"; const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); @@ -14,6 +15,7 @@ afterEach(() => { if (previous === undefined) delete process.env[key]; else process.env[key] = previous; } + resetProviderTlsProfileForTests(); }); function directDependencies( @@ -51,6 +53,46 @@ function directDependencies( } describe("provider outbound GET transport", () => { + test("Antigravity model discovery uses the opted-in profiled executor", async () => { + for (const key of proxyKeys) delete process.env[key]; + let nativeCalls = 0; + let bunCalls = 0; + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => { + bunCalls += 1; + return new Response(null, { status: 200 }); + }) as typeof fetch; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + nativeCalls += 1; + return new Response(null, { status: 200 }); + }, + }), + }); + try { + const { providerOutboundPost } = await import("../src/lib/provider-outbound"); + const response = await providerOutboundPost( + "google-antigravity", + { + adapter: "google", + authMode: "oauth", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + tlsProfile: "antigravity-browser", + }, + "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", + { headers: { authorization: "Bearer redacted" }, body: "{}" }, + ); + expect(response.status).toBe(200); + expect(nativeCalls).toBe(1); + expect(bunCalls).toBe(0); + } finally { + globalThis.fetch = realFetch; + } + }); + test("direct HTTPS connects only to the validated address with TLS verification", async () => { for (const key of proxyKeys) delete process.env[key]; const { providerOutboundGet } = await import("../src/lib/provider-outbound"); diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index dffa089c56..70f2db71ff 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -18,6 +18,7 @@ import { setProviderQuotaBeforePublishForTests, } from "../src/providers/quota"; import type { OcxConfig } from "../src/types"; +import { resetProviderTlsProfileForTests, setProviderTlsRuntimeForTest } from "../src/lib/provider-tls-profile"; const originalFetch = globalThis.fetch; const previousOpencodexHome = process.env.OPENCODEX_HOME; const previousCodexHome = process.env.CODEX_HOME; @@ -87,6 +88,7 @@ beforeEach(() => { afterEach(() => { globalThis.fetch = originalFetch; + resetProviderTlsProfileForTests(); clearAccountQuota(); clearProviderQuotaCache(); setProviderQuotaBeforePublishForTests(null); @@ -99,6 +101,53 @@ afterEach(() => { }); describe("fetchProviderQuotaReports", () => { + test("Antigravity quota probes use the opt-in profiled executor", async () => { + await saveCredential("google-antigravity", { + access: "agy-access-secret", + refresh: "agy-refresh-secret", + expires: Date.now() + 3600_000, + projectId: "agy-project-secret", + }); + let nativeCalls = 0; + let bunCalls = 0; + globalThis.fetch = (async () => { + bunCalls += 1; + return new Response(null, { status: 500 }); + }) as typeof fetch; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + nativeCalls += 1; + return new Response(JSON.stringify({ + models: { + "gemini-3.6-flash-medium": { + displayName: "Gemini 3.6 Flash", + quotaInfo: { remainingFraction: 0.64 }, + }, + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + }, + }), + }); + const config: OcxConfig = { + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + authMode: "oauth", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + tlsProfile: "antigravity-browser", + }, + }, + } as OcxConfig; + const result = await fetchProviderQuotaReports(config, true); + expect(result.reports[0]?.provider).toBe("google-antigravity"); + expect(nativeCalls).toBe(1); + expect(bunCalls).toBe(0); + }); + test("provider quota probes have no direct Response.json calls", () => { const source = readFileSync(join(import.meta.dir, "../src/providers/quota.ts"), "utf8"); expect(source).not.toMatch(/\.\s*json\s*\(/); diff --git a/tests/provider-tls-profile.test.ts b/tests/provider-tls-profile.test.ts index 8f90c80027..cdaf487a26 100644 --- a/tests/provider-tls-profile.test.ts +++ b/tests/provider-tls-profile.test.ts @@ -19,6 +19,9 @@ const canonicalProvider = { baseUrl: "https://daily-cloudcode-pa.googleapis.com", tlsProfile: "antigravity-browser" as const, }; +const userInfoUrl = new URL("https://daily-cloudcode-pa.googleapis.com/v1internal"); +userInfoUrl.username = "user"; +userInfoUrl.password = "secret"; afterEach(() => { resetProviderTlsProfileForTests(); @@ -34,7 +37,7 @@ describe("provider TLS profile validation", () => { })).toBeNull(); expect(providerTlsProfileConfigError("google-antigravity", { ...canonicalProvider, - baseUrl: "https://user:secret@daily-cloudcode-pa.googleapis.com", + baseUrl: userInfoUrl.toString(), })).toContain("canonical"); expect(providerTlsProfileConfigError("google", canonicalProvider)).toContain("google-antigravity"); expect(providerTlsProfileConfigError("google-antigravity", { @@ -77,7 +80,7 @@ describe("Antigravity TLS transport gate", () => { ])); expect(isCanonicalAntigravityUrl("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent")).toBe(true); expect(isCanonicalAntigravityUrl("https://cloudcode-pa.googleapis.com:443/v1internal")).toBe(true); - expect(isCanonicalAntigravityUrl("https://user:secret@daily-cloudcode-pa.googleapis.com/v1internal")).toBe(false); + expect(isCanonicalAntigravityUrl(userInfoUrl)).toBe(false); expect(isCanonicalAntigravityUrl("http://daily-cloudcode-pa.googleapis.com/v1internal")).toBe(false); expect(isCanonicalAntigravityUrl("https://evil.example/v1internal")).toBe(false); expect(isCanonicalAntigravityUrl("https://daily-cloudcode-pa.googleapis.com:8443/v1internal")).toBe(false); @@ -178,11 +181,33 @@ describe("Antigravity TLS transport gate", () => { baseUrl: "https://attacker.example", }, bunFetch); await executor("https://daily-cloudcode-pa.googleapis.com/v1internal"); - await executor("https://user:secret@daily-cloudcode-pa.googleapis.com/v1internal"); + await executor(userInfoUrl); expect(wreqCalls).toBe(0); expect(bunCalls).toBe(2); }); + test("keeps OAuth token destinations on Bun fetch", async () => { + let wreqCalls = 0; + let bunCalls = 0; + const bunFetch = mock(async () => { + bunCalls += 1; + return new Response("bun"); + }) as typeof globalThis.fetch; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + wreqCalls += 1; + return new Response("wreq"); + }, + }), + }); + const executor = providerTlsFetch("google-antigravity", canonicalProvider, bunFetch); + await executor("https://oauth2.googleapis.com/token", { method: "POST" }); + expect(bunCalls).toBe(1); + expect(wreqCalls).toBe(0); + }); + test("caches one successful module initialization and falls back if a later import would fail", async () => { let importCalls = 0; let bunCalls = 0; diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 9499de4d85..c2352e49ac 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -16,6 +16,7 @@ import { handleImages, IMAGES_RESPONSE_MAX_BYTES, readImageResponseBytes } from import { saveCredential } from "../src/oauth/store"; import type { OcxConfig } from "../src/types"; import { ANTIGRAVITY_REQUEST_UA } from "../src/adapters/google-antigravity-wire"; +import { resetProviderTlsProfileForTests, setProviderTlsRuntimeForTest } from "../src/lib/provider-tls-profile"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; @@ -55,6 +56,7 @@ afterEach(() => { clearThreadAccountMap(); clearAccountNeedsReauth("pool-a"); clearAccountQuota(); + resetProviderTlsProfileForTests(); if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); }); @@ -1170,6 +1172,61 @@ const CCA_CREDENTIAL = { projectId: "cca-project-123", } as const; +test("CCA image generation uses the opted-in profiled executor", async () => { + let nativeCalls = 0; + let bunCalls = 0; + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => { + bunCalls += 1; + return new Response(null, { status: 500 }); + }) as typeof fetch; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + nativeCalls += 1; + return Response.json({ + response: { + candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: CCA_TINY_PNG } }] } }], + }, + }); + }, + }), + }); + const config = { + ...ccaConfig(), + providers: { + ...ccaConfig().providers, + "google-antigravity": { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + googleMode: "cloud-code-assist", + tlsProfile: "antigravity-browser", + }, + }, + } as OcxConfig; + saveConfig(config); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + try { + const response = await handleImages( + new Request("http://127.0.0.1/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a neon cat" }), + }), + config, + "generations", + { model: "", provider: "" }, + ); + expect(response.status).toBe(200); + expect(nativeCalls).toBe(1); + expect(bunCalls).toBe(0); + } finally { + globalThis.fetch = realFetch; + } +}); + test("CCA image fallback generates images via Google Antigravity when no OpenAI upstream exists", async () => { const registryHits: CcaFetchRequest[] = []; const otherHits: CcaFetchRequest[] = []; From a5626994ba63312c1eb2a075e004b1adb3043c61 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:55:15 -0600 Subject: [PATCH 10/29] fix: harden antigravity profiled data paths --- src/lib/provider-outbound.ts | 15 +++- src/lib/provider-tls-profile.ts | 48 +++++++++++- src/lib/redact.ts | 8 ++ src/server/images.ts | 5 +- src/server/management/provider-routes.ts | 5 +- src/web-search/gemini-executor.ts | 4 +- tests/gemini-web-search.test.ts | 23 ++++++ tests/management-provider-validation.test.ts | 77 ++++++++++++++++++++ tests/provider-outbound.test.ts | 48 ++++++++++++ tests/provider-tls-profile.test.ts | 67 ++++++++++++++++- tests/server-images.test.ts | 40 ++++++++++ 11 files changed, 327 insertions(+), 13 deletions(-) diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index 4c782f9a7a..f1ec44ccac 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -7,7 +7,7 @@ import { resolvePublicAddresses, } from "./destination-policy"; import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; -import { outboundProxyConfigured } from "./proxy-env"; +import { outboundProxyConfigured, proxyForUrl } from "./proxy-env"; import { publicProviderBaseUrl } from "./provider-url"; import { isCanonicalAntigravityUrl, providerTlsFetch } from "./provider-tls-profile"; import { waitForProviderRequestSlot } from "../providers/request-pacing"; @@ -142,6 +142,19 @@ async function providerOutboundRequest( } const profiledFetch = antigravityProfileFetch(name, provider, url); const fetchOverride = provider.fetch ?? profiledFetch; + if (profiledFetch) { + // Keep the profiled executor behind the same pre-dispatch DNS boundary as + // the pinned path. A proxy owns peer selection, so match the existing + // providerOutbound proxy boundary and deliberately skip local resolution. + if (!proxyForUrl(url)) { + const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; + await resolveAddresses(url, { + context: "provider URL", + allowPrivateNetwork: false, + }); + } + return fetchOverride!(url, { ...init, method, redirect: "manual" }); + } if (fetchOverride) { // A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config // checks and redirect blocking, but does not provide the resolved-address guarantees of diff --git a/src/lib/provider-tls-profile.ts b/src/lib/provider-tls-profile.ts index c76fc5b058..3171e6e3b9 100644 --- a/src/lib/provider-tls-profile.ts +++ b/src/lib/provider-tls-profile.ts @@ -1,6 +1,8 @@ import type { OcxProviderConfig, ProviderTlsProfile } from "../types"; +import { resolvePublicAddresses } from "./destination-policy"; import { registerOptionalShutdownHook } from "./optional-shutdown-hooks"; import { proxyForUrl } from "./proxy-env"; +import { redactErrorMessage } from "./redact"; export const ANTIGRAVITY_TLS_HOSTS = new Set([ "daily-cloudcode-pa.googleapis.com", @@ -29,6 +31,7 @@ interface InitializedTransport { export interface ProviderTlsRuntimeForTest { importWreq: () => Promise; + resolveDestination?: typeof resolvePublicAddresses; } const defaultRuntime: ProviderTlsRuntimeForTest = { @@ -45,6 +48,11 @@ function setStatus(providerName: string, status: ProviderTlsProfileStatus): void statusByProvider.set(providerName, status); } +function safeProviderTlsError(error: unknown): Error { + const raw = error instanceof Error ? error.message : String(error); + return new Error(redactErrorMessage(raw)); +} + function warnFallbackOnce(): void { if (fallbackWarned) return; fallbackWarned = true; @@ -162,11 +170,22 @@ export function providerTlsFetch( return (async (input: string | URL | Request, init?: RequestInit): Promise => { const url = typeof input === "string" || input instanceof URL ? input : input.url; if (!isCanonicalAntigravityUrl(url)) return bunFetch(input, init); - const initialized = await getTransport(proxyForUrl(url)); + const proxy = proxyForUrl(url); + // A direct profiled request must pass the same public-destination gate as the + // ordinary provider executor before wreq can send its bearer-bearing request. + // Proxied requests deliberately leave peer selection to the configured proxy, + // matching providerOutbound's proxy boundary (and its inability to pin a peer). + if (!proxy) { + await (runtime.resolveDestination ?? resolvePublicAddresses)(String(url), { + context: "Antigravity profile URL", + allowPrivateNetwork: false, + }); + } + const initialized = await getTransport(proxy); if (!initialized) { setStatus(providerName, "fallback"); warnFallbackOnce(); - return bunFetch(input, init); + return bunFetch(input, { ...init, redirect: "manual" }); } setStatus(providerName, "active"); const wreqInit = { @@ -176,12 +195,33 @@ export function providerTlsFetch( cookieMode: "ephemeral" as const, redirect: "manual" as const, }; - return await initialized.module.fetch(input, wreqInit) as Response; + try { + return await initialized.module.fetch(input, wreqInit) as Response; + } catch (error) { + // Native errors are post-dispatch failures. Redact at this boundary and + // never replay through Bun, since generation may already have started. + throw safeProviderTlsError(error); + } }) as typeof globalThis.fetch; } export function setProviderTlsRuntimeForTest(next: ProviderTlsRuntimeForTest | undefined): void { - runtime = next ?? defaultRuntime; + if (next === undefined) { + runtime = defaultRuntime; + return; + } + // Test callers normally replace only the native module. Keep those tests + // deterministic in offline sandboxes while allowing explicit DNS policy + // regressions to inject a rejecting resolver. + runtime = { + ...defaultRuntime, + resolveDestination: async (url: string) => ({ + hostname: new URL(url).hostname, + addresses: [{ address: "142.250.1.1", family: 4 }], + privateNetwork: false, + }), + ...next, + }; } export function resetProviderTlsProfileForTests(): void { diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 9f9bb4af44..12df07a5c5 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -494,6 +494,14 @@ export function redactUrlForLog(url: string): string { } } +/** Redact credentials and userinfo from arbitrary diagnostic text. */ +export function redactErrorMessage(value: string): string { + return redactSecretString(value).replace( + /https?:\/\/[^\s"'<>]+/gi, + match => redactUrlForLog(match), + ); +} + const USER_HOME_PATH_PATTERNS: Array<[RegExp, string]> = [ // Windows: C:\Users\\... -> C:\Users\[USER]\... [/([A-Za-z]:\\Users\\)[^\\/]+/gi, "$1[USER]"], diff --git a/src/server/images.ts b/src/server/images.ts index 83c813047b..32dcc3df21 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -36,6 +36,7 @@ import { codexLogAccountId, decodeRequestErrorResponse } from "./responses"; import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index"; import { safeAntigravityHttpErrorMessage } from "../adapters/google-errors"; import { sanitizeUpstreamErrorText } from "../adapters/upstream-http-error"; +import { redactErrorMessage } from "../lib/redact"; import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; import { decodeValidatedImageBase64, MAX_ENCODED_BYTES_PER_IMAGE } from "../images/artifacts"; import type { AdmissionLease } from "../lib/admission"; @@ -252,7 +253,7 @@ async function tryCcaImageGeneration( // so no upstream-rejected credential or query param can reach the client, // and strip the internal base URL host from the surfaced message. const rawMsg = err instanceof Error ? err.message : String(err); - const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace( + const safeMsg = redactErrorMessage(sanitizeUpstreamErrorText(rawMsg)).replace( /https?:\/\/[^\s"'<>]+/gi, "[upstream-url]", ); @@ -304,7 +305,7 @@ async function tryCcaImageGeneration( return formatErrorResponse(504, "upstream_error", "CCA image response timed out during body read"); } const rawMsg = err instanceof Error ? err.message : String(err); - const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace(/https?:\/\/[^\s"'<>]+/gi, "[upstream-url]"); + const safeMsg = redactErrorMessage(sanitizeUpstreamErrorText(rawMsg)).replace(/https?:\/\/[^\s"'<>]+/gi, "[upstream-url]"); return formatErrorResponse(502, "upstream_error", `CCA image body read failed: ${safeMsg}`); } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index b1a5de2125..589c0e4dee 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -49,6 +49,7 @@ import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaRepo import { clearKeyCooldowns } from "../../providers/key-failover"; import { providerRequestPacingStatus } from "../../providers/request-pacing"; import { getProviderTlsProfileStatus } from "../../lib/provider-tls-profile"; +import { redactErrorMessage } from "../../lib/redact"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; @@ -412,7 +413,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } }); + test("redacts proxy credentials from profiled native errors", async () => { + accountSets = { "google-antigravity": { accounts: [{ id: "a1", credential: { projectId: "proj-9" } }], activeAccountId: "a1" } }; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + throw new Error("native connect failed at http://proxy-user:proxy-secret@example.test:8080/?access_token=gem-token-abc"); + }, + }), + }); + try { + const out = await runGeminiWebSearch("q", "google-antigravity", { + ...cca, + googleMode: "cloud-code-assist", + tlsProfile: "antigravity-browser", + }, { model: "gemini-3.7-flash", reasoning: "low", timeoutMs: 5000 }); + expect(out.error).toContain("native connect failed"); + expect(out.error).not.toMatch(/proxy-user|proxy-secret|gem-token-abc|access_token/); + } finally { + resetProviderTlsProfileForTests(); + } + }); + test("malicious baseUrl ignored: registry destination, manual redirect, bearer + IDE UA, full envelope, thinkingConfig", async () => { accountSets = { "google-antigravity": { accounts: [{ id: "a1", credential: { projectId: "proj-9" } }], activeAccountId: "a1" } }; const captured: Array<{ url: string; init: RequestInit }> = []; diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 40c12c5254..1858bd26ce 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -41,6 +41,7 @@ import { LOCAL_PROVIDER_RELOAD_NAME_HEADER, LOCAL_PROVIDER_RELOAD_PATH } from ". import { getAccountSet, saveCredential } from "../src/oauth/store"; import { fastPolicyForModel } from "../src/providers/service-tier"; import { resolveWireProtocolOverride } from "../src/server/adapter-resolve"; +import { providerTlsFetch, resetProviderTlsProfileForTests, setProviderTlsRuntimeForTest } from "../src/lib/provider-tls-profile"; // Full-suite Windows load: startServer + multi-step provider PATCH/GET flows exceed the // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). @@ -116,6 +117,8 @@ beforeEach(() => { }); afterEach(() => { + resetProviderTlsProfileForTests(); + setProviderTlsRuntimeForTest(undefined); globalThis.fetch = originalGlobalFetch; if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken; @@ -2948,6 +2951,80 @@ describe("provider management validation", () => { expect(raw).not.toContain("must-not-leak"); }); + test("GET /api/providers clears active TLS status when the profile is removed", async () => { + const profiled = { + adapter: "google" as const, + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth" as const, + googleMode: "cloud-code-assist" as const, + tlsProfile: "antigravity-browser" as const, + }; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: { openai: { ...canonicalDirect }, "google-antigravity": profiled }, + }; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => new Response("ok"), + }), + }); + await providerTlsFetch("google-antigravity", profiled, globalThis.fetch)("https://daily-cloudcode-pa.googleapis.com/v1internal"); + const request = () => new Request("http://127.0.0.1/api/providers", { method: "GET" }); + const active = await handleManagementAPI(request(), new URL(request().url), liveConfig, {}); + const activeRow = (JSON.parse(await active!.text()) as Array<{ name: string; tlsProfileStatus?: string }>) + .find(row => row.name === "google-antigravity"); + expect(activeRow?.tlsProfileStatus).toBe("active"); + + liveConfig.providers["google-antigravity"] = { ...profiled, tlsProfile: undefined }; + const removedRequest = request(); + const removed = await handleManagementAPI(removedRequest, new URL(removedRequest.url), liveConfig, {}); + const removedRow = (JSON.parse(await removed!.text()) as Array<{ name: string; tlsProfile?: string; tlsProfileStatus?: string }>) + .find(row => row.name === "google-antigravity"); + expect(removedRow).toMatchObject({ tlsProfileStatus: "disabled" }); + expect(removedRow?.tlsProfile).toBeUndefined(); + }); + + test("provider connectivity diagnostics redact profiled native errors", async () => { + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: { + openai: { ...canonicalDirect }, + "google-antigravity": { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + googleMode: "cloud-code-assist", + tlsProfile: "antigravity-browser", + }, + }, + }; + await saveCredential("google-antigravity", { + access: "management-access-token", + refresh: "management-refresh-token", + expires: Date.now() + 3_600_000, + projectId: "management-project", + }); + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + throw new Error("native management failure at http://proxy-user:proxy-secret@example.test:8080/?access_token=management-access-token"); + }, + }), + }); + const req = new Request("http://127.0.0.1/api/providers/test?name=google-antigravity", { method: "POST" }); + const response = await handleManagementAPI(req, new URL(req.url), liveConfig, {}); + expect(response?.status).toBe(200); + const body = await response!.json() as { ok: boolean; error?: string }; + expect(body.ok).toBe(false); + expect(body.error).not.toMatch(/proxy-user|proxy-secret|management-access-token|access_token/); + }); + test("provider PATCH merges headers case-insensitively", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/provider-outbound.test.ts b/tests/provider-outbound.test.ts index f92d7775e0..098b51c354 100644 --- a/tests/provider-outbound.test.ts +++ b/tests/provider-outbound.test.ts @@ -63,6 +63,11 @@ describe("provider outbound GET transport", () => { return new Response(null, { status: 200 }); }) as typeof fetch; setProviderTlsRuntimeForTest({ + resolveDestination: mock(async () => ({ + hostname: "daily-cloudcode-pa.googleapis.com", + addresses: [{ address: "142.250.1.1", family: 4 }], + privateNetwork: false, + })), importWreq: async () => ({ createTransport: async () => ({ close: async () => undefined }), fetch: async () => { @@ -84,6 +89,11 @@ describe("provider outbound GET transport", () => { }, "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", { headers: { authorization: "Bearer redacted" }, body: "{}" }, + { resolveAddresses: mock(async () => ({ + hostname: "daily-cloudcode-pa.googleapis.com", + addresses: [{ address: "142.250.1.1", family: 4 }], + privateNetwork: false, + })) }, ); expect(response.status).toBe(200); expect(nativeCalls).toBe(1); @@ -93,6 +103,44 @@ describe("provider outbound GET transport", () => { } }); + test("rejects unsafe profiled DNS answers before native bearer dispatch", async () => { + for (const key of proxyKeys) delete process.env[key]; + let nativeCalls = 0; + setProviderTlsRuntimeForTest({ + resolveDestination: mock(async () => ({ + hostname: "daily-cloudcode-pa.googleapis.com", + addresses: [{ address: "142.250.1.1", family: 4 }], + privateNetwork: false, + })), + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + nativeCalls += 1; + return new Response("must not send"); + }, + }), + }); + const { providerOutboundPost } = await import("../src/lib/provider-outbound"); + await expect(providerOutboundPost( + "google-antigravity", + { + adapter: "google", + authMode: "oauth", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + tlsProfile: "antigravity-browser", + }, + "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", + { headers: { authorization: "Bearer must-not-send" }, body: "{}" }, + { + resolveAddresses: mock(async () => { + throw new Error("provider URL hostname daily-cloudcode-pa.googleapis.com resolves to a private-network address"); + }), + }, + )).rejects.toThrow(/private-network/); + expect(nativeCalls).toBe(0); + }); + test("direct HTTPS connects only to the validated address with TLS verification", async () => { for (const key of proxyKeys) delete process.env[key]; const { providerOutboundGet } = await import("../src/lib/provider-outbound"); diff --git a/tests/provider-tls-profile.test.ts b/tests/provider-tls-profile.test.ts index cdaf487a26..7f3d54fe2a 100644 --- a/tests/provider-tls-profile.test.ts +++ b/tests/provider-tls-profile.test.ts @@ -10,6 +10,7 @@ import { providerTlsFetch, } from "../src/lib/provider-tls-profile"; import { proxyForUrl } from "../src/lib/proxy-env"; +import { PROXY_ENV_KEYS } from "../src/lib/proxy-env"; import { providerFetch } from "../src/server/responses/fetch-helpers"; const canonicalProvider = { @@ -22,8 +23,14 @@ const canonicalProvider = { const userInfoUrl = new URL("https://daily-cloudcode-pa.googleapis.com/v1internal"); userInfoUrl.username = "user"; userInfoUrl.password = "secret"; +const originalProxyEnv = Object.fromEntries(PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]).map(key => [key, process.env[key]])); afterEach(() => { + for (const key of Object.keys(originalProxyEnv)) { + const previous = originalProxyEnv[key]; + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } resetProviderTlsProfileForTests(); setProviderTlsRuntimeForTest(undefined); }); @@ -235,8 +242,10 @@ describe("Antigravity TLS transport gate", () => { test("falls back once when import or construction fails and never replays post-dispatch errors", async () => { let bunCalls = 0; - const bunFetch = mock(async () => { + const fallbackInits: RequestInit[] = []; + const bunFetch = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { bunCalls += 1; + if (init) fallbackInits.push(init); return new Response("bun"); }) as typeof globalThis.fetch; setProviderTlsRuntimeForTest({ @@ -249,19 +258,73 @@ describe("Antigravity TLS transport gate", () => { expect(await executor("https://daily-cloudcode-pa.googleapis.com/v1internal")).toBeInstanceOf(Response); expect(await executor("https://daily-cloudcode-pa.googleapis.com/v1internal")).toBeInstanceOf(Response); expect(bunCalls).toBe(2); + expect(fallbackInits).toHaveLength(2); + expect(fallbackInits.every(init => init.redirect === "manual")).toBe(true); expect(getProviderTlsProfileStatus("google-antigravity")).toBe("fallback"); resetProviderTlsProfileForTests(); setProviderTlsRuntimeForTest({ importWreq: async () => ({ createTransport: async () => ({ close: async () => undefined }), - fetch: async () => { throw new Error("post-dispatch failure"); }, + fetch: async () => { throw new Error("post-dispatch failure at http://proxy-user:proxy-secret@example.test:8080/path?access_token=BearerSecret"); }, }), }); const noReplay = providerTlsFetch("google-antigravity", canonicalProvider, bunFetch); await expect(noReplay("https://daily-cloudcode-pa.googleapis.com/v1internal")).rejects.toThrow("post-dispatch failure"); + await expect(noReplay("https://daily-cloudcode-pa.googleapis.com/v1internal")).rejects.not.toThrow(/proxy-user|proxy-secret|BearerSecret|access_token/); expect(bunCalls).toBe(2); }); + + test("rejects private and metadata DNS answers before native dispatch", async () => { + for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"]) delete process.env[key]; + process.env.NO_PROXY = ""; + process.env.no_proxy = ""; + for (const detail of ["private-network address", "blocked metadata endpoint"]) { + let nativeCalls = 0; + let bunCalls = 0; + setProviderTlsRuntimeForTest({ + resolveDestination: async () => { throw new Error(`Antigravity profile URL targets ${detail}`); }, + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { nativeCalls += 1; return new Response("must not send"); }, + }), + }); + const bunFetch = mock(async () => { bunCalls += 1; return new Response("bun"); }) as typeof globalThis.fetch; + const executor = providerTlsFetch("google-antigravity", canonicalProvider, bunFetch); + await expect(executor("https://daily-cloudcode-pa.googleapis.com/v1internal", { + method: "POST", + headers: { authorization: "Bearer should-not-send" }, + body: "{}", + })).rejects.toThrow(detail); + expect(nativeCalls).toBe(0); + expect(bunCalls).toBe(0); + resetProviderTlsProfileForTests(); + } + }); + + test("does not locally resolve proxied destinations", async () => { + process.env.HTTPS_PROXY = "http://proxy-user:proxy-secret@example.test:8080"; + delete process.env.https_proxy; + process.env.NO_PROXY = ""; + delete process.env.no_proxy; + let resolverCalls = 0; + let nativeCalls = 0; + let transportProxy: string | undefined; + setProviderTlsRuntimeForTest({ + resolveDestination: async () => { resolverCalls += 1; throw new Error("must not resolve through proxy"); }, + importWreq: async () => ({ + createTransport: async options => { + transportProxy = options.proxy; + return { close: async () => undefined }; + }, + fetch: async () => { nativeCalls += 1; return new Response("ok"); }, + }), + }); + await providerTlsFetch("google-antigravity", canonicalProvider, globalThis.fetch)("https://daily-cloudcode-pa.googleapis.com/v1internal"); + expect(resolverCalls).toBe(0); + expect(nativeCalls).toBe(1); + expect(transportProxy).toContain("proxy-user"); + }); }); describe("Antigravity proxy selection", () => { diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index c2352e49ac..8f21144c1d 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -1227,6 +1227,46 @@ test("CCA image generation uses the opted-in profiled executor", async () => { } }); +test("CCA image native errors redact proxy credentials", async () => { + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + throw new Error("native image failure at http://proxy-user:proxy-secret@example.test:8080/?api_key=image-secret"); + }, + }), + }); + const config = { + ...ccaConfig(), + providers: { + ...ccaConfig().providers, + "google-antigravity": { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + googleMode: "cloud-code-assist", + tlsProfile: "antigravity-browser", + }, + }, + } as OcxConfig; + saveConfig(config); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + const response = await handleImages( + new Request("http://127.0.0.1/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a neon cat" }), + }), + config, + "generations", + { model: "", provider: "" }, + ); + expect(response.status).toBe(502); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("CCA image generation failed"); + expect(json.error.message).not.toMatch(/proxy-user|proxy-secret|image-secret|api_key/); +}); + test("CCA image fallback generates images via Google Antigravity when no OpenAI upstream exists", async () => { const registryHits: CcaFetchRequest[] = []; const otherHits: CcaFetchRequest[] = []; From dcb9717f4e1275edb095137a8bca6c7bd4741619 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:01:52 -0600 Subject: [PATCH 11/29] fix: preserve profiled transport cancellation --- src/lib/provider-tls-profile.ts | 22 +++++++++++++- tests/gemini-web-search.test.ts | 30 ++++++++++++++++++- tests/provider-tls-profile.test.ts | 47 ++++++++++++++++++++++++++++++ tests/server-images.test.ts | 42 ++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/lib/provider-tls-profile.ts b/src/lib/provider-tls-profile.ts index 3171e6e3b9..b354031a72 100644 --- a/src/lib/provider-tls-profile.ts +++ b/src/lib/provider-tls-profile.ts @@ -48,9 +48,29 @@ function setStatus(providerName: string, status: ProviderTlsProfileStatus): void statusByProvider.set(providerName, status); } +const SAFE_NATIVE_ERROR_NAMES = new Set(["AbortError", "TimeoutError"]); +const SAFE_NATIVE_ERROR_CODES = new Set([ + "ABORT_ERR", "ECONNREFUSED", "ECONNRESET", "EAI_AGAIN", "ENETUNREACH", "ENOTFOUND", + "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", +]); + function safeProviderTlsError(error: unknown): Error { const raw = error instanceof Error ? error.message : String(error); - return new Error(redactErrorMessage(raw)); + const safe = new Error(redactErrorMessage(raw)); + if (!error || typeof error !== "object") return safe; + const native = error as { name?: unknown; code?: unknown }; + if (typeof native.name === "string" && SAFE_NATIVE_ERROR_NAMES.has(native.name)) { + safe.name = native.name; + } + if (typeof native.code === "string" && SAFE_NATIVE_ERROR_CODES.has(native.code)) { + Object.defineProperty(safe, "code", { + configurable: true, + enumerable: false, + value: native.code, + writable: false, + }); + } + return safe; } function warnFallbackOnce(): void { diff --git a/tests/gemini-web-search.test.ts b/tests/gemini-web-search.test.ts index 69b92f09a1..e66e555700 100644 --- a/tests/gemini-web-search.test.ts +++ b/tests/gemini-web-search.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import * as storeModule from "../src/oauth/store"; import { MAX_SIDECAR_RESPONSE_BYTES } from "../src/web-search/parse"; @@ -173,6 +173,34 @@ describe("runGeminiWebSearch request shape (review P1)", () => { } }); + test("classifies a profiled TimeoutError as a timeout without leaking details", async () => { + accountSets = { "google-antigravity": { accounts: [{ id: "a1", credential: { projectId: "proj-9" } }], activeAccountId: "a1" } }; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + const timeout = new Error("timed out at http://proxy-user:proxy-secret@example.test:8080/?token=gem-secret"); + timeout.name = "TimeoutError"; + throw timeout; + }, + }), + }); + const warning = spyOn(console, "warn").mockImplementation(() => undefined); + try { + const out = await runGeminiWebSearch("q", "google-antigravity", { + ...cca, + googleMode: "cloud-code-assist", + tlsProfile: "antigravity-browser", + }, { model: "gemini-3.7-flash", reasoning: "low", timeoutMs: 5000 }); + expect(out.error).toContain("timed out"); + expect(out.error).not.toMatch(/proxy-user|proxy-secret|gem-secret|token=/); + expect(warning).toHaveBeenCalledWith(expect.stringContaining("timeout")); + } finally { + warning.mockRestore(); + resetProviderTlsProfileForTests(); + } + }); + test("malicious baseUrl ignored: registry destination, manual redirect, bearer + IDE UA, full envelope, thinkingConfig", async () => { accountSets = { "google-antigravity": { accounts: [{ id: "a1", credential: { projectId: "proj-9" } }], activeAccountId: "a1" } }; const captured: Array<{ url: string; init: RequestInit }> = []; diff --git a/tests/provider-tls-profile.test.ts b/tests/provider-tls-profile.test.ts index 7f3d54fe2a..e73b569071 100644 --- a/tests/provider-tls-profile.test.ts +++ b/tests/provider-tls-profile.test.ts @@ -275,6 +275,53 @@ describe("Antigravity TLS transport gate", () => { expect(bunCalls).toBe(2); }); + test("preserves only safe cancellation identity while redacting native details", async () => { + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + const timeout = new Error("timeout at http://proxy-user:proxy-secret@example.test:8080/?access_token=secret-token"); + timeout.name = "TimeoutError"; + Object.assign(timeout, { code: "ETIMEDOUT", cause: new Error("secret cause") }); + throw timeout; + }, + }), + }); + const executor = providerTlsFetch("google-antigravity", canonicalProvider, globalThis.fetch); + let caught: unknown; + try { + await executor("https://daily-cloudcode-pa.googleapis.com/v1internal"); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect(caught).toMatchObject({ name: "TimeoutError", code: "ETIMEDOUT" }); + expect((caught as Error).message).not.toMatch(/proxy-user|proxy-secret|secret-token|access_token/); + expect((caught as { cause?: unknown }).cause).toBeUndefined(); + expect((caught as Error).stack).not.toContain("secret-token"); + }); + + test("preserves AbortError without replaying through Bun", async () => { + let bunCalls = 0; + const bunFetch = mock(async () => { + bunCalls += 1; + return new Response("bun"); + }) as typeof globalThis.fetch; + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + const abort = new Error("aborted at http://proxy-user:proxy-secret@example.test"); + abort.name = "AbortError"; + throw abort; + }, + }), + }); + const executor = providerTlsFetch("google-antigravity", canonicalProvider, bunFetch); + await expect(executor("https://daily-cloudcode-pa.googleapis.com/v1internal")).rejects.toMatchObject({ name: "AbortError" }); + expect(bunCalls).toBe(0); + }); + test("rejects private and metadata DNS answers before native dispatch", async () => { for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"]) delete process.env[key]; process.env.NO_PROXY = ""; diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 8f21144c1d..6e1936dd7c 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -1267,6 +1267,48 @@ test("CCA image native errors redact proxy credentials", async () => { expect(json.error.message).not.toMatch(/proxy-user|proxy-secret|image-secret|api_key/); }); +test("CCA image preserves profiled TimeoutError semantics", async () => { + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + const timeout = new Error("timed out at http://proxy-user:proxy-secret@example.test:8080/?token=image-secret"); + timeout.name = "TimeoutError"; + throw timeout; + }, + }), + }); + const config = { + ...ccaConfig(), + providers: { + ...ccaConfig().providers, + "google-antigravity": { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + googleMode: "cloud-code-assist", + tlsProfile: "antigravity-browser", + }, + }, + } as OcxConfig; + saveConfig(config); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + const response = await handleImages( + new Request("http://127.0.0.1/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a neon cat" }), + }), + config, + "generations", + { model: "", provider: "" }, + ); + expect(response.status).toBe(504); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("timed out"); + expect(json.error.message).not.toMatch(/proxy-user|proxy-secret|image-secret|token=/); +}); + test("CCA image fallback generates images via Google Antigravity when no OpenAI upstream exists", async () => { const registryHits: CcaFetchRequest[] = []; const otherHits: CcaFetchRequest[] = []; From 8372133f4739daecd874cc576f58adf9924f9be0 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:15:16 -0600 Subject: [PATCH 12/29] docs: document antigravity safeguards --- .../fr/reference/configuration/providers.md | 3 +- .../src/content/docs/guides/providers.md | 15 +++ .../ja/reference/configuration/providers.md | 3 +- .../ko/reference/configuration/providers.md | 3 +- .../docs/reference/configuration/providers.md | 8 +- .../ru/reference/configuration/providers.md | 3 +- .../tr/reference/configuration/providers.md | 3 +- .../reference/configuration/providers.md | 3 +- .../reference/configuration/providers.md | 3 +- .../provider-workspace/ProviderSettings.tsx | 32 +++++- .../components/provider-workspace/types.ts | 1 + gui/src/i18n/de.ts | 8 ++ gui/src/i18n/en.ts | 8 ++ gui/src/i18n/fr.ts | 8 ++ gui/src/i18n/ja.ts | 8 ++ gui/src/i18n/ko.ts | 8 ++ gui/src/i18n/ru.ts | 8 ++ gui/src/i18n/tr.ts | 8 ++ gui/src/i18n/zh-TW.ts | 8 ++ gui/src/i18n/zh.ts | 8 ++ gui/src/pages/providers-shared.ts | 2 + gui/src/provider-workspace/catalog.ts | 2 + ...provider-settings-antigravity-tls.test.tsx | 102 ++++++++++++++++++ src/server/auth-cors.ts | 3 + src/server/management/provider-routes.ts | 11 ++ tests/management-provider-validation.test.ts | 26 +++++ 26 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 gui/tests/provider-settings-antigravity-tls.test.tsx diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index feedf5ad0c..3b7f9b99d7 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -66,7 +66,8 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | --- | --- | --- | | `adapter` | `string` | L'un des `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (ou alias `azure`). | | `baseUrl` | `string` | URL de base de l'API en amont. La plupart des points de terminaison fixes intégrés ignorent une valeur incompatible ; les préréglages de clés protégés contre les collisions préservent une ancienne destination personnalisée portant le même nom. | -| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Cadencement facultatif du démarrage des requêtes sortantes côté client, distinct de l’utilisation, de la facturation et des indicateurs de limitation en amont. Le nombre de requêtes par minute est converti en intervalle régulier ; `minIntervalMs` peut imposer un intervalle plus long. Les limites du fournisseur s’appliquent à tous ses modèles, tandis que les entrées `models` ciblent les identifiants exacts des modèles en amont, par exemple `nvidia/llama-3.1-nemotron-ultra-253b-v1`, et ne peuvent qu’ajouter du délai. L’attente dans la file ne consomme pas le délai d’expiration des en-têtes de réponse en amont. Les requêtes HTTP, Responses WebSocket et les distributions explicites `fetchResponse`/`runTurn` des adaptateurs sont couvertes. | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, jitterMs?, models? }` | Cadencement facultatif du démarrage des requêtes sortantes côté client ; `jitterMs` ajoute uniquement un délai aléatoire positif (0 à 60 000 ms). Les règles de modèle ne peuvent qu’augmenter le délai. | +| `tlsProfile?` | `"antigravity-browser"` | Profil TLS/HTTP2 expérimental et non officiel, limité à Google Antigravity Cloud Code Assist et à ses hôtes canoniques. Il ne garantit ni la conformité aux conditions d’utilisation ni l’absence de suspension, peut rendre le trafic plus distinctif et revient à Bun si l’initialisation échoue. | | `responsesPath?` | `string` | Chemin de ressource relatif pour les requêtes d'authentification par clé `openai-responses`. Il doit commencer par `/` et ne contenir aucun schéma, requête ou fragment. | | `supportsServiceTier?` | `boolean` | Repli à trois états pour la capacité `service_tier`. `true` : le mode rapide peut injecter le champ et les valeurs de l’appelant sont conservées. `false` : le champ est retiré et jamais injecté, et aucune déclaration précise de modèle ne peut le réactiver. Absent : le fournisseur n’est pas classé ; les valeurs de l’appelant sont conservées intactes et le mode rapide n’injecte rien, sauf pour un modèle exact activé. Le registre classe OpenAI canonique comme `true`, et DeepSeek ainsi que Volcengine Ark comme `false`. Ne le définissez explicitement que pour les passerelles personnalisées qui prennent réellement en charge les niveaux. Les routes Chat exigent en plus une autorisation globale ou propre au modèle. | | `modelSupportsServiceTier?` | `Record` | Remplacements de capacité par identifiant exact de modèle en amont. La valeur exacte `true` autorise ce modèle Chat même sans `chatServiceTier` ; `false` restreint les valeurs globales et l’autorisation Chat. Une valeur globale explicite `supportsServiceTier: false` reste fermée et ne peut pas être réactivée. Les modèles non déclarés suivent le comportement global. La requête de gestion `PATCH /api/providers` fusionne les entrées et accepte `null` pour en supprimer une. | diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e58af8360c..41af0f623a 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -116,6 +116,21 @@ ocx logout | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | + +### Antigravity pacing and TLS profile + +The built-in `google-antigravity` provider uses conservative request pacing by default: 30 RPM, +at least 2,000 ms between request starts, and up to 500 ms of positive jitter. Existing explicit +`requestPacing` settings remain authoritative; `jitterMs` may be set from 0 through 60,000 ms and +only delays a start. Model rules can make the provider slower, never faster. + +The dashboard can explicitly enable `tlsProfile: "antigravity-browser"` for this provider. This is +an experimental, unofficial compatibility mechanism, not a compliance feature. +It may make traffic more distinctive, and initialization failures fall back to Bun; requests that +already reached the native transport are not replayed. The profile is limited to canonical Cloud +Code Assist hosts, keeps certificate and hostname verification enabled, and leaves OAuth/token/ +onboarding requests on standard Bun TLS. Users who prioritize account-policy safety should use the +official Gemini API-key, Vertex, or documented Gemini Code Assist routes. | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 27cf1406ec..6bee512f22 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -56,7 +56,8 @@ account を削除しても mapping は保持され、同じ id を再追加す | --- | --- | --- | | `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai` (または別名 `azure`) のいずれか。 | | `baseUrl` | `string` |アップストリーム API のベース URL。ほとんどの組み込み固定エンドポイントは不一致を無視します。衝突安全キー プリセットは、古い同じ名前のカスタム宛先を保持します。 | -| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 上流の使用量、請求、レート制限表示とは別の、クライアント側の送信開始間隔調整です。プロバイダー制限は全モデルに適用され、`models` は上流の正確なモデル ID に一致し、遅延を増やす場合のみ有効です。キュー待機は応答ヘッダーのタイムアウトを消費しません。HTTP、Responses WebSocket、明示的なアダプターの `fetchResponse`/`runTurn` 送信を対象にします。 | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, jitterMs?, models? }` | 上流の使用量などとは別の送信間隔調整です。`jitterMs` は 0~60,000 ms の正のランダム遅延だけを追加し、モデル設定は遅延を増やす場合のみ有効です。 | +| `tlsProfile?` | `"antigravity-browser"` | Google Antigravity Cloud Code Assist の正規ホストだけで使える、実験的で非公式な TLS/HTTP2 互換プロファイルです。利用規約への準拠や停止防止を保証せず、トラフィックを特徴的にする可能性があり、初期化に失敗すると Bun に戻ります。 | | `responsesPath?` | `string` |キー認証 `openai-responses` リクエストの相対リソース パス。 `/` で始まり、スキーム、クエリ、またはフラグメントが含まれていない必要があります。 | | `supportsServiceTier?` | `boolean` | `service_tier` ケイパビリティの 3 状態です。`true`: fast モードが注入でき、呼び出し元の値も保持されます。`false`: フィールドは削除され、注入もされません (非対応と文書化されたアップストリームには送りません)。未設定: 未分類 — 呼び出し元の値はそのまま保持され、fast モードは注入しません。レジストリは正規 OpenAI (`true`)、DeepSeek、Volcengine Ark (`false`) を分類します。実際にティアをサポートするカスタム ゲートウェイにのみ明示的に設定してください。 | | `preserveResponsesReasoningContent?` | `boolean` | リプレイされる Responses reasoning アイテムの平文 reasoning コンテンツを消去せずに保持します (消去は ChatGPT バックエンドのルールです)。DeepSeek のように reasoning リプレイを受け入れるアップストリームで有効にしてください。プロキシ生成の `ocxr1` エンベロープは常に削除されます。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 707129b2ed..4465154f89 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -56,7 +56,8 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | --- | --- | --- | | `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` 중 하나이며, `azure`는 별칭입니다. | | `baseUrl` | `string` | 상위 API 기본 URL입니다. 대부분의 내장 고정 엔드포인트는 불일치를 무시합니다. 충돌 안전 키 프리셋은 같은 이름의 이전 사용자 지정 목적지를 보존합니다. | -| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 업스트림 사용량, 과금, rate-limit 지표와 별개인 선택적 클라이언트 측 아웃바운드 요청 시작 속도 조절입니다. Provider 제한은 모든 모델에 적용되고 `models` 항목은 정확한 업스트림 모델 ID와 일치하며 지연을 더 늘릴 때만 적용됩니다. 큐 대기는 응답 헤더 타임아웃을 소모하지 않습니다. HTTP, Responses WebSocket, 명시적 어댑터 `fetchResponse`/`runTurn` 전송을 포함합니다. | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, jitterMs?, models? }` | 업스트림 지표와 별개인 요청 시작 조절입니다. `jitterMs`는 0~60,000ms의 양의 무작위 지연만 추가하며 모델 규칙은 지연을 늘릴 때만 적용됩니다. | +| `tlsProfile?` | `"antigravity-browser"` | Google Antigravity Cloud Code Assist 정규 호스트에만 적용되는 실험적·비공식 TLS/HTTP2 호환 프로필입니다. 약관 준수나 정지 방지를 보장하지 않고 트래픽을 더 식별하기 쉽게 만들 수 있으며 초기화 실패 시 Bun으로 대체됩니다. | | `responsesPath?` | `string` | 키 인증 `openai-responses` 요청의 상대 리소스 경로입니다. 반드시 `/`로 시작해야 하며 스킴, query, fragment를 포함하면 안 됩니다. | | `supportsServiceTier?` | `boolean` | `service_tier` 케이퍼빌리티 3상태입니다. `true`: fast 모드가 주입할 수 있고 호출자 값도 보존합니다. `false`: 필드를 제거하고 절대 주입하지 않습니다(미지원으로 문서화된 업스트림에는 볼 수 없습니다). 미설정: 미분류 — 호출자가 준 값은 그대로 보존하고 fast 모드는 주입하지 않습니다. 레지스트리는 정식 OpenAI(`true`), DeepSeek, Volcengine Ark(`false`)를 분류하며, 실제로 티어를 지원하는 커스텀 게이트웨이에만 명시적으로 설정하세요. | | `preserveResponsesReasoningContent?` | `boolean` | 리플레이되는 Responses reasoning 항목의 평문 reasoning 내용을 지우지 않고 유지합니다(지우는 것은 ChatGPT 백엔드 규칙입니다). DeepSeek처럼 reasoning 리플레이를 허용하는 업스트림에 켜세요. 프록시가 만든 `ocxr1` 봉투는 항상 제거됩니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c44b628714..a1c70674a9 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -66,7 +66,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | --- | --- | --- | | `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (or alias `azure`). | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | -| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while `models` entries use exact upstream model IDs (for example `nvidia/llama-3.1-nemotron-ultra-253b-v1`) and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, jitterMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval; `jitterMs` adds only a positive random delay (0–60,000 ms). Provider limits apply across all models, while `models` entries use exact upstream model IDs and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | +| `tlsProfile?` | `"antigravity-browser"` | Explicit, experimental Antigravity-only TLS/HTTP2 compatibility profile. It requires Google OAuth Cloud Code Assist and canonical Antigravity hosts. It is unofficial, does not ensure Terms-of-Service compliance or prevent suspension, may make traffic more distinctive, and falls back to Bun if initialization fails. OAuth/token/onboarding traffic remains on standard Bun TLS. Prefer official Gemini API-key, Vertex, or documented Code Assist routes when policy safety matters. | | `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. An explicit pin requires an HTTPS target and fails locally when it cannot be honored. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | | `supportsServiceTier?` | `boolean` | Tri-state canonical Fast capability fallback. `true` publishes Fast in the catalog, satisfies service-tier routing requirements, contributes a supported fingerprint, and lets fast mode inject the provider's canonical wire value on a compatible final adapter. `false` strips the field and never injects, and exact model declarations cannot reopen it. Absent leaves the provider unclassified: fast mode does not inject or normalize a canonical caller value, and caller values obey the final wire's forwarding permission (`chatServiceTier` on Chat; passthrough on Responses). The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. | @@ -209,7 +210,10 @@ configured under [`claudeCode.authMode`](/reference/configuration/server/#claude Dashboard connection tests and live model discovery use a bounded GET-only transport. Without an outbound proxy, opencodex resolves the hostname once and connects only to that validated address. HTTPS retains the original Host, SNI, and certificate verification; provider config cannot disable -certificate checks. +certificate checks. The native TLS profile performs a direct DNS safety preflight, but its native +transport cannot pin the checked address, so a small DNS time-of-check/time-of-use residual remains; +normal certificate and hostname verification are separate controls. Proxy-routed requests retain +the proxy's peer-selection boundary. When `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` applies, these operations keep Bun's native fetch. URL and literal-address checks still run, but the proxy chooses the final route, DNS answer, and peer, diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 21b70bebb5..20a662aea7 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -69,7 +69,8 @@ cross-route credential fallback не существует. Строки API GPT- | --- | --- | --- | | `adapter` | `string` | Один из `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (или alias `azure`). | | `baseUrl` | `string` | Базовый URL API upstream'а. Большинство built-in fixed-endpoint'ов игнорируют несовпадение; collision-safe key-preset'ы сохраняют старый custom destination с тем же именем. | -| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Опциональное клиентское выравнивание начала исходящих запросов, отдельное от учёта использования, биллинга и индикаторов rate limit апстрима. Лимит провайдера действует на все модели, а `models` сопоставляется с точными ID моделей апстрима и может только увеличить задержку. Ожидание очереди не расходует таймаут заголовков ответа. Поддерживаются HTTP, Responses WebSocket и явные вызовы адаптеров `fetchResponse`/`runTurn`. | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, jitterMs?, models? }` | Клиентское выравнивание начала запросов. `jitterMs` добавляет только положительную случайную задержку от 0 до 60 000 мс; правила моделей могут лишь увеличить задержку. | +| `tlsProfile?` | `"antigravity-browser"` | Экспериментальный неофициальный TLS/HTTP2-профиль только для Google Antigravity Cloud Code Assist и канонических хостов. Он не гарантирует соблюдение условий или отсутствие блокировки, может сделать трафик более отличимым и переходит на Bun при ошибке инициализации. | | `responsesPath?` | `string` | Relative resource path для key-auth запросов `openai-responses`. Должен начинаться с `/` и не может содержать scheme, query или fragment. | | `supportsServiceTier?` | `boolean` | Три состояния поддержки `service_tier`. `true`: fast mode может подставлять поле, значения вызывающего сохраняются. `false`: поле удаляется и никогда не подставляется (апстрим, для которого задокументировано отсутствие поддержки, не должен его получать). Не задано: провайдер не классифицирован — значения вызывающего сохраняются без изменений, fast mode не подставляет. Registry классифицирует canonical OpenAI (`true`), DeepSeek и Volcengine Ark (`false`); задавайте явно только для custom gateway'ев, реально поддерживающих tier'ы. | | `preserveResponsesReasoningContent?` | `boolean` | Сохранять plaintext reasoning content в replay'нутых Responses reasoning item'ах вместо очистки (очистка — правило ChatGPT backend'а). Включайте для upstream'ов, чей контракт принимает reasoning replay, например DeepSeek. Proxy-minted `ocxr1` envelope'ы удаляются всегда. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index d4e414700a..344c8615ed 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -76,6 +76,8 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | --- | --- | --- | | `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (veya takma ad `azure`) seçeneklerinden biri. | | `baseUrl` | `string` | Yukarı akış API temel URL'si. Çoğu yerleşik sabit uç nokta uyumsuzluğu yok sayar; çakışma güvenli anahtar önayarları aynı adlı daha eski özel bir hedefi korur. | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, jitterMs?, models? }` | İstemci tarafı istek başlangıç aralığı. `jitterMs` yalnızca 0–60.000 ms pozitif rastgele gecikme ekler; model kuralları yalnızca gecikmeyi artırabilir. | +| `tlsProfile?` | `"antigravity-browser"` | Yalnızca Google Antigravity Cloud Code Assist kanonik ana bilgisayarları için deneysel, resmi olmayan TLS/HTTP2 uyumluluk profili. Kullanım şartlarına uyumu veya askıya alınmayı önlemeyi garanti etmez, trafiği daha ayırt edilebilir kılabilir ve başlatma başarısız olursa Bun'a döner. | | `responsesPath?` | `string` | Anahtar kimlik doğrulamalı `openai-responses` istekleri için göreli kaynak yolu. `/` ile başlamalı ve şema, sorgu veya parça içermemelidir. | | `supportsServiceTier?` | `boolean` | Üç durumlu `service_tier` yeteneği. `true`: hızlı mod enjekte edebilir ve arayan değerleri korunur. `false`: alan kaldırılır ve asla enjekte edilmez (desteklemediği belgelenen yukarı akış bunu almamalıdır). Yok: sağlayıcı sınıflandırılmamıştır — arayan tarafından sağlanan değerler dokunulmadan korunur ve hızlı mod asla enjekte etmez. Kayıt defteri kurallı OpenAI'yi (`true`), DeepSeek'i ve Volcengine Ark'ı (`false`) sınıflandırır; bunu yalnızca katmanları gerçekten destekleyen özel ağ geçitleri için açıkça ayarlayın. | | `preserveResponsesReasoningContent?` | `boolean` | Düz metin akıl yürütme içeriğini boşaltmak yerine (boşaltma ChatGPT arka ucunun kuralıdır) tekrarlanan Responses akıl yürütme öğelerinde tutun. DeepSeek gibi sözleşmesi akıl yürütme tekrarını kabul eden yukarı akışlar için etkinleştirin. Proxy tarafından basılan `ocxr1` zarfları her zaman kaldırılır. | @@ -479,4 +481,3 @@ bildirir; senkronize edilen katalog `xhigh`'ı ayrı tutarken `max` bildirir. "visionSidecar": { "enabled": true } } ``` - diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 1564842cbb..64a8f9a3c3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -56,7 +56,8 @@ selector,而不是分配一个新名称。 | --- | --- | --- | | `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai`(或别名 `azure`)之一。 | | `baseUrl` | `string` | 上游 API 基础 URL。大多数内置固定端点会忽略不匹配的值;具备冲突安全键的预设会保留一个更早、同名的自定义目标。 | -| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 可选的客户端出站请求启动节流,与上游用量、计费和限流指标相互独立。提供商限制适用于所有模型,`models` 按上游模型精确 ID 匹配且只能增加延迟。排队等待不计入响应头超时。覆盖 HTTP、Responses WebSocket 以及显式适配器 `fetchResponse`/`runTurn` 调用。 | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, jitterMs?, models? }` | 可选的客户端请求启动节流。`jitterMs` 只会增加 0 到 60,000 毫秒的正随机延迟;模型规则只能进一步增加延迟。 | +| `tlsProfile?` | `"antigravity-browser"` | 仅用于 Google Antigravity Cloud Code Assist 正规主机的实验性、非官方 TLS/HTTP2 兼容配置。不保证遵守服务条款或避免停用,可能使流量更具特征,初始化失败时回退到 Bun。 | | `responsesPath?` | `string` | 用于 key-auth `openai-responses` 请求的相对资源路径。必须以 `/` 开头,且不能包含 scheme、query 或 fragment。 | | `supportsServiceTier?` | `boolean` | `service_tier` 能力的三态。`true`:fast 模式可以注入,调用方提供的值也会被保留。`false`:剥离该字段且绝不注入(已明确不支持的上游不会收到它)。未设置:未分类——调用方提供的值原样保留,fast 模式绝不注入。注册表已对官方 OpenAI(`true`)、DeepSeek 和 Volcengine Ark(`false`)分类;仅对真正支持分层的自定义网关显式设置。 | | `preserveResponsesReasoningContent?` | `boolean` | 在重放的 Responses reasoning 项中保留明文 reasoning 内容,而不是清空(清空是 ChatGPT 后端的规则)。对接受 reasoning 重放的上游(如 DeepSeek)启用。代理生成的 `ocxr1` 信封始终会被剥离。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index f47b5bef05..146b856c7b 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -40,7 +40,8 @@ description: 供應商項目、認證、端點、模型目錄、配額、context | --- | --- | --- | | `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai`(或別名 `azure`)之一。 | | `baseUrl` | `string` | 上游 API base URL。多數內建固定端點忽略不符;碰撞安全的金鑰預設保留較舊的同名自訂目的地。 | -| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 選用的用戶端出站請求啟動節流,與上游用量、計費及限流指標彼此獨立。供應商限制適用於所有模型,`models` 依上游模型精確 ID 比對且只能增加延遲。排隊等待不計入回應標頭逾時。涵蓋 HTTP、Responses WebSocket 及明確的適配器 `fetchResponse`/`runTurn` 呼叫。 | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, jitterMs?, models? }` | 可選的用戶端請求啟動節流。`jitterMs` 只會增加 0 至 60,000 毫秒的正隨機延遲;模型規則只能進一步增加延遲。 | +| `tlsProfile?` | `"antigravity-browser"` | 僅適用於 Google Antigravity Cloud Code Assist 正規主機的實驗性、非官方 TLS/HTTP2 相容設定。不保證符合服務條款或避免停權,可能讓流量更具辨識度,初始化失敗時回退到 Bun。 | | `responsesPath?` | `string` | Key-auth `openai-responses` 請求的相對資源路徑。必須以 `/` 開頭且不含 scheme、query 或 fragment。 | | `disabled?` | `boolean` | 將供應商保留在磁碟上但排除於路由與模型/目錄清單。 | | `apiKey?` | `string` | API 金鑰,或在請求時解析的 `${ENV_VAR}` / `$ENV_VAR` 參考。 | diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index 3f50feb8e1..41cfc4c354 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -106,6 +106,7 @@ export default function ProviderSettings({ const [pacingModelDelay, setPacingModelDelay] = useState(""); const [pacingModelJitter, setPacingModelJitter] = useState(""); const [pacingStatus, setPacingStatus] = useState(null); + const [tlsProfile, setTlsProfile] = useState(item.tlsProfile); /* eslint-disable react-hooks/set-state-in-effect -- intentional form reset when saved provider fields change */ useEffect(() => { @@ -123,10 +124,11 @@ export default function ProviderSettings({ setPacingDelay(numberDraft(item.requestPacing?.minIntervalMs)); setPacingJitter(numberDraft(item.requestPacing?.jitterMs)); setPacingModels({ ...(item.requestPacing?.models ?? {}) }); + setTlsProfile(item.tlsProfile); setMsg(null); setModeMsg(null); queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl))); - }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, savedLiveModels, savedCursorHttpVersion, item.requestPacing, baseUrlChoices]); + }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, savedLiveModels, savedCursorHttpVersion, item.requestPacing, item.tlsProfile, baseUrlChoices]); /* eslint-enable react-hooks/set-state-in-effect */ // Account mode syncs on its own: a mode PATCH refresh must not reset an in-progress @@ -206,7 +208,8 @@ export default function ProviderSettings({ || liveModels !== savedLiveModels || (adapter.trim() === "cursor" && cursorHttpVersion !== savedCursorHttpVersion); const pacingDirty = pacingSignature(pacingDraft) !== pacingSignature(item.requestPacing); - const formDirty = dirty || pacingDirty; + const tlsDirty = tlsProfile !== item.tlsProfile; + const formDirty = dirty || pacingDirty || tlsDirty; useEffect(() => { onDirtyChange?.(formDirty); return () => onDirtyChange?.(false); }, [formDirty, onDirtyChange]); @@ -239,6 +242,10 @@ export default function ProviderSettings({ ? resolvedBaseUrlForChoice(baseUrlChoices, endpointChoice, baseUrl) : baseUrl.trim(); if (!adapter.trim() || !nextBaseUrl) { setMsg({ ok: false, text: t("pws.adapterBaseRequired") }); return false; } + if (tlsDirty && tlsProfile === "antigravity-browser" && !window.confirm(t("pws.antigravityTlsConfirm"))) { + setTlsProfile(item.tlsProfile); + return false; + } setSaving(true); setMsg(null); try { @@ -256,6 +263,7 @@ export default function ProviderSettings({ note: note.trim(), allowPrivateNetwork, ...(pacingDirty ? { requestPacing: pacingDraft } : {}), + ...(tlsDirty ? { tlsProfile: tlsProfile ?? null } : {}), }; if (!pacingOnly) { // Keep omitted legacy values omitted unless the user actually changes this toggle. @@ -314,6 +322,7 @@ export default function ProviderSettings({ setPacingEnabled(item.requestPacing?.enabled === true); setPacingRpm(numberDraft(item.requestPacing?.requestsPerMinute)); setPacingDelay(numberDraft(item.requestPacing?.minIntervalMs)); setPacingModels({ ...(item.requestPacing?.models ?? {}) }); setPacingJitter(numberDraft(item.requestPacing?.jitterMs)); setPacingModelJitter(""); + setTlsProfile(item.tlsProfile); setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl)); }; @@ -342,6 +351,25 @@ export default function ProviderSettings({ {t("pws.providerId")} + {item.name === "google-antigravity" && ( +
+ {t("pws.antigravityTlsTitle")} + + {t("pws.antigravityTlsWarning")} + + {t("pws.antigravityTlsStatus", { status: item.tlsProfileStatus === "active" ? t("pws.antigravityTlsActive") : item.tlsProfileStatus === "fallback" ? t("pws.antigravityTlsFallback") : t("pws.antigravityTlsDisabled") })} + +
+ )} - {t("pws.antigravityTlsWarning")} + {t("pws.antigravityTlsStatus", { status: item.tlsProfileStatus === "active" ? t("pws.antigravityTlsActive") : item.tlsProfileStatus === "fallback" ? t("pws.antigravityTlsFallback") : t("pws.antigravityTlsDisabled") })} diff --git a/gui/tests/provider-settings-antigravity-tls.test.tsx b/gui/tests/provider-settings-antigravity-tls.test.tsx index b3ee8062e8..317c25d706 100644 --- a/gui/tests/provider-settings-antigravity-tls.test.tsx +++ b/gui/tests/provider-settings-antigravity-tls.test.tsx @@ -79,6 +79,7 @@ async function saveTls(container: HTMLElement): Promise { test("Antigravity TLS profile is opt-in and requires confirmation before save", async () => { const { root, container, patches } = await mountSettings(item(), async () => ({ ok: true })); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Unofficial"); Object.defineProperty(testWindow, "confirm", { configurable: true, value: () => false }); await saveTls(container); expect(patches).toHaveLength(0); @@ -94,6 +95,43 @@ test("confirmed Antigravity TLS profile save sends the fixed profile and shows s await act(async () => { root.unmount(); }); }); +test("combined pacing and TLS save sends both fields after one confirmation", async () => { + const { root, container, patches } = await mountSettings(item(), async () => ({ ok: true })); + const rpm = container.querySelector(".pwi-pacing-grid input[type='number']"); + expect(rpm).toBeTruthy(); + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(rpm, "31"); + rpm!.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + container.querySelector("[data-testid='antigravity-tls-profile']")!.click(); + }); + await act(async () => { + container.querySelector(".pwi-settings-sticky-bar .btn-primary")!.click(); + await Promise.resolve(); + }); + expect(patches).toHaveLength(1); + expect(patches[0]?.requestPacing).toBeDefined(); + expect(patches[0]?.tlsProfile).toBe("antigravity-browser"); + await act(async () => { root.unmount(); }); +}); + +test("cancelled combined pacing and TLS save sends neither field", async () => { + const { root, container, patches } = await mountSettings(item(), async () => ({ ok: true })); + Object.defineProperty(testWindow, "confirm", { configurable: true, value: () => false }); + const rpm = container.querySelector(".pwi-pacing-grid input[type='number']"); + expect(rpm).toBeTruthy(); + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(rpm, "31"); + rpm!.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + container.querySelector("[data-testid='antigravity-tls-profile']")!.click(); + }); + await act(async () => { + container.querySelector(".pwi-settings-sticky-bar .btn-primary")!.click(); + await Promise.resolve(); + }); + expect(patches).toHaveLength(0); + await act(async () => { root.unmount(); }); +}); + test("other providers do not render the Antigravity TLS profile control", async () => { const { root, container, patches } = await mountSettings(item("openrouter"), async () => ({ ok: true })); expect(container.querySelector("[data-testid='antigravity-tls-profile']")).toBeNull(); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index c5256b79e1..3332e4ea05 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -28,7 +28,7 @@ import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in"; -import { getProviderTlsProfileStatus } from "../lib/provider-tls-profile"; +import { getProviderTlsProfileStatus, providerTlsProfileConfigError } from "../lib/provider-tls-profile"; let _corsOrigin = "http://localhost:10100"; export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; } @@ -574,6 +574,10 @@ export function providerManagementConfigError(name: unknown, provider: unknown): return `provider ${name} must not include codexAccountMode`; } const typed = provider as unknown as OcxProviderConfig; + const tlsProfileError = providerTlsProfileConfigError(name, typed); + if (tlsProfileError) { + return `provider ${JSON.stringify(redactSecretString(name))} ${tlsProfileError}`; + } const baseUrlError = providerBaseUrlConfigError(typed.baseUrl); if (baseUrlError) return `provider ${name} ${baseUrlError}`; if (effectiveGoogleMode(name, typed) === "vertex" && typed.location !== undefined) { diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 571da0eb83..ecd6c82dd2 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -2977,6 +2977,57 @@ describe("provider management validation", () => { expect(liveConfig.providers["google-antigravity"]?.tlsProfile).toBe("antigravity-browser"); }); + test("POST and PATCH reject Antigravity TLS profiles outside the canonical OAuth CCA contract", async () => { + const canonical = { + adapter: "google" as const, + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth" as const, + googleMode: "cloud-code-assist" as const, + tlsProfile: "antigravity-browser" as const, + }; + const cases = [ + { name: "other-provider", provider: { ...canonical } }, + { name: "google-antigravity", provider: { ...canonical, authMode: "key" as const } }, + { name: "google-antigravity", provider: { ...canonical, googleMode: "ai-studio" as const } }, + { name: "google-antigravity", provider: { ...canonical, baseUrl: "https://example.test" } }, + ]; + expect(providerManagementConfigError("google-antigravity", canonical)).toBeNull(); + for (const candidate of cases) { + expect(providerManagementConfigError(candidate.name, candidate.provider)).toContain("tlsProfile"); + + const postConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { openai: { ...canonicalDirect } }, + }; + const post = new Request("http://127.0.0.1/api/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: candidate.name, provider: candidate.provider }), + }); + expect((await handleManagementAPI(post, new URL(post.url), postConfig, {}))?.status).toBe(400); + + const patchConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalDirect }, + [candidate.name]: { ...candidate.provider, tlsProfile: undefined }, + }, + }; + const patch = new Request(`http://127.0.0.1/api/providers?name=${encodeURIComponent(candidate.name)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ tlsProfile: "antigravity-browser" }), + }); + expect((await handleManagementAPI(patch, new URL(patch.url), patchConfig, {}))?.status).toBe(400); + } + }); + test("GET /api/providers clears active TLS status when the profile is removed", async () => { const profiled = { adapter: "google" as const, From 1f7ca3e3e5f61fcb392a1005fdef5e89854ec1e9 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:28:13 -0600 Subject: [PATCH 14/29] docs: repair provider guide table --- docs-site/src/content/docs/guides/providers.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 4e8d53f6d8..89d9902c09 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -116,7 +116,6 @@ ocx logout | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | - | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | From c3cd54153ea5311c01b087951919aa48aa272a2b Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:50:26 -0600 Subject: [PATCH 15/29] fix: resolve antigravity whole-branch review --- src/lib/provider-tls-profile.ts | 39 +++++++++++++---- src/oauth/index.ts | 2 +- src/server/auth-cors.ts | 2 +- src/server/management/provider-routes.ts | 8 +++- src/server/responses/core.ts | 2 + tests/antigravity-responses.test.ts | 45 ++++++++++++++++++++ tests/google-antigravity-oauth.test.ts | 31 +++++++++++++- tests/management-provider-validation.test.ts | 12 ++++++ tests/provider-tls-profile.test.ts | 28 +++++++++++- 9 files changed, 153 insertions(+), 16 deletions(-) diff --git a/src/lib/provider-tls-profile.ts b/src/lib/provider-tls-profile.ts index b354031a72..221f9db4e0 100644 --- a/src/lib/provider-tls-profile.ts +++ b/src/lib/provider-tls-profile.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { OcxProviderConfig, ProviderTlsProfile } from "../types"; import { resolvePublicAddresses } from "./destination-policy"; import { registerOptionalShutdownHook } from "./optional-shutdown-hooks"; @@ -29,6 +30,8 @@ interface InitializedTransport { transport: WreqTransport; } +type ProviderTlsContract = Pick; + export interface ProviderTlsRuntimeForTest { importWreq: () => Promise; resolveDestination?: typeof resolvePublicAddresses; @@ -39,13 +42,24 @@ const defaultRuntime: ProviderTlsRuntimeForTest = { }; let runtime = defaultRuntime; -const statusByProvider = new Map(); +const statusByProvider = new Map(); const transports = new Map>(); let shutdownDetach: (() => void) | undefined; let fallbackWarned = false; -function setStatus(providerName: string, status: ProviderTlsProfileStatus): void { - statusByProvider.set(providerName, status); +function providerTlsContractFingerprint(providerName: string, provider: ProviderTlsContract): string { + return createHash("sha256").update(JSON.stringify([ + providerName, + provider.adapter, + provider.authMode, + provider.googleMode, + provider.baseUrl, + provider.tlsProfile, + ])).digest("hex"); +} + +function setStatus(providerName: string, provider: ProviderTlsContract, status: ProviderTlsProfileStatus): void { + statusByProvider.set(providerName, { status, fingerprint: providerTlsContractFingerprint(providerName, provider) }); } const SAFE_NATIVE_ERROR_NAMES = new Set(["AbortError", "TimeoutError"]); @@ -129,8 +143,15 @@ export function isCanonicalAntigravityUrl(input: string | URL): boolean { } } -export function getProviderTlsProfileStatus(providerName: string): ProviderTlsProfileStatus { - return statusByProvider.get(providerName) ?? "disabled"; +export function getProviderTlsProfileStatus(providerName: string, provider: ProviderTlsContract): ProviderTlsProfileStatus { + const current = statusByProvider.get(providerName); + return current?.fingerprint === providerTlsContractFingerprint(providerName, provider) + ? current.status + : "disabled"; +} + +export function clearProviderTlsProfileStatus(providerName: string): void { + statusByProvider.delete(providerName); } function registerShutdownHook(): void { @@ -179,11 +200,11 @@ export function providerTlsFetch( bunFetch: typeof globalThis.fetch, ): typeof globalThis.fetch { if (provider.tlsProfile === undefined) { - setStatus(providerName, "disabled"); + setStatus(providerName, provider, "disabled"); return bunFetch; } if (!profileIsSupported(providerName, provider)) { - setStatus(providerName, "fallback"); + setStatus(providerName, provider, "fallback"); warnFallbackOnce(); return bunFetch; } @@ -203,11 +224,11 @@ export function providerTlsFetch( } const initialized = await getTransport(proxy); if (!initialized) { - setStatus(providerName, "fallback"); + setStatus(providerName, provider, "fallback"); warnFallbackOnce(); return bunFetch(input, { ...init, redirect: "manual" }); } - setStatus(providerName, "active"); + setStatus(providerName, provider, "active"); const wreqInit = { ...init, transport: initialized.transport, diff --git a/src/oauth/index.ts b/src/oauth/index.ts index f997d5080b..afcd357cfe 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -642,7 +642,7 @@ export async function refreshAnthropicAccountWithLock( writeOAuthRefreshIntent(provider, accountId, generation, now(), deps.flight?.flightId); if (deps.signal?.aborted) throw deps.signal.reason; if (deps.flight) deps.flight.dispatched = true; - const fresh = merged(await def.refresh(stored.refresh, deps.signal), stored); + const fresh = merged(await def.refresh(stored.refresh, deps.signal, stored), stored); const outcome = await mergeAccountCredential(provider, accountId, fresh, { expectedGeneration: generation, afterPrePersistRead: deps.afterPrePersistRead, diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 3332e4ea05..c1d015786b 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -734,7 +734,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { ] as const) { copyIfDefined(dto, provider, key); } - dto.tlsProfileStatus = provider.tlsProfile === undefined ? "disabled" : getProviderTlsProfileStatus(name); + dto.tlsProfileStatus = provider.tlsProfile === undefined ? "disabled" : getProviderTlsProfileStatus(name, provider); const modelCosts = sanitizeModelCostsForDisplay(provider.modelCosts); if (modelCosts) dto.modelCosts = modelCosts; // Resolve the note by DESTINATION, not by name. A preset saved under a custom name is diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index ad12301a36..de24bcf95b 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -48,7 +48,7 @@ import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { clearKeyCooldowns } from "../../providers/key-failover"; import { providerRequestPacingStatus } from "../../providers/request-pacing"; -import { getProviderTlsProfileStatus } from "../../lib/provider-tls-profile"; +import { clearProviderTlsProfileStatus, getProviderTlsProfileStatus } from "../../lib/provider-tls-profile"; import { redactErrorMessage } from "../../lib/redact"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; @@ -424,7 +424,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { afterEach(() => { globalThis.fetch = originalFetch; + resetProviderRequestPacingForTest(); clearAntigravityRoutingState(); rmSync(home, { recursive: true, force: true }); delete process.env.OPENCODEX_HOME; }); describe("Antigravity Responses integration", () => { + test("a fetchResponse attempt uses its pre-acquired pacing slot only once", async () => { + let now = 0; + const timers = new Map void }>(); + let nextTimer = 1; + const runtime: RequestPacingRuntime = { + now: () => now, + random: () => 0, + setTimer: (callback, delayMs) => { + const id = nextTimer++; + timers.set(id, { at: now + delayMs, callback }); + return id; + }, + clearTimer: handle => { timers.delete(handle as number); }, + enqueueMicrotask: callback => callback(), + }; + setProviderRequestPacingRuntimeForTest(runtime); + let sends = 0; + const testConfig = config(); + const provider = testConfig.providers["google-antigravity"]! as OcxConfig["providers"][string] & { + fetch: typeof globalThis.fetch; + }; + provider.requestPacing = { enabled: true, minIntervalMs: 100 }; + provider.fetch = (async () => { + sends += 1; + return completed(); + }) as typeof globalThis.fetch; + + const pending = handleResponses(request(), testConfig, { model: "", provider: "" }, {}); + const response = await Promise.race([pending, Bun.sleep(100).then(() => null)]); + expect(response).not.toBeNull(); + if (!response) throw new Error("pacing slot was consumed twice"); + expect(response.status).toBe(200); + expect(await response.text()).toContain("hello"); + expect({ sends, queued: providerRequestPacingStatus("google-antigravity", provider).queued, timers: timers.size }).toEqual({ sends: 1, queued: 0, timers: 0 }); + expect(providerRequestPacingStatus("google-antigravity", provider).lastStartedAt).toBe(0); + expect(timers.size).toBe(0); + }); + test("selects the Google adapter and observes a CCA SSE error before returning it", async () => { const seen: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { diff --git a/tests/google-antigravity-oauth.test.ts b/tests/google-antigravity-oauth.test.ts index bc00f4cec3..72e14d1566 100644 --- a/tests/google-antigravity-oauth.test.ts +++ b/tests/google-antigravity-oauth.test.ts @@ -4,7 +4,7 @@ import { mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getAccountSet, getCredential, saveCredential } from "../src/oauth/store"; -import { getValidAccessTokenForAccount } from "../src/oauth"; +import { getValidAccessTokenForAccount, getValidAccessTokenSnapshotForAccount } from "../src/oauth"; import { ANTIGRAVITY_IDE_VERSION } from "../src/adapters/client-fingerprint"; const realFetch = globalThis.fetch; @@ -216,4 +216,33 @@ describe("antigravity credential persistence (projectId survives the store)", () expect(after.accounts.find(account => account.credential.accountId === "acct-a")?.needsReauth).toBe(true); expect(after.accounts.find(account => account.credential.accountId === "acct-b")?.needsReauth).not.toBe(true); }); + + test("account refresh keeps the stored project id without CCA discovery", async () => { + tmp = join(tmpdir(), `ag-store-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(tmp, { recursive: true }); + process.env.HOME = tmp; + process.env.OPENCODEX_HOME = join(tmp, "ocx"); + await saveCredential("google-antigravity", { + access: "expired-access", + refresh: "refresh-account", + expires: 0, + projectId: "stored-project", + accountId: "acct-refresh", + }); + const accountId = getAccountSet("google-antigravity")!.activeAccountId; + const calls: string[] = []; + routeFetch(url => { + calls.push(url); + if (url.includes("oauth2.googleapis.com/token")) { + return new Response(JSON.stringify({ access_token: "fresh-access", expires_in: 3600 }), { status: 200 }); + } + return new Response(JSON.stringify({ cloudaicompanionProject: "unexpected-project" }), { status: 200 }); + }); + + const snapshot = await getValidAccessTokenSnapshotForAccount("google-antigravity", accountId); + expect(snapshot.accessToken).toBe("fresh-access"); + expect(snapshot.projectId).toBe("stored-project"); + expect(calls.filter(url => url.includes(":loadCodeAssist")).length).toBe(0); + expect(await getValidAccessTokenForAccount("google-antigravity", accountId)).toBe("fresh-access"); + }); }); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index ecd6c82dd2..3744bfbe8f 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -3055,6 +3055,18 @@ describe("provider management validation", () => { .find(row => row.name === "google-antigravity"); expect(activeRow?.tlsProfileStatus).toBe("active"); + liveConfig.providers["google-antigravity"] = { + ...profiled, + baseUrl: "https://cloudcode-pa.googleapis.com", + }; + const replacedRequest = request(); + const replaced = await handleManagementAPI(replacedRequest, new URL(replacedRequest.url), liveConfig, {}); + const replacedRow = (JSON.parse(await replaced!.text()) as Array<{ name: string; tlsProfileStatus?: string }>) + .find(row => row.name === "google-antigravity"); + expect(replacedRow?.tlsProfileStatus).toBe("disabled"); + + liveConfig.providers["google-antigravity"] = profiled; + liveConfig.providers["google-antigravity"] = { ...profiled, tlsProfile: undefined }; const removedRequest = request(); const removed = await handleManagementAPI(removedRequest, new URL(removedRequest.url), liveConfig, {}); diff --git a/tests/provider-tls-profile.test.ts b/tests/provider-tls-profile.test.ts index e73b569071..49f4369794 100644 --- a/tests/provider-tls-profile.test.ts +++ b/tests/provider-tls-profile.test.ts @@ -80,6 +80,30 @@ describe("provider TLS profile validation", () => { }); describe("Antigravity TLS transport gate", () => { + test("does not expose active or fallback state for a replaced provider contract", async () => { + const statusFor = (provider: typeof canonicalProvider) => getProviderTlsProfileStatus("google-antigravity", provider); + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => new Response("ok"), + }), + }); + await providerTlsFetch("google-antigravity", canonicalProvider, globalThis.fetch)( + "https://daily-cloudcode-pa.googleapis.com/v1internal", + ); + expect(statusFor(canonicalProvider)).toBe("active"); + expect(statusFor({ ...canonicalProvider, baseUrl: "https://cloudcode-pa.googleapis.com" })).toBe("disabled"); + + resetProviderTlsProfileForTests(); + setProviderTlsRuntimeForTest({ importWreq: async () => { throw new Error("native unavailable"); } }); + const bunFallback = (async () => new Response("bun")) as typeof globalThis.fetch; + await providerTlsFetch("google-antigravity", canonicalProvider, bunFallback)( + "https://daily-cloudcode-pa.googleapis.com/v1internal", + ); + expect(statusFor(canonicalProvider)).toBe("fallback"); + expect(statusFor({ ...canonicalProvider, authMode: "key" as never })).toBe("disabled"); + }); + test("recognizes only HTTPS canonical hosts", () => { expect(ANTIGRAVITY_TLS_HOSTS).toEqual(new Set([ "daily-cloudcode-pa.googleapis.com", @@ -136,7 +160,7 @@ describe("Antigravity TLS transport gate", () => { transport, }); expect(new Headers(seen.init?.headers).get("authorization")).toBe("Bearer redacted"); - expect(getProviderTlsProfileStatus("google-antigravity")).toBe("active"); + expect(getProviderTlsProfileStatus("google-antigravity", canonicalProvider)).toBe("active"); }); test("providerFetch routes the opt-in profile while leaving the default executor untouched", async () => { @@ -260,7 +284,7 @@ describe("Antigravity TLS transport gate", () => { expect(bunCalls).toBe(2); expect(fallbackInits).toHaveLength(2); expect(fallbackInits.every(init => init.redirect === "manual")).toBe(true); - expect(getProviderTlsProfileStatus("google-antigravity")).toBe("fallback"); + expect(getProviderTlsProfileStatus("google-antigravity", canonicalProvider)).toBe("fallback"); resetProviderTlsProfileForTests(); setProviderTlsRuntimeForTest({ From c67e1652ceafc8d10f49dbd8b069107f69eb4c3e Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:57:37 -0600 Subject: [PATCH 16/29] fix: avoid duplicate continuation pacing --- src/server/responses/core.ts | 1 + tests/antigravity-responses.test.ts | 56 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index ced3f0ad26..a5835c6934 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -5144,6 +5144,7 @@ async function handleResponsesInner( executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: nextParsed.modelId, + pacingSlotAcquired: true, }), }); } diff --git a/tests/antigravity-responses.test.ts b/tests/antigravity-responses.test.ts index 0adc3139b1..8a59f2c572 100644 --- a/tests/antigravity-responses.test.ts +++ b/tests/antigravity-responses.test.ts @@ -112,6 +112,62 @@ describe("Antigravity Responses integration", () => { expect(timers.size).toBe(0); }); + test("an empty-completion continuation uses one pacing admission", async () => { + let now = 0; + const timers = new Map void }>(); + let nextTimer = 1; + const runtime: RequestPacingRuntime = { + now: () => now, + random: () => 0, + setTimer: (callback, delayMs) => { + const id = nextTimer++; + timers.set(id, { at: now + delayMs, callback }); + return id; + }, + clearTimer: handle => { timers.delete(handle as number); }, + enqueueMicrotask: callback => callback(), + }; + const advanceBy = (delayMs: number) => { + const target = now + delayMs; + while (true) { + const due = [...timers.entries()] + .filter(([, timer]) => timer.at <= target) + .sort((left, right) => left[1].at - right[1].at || left[0] - right[0])[0]; + if (!due) break; + const [id, timer] = due; + timers.delete(id); + now = timer.at; + timer.callback(); + } + now = target; + }; + setProviderRequestPacingRuntimeForTest(runtime); + let sends = 0; + const testConfig = config(); + testConfig.emptyCompletionRetry = true; + const provider = testConfig.providers["google-antigravity"]! as OcxConfig["providers"][string] & { + fetch: typeof globalThis.fetch; + }; + provider.requestPacing = { enabled: true, minIntervalMs: 100 }; + provider.fetch = (async () => { + sends += 1; + return sends === 1 + ? Response.json({ response: { candidates: [{ finishReason: "STOP" }] } }) + : completed(); + }) as typeof globalThis.fetch; + + const pending = handleResponses(request(), testConfig, { model: "", provider: "" }, {}); + for (let index = 0; index < 100; index += 1) await Promise.resolve(); + expect(sends).toBe(1); + advanceBy(100); + for (let index = 0; index < 20; index += 1) await Promise.resolve(); + + expect(sends).toBe(2); + expect(providerRequestPacingStatus("google-antigravity", provider).queued).toBe(0); + expect(timers.size).toBe(0); + expect((await pending).status).toBe(200); + }); + test("selects the Google adapter and observes a CCA SSE error before returning it", async () => { const seen: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { From c2543841fd9af125d6ec3401ece027fe1e0669fd Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:58:34 -0600 Subject: [PATCH 17/29] security: restrict Antigravity OAuth destinations --- src/config.ts | 6 ++++- src/lib/provider-outbound.ts | 7 +++++- src/lib/provider-tls-profile.ts | 20 +++++++++++++++++ src/server/auth-cors.ts | 4 +++- src/server/management/provider-routes.ts | 4 +++- tests/config.test.ts | 23 ++++++++++++++++++++ tests/management-provider-validation.test.ts | 23 ++++++++++++++++++++ tests/provider-outbound.test.ts | 15 ++++++++++++- tests/provider-tls-profile.test.ts | 6 ++--- 9 files changed, 100 insertions(+), 8 deletions(-) diff --git a/src/config.ts b/src/config.ts index 2dd72f2c17..4a9d040c70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -58,7 +58,7 @@ import { import { recordOwnedConfigPath } from "./lib/config-ownership"; import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { providerDestinationConfigError } from "./lib/destination-policy"; -import { providerTlsProfileConfigError } from "./lib/provider-tls-profile"; +import { antigravityOAuthDestinationConfigError, providerTlsProfileConfigError } from "./lib/provider-tls-profile"; import { redactSecretString } from "./lib/redact"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { @@ -2106,6 +2106,10 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx const result = configSchema.safeParse(value); if (result.success) { const config = normalizeApiKeyIds(result.data as OcxConfig); + for (const [name, provider] of Object.entries(config.providers)) { + const antigravityError = antigravityOAuthDestinationConfigError(name, provider); + if (antigravityError) return { ok: false, error: `providers.${name}.baseUrl: ${antigravityError}` }; + } return { ok: true, config }; } return { ok: false, error: schemaDiagnosticsError(result.error) }; diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index f1ec44ccac..71e816a9b0 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -9,7 +9,7 @@ import { import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; import { outboundProxyConfigured, proxyForUrl } from "./proxy-env"; import { publicProviderBaseUrl } from "./provider-url"; -import { isCanonicalAntigravityUrl, providerTlsFetch } from "./provider-tls-profile"; +import { antigravityOAuthDestinationConfigError, isCanonicalAntigravityUrl, providerTlsFetch } from "./provider-tls-profile"; import { waitForProviderRequestSlot } from "../providers/request-pacing"; type ProviderGetInit = Omit; @@ -136,6 +136,11 @@ async function providerOutboundRequest( init: ProviderGetInit | ProviderPostInit, dependencies: ProviderOutboundDependencies = {}, ): Promise { + const antigravityBaseError = antigravityOAuthDestinationConfigError(name, provider); + if (antigravityBaseError) throw new ProviderOutboundPolicyError(`provider ${name} ${antigravityBaseError}`); + if (name === "google-antigravity" && !isCanonicalAntigravityUrl(url)) { + throw new ProviderOutboundPolicyError("provider google-antigravity requires a canonical Antigravity HTTPS destination for OAuth"); + } const postUrl = method === "POST" ? new URL(url) : undefined; if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:") { throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS"); diff --git a/src/lib/provider-tls-profile.ts b/src/lib/provider-tls-profile.ts index 221f9db4e0..bc76304635 100644 --- a/src/lib/provider-tls-profile.ts +++ b/src/lib/provider-tls-profile.ts @@ -32,6 +32,8 @@ interface InitializedTransport { type ProviderTlsContract = Pick; +type AntigravityOAuthDestinationContract = Pick & Partial>; + export interface ProviderTlsRuntimeForTest { importWreq: () => Promise; resolveDestination?: typeof resolvePublicAddresses; @@ -143,6 +145,18 @@ export function isCanonicalAntigravityUrl(input: string | URL): boolean { } } +/** Same-name Antigravity rows are OAuth destinations even when legacy config omitted authMode. */ +export function antigravityOAuthDestinationConfigError( + providerName: string, + provider: AntigravityOAuthDestinationContract, +): string | null { + if (providerName !== "google-antigravity") return null; + if (!isCanonicalAntigravityUrl(provider.baseUrl)) { + return "requires a canonical Antigravity HTTPS destination for OAuth"; + } + return null; +} + export function getProviderTlsProfileStatus(providerName: string, provider: ProviderTlsContract): ProviderTlsProfileStatus { const current = statusByProvider.get(providerName); return current?.fingerprint === providerTlsContractFingerprint(providerName, provider) @@ -199,6 +213,12 @@ export function providerTlsFetch( provider: Pick, bunFetch: typeof globalThis.fetch, ): typeof globalThis.fetch { + const antigravityError = antigravityOAuthDestinationConfigError(providerName, provider); + if (antigravityError) { + return (async () => { + throw new Error(`provider ${providerName} ${antigravityError}`); + }) as unknown as typeof globalThis.fetch; + } if (provider.tlsProfile === undefined) { setStatus(providerName, provider, "disabled"); return bunFetch; diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index c1d015786b..4edecf2071 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -28,7 +28,7 @@ import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in"; -import { getProviderTlsProfileStatus, providerTlsProfileConfigError } from "../lib/provider-tls-profile"; +import { antigravityOAuthDestinationConfigError, getProviderTlsProfileStatus, providerTlsProfileConfigError } from "../lib/provider-tls-profile"; let _corsOrigin = "http://localhost:10100"; export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; } @@ -578,6 +578,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (tlsProfileError) { return `provider ${JSON.stringify(redactSecretString(name))} ${tlsProfileError}`; } + const antigravityError = antigravityOAuthDestinationConfigError(name, typed); + if (antigravityError) return `provider ${name} ${antigravityError}`; const baseUrlError = providerBaseUrlConfigError(typed.baseUrl); if (baseUrlError) return `provider ${name} ${baseUrlError}`; if (effectiveGoogleMode(name, typed) === "vertex" && typed.location !== undefined) { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index de24bcf95b..ba9783e2b5 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -48,7 +48,7 @@ import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { clearKeyCooldowns } from "../../providers/key-failover"; import { providerRequestPacingStatus } from "../../providers/request-pacing"; -import { clearProviderTlsProfileStatus, getProviderTlsProfileStatus } from "../../lib/provider-tls-profile"; +import { antigravityOAuthDestinationConfigError, clearProviderTlsProfileStatus, getProviderTlsProfileStatus } from "../../lib/provider-tls-profile"; import { redactErrorMessage } from "../../lib/redact"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; @@ -751,6 +751,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { }); }); + test("config candidates reject noncanonical Antigravity OAuth destinations", () => { + const defaults = getDefaultConfig(); + const antigravity = { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + googleMode: "cloud-code-assist", + }; + expect(validateConfigCandidate({ + ...defaults, + providers: { + ...defaults.providers, + "google-antigravity": { ...antigravity, baseUrl: "https://evil.example.test", authMode: "oauth" }, + }, + })).toMatchObject({ ok: false, error: expect.stringContaining("canonical Antigravity") }); + for (const baseUrl of ["https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com"]) { + expect(validateConfigCandidate({ + ...defaults, + providers: { ...defaults.providers, "google-antigravity": { ...antigravity, baseUrl, authMode: "oauth" } }, + }).ok).toBe(true); + } + }); + test("usage and MCP config overrides change the effective bound while defaults remain compatible", () => { const defaults = getDefaultConfig(); expect(defaults.managementUsageMaxReadBytes).toBe(64 * 1024 * 1024); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 3744bfbe8f..9e071034e3 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -3028,6 +3028,29 @@ describe("provider management validation", () => { } }); + test("provider management rejects noncanonical Antigravity OAuth destinations", async () => { + const invalid = { + adapter: "google" as const, + baseUrl: "https://evil.example.test", + authMode: "oauth" as const, + googleMode: "cloud-code-assist" as const, + }; + expect(providerManagementConfigError("google-antigravity", invalid)).toContain("canonical Antigravity"); + const postConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { openai: { ...canonicalDirect } }, + }; + const post = new Request("http://127.0.0.1/api/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "google-antigravity", provider: invalid }), + }); + expect((await handleManagementAPI(post, new URL(post.url), postConfig, {}))?.status).toBe(400); + }); + test("GET /api/providers clears active TLS status when the profile is removed", async () => { const profiled = { adapter: "google" as const, diff --git a/tests/provider-outbound.test.ts b/tests/provider-outbound.test.ts index 098b51c354..16a7fb2165 100644 --- a/tests/provider-outbound.test.ts +++ b/tests/provider-outbound.test.ts @@ -53,6 +53,19 @@ function directDependencies( } describe("provider outbound GET transport", () => { + test("rejects a noncanonical Antigravity OAuth destination before dispatch", async () => { + const { providerOutboundGet, ProviderOutboundPolicyError } = await import("../src/lib/provider-outbound"); + let resolveCalls = 0; + await expect(providerOutboundGet( + "google-antigravity", + { adapter: "google", authMode: "oauth", baseUrl: "https://evil.example.test" }, + "https://evil.example.test/v1internal:fetchAvailableModels", + { headers: { authorization: "Bearer must-not-send" } }, + { resolveAddresses: async () => { resolveCalls += 1; throw new Error("must not resolve"); } }, + )).rejects.toThrow(ProviderOutboundPolicyError); + expect(resolveCalls).toBe(0); + }); + test("Antigravity model discovery uses the opted-in profiled executor", async () => { for (const key of proxyKeys) delete process.env[key]; let nativeCalls = 0; @@ -454,7 +467,7 @@ describe("provider outbound POST transport", () => { const body = JSON.stringify({ project: "test-project" }); const response = await providerOutboundPost( - "google-antigravity", + "custom", { baseUrl: "https://provider.example" }, "https://provider.example/v1internal:fetchAvailableModels", { headers: { authorization: "Bearer test-token" }, body }, diff --git a/tests/provider-tls-profile.test.ts b/tests/provider-tls-profile.test.ts index 49f4369794..b273302533 100644 --- a/tests/provider-tls-profile.test.ts +++ b/tests/provider-tls-profile.test.ts @@ -211,10 +211,10 @@ describe("Antigravity TLS transport gate", () => { ...canonicalProvider, baseUrl: "https://attacker.example", }, bunFetch); - await executor("https://daily-cloudcode-pa.googleapis.com/v1internal"); - await executor(userInfoUrl); + await expect(executor("https://daily-cloudcode-pa.googleapis.com/v1internal")).rejects.toThrow(/canonical Antigravity/); + await expect(executor(userInfoUrl)).rejects.toThrow(/canonical Antigravity/); expect(wreqCalls).toBe(0); - expect(bunCalls).toBe(2); + expect(bunCalls).toBe(0); }); test("keeps OAuth token destinations on Bun fetch", async () => { From cf0444a84ae0c654dba0a38b30e0659f7b77f3ab Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:15:27 -0600 Subject: [PATCH 18/29] fix: pair antigravity tokens with project snapshots --- src/codex/catalog/provider-fetch.ts | 10 ++++++---- src/providers/quota.ts | 14 +++++++------ src/server/images.ts | 15 ++++++++++---- src/server/management/provider-routes.ts | 4 ++-- src/server/responses/core.ts | 25 ++++++++++++++++-------- tests/antigravity-responses.test.ts | 19 ++++++++++++++++++ tests/google-models-listing.test.ts | 4 ++-- tests/provider-connection-test.test.ts | 2 +- tests/provider-quota.test.ts | 6 +++++- tests/server-images.test.ts | 6 +++++- 10 files changed, 76 insertions(+), 29 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index e0e70009fe..1f919b95c8 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -26,6 +26,7 @@ import { observeActiveOAuthAccessToken, resolveModelsAuthToken, type OAuthActiveTokenObservation, + type OAuthAccessSnapshot, } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; @@ -129,7 +130,7 @@ interface ModelsAuthResolution { readonly apiKey: string | undefined; readonly observed: boolean; readonly oauthApiBaseUrl?: string; - readonly oauthProjectId?: string; + readonly oauthSnapshot?: OAuthAccessSnapshot; } type ModelsAuthResolver = @@ -1111,8 +1112,8 @@ function observedModelsAuthResolver( return { apiKey: observation.snapshot.accessToken, observed: true, + oauthSnapshot: observation.snapshot, ...(observation.snapshot.apiBaseUrl ? { oauthApiBaseUrl: observation.snapshot.apiBaseUrl } : {}), - ...(observation.snapshot.projectId ? { oauthProjectId: observation.snapshot.projectId } : {}), }; }, }; @@ -1180,7 +1181,7 @@ async function fetchProviderModelsWithAuth( .then(snapshot => ({ apiKey: snapshot.accessToken, observed: false, - ...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}), + oauthSnapshot: snapshot, })) .catch(() => ({ apiKey: undefined, observed: false })) : { apiKey: await resolveModelsAuthToken(name, prov), observed: false } @@ -1266,7 +1267,8 @@ async function fetchProviderModelsWithAuth( return observed(configured, "degraded"); } const cloudCodeAssist = effectiveGoogleMode(name, prov) === "cloud-code-assist"; - const project = prov.project ?? auth.oauthProjectId; + const antigravityOAuth = name === "google-antigravity" && prov.authMode === "oauth"; + const project = antigravityOAuth ? auth.oauthSnapshot?.projectId : prov.project ?? auth.oauthSnapshot?.projectId; if (cloudCodeAssist && !project) return observed(configured, "degraded"); const fresh = getFreshCached(name, ttlMs); if (fresh) { diff --git a/src/providers/quota.ts b/src/providers/quota.ts index ed5887d8f3..8882b72b10 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -8,13 +8,14 @@ import { isMainAccountIdentityGenerationLive } from "../codex/main-account-cache import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { codexPlanKey } from "../codex/plan"; import { resolveEnvValue } from "../config"; -import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth"; +import { getValidAccessToken, getValidAccessTokenForAccount, getValidAccessTokenSnapshot } from "../oauth"; import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store"; import { providerFetch } from "../server/responses/fetch-helpers"; import { antigravityUserAgent } from "../adapters/client-fingerprint"; import { apiKeyPoolEntryId } from "./api-keys"; import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport"; import { getProviderRegistryEntry, providerCodexAccountMode, registryEntryForProviderDestination } from "./registry"; +import { antigravityOAuthDestinationConfigError } from "../lib/provider-tls-profile"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers"; import { @@ -2073,14 +2074,15 @@ function antigravityUsedPercent(quotaInfo: Record): number | un } async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise { - const credential = getCredential("google-antigravity"); - if (!credential?.projectId) return null; - let accessToken: string; + if (antigravityOAuthDestinationConfigError(provider, config)) return null; + let snapshot: Awaited>; try { - accessToken = await getValidAccessToken("google-antigravity"); + snapshot = await getValidAccessTokenSnapshot("google-antigravity"); } catch { return null; } + if (!snapshot.projectId) return null; + const { accessToken, projectId } = snapshot; const baseUrl = (config.baseUrl || "https://daily-cloudcode-pa.googleapis.com").replace(/\/+$/, ""); const executor = providerFetch(config, undefined, { providerName: "google-antigravity" }); const response = await executor(`${baseUrl}/v1internal:fetchAvailableModels`, { @@ -2091,7 +2093,7 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig "User-Agent": antigravityUserAgent(), Authorization: `Bearer ${accessToken}`, }, - body: JSON.stringify({ project: credential.projectId }), + body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) return null; diff --git a/src/server/images.ts b/src/server/images.ts index 32dcc3df21..c07778944a 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -33,7 +33,7 @@ import { readJsonRequestBody } from "./request-decompress"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors"; import type { RequestLogContext } from "./request-log"; import { codexLogAccountId, decodeRequestErrorResponse } from "./responses"; -import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index"; +import { getValidAccessTokenSnapshot, type OAuthAccessSnapshot } from "../oauth/index"; import { safeAntigravityHttpErrorMessage } from "../adapters/google-errors"; import { sanitizeUpstreamErrorText } from "../adapters/upstream-http-error"; import { redactErrorMessage } from "../lib/redact"; @@ -42,6 +42,7 @@ import { decodeValidatedImageBase64, MAX_ENCODED_BYTES_PER_IMAGE } from "../imag import type { AdmissionLease } from "../lib/admission"; import { codexAccountSelectionForTurn } from "./lifecycle"; import { providerFetch } from "./responses/fetch-helpers"; +import { antigravityOAuthDestinationConfigError } from "../lib/provider-tls-profile"; export type ImagesEndpoint = "generations" | "edits"; @@ -150,6 +151,11 @@ async function tryCcaImageGeneration( if (endpoint !== "generations") return undefined; const provider = config.providers?.["google-antigravity"]; if (!provider || provider.disabled) return undefined; + if (antigravityOAuthDestinationConfigError("google-antigravity", provider)) return formatErrorResponse( + 400, + "invalid_request_error", + "Antigravity requires a canonical HTTPS destination for OAuth.", + ); const prompt = (body as { prompt?: unknown })?.prompt; if (typeof prompt !== "string" || !prompt.trim()) { @@ -168,7 +174,7 @@ async function tryCcaImageGeneration( // OAuth token refresh and project discovery, not just the upstream fetch. const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS; const linkedSignal = signalWithTimeout(timeoutMs, signal); - let token: string; + let snapshot: OAuthAccessSnapshot; try { // Race the OAuth refresh against the deadline signal. getValidAccessToken // chains through 4 layers (resolveAccessSnapshotForAccount → @@ -177,7 +183,7 @@ async function tryCcaImageGeneration( // threading signal through the entire chain, race the whole call against // linkedSignal: when the signal aborts we stop awaiting and surface the // cancellation immediately instead of hanging on the refresh HTTP call. - token = await abortableRace(getValidAccessToken("google-antigravity"), linkedSignal.signal); + snapshot = await abortableRace(getValidAccessTokenSnapshot("google-antigravity"), linkedSignal.signal); } catch (err) { linkedSignal.cleanup(); // abortableRace rejects immediately when the signal fires, so client @@ -197,7 +203,8 @@ async function tryCcaImageGeneration( } return formatErrorResponse(502, "upstream_error", "CCA image generation failed: OAuth token refresh failed"); } - const project = getOAuthCredentialProjectId("google-antigravity"); + const token = snapshot.accessToken; + const project = snapshot.projectId; if (!project) { linkedSignal.cleanup(); return formatErrorResponse( diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index ba9783e2b5..bcff07fbc9 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -767,7 +767,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise undefined) : undefined; @@ -796,7 +796,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { expect(calls).toBe(2); }, 15_000); + test("fails closed when the selected OAuth snapshot has no project", async () => { + await saveCredential("google-antigravity", { + access: "access-without-project", + refresh: "refresh-without-project", + expires: Date.now() + 3600_000, + }); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return completed(); + }) as typeof fetch; + const response = await handleResponses(request(), config(), { model: "", provider: "" }, {}); + expect(response.status).toBe(400); + expect(calls).toBe(0); + }); + test("replays one 401 with the selected account generation", async () => { await saveCredential("google-antigravity", { access: "access-b", @@ -248,6 +264,7 @@ describe("Antigravity Responses integration", () => { }); let inferenceCalls = 0; const bearers: string[] = []; + const projects: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url === "https://oauth2.googleapis.com/token") { @@ -255,12 +272,14 @@ describe("Antigravity Responses integration", () => { } inferenceCalls += 1; bearers.push(new Headers(init?.headers).get("authorization") ?? ""); + projects.push((JSON.parse(String(init?.body)) as { project?: string }).project ?? ""); return inferenceCalls === 1 ? new Response("expired", { status: 401 }) : completed(); }) as typeof fetch; const logCtx: RequestLogContext = { model: "", provider: "" }; const response = await handleResponses(request(), config(), logCtx, {}); expect(response.status).toBe(200); expect(bearers).toEqual(["Bearer access-b", "Bearer fresh-b"]); + expect(projects).toEqual(["project-b", "project-b"]); expect(inferenceCalls).toBe(2); }, 15_000); diff --git a/tests/google-models-listing.test.ts b/tests/google-models-listing.test.ts index c5a63dc13b..bc76fc6b0e 100644 --- a/tests/google-models-listing.test.ts +++ b/tests/google-models-listing.test.ts @@ -124,7 +124,7 @@ describe("Antigravity live model discovery", () => { expect(seen[0]?.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"); expect(seen[0]?.init?.method).toBe("POST"); expect((seen[0]?.init?.headers as Record).Authorization).toBe("Bearer access-token"); - expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ project: "configured-project" }); + expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ project: "project-id" }); expect(live.map(model => model.id).sort()).toEqual([ "future-agent-model", "future-flash-high", @@ -236,7 +236,7 @@ describe("Antigravity live model discovery", () => { let markFetchStarted!: () => void; const responseGate = new Promise(resolve => { releaseResponse = resolve; }); const fetchStarted = new Promise(resolve => { markFetchStarted = resolve; }); - const baseUrl = "https://cca-stale-discovery.example"; + const baseUrl = "https://daily-cloudcode-pa.googleapis.com"; const priorGeneration = captureModelCacheGeneration("google-antigravity"); registerAntigravityDiscoveredWireModels(baseUrl, [{ id: "stale-model", wireModelId: "old-wire-model" }], { provider: "google-antigravity", diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index d68a0df126..03ba99b642 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -207,7 +207,7 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(seen[0]?.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"); expect(seen[0]?.init?.method).toBe("POST"); expect((seen[0]?.init?.headers as Record).Authorization).toBe("Bearer test-access-token"); - expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ project: "configured-project" }); + expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ project: "test-project-id" }); }); test("a fake key gets the upstream rejection, not a catalog-presence pass", async () => { diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 70f2db71ff..c358b4fb19 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -110,6 +110,7 @@ describe("fetchProviderQuotaReports", () => { }); let nativeCalls = 0; let bunCalls = 0; + let project: string | undefined; globalThis.fetch = (async () => { bunCalls += 1; return new Response(null, { status: 500 }); @@ -117,8 +118,9 @@ describe("fetchProviderQuotaReports", () => { setProviderTlsRuntimeForTest({ importWreq: async () => ({ createTransport: async () => ({ close: async () => undefined }), - fetch: async () => { + fetch: async (_input, init) => { nativeCalls += 1; + project = (JSON.parse(String(init?.body)) as { project?: string }).project; return new Response(JSON.stringify({ models: { "gemini-3.6-flash-medium": { @@ -138,12 +140,14 @@ describe("fetchProviderQuotaReports", () => { authMode: "oauth", googleMode: "cloud-code-assist", baseUrl: "https://daily-cloudcode-pa.googleapis.com", + project: "configured-stale-project", tlsProfile: "antigravity-browser", }, }, } as OcxConfig; const result = await fetchProviderQuotaReports(config, true); expect(result.reports[0]?.provider).toBe("google-antigravity"); + expect(project).toBe("agy-project-secret"); expect(nativeCalls).toBe(1); expect(bunCalls).toBe(0); }); diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 6e1936dd7c..f9fcab7bc1 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -1175,6 +1175,7 @@ const CCA_CREDENTIAL = { test("CCA image generation uses the opted-in profiled executor", async () => { let nativeCalls = 0; let bunCalls = 0; + let project: string | undefined; const realFetch = globalThis.fetch; globalThis.fetch = (async () => { bunCalls += 1; @@ -1183,8 +1184,9 @@ test("CCA image generation uses the opted-in profiled executor", async () => { setProviderTlsRuntimeForTest({ importWreq: async () => ({ createTransport: async () => ({ close: async () => undefined }), - fetch: async () => { + fetch: async (_input, init) => { nativeCalls += 1; + project = (JSON.parse(String(init?.body)) as { project?: string }).project; return Response.json({ response: { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: CCA_TINY_PNG } }] } }], @@ -1202,6 +1204,7 @@ test("CCA image generation uses the opted-in profiled executor", async () => { baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth", googleMode: "cloud-code-assist", + project: "configured-stale-project", tlsProfile: "antigravity-browser", }, }, @@ -1222,6 +1225,7 @@ test("CCA image generation uses the opted-in profiled executor", async () => { expect(response.status).toBe(200); expect(nativeCalls).toBe(1); expect(bunCalls).toBe(0); + expect(project).toBe("cca-project-123"); } finally { globalThis.fetch = realFetch; } From fc6231fb771c6ba51c422aeb577e195a1c30c12e Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:27:39 -0600 Subject: [PATCH 19/29] fix: unify Antigravity OAuth mode predicate --- src/codex/catalog/provider-fetch.ts | 4 ++- src/lib/provider-tls-profile.ts | 12 ++++++- src/server/management/provider-routes.ts | 6 ++-- src/server/responses/core.ts | 11 +++++-- tests/antigravity-responses.test.ts | 14 ++++++++ tests/google-models-listing.test.ts | 41 ++++++++++++++++++++++++ tests/provider-connection-test.test.ts | 27 ++++++++++++++++ 7 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 1f919b95c8..4d21004135 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -61,6 +61,7 @@ import { providerOutboundPost, providerRedirectError, } from "../../lib/provider-outbound"; +import { isAntigravityOAuthProvider } from "../../lib/provider-tls-profile"; import { redactSecretString } from "../../lib/redact"; import { extractProviderModelItems, @@ -1267,7 +1268,8 @@ async function fetchProviderModelsWithAuth( return observed(configured, "degraded"); } const cloudCodeAssist = effectiveGoogleMode(name, prov) === "cloud-code-assist"; - const antigravityOAuth = name === "google-antigravity" && prov.authMode === "oauth"; + const antigravityOAuth = isAntigravityOAuthProvider(name, prov); + if (antigravityOAuth && !cloudCodeAssist) return observed(configured, "degraded"); const project = antigravityOAuth ? auth.oauthSnapshot?.projectId : prov.project ?? auth.oauthSnapshot?.projectId; if (cloudCodeAssist && !project) return observed(configured, "degraded"); const fresh = getFreshCached(name, ttlMs); diff --git a/src/lib/provider-tls-profile.ts b/src/lib/provider-tls-profile.ts index bc76304635..a246a72f27 100644 --- a/src/lib/provider-tls-profile.ts +++ b/src/lib/provider-tls-profile.ts @@ -32,7 +32,7 @@ interface InitializedTransport { type ProviderTlsContract = Pick; -type AntigravityOAuthDestinationContract = Pick & Partial>; +type AntigravityOAuthDestinationContract = Pick & Partial>; export interface ProviderTlsRuntimeForTest { importWreq: () => Promise; @@ -145,12 +145,22 @@ export function isCanonicalAntigravityUrl(input: string | URL): boolean { } } +export function isAntigravityOAuthProvider( + providerName: string, + provider: Pick, +): boolean { + return providerName === "google-antigravity" && provider.authMode === "oauth"; +} + /** Same-name Antigravity rows are OAuth destinations even when legacy config omitted authMode. */ export function antigravityOAuthDestinationConfigError( providerName: string, provider: AntigravityOAuthDestinationContract, ): string | null { if (providerName !== "google-antigravity") return null; + if (provider.googleMode !== undefined && provider.googleMode !== "cloud-code-assist") { + return "requires Google Cloud Code Assist mode for OAuth"; + } if (!isCanonicalAntigravityUrl(provider.baseUrl)) { return "requires a canonical Antigravity HTTPS destination for OAuth"; } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index bcff07fbc9..4de9bda8f7 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -37,7 +37,7 @@ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; -import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry"; +import { providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry"; import { extractModelEnvelopeRows, extractProviderModelItems, @@ -48,7 +48,7 @@ import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { clearKeyCooldowns } from "../../providers/key-failover"; import { providerRequestPacingStatus } from "../../providers/request-pacing"; -import { antigravityOAuthDestinationConfigError, clearProviderTlsProfileStatus, getProviderTlsProfileStatus } from "../../lib/provider-tls-profile"; +import { antigravityOAuthDestinationConfigError, clearProviderTlsProfileStatus, getProviderTlsProfileStatus, isAntigravityOAuthProvider } from "../../lib/provider-tls-profile"; import { redactErrorMessage } from "../../lib/redact"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; @@ -767,7 +767,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise undefined) : undefined; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 4f4c4a2bfe..3ce0989bbe 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -73,6 +73,7 @@ import { import { isInjectionDebugEnabled } from "../../lib/debug-settings"; import { injectionDebugLog } from "../../lib/injection-debug-log"; import { resolveClientRetryAfter } from "../../lib/retry-after"; +import { antigravityOAuthDestinationConfigError, isAntigravityOAuthProvider } from "../../lib/provider-tls-profile"; import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit"; import { modelInList, namespacedToolName } from "../../types"; import type { @@ -2643,9 +2644,13 @@ async function handleResponsesInner( // OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the // existing openai-chat / anthropic adapters authenticate with no change. - const isAntigravityOAuth = route.providerName === "google-antigravity" - && route.provider.authMode === "oauth" - && route.provider.googleMode === "cloud-code-assist"; + const isAntigravityOAuth = isAntigravityOAuthProvider(route.providerName, route.provider); + if (route.providerName === "google-antigravity") { + const antigravityConfigError = antigravityOAuthDestinationConfigError(route.providerName, route.provider); + if (antigravityConfigError) { + return formatErrorResponse(400, "invalid_request_error", antigravityConfigError); + } + } const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro" || isAntigravityOAuth) && route.provider.authMode === "oauth"; let sentOAuthSnapshot: OAuthAccessSnapshot | undefined; diff --git a/tests/antigravity-responses.test.ts b/tests/antigravity-responses.test.ts index a9e557f322..862929e695 100644 --- a/tests/antigravity-responses.test.ts +++ b/tests/antigravity-responses.test.ts @@ -253,6 +253,20 @@ describe("Antigravity Responses integration", () => { expect(calls).toBe(0); }); + test("rejects an explicit ai-studio mode before using stale Antigravity project config", async () => { + const testConfig = config(); + testConfig.providers["google-antigravity"]!.googleMode = "ai-studio"; + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return completed(); + }) as typeof fetch; + + const response = await handleResponses(request(), testConfig, { model: "", provider: "" }, {}); + expect(response.status).toBe(400); + expect(calls).toBe(0); + }); + test("replays one 401 with the selected account generation", async () => { await saveCredential("google-antigravity", { access: "access-b", diff --git a/tests/google-models-listing.test.ts b/tests/google-models-listing.test.ts index bc76fc6b0e..e131c3aeb2 100644 --- a/tests/google-models-listing.test.ts +++ b/tests/google-models-listing.test.ts @@ -215,6 +215,47 @@ describe("Antigravity live model discovery", () => { } }); + test("rejects Antigravity OAuth explicitly configured as ai-studio before discovery", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-ai-studio-discovery-")); + process.env.OPENCODEX_HOME = home; + writeFileSync(join(home, "auth.json"), JSON.stringify({ + "google-antigravity": { + activeAccountId: "active", + accounts: [{ + id: "active", + credential: { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 3_600_000, + }, + }], + }, + })); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return Response.json({ models: {} }); + }) as typeof fetch; + + try { + const models = await gatherRoutedModels(configWith("google-antigravity", { + adapter: "google", + authMode: "oauth", + googleMode: "ai-studio", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + project: "stale-configured-project", + liveModels: true, + models: ["configured-only"], + })); + + expect(fetches).toBe(0); + expect(models.filter(model => model.provider === "google-antigravity").map(model => model.id)) + .toEqual(["configured-only"]); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + test("does not register wire mappings from a stale CCA discovery", async () => { const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-stale-discovery-")); process.env.OPENCODEX_HOME = home; diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index 03ba99b642..878468ec63 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -210,6 +210,33 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ project: "test-project-id" }); }); + test("Google Antigravity rejects an explicit ai-studio mode before probing", async () => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return Response.json({ models: {} }); + }) as typeof fetch; + await saveCredential("google-antigravity", { + access: "test-access-token", + refresh: "test-refresh-token", + expires: Date.now() + 3_600_000, + }); + const config = baseConfig({ + "google-antigravity": { + ...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), + googleMode: "ai-studio", + project: "stale-configured-project", + }, + }); + + const { status, body } = await probe(config, "google-antigravity"); + + expect(status).toBe(400); + expect(body.ok).toBe(false); + expect(String(body.error)).toContain("Cloud Code Assist"); + expect(fetches).toBe(0); + }); + test("a fake key gets the upstream rejection, not a catalog-presence pass", async () => { globalThis.fetch = (async () => new Response("unauthorized", { status: 401 })) as typeof fetch; const config = baseConfig({ From 1670e48005658d8e67a0729c541b3bb83ad5cbf2 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:39:21 -0600 Subject: [PATCH 20/29] fix: pair legacy Antigravity configs with snapshots --- src/codex/catalog/provider-fetch.ts | 8 ++--- src/lib/provider-tls-profile.ts | 3 +- src/providers/quota.ts | 4 +-- src/server/management/provider-routes.ts | 2 +- src/server/responses/core.ts | 7 +++-- tests/google-models-listing.test.ts | 39 ++++++++++++++++++++++++ tests/provider-connection-test.test.ts | 27 ++++++++++++++++ 7 files changed, 79 insertions(+), 11 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 4d21004135..f1a8971bfa 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1176,8 +1176,10 @@ async function fetchProviderModelsWithAuth( clearProviderDiscoveryStatus(name); return observed(configured, "authoritative"); } + const cloudCodeAssist = effectiveGoogleMode(name, prov) === "cloud-code-assist"; + const antigravityOAuth = isAntigravityOAuthProvider(name, prov); const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing" - ? prov.authMode === "oauth" && effectiveGoogleMode(name, prov) === "cloud-code-assist" + ? antigravityOAuth && cloudCodeAssist ? await getValidAccessTokenSnapshot(name) .then(snapshot => ({ apiKey: snapshot.accessToken, @@ -1261,14 +1263,12 @@ async function fetchProviderModelsWithAuth( "degraded", ); } - if (prov.authMode === "oauth" && !apiKey) { + if ((prov.authMode === "oauth" || antigravityOAuth) && !apiKey) { // No usable token (logged out, or account marked needsReauth). Still surface the // configured static catalog so the GUI Models tab / rail counts are not empty — // matching Cursor's !apiKey → configured degradation and fetch-failure fallback. return observed(configured, "degraded"); } - const cloudCodeAssist = effectiveGoogleMode(name, prov) === "cloud-code-assist"; - const antigravityOAuth = isAntigravityOAuthProvider(name, prov); if (antigravityOAuth && !cloudCodeAssist) return observed(configured, "degraded"); const project = antigravityOAuth ? auth.oauthSnapshot?.projectId : prov.project ?? auth.oauthSnapshot?.projectId; if (cloudCodeAssist && !project) return observed(configured, "degraded"); diff --git a/src/lib/provider-tls-profile.ts b/src/lib/provider-tls-profile.ts index a246a72f27..851a5665b0 100644 --- a/src/lib/provider-tls-profile.ts +++ b/src/lib/provider-tls-profile.ts @@ -149,7 +149,8 @@ export function isAntigravityOAuthProvider( providerName: string, provider: Pick, ): boolean { - return providerName === "google-antigravity" && provider.authMode === "oauth"; + return providerName === "google-antigravity" + && (provider.authMode === undefined || provider.authMode === "oauth"); } /** Same-name Antigravity rows are OAuth destinations even when legacy config omitted authMode. */ diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 8882b72b10..55f1eb11a6 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -15,7 +15,7 @@ import { antigravityUserAgent } from "../adapters/client-fingerprint"; import { apiKeyPoolEntryId } from "./api-keys"; import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport"; import { getProviderRegistryEntry, providerCodexAccountMode, registryEntryForProviderDestination } from "./registry"; -import { antigravityOAuthDestinationConfigError } from "../lib/provider-tls-profile"; +import { antigravityOAuthDestinationConfigError, isAntigravityOAuthProvider } from "../lib/provider-tls-profile"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers"; import { @@ -2144,7 +2144,7 @@ async function maybeFetchProviderQuota( if (provider.authMode === "oauth" && name === "xai") return fetchXaiQuota(name); if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name); if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name); - if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider); + if (isAntigravityOAuthProvider(name, provider)) return fetchAntigravityQuota(name, provider); // Kimi Code `/usages` accepts OAuth or coding-plan API keys, but only on the canonical // host and only for real key auth — forward/local modes carry no credential of ours. if (provider.authMode === "oauth" && name === "kimi") return fetchKimiQuota(name, provider); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 4de9bda8f7..8e6adcb541 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -772,7 +772,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise undefined) : undefined; const apiKey = snapshot?.accessToken ?? await resolveModelsAuthToken(name, prov); - if (prov.authMode === "oauth" && !apiKey) { + if ((prov.authMode === "oauth" || antigravity) && !apiKey) { return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" }); } if (prov.adapter === "cursor") { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3ce0989bbe..8a93259388 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2651,8 +2651,9 @@ async function handleResponsesInner( return formatErrorResponse(400, "invalid_request_error", antigravityConfigError); } } - const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro" || isAntigravityOAuth) - && route.provider.authMode === "oauth"; + const isOAuth401ReplayProvider = isAntigravityOAuth + || ((route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro") + && route.provider.authMode === "oauth"); let sentOAuthSnapshot: OAuthAccessSnapshot | undefined; let replayOAuthCredentialSnapshot: Pick | undefined; let anthropicPoolAccountId: string | null = null; @@ -2682,7 +2683,7 @@ async function handleResponsesInner( promptCacheKey: typeof parsed.options.promptCacheKey === "string" ? parsed.options.promptCacheKey : null, }); } - if (route.provider.authMode === "oauth") { + if (route.provider.authMode === "oauth" || isAntigravityOAuth) { try { if (route.providerName === "anthropic" && isAnthropicAccountPoolEnabled(config)) { const selection = resolveAnthropicAccountForSession(anthropicSessionKey, config); diff --git a/tests/google-models-listing.test.ts b/tests/google-models-listing.test.ts index e131c3aeb2..99dcb4e559 100644 --- a/tests/google-models-listing.test.ts +++ b/tests/google-models-listing.test.ts @@ -256,6 +256,45 @@ describe("Antigravity live model discovery", () => { } }); + test("legacy Antigravity config without authMode still ignores stale project without a snapshot project", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-legacy-discovery-")); + process.env.OPENCODEX_HOME = home; + writeFileSync(join(home, "auth.json"), JSON.stringify({ + "google-antigravity": { + activeAccountId: "active", + accounts: [{ + id: "active", + credential: { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 3_600_000, + }, + }], + }, + })); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return Response.json({ models: {} }); + }) as typeof fetch; + + try { + const models = await gatherRoutedModels(configWith("google-antigravity", { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + project: "stale-configured-project", + liveModels: true, + models: ["configured-only"], + })); + + expect(fetches).toBe(0); + expect(models.filter(model => model.provider === "google-antigravity").map(model => model.id)) + .toEqual(["configured-only"]); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + test("does not register wire mappings from a stale CCA discovery", async () => { const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-stale-discovery-")); process.env.OPENCODEX_HOME = home; diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index 878468ec63..4e9a184856 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -237,6 +237,33 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(fetches).toBe(0); }); + test("legacy Google Antigravity config without authMode requires a snapshot project", async () => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return Response.json({ models: {} }); + }) as typeof fetch; + await saveCredential("google-antigravity", { + access: "test-access-token", + refresh: "test-refresh-token", + expires: Date.now() + 3_600_000, + }); + const config = baseConfig({ + "google-antigravity": { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + project: "stale-configured-project", + }, + }); + + const { status, body } = await probe(config, "google-antigravity"); + + expect(status).toBe(200); + expect(body.ok).toBe(false); + expect(String(body.error)).toContain("project unavailable"); + expect(fetches).toBe(0); + }); + test("a fake key gets the upstream rejection, not a catalog-presence pass", async () => { globalThis.fetch = (async () => new Response("unauthorized", { status: 401 })) as typeof fetch; const config = baseConfig({ From 14672c548a5b539e69e80e8c5b5a6e1339be7f4e Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:55:18 -0600 Subject: [PATCH 21/29] fix: preserve geoblock 403 cooldown and support Retry-After zero --- src/oauth/antigravity-routing.ts | 14 ++++++++++---- src/server/responses/core.ts | 3 +++ tests/antigravity-responses.test.ts | 23 +++++++++++++++++++++-- tests/antigravity-routing.test.ts | 12 +++++++++++- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/oauth/antigravity-routing.ts b/src/oauth/antigravity-routing.ts index 9229e490ac..df10f978fc 100644 --- a/src/oauth/antigravity-routing.ts +++ b/src/oauth/antigravity-routing.ts @@ -11,7 +11,8 @@ const MAX_AFFINITY_ENTRIES = 2_000; const MAX_AFFINITY_COMPONENT_LENGTH = 128; type CooldownSource = "retry-after" | "default" | "synthetic"; -export type AntigravitySyntheticFailure = "rate-limit" | "quota" | "geoblock"; +export type AntigravityCooldownKind = "rate-limit" | "quota" | "geoblock"; +export type AntigravitySyntheticFailure = AntigravityCooldownKind; export interface AntigravityProviderError { code?: string; @@ -22,6 +23,7 @@ export interface AntigravityProviderError { interface AccountHealth { cooldownUntil: number; cooldownSource: CooldownSource; + cooldownKind: AntigravityCooldownKind; } interface AffinityEntry { @@ -49,7 +51,7 @@ function parseRetryAfterMs(value: string | null | undefined, now: number): numbe if (!text) return undefined; if (/^\d+(?:\.\d+)?$/.test(text)) { const seconds = Number(text); - if (Number.isFinite(seconds) && seconds > 0) return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS); + if (Number.isFinite(seconds) && seconds >= 0) return Math.min(Math.ceil(seconds * 1000), MAX_COOLDOWN_MS); } const timestamp = Date.parse(text); if (!Number.isFinite(timestamp)) return undefined; @@ -117,7 +119,7 @@ export function clearAntigravityRoutingStateForAccount(accountId: string): void export function getAntigravityAccountHealthSnapshot( accountId: string, now = Date.now(), -): { cooldownUntil: number; cooldownSource: CooldownSource } | null { +): { cooldownUntil: number; cooldownSource: CooldownSource; cooldownKind: AntigravityCooldownKind } | null { const health = accountHealth.get(accountId); if (!health) return null; if (health.cooldownUntil <= now) { @@ -131,11 +133,13 @@ export function recordAntigravityCooldown( accountId: string, retryAfterHeader?: string | null, now = Date.now(), + cooldownKind: AntigravityCooldownKind = "rate-limit", ): number { const delay = parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS; accountHealth.set(accountId, { cooldownUntil: now + delay, cooldownSource: retryAfterHeader?.trim() ? "retry-after" : "default", + cooldownKind, }); sweepExpiredOnWrite(now); return now + delay; @@ -165,6 +169,7 @@ export interface AntigravityAccountSelection { accountId: string | null; reason: AntigravityAccountSelectionReason; cooldownUntil?: number; + cooldownKind?: AntigravityCooldownKind; } export function resolveAntigravityAccountForSession( @@ -250,13 +255,14 @@ export function recordAntigravitySyntheticFailure( ): AntigravitySyntheticFailure | null { const failure = classifyAntigravityProviderError(payload); if (failure === "geoblock") { - accountHealth.set(accountId, { cooldownUntil: now + MAX_COOLDOWN_MS, cooldownSource: "synthetic" }); + accountHealth.set(accountId, { cooldownUntil: now + MAX_COOLDOWN_MS, cooldownSource: "synthetic", cooldownKind: "geoblock" }); return "geoblock"; } if (!failure) return null; accountHealth.set(accountId, { cooldownUntil: now + DEFAULT_COOLDOWN_MS, cooldownSource: "synthetic", + cooldownKind: failure, }); return failure; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 8a93259388..64dad8ef05 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2714,6 +2714,9 @@ async function handleResponsesInner( } if (selection.cooldownUntil !== undefined) { const retryAfter = Math.max(1, Math.ceil((selection.cooldownUntil - Date.now()) / 1000)); + if (selection.cooldownKind === "geoblock") { + return formatErrorResponse(403, "permission_error", "Selected Antigravity OAuth account is unavailable in this location"); + } return formatErrorResponse(429, "rate_limit_error", "Selected Antigravity OAuth account is temporarily rate-limited", { retryAfter: String(retryAfter) }); } if (selection.reason === "active-needs-reauth") { diff --git a/tests/antigravity-responses.test.ts b/tests/antigravity-responses.test.ts index 862929e695..c4dcf90f17 100644 --- a/tests/antigravity-responses.test.ts +++ b/tests/antigravity-responses.test.ts @@ -183,7 +183,26 @@ describe("Antigravity Responses integration", () => { expect(seen).toHaveLength(1); expect(await response.text()).toContain("quota exceeded"); const accountId = getAccountSet("google-antigravity")!.activeAccountId; - expect(getAntigravityAccountHealthSnapshot(accountId)?.cooldownSource).toBe("synthetic"); + expect(getAntigravityAccountHealthSnapshot(accountId)).toMatchObject({ cooldownSource: "synthetic", cooldownKind: "quota" }); + }); + + test("preserves 403 after a geoblock arrives as a 200 SSE error", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response(`data: ${JSON.stringify({ error: { code: 403, status: "PERMISSION_DENIED", message: "Location is not supported" } })}\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + const first = await handleResponses(request(true), fastConfig(), { model: "", provider: "" }, {}); + expect(first.status).toBe(200); + await first.text(); + const accountId = getAccountSet("google-antigravity")!.activeAccountId; + expect(getAntigravityAccountHealthSnapshot(accountId)).toMatchObject({ cooldownKind: "geoblock" }); + const second = await handleResponses(request(), fastConfig(), { model: "", provider: "" }, {}); + expect(second.status).toBe(403); + expect(calls).toBe(1); }); test("records cooldowns for actual Google envelopes in streaming and buffered Responses", async () => { @@ -204,7 +223,7 @@ describe("Antigravity Responses integration", () => { const response = await handleResponses(request(stream), fastConfig(), { model: "", provider: "" }, {}); expect(response.status).toBe(200); const accountId = getAccountSet("google-antigravity")!.activeAccountId; - expect(getAntigravityAccountHealthSnapshot(accountId)?.cooldownSource).toBe("synthetic"); + expect(getAntigravityAccountHealthSnapshot(accountId)).toMatchObject({ cooldownSource: "synthetic", cooldownKind: expected.source }); } } }); diff --git a/tests/antigravity-routing.test.ts b/tests/antigravity-routing.test.ts index 61a9637825..53357a779e 100644 --- a/tests/antigravity-routing.test.ts +++ b/tests/antigravity-routing.test.ts @@ -8,6 +8,7 @@ import { clearAntigravityRoutingState, getAntigravityAccountHealthSnapshot, recordAntigravityCooldown, + recordAntigravitySyntheticFailure, resolveAntigravityAccountForSession, retryableAntigravity429DelayMs, } from "../src/oauth/antigravity-routing"; @@ -59,13 +60,22 @@ describe("google antigravity strict account affinity", () => { const ids = accountIds(); await setActiveAccount("google-antigravity", ids.a); recordAntigravityCooldown(ids.a, "60", 1000); - expect(resolveAntigravityAccountForSession("new-conversation", 2000)).toMatchObject({ accountId: ids.a, reason: "active-cooled" }); + expect(resolveAntigravityAccountForSession("new-conversation", 2000)).toMatchObject({ accountId: ids.a, reason: "active-cooled", cooldownKind: "rate-limit" }); }); test("short retry-after is retryable once and longer cooldowns are not", () => { + expect(retryableAntigravity429DelayMs("0", 1000)).toBe(0); expect(retryableAntigravity429DelayMs("5", 1000)).toBe(5000); expect(retryableAntigravity429DelayMs("5.1", 1000)).toBeNull(); expect(retryableAntigravity429DelayMs("60", 1000)).toBeNull(); expect(retryableAntigravity429DelayMs(null, 1000)).toBeNull(); }); + + test("preserves synthetic failure kind in account health and selection", async () => { + const ids = accountIds(); + await setActiveAccount("google-antigravity", ids.a); + expect(recordAntigravitySyntheticFailure(ids.a, { code: 403, status: "PERMISSION_DENIED", message: "Location is not supported" }, 1000)).toBe("geoblock"); + expect(getAntigravityAccountHealthSnapshot(ids.a, 2000)).toMatchObject({ cooldownKind: "geoblock" }); + expect(resolveAntigravityAccountForSession("new-conversation", 2000)).toMatchObject({ accountId: ids.a, reason: "active-cooled", cooldownKind: "geoblock" }); + }); }); From aca0cf3d9243afc7c279ebb6dce855b629da981c Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:01:54 -0600 Subject: [PATCH 22/29] fix: classify QUOTA_EXCEEDED directly without requiring error message --- src/oauth/antigravity-routing.ts | 5 ++--- tests/antigravity-routing.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/oauth/antigravity-routing.ts b/src/oauth/antigravity-routing.ts index df10f978fc..394fd9ecdf 100644 --- a/src/oauth/antigravity-routing.ts +++ b/src/oauth/antigravity-routing.ts @@ -208,7 +208,7 @@ export function resolveAntigravityAccountForSession( } const RATE_LIMIT_CODES = new Set(["RATE_LIMIT_EXCEEDED", "TOO_MANY_REQUESTS"]); -const QUOTA_CODES = new Set(["QUOTA_EXCEEDED", "RESOURCE_EXHAUSTED"]); +const QUOTA_CODES = new Set(["QUOTA_EXCEEDED"]); const GEO_CODES = new Set(["LOCATION_NOT_SUPPORTED", "REGION_NOT_SUPPORTED", "GEO_BLOCKED"]); export function normalizeAntigravityProviderError(value: unknown): AntigravityProviderError | null { @@ -236,8 +236,7 @@ export function classifyAntigravityProviderError(value: unknown): AntigravitySyn const error = normalizeAntigravityProviderError(value); if (!error) return null; const message = error.message?.toLowerCase() ?? ""; - if ((QUOTA_CODES.has(error.code ?? "") || error.code === "RESOURCE_EXHAUSTED") - && /quota|resource[ _-]?exhausted/.test(message)) return "quota"; + if (QUOTA_CODES.has(error.code ?? "") || (error.code === "RESOURCE_EXHAUSTED" && /quota|resource[ _-]?exhausted/.test(message))) return "quota"; if (error.status === 429 || RATE_LIMIT_CODES.has(error.code ?? "") || (error.code === "RESOURCE_EXHAUSTED" && /rate[- ]limit|too many requests/.test(message))) { return "rate-limit"; } diff --git a/tests/antigravity-routing.test.ts b/tests/antigravity-routing.test.ts index 53357a779e..5414383628 100644 --- a/tests/antigravity-routing.test.ts +++ b/tests/antigravity-routing.test.ts @@ -6,6 +6,7 @@ import { antigravitySessionKeyFromParts, bindAntigravitySessionAffinity, clearAntigravityRoutingState, + classifyAntigravityProviderError, getAntigravityAccountHealthSnapshot, recordAntigravityCooldown, recordAntigravitySyntheticFailure, @@ -78,4 +79,10 @@ describe("google antigravity strict account affinity", () => { expect(getAntigravityAccountHealthSnapshot(ids.a, 2000)).toMatchObject({ cooldownKind: "geoblock" }); expect(resolveAntigravityAccountForSession("new-conversation", 2000)).toMatchObject({ accountId: ids.a, reason: "active-cooled", cooldownKind: "geoblock" }); }); + + test("classifies QUOTA_EXCEEDED as quota even without message", () => { + expect(classifyAntigravityProviderError({ code: "QUOTA_EXCEEDED", status: 429 })).toBe("quota"); + expect(classifyAntigravityProviderError({ code: "RESOURCE_EXHAUSTED", status: 429, message: "rate limit exceeded" })).toBe("rate-limit"); + expect(classifyAntigravityProviderError({ code: "RESOURCE_EXHAUSTED", status: 429, message: "quota exceeded" })).toBe("quota"); + }); }); From 8c8b7547e23d548ca5bcf389e283d4331338ea0b Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:06:02 -0600 Subject: [PATCH 23/29] security: redact generic URI proxy credentials and reuse shared NO_PROXY matcher --- src/lib/provider-outbound.ts | 34 +----------------------------- src/lib/proxy-env.ts | 2 +- src/lib/redact.ts | 2 +- tests/provider-tls-profile.test.ts | 16 ++++++++++++++ tests/redact.test.ts | 16 ++++++++++++++ 5 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index 71e816a9b0..e72bbb0ad0 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -7,7 +7,7 @@ import { resolvePublicAddresses, } from "./destination-policy"; import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; -import { outboundProxyConfigured, proxyForUrl } from "./proxy-env"; +import { noProxyMatches, outboundProxyConfigured, proxyForUrl } from "./proxy-env"; import { publicProviderBaseUrl } from "./provider-url"; import { antigravityOAuthDestinationConfigError, isCanonicalAntigravityUrl, providerTlsFetch } from "./provider-tls-profile"; import { waitForProviderRequestSlot } from "../providers/request-pacing"; @@ -42,38 +42,6 @@ function normalizeProxyHostname(hostname: string): string { : normalized; } -function noProxyMatches(url: URL): boolean { - const raw = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; - const hostname = normalizeProxyHostname(url.hostname); - const port = url.port || (url.protocol === "https:" ? "443" : "80"); - for (const rawEntry of raw.split(",")) { - let entry = rawEntry.trim().toLowerCase(); - if (!entry) continue; - if (entry === "*") return true; - entry = entry.replace(/^https?:\/\//, "").split("/", 1)[0]!; - - let entryHost = entry; - let entryPort = ""; - const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry); - if (bracketed) { - entryHost = bracketed[1]!; - entryPort = bracketed[2] ?? ""; - } else if ((entry.match(/:/g)?.length ?? 0) === 1) { - const separator = entry.lastIndexOf(":"); - const possiblePort = entry.slice(separator + 1); - if (/^\d+$/.test(possiblePort)) { - entryHost = entry.slice(0, separator); - entryPort = possiblePort; - } - } - if (entryPort && entryPort !== port) continue; - entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, "")); - if (!entryHost) continue; - if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true; - } - return false; -} - function antigravityProfileFetch( name: string, provider: ProviderOutboundConfig, diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index e1b7fb58af..9c4826001c 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -22,7 +22,7 @@ function proxyValue(key: ProxyEnvKey, env: ProxyEnvMap): string | undefined { return value || undefined; } -function noProxyMatches(url: URL, env: ProxyEnvMap): boolean { +export function noProxyMatches(url: URL, env: ProxyEnvMap = process.env): boolean { const raw = env.NO_PROXY?.trim() || env.no_proxy?.trim() || ""; const hostname = url.hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, ""); const port = url.port || (url.protocol === "https:" ? "443" : "80"); diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 12df07a5c5..a66857600f 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -497,7 +497,7 @@ export function redactUrlForLog(url: string): string { /** Redact credentials and userinfo from arbitrary diagnostic text. */ export function redactErrorMessage(value: string): string { return redactSecretString(value).replace( - /https?:\/\/[^\s"'<>]+/gi, + /[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/gi, match => redactUrlForLog(match), ); } diff --git a/tests/provider-tls-profile.test.ts b/tests/provider-tls-profile.test.ts index b273302533..dc44737f53 100644 --- a/tests/provider-tls-profile.test.ts +++ b/tests/provider-tls-profile.test.ts @@ -299,6 +299,22 @@ describe("Antigravity TLS transport gate", () => { expect(bunCalls).toBe(2); }); + test("redacts credentials in post-dispatch socks5 proxy errors", async () => { + setProviderTlsRuntimeForTest({ + importWreq: async () => ({ + createTransport: async () => ({ close: async () => undefined }), + fetch: async () => { + throw new Error("post-dispatch failure at socks5://alice:secret@proxy.test:1080/path?token=secret#fragment"); + }, + }), + }); + const executor = providerTlsFetch("google-antigravity", canonicalProvider, globalThis.fetch); + await expect(executor("https://daily-cloudcode-pa.googleapis.com/v1internal")) + .rejects.toThrow("post-dispatch failure at socks5://proxy.test:1080/path"); + await expect(executor("https://daily-cloudcode-pa.googleapis.com/v1internal")) + .rejects.not.toThrow(/alice|secret|token|fragment/); + }); + test("preserves only safe cancellation identity while redacting native details", async () => { setProviderTlsRuntimeForTest({ importWreq: async () => ({ diff --git a/tests/redact.test.ts b/tests/redact.test.ts index a1d75a3219..99ee4c4fe7 100644 --- a/tests/redact.test.ts +++ b/tests/redact.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { REDACTED_SECRET, redactHeaders, + redactErrorMessage, redactSecretString, redactSecrets, redactUrlForLog, @@ -538,3 +539,18 @@ describe("redactUrlForLog", () => { expect(redactUrlForLog("not a url?refreshToken=refresh-secret")).toBe("not a url"); }); }); + +describe("redactErrorMessage", () => { + test("strips credentials, queries, and fragments from every URI scheme", () => { + for (const scheme of ["socks5", "socks5h", "custom+proxy.v1"]) { + const redacted = redactErrorMessage( + `connect failed via ${scheme}://alice:secret@proxy.test:1080/path?token=secret#fragment`, + ); + expect(redacted).toBe(`connect failed via ${scheme}://proxy.test:1080/path`); + expect(redacted).not.toContain("alice"); + expect(redacted).not.toContain("secret"); + expect(redacted).not.toContain("token"); + expect(redacted).not.toContain("fragment"); + } + }); +}); From 65930263809855873188c6287e7cf14d4017dd8d Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:10:42 -0600 Subject: [PATCH 24/29] fix: handle malformed URI credential redaction and preserve NO_PROXY uppercase precedence --- src/lib/proxy-env.ts | 4 ++-- src/lib/redact.ts | 3 ++- tests/provider-tls-profile.test.ts | 6 ++++++ tests/redact.test.ts | 7 +++++++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index 9c4826001c..9d9bd679b6 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -18,12 +18,12 @@ export function outboundProxyConfigured( } function proxyValue(key: ProxyEnvKey, env: ProxyEnvMap): string | undefined { - const value = env[key]?.trim() || env[key.toLowerCase()]?.trim(); + const value = (env[key] ?? env[key.toLowerCase()] ?? "").trim(); return value || undefined; } export function noProxyMatches(url: URL, env: ProxyEnvMap = process.env): boolean { - const raw = env.NO_PROXY?.trim() || env.no_proxy?.trim() || ""; + const raw = (env.NO_PROXY ?? env.no_proxy ?? "").trim(); const hostname = url.hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, ""); const port = url.port || (url.protocol === "https:" ? "443" : "80"); for (const value of raw.split(",")) { diff --git a/src/lib/redact.ts b/src/lib/redact.ts index a66857600f..fbe705aac8 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -490,7 +490,8 @@ export function redactUrlForLog(url: string): string { parsed.hash = ""; return parsed.toString(); } catch { - return redactSecretString(url.split("?")[0] ?? url); + const stripped = (url.split(/[?#]/)[0] ?? url).replace(/:\/\/[^/@\s]+@/g, "://"); + return redactSecretString(stripped); } } diff --git a/tests/provider-tls-profile.test.ts b/tests/provider-tls-profile.test.ts index dc44737f53..a68977fa89 100644 --- a/tests/provider-tls-profile.test.ts +++ b/tests/provider-tls-profile.test.ts @@ -424,5 +424,11 @@ describe("Antigravity proxy selection", () => { expect(proxyForUrl("https://daily-cloudcode-pa.googleapis.com/v1", env)).toBeUndefined(); expect(proxyForUrl("https://other.example/v1", env)).toBe("http://proxy-user:secret@example.test:8080"); expect(proxyForUrl("http://other.example/v1", { ALL_PROXY: "http://all.example:8080" })).toBe("http://all.example:8080"); + // Uppercase NO_PROXY takes precedence over lowercase no_proxy even when empty + expect(proxyForUrl("https://daily-cloudcode-pa.googleapis.com/v1", { + HTTPS_PROXY: "http://proxy-user:secret@example.test:8080", + NO_PROXY: "", + no_proxy: "daily-cloudcode-pa.googleapis.com", + })).toBe("http://proxy-user:secret@example.test:8080"); }); }); diff --git a/tests/redact.test.ts b/tests/redact.test.ts index 99ee4c4fe7..06d3a99b72 100644 --- a/tests/redact.test.ts +++ b/tests/redact.test.ts @@ -553,4 +553,11 @@ describe("redactErrorMessage", () => { expect(redacted).not.toContain("fragment"); } }); + + test("redacts credentials from malformed URLs where new URL throws", () => { + const redacted = redactErrorMessage("malformed native socks5://alice:secret@proxy.test:99999 connection failed"); + expect(redacted).toBe("malformed native socks5://proxy.test:99999 connection failed"); + expect(redacted).not.toContain("alice"); + expect(redacted).not.toContain("secret"); + }); }); From f3fe5a12dd8072725cd86ced12f7d51fcbf380ae Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:14:54 -0600 Subject: [PATCH 25/29] fix: handle slash-containing userinfo in fallback redaction and restore proxyValue precedence --- src/lib/proxy-env.ts | 2 +- src/lib/redact.ts | 2 +- tests/redact.test.ts | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index 9d9bd679b6..4f71b5212e 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -18,7 +18,7 @@ export function outboundProxyConfigured( } function proxyValue(key: ProxyEnvKey, env: ProxyEnvMap): string | undefined { - const value = (env[key] ?? env[key.toLowerCase()] ?? "").trim(); + const value = env[key]?.trim() || env[key.toLowerCase()]?.trim(); return value || undefined; } diff --git a/src/lib/redact.ts b/src/lib/redact.ts index fbe705aac8..56d24da5ba 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -490,7 +490,7 @@ export function redactUrlForLog(url: string): string { parsed.hash = ""; return parsed.toString(); } catch { - const stripped = (url.split(/[?#]/)[0] ?? url).replace(/:\/\/[^/@\s]+@/g, "://"); + const stripped = (url.split(/[?#]/)[0] ?? url).replace(/:\/\/[^@\s]+@/g, "://"); return redactSecretString(stripped); } } diff --git a/tests/redact.test.ts b/tests/redact.test.ts index 06d3a99b72..dd7d3ecc01 100644 --- a/tests/redact.test.ts +++ b/tests/redact.test.ts @@ -559,5 +559,10 @@ describe("redactErrorMessage", () => { expect(redacted).toBe("malformed native socks5://proxy.test:99999 connection failed"); expect(redacted).not.toContain("alice"); expect(redacted).not.toContain("secret"); + + const withSlash = redactErrorMessage("malformed socks5://alice:sec/ret@proxy.test:99999/path connection failed"); + expect(withSlash).toBe("malformed socks5://proxy.test:99999/path connection failed"); + expect(withSlash).not.toContain("alice"); + expect(withSlash).not.toContain("sec/ret"); }); }); From 599c55a831b5161c1e57134fa1909fd8f9634baf Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:19:27 -0600 Subject: [PATCH 26/29] feat: permit provider-level jitter-only request pacing in config and GUI --- .../provider-workspace/ProviderSettings.tsx | 6 +++- .../provider-settings-request-pacing.test.tsx | 30 +++++++++++++++++++ src/config.ts | 3 +- tests/config.test.ts | 16 ++++++++++ tests/request-pacing.test.ts | 2 ++ 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index e4adfe21ea..b92e3a28ae 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -249,7 +249,11 @@ export default function ProviderSettings({ setSaving(true); setMsg(null); try { - if (pacingEnabled && !pacingDraft.requestsPerMinute && !pacingDraft.minIntervalMs && !pacingDraft.models) { + if (pacingEnabled + && pacingDraft.requestsPerMinute === undefined + && pacingDraft.minIntervalMs === undefined + && pacingDraft.jitterMs === undefined + && pacingDraft.models === undefined) { setMsg({ ok: false, text: t("pws.pacingRuleRequired") }); return false; } const pacingOnly = pacingDirty && !dirty && !tlsDirty; diff --git a/gui/tests/provider-settings-request-pacing.test.tsx b/gui/tests/provider-settings-request-pacing.test.tsx index d76d7d7df6..6270fd334f 100644 --- a/gui/tests/provider-settings-request-pacing.test.tsx +++ b/gui/tests/provider-settings-request-pacing.test.tsx @@ -80,3 +80,33 @@ test("settings saves provider pacing and a slower model override", async () => { expect(container.textContent).toContain("deepseek-ai/deepseek-v4-flash-0731"); await act(async () => { root.unmount(); }); }); + +test("settings saves provider-level jitter-only pacing", async () => { + const item: WorkspaceItem = { + name: "nvidia", + adapter: "openai-chat", + baseUrl: "https://integrate.api.nvidia.com/v1", + authMode: "key", + }; + const patches: ProviderUpdatePatch[] = []; + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( { patches.push(patch); return { ok: true }; }} + />); + }); + + await act(async () => { container.querySelector(".pwi-pacing-toggle input")!.click(); }); + const numbers = container.querySelectorAll('.pwi-pacing-card input[type="number"]'); + await setInput(numbers[2]!, "500"); + const save = container.querySelector(".pwi-settings-sticky-bar .btn-primary")!; + await act(async () => { save.click(); await Promise.resolve(); }); + + expect(patches).toEqual([{ requestPacing: { enabled: true, jitterMs: 500 } }]); + await act(async () => { root.unmount(); }); +}); diff --git a/src/config.ts b/src/config.ts index 4a9d040c70..3a9a29eec5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -453,6 +453,7 @@ const requestPacingSchema = z.object({ }).strict().refine(value => value.enabled === false || value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined + || value.jitterMs !== undefined || (value.models !== undefined && Object.keys(value.models).length > 0), { message: "enabled request pacing needs a provider rule or model override", }); @@ -461,7 +462,7 @@ export function requestPacingConfigError(value: unknown): string | null { if (value === undefined) return null; const parsed = requestPacingSchema.safeParse(value); if (parsed.success) return null; - return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides"; + return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs/jitterMs provider rule or model overrides"; } const fastWireSchema = z.object({ diff --git a/tests/config.test.ts b/tests/config.test.ts index 372d1a70c7..b29dc66e35 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -23,6 +23,7 @@ import { removePid, removeRuntimePort, ocxStartProcessCacheSizeForTests, + requestPacingConfigError, setOcxStartProcessCacheForTests, setProcessCommandLineExecForTests, setProcessCommandLinePlatformForTests, @@ -112,6 +113,21 @@ function writeAccountNamespaceConfig( } describe("opencodex config defaults", () => { + test("accepts and preserves provider-level jitter-only request pacing", () => { + const defaults = getDefaultConfig(); + const requestPacing = { enabled: true, jitterMs: 500 }; + const result = validateConfigCandidate({ + ...defaults, + providers: { + ...defaults.providers, + openai: { ...defaults.providers.openai!, requestPacing }, + }, + }); + + expect(requestPacingConfigError(requestPacing)).toBeNull(); + expect(result).toMatchObject({ ok: true, config: { providers: { openai: { requestPacing } } } }); + }); + test("malformed classifier config is normalized at load, even with subagentEffort absent (#1697)", () => { // normalizePersistedClaudeCode used to be reached only through a subagentEffort short-circuit, // so a config whose ONLY defect was elsewhere in claudeCode was never normalized. These diff --git a/tests/request-pacing.test.ts b/tests/request-pacing.test.ts index b936c603c1..f7916883b2 100644 --- a/tests/request-pacing.test.ts +++ b/tests/request-pacing.test.ts @@ -90,6 +90,8 @@ describe("requestPacingIntervalMs", () => { test("validates jitter and keeps model jitter as an additional delay", async () => { expect(requestPacingConfigError({ enabled: true, minIntervalMs: 100, jitterMs: 60_001 })).not.toBeNull(); expect(requestPacingConfigError({ enabled: true, minIntervalMs: 100, jitterMs: 25 })).toBeNull(); + expect(requestPacingConfigError({ enabled: true, jitterMs: 25 })).toBeNull(); + expect(requestPacingConfigError({ enabled: true, jitterMs: 0 })).toBeNull(); expect(requestPacingConfigError({ enabled: true, minIntervalMs: 100, From cb7f6232af29bab1c029d06ccb69375192778d64 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:22:12 -0600 Subject: [PATCH 27/29] test: restore Antigravity canonical destination in direct outbound POST fixture --- tests/provider-outbound.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/provider-outbound.test.ts b/tests/provider-outbound.test.ts index 16a7fb2165..35f6ae28e0 100644 --- a/tests/provider-outbound.test.ts +++ b/tests/provider-outbound.test.ts @@ -467,9 +467,9 @@ describe("provider outbound POST transport", () => { const body = JSON.stringify({ project: "test-project" }); const response = await providerOutboundPost( - "custom", - { baseUrl: "https://provider.example" }, - "https://provider.example/v1internal:fetchAvailableModels", + "google-antigravity", + { baseUrl: "https://daily-cloudcode-pa.googleapis.com", adapter: "google", authMode: "oauth", googleMode: "cloud-code-assist" }, + "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", { headers: { authorization: "Bearer test-token" }, body }, dependencies, ); From 628c800a5d4747f73918c10d19710d39e9cbc7ab Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:23:20 -0600 Subject: [PATCH 28/29] test: update CCA server-images fixture to use canonical Antigravity destination --- tests/server-images.test.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index f9fcab7bc1..3da06a4a0d 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -1111,7 +1111,7 @@ function ccaConfig(): OcxConfig { openai: disabledOpenAiProvider, "google-antigravity": { adapter: "google", - baseUrl: "https://attacker.example.com", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", googleMode: "cloud-code-assist", } as OcxConfig["providers"][string], }, @@ -1402,9 +1402,19 @@ test("CCA image fallback never sends Authorization to a tampered config baseUrl const attackerHits: CcaFetchRequest[] = []; ccaFetchMock(registryHits, attackerHits); - // ccaConfig already sets baseUrl to https://attacker.example.com — if the pin - // were ever removed, this host would receive the OAuth bearer token. - saveConfig(ccaConfig()); + // Tampered baseUrl must fail closed and never send OAuth credentials + const tampered = { + ...ccaConfig(), + providers: { + ...ccaConfig().providers, + "google-antigravity": { + adapter: "google", + baseUrl: "https://attacker.example.com", + googleMode: "cloud-code-assist", + } as OcxConfig["providers"][string], + }, + } as OcxConfig; + saveConfig(tampered); await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); const server = startServer(0); @@ -1414,16 +1424,12 @@ test("CCA image fallback never sends Authorization to a tampered config baseUrl headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: "a cat" }), }); - expect(response.status).toBe(200); - - // The registry host received the request with the OAuth bearer token. - expect(registryHits).toHaveLength(1); - expect(registryHits[0].url).toContain("daily-cloudcode-pa.googleapis.com"); - expect(registryHits[0].headers.get("authorization")).toBe("Bearer cca-access-token"); + expect(response.status).toBe(400); - // The attacker host received ZERO requests — no Authorization header leak. + // No calls should go to the attacker host or registry host. const authLeak = attackerHits.filter(r => r.headers.get("authorization")); expect(attackerHits).toHaveLength(0); + expect(registryHits).toHaveLength(0); expect(authLeak).toHaveLength(0); } finally { await server.stop(true); @@ -1486,7 +1492,7 @@ test("CCA fallback serves images when OpenAI forward auth fails but Google Antig openai: { ...canonicalOpenAiProvider, codexAccountMode: "pool" }, "google-antigravity": { adapter: "google", - baseUrl: "https://attacker.example.com", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", googleMode: "cloud-code-assist", } as OcxConfig["providers"][string], }, From 03c5f7f3157a726e9685b5b1bc55439a290f740e Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:37:10 -0600 Subject: [PATCH 29/29] fix: record HTTP 403 geoblocks, preserve strongest active cooldown, and verify web-search canonical gate --- src/oauth/antigravity-routing.ts | 46 ++++++++++++++++++++++++----- src/server/responses/core.ts | 6 ++++ tests/antigravity-responses.test.ts | 17 +++++++++++ tests/antigravity-routing.test.ts | 15 ++++++++++ tests/gemini-web-search.test.ts | 11 +++++-- 5 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/oauth/antigravity-routing.ts b/src/oauth/antigravity-routing.ts index 394fd9ecdf..55bc4f1b19 100644 --- a/src/oauth/antigravity-routing.ts +++ b/src/oauth/antigravity-routing.ts @@ -135,14 +135,31 @@ export function recordAntigravityCooldown( now = Date.now(), cooldownKind: AntigravityCooldownKind = "rate-limit", ): number { - const delay = parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS; + const delay = parseRetryAfterMs(retryAfterHeader, now) ?? (cooldownKind === "geoblock" ? MAX_COOLDOWN_MS : DEFAULT_COOLDOWN_MS); + const targetUntil = now + delay; + const existing = accountHealth.get(accountId); + if (existing && existing.cooldownUntil > now) { + const effectiveUntil = Math.max(existing.cooldownUntil, targetUntil); + const effectiveKind = (existing.cooldownKind === "geoblock" || cooldownKind === "geoblock") + ? "geoblock" + : (cooldownKind === "quota" || existing.cooldownKind === "quota") + ? "quota" + : "rate-limit"; + accountHealth.set(accountId, { + cooldownUntil: effectiveUntil, + cooldownSource: retryAfterHeader?.trim() ? "retry-after" : existing.cooldownSource, + cooldownKind: effectiveKind, + }); + sweepExpiredOnWrite(now); + return effectiveUntil; + } accountHealth.set(accountId, { - cooldownUntil: now + delay, + cooldownUntil: targetUntil, cooldownSource: retryAfterHeader?.trim() ? "retry-after" : "default", cooldownKind, }); sweepExpiredOnWrite(now); - return now + delay; + return targetUntil; } export function clearAntigravityAccountCooldown(accountId: string): boolean { @@ -253,13 +270,26 @@ export function recordAntigravitySyntheticFailure( now = Date.now(), ): AntigravitySyntheticFailure | null { const failure = classifyAntigravityProviderError(payload); - if (failure === "geoblock") { - accountHealth.set(accountId, { cooldownUntil: now + MAX_COOLDOWN_MS, cooldownSource: "synthetic", cooldownKind: "geoblock" }); - return "geoblock"; - } if (!failure) return null; + const delay = failure === "geoblock" ? MAX_COOLDOWN_MS : DEFAULT_COOLDOWN_MS; + const targetUntil = now + delay; + const existing = accountHealth.get(accountId); + if (existing && existing.cooldownUntil > now) { + const effectiveUntil = Math.max(existing.cooldownUntil, targetUntil); + const effectiveKind = (existing.cooldownKind === "geoblock" || failure === "geoblock") + ? "geoblock" + : (failure === "quota" || existing.cooldownKind === "quota") + ? "quota" + : "rate-limit"; + accountHealth.set(accountId, { + cooldownUntil: effectiveUntil, + cooldownSource: "synthetic", + cooldownKind: effectiveKind, + }); + return failure; + } accountHealth.set(accountId, { - cooldownUntil: now + DEFAULT_COOLDOWN_MS, + cooldownUntil: targetUntil, cooldownSource: "synthetic", cooldownKind: failure, }); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 64dad8ef05..6b42fe794b 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -4924,6 +4924,9 @@ async function handleResponsesInner( // Antigravity never rotates accounts. Record the account cooldown and allow only one // short, abort-aware replay on the same credential; longer Retry-After values surface to // the client so a conversation cannot churn through accounts or requests. + if (upstreamResponse.status === 403 && isAntigravityOAuth && antigravityAccountId) { + recordAntigravityCooldown(antigravityAccountId, upstreamResponse.headers.get("retry-after"), Date.now(), "geoblock"); + } if (upstreamResponse.status === 429 && isAntigravityOAuth && antigravityAccountId) { recordAntigravityCooldown(antigravityAccountId, upstreamResponse.headers.get("retry-after")); const retryAfter = retryableAntigravity429DelayMs(upstreamResponse.headers.get("retry-after")); @@ -5255,6 +5258,9 @@ async function handleResponsesInner( } } + if (response.status === 403 && isAntigravityOAuth && antigravityAccountId) { + recordAntigravityCooldown(antigravityAccountId, response.headers.get("retry-after"), Date.now(), "geoblock"); + } if (response.status === 429 && isAntigravityOAuth && antigravityAccountId) { recordAntigravityCooldown(antigravityAccountId, response.headers.get("retry-after")); const retryAfter = retryableAntigravity429DelayMs(response.headers.get("retry-after")); diff --git a/tests/antigravity-responses.test.ts b/tests/antigravity-responses.test.ts index c4dcf90f17..813b0932f1 100644 --- a/tests/antigravity-responses.test.ts +++ b/tests/antigravity-responses.test.ts @@ -334,3 +334,20 @@ describe("Antigravity Responses integration", () => { expect(calls).toBe(1); }, 10_000); }); + test("records 403 HTTP geoblock cooldown and returns 403 on subsequent requests", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response(JSON.stringify({ error: { message: "User location is not supported" } }), { + status: 403, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + const first = await handleResponses(request(), fastConfig(), { model: "", provider: "" }, {}); + expect(first.status).toBe(403); + const accountId = getAccountSet("google-antigravity")!.activeAccountId; + expect(getAntigravityAccountHealthSnapshot(accountId)).toMatchObject({ cooldownKind: "geoblock" }); + const second = await handleResponses(request(), fastConfig(), { model: "", provider: "" }, {}); + expect(second.status).toBe(403); + expect(calls).toBe(1); + }); diff --git a/tests/antigravity-routing.test.ts b/tests/antigravity-routing.test.ts index 5414383628..523f488da1 100644 --- a/tests/antigravity-routing.test.ts +++ b/tests/antigravity-routing.test.ts @@ -80,6 +80,21 @@ describe("google antigravity strict account affinity", () => { expect(resolveAntigravityAccountForSession("new-conversation", 2000)).toMatchObject({ accountId: ids.a, reason: "active-cooled", cooldownKind: "geoblock" }); }); + test("cooldown updates preserve the strongest active cooldown and kind", async () => { + const ids = accountIds(); + await setActiveAccount("google-antigravity", ids.a); + // 15-minute geoblock + recordAntigravitySyntheticFailure(ids.a, { code: 403, status: "PERMISSION_DENIED", message: "Location is not supported" }, 1000); + const healthBefore = getAntigravityAccountHealthSnapshot(ids.a, 2000); + expect(healthBefore?.cooldownKind).toBe("geoblock"); + expect(healthBefore?.cooldownUntil).toBe(1000 + 15 * 60_000); + + // Shorter 60s synthetic failure should not shorten the 15-minute geoblock or overwrite the geoblock kind + recordAntigravitySyntheticFailure(ids.a, { code: 429, status: "RESOURCE_EXHAUSTED", message: "rate limit exceeded" }, 2000); + const healthAfter = getAntigravityAccountHealthSnapshot(ids.a, 2000); + expect(healthAfter?.cooldownKind).toBe("geoblock"); + expect(healthAfter?.cooldownUntil).toBe(1000 + 15 * 60_000); + }); test("classifies QUOTA_EXCEEDED as quota even without message", () => { expect(classifyAntigravityProviderError({ code: "QUOTA_EXCEEDED", status: 429 })).toBe("quota"); expect(classifyAntigravityProviderError({ code: "RESOURCE_EXHAUSTED", status: 429, message: "rate limit exceeded" })).toBe("rate-limit"); diff --git a/tests/gemini-web-search.test.ts b/tests/gemini-web-search.test.ts index e66e555700..71b1f4e124 100644 --- a/tests/gemini-web-search.test.ts +++ b/tests/gemini-web-search.test.ts @@ -5,7 +5,7 @@ import { MAX_SIDECAR_RESPONSE_BYTES } from "../src/web-search/parse"; let accountSets: Record }>; activeAccountId?: string }> = {}; mock.module("../src/oauth/store", () => ({ ...storeModule, - getAccountSet: (provider: string) => accountSets[provider] ?? null, + getAccountSet: (provider: string) => accountSets[provider] ?? storeModule.getAccountSet(provider), })); import { mapCcaGroundedResponse } from "../src/web-search/gemini-executor"; @@ -201,7 +201,7 @@ describe("runGeminiWebSearch request shape (review P1)", () => { } }); - test("malicious baseUrl ignored: registry destination, manual redirect, bearer + IDE UA, full envelope, thinkingConfig", async () => { + test("malicious baseUrl fails closed and does not dispatch upstream", async () => { accountSets = { "google-antigravity": { accounts: [{ id: "a1", credential: { projectId: "proj-9" } }], activeAccountId: "a1" } }; const captured: Array<{ url: string; init: RequestInit }> = []; const realFetch = globalThis.fetch; @@ -212,7 +212,12 @@ describe("runGeminiWebSearch request shape (review P1)", () => { try { const evil: OcxProviderConfig = { adapter: "google", baseUrl: "https://evil.example/v1", authMode: "oauth" }; const out = await runGeminiWebSearch("q", "google-antigravity", evil, { model: "gemini-3.7-flash", reasoning: "low", timeoutMs: 5000, describeImages: false }); - expect(out.text).toBe("ok"); + expect(out.error).toContain("canonical Antigravity HTTPS destination"); + expect(captured).toHaveLength(0); + + const canonical: OcxProviderConfig = { adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth" }; + const validOut = await runGeminiWebSearch("q", "google-antigravity", canonical, { model: "gemini-3.7-flash", reasoning: "low", timeoutMs: 5000, describeImages: false }); + expect(validOut.text).toBe("ok"); expect(captured).toHaveLength(1); const req = captured[0]!; expect(new URL(req.url).origin).toBe("https://daily-cloudcode-pa.googleapis.com");