From 60edbd37abd2830019c0195c8f603bf8ed9c65a7 Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Mon, 13 Jul 2026 09:56:22 -0700 Subject: [PATCH] fix(onboard): bypass proxies for loopback readiness Signed-off-by: Ho Lim --- src/lib/core/wait.test.ts | 52 ++++++++++++++++++++++++++++++++++++++- src/lib/core/wait.ts | 35 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/lib/core/wait.test.ts b/src/lib/core/wait.test.ts index 6e7e37e7132..eef523349b2 100644 --- a/src/lib/core/wait.test.ts +++ b/src/lib/core/wait.test.ts @@ -242,13 +242,63 @@ describe("waitForPort", () => { }); describe("waitForHttp", () => { - it("returns true when curl reaches the endpoint", () => { + it("reaches a loopback server directly when an HTTP proxy is configured", async () => { + const server = childProcess.spawn( + process.execPath, + [ + "-e", + "const h=require('node:http');" + + "const s=h.createServer((_,r)=>{r.writeHead(204);r.end();});" + + "s.listen(0,'127.0.0.1',()=>console.log(s.address().port));", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + const port = await new Promise((resolve, reject) => { + server.once("error", reject); + server.stdout.once("data", (chunk) => resolve(Number(String(chunk).trim()))); + server.once("exit", (code) => reject(new Error(`loopback test server exited ${code}`))); + }); + const previousProxy = process.env.http_proxy; + process.env.http_proxy = "http://127.0.0.1:1"; + + try { + expect(waitForHttp(`http://127.0.0.1:${port}/health`, 2)).toBe(true); + } finally { + Reflect.deleteProperty(process.env, "http_proxy"); + Object.assign(process.env, previousProxy === undefined ? {} : { http_proxy: previousProxy }); + await new Promise((resolve) => { + server.once("exit", () => resolve()); + server.kill("SIGTERM"); + }); + } + }); + + it("uses a direct Node probe for loopback endpoints", () => { const buildValidatedCurlCommandArgs = curlArgs.buildValidatedCurlCommandArgs; const buildArgs = vi .spyOn(curlArgs, "buildValidatedCurlCommandArgs") .mockImplementation(buildValidatedCurlCommandArgs); const spawnSync = vi.spyOn(childProcess, "spawnSync").mockReturnValue(spawnResult(0)); const url = "http://127.0.0.1:8080/health"; + + expect(waitForHttp(url, 1)).toBe(true); + expect(buildArgs).not.toHaveBeenCalled(); + expect(spawnSync.mock.calls[0]?.[0]).toBe(process.execPath); + const probeArgs = spawnSync.mock.calls[0]?.[1]; + expect(probeArgs?.[0]).toBe("-e"); + expect(probeArgs?.[1]).toContain("require('node:http')"); + expect(probeArgs?.[1]).not.toContain(url); + expect(probeArgs?.at(-1)).toBe(url); + expect(spawnSync.mock.calls[0]?.[2]).toMatchObject({ timeout: 2_000 }); + }); + + it("keeps the validated curl path for non-loopback endpoints", () => { + const buildValidatedCurlCommandArgs = curlArgs.buildValidatedCurlCommandArgs; + const buildArgs = vi + .spyOn(curlArgs, "buildValidatedCurlCommandArgs") + .mockImplementation(buildValidatedCurlCommandArgs); + const spawnSync = vi.spyOn(childProcess, "spawnSync").mockReturnValue(spawnResult(0)); + const url = "https://example.com/health"; const rawArgs = ["-sf", "--connect-timeout", "1", "--max-time", "1", url]; expect(waitForHttp(url, 1)).toBe(true); diff --git a/src/lib/core/wait.ts b/src/lib/core/wait.ts index bc78a902867..98aca2d41de 100644 --- a/src/lib/core/wait.ts +++ b/src/lib/core/wait.ts @@ -279,6 +279,33 @@ const TCP_PROBE_SCRIPT = "s.on('error',()=>process.exit(1));" + "s.setTimeout(1000,()=>{s.destroy();process.exit(1);});"; +// Native Node HTTP clients do not consult HTTP_PROXY. Prefer this direct path +// for loopback readiness probes so user proxy settings and curl configuration +// cannot route a localhost request away from the service being checked. +const LOOPBACK_HTTP_PROBE_SCRIPT = + "const u=new URL(process.argv[1]);" + + "const m=u.protocol==='https:'?require('node:https'):require('node:http');" + + "const r=m.get(u,{timeout:1000},res=>{" + + "res.resume();process.exit(res.statusCode>=200&&res.statusCode<400?0:1);});" + + "r.on('timeout',()=>{r.destroy();process.exit(1);});" + + "r.on('error',()=>process.exit(1));"; + +function isLoopbackHttpUrl(rawUrl: string): boolean { + try { + const url = new URL(rawUrl); + const hostname = url.hostname + .replace(/^\[(.*)\]$/u, "$1") + .replace(/\.$/u, "") + .toLowerCase(); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") + ); + } catch { + return false; + } +} + /** * Synchronously wait for a TCP port to become reachable on localhost. * @@ -323,6 +350,14 @@ export function waitForHttp(url: string, timeoutSeconds = 5): boolean { return waitUntil( () => { try { + if (isLoopbackHttpUrl(url)) { + const probe = spawnSync(process.execPath, ["-e", LOOPBACK_HTTP_PROBE_SCRIPT, url], { + stdio: "ignore", + timeout: 2000, + env, + }); + return probe.status === 0; + } const result = spawnSync( "curl", buildValidatedCurlCommandArgs(["-sf", "--connect-timeout", "1", "--max-time", "1", url]),