diff --git a/docs/architecture.md b/docs/architecture.md index 3c5678b0ab..a7ad69c14d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -493,6 +493,16 @@ 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, and connected-host 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 treated as unset, 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. + ## 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 3351d15ac9..80355dbb21 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/client/src/node/index.ts b/packages/client/src/node/index.ts index 7132d09f10..46b5cff17a 100644 --- a/packages/client/src/node/index.ts +++ b/packages/client/src/node/index.ts @@ -2,6 +2,7 @@ export { createHostChannelTransport } from "../hosts/index.js"; import { HostTransportBudget } from "./buffering.js"; export { HostTransportBudget } from "./buffering.js"; import { homedir } from "node:os"; +import type { Agent } from "node:http"; import { WebSocket } from "ws"; import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target"; import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js"; @@ -19,10 +20,15 @@ export { parseHostTarget }; export type { SshTunnel } from "./ssh-tunnel.js"; export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js"; +export interface HostTransportOptions { + webSocketAgent?: (url: string) => Agent | undefined; +} + /** A factory owns one attempt, including a fresh SSH process and its teardown. */ export function createHostTransportFactory( connection: string, budget = new HostTransportBudget(), + options: HostTransportOptions = {}, ): DaemonTransportFactory { const target = parseHostTarget(connection); // Rises to "protected" once the daemon negotiates it, so a later attempt @@ -68,12 +74,13 @@ export function createHostTransportFactory( return; } const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir()); - let factory = createWebSocketTransportFactory((url, options) => { + let factory = createWebSocketTransportFactory((url, socketOptions) => { const socket = new WebSocket( socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url, - options?.protocols, + socketOptions?.protocols, { - headers: options?.headers, + headers: socketOptions?.headers, + agent: socketPath ? undefined : options.webSocketAgent?.(url), ...(socketPath ? { socketPath } : {}), maxPayload: 100 * 1024 * 1024, }, @@ -191,14 +198,15 @@ export function createHostTransportFactory( } export function createHostClient( connection: string, - config: Omit, + config: Omit & HostTransportOptions, budget?: HostTransportBudget, ): DaemonClient { const target = parseHostTarget(connection); + const { webSocketAgent, ...clientConfig } = config; return new DaemonClient({ - ...config, + ...clientConfig, url: "ws://host-connector/ws", ...(target.password ? { password: target.password } : {}), - transportFactory: createHostTransportFactory(connection, budget), + transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }), }); } diff --git a/packages/server/package.json b/packages/server/package.json index 13e5c9b49c..a31ce8d02a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", @@ -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/connected-hosts/index.ts b/packages/server/src/server/connected-hosts/index.ts index 1a8f7c2731..96d6ac35d2 100644 --- a/packages/server/src/server/connected-hosts/index.ts +++ b/packages/server/src/server/connected-hosts/index.ts @@ -21,6 +21,7 @@ import { type HostUpdate, } from "@getpaseo/protocol/hosts"; import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages"; +import { webSocketProxyAgent } from "../network/index.js"; export class HostError extends Error { constructor( @@ -242,6 +243,7 @@ export class ConnectedHosts { { clientId: `host-link-${this.self.serverId}-${row.registrationId}`, connectTimeoutMs: 10000, + webSocketAgent: webSocketProxyAgent, }, this.budget, ); diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index 317903a592..8af60cd7bc 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -1,5 +1,6 @@ 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,6 +133,11 @@ function applyCliFlagOverrides(config: ReturnType): void { async function main() { 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 9c6f88a63d..6427b71fa6 100644 --- a/packages/server/src/server/hub/relationship-remote.ts +++ b/packages/server/src/server/hub/relationship-remote.ts @@ -1,5 +1,6 @@ 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; @@ -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..46565d801a --- /dev/null +++ b/packages/server/src/server/network/index.ts @@ -0,0 +1,116 @@ +import type { Agent } from "node:http"; +import { EnvHttpProxyAgent, Pool, setGlobalDispatcher } from "undici"; +import { HttpsProxyAgent } from "https-proxy-agent"; + +interface ProxyEnvironment { + httpProxy: string; + httpsProxy: string; + noProxy: string; +} + +let environment = readEnvironment(process.env, process.platform).environment; + +function readEnvironment( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): { + environment: ProxyEnvironment; + ignored: string[]; +} { + // process.env lookups are case-insensitive on Windows. Read each actual entry once. + const values = new Map( + Object.entries(env).map(([name, value]) => [ + platform === "win32" ? name.toLowerCase() : name, + value, + ]), + ); + const ignored: string[] = []; + function proxy(variable: string): string | undefined { + const value = values.get(variable); + if (value === undefined) return undefined; + if (!value.trim()) return undefined; + 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 undefined; + } + } + const httpProxy = proxy("http_proxy") ?? proxy("HTTP_PROXY") ?? ""; + return { + environment: { + httpProxy, + httpsProxy: proxy("https_proxy") ?? proxy("HTTPS_PROXY") ?? httpProxy, + noProxy: values.get("no_proxy") ?? values.get("NO_PROXY") ?? "", + }, + ignored, + }; +} + +function bypassList(noProxy: string): string { + return noProxy.split(/[,\s]+/).includes("*") ? "*" : `${noProxy},localhost,127.0.0.1,[::1],::1`; +} + +function proxyClient(origin: URL, options: object) { + const pool = new Pool(origin, options); + if (!origin.username && !origin.password) return pool; + const credentials = `${decodeURIComponent(origin.username)}:${decodeURIComponent(origin.password)}`; + const authorization = `Basic ${Buffer.from(credentials).toString("base64")}`; + // This pool only sends CONNECT requests to this proxy, never requests to the origin. + // undici's URL auth requires both fields; HTTP Basic also permits either field to be empty. + return pool.compose( + (dispatch) => (request, handler) => + dispatch( + { + ...request, + headers: { ...request.headers, "proxy-authorization": authorization }, + }, + handler, + ), + ); +} + +/** Install once at startup; return ignored variable names for logging without exposing credentials. */ +export function initializeEnvironmentProxy( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string[] { + const resolved = readEnvironment(env, platform); + environment = resolved.environment; + setGlobalDispatcher( + new EnvHttpProxyAgent({ + httpProxy: environment.httpProxy, + httpsProxy: environment.httpsProxy, + noProxy: bypassList(environment.noProxy), + clientFactory: proxyClient, + }), + ); + return resolved.ignored; +} + +/** 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 && Number(match[2]) !== Number(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..88e7d61aa6 --- /dev/null +++ b/packages/server/src/server/network/network.test.ts @@ -0,0 +1,341 @@ +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 { connect } from "node:net"; +import { afterEach, expect, test } from "vitest"; +import { WebSocket, WebSocketServer } from "ws"; +import { getGlobalDispatcher, setGlobalDispatcher } from "undici"; +import { initializeEnvironmentProxy, webSocketProxyAgent } from "./index.js"; +import { ConnectedHosts } from "../connected-hosts/index.js"; +const original = getGlobalDispatcher(); +const servers: Server[] = []; +afterEach(async () => { + const installed = getGlobalDispatcher(); + setGlobalDispatcher(original); + if (installed !== 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([]); +}); + +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")).toBeDefined(); + 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).toEqual(["vendor.invalid:443", "vendor.invalid:443"]); +}); + +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 env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !["http_proxy", "https_proxy"].includes(key.toLowerCase()), + ), + ); + const worker = fork(fileURLToPath(new URL("../daemon-worker.ts", import.meta.url)), [], { + execArgv: ["--import", "tsx"], + silent: true, + env: { + ...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(); +}); + +test("an ignored lowercase variable falls back to the uppercase variable", async () => { + const recorder = await proxy(); + expect( + initializeEnvironmentProxy( + { + https_proxy: "socks5://proxy.invalid", + HTTPS_PROXY: recorder.url, + }, + "linux", + ), + ).toEqual(["https_proxy"]); + 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).toEqual(["vendor.invalid:443", "vendor.invalid:443"]); +}); + +test("case-insensitive environment access reports a variable only once", () => { + const env = new Proxy( + { https_proxy: "socks5://127.0.0.1:7890" }, + { + get(target, key) { + return Reflect.get(target, typeof key === "string" ? key.toLowerCase() : key); + }, + }, + ); + expect(initializeEnvironmentProxy(env)).toEqual(["https_proxy"]); +}); + +test("connected-host links send remote WebSockets through the environment proxy", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ HTTPS_PROXY: recorder.url }); + const home = await mkdtemp(path.join(os.tmpdir(), "connected-host-proxy-")); + const hosts = new ConnectedHosts(home, { serverId: "self", label: "Self" }); + try { + await hosts.add( + { connection: "wss://remote.invalid/ws" }, + { sourceId: "test", manageSources: true }, + ); + await expect.poll(() => recorder.tunnels).toContain("remote.invalid:443"); + } finally { + await hosts.close(); + await rm(home, { recursive: true, force: true }); + } +}); + +test.each(["corp.internal,*", "vendor.invalid:0443"])( + "fetch and WebSockets share NO_PROXY=%s", + async (noProxy) => { + const recorder = await proxy(); + initializeEnvironmentProxy({ HTTPS_PROXY: recorder.url, NO_PROXY: noProxy }); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined(); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(recorder.tunnels).toEqual([]); + }, +); + +test("Windows mixed-case environment names route and bypass both transports", async () => { + const recorder = await proxy(); + // Windows preserves spelling but treats environment names without regard to case. + initializeEnvironmentProxy({ Https_Proxy: recorder.url, No_Proxy: "other.invalid" }, "win32"); + expect(webSocketProxyAgent("wss://other.invalid")).toBeUndefined(); + await expect( + fetch("https://other.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined(); + 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).toEqual(["vendor.invalid:443", "vendor.invalid:443"]); +}); + +test("POSIX mixed-case names do not override standard proxy names", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy( + { Https_Proxy: recorder.url, HTTPS_PROXY: recorder.url, No_Proxy: "*" }, + "linux", + ); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined(); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(recorder.tunnels).toEqual(["vendor.invalid:443"]); +}); + +test("proxy credentials stay on CONNECT and do not reach the HTTP origin", async () => { + let originAuthorization: string | undefined; + const origin = createServer((request, response) => { + originAuthorization = request.headers["proxy-authorization"]; + response.end("through proxy"); + }); + const originAddress = new URL(`http://${await listen(origin)}`); + const authorizations: (string | undefined)[] = []; + const server = createServer(); + server.on("connect", (request, socket, head) => { + authorizations.push(request.headers["proxy-authorization"]); + const upstream = connect(Number(originAddress.port), "127.0.0.1", () => { + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length) upstream.write(head); + socket.pipe(upstream).pipe(socket); + }); + socket.on("error", () => upstream.destroy()); + upstream.on("error", () => socket.destroy()); + socket.on("close", () => upstream.destroy()); + }); + initializeEnvironmentProxy({ HTTP_PROXY: `http://user:@${await listen(server)}` }); + const response = await fetch("http://vendor.invalid", { signal: AbortSignal.timeout(3000) }); + expect(await response.text()).toBe("through proxy"); + expect(authorizations).toEqual([`Basic ${Buffer.from("user:").toString("base64")}`]); + expect(originAuthorization).toBeUndefined(); +}); + +test.each(["user:@", ":secret@", "user:secret@"])( + "fetch and WebSockets authenticate proxy credentials %s", + async (credentials) => { + const authorizations: (string | undefined)[] = []; + const server = createServer(); + server.on("connect", (request, socket) => { + authorizations.push(request.headers["proxy-authorization"]); + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + }); + initializeEnvironmentProxy({ HTTPS_PROXY: `http://${credentials}${await listen(server)}` }); + 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"); + const expected = `Basic ${Buffer.from(credentials.slice(0, -1)).toString("base64")}`; + expect(authorizations).toEqual([expected, expected]); + }, +); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index a72e268e4f..62514cd0b2 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -24,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); } @@ -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/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 037da5e0c6..46dc134ff6 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 { execFile } from "node:child_process"; +import { promisify } from "node:util"; 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"; +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,74 @@ export default function contribute(server) { await rm(root, { recursive: true, force: true }); } }, 60_000); + +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" } }), + ); + 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"), + CLAUDE_CONFIG_DIR: path.join(root, ".claude"), + 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 08fcd826d5..a0898df247 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -2,6 +2,7 @@ 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 { @@ -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 {