From 56f30d22f99eb639171c4f80584f0d32590f3678 Mon Sep 17 00:00:00 2001 From: Xing Wang Date: Tue, 5 May 2026 13:31:37 +0800 Subject: [PATCH] fix(cli): bypass HTTP_PROXY for runner webhook via raw socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When HAPI runs in an environment with HTTP_PROXY/HTTPS_PROXY set and a NO_PROXY that does not literally bypass 127.0.0.1, the loopback webhook each spawned session sends back to the runner gets routed through the user's proxy, fails to reach the runner's control port, and the parent runner reports "Session webhook timeout for PID " 15s later — every "new session" / "resume session" from the web UI. Reproducer: with HTTP_PROXY=http://127.0.0.1:7890 and the common NO_PROXY="127.*,localhost" (the form glibc / wget accept but libcurl- style parsers don't), bun's fetch and bun's node:http both forward loopback requests to the proxy. cloudflared escapes this only because its origin uses the literal "localhost", which NO_PROXY does match. The runner uses 127.0.0.1 and gets caught. bun honors HTTP_PROXY at process start for both fetch and node:http; neither API exposes a per-request bypass that actually takes effect (verified `proxy: ''`, `proxy: undefined`, runtime mutation of `process.env.NO_PROXY`). The only transport in bun that ignores the proxy stack is node:net, since it is raw TCP. Replace `runnerPost`'s `fetch` call with a minimal HTTP/1.1 client written on `node:net.connect`. Public signature, return shape, error messages and `HAPI_RUNNER_HTTP_TIMEOUT` semantics are unchanged, so all callers (`notifyRunnerSessionStarted`, `listRunnerSessions`, `stopRunnerSession`, `spawnRunnerSession`, `stopRunnerHttp`) keep working without modification. Verified end-to-end on a real spawn under bun + bad proxy env: webhook delivery 389ms vs the previous 15s timeout. --- cli/src/runner/controlClient.ts | 105 ++++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 25 deletions(-) diff --git a/cli/src/runner/controlClient.ts b/cli/src/runner/controlClient.ts index ca43581497..f2286f2bfc 100644 --- a/cli/src/runner/controlClient.ts +++ b/cli/src/runner/controlClient.ts @@ -8,6 +8,7 @@ import { clearRunnerState, readRunnerState, readSettings } from '@/persistence'; import { Metadata } from '@/api/types'; import packageJson from '../../package.json'; import { existsSync, statSync } from 'node:fs'; +import { connect } from 'node:net'; import { join } from 'node:path'; import { isBunCompiled, projectPath } from '@/projectPath'; import { isProcessAlive, killProcess } from '@/utils/process'; @@ -53,32 +54,86 @@ async function runnerPost(path: string, body?: any): Promise<{ error?: string } }; } - try { - const timeout = process.env.HAPI_RUNNER_HTTP_TIMEOUT ? parseInt(process.env.HAPI_RUNNER_HTTP_TIMEOUT) : 10_000; - const response = await fetch(`http://127.0.0.1:${state.httpPort}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body || {}), - // Mostly increased for stress test - signal: AbortSignal.timeout(timeout) - }); - - if (!response.ok) { - const errorMessage = `Request failed: ${path}, HTTP ${response.status}`; + const timeout = process.env.HAPI_RUNNER_HTTP_TIMEOUT ? parseInt(process.env.HAPI_RUNNER_HTTP_TIMEOUT) : 10_000; + const port = state.httpPort; + const payload = Buffer.from(JSON.stringify(body || {})); + + // Speak HTTP/1.1 over a raw TCP socket instead of using fetch / node:http. + // bun honors HTTP_PROXY at process startup for both APIs and offers no + // per-request bypass; if the user's NO_PROXY is misformatted (e.g. "127.*", + // a wildcard libcurl-style parsers don't accept) this loopback webhook + // gets routed through the proxy and times out. Going through node:net + // is the only path that reliably skips the proxy stack. + return await new Promise((resolve) => { + const fail = (reason: string) => { + const errorMessage = `Request failed: ${path}, ${reason}`; logger.debug(`[CONTROL CLIENT] ${errorMessage}`); - return { - error: errorMessage - }; - } - - return await response.json(); - } catch (error) { - const errorMessage = `Request failed: ${path}, ${error instanceof Error ? error.message : 'Unknown error'}`; - logger.debug(`[CONTROL CLIENT] ${errorMessage}`); - return { - error: errorMessage - } - } + resolve({ error: errorMessage }); + }; + + const socket = connect({ host: '127.0.0.1', port }); + const chunks: Buffer[] = []; + let settled = false; + const settle = (action: () => void) => { + if (settled) return; + settled = true; + socket.destroy(); + action(); + }; + + socket.setTimeout(timeout, () => { + settle(() => fail(`timed out after ${timeout}ms`)); + }); + + socket.on('connect', () => { + const head = + `POST ${path} HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + `Content-Type: application/json\r\n` + + `Content-Length: ${payload.length}\r\n` + + `Connection: close\r\n\r\n`; + socket.write(head); + socket.write(payload); + }); + + socket.on('data', (chunk: Buffer) => chunks.push(chunk)); + + socket.on('end', () => { + if (settled) return; + const raw = Buffer.concat(chunks).toString('utf8'); + const headerEnd = raw.indexOf('\r\n\r\n'); + if (headerEnd < 0) { + settle(() => fail('malformed HTTP response')); + return; + } + const statusLine = raw.slice(0, raw.indexOf('\r\n')); + const statusMatch = /^HTTP\/\d\.\d (\d+)/.exec(statusLine); + if (!statusMatch) { + settle(() => fail('malformed HTTP status line')); + return; + } + const status = parseInt(statusMatch[1]!, 10); + const responseBody = raw.slice(headerEnd + 4); + + settle(() => { + if (status < 200 || status >= 300) { + const errorMessage = `Request failed: ${path}, HTTP ${status}`; + logger.debug(`[CONTROL CLIENT] ${errorMessage}`); + resolve({ error: errorMessage }); + return; + } + try { + resolve(responseBody ? JSON.parse(responseBody) : {}); + } catch (error) { + fail(error instanceof Error ? error.message : 'invalid JSON response'); + } + }); + }); + + socket.on('error', (error) => { + settle(() => fail(error.message)); + }); + }); } export async function notifyRunnerSessionStarted(