diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e54afe2f9..ba9c2957f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index b1f21019172..6a9a98c442f 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 950fb4b0a2e..bd643858f02 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/cli/proxy.md b/docs/cli/proxy.md index 58d937b29db..f3590da162d 100644 --- a/docs/cli/proxy.md +++ b/docs/cli/proxy.md @@ -23,7 +23,7 @@ captured blobs, and purge local capture data. ```bash openclaw proxy start [--host ] [--port ] openclaw proxy run [--host ] [--port ] -- -openclaw proxy validate [--json] [--proxy-url ] [--allowed-url ] [--denied-url ] [--apns-reachable] [--apns-authority ] [--timeout-ms ] +openclaw proxy validate [--json] [--proxy-url ] [--proxy-ca-file ] [--allowed-url ] [--denied-url ] [--apns-reachable] [--apns-authority ] [--timeout-ms ] openclaw proxy coverage openclaw proxy sessions [--limit ] openclaw proxy query --preset [--session ] @@ -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 `: validate this proxy URL instead of config or env. +- `--proxy-url `: validate this `http://` or `https://` proxy URL instead of config or env. +- `--proxy-ca-file `: trust this PEM CA file for TLS verification of an HTTPS proxy endpoint. - `--allowed-url `: add a destination expected to succeed through the proxy. Repeat to check multiple destinations. - `--denied-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. diff --git a/docs/security/network-proxy.md b/docs/security/network-proxy.md index 1405b9a75de..b2386935d50 100644 --- a/docs/security/network-proxy.md +++ b/docs/security/network-proxy.md @@ -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 diff --git a/extensions/discord/src/monitor/provider.rest-proxy.test.ts b/extensions/discord/src/monitor/provider.rest-proxy.test.ts index d3a2dcc250d..27bcd74f0bc 100644 --- a/extensions/discord/src/monitor/provider.rest-proxy.test.ts +++ b/extensions/discord/src/monitor/provider.rest-proxy.test.ts @@ -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 { return value as Record; } +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)[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"); diff --git a/extensions/discord/src/monitor/rest-fetch.ts b/extensions/discord/src/monitor/rest-fetch.ts index 1711ddcd47d..7c33cd0ed08 100644 --- a/extensions/discord/src/monitor/rest-fetch.ts +++ b/extensions/discord/src/monitor/rest-fetch.ts @@ -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 | InstanceType; +type DiscordRestDispatcher = + | InstanceType + | ReturnType + | ReturnType; + +function createDirectDiscordRestDispatcher(): InstanceType { + return new Agent({ + allowH2: false, + connect: { lookup: discordDnsLookup }, + }); +} + +function createEnvProxyDiscordRestDispatcher( + runtime: RuntimeEnv, +): ReturnType | 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; } diff --git a/extensions/slack/src/client-options.ts b/extensions/slack/src/client-options.ts index 47848aed437..11b43450706 100644 --- a/extensions/slack/src/client-options.ts +++ b/extensions/slack/src/client-options.ts @@ -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 | 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; diff --git a/extensions/slack/src/client.test.ts b/extensions/slack/src/client.test.ts index 58d9a5356d9..c2a4c888cc8 100644 --- a/extensions/slack/src/client.test.ts +++ b/extensions/slack/src/client.test.ts @@ -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(options: T): NonNullable; } +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(); diff --git a/extensions/telegram/src/fetch.test.ts b/extensions/telegram/src/fetch.test.ts index 9eb979f24ea..dfee35416ab 100644 --- a/extensions/telegram/src/fetch.test.ts +++ b/extensions/telegram/src/fetch.test.ts @@ -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; destroy: ReturnType; @@ -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["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, 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)[TEST_UNDICI_RUNTIME_DEPS_KEY] = { + Agent: AgentCtor, + EnvHttpProxyAgent: EnvHttpProxyAgentCtor, + Pool: vi.fn(function MockPool( + this: MockDispatcherInstance, + _origin: unknown, + options?: Record, + ) { + 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"); diff --git a/extensions/telegram/src/fetch.ts b/extensions/telegram/src/fetch.ts index 326212c125d..a4fb39b0787 100644 --- a/extensions/telegram/src/fetch.ts +++ b/extensions/telegram/src/fetch.ts @@ -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 + | ReturnType; type TelegramDispatcherMode = "direct" | "env-proxy" | "explicit-proxy"; @@ -310,10 +315,10 @@ function createTelegramDispatcher(policy: PinnedDispatcherPolicy): { uri: policy.proxyUrl, ...poolOptions, ...(requestTlsOptions ? { requestTls: requestTlsOptions } : {}), - } satisfies ConstructorParameters[0]; + } satisfies Parameters[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[0]; + } satisfies Parameters[0]; try { return { - dispatcher: new EnvHttpProxyAgent(proxyOptions), + dispatcher: createHttp1EnvHttpProxyAgent(proxyOptions), mode: "env-proxy", effectivePolicy: policy, }; diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index d2149cbd2c7..cb182ce2bc8 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -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:*", diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index 9601f06b26b..8b605832072 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -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; }) { @@ -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)[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"); diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index 9d339f55b3a..7f5420a46f9 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -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) }, diff --git a/package.json b/package.json index 9687ed74d0c..f3bb8e10ca9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be333538db7..53366293cf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/channels/bundled-channel-catalog-read.test.ts b/src/channels/bundled-channel-catalog-read.test.ts index 28d0136769c..1c8decf78ea 100644 --- a/src/channels/bundled-channel-catalog-read.test.ts +++ b/src/channels/bundled-channel-catalog-read.test.ts @@ -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. diff --git a/src/cli/proxy-cli.runtime.test.ts b/src/cli/proxy-cli.runtime.test.ts index 7d7b032fea7..65ea9f6ea5d 100644 --- a/src/cli/proxy-cli.runtime.test.ts +++ b/src/cli/proxy-cli.runtime.test.ts @@ -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", ); }); diff --git a/src/cli/proxy-cli.runtime.ts b/src/cli/proxy-cli.runtime.ts index a7299385d08..3e7dcc65cd4 100644 --- a/src/cli/proxy-cli.runtime.ts +++ b/src/cli/proxy-cli.runtime.ts @@ -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, diff --git a/src/cli/proxy-cli.test.ts b/src/cli/proxy-cli.test.ts index c4c694af560..5ec3e677f33 100644 --- a/src/cli/proxy-cli.test.ts +++ b/src/cli/proxy-cli.test.ts @@ -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", diff --git a/src/cli/proxy-cli.ts b/src/cli/proxy-cli.ts index 30f133637d4..3bd4e18998b 100644 --- a/src/cli/proxy-cli.ts +++ b/src/cli/proxy-cli.ts @@ -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 ", "Proxy URL to validate instead of config/env") + .option("--proxy-ca-file ", "CA bundle file for verifying an HTTPS proxy endpoint") .option( "--allowed-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, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index c3a688464d8..ca9faf028d6 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1010,6 +1010,18 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index eedd6fdfc51..d910e213dde 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -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); }); diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 71c58319365..942c5ad7623 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -606,6 +606,12 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index 3372d6db092..9fdfdd01c2a 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -55,6 +55,7 @@ const TAG_OVERRIDES: Record = { "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"], }; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 2f64ddb446e..b8da04872ee 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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"); diff --git a/src/config/zod-schema.proxy.test.ts b/src/config/zod-schema.proxy.test.ts index 0784bf08d6b..e84bdb510c9 100644 --- a/src/config/zod-schema.proxy.test.ts +++ b/src/config/zod-schema.proxy.test.ts @@ -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); diff --git a/src/config/zod-schema.proxy.ts b/src/config/zod-schema.proxy.ts index 81e1b260e63..cf1bdf3e591 100644 --- a/src/config/zod-schema.proxy.ts +++ b/src/config/zod-schema.proxy.ts @@ -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() diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index edbc0251722..19b73eca93a 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -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, diff --git a/src/infra/net/http-connect-tunnel.test.ts b/src/infra/net/http-connect-tunnel.test.ts index b01e1b7cef1..e6d4dfc1f1f 100644 --- a/src/infra/net/http-connect-tunnel.test.ts +++ b/src/infra/net/http-connect-tunnel.test.ts @@ -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); diff --git a/src/infra/net/http-connect-tunnel.ts b/src/infra/net/http-connect-tunnel.ts index 5a21af1b8f8..3cbe03aab9e 100644 --- a/src/infra/net/http-connect-tunnel.ts +++ b/src/infra/net/http-connect-tunnel.ts @@ -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, diff --git a/src/infra/net/proxy-fetch.test.ts b/src/infra/net/proxy-fetch.test.ts index e3a4f635702..86626497eb9 100644 --- a/src/infra/net/proxy-fetch.test.ts +++ b/src/infra/net/proxy-fetch.test.ts @@ -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 }); diff --git a/src/infra/net/proxy-fetch.ts b/src/infra/net/proxy-fetch.ts index 4483225a15e..09578df15e7 100644 --- a/src/infra/net/proxy-fetch.ts +++ b/src/infra/net/proxy-fetch.ts @@ -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 | null = null; const resolveAgent = (): InstanceType => { 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; } diff --git a/src/infra/net/proxy/active-proxy-state.ts b/src/infra/net/proxy/active-proxy-state.ts index 80581fbe465..91e619d08ee 100644 --- a/src/infra/net/proxy/active-proxy-state.ts +++ b/src/infra/net/proxy/active-proxy-state.ts @@ -1,4 +1,5 @@ import type { ProxyConfig } from "../../../config/zod-schema.proxy.js"; +import type { ManagedProxyTlsOptions } from "./proxy-tls.js"; export type ActiveManagedProxyUrl = Readonly; @@ -7,11 +8,18 @@ export type ActiveManagedProxyLoopbackMode = NonNullable { + 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" }, + }); + }); +}); diff --git a/src/infra/net/proxy/managed-proxy-undici.ts b/src/infra/net/proxy/managed-proxy-undici.ts new file mode 100644 index 00000000000..9328315681b --- /dev/null +++ b/src/infra/net/proxy/managed-proxy-undici.ts @@ -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[0]; + +function isProxyTlsRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readProxyTlsRecord(options: object | undefined): Record | 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( + options: TOptions, + params?: AddActiveManagedProxyTlsOptionsParams, +): TOptions | (TOptions & { proxyTls: Record }); +export function addActiveManagedProxyTlsOptions( + options: TOptions | undefined, + params?: AddActiveManagedProxyTlsOptionsParams, +): + | TOptions + | (TOptions & { proxyTls: Record }) + | { + proxyTls: ManagedProxyTlsOptions; + } + | undefined; +export function addActiveManagedProxyTlsOptions( + options: TOptions | undefined, + params?: AddActiveManagedProxyTlsOptionsParams, +): + | TOptions + | (TOptions & { proxyTls: Record }) + | { 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 }); +} diff --git a/src/infra/net/proxy/proxy-lifecycle.test.ts b/src/infra/net/proxy/proxy-lifecycle.test.ts index aae3d95813f..7be477dbaba 100644 --- a/src/infra/net/proxy/proxy-lifecycle.test.ts +++ b/src/infra/net/proxy/proxy-lifecycle.test.ts @@ -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 () => { diff --git a/src/infra/net/proxy/proxy-lifecycle.ts b/src/infra/net/proxy/proxy-lifecycle.ts index ee15e7fd610..2c3fed64532 100644 --- a/src/infra/net/proxy/proxy-lifecycle.ts +++ b/src/infra/net/proxy/proxy-lifecycle.ts @@ -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; @@ -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 { @@ -204,16 +238,23 @@ export async function startProxy(config: ProxyConfig | undefined): Promise; + +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 { + 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, + }); + } +} diff --git a/src/infra/net/proxy/proxy-validation.test.ts b/src/infra/net/proxy/proxy-validation.test.ts index e93717ad80c..d443e31acfe 100644 --- a/src/infra/net/proxy/proxy-validation.test.ts +++ b/src/infra/net/proxy/proxy-validation.test.ts @@ -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: { diff --git a/src/infra/net/proxy/proxy-validation.ts b/src/infra/net/proxy/proxy-validation.ts index 82083716e47..2aa4d44c061 100644 --- a/src/infra/net/proxy/proxy-validation.ts +++ b/src/infra/net/proxy/proxy-validation.ts @@ -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>; 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 { - 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 { - 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 { @@ -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 { @@ -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 { 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, }), diff --git a/src/infra/net/ssrf.dispatcher.test.ts b/src/infra/net/ssrf.dispatcher.test.ts index 9346d6b3021..ee30e972177 100644 --- a/src/infra/net/ssrf.dispatcher.test.ts +++ b/src/infra/net/ssrf.dispatcher.test.ts @@ -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, diff --git a/src/infra/net/undici-global-dispatcher.test.ts b/src/infra/net/undici-global-dispatcher.test.ts index 8e7749acb9f..ab9e273063a 100644 --- a/src/infra/net/undici-global-dispatcher.test.ts +++ b/src/infra/net/undici-global-dispatcher.test.ts @@ -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, timeoutMs?: number) => + new Agent({ + ...options, + ...(timeoutMs ? { bodyTimeout: timeoutMs, headersTimeout: timeoutMs } : {}), + allowH2: false, + }), + ); + const createHttp1EnvHttpProxyAgent = vi.fn( + (options?: Record, 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 }; + 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 }; + 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", }); }); diff --git a/src/infra/net/undici-global-dispatcher.ts b/src/infra/net/undici-global-dispatcher.ts index 70e75954f8e..fa817a7c42b 100644 --- a/src/infra/net/undici-global-dispatcher.ts +++ b/src/infra/net/undici-global-dispatcher.ts @@ -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[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[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. diff --git a/src/infra/net/undici-runtime.test.ts b/src/infra/net/undici-runtime.test.ts new file mode 100644 index 00000000000..3c5b605f6bc --- /dev/null +++ b/src/infra/net/undici-runtime.test.ts @@ -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)[TEST_UNDICI_RUNTIME_DEPS_KEY] = { + Agent: MockAgent, + EnvHttpProxyAgent: MockEnvHttpProxyAgent, + Pool: MockPool, + ProxyAgent: MockProxyAgent, + fetch: vi.fn(), + }; +} + +function expectOptionsRecord(options: unknown, message: string): Record { + if (typeof options !== "object" || options === null || Array.isArray(options)) { + throw new Error(message); + } + return options as Record; +} + +function requireProxyAgentOptions(): Record { + 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 { + 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 { + 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): 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, 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), + ); + }); +}); diff --git a/src/infra/net/undici-runtime.ts b/src/infra/net/undici-runtime.ts index 7bd5eef5608..47f20d66c62 100644 --- a/src/infra/net/undici-runtime.ts +++ b/src/infra/net/undici-runtime.ts @@ -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[0]; +type UndiciProxyAgentOptionsRecord = Exclude; +type UndiciProxyClientFactory = NonNullable; +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)[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[1], + ); + }; +} + +function addIpSafeProxyClientFactory(options: TOptions): TOptions { + if ("clientFactory" in options) { + return options; + } + return { + ...options, + clientFactory: createIpSafeProxyClientFactory(), + }; +} + export function loadUndiciRuntimeDeps(): UndiciRuntimeDeps { const override = (globalThis as Record)[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, ); } diff --git a/src/infra/push-apns-http2.test.ts b/src/infra/push-apns-http2.test.ts index 87da09a921b..8376583fd28 100644 --- a/src/infra/push-apns-http2.test.ts +++ b/src/infra/push-apns-http2.test.ts @@ -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); diff --git a/src/infra/push-apns-http2.ts b/src/infra/push-apns-http2.ts index 32cdbe45150..9a9891a0d68 100644 --- a/src/infra/push-apns-http2.ts +++ b/src/infra/push-apns-http2.ts @@ -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 { 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, }); diff --git a/src/plugin-sdk/api-baseline.test.ts b/src/plugin-sdk/api-baseline.test.ts new file mode 100644 index 00000000000..2f39fd6a23a --- /dev/null +++ b/src/plugin-sdk/api-baseline.test.ts @@ -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) | 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" } })', + ); + }); +}); diff --git a/src/plugin-sdk/api-baseline.ts b/src/plugin-sdk/api-baseline.ts index 8c487a35c53..a8a9c63c0ca 100644 --- a/src/plugin-sdk/api-baseline.ts +++ b/src/plugin-sdk/api-baseline.ts @@ -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; diff --git a/src/plugin-sdk/extension-shared.test.ts b/src/plugin-sdk/extension-shared.test.ts new file mode 100644 index 00000000000..775bc3e8c24 --- /dev/null +++ b/src/plugin-sdk/extension-shared.test.ts @@ -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" }, + }); + }); +}); diff --git a/src/plugin-sdk/extension-shared.ts b/src/plugin-sdk/extension-shared.ts index d6ede877de3..bed63867da4 100644 --- a/src/plugin-sdk/extension-shared.ts +++ b/src/plugin-sdk/extension-shared.ts @@ -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(params?: { return undefined; } try { - const agent = createAmbientNodeProxyAgent({ protocol }); + const proxyTls = resolveActiveManagedProxyTlsOptions(); + const agent = createAmbientNodeProxyAgent({ + protocol, + ...(proxyTls ? { proxyTls } : {}), + }); if (agent === undefined) { return undefined; } diff --git a/src/plugin-sdk/fetch-runtime.ts b/src/plugin-sdk/fetch-runtime.ts index b954e8edf97..060dede333a 100644 --- a/src/plugin-sdk/fetch-runtime.ts +++ b/src/plugin-sdk/fetch-runtime.ts @@ -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,