From 65fbba4893f23fd910adddee4e8306072a45c18f Mon Sep 17 00:00:00 2001 From: "Bob.Hou" <364432949@qq.com> Date: Sat, 18 Jul 2026 14:14:14 -0400 Subject: [PATCH] fix(antigravity): wrap Pro fallback chain in try/catch for timeout resilience (#7290) * fix(antigravity): wrap executeOnce in try/catch for Pro fallback chain When a Pro-tier candidate times out or throws a network error, the exception now continues to the next candidate instead of aborting the entire chain. Includes diagnostic logging and unit tests. Signed-off-by: Minxi Hou * fix(antigravity): propagate abort signal in Pro fallback catch block Re-throw AbortError and signal.aborted immediately instead of retrying the next candidate. Prevents wasted upstream requests after client disconnect. Signed-off-by: Minxi Hou * fix(mitm): skip DNS modification when sudo unavailable (container) In containers (USER node, no sudo, not root) provisionDnsEntries() now detects the condition up-front and logs a clear message instead of attempting sudo and silently swallowing the error. Adds canElevate() to the injectable deps interface for testability, and supports SKIP_ANTIGRAVITY_DNS=true for explicit opt-out. * fix(antigravity): improve abort detection and fallback error handling Check Error.name === 'AbortError' for non-DOMException environments (polyfills, test harnesses). Capture first 400 from any candidate (not just i===0) so mixed paths surface the 400 instead of a generic error. Return firstResult when last candidate throws, consistent with the all-400 case. * test(mitm): add coverage for container-skip DNS provisioning * test(mitm): harden container-skip DNS test assertions The SKIP_ANTIGRAVITY_DNS=true and canElevate()=false tests used empty agentStates/customHosts, so they could not distinguish 'all steps skipped' from 'only the default step skipped'. Provide non-empty mocks and assert addHostsDns was NOT called. Also add a SKIP_ANTIGRAVITY_DNS=false boundary test confirming the strict === "true" comparison does not block normal provisioning, and verify sudoPassword passthrough in the canElevate=true happy-path test. * refactor(mitm): split provisionDnsEntries below complexity gate provisionDnsEntries() (complexity ~18, this PR's try/catch/log additions pushed it over check-complexity.mjs's threshold of 15) and execute()'s Pro-fallback loop (complexity 27, from wrapping executeOnce() in try/catch for timeout resilience) were both over the gate. Decomposed each into small named helpers, no behavior change: - provision.ts: split into provisionDefaultDns/provisionAgentDns/ provisionCustomHostsDns, each wrapping one best-effort DNS step. - antigravity.ts: extracted the fallback-chain catch/400-handling decisions (handleAntigravityFallbackChainError, isAntigravityAbortError, handleAntigravityFallback400) into a new antigravity/proFallbackChain.ts submodule (pure, no executor instance state), mirroring the existing antigravity/sseCollect.ts submodule pattern. Also fixes the antigravity.ts file-size cap (was pushed to 1854 lines > 1813 frozen ceiling by this PR's own try/catch addition; now 1771). execute/provisionDnsEntries no longer appear with ruleId complexity or max-lines-per-function in the check-complexity.mjs report. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(antigravity): drop redundant loop continue (cognitive-complexity gate) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(antigravity): fold fallback outcome dispatch into switch (cognitive gate) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Signed-off-by: Minxi Hou Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: HouMinXi <19586012+HouMinXi@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- open-sse/executors/antigravity.ts | 52 +++-- .../executors/antigravity/proFallbackChain.ts | 104 +++++++++ src/mitm/dns/provision.ts | 130 +++++++---- .../unit/agy-pro-fallback-chain-3786.test.ts | 203 ++++++++++++++++++ .../mitm-dns-graceful-degrade-6127.test.ts | 127 ++++++++++- 5 files changed, 557 insertions(+), 59 deletions(-) create mode 100644 open-sse/executors/antigravity/proFallbackChain.ts diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 49b1d8ff37..e77fa7211f 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -53,6 +53,10 @@ import { } from "./antigravity/sseCollect.ts"; // processAntigravitySSEPayload re-exported for external importers (tests). export { processAntigravitySSEPayload } from "./antigravity/sseCollect.ts"; +import { + handleAntigravityFallbackChainError, + handleAntigravityFallback400, +} from "./antigravity/proFallbackChain.ts"; import { applyAntigravityClientProfileHeaders, removeHeaderCaseInsensitive, @@ -1070,7 +1074,28 @@ export class AntigravityExecutor extends BaseExecutor { let firstResult: Awaited> | null = null; for (let i = 0; i < chain.length; i++) { const candidate = chain[i]; - const result = await this.executeOnce(input, candidate); + let result: Awaited>; + try { + result = await this.executeOnce(input, candidate); + } catch (error) { + const outcome = handleAntigravityFallbackChainError( + input, + error, + candidate, + i, + chain, + firstResult, + resolvedUpstreamId + ); + switch (outcome.action) { + case "throw": + throw outcome.error; + case "return": + return outcome.result; + default: + continue; + } + } // Success (or any non-400) on a candidate → return immediately. if (result.response.status !== HTTP_STATUS.BAD_REQUEST) { @@ -1078,23 +1103,18 @@ export class AntigravityExecutor extends BaseExecutor { } // Remember the FIRST 400 so the exhausted-chain case surfaces the original error. - if (i === 0) firstResult = result; + if (!firstResult) firstResult = result; - const isLast = i === chain.length - 1; - if (!isLast) { - input.log?.debug?.( - "AG_PRO_FALLBACK", - `400 on "${candidate}" — retrying with next Pro candidate "${chain[i + 1]}"` - ); - continue; - } - - // Chain exhausted: surface the FIRST candidate's sanitized 400. - input.log?.warn?.( - "AG_PRO_FALLBACK", - `Pro fallback chain exhausted (all ${chain.length} candidates 400'd) for "${resolvedUpstreamId}"` + const outcome400 = handleAntigravityFallback400( + input, + result, + firstResult, + candidate, + i, + chain, + resolvedUpstreamId ); - return firstResult ?? result; + if (outcome400.action === "return") return outcome400.result; } // Unreachable (loop always returns), but keeps the type checker happy. diff --git a/open-sse/executors/antigravity/proFallbackChain.ts b/open-sse/executors/antigravity/proFallbackChain.ts new file mode 100644 index 0000000000..ec5b710c1f --- /dev/null +++ b/open-sse/executors/antigravity/proFallbackChain.ts @@ -0,0 +1,104 @@ +// Pure Pro-family fallback-chain decision helpers for the Antigravity executor (#7290): +// decide what execute()'s per-candidate loop does after executeOnce() throws or +// returns a 400, without depending on executor instance state (no `this`). +// Extracted from antigravity.ts (file-size cap) -- mirrors the existing +// antigravity/sseCollect.ts submodule pattern. +import type { ExecuteInput } from "../base.ts"; + +/** Shape of one execute()/executeOnce() result (kept local to avoid importing the class). */ +export type AntigravityExecuteResult = { + response: Response; + url: string; + headers: Record; + transformedBody: unknown; +}; + +/** True for an aborted request (caller disconnect) — never retried across candidates. */ +export function isAntigravityAbortError(input: ExecuteInput, error: unknown): boolean { + return Boolean( + input.signal?.aborted || + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && error.name === "AbortError") + ); +} + +export type AntigravityFallbackChainErrorOutcome = + | { action: "throw"; error: unknown } + | { action: "return"; result: AntigravityExecuteResult } + | { action: "continue" }; + +/** + * Decide what execute()'s Pro-fallback loop does after executeOnce() THROWS for one + * candidate: propagate an abort immediately, retry the next candidate, surface the + * first 400 if the chain is exhausted, or throw a chain-exhausted error. + */ +export function handleAntigravityFallbackChainError( + input: ExecuteInput, + error: unknown, + candidate: string, + i: number, + chain: readonly string[], + firstResult: AntigravityExecuteResult | null, + resolvedUpstreamId: string +): AntigravityFallbackChainErrorOutcome { + // Abort signal (user disconnect) — propagate immediately, do not retry. + if (isAntigravityAbortError(input, error)) { + return { action: "throw", error }; + } + if (i < chain.length - 1) { + input.log?.debug?.( + "AG_PRO_FALLBACK", + `Exception on "${candidate}" (${error instanceof Error ? error.message : String(error)}) -- retrying with next Pro candidate "${chain[i + 1]}"` + ); + return { action: "continue" }; + } + // Last candidate also threw -- return original 400 if available, otherwise throw. + if (firstResult) { + input.log?.warn?.( + "AG_PRO_FALLBACK", + `Pro fallback chain exhausted (last candidate threw, but first candidate returned 400) for "${resolvedUpstreamId}". Returning original 400.` + ); + return { action: "return", result: firstResult }; + } + return { + action: "throw", + error: new Error( + `Pro fallback chain exhausted (all ${chain.length} candidates failed). Last error: ${error instanceof Error ? error.message : String(error)}` + ), + }; +} + +export type AntigravityFallback400Outcome = + | { action: "return"; result: AntigravityExecuteResult } + | { action: "continue" }; + +/** + * Decide what execute()'s Pro-fallback loop does after one candidate returns a 400: + * retry the next candidate, or (chain exhausted) surface the first candidate's + * sanitized 400. + */ +export function handleAntigravityFallback400( + input: ExecuteInput, + result: AntigravityExecuteResult, + firstResult: AntigravityExecuteResult | null, + candidate: string, + i: number, + chain: readonly string[], + resolvedUpstreamId: string +): AntigravityFallback400Outcome { + const isLast = i === chain.length - 1; + if (!isLast) { + input.log?.debug?.( + "AG_PRO_FALLBACK", + `400 on "${candidate}" — retrying with next Pro candidate "${chain[i + 1]}"` + ); + return { action: "continue" }; + } + + // Chain exhausted: surface the FIRST candidate's sanitized 400. + input.log?.warn?.( + "AG_PRO_FALLBACK", + `Pro fallback chain exhausted (all ${chain.length} candidates 400'd) for "${resolvedUpstreamId}"` + ); + return { action: "return", result: firstResult ?? result }; +} diff --git a/src/mitm/dns/provision.ts b/src/mitm/dns/provision.ts index 544207dbb8..8679e37ffb 100644 --- a/src/mitm/dns/provision.ts +++ b/src/mitm/dns/provision.ts @@ -3,7 +3,8 @@ * is guarded and unit-testable without spawning the MITM server (#6127 / #6198). */ -import { addDNSEntry, addDNSEntries } from "./dnsConfig.ts"; +import { addDNSEntry, addDNSEntries, isSudoAvailable } from "./dnsConfig.ts"; +import { isRoot } from "../systemCommands.ts"; import { ALL_TARGETS } from "../targets/index.ts"; import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState.ts"; import { listCustomHosts } from "@/lib/db/inspectorCustomHosts.ts"; @@ -23,9 +24,73 @@ export interface DnsProvisionDeps { addHostsDns?: (hosts: string[], sudoPassword: string) => Promise; getAgentStates?: () => ReturnType; listEnabledCustomHosts?: () => ReturnType; + /** Return true if privileged host-file writes are possible (sudo or root). */ + canElevate?: () => boolean; logger?: DnsProvisionLogger; } +/** Fully-resolved dependency set used by the per-step provisioning helpers below. */ +type ResolvedDnsProvisionDeps = { + addDefaultDns: (sudoPassword: string) => Promise; + addHostsDns: (hosts: string[], sudoPassword: string) => Promise; + getAgentStates: () => ReturnType; + listEnabledCustomHosts: () => ReturnType; + logger: DnsProvisionLogger; +}; + +/** Antigravity default hosts — best-effort, never throws. */ +async function provisionDefaultDns( + sudoPassword: string, + deps: ResolvedDnsProvisionDeps +): Promise { + try { + await deps.addDefaultDns(sudoPassword); + } catch (err) { + deps.logger.error({ err }, "Failed to add default DNS entries (continuing)"); + } +} + +/** Hosts for agents with `dns_enabled=true` in the DB — best-effort, never throws. */ +async function provisionAgentDns( + sudoPassword: string, + deps: ResolvedDnsProvisionDeps +): Promise { + try { + const agentStates = deps.getAgentStates(); + const agentHostsToAdd: string[] = []; + for (const state of agentStates) { + if (!state.dns_enabled) continue; + const target = ALL_TARGETS.find((t) => t.id === state.agent_id); + if (target) { + agentHostsToAdd.push(...target.hosts); + } + } + if (agentHostsToAdd.length > 0) { + deps.logger.info({ count: agentHostsToAdd.length }, "Adding DNS for agent host(s)..."); + await deps.addHostsDns(agentHostsToAdd, sudoPassword); + } + } catch (err) { + deps.logger.error({ err }, "Failed to add agent DNS entries (continuing)"); + } +} + +/** Enabled custom hosts — best-effort, never throws. */ +async function provisionCustomHostsDns( + sudoPassword: string, + deps: ResolvedDnsProvisionDeps +): Promise { + try { + const customHosts = deps.listEnabledCustomHosts(); + const customHostNames = customHosts.map((h) => h.host); + if (customHostNames.length > 0) { + deps.logger.info({ count: customHostNames.length }, "Adding DNS for custom host(s)..."); + await deps.addHostsDns(customHostNames, sudoPassword); + } + } catch (err) { + deps.logger.error({ err }, "Failed to add custom host DNS entries (continuing)"); + } +} + /** * Provision every AgentBridge DNS entry (Antigravity defaults + agents with * `dns_enabled=true` + enabled custom hosts). **Every step is best-effort**: a failure @@ -45,48 +110,35 @@ export async function provisionDnsEntries( sudoPassword: string, deps: DnsProvisionDeps = {} ): Promise { - const addDefaultDns = deps.addDefaultDns ?? addDNSEntry; - const addHostsDns = deps.addHostsDns ?? addDNSEntries; - const getAgentStates = deps.getAgentStates ?? getAllAgentBridgeStates; - const listEnabledCustomHosts = - deps.listEnabledCustomHosts ?? (() => listCustomHosts({ enabledOnly: true })); + const canElevate = deps.canElevate ?? (() => isSudoAvailable() || isRoot()); const logger = deps.logger ?? defaultLog; - // Antigravity default hosts. - try { - await addDefaultDns(sudoPassword); - } catch (err) { - logger.error({ err }, "Failed to add default DNS entries (continuing)"); + // Explicit opt-out: skip all DNS modification when the env var is set. + if (process.env.SKIP_ANTIGRAVITY_DNS === "true") { + logger.info("Skipping DNS entries - SKIP_ANTIGRAVITY_DNS=true"); + return; } - // Collect hosts from agents that have dns_enabled=true in the DB. - try { - const agentStates = getAgentStates(); - const agentHostsToAdd: string[] = []; - for (const state of agentStates) { - if (!state.dns_enabled) continue; - const target = ALL_TARGETS.find((t) => t.id === state.agent_id); - if (target) { - agentHostsToAdd.push(...target.hosts); - } - } - if (agentHostsToAdd.length > 0) { - logger.info({ count: agentHostsToAdd.length }, "Adding DNS for agent host(s)..."); - await addHostsDns(agentHostsToAdd, sudoPassword); - } - } catch (err) { - logger.error({ err }, "Failed to add agent DNS entries (continuing)"); + // In containers (USER node, no sudo installed, not root) we cannot write + // to /etc/hosts. Rather than attempting sudo and swallowing the error, + // detect the condition up-front and bail out with a clear message. + if (!canElevate()) { + logger.info( + "Skipping DNS entries - sudo not available and not running as root (likely a container)" + ); + return; } - // Collect enabled custom hosts. - try { - const customHosts = listEnabledCustomHosts(); - const customHostNames = customHosts.map((h) => h.host); - if (customHostNames.length > 0) { - logger.info({ count: customHostNames.length }, "Adding DNS for custom host(s)..."); - await addHostsDns(customHostNames, sudoPassword); - } - } catch (err) { - logger.error({ err }, "Failed to add custom host DNS entries (continuing)"); - } + const resolvedDeps: ResolvedDnsProvisionDeps = { + addDefaultDns: deps.addDefaultDns ?? addDNSEntry, + addHostsDns: deps.addHostsDns ?? addDNSEntries, + getAgentStates: deps.getAgentStates ?? getAllAgentBridgeStates, + listEnabledCustomHosts: + deps.listEnabledCustomHosts ?? (() => listCustomHosts({ enabledOnly: true })), + logger, + }; + + await provisionDefaultDns(sudoPassword, resolvedDeps); + await provisionAgentDns(sudoPassword, resolvedDeps); + await provisionCustomHostsDns(sudoPassword, resolvedDeps); } diff --git a/tests/unit/agy-pro-fallback-chain-3786.test.ts b/tests/unit/agy-pro-fallback-chain-3786.test.ts index 1f76ddc4d1..1762d6436a 100644 --- a/tests/unit/agy-pro-fallback-chain-3786.test.ts +++ b/tests/unit/agy-pro-fallback-chain-3786.test.ts @@ -190,6 +190,136 @@ test("(#3786) happy path: first id 200 makes exactly ONE upstream call (zero ext } }); +// --------------------------------------------------------------------------- +// Behavioral: executor catches exceptions and continues the fallback chain +// --------------------------------------------------------------------------- + +test("(#3786) exception on first candidate (timeout) falls through to second candidate returning 200", async () => { + const executor = new AntigravityExecutor(); + const originalFetch = globalThis.fetch; + seedAntigravityVersionCache("2026.04.17-test"); + const modelsTried: string[] = []; + + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + const m = envelopeModel(init); + modelsTried.push(m); + if (m === "gemini-3.1-pro-high") throw new Error("upstream timeout after 30000ms"); + return makeSuccessSSE(); + }) as typeof fetch; + + try { + const result = await executor.execute({ + model: "antigravity/gemini-3.1-pro-high", + body: { request: { contents: [] } }, + stream: false, + credentials: { accessToken: "token", projectId: "project-1" }, + log: { debug() {}, warn() {}, info() {} }, + }); + const payload = (await result.response.json()) as ChatCompletionPayload; + + assert.equal(result.response.status, 200, "second candidate should succeed after first threw"); + assert.equal(payload.choices[0].message.content, "OK"); + // First candidate retried internally (URL-level retries on throw), then second succeeded. + assert.equal(modelsTried[0], "gemini-3.1-pro-high", "first call targets first candidate"); + assert.ok(modelsTried.includes("gemini-pro-agent"), "must eventually try second candidate"); + assert.ok( + modelsTried.lastIndexOf("gemini-3.1-pro-high") < modelsTried.indexOf("gemini-pro-agent"), + "first candidate exhausted before second tried" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("(#3786) all candidates throw exceptions -- error includes 'chain exhausted'", async () => { + const executor = new AntigravityExecutor(); + const originalFetch = globalThis.fetch; + seedAntigravityVersionCache("2026.04.17-test"); + const modelsTried: string[] = []; + + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + const m = envelopeModel(init); + modelsTried.push(m); + throw new Error(`timeout on ${m}`); + }) as typeof fetch; + + try { + await assert.rejects( + () => + executor.execute({ + model: "antigravity/gemini-3.1-pro-high", + body: { request: { contents: [] } }, + stream: false, + credentials: { accessToken: "token", projectId: "project-1" }, + log: { debug() {}, warn() {}, info() {} }, + }), + (err: Error) => { + assert.ok( + err.message.includes("chain exhausted"), + `error must mention chain exhausted, got: ${err.message}` + ); + assert.ok( + err.message.includes("timeout on"), + `error must include last error message, got: ${err.message}` + ); + return true; + } + ); + // All 3 candidates tried (each retries internally on throw). + assert.ok(modelsTried.includes("gemini-3.1-pro-high"), "tried first candidate"); + assert.ok(modelsTried.includes("gemini-pro-agent"), "tried second candidate"); + assert.ok(modelsTried.includes("gemini-3-pro-high"), "tried third candidate"); + // Ordering: all first-candidate attempts before second, etc. + const lastFirst = modelsTried.lastIndexOf("gemini-3.1-pro-high"); + const firstSecond = modelsTried.indexOf("gemini-pro-agent"); + const lastSecond = modelsTried.lastIndexOf("gemini-pro-agent"); + const firstThird = modelsTried.indexOf("gemini-3-pro-high"); + assert.ok(lastFirst < firstSecond, "first candidate exhausted before second tried"); + assert.ok(lastSecond < firstThird, "second candidate exhausted before third tried"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("(#3786) mixed path: 400 on first, exception on second, 200 on third", async () => { + const executor = new AntigravityExecutor(); + const originalFetch = globalThis.fetch; + seedAntigravityVersionCache("2026.04.17-test"); + const modelsTried: string[] = []; + + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + const m = envelopeModel(init); + modelsTried.push(m); + if (m === "gemini-3.1-pro-high") return make400(m); + if (m === "gemini-pro-agent") throw new Error("connection reset"); + return makeSuccessSSE(); + }) as typeof fetch; + + try { + const result = await executor.execute({ + model: "antigravity/gemini-3.1-pro-high", + body: { request: { contents: [] } }, + stream: false, + credentials: { accessToken: "token", projectId: "project-1" }, + log: { debug() {}, warn() {}, info() {} }, + }); + const payload = (await result.response.json()) as ChatCompletionPayload; + + assert.equal(result.response.status, 200, "third candidate should succeed"); + assert.equal(payload.choices[0].message.content, "OK"); + // First candidate (400, no retry) -> second candidate (throws, retried internally) -> third (200). + assert.equal(modelsTried[0], "gemini-3.1-pro-high", "first call targets first candidate"); + assert.ok(modelsTried.includes("gemini-pro-agent"), "second candidate attempted"); + assert.ok(modelsTried.includes("gemini-3-pro-high"), "third candidate reached"); + assert.ok( + modelsTried.lastIndexOf("gemini-pro-agent") < modelsTried.indexOf("gemini-3-pro-high"), + "second candidate exhausted before third tried" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("(#3786) a non-pro model that 400s does NOT trigger the fallback chain", async () => { const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; @@ -217,3 +347,76 @@ test("(#3786) a non-pro model that 400s does NOT trigger the fallback chain", as globalThis.fetch = originalFetch; } }); + +test("(#3786) mixed: first 400 + last throws returns firstResult (original 400) instead of throwing", async () => { + const executor = new AntigravityExecutor(); + const originalFetch = globalThis.fetch; + seedAntigravityVersionCache("2026.04.17-test"); + const modelsTried: string[] = []; + + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + const m = envelopeModel(init); + modelsTried.push(m); + if (m === "gemini-3.1-pro-high") return make400(m); + // Second and third candidates throw + throw new Error(`connection reset on ${m}`); + }) as typeof fetch; + + try { + const result = await executor.execute({ + model: "antigravity/gemini-3.1-pro-high", + body: { request: { contents: [] } }, + stream: false, + credentials: { accessToken: "token", projectId: "project-1" }, + log: { debug() {}, warn() {}, info() {} }, + }); + + // Returns the original 400 from firstResult, not a thrown error. + assert.equal(result.response.status, 400, "should return first candidate's 400"); + const payload = (await result.response.json()) as ErrorPayload; + assert.ok(payload.error, "must carry an error object"); + assert.equal(typeof payload.error.message, "string"); + assert.ok(!payload.error.message.includes("at /"), "no raw stack trace (hard rule #12)"); + // All candidates tried (executeOnce retries internally, so duplicates are expected). + assert.ok(modelsTried.includes("gemini-3.1-pro-high"), "first candidate tried"); + assert.ok(modelsTried.includes("gemini-pro-agent"), "second candidate tried"); + assert.ok(modelsTried.includes("gemini-3-pro-high"), "third candidate tried"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("(#3786) AbortError from standard Error (not DOMException) propagates immediately", async () => { + const executor = new AntigravityExecutor(); + const originalFetch = globalThis.fetch; + seedAntigravityVersionCache("2026.04.17-test"); + // Signal is NOT aborted -- this exercises the new Error.name === "AbortError" path. + const controller = new AbortController(); + + globalThis.fetch = (async (_url: string, _init?: RequestInit) => { + // Simulate a polyfill/test env that throws standard Error with name AbortError + const err = new Error("The operation was aborted"); + err.name = "AbortError"; + throw err; + }) as typeof fetch; + + try { + await assert.rejects( + () => + executor.execute({ + model: "antigravity/gemini-3.1-pro-high", + body: { request: { contents: [] } }, + stream: false, + credentials: { accessToken: "token", projectId: "project-1" }, + signal: controller.signal, + log: { debug() {}, warn() {}, info() {} }, + }), + (err: Error) => { + assert.equal(err.name, "AbortError", "must propagate as AbortError"); + return true; + } + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/mitm-dns-graceful-degrade-6127.test.ts b/tests/unit/mitm-dns-graceful-degrade-6127.test.ts index b82f6ab740..4f71925d6f 100644 --- a/tests/unit/mitm-dns-graceful-degrade-6127.test.ts +++ b/tests/unit/mitm-dns-graceful-degrade-6127.test.ts @@ -27,8 +27,7 @@ function makeSpyLogger() { return { logger: { error: (payload: unknown, msg: string) => errorCalls.push({ payload, msg }), - info: (payload: unknown, msg?: string) => - infoCalls.push({ payload, msg: msg ?? "" }), + info: (payload: unknown, msg?: string) => infoCalls.push({ payload, msg: msg ?? "" }), }, errorCalls, infoCalls, @@ -83,8 +82,7 @@ test("provisionDnsEntries: a failing agent/custom step does not stop the others // Custom-hosts call must still happen even after default + agent errors. if (hosts.includes("custom.example.com")) customCalled = true; }, - getAgentStates: () => - [{ dns_enabled: true, agent_id: "__nonexistent_agent__" }] as never, + getAgentStates: () => [{ dns_enabled: true, agent_id: "__nonexistent_agent__" }] as never, listEnabledCustomHosts: () => [{ host: "custom.example.com" }] as never, logger: spy.logger, }); @@ -105,3 +103,124 @@ test("provisionDnsEntries: happy path calls the default step and does not log er assert.ok(defaultCalled, "default DNS step must be invoked"); assert.equal(spy.errorCalls.length, 0, "no error logs on the happy path"); }); + +test("provisionDnsEntries: SKIP_ANTIGRAVITY_DNS=true skips ALL DNS steps (default + agents + custom)", async () => { + const prev = process.env.SKIP_ANTIGRAVITY_DNS; + process.env.SKIP_ANTIGRAVITY_DNS = "true"; + try { + const spy = makeSpyLogger(); + let defaultCalled = false; + let hostsCalled = false; + await provisionDnsEntries("pw", { + addDefaultDns: async () => { + defaultCalled = true; + }, + addHostsDns: async () => { + hostsCalled = true; + }, + getAgentStates: () => [{ dns_enabled: true, agent_id: "antigravity" }] as never, + listEnabledCustomHosts: () => [{ host: "custom.example.com" }] as never, + logger: spy.logger, + }); + assert.ok(!defaultCalled, "default DNS step must NOT be called when SKIP_ANTIGRAVITY_DNS=true"); + assert.ok(!hostsCalled, "addHostsDns must NOT be called when SKIP_ANTIGRAVITY_DNS=true"); + assert.equal(spy.errorCalls.length, 0, "no errors expected"); + assert.ok( + spy.infoCalls.some( + (c) => typeof c.payload === "string" && c.payload.includes("SKIP_ANTIGRAVITY_DNS") + ), + "info log must mention SKIP_ANTIGRAVITY_DNS" + ); + } finally { + // Restore the original env var value. + if (prev === undefined) { + delete process.env.SKIP_ANTIGRAVITY_DNS; + } else { + process.env.SKIP_ANTIGRAVITY_DNS = prev; + } + } +}); + +test("provisionDnsEntries: SKIP_ANTIGRAVITY_DNS=false does NOT skip DNS steps", async () => { + const prev = process.env.SKIP_ANTIGRAVITY_DNS; + process.env.SKIP_ANTIGRAVITY_DNS = "false"; + try { + const spy = makeSpyLogger(); + let defaultCalled = false; + await provisionDnsEntries("pw", { + addDefaultDns: async () => { + defaultCalled = true; + }, + getAgentStates: () => [], + listEnabledCustomHosts: () => [], + logger: spy.logger, + }); + assert.ok(defaultCalled, "default DNS step must be called when SKIP_ANTIGRAVITY_DNS=false"); + assert.equal(spy.errorCalls.length, 0, "no errors expected"); + } finally { + if (prev === undefined) { + delete process.env.SKIP_ANTIGRAVITY_DNS; + } else { + process.env.SKIP_ANTIGRAVITY_DNS = prev; + } + } +}); + +test("provisionDnsEntries: canElevate()=false skips ALL DNS steps (default + agents + custom)", async () => { + const spy = makeSpyLogger(); + let defaultCalled = false; + let hostsCalled = false; + await provisionDnsEntries("pw", { + addDefaultDns: async () => { + defaultCalled = true; + }, + addHostsDns: async () => { + hostsCalled = true; + }, + canElevate: () => false, + getAgentStates: () => [{ dns_enabled: true, agent_id: "antigravity" }] as never, + listEnabledCustomHosts: () => [{ host: "custom.example.com" }] as never, + logger: spy.logger, + }); + assert.ok(!defaultCalled, "default DNS step must NOT be called when canElevate() is false"); + assert.ok(!hostsCalled, "addHostsDns must NOT be called when canElevate() is false"); + assert.equal(spy.errorCalls.length, 0, "no errors expected"); + assert.ok( + spy.infoCalls.some( + (c) => typeof c.payload === "string" && c.payload.includes("sudo not available") + ), + "info log must mention sudo not available" + ); +}); + +test("provisionDnsEntries: canElevate() returning true proceeds with DNS provisioning", async () => { + const spy = makeSpyLogger(); + let defaultCalled = false; + let agentCalled = false; + let customCalled = false; + const capturedPasswords: string[] = []; + await provisionDnsEntries("pw", { + addDefaultDns: async () => { + defaultCalled = true; + }, + addHostsDns: async (hosts: string[], sudoPassword: string) => { + capturedPasswords.push(sudoPassword); + if (hosts.some((h) => h.includes("googleapis.com"))) agentCalled = true; + if (hosts.includes("custom.example.com")) customCalled = true; + }, + canElevate: () => true, + getAgentStates: () => [{ dns_enabled: true, agent_id: "antigravity" }] as never, + listEnabledCustomHosts: () => [{ host: "custom.example.com" }] as never, + logger: spy.logger, + }); + assert.ok(defaultCalled, "default DNS step must be called when canElevate() is true"); + assert.ok(agentCalled, "agent DNS step must be called when canElevate() is true"); + assert.ok(customCalled, "custom DNS step must be called when canElevate() is true"); + assert.equal(spy.errorCalls.length, 0, "no errors expected on happy path"); + // Verify sudoPassword is passed through to addHostsDns. + assert.equal(capturedPasswords.length, 2, "addHostsDns called twice (agents + custom)"); + assert.ok( + capturedPasswords.every((p) => p === "pw"), + "sudoPassword must be forwarded to addHostsDns" + ); +});