mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 20:52:51 +00:00
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.
This commit is contained in:
@@ -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`
|
||||
|
||||
Generated
+24
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof loadConfig>): void {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
initializeEnvironmentProxy();
|
||||
const { paseoHome, logger, config } = bootstrapFromEnvironment();
|
||||
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
|
||||
let shutdownPromise: Promise<number> | null = null;
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void>((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<void>((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([]);
|
||||
});
|
||||
@@ -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) });
|
||||
|
||||
@@ -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<void>((resolve) => proxy.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/// <reference lib="dom" />
|
||||
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<string, unknown> {
|
||||
|
||||
Reference in New Issue
Block a user