diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 59dc68acd..67a7b69c4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed proxied plain-HTTP provider requests hanging after a tool call by tunneling them with CONNECT ([#8134](https://github.com/earendil-works/pi/issues/8134)). + ## [0.84.4] - 2026-08-28 ### New Features diff --git a/packages/coding-agent/src/core/http-dispatcher.ts b/packages/coding-agent/src/core/http-dispatcher.ts index 0cae09a60..7b810b39d 100644 --- a/packages/coding-agent/src/core/http-dispatcher.ts +++ b/packages/coding-agent/src/core/http-dispatcher.ts @@ -86,6 +86,8 @@ export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TI const dispatcher = withUndiciErrorListener( new undici.EnvHttpProxyAgent({ allowH2: false, + // Keep HTTP origins on CONNECT tunnels as they were before Undici 8.7. + proxyTunnel: true, bodyTimeout: normalizedTimeoutMs, connect: { autoSelectFamilyAttemptTimeout: DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS, diff --git a/packages/coding-agent/test/http-dispatcher.test.ts b/packages/coding-agent/test/http-dispatcher.test.ts index bcd4b876f..74784e169 100644 --- a/packages/coding-agent/test/http-dispatcher.test.ts +++ b/packages/coding-agent/test/http-dispatcher.test.ts @@ -1,3 +1,4 @@ +import http from "node:http"; import net from "node:net"; import tls from "node:tls"; import * as undici from "undici"; @@ -5,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { applyHttpProxySettings, configureHttpDispatcher } from "../src/core/http-dispatcher.ts"; const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY"] as const; -const DISPATCHER_PROXY_ENV_KEYS = [...PROXY_ENV_KEYS, "http_proxy", "https_proxy"] as const; +const DISPATCHER_PROXY_ENV_KEYS = [...PROXY_ENV_KEYS, "http_proxy", "https_proxy", "NO_PROXY", "no_proxy"] as const; describe("http proxy settings", () => { let savedEnv: Record<(typeof PROXY_ENV_KEYS)[number], string | undefined>; @@ -89,6 +90,63 @@ describe("http dispatcher", () => { vi.restoreAllMocks(); }); + it("tunnels proxied HTTP origins", async () => { + const origin = http.createServer((_request, response) => { + response.end("origin"); + }); + await new Promise((resolve) => origin.listen(0, "127.0.0.1", resolve)); + const originAddress = origin.address(); + if (!originAddress || typeof originAddress === "string") { + throw new Error("Origin did not bind to a TCP port"); + } + + const proxyRequestLines: string[] = []; + const proxy = net.createServer((client) => { + client.once("data", (data) => { + const [requestLine = ""] = data.toString().split("\r\n"); + proxyRequestLines.push(requestLine); + if (!requestLine.startsWith("CONNECT ")) { + client.end("HTTP/1.1 501 Not Implemented\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"); + return; + } + + const upstream = net.connect(originAddress.port, "127.0.0.1", () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + client.pipe(upstream).pipe(client); + }); + upstream.on("error", () => client.destroy()); + client.on("error", () => upstream.destroy()); + }); + }); + await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve)); + const proxyAddress = proxy.address(); + if (!proxyAddress || typeof proxyAddress === "string") { + throw new Error("Proxy did not bind to a TCP port"); + } + + process.env.HTTP_PROXY = `http://127.0.0.1:${proxyAddress.port}`; + configureHttpDispatcher(); + const dispatcher = undici.getGlobalDispatcher(); + try { + const originUrl = `http://127.0.0.1:${originAddress.port}/v1/chat/completions`; + await expect(undici.fetch(originUrl).then((response) => response.text())).resolves.toBe("origin"); + await expect(undici.fetch(originUrl).then((response) => response.text())).resolves.toBe("origin"); + expect(proxyRequestLines).not.toHaveLength(0); + expect(proxyRequestLines).toEqual( + expect.arrayContaining([ + expect.stringMatching(`^CONNECT 127\\.0\\.0\\.1:${originAddress.port} HTTP/1\\.1$`), + ]), + ); + } finally { + await dispatcher.close(); + undici.setGlobalDispatcher(originalDispatcher); + await Promise.all([ + new Promise((resolve) => proxy.close(() => resolve())), + new Promise((resolve) => origin.close(() => resolve())), + ]); + } + }); + it("allows two seconds for HTTPS connection attempts without changing the Node default", async () => { // Preserve a deliberate host fetch override while testing the dispatcher itself. globalThis.fetch = async () => {