diff --git a/docs/architecture.md b/docs/architecture.md index 961f5476a8..2ef59d7f7c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -498,6 +498,8 @@ Worker restart retains supervisor arguments/environment and rereads the configur 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. +A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes +are ignored, with one warning naming the variable in `daemon.log`. Agent and terminal environments are unchanged. System proxy settings and SOCKS-only `ALL_PROXY` configurations are not supported. diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index e2d94901bf..8af60cd7bc 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -1,6 +1,6 @@ -import { initializeEnvironmentProxy } from "./network/index.js"; import { appendFileSync, mkdirSync } from "node:fs"; import path from "node:path"; +import { initializeEnvironmentProxy } from "./network/index.js"; import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js"; import { loadConfig } from "./config.js"; import { resolvePaseoHome } from "./paseo-home.js"; @@ -132,8 +132,12 @@ function applyCliFlagOverrides(config: ReturnType): void { } async function main() { - initializeEnvironmentProxy(); const { paseoHome, logger, config } = bootstrapFromEnvironment(); + for (const variable of initializeEnvironmentProxy()) { + writeWorkerLifecycleLog(paseoHome, "Ignoring invalid or unsupported HTTP proxy variable", { + variable, + }); + } let daemon: Awaited> | null = null; let shutdownPromise: Promise | null = null; let exitHookInstalled = false; diff --git a/packages/server/src/server/hub/relationship-remote.ts b/packages/server/src/server/hub/relationship-remote.ts index a085499e50..6427b71fa6 100644 --- a/packages/server/src/server/hub/relationship-remote.ts +++ b/packages/server/src/server/hub/relationship-remote.ts @@ -1,6 +1,6 @@ -import { webSocketProxyAgent } from "../network/index.js"; import { WebSocket } from "ws"; import { z } from "zod"; +import { webSocketProxyAgent } from "../network/index.js"; import type { WebSocketLike } from "../websocket-server.js"; const SOCKET_PING_INTERVAL_MS = 10_000; diff --git a/packages/server/src/server/network/index.ts b/packages/server/src/server/network/index.ts index 6c5333ff05..a33a4c1352 100644 --- a/packages/server/src/server/network/index.ts +++ b/packages/server/src/server/network/index.ts @@ -1,5 +1,5 @@ import type { Agent } from "node:http"; -import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici"; +import { Agent as DirectAgent, Dispatcher, EnvHttpProxyAgent, setGlobalDispatcher } from "undici"; import { HttpsProxyAgent } from "https-proxy-agent"; interface ProxyEnvironment { @@ -8,14 +8,38 @@ interface ProxyEnvironment { noProxy: string; } -let environment = readEnvironment(process.env); +let environment = readEnvironment(process.env).environment; -function readEnvironment(env: NodeJS.ProcessEnv): ProxyEnvironment { - const httpProxy = env.http_proxy ?? env.HTTP_PROXY ?? ""; +function readEnvironment(env: NodeJS.ProcessEnv): { + environment: ProxyEnvironment; + ignored: string[]; +} { + const ignored: string[] = []; + function proxy(lower: string, upper: string): string | undefined { + const variable = env[lower] !== undefined ? lower : upper; + const value = env[variable]; + if (value === undefined) return undefined; + if (!value.trim()) return ""; + try { + const url = new URL(value.includes("://") ? value : `http://${value}`); + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new Error("Unsupported proxy protocol"); + decodeURIComponent(url.username); + decodeURIComponent(url.password); + return url.href; + } catch { + ignored.push(variable); + return ""; + } + } + const httpProxy = proxy("http_proxy", "HTTP_PROXY") ?? ""; return { - httpProxy, - httpsProxy: env.https_proxy ?? env.HTTPS_PROXY ?? httpProxy, - noProxy: env.no_proxy ?? env.NO_PROXY ?? "", + environment: { + httpProxy, + httpsProxy: proxy("https_proxy", "HTTPS_PROXY") ?? httpProxy, + noProxy: env.no_proxy ?? env.NO_PROXY ?? "", + }, + ignored, }; } @@ -23,16 +47,44 @@ 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); +// undici otherwise falls back to HTTP_PROXY when an explicitly ignored HTTPS proxy is empty. +class EnvironmentProxyAgent extends Dispatcher { + private readonly proxy: EnvHttpProxyAgent; + private readonly direct = new DirectAgent(); + private readonly options: ProxyEnvironment; + constructor(options: ProxyEnvironment) { + super(); + this.options = options; + this.proxy = new EnvHttpProxyAgent(options); + } + override dispatch( + options: Parameters[0], + handler: Parameters[1], + ): boolean { + if (!this.options.httpsProxy && String(options.origin).startsWith("https:")) + return this.direct.dispatch(options, handler); + return this.proxy.dispatch(options, handler); + } + override async close(): Promise { + await Promise.all([this.proxy.close(), this.direct.close()]); + } + override async destroy(): Promise { + await Promise.all([this.proxy.destroy(), this.direct.destroy()]); + } +} + +/** Install once at startup; return ignored variable names for logging without exposing credentials. */ +export function initializeEnvironmentProxy(env: NodeJS.ProcessEnv = process.env): string[] { + const resolved = readEnvironment(env); + environment = resolved.environment; setGlobalDispatcher( - new EnvHttpProxyAgent({ + new EnvironmentProxyAgent({ httpProxy: environment.httpProxy, httpsProxy: environment.httpsProxy, noProxy: bypassList(environment.noProxy), }), ); + return resolved.ignored; } /** ws uses Node HTTP agents rather than the fetch dispatcher. */ diff --git a/packages/server/src/server/network/network.test.ts b/packages/server/src/server/network/network.test.ts index 19f0c463ef..436c32358e 100644 --- a/packages/server/src/server/network/network.test.ts +++ b/packages/server/src/server/network/network.test.ts @@ -1,3 +1,8 @@ +import { fork } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { createServer, type Server } from "node:http"; import { once } from "node:events"; import { afterEach, expect, test } from "vitest"; @@ -9,7 +14,7 @@ const servers: Server[] = []; afterEach(async () => { const installed = getGlobalDispatcher(); setGlobalDispatcher(original); - await installed.close(); + if (installed !== original) await installed.close(); await Promise.all( servers.splice(0).map( (server) => @@ -90,3 +95,92 @@ test("NO_PROXY and absent variables keep matching WebSockets direct", async () = expect(await (await fetch(`http://${address}`)).text()).toBe("direct"); expect(recorder.tunnels).toEqual([]); }); + +test.each(["127.0.0.1", "localhost"])( + "scheme-less %s proxies work for fetch and WebSockets", + async (host) => { + const recorder = await proxy(); + const address = new URL(recorder.url); + initializeEnvironmentProxy({ https_proxy: `${host}:${address.port}` }); + 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).toContain("vendor.invalid:443"); + }, +); + +test.each([ + "http://[", + "http://user%zz:secret@proxy.corp", + "socks5://127.0.0.1:7890", + "ftp://proxy.corp:8080", +])("ignores invalid or unsupported proxy %s without stopping startup", async (value) => { + const recorder = await proxy(); + expect(initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, https_proxy: value })).toEqual([ + "https_proxy", + ]); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined(); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(recorder.tunnels).toEqual([]); +}); + +test("daemon starts with an ignored proxy and writes one redacted warning to daemon.log", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "daemon-invalid-proxy-")); + const worker = fork(fileURLToPath(new URL("../daemon-worker.ts", import.meta.url)), [], { + execArgv: ["--import", "tsx"], + silent: true, + env: { + ...process.env, + PASEO_HOME: home, + HOME: home, + USERPROFILE: home, + PASEO_LISTEN: "127.0.0.1:0", + PASEO_RELAY_ENABLED: "0", + https_proxy: "socks5://private:secret@127.0.0.1:7890", + }, + }); + worker.stdout?.resume(); + let stderr = ""; + worker.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + try { + await new Promise((resolve, reject) => { + worker.on("message", (message) => { + if ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === "paseo:ready" + ) + resolve(); + }); + worker.on("exit", (code) => reject(new Error(`Worker exited ${code}: ${stderr}`))); + }); + const log = await readFile(path.join(home, "daemon.log"), "utf8"); + const warnings = log + .split("\n") + .filter((line) => line.includes("Ignoring invalid or unsupported HTTP proxy variable")); + expect(warnings).toHaveLength(1); + expect(JSON.parse(warnings[0]!).variable).toBe("https_proxy"); + expect(warnings[0]).not.toContain("secret"); + } finally { + if (worker.exitCode === null && worker.signalCode === null) { + const exited = once(worker, "exit"); + worker.kill("SIGTERM"); + await exited; + } + await rm(home, { recursive: true, force: true }); + } +}, 60_000); + +test("accepts a scheme-less corporate proxy hostname", () => { + expect(initializeEnvironmentProxy({ https_proxy: "proxy.corp:8080" })).toEqual([]); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined(); +}); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 373a7e1ff1..62514cd0b2 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -1,4 +1,3 @@ -import { initializeEnvironmentProxy } from "../network/index.js"; import { PluginAgentTools } from "./agent-tools.js"; import { PluginHookHandlers } from "./lifecycle/index.js"; import { evaluateBundle } from "./bundle-evaluator.js"; @@ -25,6 +24,7 @@ import { createPluginClientId } from "./plugin-session-identity.js"; import { PluginSettingsStore } from "./settings/index.js"; import { readPluginProviderIcon } from "./provider-icon.js"; +import { initializeEnvironmentProxy } from "../network/index.js"; function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/packages/server/src/server/plugins/test-fixtures/usage-proxy-daemon.ts b/packages/server/src/server/plugins/test-fixtures/usage-proxy-daemon.ts new file mode 100644 index 0000000000..23364d3fda --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-proxy-daemon.ts @@ -0,0 +1,31 @@ +import path from "node:path"; +import { initializeEnvironmentProxy } from "../../network/index.js"; +import { DaemonClient } from "../../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../../test-utils/paseo-daemon.js"; +import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from "../builtin/index.js"; + +const mode = process.argv[2]; +if (mode !== "builtin" && mode !== "directory") throw new Error("Expected builtin or directory"); +const sources = ["claude-usage-source", "codex-usage-source"]; +// Built-ins run inside the daemon; directory plugins initialize their own subprocess network. +if (mode === "builtin") initializeEnvironmentProxy(); +const daemon = await createTestPaseoDaemon({ + pluginsEnabled: mode === "directory", + builtinPlugins: new BuiltinPluginLoader( + resolveBuiltinPluginsRoot(), + mode === "builtin" ? sources : [], + ), +}); +const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); +try { + await client.connect(); + if (mode === "directory") { + for (const source of sources) { + await client.installDirectoryPlugin(path.join(resolveBuiltinPluginsRoot(), source), source); + } + } + await client.listUsageReports(); +} finally { + await client.close(); + await daemon.close(); +} 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 1cc6d35cf2..b5100ac48a 100644 --- a/packages/server/src/server/plugins/usage-sources.e2e.test.ts +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -1,6 +1,6 @@ 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 { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -340,96 +340,72 @@ export default function contribute(server) { } }, 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'); +test.each(["builtin", "directory"])( + "%s usage plugins reach the environment proxy", + async (mode) => { + 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); + 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" } }), + ); + try { + await promisify(execFile)( + process.execPath, + [ + "--import", + "tsx", + fileURLToPath(new URL("./test-fixtures/usage-proxy-daemon.ts", import.meta.url)), + mode, + ], + { + timeout: 45_000, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + HOME: root, + USERPROFILE: root, + CODEX_HOME: path.join(root, ".codex"), + NODE_OPTIONS: `--require=${JSON.stringify(guard.replaceAll("\\", "/"))}`, + 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"); + } 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 a796c8f330..a0898df247 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -1,8 +1,8 @@ /// -import { webSocketProxyAgent } from "./network/index.js"; import { WebSocket } from "ws"; import type pino from "pino"; import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; +import { webSocketProxyAgent } from "./network/index.js"; import type { ExternalSocketMetadata } from "./websocket-server.js"; export interface RelayTransportOptions {