Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 0edf7eea7c test(server): isolate proxy fixture credential directories 2026-10-29 07:44:07 +01:00
Mohamed Boudra bf9dea429b fix(server): apply proxy policy to connected host links 2026-10-29 07:33:03 +01:00
Mohamed Boudra 629ca2f9b6 fix(server): read Windows proxy environment entries once 2026-10-29 07:23:57 +01:00
Mohamed Boudra b27a29c32f refactor(server): treat ignored proxy variables as unset 2026-10-29 06:54:19 +01:00
Mohamed Boudra 32ddc6215a fix(server): validate proxy settings without blocking startup
Normalize scheme-less proxies and share HTTP-only validation across fetch and WebSockets. Record ignored variables without credentials in the daemon log. Separate built-in and directory plugin regressions through a typed fixture.
2026-10-29 06:43:07 +01:00
Mohamed Boudra 808024c95f test(server): run usage source regressions in CI 2026-10-29 06:33:57 +01:00
Mohamed Boudra 2a630cf98c 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.
2026-10-29 06:32:59 +01:00
13 changed files with 499 additions and 8 deletions
+10
View File
@@ -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`
+24
View File
@@ -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",
+14 -6
View File
@@ -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<DaemonClientConfig, "url" | "transportFactory">,
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & 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 }),
});
}
+3 -1
View File
@@ -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 && 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 && 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",
@@ -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,
);
@@ -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<typeof loadConfig>): 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<ReturnType<typeof createPaseoDaemon>> | null = null;
let shutdownPromise: Promise<number> | null = null;
let exitHookInstalled = false;
@@ -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}`,
@@ -0,0 +1,85 @@
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).environment;
function readEnvironment(env: NodeJS.ProcessEnv): {
environment: ProxyEnvironment;
ignored: string[];
} {
// process.env lookups are case-insensitive on Windows. Read each actual entry once.
const values = new Map(Object.entries(env));
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.trim() === "*" ? "*" : `${noProxy},localhost,127.0.0.1,[::1],::1`;
}
/** 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({
httpProxy: environment.httpProxy,
httpsProxy: environment.httpsProxy,
noProxy: bypassList(environment.noProxy),
}),
);
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 && 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,243 @@
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";
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<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([]);
});
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<void>((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,
}),
).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 });
}
});
@@ -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) });
@@ -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();
}
@@ -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<void>((resolve) => proxy.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
},
60_000,
);
@@ -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<string, unknown> {