From 2a630cf98ccf8afe759742ff322a4079b9a33185 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 29 Oct 2026 06:32:59 +0100 Subject: [PATCH] fix(server): respect environment proxies across daemon traffic Install proxy routing at daemon and plugin process startup, with a separate HTTP agent for outbound WebSockets. Always exempt loopback so local provider bridges remain reachable. --- docs/architecture.md | 8 ++ package-lock.json | 24 +++++ packages/server/package.json | 2 + packages/server/src/server/daemon-worker.ts | 2 + .../src/server/hub/relationship-remote.ts | 2 + packages/server/src/server/network/index.ts | 58 ++++++++++ .../server/src/server/network/network.test.ts | 92 ++++++++++++++++ .../src/server/plugins/plugin-process.ts | 2 + .../server/plugins/usage-sources.e2e.test.ts | 100 +++++++++++++++++- packages/server/src/server/relay-transport.ts | 3 +- 10 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/server/network/index.ts create mode 100644 packages/server/src/server/network/network.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 3c5678b0ab..961f5476a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -493,6 +493,14 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management). +## Outbound networking + +The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY` +(including lowercase forms, which take precedence). Fetch and relay/Hub WebSockets +share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct. +Agent and terminal environments are unchanged. System proxy settings and SOCKS-only +`ALL_PROXY` configurations are not supported. + ## Deployment models 1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` diff --git a/package-lock.json b/package-lock.json index 0930535606..ebb20d5755 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40690,6 +40690,7 @@ "esbuild": "^0.25.12", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", + "https-proxy-agent": "^7.0.6", "lru-cache": "^11.5.1", "markdown-it": "^10.0.0", "mnemonic-id": "^3.2.7", @@ -40707,6 +40708,7 @@ "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", "typescript": "^5.9.3", + "undici": "^7.24.8", "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", @@ -41280,6 +41282,15 @@ "url": "https://opencollective.com/vitest" } }, + "packages/server/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "packages/server/node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -41349,6 +41360,19 @@ "@esbuild/win32-x64": "0.25.12" } }, + "packages/server/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "packages/server/node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", diff --git a/packages/server/package.json b/packages/server/package.json index 7f8a6c074a..225a9522ed 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -124,6 +124,7 @@ "esbuild": "^0.25.12", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", + "https-proxy-agent": "^7.0.6", "lru-cache": "^11.5.1", "markdown-it": "^10.0.0", "mnemonic-id": "^3.2.7", @@ -141,6 +142,7 @@ "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", "typescript": "^5.9.3", + "undici": "^7.24.8", "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index 317903a592..e2d94901bf 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -1,3 +1,4 @@ +import { initializeEnvironmentProxy } from "./network/index.js"; import { appendFileSync, mkdirSync } from "node:fs"; import path from "node:path"; import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js"; @@ -131,6 +132,7 @@ function applyCliFlagOverrides(config: ReturnType): void { } async function main() { + initializeEnvironmentProxy(); const { paseoHome, logger, config } = bootstrapFromEnvironment(); let daemon: Awaited> | null = null; let shutdownPromise: Promise | null = null; diff --git a/packages/server/src/server/hub/relationship-remote.ts b/packages/server/src/server/hub/relationship-remote.ts index 9c6f88a63d..a085499e50 100644 --- a/packages/server/src/server/hub/relationship-remote.ts +++ b/packages/server/src/server/hub/relationship-remote.ts @@ -1,3 +1,4 @@ +import { webSocketProxyAgent } from "../network/index.js"; import { WebSocket } from "ws"; import { z } from "zod"; import type { WebSocketLike } from "../websocket-server.js"; @@ -166,6 +167,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote { openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection { let sessionProtocol: "legacy" | "session-v1" = "legacy"; const socket = new WebSocket(input.webSocketUrl, { + agent: webSocketProxyAgent(input.webSocketUrl), handshakeTimeout: this.requestTimeoutMs, headers: { authorization: `Bearer ${input.credential}`, diff --git a/packages/server/src/server/network/index.ts b/packages/server/src/server/network/index.ts new file mode 100644 index 0000000000..6c5333ff05 --- /dev/null +++ b/packages/server/src/server/network/index.ts @@ -0,0 +1,58 @@ +import type { Agent } from "node:http"; +import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici"; +import { HttpsProxyAgent } from "https-proxy-agent"; + +interface ProxyEnvironment { + httpProxy: string; + httpsProxy: string; + noProxy: string; +} + +let environment = readEnvironment(process.env); + +function readEnvironment(env: NodeJS.ProcessEnv): ProxyEnvironment { + const httpProxy = env.http_proxy ?? env.HTTP_PROXY ?? ""; + return { + httpProxy, + httpsProxy: env.https_proxy ?? env.HTTPS_PROXY ?? httpProxy, + noProxy: env.no_proxy ?? env.NO_PROXY ?? "", + }; +} + +function bypassList(noProxy: string): string { + return noProxy.trim() === "*" ? "*" : `${noProxy},localhost,127.0.0.1,[::1],::1`; +} + +/** Install once at process startup, without changing environments inherited by agents. */ +export function initializeEnvironmentProxy(env: NodeJS.ProcessEnv = process.env): void { + environment = readEnvironment(env); + setGlobalDispatcher( + new EnvHttpProxyAgent({ + httpProxy: environment.httpProxy, + httpsProxy: environment.httpsProxy, + noProxy: bypassList(environment.noProxy), + }), + ); +} + +/** ws uses Node HTTP agents rather than the fetch dispatcher. */ +export function webSocketProxyAgent(address: string): Agent | undefined { + const url = new URL(address); + const hostname = url.hostname.toLowerCase(); + const port = url.port || (url.protocol === "wss:" ? "443" : "80"); + const entries = bypassList(environment.noProxy) + .toLowerCase() + .split(/[,\s]+/); + if ( + entries.some((entry) => { + if (entry === "*") return true; + const match = entry.match(/^(.+):(\d+)$/); + if (match && match[2] !== port) return false; + const host = (match ? match[1]! : entry).replace(/^\*?\./, ""); + return host !== "" && (hostname === host || hostname.endsWith(`.${host}`)); + }) + ) + return undefined; + const proxy = url.protocol === "wss:" ? environment.httpsProxy : environment.httpProxy; + return proxy ? new HttpsProxyAgent(proxy) : undefined; +} diff --git a/packages/server/src/server/network/network.test.ts b/packages/server/src/server/network/network.test.ts new file mode 100644 index 0000000000..19f0c463ef --- /dev/null +++ b/packages/server/src/server/network/network.test.ts @@ -0,0 +1,92 @@ +import { createServer, type Server } from "node:http"; +import { once } from "node:events"; +import { afterEach, expect, test } from "vitest"; +import { WebSocket, WebSocketServer } from "ws"; +import { getGlobalDispatcher, setGlobalDispatcher } from "undici"; +import { initializeEnvironmentProxy, webSocketProxyAgent } from "./index.js"; +const original = getGlobalDispatcher(); +const servers: Server[] = []; +afterEach(async () => { + const installed = getGlobalDispatcher(); + setGlobalDispatcher(original); + await installed.close(); + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ), + ); +}); +async function listen(server: Server) { + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + return `127.0.0.1:${address.port}`; +} +async function proxy() { + const tunnels: string[] = []; + const server = createServer(); + server.on("connect", (request, socket) => { + tunnels.push(request.url!); + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + }); + return { url: `http://${await listen(server)}`, tunnels }; +} +test("loopback fetch and WebSockets bypass HTTP_PROXY with empty NO_PROXY", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, NO_PROXY: "" }); + const server = createServer((_, response) => response.end("direct")); + const sockets = new WebSocketServer({ server }); + sockets.on("connection", (socket) => socket.send("direct")); + const address = await listen(server); + expect(await (await fetch(`http://${address}`)).text()).toBe("direct"); + const socket = new WebSocket(`ws://${address}`, { + agent: webSocketProxyAgent(`ws://${address}`), + }); + expect((await once(socket, "message"))[0].toString()).toBe("direct"); + socket.close(); + await once(socket, "close"); + await new Promise((resolve) => sockets.close(() => resolve())); + for (const host of ["localhost", "127.0.0.1", "[::1]"]) + expect(webSocketProxyAgent(`wss://${host}:1234`)).toBeUndefined(); + expect(recorder.tunnels).toEqual([]); +}); +test("fetch and WebSockets send non-loopback traffic through CONNECT", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ http_proxy: recorder.url }); + await expect( + fetch("https://vendor.invalid/", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + expect( + recorder.tunnels.filter((host) => host === "vendor.invalid:443").length, + ).toBeGreaterThanOrEqual(2); +}); +test("NO_PROXY and absent variables keep matching WebSockets direct", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ + HTTPS_PROXY: recorder.url, + NO_PROXY: "vendor.invalid,.example.invalid:443", + }); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined(); + await expect( + fetch("https://vendor.invalid/", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(webSocketProxyAgent("wss://sub.example.invalid")).toBeUndefined(); + expect(webSocketProxyAgent("wss://example.invalid:444")).toBeDefined(); + initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, no_proxy: "*" }); + expect(webSocketProxyAgent("ws://vendor.invalid")).toBeUndefined(); + initializeEnvironmentProxy({}); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined(); + const address = await listen(createServer((_, response) => response.end("direct"))); + expect(await (await fetch(`http://${address}`)).text()).toBe("direct"); + expect(recorder.tunnels).toEqual([]); +}); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index a72e268e4f..373a7e1ff1 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -1,3 +1,4 @@ +import { initializeEnvironmentProxy } from "../network/index.js"; import { PluginAgentTools } from "./agent-tools.js"; import { PluginHookHandlers } from "./lifecycle/index.js"; import { evaluateBundle } from "./bundle-evaluator.js"; @@ -606,6 +607,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { const parsed = PluginProcessRequestSchema.safeParse(raw); if (!worker && parsed.success && parsed.data.type === "initialize") { try { + initializeEnvironmentProxy(); worker = createPluginWorker({ channel, contribute: evaluateBundle(parsed.data.bundle) }); } catch (error) { channel.send({ type: "fatal", error: describeError(error) }); diff --git a/packages/server/src/server/plugins/usage-sources.e2e.test.ts b/packages/server/src/server/plugins/usage-sources.e2e.test.ts index 037da5e0c6..1cc6d35cf2 100644 --- a/packages/server/src/server/plugins/usage-sources.e2e.test.ts +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -1,7 +1,11 @@ -import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { cp, mkdir, mkdtemp, readFile as readTextFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { createServer } from "node:http"; +import { once } from "node:events"; import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -335,3 +339,97 @@ export default function contribute(server) { await rm(root, { recursive: true, force: true }); } }, 60_000); + +test("built-in usage plugins reach the environment proxy", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "usage-proxy-")); + const tunnels: string[] = []; + const proxy = createServer(); + proxy.on("connect", (request, socket) => { + tunnels.push(request.url!); + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + }); + proxy.listen(0, "127.0.0.1"); + await once(proxy, "listening"); + const address = proxy.address(); + if (!address || typeof address === "string") throw new Error("Missing proxy address"); + const guard = path.join(root, "network-guard.cjs"); + // Prevent vendor DNS/network access even when proxy support is broken. + await writeFile( + guard, + `const dns = require('node:dns'); +const lookup = dns.lookup; +dns.lookup = function(host, ...args) { + if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return lookup.call(this, host, ...args); + args.at(-1)(Object.assign(new Error('External DNS forbidden in proxy test'), { code: 'ENOTFOUND' })); +};`, + ); + await mkdir(path.join(root, ".claude")); + await mkdir(path.join(root, ".codex")); + await writeFile( + path.join(root, ".claude", ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "fixture-claude" } }), + ); + await writeFile( + path.join(root, ".codex", "auth.json"), + JSON.stringify({ tokens: { access_token: "fixture-codex", account_id: "fixture-account" } }), + ); + const external = path.join(root, "external-codex"); + await cp(path.join(resolveBuiltinPluginsRoot(), "codex-usage-source"), external, { + recursive: true, + }); + const entry = path.join(external, "index.server.ts"); + await writeFile( + entry, + (await readTextFile(entry, "utf8")).replace('id: "codex"', 'id: "external-codex"'), + ); + try { + const directory = path.dirname(fileURLToPath(import.meta.url)); + const moduleUrl = (file: string) => + JSON.stringify(pathToFileURL(path.resolve(directory, file)).href); + await promisify(execFile)( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "-e", + ` + import { initializeEnvironmentProxy } from ${moduleUrl("../network/index.ts")}; + import { DaemonClient } from ${moduleUrl("../test-utils/daemon-client.ts")}; + import { createTestPaseoDaemon } from ${moduleUrl("../test-utils/paseo-daemon.ts")}; + import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from ${moduleUrl("./builtin/index.ts")}; + initializeEnvironmentProxy(); + const daemon = await createTestPaseoDaemon({ pluginsEnabled: true, builtinPlugins: new BuiltinPluginLoader(resolveBuiltinPluginsRoot(), ["claude-usage-source", "codex-usage-source"]) }); + const client = new DaemonClient({ url: 'ws://127.0.0.1:' + daemon.port + '/ws' }); + try { + await client.connect(); + await client.listUsageReports(); + await client.installDirectoryPlugin(${JSON.stringify(external)}, 'external-codex'); + const result = await client.listUsageReports({ forceRefresh: true }); + if (!result.reports.some(report => report.sourceId === "external-codex")) throw new Error("Installed usage source missing"); + } finally { await client.close(); await daemon.close(); } + `, + ], + { + timeout: 45_000, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + HOME: root, + CODEX_HOME: path.join(root, ".codex"), + NODE_OPTIONS: `--require=${guard}`, + https_proxy: `http://127.0.0.1:${address.port}`, + no_proxy: "", + NO_PROXY: "", + }, + }, + ); + expect(tunnels).toContain("api.anthropic.com:443"); + expect(tunnels).toContain("chatgpt.com:443"); + expect(tunnels.filter((host) => host === "chatgpt.com:443").length).toBeGreaterThanOrEqual(3); + } finally { + proxy.closeAllConnections(); + await new Promise((resolve) => proxy.close(() => resolve())); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 08fcd826d5..a796c8f330 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -1,4 +1,5 @@ /// +import { webSocketProxyAgent } from "./network/index.js"; import { WebSocket } from "ws"; import type pino from "pino"; import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; @@ -56,7 +57,7 @@ const RELAY_WEBSOCKET_OPTIONS = { } as const; function createDefaultRelayWebSocket(url: string): RelayWebSocketLike { - return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS); + return new WebSocket(url, { ...RELAY_WEBSOCKET_OPTIONS, agent: webSocketProxyAgent(url) }); } function isRecord(value: unknown): value is Record {