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 <houminxi@gmail.com>

* 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 <houminxi@gmail.com>

* 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 <houminxi@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: HouMinXi <19586012+HouMinXi@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Bob.Hou
2026-07-18 14:14:14 -04:00
committed by GitHub
parent 43eb470790
commit 65fbba4893
5 changed files with 557 additions and 59 deletions

View File

@@ -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<ReturnType<AntigravityExecutor["executeOnce"]>> | null = null;
for (let i = 0; i < chain.length; i++) {
const candidate = chain[i];
const result = await this.executeOnce(input, candidate);
let result: Awaited<ReturnType<AntigravityExecutor["executeOnce"]>>;
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.

View File

@@ -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<string, string>;
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 };
}

View File

@@ -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<void>;
getAgentStates?: () => ReturnType<typeof getAllAgentBridgeStates>;
listEnabledCustomHosts?: () => ReturnType<typeof listCustomHosts>;
/** 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<void>;
addHostsDns: (hosts: string[], sudoPassword: string) => Promise<void>;
getAgentStates: () => ReturnType<typeof getAllAgentBridgeStates>;
listEnabledCustomHosts: () => ReturnType<typeof listCustomHosts>;
logger: DnsProvisionLogger;
};
/** Antigravity default hosts — best-effort, never throws. */
async function provisionDefaultDns(
sudoPassword: string,
deps: ResolvedDnsProvisionDeps
): Promise<void> {
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<void> {
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<void> {
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<void> {
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);
}

View File

@@ -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;
}
});

View File

@@ -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"
);
});