diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index da69607..42a70c7 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -788,7 +788,7 @@ Batch `--report` flags apply only to `test run --all --wait` (and batch `test re `--target-url` and `--local` can supply an ad-hoc origin while `--env` supplies its sign-in configuration and run attribution. When no saved origin matches and no `--env` is selected, a supporting backend clones the project default's sign-in configuration into a temporary environment. Explicit `--local` can use a public environment's sign-in configuration on a new backend; an older backend's refusal is rendered verbatim and the tunnel is closed. Unknown environment names remain validation errors with the available names. `--env` is also accepted by `--all` and `test rerun`. -**Reachability preflight (refuse before charge).** For a public `--target-url`, the CLI probes **before dispatching** (and before anything is billed): a DNS resolve plus a lightweight HTTP request. Loopback tunnel targets use a TCP probe of the stored host and port instead. A confirmed-dead target — DNS `NXDOMAIN`, connection refused, or a `502`/`503`/`504` gateway error (the signature of a tunnel that has gone away) — is refused with a validation error (exit 5) instead of dispatching a run that can only fail against a URL nobody is serving. A resolved address that lands in private/loopback/link-local space is always refused (the hostname passed the literal check but actually points somewhere unreachable from the runner). Ambiguous signals — a timeout, a TLS error, an odd status — only produce a stderr warning and never block; behind a configured HTTP(S) proxy, a local DNS failure is also downgraded to a warning, since resolution really happens at the proxy. `--skip-preflight` (on `test run`, `test create`, and `test create-batch`) skips these probes; metadata reads needed to resolve an environment still occur. Note: for a backend test the probe is a heuristic (the test's own base URL is baked into its code) — reach for `--skip-preflight` if a refusal surprises you there. +**Reachability preflight (refuse before charge).** For a public `--target-url`, the CLI probes **before dispatching** (and before anything is billed): a DNS resolve plus a lightweight HTTP request, sharing an 8-second budget. A stalled DNS lookup produces an advisory when that budget expires; interruption stops waiting immediately without dispatching a run. Loopback tunnel targets use a TCP probe of the stored host and port instead. A confirmed-dead target — DNS `NXDOMAIN`, connection refused, or a `502`/`503`/`504` gateway error (the signature of a tunnel that has gone away) — is refused with a validation error (exit 5) instead of dispatching a run that can only fail against a URL nobody is serving. A resolved address that lands in private/loopback/link-local space is always refused (the hostname passed the literal check but actually points somewhere unreachable from the runner). Ambiguous signals — a timeout, a TLS error, an odd status — only produce a stderr warning and never block; behind a configured HTTP(S) proxy, a local DNS failure is also downgraded to a warning, since resolution really happens at the proxy. `--skip-preflight` (on `test run`, `test create`, and `test create-batch`) skips these probes; metadata reads needed to resolve an environment still occur. Note: for a backend test the probe is a heuristic (the test's own base URL is baked into its code) — reach for `--skip-preflight` if a refusal surprises you there. The `[advisory]` about `--target-url` on V3-routed accounts is now **response-driven**: the CLI reads the run's actual trigger response rather than guessing from account flags, so it fires only when the override genuinely did not take effect (newer backends apply `--target-url` to fresh frontend runs on V3; older ones ignore it and the advisory says so). The CLI auto-mints an idempotency key (printed to stderr under `--output json`, `--verbose`, or `--debug`); pass `--idempotency-key ` to control it explicitly. diff --git a/src/commands/test.run.spec.ts b/src/commands/test.run.spec.ts index fef12ac..9a3ee9c 100644 --- a/src/commands/test.run.spec.ts +++ b/src/commands/test.run.spec.ts @@ -5,6 +5,7 @@ * sleep injection is wired through `TestDeps.sleep` to avoid real delays. */ +import * as dns from 'node:dns'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -7103,6 +7104,82 @@ describe('runTestRun — --target-url reachability preflight wiring', () => { // DNS lookups, no network flakiness. const LITERAL_IP_TARGET = 'http://203.0.113.10'; + it('interrupting stalled DNS rejects before any probe HTTP request or trigger POST', async () => { + const { credentialsPath } = makeCreds(); + const shutdown = new ShutdownController(); + const fetchImpl = vi.fn(); + const stderr = vi.fn(); + const rejected = vi.fn(); + vi.useFakeTimers(); + const lookup = vi.spyOn(dns.promises, 'lookup').mockImplementation(() => new Promise(() => {})); + try { + const pending = runTestRun( + { + profile: 'default', + output: 'json', + debug: false, + dryRun: false, + testId: 'test_xyz', + wait: false, + timeoutSeconds: 60, + targetUrl: 'https://preview.example.test', + }, + { credentialsPath, fetchImpl, shutdown, stdout: () => {}, stderr }, + ).catch(rejected); + await vi.advanceTimersByTimeAsync(0); + expect(lookup).toHaveBeenCalledWith('preview.example.test', { all: true }); + shutdown.interrupt('SIGINT'); + await vi.advanceTimersByTimeAsync(0); + expect(rejected).toHaveBeenCalledWith(shutdown.signal.reason); + await pending; + expect(fetchImpl).not.toHaveBeenCalled(); + expect(stderr.mock.calls.some(([line]) => line.includes('[advisory]'))).toBe(false); + expect(shutdown.hasCriticalOperations).toBe(false); + expect(vi.getTimerCount()).toBe(0); + } finally { + lookup.mockRestore(); + vi.useRealTimers(); + } + }); + + it('a stalled DNS timeout advises and still dispatches the run', async () => { + const { credentialsPath } = makeCreds(); + const targetUrl = 'https://preview.example.test'; + const fetchImpl = vi.fn(makeFetch(() => ({ body: { ...TRIGGER_RESP, targetUrl } }))); + const stderr = vi.fn(); + const settled = vi.fn(); + vi.useFakeTimers(); + const lookup = vi.spyOn(dns.promises, 'lookup').mockImplementation(() => new Promise(() => {})); + try { + const pending = runTestRun( + { + profile: 'default', + output: 'json', + debug: false, + testId: 'test_xyz', + wait: false, + timeoutSeconds: 60, + targetUrl, + }, + { credentialsPath, fetchImpl, stdout: () => {}, stderr }, + ).then(settled); + await vi.advanceTimersByTimeAsync(7_999); + expect(fetchImpl).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(settled).toHaveBeenCalledWith(expect.objectContaining({ runId: 'run_abc' })); + await pending; + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(fetchImpl).toHaveBeenCalledWith( + expect.stringContaining('/tests/test_xyz/runs'), + expect.objectContaining({ method: 'POST' }), + ); + expect(stderr).toHaveBeenCalledWith(expect.stringMatching(/\[advisory\].*DNS.*8s/)); + } finally { + lookup.mockRestore(); + vi.useRealTimers(); + } + }); + it('a gateway-error response (502) refuses before the trigger POST — exit 5, no run row', async () => { const { credentialsPath } = makeCreds(); const postedUrls: string[] = []; diff --git a/src/lib/target-url-preflight-lifetime.test.ts b/src/lib/target-url-preflight-lifetime.test.ts new file mode 100644 index 0000000..11d9c77 --- /dev/null +++ b/src/lib/target-url-preflight-lifetime.test.ts @@ -0,0 +1,237 @@ +import { getEventListeners } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { InterruptError } from './errors.js'; +import { ShutdownController } from './interrupt.js'; +import { probeTargetUrl } from './target-url-preflight.js'; + +/** Lets tests settle DNS after a chosen deadline or interruption. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('target URL preflight lifetime', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it.each([false, true])( + 'bounds a stalled DNS lookup to 8s (proxy active: %s)', + async proxyActive => { + const shutdown = new ShutdownController(); + const dnsLookup = vi.fn(() => new Promise(() => {})); + const fetchImpl = vi.fn(); + const settled = vi.fn(); + const pending = probeTargetUrl('https://example.test', { + dnsLookup, + fetchImpl, + proxyActive, + shutdown, + shutdownSignal: shutdown.signal, + }).then(settled); + + await vi.advanceTimersByTimeAsync(7_999); + expect(dnsLookup).toHaveBeenCalledOnce(); + expect(settled).not.toHaveBeenCalled(); + const trackedDuringDns = shutdown.hasCriticalOperations; + + await vi.advanceTimersByTimeAsync(1); + expect(settled).toHaveBeenCalledWith({ + verdict: 'warn', + reason: expect.stringMatching(/DNS.*8s/), + }); + await pending; + expect(trackedDuringDns).toBe(true); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(shutdown.hasCriticalOperations).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it.each(['SIGINT', 'SIGTERM'] as const)( + 'propagates %s immediately while DNS is pending', + async signal => { + const shutdown = new ShutdownController(); + const fetchImpl = vi.fn(); + const rejected = vi.fn(); + const pending = probeTargetUrl('https://example.test', { + dnsLookup: () => new Promise(() => {}), + fetchImpl, + proxyActive: false, + shutdown, + shutdownSignal: shutdown.signal, + }).catch(rejected); + + await vi.advanceTimersByTimeAsync(0); + const trackedDuringDns = shutdown.hasCriticalOperations; + shutdown.interrupt(signal); + await vi.advanceTimersByTimeAsync(0); + expect(rejected).toHaveBeenCalledWith(shutdown.signal.reason); + await pending; + expect(trackedDuringDns).toBe(true); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(shutdown.hasCriticalOperations).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it.each(['https://example.test', 'https://93.184.216.34', 'https://[2606:4700::1111]'])( + 'does no DNS or HTTP work for an already-aborted signal (%s)', + async url => { + const controller = new AbortController(); + const reason = new InterruptError('SIGINT'); + controller.abort(reason); + const dnsLookup = vi.fn().mockResolvedValue({ address: '93.184.216.34' }); + const fetchImpl = vi.fn().mockResolvedValue(new Response(null)); + await expect( + probeTargetUrl(url, { dnsLookup, fetchImpl, shutdownSignal: controller.signal }), + ).rejects.toBe(reason); + expect(dnsLookup).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it('propagates an InterruptError from the DNS hook without an advisory', async () => { + const reason = new InterruptError('SIGINT'); + const fetchImpl = vi.fn(); + await expect( + probeTargetUrl('https://example.test', { + dnsLookup: () => Promise.reject(reason), + fetchImpl, + }), + ).rejects.toBe(reason); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('checks interruption again when DNS resolves in the same turn', async () => { + const lookup = deferred(); + const shutdown = new ShutdownController(); + const fetchImpl = vi.fn(); + const rejected = vi.fn(); + const pending = probeTargetUrl('https://example.test', { + dnsLookup: () => lookup.promise, + fetchImpl, + shutdownSignal: shutdown.signal, + }).catch(rejected); + await vi.advanceTimersByTimeAsync(0); + lookup.resolve({ address: '93.184.216.34' }); + shutdown.interrupt('SIGINT'); + await vi.advanceTimersByTimeAsync(0); + expect(rejected).toHaveBeenCalledWith(shutdown.signal.reason); + await pending; + expect(fetchImpl).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([ + ['timeout', 'resolve'], + ['timeout', 'reject'], + ['interrupt', 'resolve'], + ['interrupt', 'reject'], + ] as const)('ignores a late DNS %s/%s without starting HTTP', async (stop, completion) => { + const lookup = deferred(); + const shutdown = new ShutdownController(); + const fetchImpl = vi.fn(); + const resolved = vi.fn(); + const rejected = vi.fn(); + const pending = probeTargetUrl('https://example.test', { + dnsLookup: () => lookup.promise, + fetchImpl, + shutdown, + shutdownSignal: shutdown.signal, + }).then(resolved, rejected); + + await vi.advanceTimersByTimeAsync(0); + if (stop === 'timeout') await vi.advanceTimersByTimeAsync(8_000); + else shutdown.interrupt('SIGINT'); + await vi.advanceTimersByTimeAsync(0); + expect(resolved.mock.calls.length + rejected.mock.calls.length).toBe(1); + await pending; + + if (completion === 'resolve') lookup.resolve({ address: '93.184.216.34' }); + else lookup.reject(Object.assign(new Error('late DNS failure'), { code: 'ENOTFOUND' })); + // A late rejection is also observed by Vitest's unhandled-rejection guard. + await vi.advanceTimersByTimeAsync(8_000); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(resolved.mock.calls.length + rejected.mock.calls.length).toBe(1); + expect(shutdown.hasCriticalOperations).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it('shares one 8s budget between DNS and HTTP', async () => { + const lookup = deferred(); + let requestSignal: AbortSignal | undefined; + const fetchImpl = vi.fn((_input: unknown, init?: RequestInit) => { + requestSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + requestSignal?.addEventListener('abort', () => reject(requestSignal?.reason), { + once: true, + }); + }); + }); + const settled = vi.fn(); + const pending = probeTargetUrl('https://example.test', { + dnsLookup: () => lookup.promise, + fetchImpl, + }).then(settled); + + await vi.advanceTimersByTimeAsync(6_000); + lookup.resolve({ address: '93.184.216.34' }); + await vi.advanceTimersByTimeAsync(0); + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(requestSignal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1_999); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(requestSignal?.aborted).toBe(true); + expect(settled).toHaveBeenCalledWith({ + verdict: 'warn', + reason: expect.stringContaining('8s'), + }); + await pending; + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(['success', 'DNS refusal', 'DNS advisory', 'HTTP refusal'])( + 'cleans up its timer and DNS abort listener after %s', + async outcome => { + const shutdown = new ShutdownController(); + const addListener = vi.spyOn(AbortSignal.prototype, 'addEventListener'); + const dnsLookup = vi.fn(async () => { + if (outcome.startsWith('DNS')) { + throw Object.assign(new Error('DNS failure'), { + code: outcome === 'DNS refusal' ? 'ENOTFOUND' : 'EAI_AGAIN', + }); + } + return { address: '93.184.216.34' }; + }); + const result = await probeTargetUrl('https://example.test', { + dnsLookup, + fetchImpl: vi + .fn() + .mockResolvedValue( + new Response(null, { status: outcome === 'HTTP refusal' ? 503 : 200 }), + ), + shutdown, + shutdownSignal: shutdown.signal, + }); + expect(result.verdict).toBe( + outcome === 'success' ? 'ok' : outcome === 'DNS advisory' ? 'warn' : 'refuse', + ); + expect(vi.getTimerCount()).toBe(0); + expect(shutdown.hasCriticalOperations).toBe(false); + for (const signal of addListener.mock.instances) { + expect(getEventListeners(signal as AbortSignal, 'abort')).toHaveLength(0); + } + }, + ); +}); diff --git a/src/lib/target-url-preflight.ts b/src/lib/target-url-preflight.ts index 03d80d6..f0fc35c 100644 --- a/src/lib/target-url-preflight.ts +++ b/src/lib/target-url-preflight.ts @@ -50,7 +50,7 @@ import { globalShutdown, type ShutdownHandle } from './interrupt.js'; import { isProxyAgentActive } from './proxy.js'; import { disallowedIpReason } from './target-url.js'; -/** ~8s, matching the MCP plugin's `checkPortListening` TCP-probe budget. */ +/** Shared DNS + HTTP budget, matching the MCP plugin's ~8s probe budget. */ const PREFLIGHT_TIMEOUT_MS = 8_000; const GATEWAY_ERROR_STATUSES = new Set([502, 503, 504]); @@ -122,6 +122,27 @@ function bareHost(hostname: string): string { return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; } +/** Stop waiting even though the system resolver itself cannot be cancelled. */ +async function lookupWithSignal( + hostname: string, + lookup: (hostname: string) => Promise, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + let onAbort!: () => void; + try { + return await new Promise((resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + // Both handlers remain attached after an abort: a late DNS result cannot + // resume the probe, and a late rejection must not become unhandled. + lookup(hostname).then(resolve, reject); + }); + } finally { + signal.removeEventListener('abort', onAbort); + } +} + /** * Probe `rawUrl` for the narrow refusal set described in the module * docstring. Network failures classify to `warn` or `refuse`, while a process @@ -141,130 +162,151 @@ export async function probeTargetUrl( return { verdict: 'warn', reason: 'could not parse --target-url for the reachability probe' }; } - const host = bareHost(parsed.hostname); - - // Step 1 — DNS. Skipped for a literal IP host (nothing to resolve, and - // dns.lookup on a literal is a needless round trip through the resolver). - if (isIP(host) === 0) { - const dnsLookup = - deps.dnsLookup ?? ((hostname: string) => dns.promises.lookup(hostname, { all: true })); - let lookupResult: unknown; + const shutdownSignal = deps.shutdownSignal ?? globalShutdown.signal; + const shutdown = deps.shutdown ?? globalShutdown; + return shutdown.runCriticalOperation(async () => { + shutdownSignal.throwIfAborted(); + const deadline = new AbortController(); + const timer = setTimeout(() => { + deadline.abort(new DOMException('Target URL preflight timed out', 'TimeoutError')); + }, PREFLIGHT_TIMEOUT_MS); + timer.unref?.(); + const signal = AbortSignal.any([deadline.signal, shutdownSignal]); try { - lookupResult = await dnsLookup(host); - } catch (err) { - const code = (err as { code?: unknown } | null)?.code; - if (code === 'ENOTFOUND' || code === 'EAI_NONAME') { - if (proxied) { + const host = bareHost(parsed.hostname); + + // Step 1 — DNS. Skipped for a literal IP host (nothing to resolve, and + // dns.lookup on a literal is a needless round trip through the resolver). + if (isIP(host) === 0) { + const dnsLookup = + deps.dnsLookup ?? ((hostname: string) => dns.promises.lookup(hostname, { all: true })); + let lookupResult: unknown; + try { + lookupResult = await lookupWithSignal(host, dnsLookup, signal); + signal.throwIfAborted(); + } catch (err) { + if (shutdownSignal.aborted) throw shutdownSignal.reason; + if (err instanceof InterruptError) throw err; + if (signal.aborted) { + return { + verdict: 'warn', + reason: `DNS lookup for "${host}" did not finish within ${PREFLIGHT_TIMEOUT_MS / 1000}s`, + }; + } + const code = (err as { code?: unknown } | null)?.code; + if (code === 'ENOTFOUND' || code === 'EAI_NONAME') { + if (proxied) { + return { + verdict: 'warn', + reason: `DNS lookup for "${host}" failed locally (${code}) — not conclusive behind a configured proxy`, + }; + } + return { + verdict: 'refuse', + reason: `--target-url host "${host}" does not resolve (${code})`, + }; + } + // Anything else (e.g. EAI_AGAIN — a transient resolver hiccup) is + // ambiguous, not a confirmed dead target — warn, never refuse. return { verdict: 'warn', - reason: `DNS lookup for "${host}" failed locally (${code}) — not conclusive behind a configured proxy`, + reason: `DNS lookup for "${host}" failed (${typeof code === 'string' ? code : 'unknown error'})`, }; } - return { - verdict: 'refuse', - reason: `--target-url host "${host}" does not resolve (${code})`, - }; - } - // Anything else (e.g. EAI_AGAIN — a transient resolver hiccup) is - // ambiguous, not a confirmed dead target — warn, never refuse. - return { - verdict: 'warn', - reason: `DNS lookup for "${host}" failed (${typeof code === 'string' ? code : 'unknown error'})`, - }; - } - // DNS rebinding: a public-looking hostname (e.g. an attacker-controlled - // domain, or a tunnel host with attacker-influenced DNS) whose A/AAAA - // record actually points at a private, loopback, link-local, or - // metadata-service address. `assertNotLocal` never catches this — it - // only ever inspected the literal `--target-url` *string*, and this - // hostname passed that check honestly (it doesn't LOOK private). Unlike - // the DNS-failure branch above, there is no proxy carve-out: resolving - // locally to a private address is a security signal, not an ambiguous - // "can't tell from here" result, and a false positive has a documented - // escape hatch (`--skip-preflight`) rather than a broken workflow. - for (const address of resolvedAddresses(lookupResult)) { - const blocked = disallowedIpReason(address); - if (blocked !== undefined) { - return { - verdict: 'refuse', - reason: `--target-url host "${host}" resolves to ${address} (${blocked.reason})`, - }; + // DNS rebinding: a public-looking hostname (e.g. an attacker-controlled + // domain, or a tunnel host with attacker-influenced DNS) whose A/AAAA + // record actually points at a private, loopback, link-local, or + // metadata-service address. `assertNotLocal` never catches this — it + // only ever inspected the literal `--target-url` *string*, and this + // hostname passed that check honestly (it doesn't LOOK private). Unlike + // the DNS-failure branch above, there is no proxy carve-out: resolving + // locally to a private address is a security signal, not an ambiguous + // "can't tell from here" result, and a false positive has a documented + // escape hatch (`--skip-preflight`) rather than a broken workflow. + for (const address of resolvedAddresses(lookupResult)) { + const blocked = disallowedIpReason(address); + if (blocked !== undefined) { + return { + verdict: 'refuse', + reason: `--target-url host "${host}" resolves to ${address} (${blocked.reason})`, + }; + } + } } - } - } - // Step 2 — HTTP probe. Runs through the global `fetch`, which picks up - // the proxy dispatcher `proxy.ts` installs at startup when one is - // configured — so this step (unlike the DNS step above) is proxy-aware - // by construction, not just proxy-tolerant. - const fetchImpl = deps.fetchImpl ?? fetch; - const shutdownSignal = deps.shutdownSignal ?? globalShutdown.signal; - const shutdown = deps.shutdown ?? globalShutdown; - return shutdown.runCriticalOperation(async () => { - try { - const resp = await fetchImpl(rawUrl, { - method: 'GET', - // Never follow — see the module docstring. A 3xx is reachability - // proof on its own (handled immediately below); the redirect target - // is unvalidated and must not be dereferenced. - redirect: 'manual', - signal: AbortSignal.any([AbortSignal.timeout(PREFLIGHT_TIMEOUT_MS), shutdownSignal]), - }); - // Never read the body — only the status matters here. Cancel it so the - // connection is released promptly instead of buffering a full page. - await resp.body?.cancel().catch(() => undefined); - if (shutdownSignal.aborted) throw shutdownSignal.reason; - if (resp.status >= 300 && resp.status < 400) { - // A redirect is a live-server signal (e.g. a real app 302ing an - // unauthenticated GET to a login page) — reachable, not refused. The - // Location header is deliberately never inspected or dereferenced. + // Step 2 — HTTP probe. Runs through the global `fetch`, which picks up + // the proxy dispatcher `proxy.ts` installs at startup when one is + // configured — so this step (unlike the DNS step above) is proxy-aware + // by construction, not just proxy-tolerant. + const fetchImpl = deps.fetchImpl ?? fetch; + try { + signal.throwIfAborted(); + const resp = await fetchImpl(rawUrl, { + method: 'GET', + // Never follow — see the module docstring. A 3xx is reachability + // proof on its own (handled immediately below); the redirect target + // is unvalidated and must not be dereferenced. + redirect: 'manual', + signal, + }); + // Never read the body — only the status matters here. Cancel it so the + // connection is released promptly instead of buffering a full page. + await resp.body?.cancel().catch(() => undefined); + if (shutdownSignal.aborted) throw shutdownSignal.reason; + if (resp.status >= 300 && resp.status < 400) { + // A redirect is a live-server signal (e.g. a real app 302ing an + // unauthenticated GET to a login page) — reachable, not refused. The + // Location header is deliberately never inspected or dereferenced. + return { verdict: 'ok' }; + } + if (GATEWAY_ERROR_STATUSES.has(resp.status)) { + if (proxied) { + return { + verdict: 'warn', + reason: `--target-url responded HTTP ${resp.status} — not conclusive behind a configured proxy`, + }; + } + return { + verdict: 'refuse', + reason: `--target-url responded HTTP ${resp.status} (gateway error — the target is unreachable)`, + }; + } + if (resp.status >= 400) { + return { verdict: 'warn', reason: `--target-url responded HTTP ${resp.status}` }; + } return { verdict: 'ok' }; - } - if (GATEWAY_ERROR_STATUSES.has(resp.status)) { - if (proxied) { + } catch (err) { + if (shutdownSignal.aborted) throw shutdownSignal.reason; + if (err instanceof InterruptError) throw err; + if (errorHasCode(err, ['ECONNREFUSED'])) { + if (proxied) { + return { + verdict: 'warn', + reason: + 'connection to --target-url was refused — not conclusive behind a configured proxy', + }; + } return { - verdict: 'warn', - reason: `--target-url responded HTTP ${resp.status} — not conclusive behind a configured proxy`, + verdict: 'refuse', + reason: 'connection to --target-url was refused (ECONNREFUSED)', }; } - return { - verdict: 'refuse', - reason: `--target-url responded HTTP ${resp.status} (gateway error — the target is unreachable)`, - }; - } - if (resp.status >= 400) { - return { verdict: 'warn', reason: `--target-url responded HTTP ${resp.status}` }; - } - return { verdict: 'ok' }; - } catch (err) { - if (shutdownSignal.aborted) throw shutdownSignal.reason; - if (err instanceof InterruptError) throw err; - if (errorHasCode(err, ['ECONNREFUSED'])) { - if (proxied) { + if (isTimeoutError(err)) { return { verdict: 'warn', - reason: - 'connection to --target-url was refused — not conclusive behind a configured proxy', + reason: `--target-url did not respond within ${PREFLIGHT_TIMEOUT_MS / 1000}s`, }; } - return { - verdict: 'refuse', - reason: 'connection to --target-url was refused (ECONNREFUSED)', - }; - } - if (isTimeoutError(err)) { + // TLS errors and anything else unclassified: warn only — the refusal + // set stays narrow to exactly what that analysis measured. return { verdict: 'warn', - reason: `--target-url did not respond within ${PREFLIGHT_TIMEOUT_MS / 1000}s`, + reason: `could not reach --target-url (${err instanceof Error ? err.message : String(err)})`, }; } - // TLS errors and anything else unclassified: warn only — the refusal - // set stays narrow to exactly what that analysis measured. - return { - verdict: 'warn', - reason: `could not reach --target-url (${err instanceof Error ? err.message : String(err)})`, - }; + } finally { + clearTimeout(timer); } }); }