mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 13:34:49 +00:00
Support HTTPS managed proxy CA trust (#79171)
* fix: support HTTPS managed proxy CA trust * fix: strip IP SNI for HTTPS proxy dispatchers * fix: harden managed proxy undici dispatchers * docs: refresh proxy baselines * fix: drop stale whatsapp undici dependency * fix: satisfy proxy dispatcher lint and tests --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/skills: apply the full effective tool policy pipeline to inline `command-dispatch: tool` skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
23eb36c39b4e1b0b711211fd946d23579a3f87cb0aee3c0701e5f9447c3c2527 config-baseline.json
|
||||
ec106c0eb093aaaa1382733d4a67ac9f3d4b9bbcadda3a3d5a56d70f1aea2a97 config-baseline.core.json
|
||||
1a4ff6c148f4c28eb2c07c77025c6ba13ed9f56d23bbb221fc6dd83781fda671 config-baseline.json
|
||||
a2663c4aed132ae968e8e6ef84566d22063143f8b093e839e1063393135842f5 config-baseline.core.json
|
||||
fe4f1cb00d7d1dee9746779ec3cf14236e5f672c91502268a12ad6e467a2c4ad config-baseline.channel.json
|
||||
e9049ce0154f484f44bb0ac174a44198269256044da5ba62a6e107e78bfd7a70 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
2c665b045d30f690c5fd6adb89481a003d5cc55ab4eed1a0456ef47136f6b684 plugin-sdk-api-baseline.json
|
||||
f4b6c016576cd19409356ef23d18da0e54cb6c5904f864049461ace921e1f72c plugin-sdk-api-baseline.jsonl
|
||||
eee268e3221ea1d909bda308a94514846231db842e55fa0e26d7136b1c25be03 plugin-sdk-api-baseline.json
|
||||
5d5a8b338c223d8857edd431325ceb0eca4024a73a7b7e3e4a106f8a18c35c30 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -23,7 +23,7 @@ captured blobs, and purge local capture data.
|
||||
```bash
|
||||
openclaw proxy start [--host <host>] [--port <port>]
|
||||
openclaw proxy run [--host <host>] [--port <port>] -- <cmd...>
|
||||
openclaw proxy validate [--json] [--proxy-url <url>] [--allowed-url <url>] [--denied-url <url>] [--apns-reachable] [--apns-authority <url>] [--timeout-ms <ms>]
|
||||
openclaw proxy validate [--json] [--proxy-url <url>] [--proxy-ca-file <path>] [--allowed-url <url>] [--denied-url <url>] [--apns-reachable] [--apns-authority <url>] [--timeout-ms <ms>]
|
||||
openclaw proxy coverage
|
||||
openclaw proxy sessions [--limit <count>]
|
||||
openclaw proxy query --preset <name> [--session <id>]
|
||||
@@ -34,21 +34,26 @@ openclaw proxy purge
|
||||
## Validate
|
||||
|
||||
`openclaw proxy validate` checks the effective operator-managed proxy URL from
|
||||
`--proxy-url`, config, or `OPENCLAW_PROXY_URL`. It reports a config problem when
|
||||
no proxy is enabled and configured; use `--proxy-url` for a one-off preflight
|
||||
before changing config. By default it verifies that a public destination succeeds
|
||||
through the proxy and that the proxy cannot reach a temporary loopback canary.
|
||||
Custom denied destinations are fail-closed: HTTP responses and ambiguous
|
||||
transport failures both fail unless you can verify a deployment-specific denial
|
||||
signal separately. Add `--apns-reachable` to also open an APNs HTTP/2 CONNECT
|
||||
tunnel through the proxy and confirm sandbox APNs responds; the probe uses an
|
||||
intentionally invalid provider token, so an APNs `403 InvalidProviderToken`
|
||||
response is a successful reachability signal.
|
||||
`--proxy-url`, config, or `OPENCLAW_PROXY_URL`. Managed proxy URLs can use
|
||||
`http://` for a plain forward-proxy listener or `https://` when OpenClaw must
|
||||
open TLS to the proxy endpoint before sending proxy requests. It reports a
|
||||
config problem when no proxy is enabled and configured; use `--proxy-url` for a
|
||||
one-off preflight before changing config. Add `--proxy-ca-file` to trust a
|
||||
private CA for the TLS connection to an HTTPS proxy endpoint. By default it
|
||||
verifies that a public destination succeeds through the proxy and that the proxy
|
||||
cannot reach a temporary loopback canary. Custom denied destinations are
|
||||
fail-closed: HTTP responses and ambiguous transport failures both fail unless
|
||||
you can verify a deployment-specific denial signal separately. Add
|
||||
`--apns-reachable` to also open an APNs HTTP/2 CONNECT tunnel through the proxy
|
||||
and confirm sandbox APNs responds; the probe uses an intentionally invalid
|
||||
provider token, so an APNs `403 InvalidProviderToken` response is a successful
|
||||
reachability signal.
|
||||
|
||||
Options:
|
||||
|
||||
- `--json`: print machine-readable JSON.
|
||||
- `--proxy-url <url>`: validate this proxy URL instead of config or env.
|
||||
- `--proxy-url <url>`: validate this `http://` or `https://` proxy URL instead of config or env.
|
||||
- `--proxy-ca-file <path>`: trust this PEM CA file for TLS verification of an HTTPS proxy endpoint.
|
||||
- `--allowed-url <url>`: add a destination expected to succeed through the proxy. Repeat to check multiple destinations.
|
||||
- `--denied-url <url>`: add a destination expected to be blocked by the proxy. Repeat to check multiple destinations.
|
||||
- `--apns-reachable`: also verify sandbox APNs HTTP/2 is reachable through the proxy.
|
||||
|
||||
@@ -40,7 +40,12 @@ Internally, OpenClaw installs Proxyline as the process-level routing runtime for
|
||||
|
||||
Some plugins own custom transports that need explicit proxy wiring even when process-level routing exists. For example, Telegram's Bot API transport uses its own HTTP/1 undici dispatcher and therefore honors process proxy env plus the managed `OPENCLAW_PROXY_URL` fallback in that owner-specific transport path.
|
||||
|
||||
The proxy URL itself must use `http://`. HTTPS destinations are still supported through the proxy with HTTP `CONNECT`; this only means OpenClaw expects a plain HTTP forward-proxy listener such as `http://127.0.0.1:3128`.
|
||||
The proxy URL itself can use either `http://` or `https://`. These schemes describe the connection from OpenClaw to the proxy endpoint:
|
||||
|
||||
- `http://proxy.example:3128`: OpenClaw opens a plain TCP connection to the forward proxy and sends HTTP proxy requests, including `CONNECT` for HTTPS destinations.
|
||||
- `https://proxy.example:8443`: OpenClaw opens TLS to the proxy endpoint, verifies the proxy certificate, and then sends HTTP proxy requests inside that TLS session.
|
||||
|
||||
Destination HTTPS is separate from proxy endpoint TLS. For an HTTPS destination, OpenClaw still asks the proxy for an HTTP `CONNECT` tunnel and then starts destination TLS through that tunnel.
|
||||
|
||||
While the proxy is active, OpenClaw clears `no_proxy` and `NO_PROXY`. Those bypass lists are destination-based, so leaving `localhost` or `127.0.0.1` there would let high-risk SSRF targets skip the filtering proxy.
|
||||
|
||||
@@ -62,6 +67,16 @@ proxy:
|
||||
proxyUrl: http://127.0.0.1:3128
|
||||
```
|
||||
|
||||
For an HTTPS proxy endpoint with a private proxy CA:
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
enabled: true
|
||||
proxyUrl: https://proxy.corp.example:8443
|
||||
tls:
|
||||
caFile: /etc/openclaw/proxy-ca.pem
|
||||
```
|
||||
|
||||
You can also provide the URL through the environment, while keeping `proxy.enabled=true` in config:
|
||||
|
||||
```bash
|
||||
@@ -150,6 +165,12 @@ Validate the proxy from the same host, container, or service account that runs O
|
||||
openclaw proxy validate --proxy-url http://127.0.0.1:3128
|
||||
```
|
||||
|
||||
For an HTTPS proxy endpoint signed by a private CA:
|
||||
|
||||
```bash
|
||||
openclaw proxy validate --proxy-url https://proxy.corp.example:8443 --proxy-ca-file /etc/openclaw/proxy-ca.pem
|
||||
```
|
||||
|
||||
By default, when no custom destinations are provided, the command checks that `https://example.com/` succeeds and starts a temporary loopback canary that the proxy must not reach. The default denied check passes when the proxy returns a non-2xx denial response or blocks the canary with a transport failure; it fails if a successful response reaches the canary. If no proxy is enabled and configured, validation reports a config problem; use `--proxy-url` for a one-off preflight before changing config. Use `--allowed-url` and `--denied-url` to test deployment-specific expectations. Add `--apns-reachable` to also verify direct APNs HTTP/2 delivery can open a CONNECT tunnel through the proxy and receive a sandbox APNs response; the probe uses an intentionally invalid provider token, so `403 InvalidProviderToken` is expected and counts as reachable. Custom denied destinations are fail-closed: any HTTP response means the destination was reachable through the proxy, and any transport error is reported as inconclusive because OpenClaw cannot prove the proxy blocked a reachable origin. On validation failure, the command exits with code 1.
|
||||
|
||||
Use `--json` for automation. The JSON output contains the overall result, the effective proxy config source, any config errors, and each destination check. Proxy URL credentials are redacted in text and JSON output:
|
||||
@@ -190,11 +211,28 @@ curl -x http://127.0.0.1:3128 http://169.254.169.254/
|
||||
|
||||
The public request should succeed. The loopback and metadata requests should be blocked by the proxy. For `openclaw proxy validate`, the built-in loopback canary can distinguish a proxy denial from a reachable origin. Custom `--denied-url` checks do not have that canary, so treat both HTTP responses and ambiguous transport failures as validation failures unless your proxy exposes a deployment-specific denial signal you can verify separately.
|
||||
|
||||
## Proxy CA trust
|
||||
|
||||
Use managed `proxy.tls.caFile` when the proxy endpoint itself uses a certificate signed by a private CA:
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
enabled: true
|
||||
proxyUrl: https://proxy.corp.example:8443
|
||||
tls:
|
||||
caFile: /etc/openclaw/proxy-ca.pem
|
||||
```
|
||||
|
||||
That CA is used for TLS verification of the proxy endpoint. It is not a destination MITM trust setting, a client certificate, or a replacement for the proxy's destination policy.
|
||||
|
||||
Use `NODE_EXTRA_CA_CERTS` only when the whole Node process must trust an additional CA from process startup, such as when an enterprise TLS inspection system re-signs destination certificates for every HTTPS client in the process. `NODE_EXTRA_CA_CERTS` is process-global and must be present before Node starts. Prefer `proxy.tls.caFile` for HTTPS proxy endpoint trust because it is scoped to managed proxy routing.
|
||||
|
||||
Then enable OpenClaw proxy routing:
|
||||
|
||||
```bash
|
||||
openclaw config set proxy.enabled true
|
||||
openclaw config set proxy.proxyUrl http://127.0.0.1:3128
|
||||
openclaw config set proxy.proxyUrl https://proxy.corp.example:8443
|
||||
openclaw config set proxy.tls.caFile /etc/openclaw/proxy-ca.pem
|
||||
openclaw gateway run
|
||||
```
|
||||
|
||||
@@ -203,7 +241,9 @@ or set:
|
||||
```yaml
|
||||
proxy:
|
||||
enabled: true
|
||||
proxyUrl: http://127.0.0.1:3128
|
||||
proxyUrl: https://proxy.corp.example:8443
|
||||
tls:
|
||||
caFile: /etc/openclaw/proxy-ca.pem
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { undiciFetchMock, agentSpy, proxyAgentSpy } = vi.hoisted(() => ({
|
||||
const { undiciFetchMock, agentSpy, envHttpProxyAgentSpy, proxyAgentSpy } = vi.hoisted(() => ({
|
||||
undiciFetchMock: vi.fn(),
|
||||
agentSpy: vi.fn(),
|
||||
envHttpProxyAgentSpy: vi.fn(),
|
||||
proxyAgentSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__";
|
||||
|
||||
vi.mock("undici", () => {
|
||||
class Agent {
|
||||
options: unknown;
|
||||
@@ -14,6 +20,23 @@ vi.mock("undici", () => {
|
||||
agentSpy(options);
|
||||
}
|
||||
}
|
||||
class EnvHttpProxyAgent {
|
||||
options: unknown;
|
||||
constructor(options?: unknown) {
|
||||
if (
|
||||
typeof options === "object" &&
|
||||
options !== null &&
|
||||
("httpsProxy" in options || "httpProxy" in options)
|
||||
) {
|
||||
const proxyOptions = options as { httpsProxy?: unknown; httpProxy?: unknown };
|
||||
if (proxyOptions.httpsProxy === "bad-proxy" || proxyOptions.httpProxy === "bad-proxy") {
|
||||
throw new Error("bad env proxy");
|
||||
}
|
||||
}
|
||||
this.options = options;
|
||||
envHttpProxyAgentSpy(options);
|
||||
}
|
||||
}
|
||||
class ProxyAgent {
|
||||
options: unknown;
|
||||
uri: string;
|
||||
@@ -29,6 +52,7 @@ vi.mock("undici", () => {
|
||||
}
|
||||
return {
|
||||
Agent,
|
||||
EnvHttpProxyAgent,
|
||||
ProxyAgent,
|
||||
fetch: undiciFetchMock,
|
||||
};
|
||||
@@ -67,18 +91,105 @@ function recordField(value: unknown, field: string): Record<string, unknown> {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function installUndiciRuntimeDeps(): void {
|
||||
class Agent {
|
||||
options: unknown;
|
||||
constructor(options?: unknown) {
|
||||
this.options = options;
|
||||
agentSpy(options);
|
||||
}
|
||||
}
|
||||
class EnvHttpProxyAgent {
|
||||
options: unknown;
|
||||
constructor(options?: unknown) {
|
||||
if (
|
||||
typeof options === "object" &&
|
||||
options !== null &&
|
||||
("httpsProxy" in options || "httpProxy" in options)
|
||||
) {
|
||||
const proxyOptions = options as { httpsProxy?: unknown; httpProxy?: unknown };
|
||||
if (proxyOptions.httpsProxy === "bad-proxy" || proxyOptions.httpProxy === "bad-proxy") {
|
||||
throw new Error("bad env proxy");
|
||||
}
|
||||
}
|
||||
this.options = options;
|
||||
envHttpProxyAgentSpy(options);
|
||||
}
|
||||
}
|
||||
class ProxyAgent {
|
||||
options: unknown;
|
||||
uri: string;
|
||||
constructor(options: string | { uri: string; allowH2?: boolean }) {
|
||||
const resolved = typeof options === "string" ? { uri: options } : options;
|
||||
if (resolved.uri === "bad-proxy") {
|
||||
throw new Error("bad proxy");
|
||||
}
|
||||
this.options = resolved;
|
||||
this.uri = resolved.uri;
|
||||
proxyAgentSpy(resolved);
|
||||
}
|
||||
}
|
||||
class Pool {
|
||||
constructor(
|
||||
readonly origin: unknown,
|
||||
readonly options: unknown,
|
||||
) {}
|
||||
}
|
||||
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
|
||||
Agent,
|
||||
EnvHttpProxyAgent,
|
||||
Pool,
|
||||
ProxyAgent,
|
||||
fetch: undiciFetchMock,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveDiscordRestFetch", () => {
|
||||
const proxyEnvKeys = [
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
"NO_PROXY",
|
||||
"OPENCLAW_PROXY_ACTIVE",
|
||||
"OPENCLAW_PROXY_CA_FILE",
|
||||
] as const;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ resolveDiscordRestFetch } = await import("./rest-fetch.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
for (const key of proxyEnvKeys) {
|
||||
vi.stubEnv(key, "");
|
||||
}
|
||||
undiciFetchMock.mockReset();
|
||||
agentSpy.mockReset();
|
||||
envHttpProxyAgentSpy.mockReset();
|
||||
proxyAgentSpy.mockReset();
|
||||
installUndiciRuntimeDeps();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function writeTempCa(contents: string): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-discord-rest-proxy-ca-"));
|
||||
tempDirs.push(dir);
|
||||
const caFile = path.join(dir, "proxy-ca.pem");
|
||||
writeFileSync(caFile, contents, "utf8");
|
||||
return caFile;
|
||||
}
|
||||
|
||||
it("uses undici proxy fetch when a proxy URL is configured", async () => {
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
@@ -105,6 +216,31 @@ describe("resolveDiscordRestFetch", () => {
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses managed proxy CA trust when a configured REST proxy matches the managed proxy", async () => {
|
||||
const caFile = writeTempCa("discord-rest-configured-proxy-ca");
|
||||
vi.stubEnv("HTTPS_PROXY", "https://127.0.0.1:8443");
|
||||
vi.stubEnv("https_proxy", "https://127.0.0.1:8443");
|
||||
vi.stubEnv("OPENCLAW_PROXY_ACTIVE", "1");
|
||||
vi.stubEnv("OPENCLAW_PROXY_CA_FILE", caFile);
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as const;
|
||||
undiciFetchMock.mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
|
||||
const fetcher = resolveDiscordRestFetch("https://127.0.0.1:8443", runtime);
|
||||
await fetcher("https://discord.com/api/v10/oauth2/applications/@me");
|
||||
|
||||
const proxyOptions = objectArgAt(proxyAgentSpy, 0, 0);
|
||||
expect(proxyOptions.uri).toBe("https://127.0.0.1:8443");
|
||||
expect(recordField(proxyOptions.proxyTls, "proxyTls").ca).toBe(
|
||||
"discord-rest-configured-proxy-ca",
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith("discord: rest proxy enabled");
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to global fetch when proxy URL is invalid", () => {
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
@@ -180,6 +316,58 @@ describe("resolveDiscordRestFetch", () => {
|
||||
expect(runtime.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses managed env proxy CA trust when no discord proxy URL is configured", async () => {
|
||||
const caFile = writeTempCa("discord-rest-managed-proxy-ca");
|
||||
vi.stubEnv("HTTPS_PROXY", "https://proxy.example:8443");
|
||||
vi.stubEnv("https_proxy", "https://proxy.example:8443");
|
||||
vi.stubEnv("OPENCLAW_PROXY_ACTIVE", "1");
|
||||
vi.stubEnv("OPENCLAW_PROXY_CA_FILE", caFile);
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as const;
|
||||
undiciFetchMock.mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
|
||||
const fetcher = resolveDiscordRestFetch(undefined, runtime);
|
||||
await fetcher("https://discord.com/api/v10/oauth2/applications/@me");
|
||||
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
const proxyOptions = objectArgAt(envHttpProxyAgentSpy, 0, 0);
|
||||
expect(proxyOptions.httpsProxy).toBe("https://proxy.example:8443");
|
||||
expect(recordField(proxyOptions.proxyTls, "proxyTls").ca).toBe("discord-rest-managed-proxy-ca");
|
||||
const fetchOptions = objectArgAt(undiciFetchMock, 0, 1);
|
||||
const dispatcherOptions = recordField(
|
||||
recordField(fetchOptions.dispatcher, "dispatcher").options,
|
||||
"dispatcher.options",
|
||||
);
|
||||
expect(recordField(dispatcherOptions.proxyTls, "dispatcher.options.proxyTls").ca).toBe(
|
||||
"discord-rest-managed-proxy-ca",
|
||||
);
|
||||
expect(runtime.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to direct REST fetch when env proxy options are invalid", async () => {
|
||||
vi.stubEnv("https_proxy", "bad-proxy");
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as const;
|
||||
undiciFetchMock.mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
|
||||
const fetcher = resolveDiscordRestFetch(undefined, runtime);
|
||||
await fetcher("https://discord.com/api/v10/oauth2/applications/@me");
|
||||
|
||||
expect(envHttpProxyAgentSpy).not.toHaveBeenCalled();
|
||||
expect(agentSpy).toHaveBeenCalledTimes(1);
|
||||
const agentOptions = objectArgAt(agentSpy, 0, 0);
|
||||
expect(agentOptions.allowH2).toBe(false);
|
||||
expect(typeof recordField(agentOptions.connect, "connect").lookup).toBe("function");
|
||||
expect(String(argAt(runtime.error, 0, 0))).toContain("bad env proxy");
|
||||
expect(runtime.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses debug proxy env when no discord proxy URL is configured", async () => {
|
||||
vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "1");
|
||||
vi.stubEnv("OPENCLAW_DEBUG_PROXY_URL", "http://127.0.0.1:7777");
|
||||
|
||||
@@ -1,18 +1,57 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { wrapFetchWithAbortSignal } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
createHttp1EnvHttpProxyAgent,
|
||||
createHttp1ProxyAgent,
|
||||
resolveEnvHttpProxyAgentOptions,
|
||||
wrapFetchWithAbortSignal,
|
||||
} from "openclaw/plugin-sdk/fetch-runtime";
|
||||
import {
|
||||
captureHttpExchange,
|
||||
resolveEffectiveDebugProxyUrl,
|
||||
} from "openclaw/plugin-sdk/proxy-capture";
|
||||
import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
|
||||
import { danger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { Agent, ProxyAgent, fetch as undiciFetch } from "undici";
|
||||
import { Agent, fetch as undiciFetch } from "undici";
|
||||
import { createDiscordDnsLookup } from "../network-config.js";
|
||||
import { withValidatedDiscordProxy } from "../proxy-fetch.js";
|
||||
|
||||
const discordDnsLookup = createDiscordDnsLookup();
|
||||
|
||||
type DiscordRestDispatcher = InstanceType<typeof Agent> | InstanceType<typeof ProxyAgent>;
|
||||
type DiscordRestDispatcher =
|
||||
| InstanceType<typeof Agent>
|
||||
| ReturnType<typeof createHttp1EnvHttpProxyAgent>
|
||||
| ReturnType<typeof createHttp1ProxyAgent>;
|
||||
|
||||
function createDirectDiscordRestDispatcher(): InstanceType<typeof Agent> {
|
||||
return new Agent({
|
||||
allowH2: false,
|
||||
connect: { lookup: discordDnsLookup },
|
||||
});
|
||||
}
|
||||
|
||||
function createEnvProxyDiscordRestDispatcher(
|
||||
runtime: RuntimeEnv,
|
||||
): ReturnType<typeof createHttp1EnvHttpProxyAgent> | undefined {
|
||||
const envProxyOptions = resolveEnvHttpProxyAgentOptions();
|
||||
if (!envProxyOptions) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return createHttp1EnvHttpProxyAgent({
|
||||
...envProxyOptions,
|
||||
connect: { lookup: discordDnsLookup },
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(
|
||||
danger(
|
||||
`discord: env proxy unavailable for REST fetch; using direct dispatcher: ${formatErrorMessage(err)}`,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createDiscordRestFetchWithDispatcher(dispatcher: DiscordRestDispatcher): typeof fetch {
|
||||
return wrapFetchWithAbortSignal(((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
@@ -42,12 +81,7 @@ export function resolveDiscordRestFetch(
|
||||
const effectiveProxyUrl = resolveEffectiveDebugProxyUrl(proxyUrl);
|
||||
if (effectiveProxyUrl) {
|
||||
const fetcher = withValidatedDiscordProxy(effectiveProxyUrl, runtime, (proxy) =>
|
||||
createDiscordRestFetchWithDispatcher(
|
||||
new ProxyAgent({
|
||||
uri: proxy,
|
||||
allowH2: false,
|
||||
}),
|
||||
),
|
||||
createDiscordRestFetchWithDispatcher(createHttp1ProxyAgent({ uri: proxy })),
|
||||
);
|
||||
if (!fetcher) {
|
||||
return fetch;
|
||||
@@ -57,10 +91,7 @@ export function resolveDiscordRestFetch(
|
||||
}
|
||||
|
||||
const fetcher = createDiscordRestFetchWithDispatcher(
|
||||
new Agent({
|
||||
allowH2: false,
|
||||
connect: { lookup: discordDnsLookup },
|
||||
}),
|
||||
createEnvProxyDiscordRestDispatcher(runtime) ?? createDirectDiscordRestDispatcher(),
|
||||
);
|
||||
return fetcher;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { RetryOptions, WebClientOptions } from "@slack/web-api";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import { resolveEnvHttpProxyUrl } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
import {
|
||||
resolveActiveManagedProxyTlsOptions,
|
||||
resolveEnvHttpProxyUrl,
|
||||
} from "openclaw/plugin-sdk/fetch-runtime";
|
||||
|
||||
export const SLACK_DEFAULT_RETRY_OPTIONS: RetryOptions = {
|
||||
retries: 2,
|
||||
@@ -70,8 +73,10 @@ function resolveSlackProxyAgent(): HttpsProxyAgent<string> | undefined {
|
||||
if (isHostExcludedByNoProxy("slack.com")) {
|
||||
return undefined;
|
||||
}
|
||||
const proxyTls = resolveActiveManagedProxyTlsOptions({ proxyUrl });
|
||||
const proxyAgentOptions = proxyTls?.ca ? { ca: proxyTls.ca } : undefined;
|
||||
try {
|
||||
return new HttpsProxyAgent(proxyUrl);
|
||||
return new HttpsProxyAgent(proxyUrl, proxyAgentOptions);
|
||||
} catch {
|
||||
// Malformed proxy URL; degrade gracefully to direct connection.
|
||||
return undefined;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@slack/web-api", () => {
|
||||
@@ -30,8 +33,11 @@ const PROXY_KEYS = [
|
||||
"http_proxy",
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
"OPENCLAW_PROXY_ACTIVE",
|
||||
"OPENCLAW_PROXY_CA_FILE",
|
||||
] as const;
|
||||
const originalEnv = { ...process.env };
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function clearProxyEnvForTest() {
|
||||
for (const key of PROXY_KEYS) {
|
||||
@@ -56,6 +62,14 @@ function requireAgent<T extends { agent?: unknown }>(options: T): NonNullable<T[
|
||||
return options.agent as NonNullable<T["agent"]>;
|
||||
}
|
||||
|
||||
function writeTempCa(contents: string): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-proxy-ca-"));
|
||||
tempDirs.push(dir);
|
||||
const caFile = path.join(dir, "proxy-ca.pem");
|
||||
writeFileSync(caFile, contents, "utf8");
|
||||
return caFile;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const slackWebApi = await import("@slack/web-api");
|
||||
({
|
||||
@@ -193,6 +207,9 @@ describe("slack proxy agent", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
restoreProxyEnvForTest();
|
||||
});
|
||||
|
||||
@@ -204,6 +221,18 @@ describe("slack proxy agent", () => {
|
||||
expect(agent.constructor.name).toBe("HttpsProxyAgent");
|
||||
});
|
||||
|
||||
it("adds managed proxy CA trust to Slack env proxy agents", () => {
|
||||
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 } };
|
||||
|
||||
expect(agent.connectOpts?.ca).toBe("slack-managed-proxy-ca");
|
||||
});
|
||||
|
||||
it("falls back to HTTP_PROXY when HTTPS_PROXY is not set", () => {
|
||||
process.env.HTTP_PROXY = "http://proxy.example.com:3128";
|
||||
const options = resolveSlackWebClientOptions();
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -10,6 +13,7 @@ const loggerWarn = vi.hoisted(() => vi.fn());
|
||||
|
||||
const undiciFetch = vi.hoisted(() => vi.fn());
|
||||
const setGlobalDispatcher = vi.hoisted(() => vi.fn());
|
||||
const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__";
|
||||
type MockDispatcherInstance = {
|
||||
options?: Record<string, unknown> | string;
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
@@ -103,6 +107,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
let resolveTelegramFetch: typeof import("./fetch.js").resolveTelegramFetch;
|
||||
let resolveTelegramApiBase: typeof import("./fetch.js").resolveTelegramApiBase;
|
||||
let resolveTelegramTransport: typeof import("./fetch.js").resolveTelegramTransport;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
type TelegramDispatcherPolicy = NonNullable<
|
||||
ReturnType<typeof resolveTelegramTransport>["dispatcherAttempts"]
|
||||
@@ -132,6 +137,8 @@ beforeEach(() => {
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
"OPENCLAW_PROXY_URL",
|
||||
"OPENCLAW_PROXY_ACTIVE",
|
||||
"OPENCLAW_PROXY_CA_FILE",
|
||||
]) {
|
||||
vi.stubEnv(key, "");
|
||||
}
|
||||
@@ -140,10 +147,15 @@ beforeEach(() => {
|
||||
loggerWarn.mockReset();
|
||||
getDefaultResultOrder.mockReset();
|
||||
getDefaultResultOrder.mockReturnValue("ipv4first");
|
||||
installUndiciRuntimeDeps();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
|
||||
vi.unstubAllEnvs();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function resolveTelegramFetchOrThrow(
|
||||
@@ -183,6 +195,32 @@ function constructorOptions(ctor: ReturnType<typeof vi.fn>, label: string): unkn
|
||||
return call[0];
|
||||
}
|
||||
|
||||
function writeTempCa(contents: string): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-proxy-ca-"));
|
||||
tempDirs.push(dir);
|
||||
const caFile = path.join(dir, "proxy-ca.pem");
|
||||
writeFileSync(caFile, contents, "utf8");
|
||||
return caFile;
|
||||
}
|
||||
|
||||
function installUndiciRuntimeDeps(): void {
|
||||
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
|
||||
Agent: AgentCtor,
|
||||
EnvHttpProxyAgent: EnvHttpProxyAgentCtor,
|
||||
Pool: vi.fn(function MockPool(
|
||||
this: MockDispatcherInstance,
|
||||
_origin: unknown,
|
||||
options?: Record<string, unknown>,
|
||||
) {
|
||||
this.options = options;
|
||||
this.destroy = vi.fn(async () => undefined);
|
||||
this.close = vi.fn(async () => undefined);
|
||||
}),
|
||||
ProxyAgent: ProxyAgentCtor,
|
||||
fetch: undiciFetch,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFetchFallbackError(code: string) {
|
||||
const connectErr = Object.assign(new Error(`connect ${code} api.telegram.org:443`), {
|
||||
code,
|
||||
@@ -422,6 +460,32 @@ describe("resolveTelegramFetch", () => {
|
||||
expect(dispatcher?.options?.proxyTls?.autoSelectFamilyAttemptTimeout).toBe(300);
|
||||
});
|
||||
|
||||
it("adds managed proxy CA trust to Telegram env proxy dispatchers", async () => {
|
||||
const caFile = writeTempCa("telegram-managed-proxy-ca");
|
||||
vi.stubEnv("https_proxy", "https://proxy.example:8443");
|
||||
vi.stubEnv("OPENCLAW_PROXY_ACTIVE", "1");
|
||||
vi.stubEnv("OPENCLAW_PROXY_CA_FILE", caFile);
|
||||
undiciFetch.mockResolvedValue({ ok: true } as Response);
|
||||
|
||||
const resolved = resolveTelegramFetchOrThrow(undefined, {
|
||||
network: {
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
});
|
||||
|
||||
await resolved("https://api.telegram.org/botx/getMe");
|
||||
|
||||
expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1);
|
||||
const envProxyOptions = constructorOptions(EnvHttpProxyAgentCtor, "env proxy") as {
|
||||
httpsProxy?: string;
|
||||
proxyTls?: { ca?: unknown; autoSelectFamily?: boolean };
|
||||
};
|
||||
expect(envProxyOptions.httpsProxy).toBe("https://proxy.example:8443");
|
||||
expect(envProxyOptions.proxyTls?.ca).toBe("telegram-managed-proxy-ca");
|
||||
expect(envProxyOptions.proxyTls?.autoSelectFamily).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the OpenClaw debug proxy URL when no explicit proxy fetch is provided", async () => {
|
||||
vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "1");
|
||||
vi.stubEnv("OPENCLAW_DEBUG_PROXY_URL", "http://127.0.0.1:7777");
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as dns from "node:dns";
|
||||
import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
createHttp1EnvHttpProxyAgent,
|
||||
createHttp1ProxyAgent,
|
||||
createPinnedLookup,
|
||||
hasEnvHttpProxyAgentConfigured,
|
||||
resolveEnvHttpProxyAgentOptions,
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici";
|
||||
import { Agent, fetch as undiciFetch } from "undici";
|
||||
import { normalizeTelegramApiRoot } from "./api-root.js";
|
||||
import {
|
||||
resolveTelegramAutoSelectFamilyDecision,
|
||||
@@ -68,7 +70,10 @@ type RequestInitWithDispatcher = RequestInit & {
|
||||
dispatcher?: unknown;
|
||||
};
|
||||
|
||||
type TelegramDispatcher = Agent | EnvHttpProxyAgent | ProxyAgent;
|
||||
type TelegramDispatcher =
|
||||
| Agent
|
||||
| ReturnType<typeof createHttp1EnvHttpProxyAgent>
|
||||
| ReturnType<typeof createHttp1ProxyAgent>;
|
||||
|
||||
type TelegramDispatcherMode = "direct" | "env-proxy" | "explicit-proxy";
|
||||
|
||||
@@ -310,10 +315,10 @@ function createTelegramDispatcher(policy: PinnedDispatcherPolicy): {
|
||||
uri: policy.proxyUrl,
|
||||
...poolOptions,
|
||||
...(requestTlsOptions ? { requestTls: requestTlsOptions } : {}),
|
||||
} satisfies ConstructorParameters<typeof ProxyAgent>[0];
|
||||
} satisfies Parameters<typeof createHttp1ProxyAgent>[0];
|
||||
try {
|
||||
return {
|
||||
dispatcher: new ProxyAgent(proxyOptions),
|
||||
dispatcher: createHttp1ProxyAgent(proxyOptions),
|
||||
mode: "explicit-proxy",
|
||||
effectivePolicy: policy,
|
||||
};
|
||||
@@ -331,10 +336,10 @@ function createTelegramDispatcher(policy: PinnedDispatcherPolicy): {
|
||||
...resolveEnvHttpProxyAgentOptions(),
|
||||
...(connectOptions ? { connect: connectOptions } : {}),
|
||||
...(proxyTlsOptions ? { proxyTls: proxyTlsOptions } : {}),
|
||||
} satisfies ConstructorParameters<typeof EnvHttpProxyAgent>[0];
|
||||
} satisfies Parameters<typeof createHttp1EnvHttpProxyAgent>[0];
|
||||
try {
|
||||
return {
|
||||
dispatcher: new EnvHttpProxyAgent(proxyOptions),
|
||||
dispatcher: createHttp1EnvHttpProxyAgent(proxyOptions),
|
||||
mode: "env-proxy",
|
||||
effectivePolicy: policy,
|
||||
};
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"baileys": "7.0.0-rc11",
|
||||
"https-proxy-agent": "9.0.0",
|
||||
"jimp": "1.6.1",
|
||||
"typebox": "1.1.38",
|
||||
"undici": "8.3.0"
|
||||
"typebox": "1.1.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
|
||||
@@ -7,6 +7,30 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
|
||||
import { enqueueCredsSave } from "./creds-persistence.js";
|
||||
import { baileys, getLastSocket, resetBaileysMocks, resetLoadConfigMock } from "./test-helpers.js";
|
||||
|
||||
const { envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({
|
||||
envHttpProxyAgentCtor: vi.fn(function MockEnvHttpProxyAgent(
|
||||
this: { options: unknown; dispatch: () => void },
|
||||
options: unknown,
|
||||
) {
|
||||
this.options = options;
|
||||
this.dispatch = () => {};
|
||||
}),
|
||||
proxyAgentCtor: vi.fn(function MockProxyAgent(
|
||||
this: { options: unknown; dispatch: () => void },
|
||||
options: unknown,
|
||||
) {
|
||||
this.options = options;
|
||||
this.dispatch = () => {};
|
||||
}),
|
||||
}));
|
||||
|
||||
const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__";
|
||||
|
||||
vi.mock("undici", () => ({
|
||||
EnvHttpProxyAgent: envHttpProxyAgentCtor,
|
||||
ProxyAgent: proxyAgentCtor,
|
||||
}));
|
||||
|
||||
const useMultiFileAuthStateMock = vi.mocked(baileys.useMultiFileAuthState);
|
||||
|
||||
let createWaSocket: typeof import("./session.js").createWaSocket;
|
||||
@@ -36,6 +60,13 @@ function createTempAuthDir(prefix: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function createTempCaFile(contents: string): string {
|
||||
const dir = createTempAuthDir("openclaw-wa-proxy-ca");
|
||||
const caFile = path.join(dir, "proxy-ca.pem");
|
||||
fsSync.writeFileSync(caFile, contents, "utf8");
|
||||
return caFile;
|
||||
}
|
||||
|
||||
function mockFsOpenForCredsWrites(params?: {
|
||||
onTempWrite?: (filePath: string) => Promise<void> | void;
|
||||
}) {
|
||||
@@ -223,6 +254,16 @@ function expectRuntimeLogContaining(
|
||||
expect(runtime.log.mock.calls.map(([message]) => String(message)).join("\n")).toContain(text);
|
||||
}
|
||||
|
||||
function installUndiciRuntimeDeps(): void {
|
||||
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
|
||||
Agent: vi.fn(),
|
||||
EnvHttpProxyAgent: envHttpProxyAgentCtor,
|
||||
Pool: vi.fn(),
|
||||
ProxyAgent: proxyAgentCtor,
|
||||
fetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("web session", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
@@ -238,11 +279,15 @@ describe("web session", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
envHttpProxyAgentCtor.mockClear();
|
||||
proxyAgentCtor.mockClear();
|
||||
installUndiciRuntimeDeps();
|
||||
resetBaileysMocks();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
|
||||
await waitForCredsSaveQueue();
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
@@ -301,6 +346,45 @@ describe("web session", () => {
|
||||
expect(typeof (fetchAgent as { dispatch?: unknown }).dispatch).toBe("function");
|
||||
});
|
||||
|
||||
it("adds managed proxy CA trust to WhatsApp env proxy agents", async () => {
|
||||
const caFile = createTempCaFile("whatsapp-managed-proxy-ca");
|
||||
vi.stubEnv("HTTPS_PROXY", "https://proxy.test:8443");
|
||||
vi.stubEnv("OPENCLAW_PROXY_ACTIVE", "1");
|
||||
vi.stubEnv("OPENCLAW_PROXY_CA_FILE", caFile);
|
||||
|
||||
await createWaSocket(false, false);
|
||||
|
||||
const passed = readLastSocketOptions();
|
||||
const agent = requireValue(
|
||||
passed.agent as { connectOpts?: { ca?: unknown } } | undefined,
|
||||
"WebSocket proxy agent",
|
||||
);
|
||||
expect(agent.connectOpts?.ca).toBe("whatsapp-managed-proxy-ca");
|
||||
expect(proxyAgentCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
proxyTls: expect.objectContaining({ ca: "whatsapp-managed-proxy-ca" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds managed proxy CA trust to WhatsApp env fetch dispatchers", async () => {
|
||||
const caFile = createTempCaFile("whatsapp-managed-env-proxy-ca");
|
||||
vi.stubEnv("HTTPS_PROXY", "https://proxy.test:8443");
|
||||
vi.stubEnv("NO_PROXY", "mmg.whatsapp.net");
|
||||
vi.stubEnv("OPENCLAW_PROXY_ACTIVE", "1");
|
||||
vi.stubEnv("OPENCLAW_PROXY_CA_FILE", caFile);
|
||||
|
||||
await createWaSocket(false, false);
|
||||
|
||||
const passed = readLastSocketOptions();
|
||||
expect(passed.agent).toBeUndefined();
|
||||
expect(envHttpProxyAgentCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
proxyTls: expect.objectContaining({ ca: "whatsapp-managed-env-proxy-ca" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses lowercase HTTPS proxy before uppercase for WA WebSocket connection", async () => {
|
||||
vi.stubEnv("HTTPS_PROXY", "http://upper-proxy.test:8080");
|
||||
vi.stubEnv("https_proxy", "http://lower-proxy.test:8080");
|
||||
|
||||
@@ -5,6 +5,9 @@ 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,
|
||||
} from "openclaw/plugin-sdk/fetch-runtime";
|
||||
@@ -232,8 +235,10 @@ async function resolveEnvProxyAgent(
|
||||
if (!proxyUrl) {
|
||||
return undefined;
|
||||
}
|
||||
const proxyTls = resolveActiveManagedProxyTlsOptions({ proxyUrl });
|
||||
const proxyAgentOptions = proxyTls?.ca ? { ca: proxyTls.ca } : undefined;
|
||||
try {
|
||||
const agent = new HttpsProxyAgent(proxyUrl) as Agent;
|
||||
const agent = new HttpsProxyAgent(proxyUrl, proxyAgentOptions) as Agent;
|
||||
logger.info("Using ambient env proxy for WhatsApp WebSocket connection");
|
||||
return agent;
|
||||
} catch (error) {
|
||||
@@ -255,10 +260,7 @@ async function resolveEnvFetchDispatcher(
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const { EnvHttpProxyAgent, ProxyAgent } = await import("undici");
|
||||
return proxyUrl
|
||||
? new ProxyAgent({ allowH2: false, uri: proxyUrl })
|
||||
: new EnvHttpProxyAgent({ allowH2: false });
|
||||
return proxyUrl ? createHttp1ProxyAgent({ uri: proxyUrl }) : createHttp1EnvHttpProxyAgent();
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error: String(error) },
|
||||
|
||||
@@ -1766,7 +1766,7 @@
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@mozilla/readability": "0.6.0",
|
||||
"@openclaw/fs-safe": "0.2.4",
|
||||
"@openclaw/proxyline": "0.3.0",
|
||||
"@openclaw/proxyline": "0.3.2",
|
||||
"ajv": "8.20.0",
|
||||
"chalk": "5.6.2",
|
||||
"chokidar": "5.0.0",
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -87,8 +87,8 @@ importers:
|
||||
specifier: 0.2.4
|
||||
version: 0.2.4
|
||||
'@openclaw/proxyline':
|
||||
specifier: 0.3.0
|
||||
version: 0.3.0(undici@8.3.0)
|
||||
specifier: 0.3.2
|
||||
version: 0.3.2(undici@8.3.0)
|
||||
ajv:
|
||||
specifier: 8.20.0
|
||||
version: 8.20.0
|
||||
@@ -1646,9 +1646,6 @@ importers:
|
||||
typebox:
|
||||
specifier: 1.1.38
|
||||
version: 1.1.38
|
||||
undici:
|
||||
specifier: 8.3.0
|
||||
version: 8.3.0
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
@@ -3340,8 +3337,8 @@ packages:
|
||||
resolution: {integrity: sha512-Fo3WTQhxu0asD/rZqIKBqhX6fuZfjyHxSW5yTKfcRx+D9BRAcz0AGoVh+3ur/4XRvZkvsh3Ud8XTw006yRYLgg==}
|
||||
engines: {node: '>=20.11'}
|
||||
|
||||
'@openclaw/proxyline@0.3.0':
|
||||
resolution: {integrity: sha512-+18F9jk948+qK70V63Nfiu4odfvdf+0T9qyKyzylmt1WLYGTtLLo88pLgtS+vN5CORJyCuC56Rdswkws5VsEig==}
|
||||
'@openclaw/proxyline@0.3.2':
|
||||
resolution: {integrity: sha512-AHW1hsqbpEJjrzzVdP5TXqHG4j3zmWU5qkPp0rvrGq3vEdbdwNuvAsmfQF0PhJ9ARR7IoS5mKxmD6von3GnAkQ==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
peerDependencies:
|
||||
undici: '>=7.25.0 <9'
|
||||
@@ -9631,7 +9628,7 @@ snapshots:
|
||||
jszip: 3.10.1
|
||||
tar: 7.5.15
|
||||
|
||||
'@openclaw/proxyline@0.3.0(undici@8.3.0)':
|
||||
'@openclaw/proxyline@0.3.2(undici@8.3.0)':
|
||||
dependencies:
|
||||
undici: 8.3.0
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ vi.mock("../plugins/bundled-dir.js", () => ({
|
||||
resolveSourceCheckoutDependencyDiagnostic: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/channel-catalog-registry.js", () => ({
|
||||
listChannelCatalogEntries: vi.fn(() => {
|
||||
throw new Error("bundled channel catalog read must not run full plugin discovery");
|
||||
}),
|
||||
}));
|
||||
|
||||
// The channel-catalog.json fallback still walks package roots via
|
||||
// resolveOpenClawPackageRootSync. Isolate from the real repo by mocking
|
||||
// moduleUrl/argv1 resolution to null and deriving only from the tmp cwd.
|
||||
|
||||
@@ -111,6 +111,7 @@ describe("proxy cli runtime", () => {
|
||||
|
||||
await runProxyValidateCommand({
|
||||
proxyUrl: "http://override.example:3128",
|
||||
proxyCaFile: "./ca.pem",
|
||||
allowedUrls: ["https://allowed.example/"],
|
||||
deniedUrls: ["http://127.0.0.1/"],
|
||||
apnsReachability: true,
|
||||
@@ -126,6 +127,7 @@ describe("proxy cli runtime", () => {
|
||||
},
|
||||
env: process.env,
|
||||
proxyUrlOverride: "http://override.example:3128",
|
||||
proxyCaFileOverride: "./ca.pem",
|
||||
allowedUrls: ["https://allowed.example/"],
|
||||
deniedUrls: ["http://127.0.0.1/"],
|
||||
apnsReachability: true,
|
||||
@@ -278,7 +280,34 @@ describe("proxy cli runtime", () => {
|
||||
"Problems\n" +
|
||||
" - proxyUrl must use http://\n\n" +
|
||||
"Next steps\n" +
|
||||
" Fix proxy.proxyUrl, OPENCLAW_PROXY_URL, or --proxy-url so it uses a reachable http:// proxy.\n",
|
||||
" Fix proxy.proxyUrl, OPENCLAW_PROXY_URL, or --proxy-url so it uses a reachable http:// or https:// proxy.\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints CA-file guidance when proxy CA files cannot be read", async () => {
|
||||
runProxyValidationMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "https://proxy.example:8443",
|
||||
source: "config",
|
||||
errors: ["proxy CA file could not be read (/missing/ca.pem): ENOENT"],
|
||||
},
|
||||
checks: [],
|
||||
});
|
||||
const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js");
|
||||
|
||||
await runProxyValidateCommand({});
|
||||
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
"Proxy validation failed\n\n" +
|
||||
"Proxy\n" +
|
||||
" Source: config\n" +
|
||||
" URL: https://proxy.example:8443/\n\n" +
|
||||
"Problems\n" +
|
||||
" - proxy CA file could not be read (/missing/ca.pem): ENOENT\n\n" +
|
||||
"Next steps\n" +
|
||||
" Confirm proxy.tls.caFile or --proxy-ca-file points to a readable PEM CA file for the HTTPS proxy endpoint.\n",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -195,9 +195,14 @@ function formatProxyValidationNextSteps(result: ProxyValidationResult): string[]
|
||||
"Enable proxy.enabled with proxy.proxyUrl or OPENCLAW_PROXY_URL, or pass --proxy-url for an explicit one-off validation.",
|
||||
];
|
||||
}
|
||||
if (result.config.errors.some((error) => error.includes("proxy CA file could not be read"))) {
|
||||
return [
|
||||
"Confirm proxy.tls.caFile or --proxy-ca-file points to a readable PEM CA file for the HTTPS proxy endpoint.",
|
||||
];
|
||||
}
|
||||
if (result.config.errors.length > 0) {
|
||||
return [
|
||||
"Fix proxy.proxyUrl, OPENCLAW_PROXY_URL, or --proxy-url so it uses a reachable http:// proxy.",
|
||||
"Fix proxy.proxyUrl, OPENCLAW_PROXY_URL, or --proxy-url so it uses a reachable http:// or https:// proxy.",
|
||||
];
|
||||
}
|
||||
if (result.checks.some((check) => !check.ok && check.kind === "allowed")) {
|
||||
@@ -254,6 +259,7 @@ function formatProxyValidationText(result: ProxyValidationResult): string {
|
||||
export async function runProxyValidateCommand(opts: {
|
||||
json?: boolean;
|
||||
proxyUrl?: string;
|
||||
proxyCaFile?: string;
|
||||
allowedUrls?: string[];
|
||||
deniedUrls?: string[];
|
||||
apnsReachability?: boolean;
|
||||
@@ -265,6 +271,7 @@ export async function runProxyValidateCommand(opts: {
|
||||
config: config?.proxy,
|
||||
env: process.env,
|
||||
proxyUrlOverride: opts.proxyUrl,
|
||||
proxyCaFileOverride: opts.proxyCaFile,
|
||||
allowedUrls: opts.allowedUrls,
|
||||
deniedUrls: opts.deniedUrls,
|
||||
apnsReachability: opts.apnsReachability,
|
||||
|
||||
@@ -24,6 +24,7 @@ describe("proxy cli", () => {
|
||||
expect(validate?.options.map((option) => option.long)).toEqual([
|
||||
"--json",
|
||||
"--proxy-url",
|
||||
"--proxy-ca-file",
|
||||
"--allowed-url",
|
||||
"--denied-url",
|
||||
"--apns-reachable",
|
||||
|
||||
@@ -61,6 +61,7 @@ export function registerProxyCli(program: Command) {
|
||||
.description("Validate the operator-managed network proxy")
|
||||
.option("--json", "Print machine-readable JSON")
|
||||
.option("--proxy-url <url>", "Proxy URL to validate instead of config/env")
|
||||
.option("--proxy-ca-file <path>", "CA bundle file for verifying an HTTPS proxy endpoint")
|
||||
.option(
|
||||
"--allowed-url <url>",
|
||||
"Destination expected to succeed through the proxy",
|
||||
@@ -74,6 +75,7 @@ export function registerProxyCli(program: Command) {
|
||||
async (opts: {
|
||||
json?: boolean;
|
||||
proxyUrl?: string;
|
||||
proxyCaFile?: string;
|
||||
allowedUrl?: string[];
|
||||
deniedUrl?: string[];
|
||||
apnsReachable?: boolean;
|
||||
@@ -84,6 +86,7 @@ export function registerProxyCli(program: Command) {
|
||||
await runtime.runProxyValidateCommand({
|
||||
json: opts.json,
|
||||
proxyUrl: opts.proxyUrl,
|
||||
proxyCaFile: opts.proxyCaFile,
|
||||
allowedUrls: opts.allowedUrl,
|
||||
deniedUrls: opts.deniedUrl,
|
||||
apnsReachability: opts.apnsReachable,
|
||||
|
||||
@@ -1010,6 +1010,18 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Optional SNI/server-name override used when establishing TLS to the proxy.",
|
||||
"models.providers.*.request.proxy.tls.insecureSkipVerify":
|
||||
"Skips proxy TLS certificate verification. Use only for controlled development environments.",
|
||||
proxy:
|
||||
"Operator-managed forward proxy routing for OpenClaw runtime HTTP, HTTPS, WebSocket, and supported raw-egress paths. Use this when central egress control is part of the deployment boundary.",
|
||||
"proxy.enabled":
|
||||
"Enables operator-managed proxy routing. When enabled, OpenClaw fails startup if no managed proxy URL is configured.",
|
||||
"proxy.proxyUrl":
|
||||
"Managed forward proxy URL. Use http:// for a plain CONNECT proxy or https:// when the connection to the proxy endpoint itself must use TLS.",
|
||||
"proxy.tls":
|
||||
"TLS settings used when connecting to the managed proxy endpoint. These settings apply to proxy TLS, not destination TLS after CONNECT.",
|
||||
"proxy.tls.caFile":
|
||||
"Filesystem path to a custom CA bundle used to verify an HTTPS managed proxy endpoint certificate.",
|
||||
"proxy.loopbackMode":
|
||||
'Controls Gateway loopback control-plane routing while managed proxy mode is active: "gateway-only", "proxy", or "block".',
|
||||
"models.providers.*.request.tls":
|
||||
"Optional TLS settings used when connecting directly to the upstream model endpoint.",
|
||||
"models.providers.*.request.tls.ca":
|
||||
|
||||
@@ -173,6 +173,7 @@ describe("mapSensitivePaths", () => {
|
||||
expect(hints["models.providers.*.request.headers.*"]?.sensitive).toBe(true);
|
||||
expect(hints["models.providers.*.request.proxy.tls.cert"]?.sensitive).toBe(true);
|
||||
expect(hints["proxy.proxyUrl"]?.sensitive).toBe(true);
|
||||
expect(hints["proxy.tls.caFile"]?.sensitive).toBeUndefined();
|
||||
expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -606,6 +606,12 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"models.providers.*.request.proxy.tls.serverName": "Model Provider Request Proxy TLS Server Name",
|
||||
"models.providers.*.request.proxy.tls.insecureSkipVerify":
|
||||
"Model Provider Request Proxy TLS Skip Verify",
|
||||
proxy: "Managed Proxy",
|
||||
"proxy.enabled": "Managed Proxy Enabled",
|
||||
"proxy.proxyUrl": "Managed Proxy URL",
|
||||
"proxy.tls": "Managed Proxy TLS",
|
||||
"proxy.tls.caFile": "Managed Proxy TLS CA File",
|
||||
"proxy.loopbackMode": "Managed Proxy Loopback Mode",
|
||||
"models.providers.*.request.tls": "Model Provider Request TLS",
|
||||
"models.providers.*.request.tls.ca": "Model Provider Request TLS CA",
|
||||
"models.providers.*.request.tls.cert": "Model Provider Request TLS Cert",
|
||||
|
||||
@@ -55,6 +55,7 @@ const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
|
||||
"gateway.controlUi.dangerouslyDisableDeviceAuth": ["security", "access", "network", "advanced"],
|
||||
"gateway.controlUi.allowInsecureAuth": ["security", "access", "network", "advanced"],
|
||||
"gateway.nodes.pairing.autoApproveCidrs": ["security", "access", "network", "advanced"],
|
||||
"proxy.tls.caFile": ["security", "network", "storage", "advanced"],
|
||||
"tools.exec.applyPatch.workspaceOnly": ["tools", "security", "access", "advanced"],
|
||||
};
|
||||
|
||||
|
||||
@@ -115,6 +115,9 @@ describe("config schema", () => {
|
||||
expect(res.uiHints["mcp.servers.*.headers.*"]?.sensitive).toBe(true);
|
||||
expect(res.uiHints["mcp.servers.*.url"]?.tags).toContain(SENSITIVE_URL_HINT_TAG);
|
||||
expect(res.uiHints["models.providers.*.baseUrl"]?.tags).toContain(SENSITIVE_URL_HINT_TAG);
|
||||
expect(res.uiHints["proxy.tls.caFile"]?.tags).toEqual(
|
||||
expect.arrayContaining(["security", "network", "storage"]),
|
||||
);
|
||||
expect(res.version).toBeTypeOf("string");
|
||||
expect(res.version.trim().length).toBeGreaterThan(0);
|
||||
expect(res.generatedAt).toBeTypeOf("string");
|
||||
@@ -338,6 +341,13 @@ describe("config schema", () => {
|
||||
expect(tags).toContain("auth");
|
||||
});
|
||||
|
||||
it("classifies managed proxy CA files as security-relevant config", () => {
|
||||
const tags = deriveTagsForPath("proxy.tls.caFile");
|
||||
expect(tags).toContain("security");
|
||||
expect(tags).toContain("network");
|
||||
expect(tags).toContain("storage");
|
||||
});
|
||||
|
||||
it("derives tools/performance tags for web fetch timeout paths", () => {
|
||||
const tags = deriveTagsForPath("tools.web.fetch.timeoutSeconds");
|
||||
expect(tags).toContain("tools");
|
||||
|
||||
@@ -23,11 +23,17 @@ describe("ProxyConfigSchema", () => {
|
||||
const result = ProxyConfigSchema.parse({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
tls: {
|
||||
caFile: "/etc/openclaw/proxy-ca.pem",
|
||||
},
|
||||
loopbackMode: "gateway-only",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
tls: {
|
||||
caFile: "/etc/openclaw/proxy-ca.pem",
|
||||
},
|
||||
loopbackMode: "gateway-only",
|
||||
});
|
||||
});
|
||||
@@ -45,13 +51,13 @@ describe("ProxyConfigSchema", () => {
|
||||
expect(issues.map((issue) => issue.path.join("."))).toContain("loopbackMode");
|
||||
});
|
||||
|
||||
it("rejects HTTPS proxy URLs because the node:http routing layer requires HTTP proxies", () => {
|
||||
expect(() =>
|
||||
ProxyConfigSchema.parse({
|
||||
enabled: true,
|
||||
proxyUrl: "https://proxy.example.com:8443",
|
||||
}),
|
||||
).toThrow(/http:\/\//i);
|
||||
it("accepts HTTPS proxy URLs for TLS-to-proxy endpoints", () => {
|
||||
const result = ProxyConfigSchema.parse({
|
||||
enabled: true,
|
||||
proxyUrl: "https://proxy.example.com:8443",
|
||||
});
|
||||
|
||||
expect(result?.proxyUrl).toBe("https://proxy.example.com:8443");
|
||||
});
|
||||
|
||||
it("does not expose bundled-proxy or unsupported upstream proxy keys", () => {
|
||||
@@ -77,6 +83,18 @@ describe("ProxyConfigSchema", () => {
|
||||
expect(issues[0]?.code).toBe("unrecognized_keys");
|
||||
});
|
||||
|
||||
it("rejects unknown proxy TLS keys", () => {
|
||||
expect(() =>
|
||||
ProxyConfigSchema.parse({
|
||||
enabled: true,
|
||||
proxyUrl: "https://proxy.example.com:8443",
|
||||
tls: {
|
||||
ca: "/etc/openclaw/proxy-ca.pem",
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts enabled: false to disable the proxy", () => {
|
||||
const result = ProxyConfigSchema.parse({ enabled: false });
|
||||
expect(result?.enabled).toBe(false);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { sensitive } from "./zod-schema.sensitive.js";
|
||||
|
||||
function isHttpProxyUrl(value: string): boolean {
|
||||
function isHttpOrHttpsProxyUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:";
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -12,16 +12,24 @@ function isHttpProxyUrl(value: string): boolean {
|
||||
|
||||
export const ProxyLoopbackModeSchema = z.enum(["gateway-only", "proxy", "block"]);
|
||||
|
||||
const ProxyTlsConfigSchema = z
|
||||
.object({
|
||||
caFile: z.string().min(1).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const ProxyConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
proxyUrl: z
|
||||
.url()
|
||||
.refine(isHttpProxyUrl, {
|
||||
message: "proxyUrl must use http://",
|
||||
.refine(isHttpOrHttpsProxyUrl, {
|
||||
message: "proxyUrl must use http:// or https://",
|
||||
})
|
||||
.register(sensitive)
|
||||
.optional(),
|
||||
tls: ProxyTlsConfigSchema,
|
||||
loopbackMode: ProxyLoopbackModeSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -241,6 +241,7 @@ describe("fetchWithSsrFGuard hardening", () => {
|
||||
autoSelectFamily: true,
|
||||
autoSelectFamilyAttemptTimeout: 300,
|
||||
},
|
||||
clientFactory: expect.any(Function),
|
||||
proxyTls: {
|
||||
autoSelectFamily: true,
|
||||
autoSelectFamilyAttemptTimeout: 300,
|
||||
@@ -600,6 +601,7 @@ describe("fetchWithSsrFGuard hardening", () => {
|
||||
|
||||
expect(proxyAgentCtor).toHaveBeenCalledWith({
|
||||
uri: "http://proxy.example:7890",
|
||||
clientFactory: expect.any(Function),
|
||||
proxyTls: {
|
||||
autoSelectFamily: true,
|
||||
autoSelectFamilyAttemptTimeout: 300,
|
||||
|
||||
@@ -89,6 +89,15 @@ const {
|
||||
|
||||
vi.mock("node:net", () => ({
|
||||
connect: netConnectSpy,
|
||||
isIP: (host: string) => {
|
||||
if (host === "127.0.0.1") {
|
||||
return 4;
|
||||
}
|
||||
if (host === "::1") {
|
||||
return 6;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("node:tls", () => ({
|
||||
@@ -155,6 +164,7 @@ describe("openHttpConnectTunnel", () => {
|
||||
|
||||
await openHttpConnectTunnel({
|
||||
proxyUrl: new URL("https://proxy.example:8443"),
|
||||
proxyTls: { ca: "proxy-ca" },
|
||||
targetHost: "api.sandbox.push.apple.com",
|
||||
targetPort: 443,
|
||||
});
|
||||
@@ -164,6 +174,7 @@ describe("openHttpConnectTunnel", () => {
|
||||
port: 8443,
|
||||
servername: "proxy.example",
|
||||
ALPNProtocols: ["http/1.1"],
|
||||
ca: "proxy-ca",
|
||||
});
|
||||
expect(tlsConnectSpy).toHaveBeenLastCalledWith({
|
||||
socket: proxySocket,
|
||||
@@ -172,6 +183,28 @@ describe("openHttpConnectTunnel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("omits SNI for HTTPS proxy IP literals", async () => {
|
||||
const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
const targetTlsSocket = new FakeSocket();
|
||||
setNextProxyTlsSocket(proxySocket);
|
||||
setNextTargetTlsSocket(targetTlsSocket);
|
||||
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
|
||||
|
||||
await openHttpConnectTunnel({
|
||||
proxyUrl: new URL("https://127.0.0.1:8443"),
|
||||
proxyTls: { ca: "proxy-ca" },
|
||||
targetHost: "api.sandbox.push.apple.com",
|
||||
targetPort: 443,
|
||||
});
|
||||
|
||||
expect(requireFirstTlsConnectOptions()).toEqual({
|
||||
host: "127.0.0.1",
|
||||
port: 8443,
|
||||
ALPNProtocols: ["http/1.1"],
|
||||
ca: "proxy-ca",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends basic proxy authorization and redacts credentials when CONNECT fails", async () => {
|
||||
const proxySocket = new FakeSocket("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n");
|
||||
setNextNetSocket(proxySocket);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as net from "node:net";
|
||||
import * as tls from "node:tls";
|
||||
import type { ManagedProxyTlsOptions } from "./proxy/proxy-tls.js";
|
||||
|
||||
export type HttpConnectTunnelParams = {
|
||||
proxyUrl: URL;
|
||||
proxyTls?: ManagedProxyTlsOptions;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
timeoutMs?: number;
|
||||
@@ -107,8 +109,9 @@ function isSuccessfulConnectStatusLine(statusLine: string): boolean {
|
||||
return /^HTTP\/1\.[01] 2\d\d\b/.test(statusLine);
|
||||
}
|
||||
|
||||
function connectToProxy(proxy: URL): ProxySocket {
|
||||
function connectToProxy(proxy: URL, proxyTls: ManagedProxyTlsOptions | undefined): ProxySocket {
|
||||
const proxyHost = resolveProxyHost(proxy);
|
||||
const proxyServername = net.isIP(proxyHost) === 0 ? proxyHost : undefined;
|
||||
const connectOptions = {
|
||||
host: proxyHost,
|
||||
port: resolveProxyPort(proxy),
|
||||
@@ -116,8 +119,9 @@ function connectToProxy(proxy: URL): ProxySocket {
|
||||
if (proxy.protocol === "https:") {
|
||||
return tls.connect({
|
||||
...connectOptions,
|
||||
servername: proxyHost,
|
||||
...(proxyServername ? { servername: proxyServername } : {}),
|
||||
ALPNProtocols: ["http/1.1"],
|
||||
...(proxyTls?.ca ? { ca: proxyTls.ca } : {}),
|
||||
});
|
||||
}
|
||||
return net.connect(connectOptions);
|
||||
@@ -140,7 +144,7 @@ class HttpConnectTunnelAttempt {
|
||||
public start(): void {
|
||||
try {
|
||||
this.startTimeout();
|
||||
this.proxySocket = connectToProxy(this.proxy);
|
||||
this.proxySocket = connectToProxy(this.proxy, this.params.proxyTls);
|
||||
this.proxySocket.once(
|
||||
this.proxy.protocol === "https:" ? "secureConnect" : "connect",
|
||||
this.onProxyConnected,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
_resetActiveManagedProxyStateForTests,
|
||||
registerActiveManagedProxyUrl,
|
||||
stopActiveManagedProxyRegistration,
|
||||
} from "./proxy/active-proxy-state.js";
|
||||
|
||||
const PROXY_ENV_KEYS = [
|
||||
"HTTPS_PROXY",
|
||||
@@ -39,11 +44,11 @@ const {
|
||||
}
|
||||
class ProxyAgent {
|
||||
static lastCreated: ProxyAgent | undefined;
|
||||
proxyUrl: string;
|
||||
constructor(proxyUrl: string) {
|
||||
this.proxyUrl = proxyUrl;
|
||||
readonly proxyUrl: string | undefined;
|
||||
constructor(public readonly options: { uri?: string; proxyTls?: unknown } | string) {
|
||||
this.proxyUrl = typeof options === "string" ? options : options.uri;
|
||||
ProxyAgent.lastCreated = this;
|
||||
proxyAgentSpy(proxyUrl);
|
||||
proxyAgentSpy(options);
|
||||
}
|
||||
}
|
||||
class EnvHttpProxyAgent {
|
||||
@@ -142,6 +147,11 @@ describe("makeProxyFetch", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_resetActiveManagedProxyStateForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetActiveManagedProxyStateForTests();
|
||||
});
|
||||
|
||||
it("uses undici fetch with ProxyAgent dispatcher", async () => {
|
||||
@@ -152,7 +162,7 @@ describe("makeProxyFetch", () => {
|
||||
expect(proxyAgentSpy).not.toHaveBeenCalled();
|
||||
await proxyFetch("https://api.example.com/v1/audio");
|
||||
|
||||
expect(proxyAgentSpy).toHaveBeenCalledWith(proxyUrl);
|
||||
expect(proxyAgentSpy).toHaveBeenCalledWith({ uri: proxyUrl });
|
||||
expect(undiciFetch).toHaveBeenCalledOnce();
|
||||
const [input] = requireUndiciFetchCall();
|
||||
const init = requireUndiciFetchInit();
|
||||
@@ -160,6 +170,26 @@ describe("makeProxyFetch", () => {
|
||||
expect(init.dispatcher).toBe(getLastAgent());
|
||||
});
|
||||
|
||||
it("adds active managed proxy CA trust to explicit proxy fetch dispatchers", async () => {
|
||||
const registration = registerActiveManagedProxyUrl(new URL("https://proxy.test:8443"), {
|
||||
proxyTls: { ca: "explicit-proxy-fetch-ca" },
|
||||
});
|
||||
undiciFetch.mockResolvedValue({ ok: true });
|
||||
|
||||
try {
|
||||
const proxyFetch = makeProxyFetch("https://proxy.test:8443");
|
||||
|
||||
await proxyFetch("https://api.example.com/v1/audio");
|
||||
|
||||
expect(proxyAgentSpy).toHaveBeenCalledWith({
|
||||
uri: "https://proxy.test:8443",
|
||||
proxyTls: { ca: "explicit-proxy-fetch-ca" },
|
||||
});
|
||||
} finally {
|
||||
stopActiveManagedProxyRegistration(registration);
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses the same ProxyAgent across calls", async () => {
|
||||
undiciFetch.mockResolvedValue({ ok: true });
|
||||
|
||||
@@ -306,10 +336,12 @@ describe("resolveProxyFetchFromEnv", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
_resetActiveManagedProxyStateForTests();
|
||||
clearProxyEnv();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
_resetActiveManagedProxyStateForTests();
|
||||
restoreProxyEnv();
|
||||
});
|
||||
|
||||
@@ -337,6 +369,29 @@ describe("resolveProxyFetchFromEnv", () => {
|
||||
expect(init.dispatcher).toBe(EnvHttpProxyAgent.lastCreated);
|
||||
});
|
||||
|
||||
it("adds active managed proxy CA trust to env proxy fetch dispatchers", () => {
|
||||
const registration = registerActiveManagedProxyUrl(new URL("https://proxy.test:8443"), {
|
||||
proxyTls: { ca: "proxy-fetch-ca" },
|
||||
});
|
||||
|
||||
try {
|
||||
const fetchFn = requireProxyFetch(
|
||||
resolveProxyFetchFromEnv({
|
||||
HTTP_PROXY: "",
|
||||
HTTPS_PROXY: "https://proxy.test:8443",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fetchFn).toBeTypeOf("function");
|
||||
expect(envAgentSpy).toHaveBeenCalledWith({
|
||||
httpsProxy: "https://proxy.test:8443",
|
||||
proxyTls: { ca: "proxy-fetch-ca" },
|
||||
});
|
||||
} finally {
|
||||
stopActiveManagedProxyRegistration(registration);
|
||||
}
|
||||
});
|
||||
|
||||
it("converts global FormData bodies when using proxy env fetch", async () => {
|
||||
undiciFetch.mockResolvedValue({ ok: true });
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { logWarn } from "../../logger.js";
|
||||
import { formatErrorMessage } from "../errors.js";
|
||||
import { normalizeHeadersInitForFetch } from "../fetch-headers.js";
|
||||
import { resolveEnvHttpProxyAgentOptions } from "./proxy-env.js";
|
||||
import {
|
||||
addActiveManagedProxyTlsOptions,
|
||||
resolveManagedEnvHttpProxyAgentOptions,
|
||||
} from "./proxy/managed-proxy-undici.js";
|
||||
import { loadUndiciRuntimeDeps, type UndiciRuntimeDeps } from "./undici-runtime.js";
|
||||
|
||||
export const PROXY_FETCH_PROXY_URL = Symbol.for("openclaw.proxyFetch.proxyUrl");
|
||||
@@ -75,7 +78,7 @@ export function makeProxyFetch(proxyUrl: string): typeof fetch {
|
||||
let agent: InstanceType<UndiciRuntimeDeps["ProxyAgent"]> | null = null;
|
||||
const resolveAgent = (): InstanceType<UndiciRuntimeDeps["ProxyAgent"]> => {
|
||||
if (!agent) {
|
||||
agent = new ProxyAgent(proxyUrl);
|
||||
agent = new ProxyAgent(addActiveManagedProxyTlsOptions({ uri: proxyUrl }));
|
||||
}
|
||||
return agent;
|
||||
};
|
||||
@@ -113,7 +116,7 @@ export function getProxyUrlFromFetch(fetchImpl?: typeof fetch): string | undefin
|
||||
export function resolveProxyFetchFromEnv(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): typeof fetch | undefined {
|
||||
const proxyOptions = resolveEnvHttpProxyAgentOptions(env);
|
||||
const proxyOptions = resolveManagedEnvHttpProxyAgentOptions(env);
|
||||
if (!proxyOptions) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProxyConfig } from "../../../config/zod-schema.proxy.js";
|
||||
import type { ManagedProxyTlsOptions } from "./proxy-tls.js";
|
||||
|
||||
export type ActiveManagedProxyUrl = Readonly<URL>;
|
||||
|
||||
@@ -7,11 +8,18 @@ export type ActiveManagedProxyLoopbackMode = NonNullable<NonNullable<ProxyConfig
|
||||
export type ActiveManagedProxyRegistration = {
|
||||
proxyUrl: ActiveManagedProxyUrl;
|
||||
loopbackMode: ActiveManagedProxyLoopbackMode;
|
||||
proxyTls?: ManagedProxyTlsOptions;
|
||||
stopped: boolean;
|
||||
};
|
||||
|
||||
export type RegisterActiveManagedProxyOptions = {
|
||||
loopbackMode?: ActiveManagedProxyLoopbackMode;
|
||||
proxyTls?: ManagedProxyTlsOptions;
|
||||
};
|
||||
|
||||
let activeProxyUrl: ActiveManagedProxyUrl | undefined;
|
||||
let activeProxyLoopbackMode: ActiveManagedProxyLoopbackMode | undefined;
|
||||
let activeProxyTlsOptions: ManagedProxyTlsOptions | undefined;
|
||||
let activeProxyRegistrationCount = 0;
|
||||
|
||||
function parseActiveManagedProxyLoopbackMode(
|
||||
@@ -35,9 +43,12 @@ function readInheritedActiveManagedProxyLoopbackMode(): ActiveManagedProxyLoopba
|
||||
|
||||
export function registerActiveManagedProxyUrl(
|
||||
proxyUrl: URL,
|
||||
loopbackMode: ActiveManagedProxyLoopbackMode = "gateway-only",
|
||||
options: ActiveManagedProxyLoopbackMode | RegisterActiveManagedProxyOptions = "gateway-only",
|
||||
): ActiveManagedProxyRegistration {
|
||||
const normalizedProxyUrl = new URL(proxyUrl.href);
|
||||
const loopbackMode =
|
||||
typeof options === "string" ? options : (options.loopbackMode ?? "gateway-only");
|
||||
const proxyTls = typeof options === "string" ? undefined : options.proxyTls;
|
||||
if (activeProxyUrl !== undefined) {
|
||||
if (activeProxyUrl.href !== normalizedProxyUrl.href) {
|
||||
throw new Error(
|
||||
@@ -51,14 +62,33 @@ export function registerActiveManagedProxyUrl(
|
||||
"stop the current proxy before changing proxy.loopbackMode.",
|
||||
);
|
||||
}
|
||||
if (!areProxyTlsOptionsEqual(activeProxyTlsOptions, proxyTls)) {
|
||||
throw new Error(
|
||||
"proxy: cannot activate a managed proxy with different proxy TLS options while another proxy is active; " +
|
||||
"stop the current proxy before changing proxy.tls.",
|
||||
);
|
||||
}
|
||||
activeProxyRegistrationCount += 1;
|
||||
return { proxyUrl: activeProxyUrl, loopbackMode, stopped: false };
|
||||
return {
|
||||
proxyUrl: activeProxyUrl,
|
||||
loopbackMode,
|
||||
proxyTls: activeProxyTlsOptions,
|
||||
stopped: false,
|
||||
};
|
||||
}
|
||||
|
||||
activeProxyUrl = normalizedProxyUrl;
|
||||
activeProxyLoopbackMode = loopbackMode;
|
||||
activeProxyTlsOptions = proxyTls;
|
||||
activeProxyRegistrationCount = 1;
|
||||
return { proxyUrl: activeProxyUrl, loopbackMode, stopped: false };
|
||||
return { proxyUrl: activeProxyUrl, loopbackMode, proxyTls, stopped: false };
|
||||
}
|
||||
|
||||
function areProxyTlsOptionsEqual(
|
||||
left: ManagedProxyTlsOptions | undefined,
|
||||
right: ManagedProxyTlsOptions | undefined,
|
||||
): boolean {
|
||||
return left?.ca === right?.ca;
|
||||
}
|
||||
|
||||
export function stopActiveManagedProxyRegistration(
|
||||
@@ -75,6 +105,7 @@ export function stopActiveManagedProxyRegistration(
|
||||
if (activeProxyRegistrationCount === 0) {
|
||||
activeProxyUrl = undefined;
|
||||
activeProxyLoopbackMode = undefined;
|
||||
activeProxyTlsOptions = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +117,13 @@ export function getActiveManagedProxyUrl(): ActiveManagedProxyUrl | undefined {
|
||||
return activeProxyUrl;
|
||||
}
|
||||
|
||||
export function getActiveManagedProxyTlsOptions(): ManagedProxyTlsOptions | undefined {
|
||||
return activeProxyTlsOptions;
|
||||
}
|
||||
|
||||
export function _resetActiveManagedProxyStateForTests(): void {
|
||||
activeProxyUrl = undefined;
|
||||
activeProxyLoopbackMode = undefined;
|
||||
activeProxyTlsOptions = undefined;
|
||||
activeProxyRegistrationCount = 0;
|
||||
}
|
||||
|
||||
112
src/infra/net/proxy/managed-proxy-undici.test.ts
Normal file
112
src/infra/net/proxy/managed-proxy-undici.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
_resetActiveManagedProxyStateForTests,
|
||||
registerActiveManagedProxyUrl,
|
||||
} from "./active-proxy-state.js";
|
||||
import {
|
||||
addActiveManagedProxyTlsOptions,
|
||||
resolveActiveManagedProxyTlsOptions,
|
||||
resolveManagedEnvHttpProxyAgentOptions,
|
||||
} from "./managed-proxy-undici.js";
|
||||
|
||||
describe("managed proxy undici TLS options", () => {
|
||||
const envKeys = [
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"OPENCLAW_PROXY_ACTIVE",
|
||||
"OPENCLAW_PROXY_CA_FILE",
|
||||
] as const;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
_resetActiveManagedProxyStateForTests();
|
||||
for (const key of envKeys) {
|
||||
vi.stubEnv(key, "");
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetActiveManagedProxyStateForTests();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function writeTempCa(contents: string): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-managed-proxy-ca-"));
|
||||
tempDirs.push(dir);
|
||||
const caFile = path.join(dir, "proxy-ca.pem");
|
||||
writeFileSync(caFile, contents, "utf8");
|
||||
return caFile;
|
||||
}
|
||||
|
||||
it("adds active proxy CA trust only to matching explicit proxy URLs", () => {
|
||||
vi.stubEnv("OPENCLAW_PROXY_ACTIVE", "1");
|
||||
registerActiveManagedProxyUrl(new URL("https://managed.example:8443"), {
|
||||
loopbackMode: "gateway-only",
|
||||
proxyTls: { ca: "active-managed-ca" },
|
||||
});
|
||||
|
||||
expect(
|
||||
addActiveManagedProxyTlsOptions({
|
||||
uri: "https://managed.example:8443",
|
||||
allowH2: false,
|
||||
}),
|
||||
).toStrictEqual({
|
||||
uri: "https://managed.example:8443",
|
||||
allowH2: false,
|
||||
proxyTls: { ca: "active-managed-ca" },
|
||||
});
|
||||
expect(
|
||||
addActiveManagedProxyTlsOptions({
|
||||
uri: "https://account-proxy.example:8443",
|
||||
allowH2: false,
|
||||
}),
|
||||
).toStrictEqual({
|
||||
uri: "https://account-proxy.example:8443",
|
||||
allowH2: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("loads inherited proxy CA trust only for the inherited proxy URL", () => {
|
||||
const caFile = writeTempCa("inherited-managed-ca");
|
||||
vi.stubEnv("OPENCLAW_PROXY_ACTIVE", "1");
|
||||
vi.stubEnv("https_proxy", "https://managed.example:8443");
|
||||
vi.stubEnv("OPENCLAW_PROXY_CA_FILE", caFile);
|
||||
|
||||
expect(resolveActiveManagedProxyTlsOptions()).toStrictEqual({
|
||||
ca: "inherited-managed-ca",
|
||||
});
|
||||
expect(
|
||||
resolveActiveManagedProxyTlsOptions({
|
||||
proxyUrl: "https://managed.example:8443",
|
||||
}),
|
||||
).toStrictEqual({ ca: "inherited-managed-ca" });
|
||||
expect(
|
||||
resolveActiveManagedProxyTlsOptions({
|
||||
proxyUrl: "https://account-proxy.example:8443",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("loads inherited proxy CA trust from supplied env", () => {
|
||||
const caFile = writeTempCa("supplied-env-managed-ca");
|
||||
|
||||
expect(
|
||||
resolveManagedEnvHttpProxyAgentOptions({
|
||||
OPENCLAW_PROXY_ACTIVE: "1",
|
||||
HTTPS_PROXY: "https://managed.example:8443",
|
||||
OPENCLAW_PROXY_CA_FILE: caFile,
|
||||
}),
|
||||
).toStrictEqual({
|
||||
httpsProxy: "https://managed.example:8443",
|
||||
proxyTls: { ca: "supplied-env-managed-ca" },
|
||||
});
|
||||
});
|
||||
});
|
||||
149
src/infra/net/proxy/managed-proxy-undici.ts
Normal file
149
src/infra/net/proxy/managed-proxy-undici.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { EnvHttpProxyAgent } from "undici";
|
||||
import { resolveEnvHttpProxyAgentOptions, resolveEnvHttpProxyUrl } from "../proxy-env.js";
|
||||
import { getActiveManagedProxyTlsOptions, getActiveManagedProxyUrl } from "./active-proxy-state.js";
|
||||
import {
|
||||
loadManagedProxyTlsOptionsSync,
|
||||
resolveManagedProxyCaFileForUrl,
|
||||
type ManagedProxyTlsOptions,
|
||||
} from "./proxy-tls.js";
|
||||
|
||||
export type ManagedEnvHttpProxyAgentOptions = ConstructorParameters<typeof EnvHttpProxyAgent>[0];
|
||||
|
||||
function isProxyTlsRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readProxyTlsRecord(options: object | undefined): Record<string, unknown> | undefined {
|
||||
if (!options || !("proxyTls" in options)) {
|
||||
return undefined;
|
||||
}
|
||||
return isProxyTlsRecord(options.proxyTls) ? options.proxyTls : undefined;
|
||||
}
|
||||
|
||||
function readProxyUrlFromOptions(options: object | undefined): string | undefined {
|
||||
if (!options) {
|
||||
return undefined;
|
||||
}
|
||||
if ("uri" in options) {
|
||||
const uri: unknown = Reflect.get(options, "uri");
|
||||
return uri instanceof URL ? uri.href : typeof uri === "string" ? uri : undefined;
|
||||
}
|
||||
if ("httpsProxy" in options || "httpProxy" in options) {
|
||||
const httpsProxy: unknown = Reflect.get(options, "httpsProxy");
|
||||
const httpProxy: unknown = Reflect.get(options, "httpProxy");
|
||||
return typeof httpsProxy === "string"
|
||||
? httpsProxy
|
||||
: typeof httpProxy === "string"
|
||||
? httpProxy
|
||||
: undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeProxyUrl(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(value).href;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type ManagedProxyTlsEnv = NodeJS.ProcessEnv;
|
||||
|
||||
type ResolveActiveManagedProxyTlsOptionsParams = {
|
||||
proxyUrl?: string;
|
||||
env?: ManagedProxyTlsEnv;
|
||||
};
|
||||
|
||||
type AddActiveManagedProxyTlsOptionsParams = {
|
||||
env?: ManagedProxyTlsEnv;
|
||||
};
|
||||
|
||||
function resolveManagedProxyUrl(env: ManagedProxyTlsEnv = process.env): string | undefined {
|
||||
const activeProxyUrl = getActiveManagedProxyUrl();
|
||||
if (activeProxyUrl) {
|
||||
return activeProxyUrl.href;
|
||||
}
|
||||
if (env["OPENCLAW_PROXY_ACTIVE"] !== "1") {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeProxyUrl(resolveEnvHttpProxyUrl("https", env));
|
||||
}
|
||||
|
||||
export function resolveActiveManagedProxyTlsOptions(
|
||||
params?: ResolveActiveManagedProxyTlsOptionsParams,
|
||||
): ManagedProxyTlsOptions | undefined {
|
||||
const env = params?.env ?? process.env;
|
||||
const managedProxyUrl = resolveManagedProxyUrl(env);
|
||||
const targetProxyUrl = normalizeProxyUrl(
|
||||
params?.proxyUrl ?? resolveEnvHttpProxyUrl("https", env),
|
||||
);
|
||||
if (!managedProxyUrl || targetProxyUrl !== managedProxyUrl) {
|
||||
return undefined;
|
||||
}
|
||||
const activeProxyTls = getActiveManagedProxyTlsOptions();
|
||||
if (activeProxyTls) {
|
||||
return activeProxyTls;
|
||||
}
|
||||
const proxyCaFile = resolveManagedProxyCaFileForUrl({
|
||||
proxyUrl: managedProxyUrl,
|
||||
caFileOverride: env["OPENCLAW_PROXY_CA_FILE"],
|
||||
});
|
||||
try {
|
||||
return loadManagedProxyTlsOptionsSync(proxyCaFile);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function addActiveManagedProxyTlsOptions(
|
||||
options: undefined,
|
||||
params?: AddActiveManagedProxyTlsOptionsParams,
|
||||
): { proxyTls: ManagedProxyTlsOptions } | undefined;
|
||||
export function addActiveManagedProxyTlsOptions<TOptions extends object>(
|
||||
options: TOptions,
|
||||
params?: AddActiveManagedProxyTlsOptionsParams,
|
||||
): TOptions | (TOptions & { proxyTls: Record<string, unknown> });
|
||||
export function addActiveManagedProxyTlsOptions<TOptions extends object>(
|
||||
options: TOptions | undefined,
|
||||
params?: AddActiveManagedProxyTlsOptionsParams,
|
||||
):
|
||||
| TOptions
|
||||
| (TOptions & { proxyTls: Record<string, unknown> })
|
||||
| {
|
||||
proxyTls: ManagedProxyTlsOptions;
|
||||
}
|
||||
| undefined;
|
||||
export function addActiveManagedProxyTlsOptions<TOptions extends object>(
|
||||
options: TOptions | undefined,
|
||||
params?: AddActiveManagedProxyTlsOptionsParams,
|
||||
):
|
||||
| TOptions
|
||||
| (TOptions & { proxyTls: Record<string, unknown> })
|
||||
| { proxyTls: ManagedProxyTlsOptions }
|
||||
| undefined {
|
||||
const proxyTls = resolveActiveManagedProxyTlsOptions({
|
||||
proxyUrl: readProxyUrlFromOptions(options),
|
||||
env: params?.env,
|
||||
});
|
||||
if (!proxyTls) {
|
||||
return options;
|
||||
}
|
||||
const existingProxyTls = readProxyTlsRecord(options);
|
||||
return {
|
||||
...options,
|
||||
proxyTls: {
|
||||
...proxyTls,
|
||||
...existingProxyTls,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveManagedEnvHttpProxyAgentOptions(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ManagedEnvHttpProxyAgentOptions | undefined {
|
||||
return addActiveManagedProxyTlsOptions(resolveEnvHttpProxyAgentOptions(env), { env });
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
@@ -42,7 +45,10 @@ vi.mock("../../../logger.js", () => ({
|
||||
}));
|
||||
|
||||
import { logInfo, logWarn } from "../../../logger.js";
|
||||
import { _resetActiveManagedProxyStateForTests } from "./active-proxy-state.js";
|
||||
import {
|
||||
_resetActiveManagedProxyStateForTests,
|
||||
getActiveManagedProxyTlsOptions,
|
||||
} from "./active-proxy-state.js";
|
||||
import {
|
||||
ensureInheritedManagedProxyRoutingActive,
|
||||
resetProxyLifecycleForTests,
|
||||
@@ -85,9 +91,11 @@ describe("startProxy", () => {
|
||||
"no_proxy",
|
||||
"NO_PROXY",
|
||||
"OPENCLAW_PROXY_ACTIVE",
|
||||
"OPENCLAW_PROXY_CA_FILE",
|
||||
"OPENCLAW_PROXY_LOOPBACK_MODE",
|
||||
"OPENCLAW_PROXY_URL",
|
||||
];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of envKeysToClean) {
|
||||
@@ -106,6 +114,9 @@ describe("startProxy", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
for (const key of envKeysToClean) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
@@ -115,6 +126,14 @@ describe("startProxy", () => {
|
||||
}
|
||||
});
|
||||
|
||||
function writeTempCa(contents = "proxy-ca"): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-lifecycle-ca-"));
|
||||
tempDirs.push(dir);
|
||||
const caFile = path.join(dir, "proxy-ca.pem");
|
||||
writeFileSync(caFile, contents, "utf8");
|
||||
return caFile;
|
||||
}
|
||||
|
||||
it("returns null silently and does not touch env when not explicitly enabled", async () => {
|
||||
const handle = await startProxy(undefined);
|
||||
|
||||
@@ -177,13 +196,78 @@ describe("startProxy", () => {
|
||||
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3129");
|
||||
});
|
||||
|
||||
it("throws for HTTPS proxy URLs from OPENCLAW_PROXY_URL", async () => {
|
||||
it("uses HTTPS proxy URLs from OPENCLAW_PROXY_URL", async () => {
|
||||
process.env["OPENCLAW_PROXY_URL"] = "https://127.0.0.1:3128";
|
||||
|
||||
await expect(startProxy({ enabled: true })).rejects.toThrow("http:// forward proxy");
|
||||
const handle = await startProxy({ enabled: true });
|
||||
|
||||
expect(process.env["HTTP_PROXY"]).toBeUndefined();
|
||||
expect(mockLogWarn).not.toHaveBeenCalled();
|
||||
expect(expectProxyHandle(handle).proxyUrl).toBe("https://127.0.0.1:3128");
|
||||
expect(process.env["HTTP_PROXY"]).toBe("https://127.0.0.1:3128");
|
||||
expect(installGlobalProxyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: "managed",
|
||||
proxyUrl: "https://127.0.0.1:3128",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes configured proxy CA trust to Proxyline", async () => {
|
||||
const caFile = writeTempCa("active-proxy-ca");
|
||||
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "https://127.0.0.1:3128",
|
||||
tls: { caFile },
|
||||
});
|
||||
|
||||
expect(getActiveManagedProxyTlsOptions()).toEqual({ ca: "active-proxy-ca" });
|
||||
expect(process.env["OPENCLAW_PROXY_CA_FILE"]).toBe(caFile);
|
||||
expect(installGlobalProxyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
proxyTls: { ca: "active-proxy-ca" },
|
||||
}),
|
||||
);
|
||||
|
||||
await stopProxy(expectProxyHandle(handle));
|
||||
});
|
||||
|
||||
it("does not load configured proxy CA files for plain HTTP proxy URLs", async () => {
|
||||
const missingCaFile = path.join(os.tmpdir(), "openclaw-missing-http-proxy-ca.pem");
|
||||
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
tls: { caFile: missingCaFile },
|
||||
});
|
||||
|
||||
expect(expectProxyHandle(handle).proxyUrl).toBe("http://127.0.0.1:3128");
|
||||
expect(installGlobalProxyMock).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
proxyTls: expect.anything(),
|
||||
}),
|
||||
);
|
||||
|
||||
await stopProxy(handle);
|
||||
});
|
||||
|
||||
it("loads inherited HTTPS proxy CA trust for child routing", () => {
|
||||
const caFile = writeTempCa("inherited-https-proxy-ca");
|
||||
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
|
||||
process.env["OPENCLAW_PROXY_LOOPBACK_MODE"] = "gateway-only";
|
||||
process.env["HTTP_PROXY"] = "https://proxy.example:8443";
|
||||
process.env["OPENCLAW_PROXY_CA_FILE"] = caFile;
|
||||
|
||||
ensureInheritedManagedProxyRoutingActive();
|
||||
|
||||
expect(getActiveManagedProxyTlsOptions()).toBeUndefined();
|
||||
expect(installGlobalProxyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ifActive: "reuse-compatible",
|
||||
mode: "managed",
|
||||
proxyTls: { ca: "inherited-https-proxy-ca" },
|
||||
proxyUrl: "https://proxy.example:8443",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets process proxy env vars for inherited clients", async () => {
|
||||
|
||||
@@ -25,6 +25,11 @@ import {
|
||||
stopActiveManagedProxyRegistration,
|
||||
type ActiveManagedProxyRegistration,
|
||||
} from "./active-proxy-state.js";
|
||||
import {
|
||||
loadManagedProxyTlsOptions,
|
||||
loadManagedProxyTlsOptionsSync,
|
||||
resolveManagedProxyCaFileForUrl,
|
||||
} from "./proxy-tls.js";
|
||||
|
||||
export type ProxyHandle = {
|
||||
/** The operator-managed proxy URL injected into process.env. */
|
||||
@@ -37,7 +42,11 @@ export type ProxyHandle = {
|
||||
|
||||
const PROXY_ENV_KEYS = ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"] as const;
|
||||
const NO_PROXY_ENV_KEYS = ["no_proxy", "NO_PROXY"] as const;
|
||||
const PROXY_ACTIVE_KEYS = ["OPENCLAW_PROXY_ACTIVE", "OPENCLAW_PROXY_LOOPBACK_MODE"] as const;
|
||||
const PROXY_ACTIVE_KEYS = [
|
||||
"OPENCLAW_PROXY_ACTIVE",
|
||||
"OPENCLAW_PROXY_LOOPBACK_MODE",
|
||||
"OPENCLAW_PROXY_CA_FILE",
|
||||
] as const;
|
||||
const ALL_PROXY_ENV_KEYS = [...PROXY_ENV_KEYS, ...NO_PROXY_ENV_KEYS, ...PROXY_ACTIVE_KEYS] as const;
|
||||
type ProxyEnvKey = (typeof ALL_PROXY_ENV_KEYS)[number];
|
||||
type ProxyEnvSnapshot = Record<ProxyEnvKey, string | undefined>;
|
||||
@@ -64,21 +73,35 @@ function captureProxyEnv(): ProxyEnvSnapshot {
|
||||
NO_PROXY: process.env["NO_PROXY"],
|
||||
OPENCLAW_PROXY_ACTIVE: process.env["OPENCLAW_PROXY_ACTIVE"],
|
||||
OPENCLAW_PROXY_LOOPBACK_MODE: process.env["OPENCLAW_PROXY_LOOPBACK_MODE"],
|
||||
OPENCLAW_PROXY_CA_FILE: process.env["OPENCLAW_PROXY_CA_FILE"],
|
||||
};
|
||||
}
|
||||
|
||||
function injectProxyEnv(proxyUrl: string, loopbackMode: ProxyLoopbackMode): ProxyEnvSnapshot {
|
||||
function injectProxyEnv(
|
||||
proxyUrl: string,
|
||||
loopbackMode: ProxyLoopbackMode,
|
||||
proxyCaFile: string | undefined,
|
||||
): ProxyEnvSnapshot {
|
||||
const snapshot = captureProxyEnv();
|
||||
applyProxyEnv(proxyUrl, loopbackMode);
|
||||
applyProxyEnv(proxyUrl, loopbackMode, proxyCaFile);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function applyProxyEnv(proxyUrl: string, loopbackMode: ProxyLoopbackMode): void {
|
||||
function applyProxyEnv(
|
||||
proxyUrl: string,
|
||||
loopbackMode: ProxyLoopbackMode,
|
||||
proxyCaFile: string | undefined,
|
||||
): void {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
process.env[key] = proxyUrl;
|
||||
}
|
||||
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
|
||||
process.env["OPENCLAW_PROXY_LOOPBACK_MODE"] = loopbackMode;
|
||||
if (proxyCaFile) {
|
||||
process.env["OPENCLAW_PROXY_CA_FILE"] = proxyCaFile;
|
||||
} else {
|
||||
delete process.env["OPENCLAW_PROXY_CA_FILE"];
|
||||
}
|
||||
for (const key of NO_PROXY_ENV_KEYS) {
|
||||
process.env[key] = "";
|
||||
}
|
||||
@@ -129,7 +152,7 @@ function stopActiveProxyRegistration(registration: ActiveManagedProxyRegistratio
|
||||
function isSupportedProxyUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:";
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -140,13 +163,13 @@ function resolveProxyUrl(config: ProxyConfig | undefined): string {
|
||||
if (!candidate) {
|
||||
throw new Error(
|
||||
"proxy: enabled but no HTTP proxy URL is configured; set proxy.proxyUrl " +
|
||||
"or OPENCLAW_PROXY_URL to an http:// forward proxy.",
|
||||
"or OPENCLAW_PROXY_URL to an http:// or https:// forward proxy.",
|
||||
);
|
||||
}
|
||||
if (!isSupportedProxyUrl(candidate)) {
|
||||
throw new Error(
|
||||
"proxy: enabled but proxy URL is invalid; set proxy.proxyUrl " +
|
||||
"or OPENCLAW_PROXY_URL to an http:// forward proxy.",
|
||||
"or OPENCLAW_PROXY_URL to an http:// or https:// forward proxy.",
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
@@ -169,9 +192,15 @@ export function ensureInheritedManagedProxyRoutingActive(): void {
|
||||
if (!proxyUrl || !isSupportedProxyUrl(proxyUrl)) {
|
||||
return;
|
||||
}
|
||||
const proxyCaFile = resolveManagedProxyCaFileForUrl({
|
||||
proxyUrl,
|
||||
caFileOverride: process.env["OPENCLAW_PROXY_CA_FILE"],
|
||||
});
|
||||
const proxyTls = loadManagedProxyTlsOptionsSync(proxyCaFile);
|
||||
proxylineHandle = installGlobalProxy({
|
||||
mode: "managed",
|
||||
proxyUrl,
|
||||
...(proxyTls ? { proxyTls } : {}),
|
||||
ifActive: "reuse-compatible",
|
||||
undici: MANAGED_PROXY_UNDICI_OPTIONS,
|
||||
});
|
||||
@@ -185,9 +214,14 @@ export async function startProxy(config: ProxyConfig | undefined): Promise<Proxy
|
||||
|
||||
const proxyUrl = resolveProxyUrl(config);
|
||||
const loopbackMode = config.loopbackMode ?? "gateway-only";
|
||||
const proxyCaFile = resolveManagedProxyCaFileForUrl({ proxyUrl, config });
|
||||
const proxyTls = await loadManagedProxyTlsOptions(proxyCaFile);
|
||||
const activeProxyUrl = getActiveManagedProxyUrl();
|
||||
if (activeProxyUrl) {
|
||||
const registration = registerActiveManagedProxyUrl(new URL(proxyUrl), loopbackMode);
|
||||
const registration = registerActiveManagedProxyUrl(new URL(proxyUrl), {
|
||||
loopbackMode,
|
||||
proxyTls,
|
||||
});
|
||||
const handle: ProxyHandle = {
|
||||
proxyUrl,
|
||||
stop: async () => {
|
||||
@@ -204,16 +238,23 @@ export async function startProxy(config: ProxyConfig | undefined): Promise<Proxy
|
||||
let registration: ActiveManagedProxyRegistration | null = null;
|
||||
|
||||
try {
|
||||
injectProxyEnv(proxyUrl, loopbackMode);
|
||||
injectProxyEnv(proxyUrl, loopbackMode, proxyCaFile);
|
||||
proxylineHandle = installGlobalProxy({
|
||||
mode: "managed",
|
||||
proxyUrl,
|
||||
...(proxyTls ? { proxyTls } : {}),
|
||||
ifActive: "replace",
|
||||
undici: MANAGED_PROXY_UNDICI_OPTIONS,
|
||||
});
|
||||
forceResetGlobalDispatcher({ preserveProxylineManaged: true });
|
||||
registration = registerActiveManagedProxyUrl(new URL(proxyUrl), loopbackMode);
|
||||
registration = registerActiveManagedProxyUrl(new URL(proxyUrl), {
|
||||
loopbackMode,
|
||||
proxyTls,
|
||||
});
|
||||
} catch (err) {
|
||||
if (registration) {
|
||||
stopActiveManagedProxyRegistration(registration);
|
||||
}
|
||||
restoreAfterFailedProxyActivation(lifecycleBaseEnvSnapshot);
|
||||
throw new Error(`proxy: failed to activate external proxy routing: ${String(err)}`, {
|
||||
cause: err,
|
||||
|
||||
81
src/infra/net/proxy/proxy-tls.ts
Normal file
81
src/infra/net/proxy/proxy-tls.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { ProxyConfig } from "../../../config/zod-schema.proxy.js";
|
||||
|
||||
export type ManagedProxyTlsOptions = Readonly<{
|
||||
ca?: string;
|
||||
}>;
|
||||
|
||||
function normalizeOptionalPath(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function formatReadError(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function isHttpsProxyUrl(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new URL(value).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveManagedProxyCaFile(params: {
|
||||
config?: ProxyConfig;
|
||||
caFileOverride?: string;
|
||||
}): string | undefined {
|
||||
return (
|
||||
normalizeOptionalPath(params.caFileOverride) ??
|
||||
normalizeOptionalPath(params.config?.tls?.caFile)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveManagedProxyCaFileForUrl(params: {
|
||||
proxyUrl: string | undefined;
|
||||
config?: ProxyConfig;
|
||||
caFileOverride?: string;
|
||||
}): string | undefined {
|
||||
if (!isHttpsProxyUrl(params.proxyUrl)) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveManagedProxyCaFile({
|
||||
config: params.config,
|
||||
caFileOverride: params.caFileOverride,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadManagedProxyTlsOptions(
|
||||
caFile: string | undefined,
|
||||
): Promise<ManagedProxyTlsOptions | undefined> {
|
||||
if (!caFile) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return { ca: await readFile(caFile, "utf8") };
|
||||
} catch (err) {
|
||||
throw new Error(`proxy CA file could not be read (${caFile}): ${formatReadError(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function loadManagedProxyTlsOptionsSync(
|
||||
caFile: string | undefined,
|
||||
): ManagedProxyTlsOptions | undefined {
|
||||
if (!caFile) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return { ca: readFileSync(caFile, "utf8") };
|
||||
} catch (err) {
|
||||
throw new Error(`proxy CA file could not be read (${caFile}): ${formatReadError(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_PROXY_VALIDATION_ALLOWED_URLS,
|
||||
resolveProxyValidationConfig,
|
||||
@@ -6,6 +9,22 @@ import {
|
||||
} from "./proxy-validation.js";
|
||||
|
||||
describe("proxy validation", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function writeTempCa(contents = "proxy-ca"): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-validation-ca-"));
|
||||
tempDirs.push(dir);
|
||||
const caFile = path.join(dir, "proxy-ca.pem");
|
||||
writeFileSync(caFile, contents, "utf8");
|
||||
return caFile;
|
||||
}
|
||||
|
||||
it("resolves proxy URL overrides before config and OPENCLAW_PROXY_URL", () => {
|
||||
const result = resolveProxyValidationConfig({
|
||||
proxyUrlOverride: "http://override-proxy.example:3128",
|
||||
@@ -166,7 +185,7 @@ describe("proxy validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-http proxy URLs", () => {
|
||||
it("accepts HTTPS proxy URLs", () => {
|
||||
const result = resolveProxyValidationConfig({
|
||||
config: {
|
||||
enabled: true,
|
||||
@@ -175,7 +194,24 @@ describe("proxy validation", () => {
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.errors).toEqual(["proxyUrl must use http://"]);
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
proxyUrl: "https://proxy.example:3128",
|
||||
source: "config",
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported proxy URL protocols", () => {
|
||||
const result = resolveProxyValidationConfig({
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "socks5://proxy.example:1080",
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.errors).toEqual(["proxyUrl must use http:// or https://"]);
|
||||
});
|
||||
|
||||
it("checks default allowed and denied destinations through the proxy", async () => {
|
||||
@@ -461,6 +497,128 @@ describe("proxy validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes CLI proxy CA file contents to validation checks", async () => {
|
||||
const caFile = writeTempCa("cli-proxy-ca");
|
||||
const fetchCheck = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
const apnsCheck = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 403, apnsId: "00000000-0000-0000-0000-000000000000" });
|
||||
|
||||
const result = await runProxyValidation({
|
||||
proxyUrlOverride: "https://proxy.example:8443",
|
||||
proxyCaFileOverride: caFile,
|
||||
allowedUrls: ["https://example.com/"],
|
||||
deniedUrls: [],
|
||||
apnsReachability: true,
|
||||
fetchCheck,
|
||||
apnsCheck,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fetchCheck).toHaveBeenCalledWith({
|
||||
proxyUrl: "https://proxy.example:8443",
|
||||
targetUrl: "https://example.com/",
|
||||
timeoutMs: 5000,
|
||||
proxyTls: { ca: "cli-proxy-ca" },
|
||||
});
|
||||
expect(apnsCheck).toHaveBeenCalledWith({
|
||||
proxyUrl: "https://proxy.example:8443",
|
||||
authority: "https://api.sandbox.push.apple.com",
|
||||
timeoutMs: 5000,
|
||||
proxyTls: { ca: "cli-proxy-ca" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inherit configured proxy CA files for explicit proxy URL validation", async () => {
|
||||
const configCaFile = writeTempCa("stale-config-proxy-ca");
|
||||
const fetchCheck = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const result = await runProxyValidation({
|
||||
proxyUrlOverride: "https://override-proxy.example:8443",
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "https://config-proxy.example:8443",
|
||||
tls: { caFile: configCaFile },
|
||||
},
|
||||
allowedUrls: ["https://example.com/"],
|
||||
deniedUrls: [],
|
||||
fetchCheck,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.config.proxyCaFile).toBeUndefined();
|
||||
expect(fetchCheck).toHaveBeenCalledWith({
|
||||
proxyUrl: "https://override-proxy.example:8443",
|
||||
targetUrl: "https://example.com/",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not load proxy CA files for plain HTTP proxy validation", async () => {
|
||||
const missingCaFile = path.join(os.tmpdir(), "openclaw-missing-http-proxy-validation-ca.pem");
|
||||
const fetchCheck = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const result = await runProxyValidation({
|
||||
proxyUrlOverride: "http://proxy.example:8080",
|
||||
proxyCaFileOverride: missingCaFile,
|
||||
allowedUrls: ["https://example.com/"],
|
||||
deniedUrls: [],
|
||||
fetchCheck,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fetchCheck).toHaveBeenCalledWith({
|
||||
proxyUrl: "http://proxy.example:8080",
|
||||
targetUrl: "https://example.com/",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured proxy CA file contents when no CLI override is supplied", async () => {
|
||||
const caFile = writeTempCa("config-proxy-ca");
|
||||
const fetchCheck = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await runProxyValidation({
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "https://proxy.example:8443",
|
||||
tls: { caFile },
|
||||
},
|
||||
env: {},
|
||||
allowedUrls: ["https://example.com/"],
|
||||
deniedUrls: [],
|
||||
fetchCheck,
|
||||
});
|
||||
|
||||
expect(fetchCheck).toHaveBeenCalledWith({
|
||||
proxyUrl: "https://proxy.example:8443",
|
||||
targetUrl: "https://example.com/",
|
||||
timeoutMs: 5000,
|
||||
proxyTls: { ca: "config-proxy-ca" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed before probing when proxy CA file cannot be loaded", async () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-validation-missing-ca-"));
|
||||
tempDirs.push(dir);
|
||||
const fetchCheck = vi.fn();
|
||||
|
||||
const result = await runProxyValidation({
|
||||
proxyUrlOverride: "https://proxy.example:8443",
|
||||
proxyCaFileOverride: path.join(dir, "missing.pem"),
|
||||
allowedUrls: ["https://example.com/"],
|
||||
deniedUrls: [],
|
||||
fetchCheck,
|
||||
});
|
||||
|
||||
expect(fetchCheck).not.toHaveBeenCalled();
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.config.errors).toEqual([
|
||||
expect.stringContaining("proxy CA file could not be read"),
|
||||
]);
|
||||
expect(result.checks).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts APNs 403 reachability with InvalidProviderToken when apns-id is unavailable", async () => {
|
||||
const result = await runProxyValidation({
|
||||
config: {
|
||||
|
||||
@@ -4,6 +4,11 @@ import type { ProxyConfig } from "../../../config/zod-schema.proxy.js";
|
||||
import { probeApnsHttp2ReachabilityViaProxy } from "../../push-apns-http2.js";
|
||||
import { fetchWithRuntimeDispatcher } from "../runtime-fetch.js";
|
||||
import { createHttp1ProxyAgent } from "../undici-runtime.js";
|
||||
import {
|
||||
loadManagedProxyTlsOptions,
|
||||
resolveManagedProxyCaFileForUrl,
|
||||
type ManagedProxyTlsOptions,
|
||||
} from "./proxy-tls.js";
|
||||
|
||||
export const DEFAULT_PROXY_VALIDATION_ALLOWED_URLS = ["https://example.com/"] as const;
|
||||
export const DEFAULT_PROXY_VALIDATION_APNS_AUTHORITY = "https://api.sandbox.push.apple.com";
|
||||
@@ -17,6 +22,7 @@ export type ProxyValidationConfigSource = "override" | "config" | "env" | "missi
|
||||
export type ProxyValidationResolvedConfig = {
|
||||
enabled: boolean;
|
||||
proxyUrl?: string;
|
||||
proxyCaFile?: string;
|
||||
source: ProxyValidationConfigSource;
|
||||
errors: string[];
|
||||
};
|
||||
@@ -39,6 +45,7 @@ export type ProxyValidationResult = {
|
||||
|
||||
export type ProxyValidationFetchCheckParams = {
|
||||
proxyUrl: string;
|
||||
proxyTls?: ManagedProxyTlsOptions;
|
||||
targetUrl: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
@@ -55,6 +62,7 @@ export type ProxyValidationFetchCheck = (
|
||||
|
||||
export type ProxyValidationApnsCheckParams = {
|
||||
proxyUrl: string;
|
||||
proxyTls?: ManagedProxyTlsOptions;
|
||||
authority: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
@@ -75,6 +83,7 @@ export type ResolveProxyValidationConfigOptions = {
|
||||
config?: ProxyConfig;
|
||||
env?: NodeJS.ProcessEnv | Partial<Record<"OPENCLAW_PROXY_URL", string | undefined>>;
|
||||
proxyUrlOverride?: string;
|
||||
proxyCaFileOverride?: string;
|
||||
};
|
||||
|
||||
export type RunProxyValidationOptions = ResolveProxyValidationConfigOptions & {
|
||||
@@ -92,9 +101,10 @@ function normalizeProxyUrl(value: string | undefined): string | undefined {
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isHttpProxyUrl(value: string): boolean {
|
||||
function isHttpOrHttpsProxyUrl(value: string): boolean {
|
||||
try {
|
||||
return new URL(value).protocol === "http:";
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -104,8 +114,8 @@ function validateProxyUrl(value: string | undefined): string[] {
|
||||
if (!value) {
|
||||
return ["proxy validation requires proxy.proxyUrl, --proxy-url, or OPENCLAW_PROXY_URL"];
|
||||
}
|
||||
if (!isHttpProxyUrl(value)) {
|
||||
return ["proxyUrl must use http://"];
|
||||
if (!isHttpOrHttpsProxyUrl(value)) {
|
||||
return ["proxyUrl must use http:// or https://"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -133,9 +143,14 @@ export function resolveProxyValidationConfig(
|
||||
): ProxyValidationResolvedConfig {
|
||||
const overrideUrl = normalizeProxyUrl(options.proxyUrlOverride);
|
||||
if (overrideUrl) {
|
||||
const proxyCaFile = resolveManagedProxyCaFileForUrl({
|
||||
proxyUrl: overrideUrl,
|
||||
caFileOverride: options.proxyCaFileOverride,
|
||||
});
|
||||
return {
|
||||
enabled: true,
|
||||
proxyUrl: overrideUrl,
|
||||
...(proxyCaFile ? { proxyCaFile } : {}),
|
||||
source: "override",
|
||||
errors: validateResolvedProxy("override", true, overrideUrl),
|
||||
};
|
||||
@@ -143,9 +158,15 @@ export function resolveProxyValidationConfig(
|
||||
|
||||
const configUrl = normalizeProxyUrl(options.config?.proxyUrl);
|
||||
if (configUrl) {
|
||||
const proxyCaFile = resolveManagedProxyCaFileForUrl({
|
||||
proxyUrl: configUrl,
|
||||
config: options.config,
|
||||
caFileOverride: options.proxyCaFileOverride,
|
||||
});
|
||||
return {
|
||||
enabled: options.config?.enabled === true,
|
||||
proxyUrl: configUrl,
|
||||
...(proxyCaFile ? { proxyCaFile } : {}),
|
||||
source: "config",
|
||||
errors: validateResolvedProxy("config", options.config?.enabled === true, configUrl),
|
||||
};
|
||||
@@ -153,9 +174,15 @@ export function resolveProxyValidationConfig(
|
||||
|
||||
const envUrl = normalizeProxyUrl(options.env?.OPENCLAW_PROXY_URL);
|
||||
if (envUrl) {
|
||||
const proxyCaFile = resolveManagedProxyCaFileForUrl({
|
||||
proxyUrl: envUrl,
|
||||
config: options.config,
|
||||
caFileOverride: options.proxyCaFileOverride,
|
||||
});
|
||||
return {
|
||||
enabled: options.config?.enabled === true,
|
||||
proxyUrl: envUrl,
|
||||
...(proxyCaFile ? { proxyCaFile } : {}),
|
||||
source: "env",
|
||||
errors: validateResolvedProxy("env", options.config?.enabled === true, envUrl),
|
||||
};
|
||||
@@ -180,10 +207,17 @@ export function resolveProxyValidationConfig(
|
||||
|
||||
async function defaultProxyValidationFetchCheck({
|
||||
proxyUrl,
|
||||
proxyTls,
|
||||
targetUrl,
|
||||
timeoutMs,
|
||||
}: ProxyValidationFetchCheckParams): Promise<ProxyValidationFetchCheckResult> {
|
||||
const dispatcher = createHttp1ProxyAgent({ uri: proxyUrl }, timeoutMs);
|
||||
const dispatcher = createHttp1ProxyAgent(
|
||||
{
|
||||
uri: proxyUrl,
|
||||
...(proxyTls ? { proxyTls } : {}),
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
const response = await fetchWithRuntimeDispatcher(targetUrl, {
|
||||
dispatcher,
|
||||
@@ -202,10 +236,16 @@ async function defaultProxyValidationFetchCheck({
|
||||
|
||||
async function defaultProxyValidationApnsCheck({
|
||||
proxyUrl,
|
||||
proxyTls,
|
||||
authority,
|
||||
timeoutMs,
|
||||
}: ProxyValidationApnsCheckParams): Promise<ProxyValidationApnsCheckResult> {
|
||||
const result = await probeApnsHttp2ReachabilityViaProxy({ proxyUrl, authority, timeoutMs });
|
||||
const result = await probeApnsHttp2ReachabilityViaProxy({
|
||||
proxyUrl,
|
||||
...(proxyTls ? { proxyTls } : {}),
|
||||
authority,
|
||||
timeoutMs,
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
apnsId: result.responseHeaders?.["apns-id"],
|
||||
@@ -329,6 +369,7 @@ async function resolveDeniedTargets(
|
||||
async function runAllowedCheck(params: {
|
||||
url: string;
|
||||
proxyUrl: string;
|
||||
proxyTls?: ManagedProxyTlsOptions;
|
||||
timeoutMs: number;
|
||||
fetchCheck: ProxyValidationFetchCheck;
|
||||
}): Promise<ProxyValidationCheck> {
|
||||
@@ -344,6 +385,7 @@ async function runAllowedCheck(params: {
|
||||
try {
|
||||
const result = await params.fetchCheck({
|
||||
proxyUrl: params.proxyUrl,
|
||||
...(params.proxyTls ? { proxyTls: params.proxyTls } : {}),
|
||||
targetUrl: params.url,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
@@ -370,6 +412,7 @@ async function runAllowedCheck(params: {
|
||||
async function runDeniedCheck(params: {
|
||||
target: ProxyValidationDeniedTarget;
|
||||
proxyUrl: string;
|
||||
proxyTls?: ManagedProxyTlsOptions;
|
||||
timeoutMs: number;
|
||||
fetchCheck: ProxyValidationFetchCheck;
|
||||
}): Promise<ProxyValidationCheck> {
|
||||
@@ -385,6 +428,7 @@ async function runDeniedCheck(params: {
|
||||
try {
|
||||
const result = await params.fetchCheck({
|
||||
proxyUrl: params.proxyUrl,
|
||||
...(params.proxyTls ? { proxyTls: params.proxyTls } : {}),
|
||||
targetUrl: params.target.url,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
@@ -440,12 +484,14 @@ async function runDeniedCheck(params: {
|
||||
async function runApnsReachabilityCheck(params: {
|
||||
authority: string;
|
||||
proxyUrl: string;
|
||||
proxyTls?: ManagedProxyTlsOptions;
|
||||
timeoutMs: number;
|
||||
apnsCheck: ProxyValidationApnsCheck;
|
||||
}): Promise<ProxyValidationCheck> {
|
||||
try {
|
||||
const result = await params.apnsCheck({
|
||||
proxyUrl: params.proxyUrl,
|
||||
...(params.proxyTls ? { proxyTls: params.proxyTls } : {}),
|
||||
authority: params.authority,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
@@ -499,6 +545,19 @@ export async function runProxyValidation(
|
||||
}
|
||||
|
||||
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
||||
let proxyTls: ManagedProxyTlsOptions | undefined;
|
||||
try {
|
||||
proxyTls = await loadManagedProxyTlsOptions(config.proxyCaFile);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
config: {
|
||||
...config,
|
||||
errors: [...config.errors, err instanceof Error ? err.message : String(err)],
|
||||
},
|
||||
checks: [],
|
||||
};
|
||||
}
|
||||
const fetchCheck = options.fetchCheck ?? defaultProxyValidationFetchCheck;
|
||||
const apnsCheck = options.apnsCheck ?? defaultProxyValidationApnsCheck;
|
||||
const apnsAuthority = options.apnsAuthority ?? DEFAULT_PROXY_VALIDATION_APNS_AUTHORITY;
|
||||
@@ -508,11 +567,25 @@ export async function runProxyValidation(
|
||||
|
||||
try {
|
||||
for (const url of allowedUrls) {
|
||||
checks.push(await runAllowedCheck({ url, proxyUrl: config.proxyUrl, timeoutMs, fetchCheck }));
|
||||
checks.push(
|
||||
await runAllowedCheck({
|
||||
url,
|
||||
proxyUrl: config.proxyUrl,
|
||||
proxyTls,
|
||||
timeoutMs,
|
||||
fetchCheck,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const target of deniedTargets.targets) {
|
||||
checks.push(
|
||||
await runDeniedCheck({ target, proxyUrl: config.proxyUrl, timeoutMs, fetchCheck }),
|
||||
await runDeniedCheck({
|
||||
target,
|
||||
proxyUrl: config.proxyUrl,
|
||||
proxyTls,
|
||||
timeoutMs,
|
||||
fetchCheck,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (options.apnsReachability === true) {
|
||||
@@ -520,6 +593,7 @@ export async function runProxyValidation(
|
||||
await runApnsReachabilityCheck({
|
||||
authority: apnsAuthority,
|
||||
proxyUrl: config.proxyUrl,
|
||||
proxyTls,
|
||||
timeoutMs,
|
||||
apnsCheck,
|
||||
}),
|
||||
|
||||
@@ -298,6 +298,7 @@ describe("createPinnedDispatcher", () => {
|
||||
autoSelectFamilyAttemptTimeout: 300,
|
||||
lookup,
|
||||
},
|
||||
clientFactory: expect.any(Function),
|
||||
allowH2: false,
|
||||
proxyTls: {
|
||||
autoSelectFamily: true,
|
||||
@@ -324,6 +325,7 @@ describe("createPinnedDispatcher", () => {
|
||||
|
||||
expect(proxyAgentCtor).toHaveBeenCalledWith({
|
||||
uri: "http://127.0.0.1:7890",
|
||||
clientFactory: expect.any(Function),
|
||||
proxyTls: {
|
||||
autoSelectFamily: true,
|
||||
autoSelectFamilyAttemptTimeout: 300,
|
||||
@@ -359,6 +361,7 @@ describe("createPinnedDispatcher", () => {
|
||||
|
||||
expect(proxyAgentCtor).toHaveBeenCalledWith({
|
||||
uri: "http://127.0.0.1:7890",
|
||||
clientFactory: expect.any(Function),
|
||||
requestTls: {
|
||||
autoSelectFamily: false,
|
||||
lookup,
|
||||
|
||||
@@ -14,6 +14,8 @@ const {
|
||||
getDefaultAutoSelectFamily,
|
||||
setDefaultAutoSelectFamily,
|
||||
isProxylineDispatcher,
|
||||
createHttp1Agent,
|
||||
createHttp1EnvHttpProxyAgent,
|
||||
loadUndiciGlobalDispatcherDeps,
|
||||
} = vi.hoisted(() => {
|
||||
class Agent {
|
||||
@@ -75,6 +77,23 @@ const {
|
||||
const isProxylineDispatcher = vi.fn(
|
||||
(dispatcher: unknown) => dispatcher instanceof ManagedUndiciDispatcher,
|
||||
);
|
||||
const createHttp1Agent = vi.fn(
|
||||
(options?: Record<string, unknown>, timeoutMs?: number) =>
|
||||
new Agent({
|
||||
...options,
|
||||
...(timeoutMs ? { bodyTimeout: timeoutMs, headersTimeout: timeoutMs } : {}),
|
||||
allowH2: false,
|
||||
}),
|
||||
);
|
||||
const createHttp1EnvHttpProxyAgent = vi.fn(
|
||||
(options?: Record<string, unknown>, timeoutMs?: number) =>
|
||||
new EnvHttpProxyAgent({
|
||||
...options,
|
||||
...(timeoutMs ? { bodyTimeout: timeoutMs, headersTimeout: timeoutMs } : {}),
|
||||
allowH2: false,
|
||||
clientFactory: "ip-safe-test-client-factory",
|
||||
}),
|
||||
);
|
||||
const loadUndiciGlobalDispatcherDeps = vi.fn(() => ({
|
||||
Agent,
|
||||
EnvHttpProxyAgent,
|
||||
@@ -93,6 +112,8 @@ const {
|
||||
getCurrentDispatcher,
|
||||
getDefaultAutoSelectFamily,
|
||||
isProxylineDispatcher,
|
||||
createHttp1Agent,
|
||||
createHttp1EnvHttpProxyAgent,
|
||||
setDefaultAutoSelectFamily,
|
||||
loadUndiciGlobalDispatcherDeps,
|
||||
};
|
||||
@@ -118,9 +139,12 @@ vi.mock("node:net", () => ({
|
||||
vi.mock("./proxy-env.js", () => ({
|
||||
hasEnvHttpProxyAgentConfigured: vi.fn(() => false),
|
||||
resolveEnvHttpProxyAgentOptions: vi.fn(() => undefined),
|
||||
resolveEnvHttpProxyUrl: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./undici-runtime.js", () => ({
|
||||
createHttp1Agent,
|
||||
createHttp1EnvHttpProxyAgent,
|
||||
loadUndiciGlobalDispatcherDeps,
|
||||
}));
|
||||
|
||||
@@ -129,7 +153,16 @@ vi.mock("../wsl.js", () => ({
|
||||
}));
|
||||
|
||||
import { isWSL2Sync } from "../wsl.js";
|
||||
import { hasEnvHttpProxyAgentConfigured, resolveEnvHttpProxyAgentOptions } from "./proxy-env.js";
|
||||
import {
|
||||
hasEnvHttpProxyAgentConfigured,
|
||||
resolveEnvHttpProxyAgentOptions,
|
||||
resolveEnvHttpProxyUrl,
|
||||
} from "./proxy-env.js";
|
||||
import {
|
||||
_resetActiveManagedProxyStateForTests,
|
||||
registerActiveManagedProxyUrl,
|
||||
stopActiveManagedProxyRegistration,
|
||||
} from "./proxy/active-proxy-state.js";
|
||||
let DEFAULT_UNDICI_STREAM_TIMEOUT_MS: typeof import("./undici-global-dispatcher.js").DEFAULT_UNDICI_STREAM_TIMEOUT_MS;
|
||||
let ensureGlobalUndiciDispatcherStreamTimeouts: typeof import("./undici-global-dispatcher.js").ensureGlobalUndiciDispatcherStreamTimeouts;
|
||||
let ensureGlobalUndiciEnvProxyDispatcher: typeof import("./undici-global-dispatcher.js").ensureGlobalUndiciEnvProxyDispatcher;
|
||||
@@ -154,11 +187,13 @@ describe("ensureGlobalUndiciStreamTimeouts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetGlobalUndiciStreamTimeoutsForTests();
|
||||
_resetActiveManagedProxyStateForTests();
|
||||
setCurrentDispatcher(new Agent());
|
||||
getDefaultAutoSelectFamily.mockReturnValue(undefined);
|
||||
vi.mocked(isWSL2Sync).mockReturnValue(false);
|
||||
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(false);
|
||||
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue(undefined);
|
||||
vi.mocked(resolveEnvHttpProxyUrl).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("records timeout bridge without importing undici when no env proxy is configured", () => {
|
||||
@@ -261,6 +296,35 @@ describe("ensureGlobalUndiciStreamTimeouts", () => {
|
||||
expect(next.options?.allowH2).toBe(false);
|
||||
});
|
||||
|
||||
it("adds active managed proxy CA trust when replacing EnvHttpProxyAgent dispatcher", () => {
|
||||
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
|
||||
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue({
|
||||
httpProxy: "https://proxy.example:8443",
|
||||
httpsProxy: "https://proxy.example:8443",
|
||||
});
|
||||
const registration = registerActiveManagedProxyUrl(new URL("https://proxy.example:8443"), {
|
||||
proxyTls: { ca: "dispatcher-ca" },
|
||||
});
|
||||
setCurrentDispatcher(new EnvHttpProxyAgent());
|
||||
|
||||
try {
|
||||
ensureGlobalUndiciStreamTimeouts();
|
||||
|
||||
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
|
||||
const next = getCurrentDispatcher() as { options?: Record<string, unknown> };
|
||||
expect(next).toBeInstanceOf(EnvHttpProxyAgent);
|
||||
expect(next.options).toEqual(
|
||||
expect.objectContaining({
|
||||
httpProxy: "https://proxy.example:8443",
|
||||
httpsProxy: "https://proxy.example:8443",
|
||||
proxyTls: expect.objectContaining({ ca: "dispatcher-ca" }),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
stopActiveManagedProxyRegistration(registration);
|
||||
}
|
||||
});
|
||||
|
||||
it("records timeout bridge but does not override unsupported custom proxy dispatcher types", () => {
|
||||
setCurrentDispatcher(new ProxyAgent("http://proxy.test:8080"));
|
||||
|
||||
@@ -577,10 +641,12 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetGlobalUndiciStreamTimeoutsForTests();
|
||||
_resetActiveManagedProxyStateForTests();
|
||||
setCurrentDispatcher(new Agent());
|
||||
vi.mocked(isWSL2Sync).mockReturnValue(false);
|
||||
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(false);
|
||||
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue(undefined);
|
||||
vi.mocked(resolveEnvHttpProxyUrl).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("installs EnvHttpProxyAgent when env HTTP proxy is configured on a default Agent", () => {
|
||||
@@ -610,9 +676,38 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
|
||||
httpProxy: "socks5://proxy.test:1080",
|
||||
httpsProxy: "socks5://proxy.test:1080",
|
||||
allowH2: false,
|
||||
clientFactory: "ip-safe-test-client-factory",
|
||||
});
|
||||
});
|
||||
|
||||
it("installs EnvHttpProxyAgent with active managed proxy CA trust", () => {
|
||||
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
|
||||
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue({
|
||||
httpProxy: "https://proxy.example:8443",
|
||||
httpsProxy: "https://proxy.example:8443",
|
||||
});
|
||||
const registration = registerActiveManagedProxyUrl(new URL("https://proxy.example:8443"), {
|
||||
proxyTls: { ca: "bootstrap-ca" },
|
||||
});
|
||||
|
||||
try {
|
||||
ensureGlobalUndiciEnvProxyDispatcher();
|
||||
|
||||
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
|
||||
const next = getCurrentDispatcher() as { options?: Record<string, unknown> };
|
||||
expect(next).toBeInstanceOf(EnvHttpProxyAgent);
|
||||
expect(next.options).toEqual({
|
||||
httpProxy: "https://proxy.example:8443",
|
||||
httpsProxy: "https://proxy.example:8443",
|
||||
proxyTls: { ca: "bootstrap-ca" },
|
||||
allowH2: false,
|
||||
clientFactory: "ip-safe-test-client-factory",
|
||||
});
|
||||
} finally {
|
||||
stopActiveManagedProxyRegistration(registration);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not override unsupported custom proxy dispatcher types", () => {
|
||||
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
|
||||
setCurrentDispatcher(new ProxyAgent("http://proxy.test:8080"));
|
||||
@@ -675,6 +770,7 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
|
||||
httpProxy: "http://new-proxy.example:3128",
|
||||
httpsProxy: "http://new-proxy.example:3128",
|
||||
allowH2: false,
|
||||
clientFactory: "ip-safe-test-client-factory",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -698,6 +794,7 @@ describe("forceResetGlobalDispatcher", () => {
|
||||
resetGlobalUndiciStreamTimeoutsForTests();
|
||||
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(false);
|
||||
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue(undefined);
|
||||
vi.mocked(resolveEnvHttpProxyUrl).mockReturnValue(undefined);
|
||||
vi.mocked(isWSL2Sync).mockReturnValue(false);
|
||||
});
|
||||
|
||||
@@ -744,6 +841,7 @@ describe("forceResetGlobalDispatcher", () => {
|
||||
httpProxy: "http://proxy-b.example:8080",
|
||||
httpsProxy: "http://proxy-b.example:8080",
|
||||
allowH2: false,
|
||||
clientFactory: "ip-safe-test-client-factory",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -763,6 +861,7 @@ describe("forceResetGlobalDispatcher", () => {
|
||||
httpProxy: "http://proxy-all.example:3128",
|
||||
httpsProxy: "http://proxy-all.example:3128",
|
||||
allowH2: false,
|
||||
clientFactory: "ip-safe-test-client-factory",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { isProxylineDispatcher } from "@openclaw/proxyline/dispatcher-brand";
|
||||
import { hasEnvHttpProxyAgentConfigured, resolveEnvHttpProxyAgentOptions } from "./proxy-env.js";
|
||||
import { addActiveManagedProxyTlsOptions } from "./proxy/managed-proxy-undici.js";
|
||||
import {
|
||||
createUndiciAutoSelectFamilyConnectOptions,
|
||||
resolveUndiciAutoSelectFamily,
|
||||
withTemporaryUndiciAutoSelectFamily,
|
||||
} from "./undici-family-policy.js";
|
||||
import {
|
||||
createHttp1Agent,
|
||||
createHttp1EnvHttpProxyAgent,
|
||||
loadUndiciGlobalDispatcherDeps,
|
||||
type UndiciGlobalDispatcherDeps,
|
||||
} from "./undici-runtime.js";
|
||||
@@ -153,7 +156,7 @@ function resolveEnvProxyDispatcherOptions(): ConstructorParameters<
|
||||
UndiciGlobalDispatcherDeps["EnvHttpProxyAgent"]
|
||||
>[0] {
|
||||
return {
|
||||
...resolveEnvHttpProxyAgentOptions(),
|
||||
...addActiveManagedProxyTlsOptions(resolveEnvHttpProxyAgentOptions()),
|
||||
...HTTP1_ONLY_DISPATCHER_OPTIONS,
|
||||
} as ConstructorParameters<UndiciGlobalDispatcherDeps["EnvHttpProxyAgent"]>[0];
|
||||
}
|
||||
@@ -207,7 +210,7 @@ export function ensureGlobalUndiciEnvProxyDispatcher(): void {
|
||||
return;
|
||||
}
|
||||
const runtime = loadUndiciGlobalDispatcherDeps();
|
||||
const { EnvHttpProxyAgent, setGlobalDispatcher } = runtime;
|
||||
const { setGlobalDispatcher } = runtime;
|
||||
const proxyOptions = resolveEnvProxyDispatcherOptions();
|
||||
const nextBootstrapKey = resolveEnvProxyBootstrapKey(proxyOptions);
|
||||
const currentKind = resolveCurrentDispatcherKind(runtime);
|
||||
@@ -226,7 +229,7 @@ export function ensureGlobalUndiciEnvProxyDispatcher(): void {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setGlobalDispatcher(new EnvHttpProxyAgent(proxyOptions));
|
||||
setGlobalDispatcher(createHttp1EnvHttpProxyAgent(proxyOptions));
|
||||
lastAppliedProxyBootstrapKey = nextBootstrapKey;
|
||||
} catch {
|
||||
// Best-effort bootstrap only.
|
||||
@@ -260,22 +263,15 @@ function applyGlobalDispatcherStreamTimeouts(params: {
|
||||
);
|
||||
} else if (kind === "env-proxy") {
|
||||
const proxyOptions = {
|
||||
...resolveEnvHttpProxyAgentOptions(),
|
||||
...addActiveManagedProxyTlsOptions(resolveEnvHttpProxyAgentOptions()),
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
...(connect ? { connect } : {}),
|
||||
...HTTP1_ONLY_DISPATCHER_OPTIONS,
|
||||
} as ConstructorParameters<UndiciGlobalDispatcherDeps["EnvHttpProxyAgent"]>[0];
|
||||
runtime.setGlobalDispatcher(new runtime.EnvHttpProxyAgent(proxyOptions));
|
||||
runtime.setGlobalDispatcher(createHttp1EnvHttpProxyAgent(proxyOptions, timeoutMs));
|
||||
} else {
|
||||
runtime.setGlobalDispatcher(
|
||||
new runtime.Agent({
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
...(connect ? { connect } : {}),
|
||||
...HTTP1_ONLY_DISPATCHER_OPTIONS,
|
||||
}),
|
||||
);
|
||||
runtime.setGlobalDispatcher(createHttp1Agent(connect ? { connect } : undefined, timeoutMs));
|
||||
}
|
||||
lastAppliedTimeoutKey = nextKey;
|
||||
} catch {
|
||||
@@ -348,8 +344,8 @@ export function forceResetGlobalDispatcher(opts?: { preserveProxylineManaged?: b
|
||||
}
|
||||
lastAppliedProxyBootstrapKey = null;
|
||||
try {
|
||||
const { Agent, setGlobalDispatcher } = loadUndiciGlobalDispatcherDeps();
|
||||
setGlobalDispatcher(new Agent(HTTP1_ONLY_DISPATCHER_OPTIONS));
|
||||
const { setGlobalDispatcher } = loadUndiciGlobalDispatcherDeps();
|
||||
setGlobalDispatcher(createHttp1Agent());
|
||||
} catch {
|
||||
// Best-effort reset only.
|
||||
}
|
||||
@@ -357,7 +353,7 @@ export function forceResetGlobalDispatcher(opts?: { preserveProxylineManaged?: b
|
||||
}
|
||||
try {
|
||||
const runtime = loadUndiciGlobalDispatcherDeps();
|
||||
const { EnvHttpProxyAgent, setGlobalDispatcher } = runtime;
|
||||
const { setGlobalDispatcher } = runtime;
|
||||
const proxyOptions = resolveEnvProxyDispatcherOptions();
|
||||
if (opts?.preserveProxylineManaged) {
|
||||
const current = resolveCurrentDispatcherInfo(runtime);
|
||||
@@ -366,7 +362,7 @@ export function forceResetGlobalDispatcher(opts?: { preserveProxylineManaged?: b
|
||||
return;
|
||||
}
|
||||
}
|
||||
setGlobalDispatcher(new EnvHttpProxyAgent(proxyOptions));
|
||||
setGlobalDispatcher(createHttp1EnvHttpProxyAgent(proxyOptions));
|
||||
lastAppliedProxyBootstrapKey = resolveEnvProxyBootstrapKey(proxyOptions);
|
||||
} catch {
|
||||
// Best-effort reset only.
|
||||
|
||||
187
src/infra/net/undici-runtime.test.ts
Normal file
187
src/infra/net/undici-runtime.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
_resetActiveManagedProxyStateForTests,
|
||||
registerActiveManagedProxyUrl,
|
||||
stopActiveManagedProxyRegistration,
|
||||
} from "./proxy/active-proxy-state.js";
|
||||
import {
|
||||
createHttp1EnvHttpProxyAgent,
|
||||
createHttp1ProxyAgent,
|
||||
TEST_UNDICI_RUNTIME_DEPS_KEY,
|
||||
} from "./undici-runtime.js";
|
||||
|
||||
const envHttpProxyAgentCtor = vi.fn();
|
||||
const poolCtor = vi.fn();
|
||||
const proxyAgentCtor = vi.fn();
|
||||
const proxyConnect = vi.fn();
|
||||
|
||||
class MockAgent {
|
||||
readonly __testStub = true;
|
||||
}
|
||||
|
||||
class MockPool {
|
||||
readonly __testStub = true;
|
||||
|
||||
constructor(
|
||||
public readonly origin: unknown,
|
||||
public readonly options: unknown,
|
||||
) {
|
||||
poolCtor(origin, options);
|
||||
}
|
||||
}
|
||||
|
||||
class MockEnvHttpProxyAgent {
|
||||
readonly __testStub = true;
|
||||
|
||||
constructor(public readonly options: unknown) {
|
||||
envHttpProxyAgentCtor(options);
|
||||
}
|
||||
}
|
||||
|
||||
class MockProxyAgent {
|
||||
readonly __testStub = true;
|
||||
|
||||
constructor(public readonly options: unknown) {
|
||||
proxyAgentCtor(options);
|
||||
}
|
||||
}
|
||||
|
||||
function installUndiciRuntimeDeps(): void {
|
||||
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
|
||||
Agent: MockAgent,
|
||||
EnvHttpProxyAgent: MockEnvHttpProxyAgent,
|
||||
Pool: MockPool,
|
||||
ProxyAgent: MockProxyAgent,
|
||||
fetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function expectOptionsRecord(options: unknown, message: string): Record<string, unknown> {
|
||||
if (typeof options !== "object" || options === null || Array.isArray(options)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return options as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireProxyAgentOptions(): Record<string, unknown> {
|
||||
const call = proxyAgentCtor.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("expected ProxyAgent constructor call");
|
||||
}
|
||||
return expectOptionsRecord(call[0], "expected ProxyAgent options object");
|
||||
}
|
||||
|
||||
function requireEnvHttpProxyAgentOptions(): Record<string, unknown> {
|
||||
const call = envHttpProxyAgentCtor.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("expected EnvHttpProxyAgent constructor call");
|
||||
}
|
||||
return expectOptionsRecord(call[0], "expected EnvHttpProxyAgent options object");
|
||||
}
|
||||
|
||||
function requireClientOptions(): Record<string, unknown> {
|
||||
const call = poolCtor.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("expected Pool constructor call");
|
||||
}
|
||||
return expectOptionsRecord(call[1], "expected Pool options object");
|
||||
}
|
||||
|
||||
function invokeProxyClientFactory(options: Record<string, unknown>): void {
|
||||
const clientFactory = options.clientFactory;
|
||||
if (typeof clientFactory !== "function") {
|
||||
throw new Error("expected ProxyAgent clientFactory");
|
||||
}
|
||||
clientFactory(new URL("https://127.0.0.1:8443"), { connect: proxyConnect });
|
||||
}
|
||||
|
||||
function invokeClientConnect(options: Record<string, unknown>, servername: string): void {
|
||||
const connect = options.connect;
|
||||
if (typeof connect !== "function") {
|
||||
throw new Error("expected wrapped Client connect");
|
||||
}
|
||||
connect({ host: "127.0.0.1:8443", servername }, vi.fn());
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
|
||||
envHttpProxyAgentCtor.mockReset();
|
||||
poolCtor.mockReset();
|
||||
proxyAgentCtor.mockReset();
|
||||
proxyConnect.mockReset();
|
||||
_resetActiveManagedProxyStateForTests();
|
||||
});
|
||||
|
||||
describe("createHttp1ProxyAgent", () => {
|
||||
it("adds active managed proxy CA trust to explicit ProxyAgent options", () => {
|
||||
installUndiciRuntimeDeps();
|
||||
const registration = registerActiveManagedProxyUrl(new URL("https://proxy.test:8443"), {
|
||||
proxyTls: { ca: "explicit-proxy-agent-ca" },
|
||||
});
|
||||
|
||||
try {
|
||||
createHttp1ProxyAgent({ uri: "https://proxy.test:8443" });
|
||||
|
||||
const options = requireProxyAgentOptions();
|
||||
expect(options.uri).toBe("https://proxy.test:8443");
|
||||
expect(options.allowH2).toBe(false);
|
||||
expect(options.proxyTls).toMatchObject({ ca: "explicit-proxy-agent-ca" });
|
||||
} finally {
|
||||
stopActiveManagedProxyRegistration(registration);
|
||||
}
|
||||
});
|
||||
|
||||
it("strips invalid IP SNI when undici connects to an HTTPS proxy by IP", () => {
|
||||
installUndiciRuntimeDeps();
|
||||
|
||||
createHttp1ProxyAgent({ uri: "https://127.0.0.1:8443" });
|
||||
invokeProxyClientFactory(requireProxyAgentOptions());
|
||||
invokeClientConnect(requireClientOptions(), "127.0.0.1");
|
||||
|
||||
expect(proxyConnect).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ servername: "127.0.0.1" }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("strips invalid bracketed IPv6 SNI when undici connects to an HTTPS proxy by IP", () => {
|
||||
installUndiciRuntimeDeps();
|
||||
|
||||
createHttp1ProxyAgent({ uri: "https://[::1]:8443" });
|
||||
invokeProxyClientFactory(requireProxyAgentOptions());
|
||||
invokeClientConnect(requireClientOptions(), "[::1]");
|
||||
|
||||
expect(proxyConnect).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ servername: "[::1]" }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves DNS SNI when undici connects to an HTTPS proxy by hostname", () => {
|
||||
installUndiciRuntimeDeps();
|
||||
|
||||
createHttp1ProxyAgent({ uri: "https://proxy.example:8443" });
|
||||
invokeProxyClientFactory(requireProxyAgentOptions());
|
||||
invokeClientConnect(requireClientOptions(), "proxy.example");
|
||||
|
||||
expect(proxyConnect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ servername: "proxy.example" }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHttp1EnvHttpProxyAgent", () => {
|
||||
it("installs the IP-safe proxy client factory for env proxy dispatchers", () => {
|
||||
installUndiciRuntimeDeps();
|
||||
|
||||
createHttp1EnvHttpProxyAgent({ httpsProxy: "https://127.0.0.1:8443" });
|
||||
invokeProxyClientFactory(requireEnvHttpProxyAgentOptions());
|
||||
invokeClientConnect(requireClientOptions(), "127.0.0.1");
|
||||
|
||||
expect(proxyConnect).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ servername: "127.0.0.1" }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createRequire } from "node:module";
|
||||
import net from "node:net";
|
||||
import { addActiveManagedProxyTlsOptions } from "./proxy/managed-proxy-undici.js";
|
||||
import { resolveUndiciAutoSelectFamilyConnectOptions } from "./undici-family-policy.js";
|
||||
|
||||
export const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__";
|
||||
@@ -21,6 +23,9 @@ type UndiciEnvHttpProxyAgentOptions = ConstructorParameters<
|
||||
UndiciRuntimeDeps["EnvHttpProxyAgent"]
|
||||
>[0];
|
||||
type UndiciProxyAgentOptions = ConstructorParameters<UndiciRuntimeDeps["ProxyAgent"]>[0];
|
||||
type UndiciProxyAgentOptionsRecord = Exclude<UndiciProxyAgentOptions, string | URL>;
|
||||
type UndiciProxyClientFactory = NonNullable<UndiciProxyAgentOptionsRecord["clientFactory"]>;
|
||||
type UnknownFunction = (...args: unknown[]) => unknown;
|
||||
|
||||
// Guarded fetch dispatchers intentionally stay on HTTP/1.1. Undici 8 enables
|
||||
// HTTP/2 ALPN by default, but our guarded paths rely on dispatcher overrides
|
||||
@@ -66,6 +71,64 @@ function isUndiciGlobalDispatcherDeps(value: unknown): value is UndiciGlobalDisp
|
||||
);
|
||||
}
|
||||
|
||||
function loadUndiciProxyPoolCtor(): typeof import("undici").Pool {
|
||||
const override = (globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY];
|
||||
if (
|
||||
typeof override === "object" &&
|
||||
override !== null &&
|
||||
typeof (override as { Pool?: unknown }).Pool === "function"
|
||||
) {
|
||||
return (override as { Pool: typeof import("undici").Pool }).Pool;
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
return (require("undici") as typeof import("undici")).Pool;
|
||||
}
|
||||
|
||||
function stripIpServernameFromConnectOptions(options: unknown): unknown {
|
||||
if (!isObjectRecord(options) || typeof options.servername !== "string") {
|
||||
return options;
|
||||
}
|
||||
const servername = options.servername.replace(/^\[|\]$/g, "");
|
||||
if (net.isIP(servername) === 0) {
|
||||
return options;
|
||||
}
|
||||
const next = { ...options };
|
||||
delete next.servername;
|
||||
return next;
|
||||
}
|
||||
|
||||
function stripIpServernameFromConnect(connect: unknown): unknown {
|
||||
if (typeof connect !== "function") {
|
||||
return connect;
|
||||
}
|
||||
return (options: unknown, callback: unknown): unknown =>
|
||||
(connect as UnknownFunction)(stripIpServernameFromConnectOptions(options), callback);
|
||||
}
|
||||
|
||||
function createIpSafeProxyClientFactory(): UndiciProxyClientFactory {
|
||||
return (origin, options) => {
|
||||
const Pool = loadUndiciProxyPoolCtor();
|
||||
const clientOptions = isObjectRecord(options)
|
||||
? { ...options, connect: stripIpServernameFromConnect(options.connect) }
|
||||
: options;
|
||||
return new Pool(
|
||||
origin,
|
||||
clientOptions as ConstructorParameters<typeof import("undici").Pool>[1],
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function addIpSafeProxyClientFactory<TOptions extends object>(options: TOptions): TOptions {
|
||||
if ("clientFactory" in options) {
|
||||
return options;
|
||||
}
|
||||
return {
|
||||
...options,
|
||||
clientFactory: createIpSafeProxyClientFactory(),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUndiciRuntimeDeps(): UndiciRuntimeDeps {
|
||||
const override = (globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY];
|
||||
if (isUndiciRuntimeDeps(override)) {
|
||||
@@ -157,10 +220,14 @@ export function createHttp1EnvHttpProxyAgent(
|
||||
): import("undici").EnvHttpProxyAgent {
|
||||
const { EnvHttpProxyAgent } = loadUndiciRuntimeDeps();
|
||||
return new EnvHttpProxyAgent(
|
||||
withHttp1OnlyDispatcherOptions(options, timeoutMs, {
|
||||
connect: true,
|
||||
proxyTls: true,
|
||||
}),
|
||||
withHttp1OnlyDispatcherOptions(
|
||||
addIpSafeProxyClientFactory(addActiveManagedProxyTlsOptions(options) ?? {}),
|
||||
timeoutMs,
|
||||
{
|
||||
connect: true,
|
||||
proxyTls: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,8 +241,12 @@ export function createHttp1ProxyAgent(
|
||||
? { uri: options.toString() }
|
||||
: { ...options };
|
||||
return new ProxyAgent(
|
||||
withHttp1OnlyDispatcherOptions(normalized as object, timeoutMs, {
|
||||
proxyTls: true,
|
||||
}) as UndiciProxyAgentOptions,
|
||||
withHttp1OnlyDispatcherOptions(
|
||||
addIpSafeProxyClientFactory(addActiveManagedProxyTlsOptions(normalized as object)),
|
||||
timeoutMs,
|
||||
{
|
||||
proxyTls: true,
|
||||
},
|
||||
) as UndiciProxyAgentOptions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,10 @@ describe("connectApnsHttp2Session", () => {
|
||||
});
|
||||
|
||||
it("uses an HTTP CONNECT tunnel when managed proxy is active", async () => {
|
||||
const registration = registerActiveManagedProxyUrl(new URL("http://proxy.example:8080"));
|
||||
const registration = registerActiveManagedProxyUrl(new URL("https://proxy.example:8443"), {
|
||||
loopbackMode: "gateway-only",
|
||||
proxyTls: { ca: "active-proxy-ca" },
|
||||
});
|
||||
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
|
||||
|
||||
const session = await connectApnsHttp2Session({
|
||||
@@ -177,7 +180,8 @@ describe("connectApnsHttp2Session", () => {
|
||||
if (!(proxyUrl instanceof URL)) {
|
||||
throw new Error("expected active managed proxy URL");
|
||||
}
|
||||
expect(proxyUrl.href).toBe("http://proxy.example:8080/");
|
||||
expect(proxyUrl.href).toBe("https://proxy.example:8443/");
|
||||
expect(tunnelCall.proxyTls).toEqual({ ca: "active-proxy-ca" });
|
||||
expect(tunnelCall.targetHost).toBe("api.push.apple.com");
|
||||
expect(tunnelCall.targetPort).toBe(443);
|
||||
expect(tunnelCall.timeoutMs).toBe(10_000);
|
||||
@@ -217,6 +221,7 @@ describe("connectApnsHttp2Session", () => {
|
||||
const result = await probeApnsHttp2ReachabilityViaProxy({
|
||||
authority: "https://api.sandbox.push.apple.com",
|
||||
proxyUrl: "http://proxy.example:8080",
|
||||
proxyTls: { ca: "probe-proxy-ca" },
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
@@ -232,6 +237,7 @@ describe("connectApnsHttp2Session", () => {
|
||||
throw new Error("expected explicit proxy URL");
|
||||
}
|
||||
expect(proxyUrl.href).toBe("http://proxy.example:8080/");
|
||||
expect(tunnelCall.proxyTls).toEqual({ ca: "probe-proxy-ca" });
|
||||
expect(tunnelCall?.targetHost).toBe("api.sandbox.push.apple.com");
|
||||
expect(tunnelCall?.targetPort).toBe(443);
|
||||
expect(tunnelCall?.timeoutMs).toBe(10_000);
|
||||
|
||||
@@ -2,8 +2,10 @@ import http2 from "node:http2";
|
||||
import { openHttpConnectTunnel } from "./net/http-connect-tunnel.js";
|
||||
import {
|
||||
getActiveManagedProxyUrl,
|
||||
getActiveManagedProxyTlsOptions,
|
||||
type ActiveManagedProxyUrl,
|
||||
} from "./net/proxy/active-proxy-state.js";
|
||||
import type { ManagedProxyTlsOptions } from "./net/proxy/proxy-tls.js";
|
||||
|
||||
const APNS_DEFAULT_PORT = "443";
|
||||
|
||||
@@ -24,6 +26,7 @@ export type ConnectApnsHttp2SessionParams = {
|
||||
export type ProbeApnsHttp2ReachabilityViaProxyParams = {
|
||||
authority: string;
|
||||
proxyUrl: string;
|
||||
proxyTls?: ManagedProxyTlsOptions;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
@@ -61,11 +64,13 @@ function assertApnsAuthority(authority: string): ApnsAuthority {
|
||||
async function openProxiedApnsHttp2Session(params: {
|
||||
authority: ApnsAuthority;
|
||||
proxyUrl: ActiveManagedProxyUrl;
|
||||
proxyTls?: ManagedProxyTlsOptions;
|
||||
timeoutMs: number;
|
||||
}): Promise<http2.ClientHttp2Session> {
|
||||
const apnsHost = new URL(params.authority).hostname;
|
||||
const tlsSocket = await openHttpConnectTunnel({
|
||||
proxyUrl: params.proxyUrl,
|
||||
...(params.proxyTls ? { proxyTls: params.proxyTls } : {}),
|
||||
targetHost: apnsHost,
|
||||
targetPort: 443,
|
||||
timeoutMs: params.timeoutMs,
|
||||
@@ -88,6 +93,7 @@ export async function connectApnsHttp2Session(
|
||||
return await openProxiedApnsHttp2Session({
|
||||
authority,
|
||||
proxyUrl,
|
||||
proxyTls: getActiveManagedProxyTlsOptions(),
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
}
|
||||
@@ -99,6 +105,7 @@ export async function probeApnsHttp2ReachabilityViaProxy(
|
||||
const session = await openProxiedApnsHttp2Session({
|
||||
authority,
|
||||
proxyUrl: new URL(params.proxyUrl),
|
||||
...(params.proxyTls ? { proxyTls: params.proxyTls } : {}),
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
|
||||
|
||||
18
src/plugin-sdk/api-baseline.test.ts
Normal file
18
src/plugin-sdk/api-baseline.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizePluginSdkApiDeclarationText } from "./api-baseline.js";
|
||||
|
||||
describe("Plugin SDK API baseline", () => {
|
||||
it("normalizes declaration import paths to repo-relative paths", () => {
|
||||
const repoRoot = process.cwd();
|
||||
const modelCatalogPath = path.join(repoRoot, "src", "agents", "pi-model-discovery-runtime");
|
||||
const declaration = `export function __setModelCatalogImportForTest(loader?: (() => Promise<typeof import("${modelCatalogPath}", { with: { "resolution-mode": "import" } })>) | undefined): void;`;
|
||||
|
||||
const normalized = normalizePluginSdkApiDeclarationText(repoRoot, declaration);
|
||||
|
||||
expect(normalized).not.toContain(repoRoot);
|
||||
expect(normalized).toContain(
|
||||
'import("src/agents/pi-model-discovery-runtime", { with: { "resolution-mode": "import" } })',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -97,11 +97,14 @@ function normalizeDeclarationImportSpecifier(repoRoot: string, value: string): s
|
||||
return relative.split(path.sep).join(path.posix.sep);
|
||||
}
|
||||
|
||||
function normalizeDeclarationText(repoRoot: string, value: string): string {
|
||||
return value.replaceAll(/import\("([^"]+)"/g, (match, specifier: string) => {
|
||||
const normalized = normalizeDeclarationImportSpecifier(repoRoot, specifier);
|
||||
return normalized === specifier ? match : `import("${normalized}"`;
|
||||
});
|
||||
export function normalizePluginSdkApiDeclarationText(repoRoot: string, value: string): string {
|
||||
return value.replaceAll(
|
||||
/import\("([^"]+)"((?:\s*,[^)]*)?)\)/g,
|
||||
(match, specifier: string, suffix: string) => {
|
||||
const normalized = normalizeDeclarationImportSpecifier(repoRoot, specifier);
|
||||
return normalized === specifier ? match : `import("${normalized}"${suffix})`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createCompilerContext(repoRoot: string) {
|
||||
@@ -233,7 +236,7 @@ function printNode(
|
||||
if (signatures.length === 0) {
|
||||
return `export function ${declaration.name?.text ?? "anonymous"}();`;
|
||||
}
|
||||
return normalizeDeclarationText(
|
||||
return normalizePluginSdkApiDeclarationText(
|
||||
repoRoot,
|
||||
signatures
|
||||
.map(
|
||||
@@ -251,7 +254,7 @@ function printNode(
|
||||
declaration.parent && (ts.getCombinedNodeFlags(declaration.parent) & ts.NodeFlags.Const) !== 0
|
||||
? "const"
|
||||
: "let";
|
||||
return normalizeDeclarationText(
|
||||
return normalizePluginSdkApiDeclarationText(
|
||||
repoRoot,
|
||||
`export ${prefix} ${name}: ${checker.typeToString(type, declaration, ts.TypeFormatFlags.NoTruncation)};`,
|
||||
);
|
||||
@@ -275,7 +278,7 @@ function printNode(
|
||||
|
||||
if (ts.isTypeAliasDeclaration(declaration)) {
|
||||
const type = checker.getTypeAtLocation(declaration);
|
||||
const rendered = normalizeDeclarationText(
|
||||
const rendered = normalizePluginSdkApiDeclarationText(
|
||||
repoRoot,
|
||||
`export type ${declaration.name.text} = ${checker.typeToString(
|
||||
type,
|
||||
@@ -295,7 +298,7 @@ function printNode(
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const normalizedText = normalizeDeclarationText(repoRoot, text);
|
||||
const normalizedText = normalizePluginSdkApiDeclarationText(repoRoot, text);
|
||||
return normalizedText.length > 1200
|
||||
? `${normalizedText.slice(0, 1175).trimEnd()}\n/* truncated; see source */`
|
||||
: normalizedText;
|
||||
|
||||
65
src/plugin-sdk/extension-shared.test.ts
Normal file
65
src/plugin-sdk/extension-shared.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createAmbientNodeProxyAgentMock = vi.hoisted(() => vi.fn(() => ({ proxy: true })));
|
||||
const hasAmbientNodeProxyConfiguredMock = vi.hoisted(() => vi.fn(() => true));
|
||||
|
||||
vi.mock("@openclaw/proxyline", () => ({
|
||||
createAmbientNodeProxyAgent: createAmbientNodeProxyAgentMock,
|
||||
hasAmbientNodeProxyConfigured: hasAmbientNodeProxyConfiguredMock,
|
||||
}));
|
||||
|
||||
import { resolveAmbientNodeProxyAgent } from "./extension-shared.js";
|
||||
|
||||
describe("resolveAmbientNodeProxyAgent", () => {
|
||||
const envKeys = [
|
||||
"HTTPS_PROXY",
|
||||
"HTTP_PROXY",
|
||||
"https_proxy",
|
||||
"http_proxy",
|
||||
"OPENCLAW_PROXY_ACTIVE",
|
||||
"OPENCLAW_PROXY_CA_FILE",
|
||||
] as const;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
createAmbientNodeProxyAgentMock.mockClear();
|
||||
hasAmbientNodeProxyConfiguredMock.mockClear();
|
||||
hasAmbientNodeProxyConfiguredMock.mockReturnValue(true);
|
||||
for (const key of envKeys) {
|
||||
vi.stubEnv(key, "");
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function writeTempCa(contents: string): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-extension-shared-proxy-ca-"));
|
||||
tempDirs.push(dir);
|
||||
const caFile = path.join(dir, "proxy-ca.pem");
|
||||
writeFileSync(caFile, contents, "utf8");
|
||||
return caFile;
|
||||
}
|
||||
|
||||
it("adds managed proxy CA trust to ambient Node proxy agents", async () => {
|
||||
const caFile = writeTempCa("extension-shared-managed-proxy-ca");
|
||||
vi.stubEnv("https_proxy", "https://proxy.example:8443");
|
||||
vi.stubEnv("OPENCLAW_PROXY_ACTIVE", "1");
|
||||
vi.stubEnv("OPENCLAW_PROXY_CA_FILE", caFile);
|
||||
|
||||
const agent = await resolveAmbientNodeProxyAgent<{ proxy: true }>();
|
||||
|
||||
expect(agent).toEqual({ proxy: true });
|
||||
expect(createAmbientNodeProxyAgentMock).toHaveBeenCalledWith({
|
||||
protocol: "https",
|
||||
proxyTls: { ca: "extension-shared-managed-proxy-ca" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createAmbientNodeProxyAgent, hasAmbientNodeProxyConfigured } from "@openclaw/proxyline";
|
||||
import type { z } from "zod";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveActiveManagedProxyTlsOptions } from "../infra/net/proxy/managed-proxy-undici.js";
|
||||
import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
|
||||
import { runPassiveAccountLifecycle } from "./channel-lifecycle.core.js";
|
||||
import { createLoggerBackedRuntime } from "./runtime-logger.js";
|
||||
@@ -237,7 +238,11 @@ export async function resolveAmbientNodeProxyAgent<TAgent>(params?: {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const agent = createAmbientNodeProxyAgent({ protocol });
|
||||
const proxyTls = resolveActiveManagedProxyTlsOptions();
|
||||
const agent = createAmbientNodeProxyAgent({
|
||||
protocol,
|
||||
...(proxyTls ? { proxyTls } : {}),
|
||||
});
|
||||
if (agent === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// Public fetch/proxy helpers for plugins that need wrapped fetch behavior.
|
||||
|
||||
export { resolveFetch, wrapFetchWithAbortSignal } from "../infra/fetch.js";
|
||||
export {
|
||||
createHttp1EnvHttpProxyAgent,
|
||||
createHttp1ProxyAgent,
|
||||
} from "../infra/net/undici-runtime.js";
|
||||
export { withTrustedEnvProxyGuardedFetchMode } from "../infra/net/fetch-guard.ts";
|
||||
export {
|
||||
addActiveManagedProxyTlsOptions,
|
||||
resolveActiveManagedProxyTlsOptions,
|
||||
} from "../infra/net/proxy/managed-proxy-undici.js";
|
||||
export {
|
||||
hasEnvHttpProxyConfigured,
|
||||
hasEnvHttpProxyAgentConfigured,
|
||||
|
||||
Reference in New Issue
Block a user