refactor: route node proxy agents through proxyline

This commit is contained in:
Peter Steinberger
2026-05-28 19:21:14 +01:00
parent 2305bca782
commit 4ad9f0bdbb
20 changed files with 312 additions and 470 deletions

View File

@@ -10,7 +10,6 @@
"dependencies": {
"@discordjs/voice": "0.19.2",
"discord-api-types": "0.38.48",
"https-proxy-agent": "9.0.0",
"libopus-wasm": "0.1.0",
"typebox": "1.1.38",
"undici": "8.3.0",
@@ -374,32 +373,6 @@
"@types/node": "*"
}
},
"node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"license": "MIT",
"engines": {
"node": ">= 20"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/discord-api-types": {
"version": "0.38.48",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.48.tgz",
@@ -409,19 +382,6 @@
"scripts/actions/documentation"
]
},
"node_modules/https-proxy-agent": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz",
"integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==",
"license": "MIT",
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/libopus-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/libopus-wasm/-/libopus-wasm-0.1.0.tgz",
@@ -431,12 +391,6 @@
"node": ">=20"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/prism-media": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",

View File

@@ -10,7 +10,6 @@
"dependencies": {
"@discordjs/voice": "0.19.2",
"discord-api-types": "0.38.48",
"https-proxy-agent": "9.0.0",
"libopus-wasm": "0.1.0",
"typebox": "1.1.38",
"undici": "8.3.0",

View File

@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import type { Agent as HttpAgent } from "node:http";
import { Agent as HttpsAgent } from "node:https";
import * as httpsProxyAgent from "https-proxy-agent";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-contracts";
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime";
import {
captureWsEvent,
resolveEffectiveDebugProxyUrl,
@@ -36,9 +37,7 @@ type DiscordGatewayWebSocketCtor = new (
url: string,
options?: { agent?: unknown; handshakeTimeout?: number },
) => ws.WebSocket;
type DiscordGatewayWebSocketAgent =
| InstanceType<typeof HttpsAgent>
| InstanceType<typeof httpsProxyAgent.HttpsProxyAgent<string>>;
type DiscordGatewayWebSocketAgent = InstanceType<typeof HttpsAgent> | HttpAgent;
const registrationPromises = new WeakMap<discordGateway.GatewayPlugin, Promise<void>>();
type DiscordGatewayClient = Parameters<discordGateway.GatewayPlugin["registerClient"]>[0];
type GatewayPluginTestingOptions = {
@@ -49,7 +48,7 @@ type GatewayPluginTestingOptions = {
webSocketCtor?: DiscordGatewayWebSocketCtor;
};
type CreateDiscordGatewayPluginTestingOptions = GatewayPluginTestingOptions & {
HttpsProxyAgentCtor?: typeof httpsProxyAgent.HttpsProxyAgent;
createProxyAgent?: (proxyUrl: string) => HttpAgent;
};
type DiscordGatewayRegistrationState = {
client?: DiscordGatewayClient;
@@ -280,9 +279,9 @@ export function createDiscordGatewayPlugin(params: {
if (proxy) {
try {
validateDiscordProxyUrl(proxy);
const HttpsProxyAgentCtor =
params.testing?.HttpsProxyAgentCtor ?? httpsProxyAgent.HttpsProxyAgent;
wsAgent = new HttpsProxyAgentCtor<string>(proxy);
wsAgent =
params.testing?.createProxyAgent?.(proxy) ??
createNodeProxyAgent({ mode: "explicit", proxyUrl: proxy, protocol: "https" });
fetchImpl = createDiscordGatewayMetadataFetch(debugProxySettings.enabled, proxy);
params.runtime.log?.("discord: gateway proxy enabled");
} catch (err) {

View File

@@ -158,10 +158,6 @@ vi.mock("node:https", () => ({
Agent: HttpsAgent,
}));
vi.mock("https-proxy-agent", () => ({
HttpsProxyAgent,
}));
vi.mock("ws", () => ({
default: function MockWebSocket(
url: string,
@@ -228,8 +224,8 @@ describe("createDiscordGatewayPlugin", () => {
function createProxyTestingOverrides() {
return {
HttpsProxyAgentCtor:
HttpsProxyAgent as unknown as typeof import("https-proxy-agent").HttpsProxyAgent,
createProxyAgent: (proxyUrl: string) =>
new HttpsProxyAgent(proxyUrl) as unknown as import("node:http").Agent,
webSocketCtor: function WebSocketCtor(
url: string,
options?: { agent?: unknown; handshakeTimeout?: number },

View File

@@ -11,7 +11,6 @@
"@slack/bolt": "4.7.2",
"@slack/types": "2.21.1",
"@slack/web-api": "7.16.0",
"https-proxy-agent": "9.0.0",
"typebox": "1.1.38",
"zod": "4.4.3"
},
@@ -263,15 +262,6 @@
"node": ">= 0.6"
}
},
"node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"license": "MIT",
"engines": {
"node": ">= 20"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -813,19 +803,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/https-proxy-agent": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz",
"integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==",
"license": "MIT",
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",

View File

@@ -11,7 +11,6 @@
"@slack/bolt": "4.7.2",
"@slack/types": "2.21.1",
"@slack/web-api": "7.16.0",
"https-proxy-agent": "9.0.0",
"typebox": "1.1.38",
"zod": "4.4.3"
},

View File

@@ -1,9 +1,6 @@
import type { Agent } from "node:http";
import type { RetryOptions, WebClientOptions } from "@slack/web-api";
import { HttpsProxyAgent } from "https-proxy-agent";
import {
resolveActiveManagedProxyTlsOptions,
resolveEnvHttpProxyUrl,
} from "openclaw/plugin-sdk/fetch-runtime";
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime";
export const SLACK_DEFAULT_RETRY_OPTIONS: RetryOptions = {
retries: 2,
@@ -17,38 +14,6 @@ export const SLACK_WRITE_RETRY_OPTIONS: RetryOptions = {
retries: 0,
};
/**
* Check whether a hostname is excluded from proxying by `NO_PROXY` / `no_proxy`.
* Supports comma-separated entries with optional leading dots (e.g. `.slack.com`).
*/
function isHostExcludedByNoProxy(hostname: string, env: NodeJS.ProcessEnv = process.env): boolean {
const raw = env.no_proxy ?? env.NO_PROXY;
if (!raw) {
return false;
}
const entries = raw
.split(/[,\s]+/)
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
const lower = hostname.toLowerCase();
for (const entry of entries) {
if (entry === "*") {
return true;
}
// Strip optional wildcard/leading dot so `*.slack.com` and `.slack.com`
// match both `slack.com` (apex) and Slack subdomains.
const bare = entry.startsWith("*.")
? entry.slice(2)
: entry.startsWith(".")
? entry.slice(1)
: entry;
if (lower === bare || lower.endsWith(`.${bare}`)) {
return true;
}
}
return false;
}
/**
* Build an HTTPS proxy agent from env vars (HTTPS_PROXY, HTTP_PROXY, etc.)
* for use as the `agent` option in Slack WebClient and Socket Mode connections.
@@ -64,19 +29,13 @@ function isHostExcludedByNoProxy(hostname: string, env: NodeJS.ProcessEnv = proc
* Returns `undefined` when no proxy env var is configured or when Slack hosts
* are excluded by `NO_PROXY`.
*/
function resolveSlackProxyAgent(): HttpsProxyAgent<string> | undefined {
const proxyUrl = resolveEnvHttpProxyUrl("https");
if (!proxyUrl) {
return undefined;
}
// Slack Socket Mode connects to these hosts; skip proxy if excluded.
if (isHostExcludedByNoProxy("slack.com")) {
return undefined;
}
const proxyTls = resolveActiveManagedProxyTlsOptions({ proxyUrl });
const proxyAgentOptions = proxyTls?.ca ? { ca: proxyTls.ca } : undefined;
function resolveSlackProxyAgent(): Agent | undefined {
try {
return new HttpsProxyAgent(proxyUrl, proxyAgentOptions);
return createNodeProxyAgent({
mode: "env",
targetUrl: "https://slack.com/",
protocol: "https",
});
} catch {
// Malformed proxy URL; degrade gracefully to direct connection.
return undefined;

View File

@@ -218,26 +218,26 @@ describe("slack proxy agent", () => {
const options = resolveSlackWebClientOptions();
const agent = requireAgent(options);
expect(agent.constructor.name).toBe("HttpsProxyAgent");
expect(agent.constructor.name).toBe("ProxylineNodeProxyAgent");
});
it("adds managed proxy CA trust to Slack env proxy agents", () => {
it("creates Slack env proxy agents while managed proxy CA trust is active", () => {
const caFile = writeTempCa("slack-managed-proxy-ca");
process.env.HTTPS_PROXY = "https://proxy.example.com:8443";
process.env.OPENCLAW_PROXY_ACTIVE = "1";
process.env.OPENCLAW_PROXY_CA_FILE = caFile;
const options = resolveSlackWebClientOptions();
const agent = requireAgent(options) as { connectOpts?: { ca?: unknown } };
const agent = requireAgent(options);
expect(agent.connectOpts?.ca).toBe("slack-managed-proxy-ca");
expect(agent.constructor.name).toBe("ProxylineNodeProxyAgent");
});
it("falls back to HTTP_PROXY when HTTPS_PROXY is not set", () => {
process.env.HTTP_PROXY = "http://proxy.example.com:3128";
const options = resolveSlackWebClientOptions();
expect(requireAgent(options).constructor.name).toBe("HttpsProxyAgent");
expect(requireAgent(options).constructor.name).toBe("ProxylineNodeProxyAgent");
});
it("does not set agent when no proxy env var is configured", () => {
@@ -260,10 +260,12 @@ describe("slack proxy agent", () => {
const options = resolveSlackWebClientOptions();
const agent = requireAgent(options);
// HttpsProxyAgent stores the proxy URL — verify it picked the lower-case one
expect((agent as unknown as { proxy: { href: string } }).proxy.href).toContain(
"lower.example.com",
);
// Proxyline stores the effective proxy URL in its resolver.
expect(
(agent as unknown as { getProxyForUrl: (url: string) => string }).getProxyForUrl(
"https://slack.com/",
),
).toContain("lower.example.com");
});
it("treats empty lowercase https_proxy as authoritative over uppercase", () => {
@@ -279,7 +281,7 @@ describe("slack proxy agent", () => {
const options = resolveSlackWriteClientOptions();
const agent = requireAgent(options);
expect(agent.constructor.name).toBe("HttpsProxyAgent");
expect(agent.constructor.name).toBe("ProxylineNodeProxyAgent");
});
it("respects NO_PROXY excluding slack.com", () => {
@@ -319,7 +321,7 @@ describe("slack proxy agent", () => {
process.env.NO_PROXY = "localhost,.internal.corp";
const options = resolveSlackWebClientOptions();
expect(requireAgent(options).constructor.name).toBe("HttpsProxyAgent");
expect(requireAgent(options).constructor.name).toBe("ProxylineNodeProxyAgent");
});
it("degrades gracefully on malformed proxy URL", () => {

View File

@@ -10,7 +10,6 @@
"dependencies": {
"audio-decode": "2.2.3",
"baileys": "7.0.0-rc13",
"https-proxy-agent": "9.0.0",
"typebox": "1.1.38"
},
"peerDependencies": {
@@ -240,15 +239,6 @@
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"license": "MIT",
"engines": {
"node": ">= 20"
}
},
"node_modules/async-mutex": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
@@ -434,19 +424,6 @@
"integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz",
"integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==",
"license": "MIT",
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",

View File

@@ -10,7 +10,6 @@
"dependencies": {
"audio-decode": "2.2.3",
"baileys": "7.0.0-rc13",
"https-proxy-agent": "9.0.0",
"typebox": "1.1.38"
},
"devDependencies": {

View File

@@ -414,7 +414,10 @@ describe("web session", () => {
await createWaSocket(false, false);
const passed = readLastSocketOptions();
const agent = requireValue(passed.agent, "WebSocket proxy agent");
const agent = requireValue(
passed.agent as { constructor: { name: string } } | undefined,
"WebSocket proxy agent",
);
const fetchAgent = requireValue(passed.fetchAgent, "fetch proxy agent");
expect(fetchAgent).not.toBe(agent);
expect(typeof (fetchAgent as { dispatch?: unknown }).dispatch).toBe("function");
@@ -430,10 +433,10 @@ describe("web session", () => {
const passed = readLastSocketOptions();
const agent = requireValue(
passed.agent as { connectOpts?: { ca?: unknown } } | undefined,
passed.agent as { constructor: { name: string } } | undefined,
"WebSocket proxy agent",
);
expect(agent.connectOpts?.ca).toBe("whatsapp-managed-proxy-ca");
expect(agent.constructor.name).toBe("ProxylineNodeProxyAgent");
expect(proxyAgentCtor).toHaveBeenCalledWith(
expect.objectContaining({
proxyTls: expect.objectContaining({ ca: "whatsapp-managed-proxy-ca" }),
@@ -466,10 +469,10 @@ describe("web session", () => {
await createWaSocket(false, false);
const agent = requireValue(
readLastSocketOptions().agent as { proxy?: URL } | undefined,
readLastSocketOptions().agent as { getProxyForUrl?: (url: string) => string } | undefined,
"WebSocket proxy agent",
);
expect(agent.proxy?.href).toContain("lower-proxy.test");
expect(agent.getProxyForUrl?.("https://mmg.whatsapp.net/")).toContain("lower-proxy.test");
});
it("skips WA WebSocket env proxy agent when NO_PROXY covers WhatsApp Web", async () => {

View File

@@ -1,14 +1,11 @@
import { randomUUID } from "node:crypto";
import type { Agent } from "node:https";
import { HttpsProxyAgent } from "https-proxy-agent";
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
import { VERSION } from "openclaw/plugin-sdk/cli-runtime";
import {
createHttp1EnvHttpProxyAgent,
createHttp1ProxyAgent,
resolveActiveManagedProxyTlsOptions,
resolveEnvHttpProxyUrl,
shouldUseEnvHttpProxyForUrl,
createNodeProxyAgent,
} from "openclaw/plugin-sdk/fetch-runtime";
import { danger, success } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger, toPinoLikeLogger } from "openclaw/plugin-sdk/runtime-env";
@@ -235,17 +232,15 @@ export async function createWaSocket(
async function resolveEnvProxyAgent(
logger: ReturnType<typeof getChildLogger>,
): Promise<Agent | undefined> {
if (!shouldUseEnvHttpProxyForUrl(WHATSAPP_WEBSOCKET_PROXY_TARGET)) {
return undefined;
}
const proxyUrl = resolveEnvHttpProxyUrl("https");
if (!proxyUrl) {
return undefined;
}
const proxyTls = resolveActiveManagedProxyTlsOptions({ proxyUrl });
const proxyAgentOptions = proxyTls?.ca ? { ca: proxyTls.ca } : undefined;
try {
const agent = new HttpsProxyAgent(proxyUrl, proxyAgentOptions) as Agent;
const agent = createNodeProxyAgent({
mode: "env",
targetUrl: WHATSAPP_WEBSOCKET_PROXY_TARGET,
protocol: "https",
}) as Agent | undefined;
if (!agent) {
return undefined;
}
logger.info("Using ambient env proxy for WhatsApp WebSocket connection");
return agent;
} catch (error) {
@@ -278,6 +273,15 @@ async function resolveEnvFetchDispatcher(
}
function resolveProxyUrlFromAgent(agent: unknown): string | undefined {
if (
typeof agent === "object" &&
agent !== null &&
"getProxyForUrl" in agent &&
typeof agent.getProxyForUrl === "function"
) {
const proxyUrl = agent.getProxyForUrl(WHATSAPP_WEBSOCKET_PROXY_TARGET);
return typeof proxyUrl === "string" && proxyUrl.length > 0 ? proxyUrl : undefined;
}
if (typeof agent !== "object" || agent === null || !("proxy" in agent)) {
return undefined;
}

101
npm-shrinkwrap.json generated
View File

@@ -40,8 +40,6 @@
"grammy": "1.43.0",
"highlight.js": "11.11.1",
"hosted-git-info": "9.0.3",
"http-proxy-agent": "9.0.0",
"https-proxy-agent": "9.0.0",
"ignore": "7.0.5",
"ipaddr.js": "2.4.0",
"jiti": "2.7.0",
@@ -992,12 +990,12 @@
}
},
"node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"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": ">= 20"
"node": ">= 14"
}
},
"node_modules/ajv": {
@@ -1965,28 +1963,6 @@
"node": ">=18"
}
},
"node_modules/gaxios/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"
}
},
"node_modules/gaxios/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"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
@@ -2275,30 +2251,17 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/http-proxy-agent": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz",
"integrity": "sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==",
"license": "MIT",
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/https-proxy-agent": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz",
"integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==",
"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": "9.0.0",
"debug": "^4.3.4"
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 20"
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
@@ -2787,28 +2750,6 @@
"node-edge-tts": "bin.js"
}
},
"node_modules/node-edge-tts/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"
}
},
"node_modules/node-edge-tts/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"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
@@ -4010,28 +3951,6 @@
"node": ">= 16"
}
},
"node_modules/web-push/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"
}
},
"node_modules/web-push/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"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",

View File

@@ -1834,8 +1834,8 @@
"@openclaw/proxyline": "0.3.3",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"clawpdf": "0.2.0",
"chokidar": "5.0.0",
"clawpdf": "0.2.0",
"commander": "14.0.3",
"croner": "10.0.1",
"cross-spawn": "7.0.6",
@@ -1847,8 +1847,6 @@
"grammy": "1.43.0",
"highlight.js": "11.11.1",
"hosted-git-info": "9.0.3",
"http-proxy-agent": "9.0.0",
"https-proxy-agent": "9.0.0",
"ignore": "7.0.5",
"ipaddr.js": "2.4.0",
"jiti": "2.7.0",

43
pnpm-lock.yaml generated
View File

@@ -131,12 +131,6 @@ importers:
hosted-git-info:
specifier: 9.0.3
version: 9.0.3
http-proxy-agent:
specifier: 9.0.0
version: 9.0.0
https-proxy-agent:
specifier: 9.0.0
version: 9.0.0
ignore:
specifier: 7.0.5
version: 7.0.5
@@ -666,9 +660,6 @@ importers:
discord-api-types:
specifier: 0.38.48
version: 0.38.48
https-proxy-agent:
specifier: 9.0.0
version: 9.0.0
libopus-wasm:
specifier: 0.1.0
version: 0.1.0
@@ -1437,9 +1428,6 @@ importers:
'@slack/web-api':
specifier: 7.16.0
version: 7.16.0
https-proxy-agent:
specifier: 9.0.0
version: 9.0.0
typebox:
specifier: 1.1.38
version: 1.1.38
@@ -1681,9 +1669,6 @@ importers:
baileys:
specifier: 7.0.0-rc13
version: 7.0.0-rc13(audio-decode@2.2.3)(sharp@0.34.5)
https-proxy-agent:
specifier: 9.0.0
version: 9.0.0
typebox:
specifier: 1.1.38
version: 1.1.38
@@ -4248,10 +4233,6 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
agent-base@9.0.0:
resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==}
engines: {node: '>= 20'}
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
@@ -5193,10 +5174,6 @@ packages:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
http-proxy-agent@9.0.0:
resolution: {integrity: sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==}
engines: {node: '>= 20'}
http_ece@1.2.0:
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
engines: {node: '>=16'}
@@ -5205,10 +5182,6 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
https-proxy-agent@9.0.0:
resolution: {integrity: sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==}
engines: {node: '>= 20'}
human-signals@1.1.1:
resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==}
engines: {node: '>=8.12.0'}
@@ -9838,8 +9811,6 @@ snapshots:
agent-base@7.1.4: {}
agent-base@9.0.0: {}
ajv-formats@3.0.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
@@ -10883,13 +10854,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
http-proxy-agent@9.0.0:
dependencies:
agent-base: 9.0.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
http_ece@1.2.0: {}
https-proxy-agent@7.0.6:
@@ -10899,13 +10863,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
https-proxy-agent@9.0.0:
dependencies:
agent-base: 9.0.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
human-signals@1.1.1: {}
iconv-lite@0.7.2:

View File

@@ -77,11 +77,6 @@
"class": "core-runtime",
"risk": ["network", "proxy"]
},
"https-proxy-agent": {
"owner": "core:proxy",
"class": "core-runtime",
"risk": ["network", "proxy"]
},
"ipaddr.js": {
"owner": "core:ssrf-guard",
"class": "core-runtime",

View File

@@ -0,0 +1,177 @@
import type { Agent as HttpAgent } from "node:http";
import { createAmbientNodeProxyAgent } from "@openclaw/proxyline";
import { matchesNoProxy, resolveEnvHttpProxyAgentOptions } from "./proxy-env.js";
import { resolveActiveManagedProxyTlsOptions } from "./proxy/managed-proxy-undici.js";
export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE =
"Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL.";
type NodeProxyProtocol = "http" | "https";
type ProxylineAgentOptions = NonNullable<Parameters<typeof createAmbientNodeProxyAgent>[0]>;
type ProxylineEnvSnapshot = NonNullable<ProxylineAgentOptions["env"]>;
type ProxylineTlsOptions = ProxylineAgentOptions["proxyTls"];
export type CreateNodeProxyAgentOptions =
| {
mode: "env";
targetUrl: string | URL;
protocol?: NodeProxyProtocol;
}
| {
mode: "explicit";
proxyUrl: string | URL;
protocol?: NodeProxyProtocol;
};
function inferTargetProtocol(targetUrl: string | URL): NodeProxyProtocol | undefined {
const parsed = parseTargetUrl(targetUrl);
if (parsed === undefined) {
return undefined;
}
if (parsed.protocol === "http:" || parsed.protocol === "ws:") {
return "http";
}
if (parsed.protocol === "https:" || parsed.protocol === "wss:") {
return "https";
}
return undefined;
}
function parseTargetUrl(targetUrl: string | URL): URL | undefined {
let parsed: URL;
try {
parsed = targetUrl instanceof URL ? targetUrl : new URL(targetUrl);
} catch {
return undefined;
}
return parsed;
}
function formatNoProxyTargetUrl(targetUrl: string | URL): string | undefined {
const target = parseTargetUrl(targetUrl);
if (target === undefined) {
return undefined;
}
const parsed = new URL(target.href);
if (parsed.protocol === "ws:") {
parsed.protocol = "http:";
} else if (parsed.protocol === "wss:") {
parsed.protocol = "https:";
}
return parsed.href;
}
function proxyUrlWithDefaultScheme(proxyUrl: string, protocol: NodeProxyProtocol): URL {
const withScheme = proxyUrl.includes("://") ? proxyUrl : `${protocol}://${proxyUrl}`;
let parsed: URL;
try {
parsed = new URL(withScheme);
} catch (error) {
throw new Error(
`Invalid proxy URL ${JSON.stringify(proxyUrl)}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`${UNSUPPORTED_PROXY_PROTOCOL_MESSAGE} Got ${parsed.protocol}`);
}
return parsed;
}
function fixedProxyEnv(proxyUrl: URL): ProxylineEnvSnapshot {
const href = proxyUrl.href;
return {
HTTP_PROXY: href,
HTTPS_PROXY: href,
ALL_PROXY: undefined,
NO_PROXY: undefined,
http_proxy: undefined,
https_proxy: undefined,
all_proxy: undefined,
no_proxy: undefined,
};
}
export function resolveEnvNodeProxyUrlForTarget(
targetUrl: string | URL,
env: NodeJS.ProcessEnv = process.env,
): URL | undefined {
const protocol = inferTargetProtocol(targetUrl);
if (protocol === undefined) {
return undefined;
}
const formattedTarget = formatNoProxyTargetUrl(targetUrl);
if (formattedTarget === undefined) {
return undefined;
}
if (matchesNoProxy(formattedTarget, env)) {
return undefined;
}
const proxyOptions = resolveEnvHttpProxyAgentOptions(env);
const proxyUrl = protocol === "https" ? proxyOptions?.httpsProxy : proxyOptions?.httpProxy;
return proxyUrl ? proxyUrlWithDefaultScheme(proxyUrl, protocol) : undefined;
}
function createFixedNodeProxyAgent(
proxyUrl: string | URL,
options: {
protocol?: NodeProxyProtocol;
proxyTls?: ProxylineTlsOptions;
} = {},
): HttpAgent {
const parsedProxyUrl =
proxyUrl instanceof URL
? proxyUrl
: proxyUrlWithDefaultScheme(proxyUrl, options.protocol ?? "https");
const agent = createAmbientNodeProxyAgent({
env: fixedProxyEnv(parsedProxyUrl),
protocol: options.protocol ?? "https",
...(options.proxyTls !== undefined ? { proxyTls: options.proxyTls } : {}),
});
if (agent === undefined) {
throw new Error(`${UNSUPPORTED_PROXY_PROTOCOL_MESSAGE} Got ${parsedProxyUrl.protocol}`);
}
return agent as HttpAgent;
}
export function createNodeProxyAgent(
options: Extract<CreateNodeProxyAgentOptions, { mode: "explicit" }>,
): HttpAgent;
export function createNodeProxyAgent(
options: Extract<CreateNodeProxyAgentOptions, { mode: "env" }>,
): HttpAgent | undefined;
export function createNodeProxyAgent(options: CreateNodeProxyAgentOptions): HttpAgent | undefined {
if (options.mode === "explicit") {
return createFixedNodeProxyAgent(options.proxyUrl, { protocol: options.protocol });
}
return createEnvNodeProxyAgentForTarget(options.targetUrl, { protocol: options.protocol });
}
function createEnvNodeProxyAgentForTarget(
targetUrl: string | URL,
options: {
protocol?: NodeProxyProtocol;
} = {},
): HttpAgent | undefined {
const proxyUrl = resolveEnvNodeProxyUrlForTarget(targetUrl);
if (proxyUrl === undefined) {
return undefined;
}
return createFixedNodeProxyAgent(proxyUrl, {
protocol: options.protocol ?? inferTargetProtocol(targetUrl) ?? "https",
proxyTls: resolveActiveManagedProxyTlsOptions({ proxyUrl: proxyUrl.href }),
});
}
export function createFixedNodeProxyAgentPair(proxyUrl: string | URL): {
httpAgent: HttpAgent;
httpsAgent: HttpAgent;
} {
const parsedProxyUrl =
proxyUrl instanceof URL ? proxyUrl : proxyUrlWithDefaultScheme(proxyUrl, "https");
const proxyTls = resolveActiveManagedProxyTlsOptions({ proxyUrl: parsedProxyUrl.href });
return {
httpAgent: createFixedNodeProxyAgent(parsedProxyUrl, { protocol: "http", proxyTls }),
httpsAgent: createFixedNodeProxyAgent(parsedProxyUrl, { protocol: "https", proxyTls }),
};
}

View File

@@ -1,5 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveHttpProxyUrlForTarget } from "./node-http-proxy.js";
import {
createHttpProxyAgentsForTarget,
resolveHttpProxyUrlForTarget,
UNSUPPORTED_PROXY_PROTOCOL_MESSAGE,
} from "./node-http-proxy.js";
function clearProxyEnv(): void {
for (const key of [
@@ -12,7 +16,7 @@ function clearProxyEnv(): void {
"no_proxy",
"NO_PROXY",
]) {
vi.stubEnv(key, "");
vi.stubEnv(key, undefined);
}
}
@@ -39,4 +43,54 @@ describe("node HTTP proxy resolution", () => {
"http://proxy.example:8080/",
);
});
it("honors default WebSocket ports in NO_PROXY", () => {
clearProxyEnv();
vi.stubEnv("HTTPS_PROXY", "http://proxy.example:8080");
vi.stubEnv("NO_PROXY", "web.whatsapp.com:443");
expect(resolveHttpProxyUrlForTarget("wss://web.whatsapp.com/ws")).toBeUndefined();
});
it("does not mutate URL inputs when normalizing WebSocket targets", () => {
clearProxyEnv();
vi.stubEnv("HTTPS_PROXY", "http://proxy.example:8080");
vi.stubEnv("NO_PROXY", "web.whatsapp.com:443");
const target = new URL("wss://web.whatsapp.com/ws");
expect(resolveHttpProxyUrlForTarget(target)).toBeUndefined();
expect(target.href).toBe("wss://web.whatsapp.com/ws");
});
it("uses Proxyline Node agents for resolved env proxies", () => {
clearProxyEnv();
vi.stubEnv("HTTPS_PROXY", "http://proxy.example:8080");
const agents = createHttpProxyAgentsForTarget("https://api.example.test/v1");
expect(agents?.httpsAgent.constructor.name).toBe("ProxylineNodeProxyAgent");
expect(
(
agents?.httpsAgent as { getProxyForUrl?: (url: string) => string } | undefined
)?.getProxyForUrl?.("https://api.example.test/v1"),
).toBe("http://proxy.example:8080/");
});
it("falls back to ALL_PROXY for Node agent proxy resolution", () => {
clearProxyEnv();
vi.stubEnv("ALL_PROXY", "http://proxy.example:8080");
expect(resolveHttpProxyUrlForTarget("https://api.example.test/v1")?.href).toBe(
"http://proxy.example:8080/",
);
});
it("rejects unsupported env proxy protocols", () => {
clearProxyEnv();
vi.stubEnv("HTTPS_PROXY", "socks5://proxy.example:1080");
expect(() => resolveHttpProxyUrlForTarget("https://api.example.test/v1")).toThrow(
UNSUPPORTED_PROXY_PROTOCOL_MESSAGE,
);
});
});

View File

@@ -1,147 +1,20 @@
import type { Agent as HttpAgent } from "node:http";
import type { Agent as HttpsAgent } from "node:https";
import { HttpProxyAgent } from "http-proxy-agent";
import { HttpsProxyAgent } from "https-proxy-agent";
const DEFAULT_PROXY_PORTS: Record<string, number> = {
ftp: 21,
gopher: 70,
http: 80,
https: 443,
ws: 80,
wss: 443,
};
import {
createFixedNodeProxyAgentPair,
resolveEnvNodeProxyUrlForTarget,
UNSUPPORTED_PROXY_PROTOCOL_MESSAGE,
} from "../../infra/net/node-proxy-agent.js";
export interface NodeHttpProxyAgents {
httpAgent: HttpAgent;
httpsAgent: HttpsAgent;
}
export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE =
"Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL.";
function getProxyEnv(key: string): string {
return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || "";
}
function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined {
if (targetUrl instanceof URL) {
return targetUrl;
}
try {
return new URL(targetUrl);
} catch {
return undefined;
}
}
function normalizeProxyHostname(hostname: string): string {
let normalized = hostname.toLowerCase();
if (normalized.startsWith("[") && normalized.endsWith("]")) {
normalized = normalized.slice(1, -1);
}
return normalized.endsWith(".") ? normalized.slice(0, -1) : normalized;
}
function parseNoProxyEntry(entry: string): { hostname: string; port: number } {
const bracketed = entry.match(/^\[([^\]]+)\](?::(\d+))?$/);
if (bracketed) {
return {
hostname: normalizeProxyHostname(bracketed[1] ?? ""),
port: bracketed[2] ? Number.parseInt(bracketed[2], 10) : 0,
};
}
const firstColon = entry.indexOf(":");
const lastColon = entry.lastIndexOf(":");
if (firstColon > -1 && firstColon === lastColon) {
const portRaw = entry.slice(lastColon + 1);
if (/^\d+$/.test(portRaw)) {
return {
hostname: normalizeProxyHostname(entry.slice(0, lastColon)),
port: Number.parseInt(portRaw, 10),
};
}
}
return { hostname: normalizeProxyHostname(entry), port: 0 };
}
function shouldProxyHostname(hostname: string, port: number): boolean {
const normalizedHostname = normalizeProxyHostname(hostname);
const noProxy = getProxyEnv("no_proxy").toLowerCase();
if (!noProxy) {
return true;
}
if (noProxy === "*") {
return false;
}
return noProxy.split(/[,\s]/).every((proxy) => {
if (!proxy) {
return true;
}
const parsedProxy = parseNoProxyEntry(proxy);
let proxyHostname = parsedProxy.hostname;
const proxyPort = parsedProxy.port;
if (proxyPort && proxyPort !== port) {
return true;
}
if (!/^[.*]/.test(proxyHostname)) {
return normalizedHostname !== proxyHostname;
}
if (proxyHostname.startsWith("*")) {
proxyHostname = proxyHostname.slice(1);
}
return !normalizedHostname.endsWith(proxyHostname);
});
}
function getProxyForUrl(targetUrl: string | URL): string {
const parsedUrl = parseProxyTargetUrl(targetUrl);
if (!parsedUrl?.protocol || !parsedUrl.host) {
return "";
}
const protocol = parsedUrl.protocol.split(":", 1)[0];
const hostname = parsedUrl.hostname;
const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0;
if (!shouldProxyHostname(hostname, port)) {
return "";
}
let proxy = getProxyEnv(`${protocol}_proxy`) || getProxyEnv("all_proxy");
if (proxy && !proxy.includes("://")) {
proxy = `${protocol}://${proxy}`;
}
return proxy;
}
export { UNSUPPORTED_PROXY_PROTOCOL_MESSAGE };
export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | undefined {
const proxy = getProxyForUrl(targetUrl);
if (!proxy) {
return undefined;
}
let proxyUrl: URL;
try {
proxyUrl = new URL(proxy);
} catch (error) {
throw new Error(
`Invalid proxy URL ${JSON.stringify(proxy)}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") {
throw new Error(`${UNSUPPORTED_PROXY_PROTOCOL_MESSAGE} Got ${proxyUrl.protocol}`);
}
return proxyUrl;
return resolveEnvNodeProxyUrlForTarget(targetUrl);
}
export function createHttpProxyAgentsForTarget(
@@ -152,8 +25,5 @@ export function createHttpProxyAgentsForTarget(
return undefined;
}
return {
httpAgent: new HttpProxyAgent(proxyUrl),
httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent,
};
return createFixedNodeProxyAgentPair(proxyUrl) as NodeHttpProxyAgents;
}

View File

@@ -10,6 +10,10 @@ export {
addActiveManagedProxyTlsOptions,
resolveActiveManagedProxyTlsOptions,
} from "../infra/net/proxy/managed-proxy-undici.js";
export {
createNodeProxyAgent,
type CreateNodeProxyAgentOptions,
} from "../infra/net/node-proxy-agent.js";
export {
hasEnvHttpProxyConfigured,
hasEnvHttpProxyAgentConfigured,