diff --git a/CHANGELOG.md b/CHANGELOG.md index d14078fd526..8d6ba910e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Gateway/chat: accept non-image attachments through `chat.send` by staging them as agent-readable media paths, while keeping unsupported RPC attachment paths explicit instead of silently dropping files. Fixes #48123. (#67572) Thanks @samzong. +- Security/networking: add opt-in operator-managed outbound proxy routing (proxy.enabled + proxy.proxyUrl/OPENCLAW_PROXY_URL) with strict http:// forward-proxy validation, loopback-only Gateway bypass, and cleanup of proxy env/dispatcher state on exit. (#70044) Thanks @jesse-merhi and @joshavant. ### Fixes diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 15d51d27164..04e8808f96a 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -eaa444149224b6cd634d034003c07f3c430d4e680e58aaa158e0038c1b03398e config-baseline.json -6aa2d317b20d73fba7b5f0d36dffc3d0c33796147b544434654d9fe4c1885c5f config-baseline.core.json +b80df5537b3569826a23b8176910476493ae569b65f9b4c2fa9e0ad415eb4a2b config-baseline.json +8530c8fd54e04a2ab7f6704195f9959311e289ae122ebd8e27af236de435fef9 config-baseline.core.json c4f07c228d4f07e7afafa5b600b4a80f5b26aaed7267c7287a64d04a527be8e8 config-baseline.channel.json 1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json diff --git a/docs/docs.json b/docs/docs.json index 1a5876fe218..82d0ac57122 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1521,6 +1521,7 @@ { "group": "Security", "pages": [ + "security/network-proxy", "security/formal-verification", "security/THREAT-MODEL-ATLAS", "security/CONTRIBUTING-THREAT-MODEL" diff --git a/docs/security/network-proxy.md b/docs/security/network-proxy.md new file mode 100644 index 00000000000..d4eb0a260fe --- /dev/null +++ b/docs/security/network-proxy.md @@ -0,0 +1,155 @@ +--- +summary: "How to route OpenClaw runtime HTTP and WebSocket traffic through an operator-managed filtering proxy" +title: "Network proxy" +read_when: + - You want defense-in-depth against SSRF and DNS rebinding attacks + - Configuring an external forward proxy for OpenClaw runtime traffic +--- + +# Network Proxy + +OpenClaw can route runtime HTTP and WebSocket traffic through an operator-managed forward proxy. This is optional defense in depth for deployments that want central egress control, stronger SSRF protection, and better network auditability. + +OpenClaw does not ship, download, start, configure, or certify a proxy. You run the proxy technology that fits your environment, and OpenClaw routes normal process-local HTTP and WebSocket clients through it. + +## Why Use a Proxy? + +A proxy gives operators one network control point for outbound HTTP and WebSocket traffic. That can be useful even outside SSRF hardening: + +- Central policy: maintain one egress policy instead of relying on every application HTTP call site to get network rules right. +- Connect-time checks: evaluate the destination after DNS resolution and immediately before the proxy opens the upstream connection. +- DNS rebinding defense: reduce the gap between an application-level DNS check and the actual outbound connection. +- Broader JavaScript coverage: route ordinary `fetch`, `node:http`, `node:https`, WebSocket, axios, got, node-fetch, and similar clients through the same path. +- Auditability: log allowed and denied destinations at the egress boundary. +- Operational control: enforce destination rules, network segmentation, rate limits, or outbound allowlists without rebuilding OpenClaw. + +OpenClaw still keeps application-level SSRF guards such as `fetchWithSsrFGuard`. Proxy routing is an additional process-level guardrail for normal HTTP and WebSocket egress, not a replacement for guarded fetches or an OS-level network sandbox. + +## How OpenClaw Routes Traffic + +When `proxy.enabled=true` and a proxy URL is configured, protected runtime processes such as `openclaw gateway run`, `openclaw node run`, and `openclaw agent --local` route normal HTTP and WebSocket egress through the configured proxy: + +```text +OpenClaw process + fetch -> operator-managed filtering proxy -> public internet + node:http and https -> operator-managed filtering proxy -> public internet + WebSocket clients -> operator-managed filtering proxy -> public internet +``` + +The public contract is the routing behavior, not the internal Node hooks used to implement it. OpenClaw Gateway control-plane WebSocket clients use a narrow direct path for local loopback Gateway RPC traffic when the Gateway URL uses a literal loopback IP such as `127.0.0.1` or `[::1]`. That control-plane path must be able to reach loopback Gateways even when the operator proxy blocks loopback destinations. Normal runtime HTTP and WebSocket requests still use the configured proxy. + +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`. + +While the proxy is active, OpenClaw clears `no_proxy`, `NO_PROXY`, and `GLOBAL_AGENT_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. + +On shutdown, OpenClaw restores the previous proxy environment and resets cached process routing state. + +## Configuration + +```yaml +proxy: + enabled: true + proxyUrl: http://127.0.0.1:3128 +``` + +You can also provide the URL through the environment, while keeping `proxy.enabled=true` in config: + +```bash +OPENCLAW_PROXY_URL=http://127.0.0.1:3128 openclaw gateway run +``` + +`proxy.proxyUrl` takes precedence over `OPENCLAW_PROXY_URL`. + +If `enabled=true` but no valid proxy URL is configured, protected commands fail startup instead of falling back to direct network access. + +For managed gateway services started with `openclaw gateway start`, prefer storing the URL in config: + +```bash +openclaw config set proxy.enabled true +openclaw config set proxy.proxyUrl http://127.0.0.1:3128 +openclaw gateway install --force +openclaw gateway start +``` + +The environment fallback is best for foreground runs. If you use it with an installed service, put `OPENCLAW_PROXY_URL` in the service durable environment, such as `$OPENCLAW_STATE_DIR/.env` or `~/.openclaw/.env`, then reinstall the service so launchd, systemd, or Scheduled Tasks starts the gateway with that value. + +For `openclaw --container ...` commands, OpenClaw forwards `OPENCLAW_PROXY_URL` into the container-targeted child CLI when it is set. The URL must be reachable from inside the container; `127.0.0.1` refers to the container itself, not the host. OpenClaw rejects loopback proxy URLs for container-targeted commands unless you explicitly override that safety check. + +## Proxy Requirements + +The proxy policy is the security boundary. OpenClaw cannot verify that the proxy blocks the right targets. + +Configure the proxy to: + +- Bind only to loopback or a private trusted interface. +- Restrict access so only the OpenClaw process, host, container, or service account can use it. +- Resolve destinations itself and block destination IPs after DNS resolution. +- Apply policy at connect time for both plain HTTP requests and HTTPS `CONNECT` tunnels. +- Reject destination-based bypasses for loopback, private, link-local, metadata, multicast, reserved, or documentation ranges. +- Avoid hostname allowlists unless you fully trust the DNS resolution path. +- Log destination, decision, status, and reason without logging request bodies, authorization headers, cookies, or other secrets. +- Keep proxy policy under version control and review changes like security-sensitive configuration. + +## Recommended Blocked Destinations + +Use this denylist as the starting point for any forward proxy, firewall, or egress policy. + +OpenClaw application-level classifier logic lives in `src/infra/net/ssrf.ts` and `src/shared/net/ip.ts`. The relevant parity hooks are `BLOCKED_HOSTNAMES`, `BLOCKED_IPV4_SPECIAL_USE_RANGES`, `BLOCKED_IPV6_SPECIAL_USE_RANGES`, `RFC2544_BENCHMARK_PREFIX`, and the embedded IPv4 sentinel handling for NAT64, 6to4, Teredo, ISATAP, and IPv4-mapped forms. Those files are useful references when maintaining an external proxy policy, but OpenClaw does not automatically export or enforce those rules in your proxy. + +| Range or host | Why to block | +| ------------------------------------------------------------------------------------ | ---------------------------------------------------- | +| `127.0.0.0/8`, `localhost`, `localhost.localdomain` | IPv4 loopback | +| `::1/128` | IPv6 loopback | +| `0.0.0.0/8`, `::/128` | Unspecified and this-network addresses | +| `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | RFC1918 private networks | +| `169.254.0.0/16`, `fe80::/10` | Link-local addresses and common cloud metadata paths | +| `169.254.169.254`, `metadata.google.internal` | Cloud metadata services | +| `100.64.0.0/10` | Carrier-grade NAT shared address space | +| `198.18.0.0/15`, `2001:2::/48` | Benchmarking ranges | +| `192.0.0.0/24`, `192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`, `2001:db8::/32` | Special-use and documentation ranges | +| `224.0.0.0/4`, `ff00::/8` | Multicast | +| `240.0.0.0/4` | Reserved IPv4 | +| `fc00::/7`, `fec0::/10` | IPv6 local/private ranges | +| `100::/64`, `2001:20::/28` | IPv6 discard and ORCHIDv2 ranges | +| `64:ff9b::/96`, `64:ff9b:1::/48` | NAT64 prefixes with embedded IPv4 | +| `2002::/16`, `2001::/32` | 6to4 and Teredo with embedded IPv4 | +| `::/96`, `::ffff:0:0/96` | IPv4-compatible and IPv4-mapped IPv6 | + +If your cloud provider or network platform documents additional metadata hosts or reserved ranges, add those too. + +## Validation + +Validate the proxy from the same host, container, or service account that runs OpenClaw: + +```bash +curl -x http://127.0.0.1:3128 https://example.com/ +curl -x http://127.0.0.1:3128 http://127.0.0.1/ +curl -x http://127.0.0.1:3128 http://169.254.169.254/ +``` + +The public request should succeed. The loopback and metadata requests should fail at the proxy. + +Then enable OpenClaw proxy routing: + +```bash +openclaw config set proxy.enabled true +openclaw config set proxy.proxyUrl http://127.0.0.1:3128 +openclaw gateway run +``` + +or set: + +```yaml +proxy: + enabled: true + proxyUrl: http://127.0.0.1:3128 +``` + +## Limits + +- The proxy improves coverage for process-local JavaScript HTTP and WebSocket clients, but it does not replace application-level `fetchWithSsrFGuard`. +- Raw `net`, `tls`, and `http2` sockets, native addons, and child processes may bypass Node-level proxy routing unless they inherit and respect proxy environment variables. +- User local WebUIs and local model servers should be allowlisted in the operator proxy policy when needed; OpenClaw does not expose a general local-network bypass for them. +- Gateway control-plane proxy bypass is intentionally limited to literal loopback IP URLs. Use `ws://127.0.0.1:18789` or `ws://[::1]:18789` for local direct Gateway control-plane connections; `localhost` hostnames route like ordinary hostname-based traffic. +- OpenClaw does not inspect, test, or certify your proxy policy. +- Treat proxy policy changes as security-sensitive operational changes. diff --git a/package.json b/package.json index 0431b5d7578..67bfea2b4e2 100644 --- a/package.json +++ b/package.json @@ -1601,6 +1601,7 @@ "croner": "^10.0.1", "dotenv": "^17.4.2", "file-type": "22.0.1", + "global-agent": "^4.1.3", "https-proxy-agent": "^9.0.0", "ipaddr.js": "^2.3.0", "jiti": "^2.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ec509f294b..e6758a1e05b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: file-type: specifier: 22.0.1 version: 22.0.1 + global-agent: + specifier: ^4.1.3 + version: 4.1.3 https-proxy-agent: specifier: ^9.0.0 version: 9.0.0 @@ -4945,10 +4948,18 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + defu@6.1.5: resolution: {integrity: sha512-pwdBJxJuJXmqrLO6s0VBmfbRz+G7FUzkjldAsdi9Yrv86mPyzq0ll1o8+8gB4Gsr6GJHbK1Lh3ngllgTInDCjA==} @@ -5110,6 +5121,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + escodegen@2.1.0: resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} engines: {node: '>=6.0'} @@ -5392,6 +5407,14 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-agent@4.1.3: + resolution: {integrity: sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==} + engines: {node: '>=10.0'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + google-auth-library@10.6.2: resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} engines: {node: '>=18'} @@ -5431,6 +5454,9 @@ packages: resolution: {integrity: sha512-RDKhzgQTQfMaLvIFhjahU+2gGnRBK6dYOd5Gd9BzkmnBneOCRYjRC003RIMrdAbH52+l+CnMS4bBCXGer8tEhg==} deprecated: This project is not maintained. Use Object.hasOwn() instead. + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -5992,6 +6018,10 @@ packages: engines: {node: '>= 20'} hasBin: true + matcher@4.0.0: + resolution: {integrity: sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -6294,6 +6324,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -6923,6 +6957,10 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + serialize-error@8.1.0: + resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -7287,6 +7325,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -11694,8 +11736,20 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + defu@6.1.5: {} degenerator@5.0.1: @@ -11858,6 +11912,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + escodegen@2.1.0: dependencies: esprima: 4.0.1 @@ -12234,6 +12290,18 @@ snapshots: path-is-absolute: 1.0.1 optional: true + global-agent@4.1.3: + dependencies: + globalthis: 1.0.4 + matcher: 4.0.0 + semver: 7.7.4 + serialize-error: 8.1.0 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + google-auth-library@10.6.2: dependencies: base64-js: 1.5.1 @@ -12287,6 +12355,10 @@ snapshots: has-own@1.0.1: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -12931,6 +13003,10 @@ snapshots: marked@18.0.2: {} + matcher@4.0.0: + dependencies: + escape-string-regexp: 4.0.0 + math-intrinsics@1.1.0: {} matrix-events-sdk@0.0.1: {} @@ -13423,6 +13499,8 @@ snapshots: object-inspect@1.13.4: {} + object-keys@1.1.1: {} + obug@2.1.1: {} ogg-opus-decoder@1.7.3: @@ -14177,6 +14255,10 @@ snapshots: transitivePeerDependencies: - supports-color + serialize-error@8.1.0: + dependencies: + type-fest: 0.20.2 + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -14594,6 +14676,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-fest@0.20.2: {} + type-is@2.0.1: dependencies: content-type: 1.0.5 diff --git a/src/acp/server.startup.test.ts b/src/acp/server.startup.test.ts index 8c072948ed6..33ab0edc1f8 100644 --- a/src/acp/server.startup.test.ts +++ b/src/acp/server.startup.test.ts @@ -22,6 +22,8 @@ const mockState = vi.hoisted(() => ({ agentSideConnectionCtor: vi.fn(), agentStart: vi.fn(), routeLogsToStderr: vi.fn(), + startProxy: vi.fn(async (_config: unknown) => null as unknown), + stopProxy: vi.fn(async (_handle: unknown) => {}), resolveGatewayClientBootstrap: vi.fn(async (_params) => ({ url: "ws://127.0.0.1:18789", urlSource: "local loopback", @@ -113,6 +115,11 @@ vi.mock("../logging/console.js", () => ({ routeLogsToStderr: () => mockState.routeLogsToStderr(), })); +vi.mock("../infra/net/proxy/proxy-lifecycle.js", () => ({ + startProxy: (config: unknown) => mockState.startProxy(config), + stopProxy: (handle: unknown) => mockState.stopProxy(handle), +})); + vi.mock("./translator.js", () => ({ AcpGatewayAgent: class { start(): void { @@ -176,6 +183,10 @@ describe("serveAcpGateway startup", () => { mockState.agentSideConnectionCtor.mockReset(); mockState.agentStart.mockReset(); mockState.routeLogsToStderr.mockReset(); + mockState.startProxy.mockReset(); + mockState.stopProxy.mockReset(); + mockState.startProxy.mockResolvedValue(null); + mockState.stopProxy.mockResolvedValue(undefined); mockState.resolveGatewayClientBootstrap.mockReset(); mockState.resolveGatewayClientBootstrap.mockResolvedValue({ url: "ws://127.0.0.1:18789", @@ -296,4 +307,22 @@ describe("serveAcpGateway startup", () => { onceSpy.mockRestore(); } }); + + it("does not proxy the standalone ACP control-plane Gateway connection", async () => { + const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); + + try { + const servePromise = serveAcpGateway({}); + await vi.waitFor(() => { + expect(mockState.gateways).toHaveLength(1); + }); + + expect(mockState.startProxy).not.toHaveBeenCalled(); + await emitHelloAndWaitForAgentSideConnection(); + await stopServeWithSigint(signalHandlers, servePromise); + expect(mockState.stopProxy).not.toHaveBeenCalled(); + } finally { + onceSpy.mockRestore(); + } + }); }); diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 2511096649c..89b78717f8e 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -1,5 +1,11 @@ +import { hasFlag } from "./argv.js"; + export type CliCommandPluginLoadPolicy = "never" | "always" | "text-only"; export type CliRouteConfigGuardPolicy = "never" | "always" | "when-suppressed"; +export type CliNetworkProxyPolicy = "default" | "bypass"; +export type CliNetworkProxyPolicyResolver = + | CliNetworkProxyPolicy + | ((ctx: { argv: string[]; commandPath: string[] }) => CliNetworkProxyPolicy); export type CliRoutedCommandId = | "health" | "status" @@ -21,6 +27,7 @@ export type CliCommandPathPolicy = { loadPlugins: CliCommandPluginLoadPolicy; hideBanner: boolean; ensureCliPath: boolean; + networkProxy: CliNetworkProxyPolicyResolver; }; export type CliCommandCatalogEntry = { @@ -38,11 +45,17 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ commandPath: ["crestodian"], policy: { bypassConfigGuard: true, loadPlugins: "never", ensureCliPath: false }, }, - { commandPath: ["agent"], policy: { loadPlugins: "always" } }, + { + commandPath: ["agent"], + policy: { + loadPlugins: "always", + networkProxy: ({ argv }) => (hasFlag(argv, "--local") ? "default" : "bypass"), + }, + }, { commandPath: ["message"], policy: { loadPlugins: "never" } }, { commandPath: ["channels"], policy: { loadPlugins: "always" } }, { commandPath: ["directory"], policy: { loadPlugins: "always" } }, - { commandPath: ["agents"], policy: { loadPlugins: "always" } }, + { commandPath: ["agents"], policy: { loadPlugins: "always", networkProxy: "bypass" } }, { commandPath: ["agents", "bind"], exact: true, @@ -69,34 +82,59 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ policy: { loadPlugins: "never" }, }, { commandPath: ["configure"], policy: { bypassConfigGuard: true, loadPlugins: "never" } }, - { commandPath: ["migrate"], policy: { bypassConfigGuard: true, loadPlugins: "never" } }, + { + commandPath: ["migrate"], + policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" }, + }, { commandPath: ["status"], policy: { loadPlugins: "never", routeConfigGuard: "when-suppressed", ensureCliPath: false, + networkProxy: "bypass", }, route: { id: "status" }, }, { commandPath: ["health"], - policy: { loadPlugins: "never", ensureCliPath: false }, + policy: { loadPlugins: "never", ensureCliPath: false, networkProxy: "bypass" }, route: { id: "health" }, }, + { + commandPath: ["gateway"], + policy: { + networkProxy: ({ commandPath }) => + commandPath.length === 1 || commandPath[1] === "run" ? "default" : "bypass", + }, + }, { commandPath: ["gateway", "status"], exact: true, policy: { routeConfigGuard: "always", loadPlugins: "never", + networkProxy: "bypass", }, route: { id: "gateway-status" }, }, + { commandPath: ["gateway", "call"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["gateway", "diagnostics"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["gateway", "discover"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["gateway", "export"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["gateway", "health"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["gateway", "install"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["gateway", "probe"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["gateway", "restart"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["gateway", "stability"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["gateway", "start"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["gateway", "stop"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["gateway", "uninstall"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["gateway", "usage-cost"], exact: true, policy: { networkProxy: "bypass" } }, { commandPath: ["sessions"], exact: true, - policy: { ensureCliPath: false }, + policy: { ensureCliPath: false, networkProxy: "bypass" }, route: { id: "sessions" }, }, { @@ -106,70 +144,121 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ // is only used in human text output. text-only skips the bundled-plugin // import waterfall in `--json` mode, mirroring what `channels list` // already does. Human (non-JSON) invocations still load plugins. (#71739) - policy: { loadPlugins: "text-only" }, + policy: { loadPlugins: "text-only", networkProxy: "bypass" }, route: { id: "agents-list" }, }, { commandPath: ["config", "get"], exact: true, - policy: { ensureCliPath: false }, + policy: { ensureCliPath: false, networkProxy: "bypass" }, route: { id: "config-get" }, }, { commandPath: ["config", "unset"], exact: true, - policy: { ensureCliPath: false }, + policy: { ensureCliPath: false, networkProxy: "bypass" }, route: { id: "config-unset" }, }, { commandPath: ["models", "list"], exact: true, - policy: { ensureCliPath: false, routeConfigGuard: "always" }, + policy: { ensureCliPath: false, routeConfigGuard: "always", networkProxy: "bypass" }, route: { id: "models-list" }, }, { commandPath: ["models", "status"], exact: true, - policy: { ensureCliPath: false, routeConfigGuard: "always" }, + policy: { + ensureCliPath: false, + routeConfigGuard: "always", + networkProxy: ({ argv }) => (hasFlag(argv, "--probe") ? "default" : "bypass"), + }, route: { id: "models-status" }, }, { commandPath: ["tasks", "list"], exact: true, - policy: { ensureCliPath: false, routeConfigGuard: "when-suppressed", loadPlugins: "never" }, + policy: { + ensureCliPath: false, + routeConfigGuard: "when-suppressed", + loadPlugins: "never", + networkProxy: "bypass", + }, route: { id: "tasks-list" }, }, { commandPath: ["tasks", "audit"], exact: true, - policy: { ensureCliPath: false, routeConfigGuard: "when-suppressed", loadPlugins: "never" }, + policy: { + ensureCliPath: false, + routeConfigGuard: "when-suppressed", + loadPlugins: "never", + networkProxy: "bypass", + }, route: { id: "tasks-audit" }, }, { commandPath: ["tasks"], - policy: { ensureCliPath: false, routeConfigGuard: "when-suppressed", loadPlugins: "never" }, + policy: { + ensureCliPath: false, + routeConfigGuard: "when-suppressed", + loadPlugins: "never", + networkProxy: "bypass", + }, route: { id: "tasks-list" }, }, - { commandPath: ["backup"], policy: { bypassConfigGuard: true } }, + { commandPath: ["acp"], policy: { networkProxy: "bypass" } }, + { commandPath: ["approvals"], policy: { networkProxy: "bypass" } }, + { commandPath: ["backup"], policy: { bypassConfigGuard: true, networkProxy: "bypass" } }, + { commandPath: ["chat"], policy: { networkProxy: "bypass" } }, + { commandPath: ["config"], policy: { networkProxy: "bypass" } }, + { commandPath: ["cron"], policy: { networkProxy: "bypass" } }, + { commandPath: ["dashboard"], policy: { networkProxy: "bypass" } }, + { commandPath: ["daemon"], policy: { networkProxy: "bypass" } }, + { commandPath: ["devices"], policy: { networkProxy: "bypass" } }, { commandPath: ["doctor"], policy: { bypassConfigGuard: true } }, + { commandPath: ["exec-policy"], policy: { networkProxy: "bypass" } }, + { commandPath: ["hooks"], policy: { networkProxy: "bypass" } }, + { commandPath: ["logs"], policy: { networkProxy: "bypass" } }, + { commandPath: ["mcp"], policy: { networkProxy: "bypass" } }, + { + commandPath: ["node"], + policy: { networkProxy: "bypass" }, + }, + { + commandPath: ["node", "run"], + exact: true, + policy: { networkProxy: "default" }, + }, + { commandPath: ["nodes"], policy: { networkProxy: "bypass" } }, + { commandPath: ["pairing"], policy: { networkProxy: "bypass" } }, + { commandPath: ["proxy"], policy: { networkProxy: "bypass" } }, + { commandPath: ["qr"], policy: { networkProxy: "bypass" } }, + { commandPath: ["reset"], policy: { networkProxy: "bypass" } }, { commandPath: ["completion"], policy: { bypassConfigGuard: true, hideBanner: true, + networkProxy: "bypass", }, }, - { commandPath: ["secrets"], policy: { bypassConfigGuard: true } }, + { commandPath: ["secrets"], policy: { bypassConfigGuard: true, networkProxy: "bypass" } }, + { commandPath: ["security"], policy: { networkProxy: "bypass" } }, + { commandPath: ["system"], policy: { networkProxy: "bypass" } }, + { commandPath: ["terminal"], policy: { networkProxy: "bypass" } }, + { commandPath: ["tui"], policy: { networkProxy: "bypass" } }, + { commandPath: ["uninstall"], policy: { networkProxy: "bypass" } }, { commandPath: ["update"], policy: { hideBanner: true } }, { commandPath: ["config", "validate"], exact: true, - policy: { bypassConfigGuard: true }, + policy: { bypassConfigGuard: true, networkProxy: "bypass" }, }, { commandPath: ["config", "schema"], exact: true, - policy: { bypassConfigGuard: true }, + policy: { bypassConfigGuard: true, networkProxy: "bypass" }, }, { commandPath: ["plugins", "update"], @@ -184,23 +273,43 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ { commandPath: ["channels", "add"], exact: true, - policy: { loadPlugins: "never" }, + policy: { loadPlugins: "never", networkProxy: "bypass" }, + }, + { + commandPath: ["channels", "logs"], + exact: true, + policy: { loadPlugins: "never", networkProxy: "bypass" }, + }, + { + commandPath: ["channels", "remove"], + exact: true, + policy: { networkProxy: "bypass" }, + }, + { + commandPath: ["channels", "resolve"], + exact: true, + policy: { networkProxy: "bypass" }, }, { commandPath: ["channels", "status"], exact: true, - policy: { loadPlugins: "never" }, + policy: { + loadPlugins: "never", + networkProxy: ({ argv }) => (hasFlag(argv, "--probe") ? "default" : "bypass"), + }, route: { id: "channels-status" }, }, { commandPath: ["channels", "list"], exact: true, - policy: { loadPlugins: "never" }, + policy: { loadPlugins: "never", networkProxy: "bypass" }, route: { id: "channels-list" }, }, - { - commandPath: ["channels", "logs"], - exact: true, - policy: { loadPlugins: "never" }, - }, + { commandPath: ["skills"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["skills", "check"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["skills", "info"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["skills", "install"], exact: true }, + { commandPath: ["skills", "list"], exact: true, policy: { networkProxy: "bypass" } }, + { commandPath: ["skills", "search"], exact: true }, + { commandPath: ["skills", "update"], exact: true }, ]; diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index e92ce4c55ac..89a2da50ab9 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -1,7 +1,17 @@ -import { describe, expect, it } from "vitest"; -import { resolveCliCommandPathPolicy } from "./command-path-policy.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CliCommandCatalogEntry } from "./command-catalog.js"; +import { + resolveCliCatalogCommandPath, + resolveCliCommandPathPolicy, + resolveCliNetworkProxyPolicy, +} from "./command-path-policy.js"; describe("command-path-policy", () => { + afterEach(() => { + vi.doUnmock("./command-catalog.js"); + vi.resetModules(); + }); + it("resolves status policy with shared startup semantics", () => { expect(resolveCliCommandPathPolicy(["status"])).toEqual({ bypassConfigGuard: false, @@ -9,6 +19,7 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: false, ensureCliPath: false, + networkProxy: "bypass", }); }); @@ -19,6 +30,7 @@ describe("command-path-policy", () => { loadPlugins: "always", hideBanner: false, ensureCliPath: true, + networkProxy: "default", }); expect(resolveCliCommandPathPolicy(["channels", "add"])).toEqual({ bypassConfigGuard: false, @@ -26,6 +38,7 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: false, ensureCliPath: true, + networkProxy: "bypass", }); expect(resolveCliCommandPathPolicy(["channels", "status"])).toEqual({ bypassConfigGuard: false, @@ -33,6 +46,7 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: false, ensureCliPath: true, + networkProxy: expect.any(Function), }); expect(resolveCliCommandPathPolicy(["channels", "list"])).toEqual({ bypassConfigGuard: false, @@ -40,6 +54,7 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: false, ensureCliPath: true, + networkProxy: "bypass", }); expect(resolveCliCommandPathPolicy(["channels", "logs"])).toEqual({ bypassConfigGuard: false, @@ -47,6 +62,7 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: false, ensureCliPath: true, + networkProxy: "bypass", }); }); @@ -64,6 +80,7 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: false, ensureCliPath: true, + networkProxy: "bypass", }); } }); @@ -75,6 +92,7 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: false, ensureCliPath: true, + networkProxy: "default", }); expect(resolveCliCommandPathPolicy(["config", "validate"])).toEqual({ bypassConfigGuard: true, @@ -82,6 +100,7 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: false, ensureCliPath: true, + networkProxy: "bypass", }); expect(resolveCliCommandPathPolicy(["gateway", "status"])).toEqual({ bypassConfigGuard: false, @@ -89,6 +108,7 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: false, ensureCliPath: true, + networkProxy: "bypass", }); expect(resolveCliCommandPathPolicy(["plugins", "update"])).toEqual({ bypassConfigGuard: false, @@ -96,6 +116,7 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: true, ensureCliPath: true, + networkProxy: "default", }); for (const commandPath of [ ["plugins", "install"], @@ -110,6 +131,7 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: false, ensureCliPath: true, + networkProxy: "default", }); } expect(resolveCliCommandPathPolicy(["cron", "list"])).toEqual({ @@ -118,6 +140,116 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: false, ensureCliPath: true, + networkProxy: "bypass", }); }); + + it("defaults unknown command paths to network proxy routing", () => { + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "googlemeet", "login"])).toBe( + "default", + ); + }); + + it("resolves static network proxy bypass policies from the catalog", () => { + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "status"])).toBe("bypass"); + expect( + resolveCliNetworkProxyPolicy(["node", "openclaw", "config", "get", "proxy.enabled"]), + ).toBe("bypass"); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "proxy", "start"])).toBe("bypass"); + }); + + it("resolves mixed network proxy policies from argv-sensitive catalog entries", () => { + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "gateway"])).toBe("default"); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "gateway", "run"])).toBe("default"); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "gateway", "health"])).toBe("bypass"); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "node", "run"])).toBe("default"); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "node", "status"])).toBe("bypass"); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "agent", "--local"])).toBe("default"); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "agent", "run"])).toBe("bypass"); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "channels", "status"])).toBe("bypass"); + expect( + resolveCliNetworkProxyPolicy(["node", "openclaw", "channels", "status", "--probe"]), + ).toBe("default"); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "models", "status"])).toBe("bypass"); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "models", "status", "--probe"])).toBe( + "default", + ); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "info", "browser"])).toBe( + "bypass", + ); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "search", "browser"])).toBe( + "default", + ); + }); + + it("uses the longest catalog command path for deep network proxy overrides", async () => { + const catalog: readonly CliCommandCatalogEntry[] = [ + { commandPath: ["nodes"], policy: { networkProxy: "bypass" } }, + { + commandPath: ["nodes", "camera", "snap"], + exact: true, + policy: { networkProxy: "default" }, + }, + ]; + + vi.resetModules(); + vi.doMock("./command-catalog.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, cliCommandCatalog: catalog }; + }); + const { resolveCliCatalogCommandPath, resolveCliNetworkProxyPolicy } = + await import("./command-path-policy.js"); + + expect(resolveCliCatalogCommandPath(["node", "openclaw", "nodes", "camera", "snap"])).toEqual([ + "nodes", + "camera", + "snap", + ]); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "nodes", "camera", "snap"])).toBe( + "default", + ); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "nodes", "camera", "list"])).toBe( + "bypass", + ); + }); + + it("stops catalog command path resolution before positional arguments", () => { + expect( + resolveCliCatalogCommandPath(["node", "openclaw", "config", "get", "proxy.enabled"]), + ).toEqual(["config", "get"]); + expect( + resolveCliCatalogCommandPath(["node", "openclaw", "message", "send", "--to", "demo"]), + ).toEqual(["message"]); + }); + + it("treats bare gateway invocations with options as the gateway runtime", () => { + const argv = ["node", "openclaw", "gateway", "--port", "1234"]; + + expect(resolveCliCatalogCommandPath(argv)).toEqual(["gateway"]); + expect(resolveCliNetworkProxyPolicy(argv)).toBe("default"); + }); + + it("does not let gateway run option values spoof bypass subcommands", () => { + for (const argv of [ + ["node", "openclaw", "gateway", "--token", "status"], + ["node", "openclaw", "gateway", "--token=status"], + ["node", "openclaw", "gateway", "--password", "health"], + ["node", "openclaw", "gateway", "--password-file", "status"], + ["node", "openclaw", "gateway", "--ws-log", "compact"], + ]) { + expect(resolveCliCatalogCommandPath(argv), argv.join(" ")).toEqual(["gateway"]); + expect(resolveCliNetworkProxyPolicy(argv), argv.join(" ")).toBe("default"); + } + }); + + it("still resolves real gateway bypass subcommands after their command token", () => { + expect(resolveCliCatalogCommandPath(["node", "openclaw", "gateway", "status"])).toEqual([ + "gateway", + "status", + ]); + expect( + resolveCliCatalogCommandPath(["node", "openclaw", "gateway", "status", "--token", "secret"]), + ).toEqual(["gateway", "status"]); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "gateway", "status"])).toBe("bypass"); + }); }); diff --git a/src/cli/command-path-policy.ts b/src/cli/command-path-policy.ts index 71ff2185151..47c61c13671 100644 --- a/src/cli/command-path-policy.ts +++ b/src/cli/command-path-policy.ts @@ -1,6 +1,12 @@ import { isGatewayConfigBypassCommandPath } from "../gateway/explicit-connection-policy.js"; -import { cliCommandCatalog, type CliCommandPathPolicy } from "./command-catalog.js"; +import { getCommandPathWithRootOptions } from "./argv.js"; +import { + cliCommandCatalog, + type CliCommandPathPolicy, + type CliNetworkProxyPolicy, +} from "./command-catalog.js"; import { matchesCommandPath } from "./command-path-matches.js"; +import { resolveGatewayCatalogCommandPath } from "./gateway-run-argv.js"; const DEFAULT_CLI_COMMAND_PATH_POLICY: CliCommandPathPolicy = { bypassConfigGuard: false, @@ -8,6 +14,7 @@ const DEFAULT_CLI_COMMAND_PATH_POLICY: CliCommandPathPolicy = { loadPlugins: "never", hideBanner: false, ensureCliPath: true, + networkProxy: "default", }; export function resolveCliCommandPathPolicy(commandPath: string[]): CliCommandPathPolicy { @@ -26,3 +33,31 @@ export function resolveCliCommandPathPolicy(commandPath: string[]): CliCommandPa } return resolvedPolicy; } + +function isCommandPathPrefix(commandPath: string[], pattern: readonly string[]): boolean { + return pattern.every((segment, index) => commandPath[index] === segment); +} + +export function resolveCliCatalogCommandPath(argv: string[]): string[] { + const tokens = + resolveGatewayCatalogCommandPath(argv) ?? getCommandPathWithRootOptions(argv, argv.length); + if (tokens.length === 0) { + return []; + } + let bestMatch: readonly string[] | null = null; + for (const entry of cliCommandCatalog) { + if (!isCommandPathPrefix(tokens, entry.commandPath)) { + continue; + } + if (!bestMatch || entry.commandPath.length > bestMatch.length) { + bestMatch = entry.commandPath; + } + } + return bestMatch ? [...bestMatch] : [tokens[0]]; +} + +export function resolveCliNetworkProxyPolicy(argv: string[]): CliNetworkProxyPolicy { + const commandPath = resolveCliCatalogCommandPath(argv); + const networkProxy = resolveCliCommandPathPolicy(commandPath).networkProxy; + return typeof networkProxy === "function" ? networkProxy({ argv, commandPath }) : networkProxy; +} diff --git a/src/cli/container-target.test.ts b/src/cli/container-target.test.ts index a7a62e6bbc9..c8c7a3f099a 100644 --- a/src/cli/container-target.test.ts +++ b/src/cli/container-target.test.ts @@ -213,6 +213,157 @@ describe("maybeRunCliInContainer", () => { ); }); + it("passes the proxy URL env fallback into the child container CLI", () => { + const spawnSync = vi + .fn() + .mockReturnValueOnce({ + status: 0, + stdout: "true\n", + }) + .mockReturnValueOnce({ + status: 1, + stdout: "", + }) + .mockReturnValueOnce({ + status: 0, + stdout: "", + }); + + maybeRunCliInContainer(["node", "openclaw", "status"], { + env: { + OPENCLAW_CONTAINER: "demo", + OPENCLAW_PROXY_URL: " http://proxy.internal:3128 ", + } as NodeJS.ProcessEnv, + spawnSync, + }); + + expect(spawnSync).toHaveBeenNthCalledWith( + 3, + "podman", + [ + "exec", + "-i", + "--env", + "OPENCLAW_CONTAINER_HINT=demo", + "--env", + "OPENCLAW_CLI_CONTAINER_BYPASS=1", + "--env", + "OPENCLAW_PROXY_URL=http://proxy.internal:3128", + "demo", + "openclaw", + "status", + ], + { + stdio: "inherit", + env: { + OPENCLAW_CONTAINER: "", + OPENCLAW_PROXY_URL: " http://proxy.internal:3128 ", + }, + }, + ); + }); + + it.each([ + "http://127.0.0.1:3128", + "http://127.1:3128", + "http://127.0.0.01:3128", + "http://localhost.:3128", + "http://[::1]:3128", + "http://[::ffff:127.0.0.1]:3128", + ])("fails before forwarding loopback proxy URL %s into a child container CLI", (proxyUrl) => { + const spawnSync = vi + .fn() + .mockReturnValueOnce({ + status: 0, + stdout: "true\n", + }) + .mockReturnValueOnce({ + status: 1, + stdout: "", + }); + + expect(() => + maybeRunCliInContainer(["node", "openclaw", "status"], { + env: { + OPENCLAW_CONTAINER: "demo", + OPENCLAW_PROXY_URL: ` ${proxyUrl} `, + } as NodeJS.ProcessEnv, + spawnSync, + }), + ).toThrow("127.0.0.1 inside a container points at the container"); + + expect(spawnSync).toHaveBeenCalledTimes(2); + }); + + it("redacts proxy URL credentials and URL suffixes before rejecting loopback container proxy forwarding", () => { + const spawnSync = vi + .fn() + .mockReturnValueOnce({ + status: 0, + stdout: "true\n", + }) + .mockReturnValueOnce({ + status: 1, + stdout: "", + }); + + let message = ""; + try { + maybeRunCliInContainer(["node", "openclaw", "status"], { + env: { + OPENCLAW_CONTAINER: "demo", + OPENCLAW_PROXY_URL: + "http://proxy-user:proxy-secret@127.1:3128?token=proxy-query-secret#proxy-fragment-secret", + } as NodeJS.ProcessEnv, + spawnSync, + }); + } catch (err) { + message = err instanceof Error ? err.message : String(err); + } + + expect(message).toContain("OPENCLAW_PROXY_URL=http://redacted:redacted@127.0.0.1:3128/"); + expect(message).not.toContain("proxy-user"); + expect(message).not.toContain("proxy-secret"); + expect(message).not.toContain("proxy-query-secret"); + expect(message).not.toContain("proxy-fragment-secret"); + expect(message).not.toContain("?token="); + expect(message).not.toContain("#"); + expect(spawnSync).toHaveBeenCalledTimes(2); + }); + + it("allows explicitly overridden loopback proxy URL forwarding into a child container CLI", () => { + const spawnSync = vi + .fn() + .mockReturnValueOnce({ + status: 0, + stdout: "true\n", + }) + .mockReturnValueOnce({ + status: 1, + stdout: "", + }) + .mockReturnValueOnce({ + status: 0, + stdout: "", + }); + + maybeRunCliInContainer(["node", "openclaw", "status"], { + env: { + OPENCLAW_CONTAINER: "demo", + OPENCLAW_PROXY_URL: " http://127.0.0.1:3128 ", + OPENCLAW_CONTAINER_ALLOW_LOOPBACK_PROXY_URL: "1", + } as NodeJS.ProcessEnv, + spawnSync, + }); + + expect(spawnSync).toHaveBeenNthCalledWith( + 3, + "podman", + expect.arrayContaining(["OPENCLAW_PROXY_URL=http://127.0.0.1:3128"]), + expect.anything(), + ); + }); + it("executes through podman when the named container is running", () => { const spawnSync = vi .fn() diff --git a/src/cli/container-target.ts b/src/cli/container-target.ts index 9ec2ac82f6f..8457b71ccdb 100644 --- a/src/cli/container-target.ts +++ b/src/cli/container-target.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { isIP } from "node:net"; import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { resolveCliArgvInvocation } from "./argv-invocation.js"; @@ -26,6 +27,8 @@ type ContainerRuntimeExec = { argsPrefix: string[]; }; +const CONTAINER_ALLOW_LOOPBACK_PROXY_URL_ENV = "OPENCLAW_CONTAINER_ALLOW_LOOPBACK_PROXY_URL"; + export function parseCliContainerArgs(argv: string[]): CliContainerParseResult { let container: string | null = null; @@ -127,10 +130,16 @@ function buildContainerExecArgs(params: { exec: ContainerRuntimeExec; containerName: string; argv: string[]; + env: NodeJS.ProcessEnv; stdinIsTTY: boolean; stdoutIsTTY: boolean; }): string[] { const envFlag = params.exec.runtime === "docker" ? "-e" : "--env"; + const proxyUrl = normalizeOptionalString(params.env.OPENCLAW_PROXY_URL); + if (proxyUrl) { + assertContainerProxyUrlIsReachable(proxyUrl, params.env); + } + const proxyEnvArgs = proxyUrl ? [envFlag, `OPENCLAW_PROXY_URL=${proxyUrl}`] : []; const interactiveFlags = ["-i", ...(params.stdinIsTTY && params.stdoutIsTTY ? ["-t"] : [])]; return [ ...params.exec.argsPrefix, @@ -140,12 +149,70 @@ function buildContainerExecArgs(params: { `OPENCLAW_CONTAINER_HINT=${params.containerName}`, envFlag, "OPENCLAW_CLI_CONTAINER_BYPASS=1", + ...proxyEnvArgs, params.containerName, "openclaw", ...params.argv, ]; } +function assertContainerProxyUrlIsReachable(proxyUrl: string, env: NodeJS.ProcessEnv): void { + if (env[CONTAINER_ALLOW_LOOPBACK_PROXY_URL_ENV] === "1") { + return; + } + let parsed: URL; + try { + parsed = new URL(proxyUrl); + } catch { + return; + } + if (!isLoopbackProxyHostname(parsed.hostname)) { + return; + } + throw new Error( + `OPENCLAW_PROXY_URL=${redactProxyUrlForMessage(proxyUrl)} is loopback; 127.0.0.1 inside a container points at the container, not the host. ` + + `Use a container-reachable proxy address, or set ${CONTAINER_ALLOW_LOOPBACK_PROXY_URL_ENV}=1 if this is intentional.`, + ); +} + +function isLoopbackProxyHostname(hostname: string): boolean { + const normalizedHostname = hostname.toLowerCase().replace(/\.+$/, ""); + if (normalizedHostname === "localhost") { + return true; + } + if (isIP(normalizedHostname) === 4) { + return normalizedHostname.split(".", 1)[0] === "127"; + } + const ipv6Hostname = normalizedHostname.replace(/^\[|\]$/g, ""); + if (isIP(ipv6Hostname) !== 6) { + return false; + } + if (ipv6Hostname === "::1" || ipv6Hostname === "0:0:0:0:0:0:0:1") { + return true; + } + const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(ipv6Hostname); + if (!mapped) { + return false; + } + const high = Number.parseInt(mapped[1], 16); + return Number.isInteger(high) && high >= 0x7f00 && high <= 0x7fff; +} + +function redactProxyUrlForMessage(raw: string): string { + try { + const url = new URL(raw); + if (url.username || url.password) { + url.username = "redacted"; + url.password = url.password ? "redacted" : ""; + } + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return ""; + } +} + function buildContainerExecEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const next = { ...env }; // Container-targeted CLI invocations should use the container's own profile @@ -230,6 +297,7 @@ export function maybeRunCliInContainer( exec: runningContainer, containerName: runningContainer.containerName, argv: parsed.argv.slice(2), + env: resolvedDeps.env, stdinIsTTY: resolvedDeps.stdinIsTTY, stdoutIsTTY: resolvedDeps.stdoutIsTTY, }), diff --git a/src/cli/gateway-run-argv.ts b/src/cli/gateway-run-argv.ts new file mode 100644 index 00000000000..5c915283a95 --- /dev/null +++ b/src/cli/gateway-run-argv.ts @@ -0,0 +1,104 @@ +import { isValueToken } from "../infra/cli-root-options.js"; + +const GATEWAY_RUN_VALUE_FLAGS = new Set([ + "--port", + "--bind", + "--token", + "--auth", + "--password", + "--password-file", + "--tailscale", + "--ws-log", + "--raw-stream-path", +]); + +const GATEWAY_RUN_BOOLEAN_FLAGS = new Set([ + "--tailscale-reset-on-exit", + "--allow-unconfigured", + "--dev", + "--reset", + "--force", + "--verbose", + "--cli-backend-logs", + "--claude-cli-logs", + "--compact", + "--raw-stream", +]); + +export function consumeGatewayRunOptionToken(args: ReadonlyArray, index: number): number { + const arg = args[index]; + if (!arg || arg === "--" || !arg.startsWith("-")) { + return 0; + } + const equalsIndex = arg.indexOf("="); + const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex); + if (GATEWAY_RUN_BOOLEAN_FLAGS.has(flag)) { + return equalsIndex === -1 ? 1 : 0; + } + if (!GATEWAY_RUN_VALUE_FLAGS.has(flag)) { + return 0; + } + if (equalsIndex !== -1) { + return arg.slice(equalsIndex + 1).trim() ? 1 : 0; + } + return isValueToken(args[index + 1]) ? 2 : 0; +} + +export function consumeGatewayFastPathRootOptionToken( + args: ReadonlyArray, + index: number, +): number { + const arg = args[index]; + if (!arg || arg === "--") { + return 0; + } + if (arg === "--no-color") { + return 1; + } + if (arg.startsWith("--profile=")) { + return arg.slice("--profile=".length).trim() ? 1 : 0; + } + if (arg === "--profile") { + return isValueToken(args[index + 1]) ? 2 : 0; + } + return 0; +} + +export function resolveGatewayCatalogCommandPath(argv: string[]): string[] | null { + const args = argv.slice(2); + let sawGateway = false; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg || arg === "--") { + break; + } + if (!sawGateway) { + const consumed = consumeGatewayFastPathRootOptionToken(args, index); + if (consumed > 0) { + index += consumed - 1; + continue; + } + if (arg.startsWith("-")) { + continue; + } + if (arg !== "gateway") { + return null; + } + sawGateway = true; + continue; + } + + const consumed = consumeGatewayRunOptionToken(args, index); + if (consumed > 0) { + index += consumed - 1; + continue; + } + if (arg.startsWith("-")) { + continue; + } + return ["gateway", arg]; + } + + return sawGateway ? ["gateway"] : null; +} diff --git a/src/cli/run-main-policy.ts b/src/cli/run-main-policy.ts index 513f5e94eb7..9e3ece97bb2 100644 --- a/src/cli/run-main-policy.ts +++ b/src/cli/run-main-policy.ts @@ -9,7 +9,10 @@ import { normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; import { resolveCliArgvInvocation } from "./argv-invocation.js"; -import { resolveCliCommandPathPolicy } from "./command-path-policy.js"; +import { + resolveCliCommandPathPolicy, + resolveCliNetworkProxyPolicy, +} from "./command-path-policy.js"; export function rewriteUpdateFlagArgv(argv: string[]): string[] { const index = argv.indexOf("--update"); @@ -73,6 +76,16 @@ export function shouldStartCrestodianForModernOnboard(argv: string[]): boolean { ); } +export function shouldStartProxyForCli(argv: string[]): boolean { + const policyArgv = rewriteUpdateFlagArgv(argv); + const invocation = resolveCliArgvInvocation(policyArgv); + const [primary] = invocation.commandPath; + if (invocation.hasHelpOrVersion || !primary) { + return false; + } + return resolveCliNetworkProxyPolicy(policyArgv) === "default"; +} + export function resolveMissingPluginCommandMessage( pluginId: string, config?: OpenClawConfig, diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 354e0a843aa..696627f9305 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -1,7 +1,7 @@ import process from "node:process"; import { CommanderError } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { runCli } from "./run-main.js"; +import { runCli, shouldStartProxyForCli } from "./run-main.js"; const tryRouteCliMock = vi.hoisted(() => vi.fn()); const loadDotEnvMock = vi.hoisted(() => vi.fn()); @@ -32,6 +32,11 @@ const createCliProgressMock = vi.hoisted(() => done: progressDoneMock, })), ); +const loadConfigMock = vi.hoisted(() => vi.fn(() => ({}))); +const startProxyMock = vi.hoisted(() => + vi.fn<(config: unknown) => Promise>(async () => null), +); +const stopProxyMock = vi.hoisted(() => vi.fn<(handle: unknown) => Promise>(async () => {})); const maybeRunCliInContainerMock = vi.hoisted(() => vi.fn< (argv: string[]) => { handled: true; exitCode: number } | { handled: false; argv: string[] } @@ -166,6 +171,37 @@ vi.mock("./progress.js", () => ({ createCliProgress: createCliProgressMock, })); +vi.mock("../config/io.js", () => ({ + getRuntimeConfig: loadConfigMock, +})); + +vi.mock("../infra/net/proxy/proxy-lifecycle.js", () => ({ + startProxy: startProxyMock, + stopProxy: stopProxyMock, +})); + +function makeProxyHandle() { + return { + proxyUrl: "http://127.0.0.1:19876", + injectedProxyUrl: "http://127.0.0.1:19876", + envSnapshot: { + http_proxy: undefined, + https_proxy: undefined, + HTTP_PROXY: undefined, + HTTPS_PROXY: undefined, + GLOBAL_AGENT_HTTP_PROXY: undefined, + GLOBAL_AGENT_HTTPS_PROXY: undefined, + GLOBAL_AGENT_FORCE_GLOBAL_AGENT: undefined, + no_proxy: undefined, + NO_PROXY: undefined, + GLOBAL_AGENT_NO_PROXY: undefined, + OPENCLAW_PROXY_ACTIVE: undefined, + }, + stop: vi.fn(async () => {}), + kill: vi.fn(), + }; +} + describe("runCli exit behavior", () => { beforeEach(() => { vi.clearAllMocks(); @@ -173,6 +209,9 @@ describe("runCli exit behavior", () => { outputPrecomputedBrowserHelpTextMock.mockReturnValue(false); outputPrecomputedRootHelpTextMock.mockReturnValue(false); hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(false); + loadConfigMock.mockReturnValue({}); + startProxyMock.mockResolvedValue(null); + stopProxyMock.mockResolvedValue(undefined); getProgramContextMock.mockReturnValue(null); delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH; delete process.env.OPENCLAW_HIDE_BANNER; @@ -272,6 +311,185 @@ describe("runCli exit behavior", () => { exitSpy.mockRestore(); }); + it("does not start the managed proxy for local gateway client commands", async () => { + tryRouteCliMock.mockResolvedValueOnce(true); + + await runCli(["node", "openclaw", "status"]); + + expect(startProxyMock).not.toHaveBeenCalled(); + expect(stopProxyMock).not.toHaveBeenCalled(); + }); + + it.each([ + ["gateway runtime", ["node", "openclaw", "gateway", "run"]], + ["bare gateway runtime", ["node", "openclaw", "gateway"]], + ["node runtime", ["node", "openclaw", "node", "run"]], + ["local agent runtime", ["node", "openclaw", "agent", "--local"]], + ["provider inference", ["node", "openclaw", "infer", "web", "fetch", "https://example.com"]], + ["model command", ["node", "openclaw", "models", "auth", "login", "openai"]], + ["plugin command", ["node", "openclaw", "plugins", "marketplace", "list"]], + ["skill command", ["node", "openclaw", "skills", "search", "browser"]], + ["update command", ["node", "openclaw", "update", "check"]], + ["channel probe", ["node", "openclaw", "channels", "status", "--probe"]], + ["channel capabilities probe", ["node", "openclaw", "channels", "capabilities"]], + ["directory plugin command", ["node", "openclaw", "directory", "peers", "list"]], + ["message plugin command", ["node", "openclaw", "message", "send", "--to", "demo"]], + ["unknown plugin command", ["node", "openclaw", "googlemeet", "login"]], + ])("starts managed proxy routing for %s", (_name, argv) => { + expect(shouldStartProxyForCli(argv)).toBe(true); + }); + + it.each([ + ["root help", ["node", "openclaw", "--help"]], + ["root version", ["node", "openclaw", "--version"]], + ["gateway help", ["node", "openclaw", "gateway", "--help"]], + ["gateway run help", ["node", "openclaw", "gateway", "run", "--help"]], + ["status", ["node", "openclaw", "status"]], + ["health", ["node", "openclaw", "health"]], + ["gateway status", ["node", "openclaw", "gateway", "status"]], + ["gateway health", ["node", "openclaw", "gateway", "health"]], + ["remote agent control-plane", ["node", "openclaw", "agent", "run"]], + ["chat control-plane", ["node", "openclaw", "chat"]], + ["terminal control-plane", ["node", "openclaw", "terminal"]], + ["config", ["node", "openclaw", "config", "get", "proxy.enabled"]], + ["completion", ["node", "openclaw", "completion", "zsh"]], + ["debug proxy cli", ["node", "openclaw", "proxy", "start"]], + ["agents list", ["node", "openclaw", "agents", "list"]], + ["models list", ["node", "openclaw", "models", "list"]], + ["models status without live probe", ["node", "openclaw", "models", "status"]], + ["tasks list", ["node", "openclaw", "tasks", "list"]], + ["migrate", ["node", "openclaw", "migrate"]], + ])("skips managed proxy routing for %s", (_name, argv) => { + expect(shouldStartProxyForCli(argv)).toBe(false); + }); + + it("starts the managed proxy for network-capable commands by default", async () => { + tryRouteCliMock.mockResolvedValueOnce(true); + + await runCli(["node", "openclaw", "plugins", "marketplace", "list"]); + + expect(startProxyMock).toHaveBeenCalledWith(undefined); + }); + + it("starts the managed proxy for unknown plugin commands by default", async () => { + tryRouteCliMock.mockResolvedValueOnce(true); + + await runCli(["node", "openclaw", "googlemeet", "login"]); + + expect(startProxyMock).toHaveBeenCalledWith(undefined); + }); + + it("fails protected commands when managed proxy activation fails", async () => { + startProxyMock.mockRejectedValueOnce(new Error("proxy: enabled but no HTTP proxy URL")); + + await expect(runCli(["node", "openclaw", "gateway", "run"])).rejects.toThrow( + "proxy: enabled but no HTTP proxy URL", + ); + + expect(tryRouteCliMock).not.toHaveBeenCalled(); + expect(stopProxyMock).not.toHaveBeenCalled(); + }); + + it("fails protected commands when config cannot be loaded for managed proxy startup", async () => { + loadConfigMock.mockImplementationOnce(() => { + throw new Error("config parse failed"); + }); + + await expect(runCli(["node", "openclaw", "gateway", "run"])).rejects.toThrow( + "config parse failed", + ); + + expect(startProxyMock).not.toHaveBeenCalled(); + expect(tryRouteCliMock).not.toHaveBeenCalled(); + }); + + it("stops the managed proxy after normal gateway runtime completion", async () => { + const handle = makeProxyHandle(); + startProxyMock.mockResolvedValueOnce(handle); + + await runCli(["node", "openclaw", "gateway", "run"]); + + expect(startProxyMock).toHaveBeenCalledWith(undefined); + expect(stopProxyMock).toHaveBeenCalledOnce(); + expect(stopProxyMock).toHaveBeenCalledWith(handle); + }); + + it("stops the managed proxy and exits after SIGINT", async () => { + const handle = makeProxyHandle(); + startProxyMock.mockResolvedValueOnce(handle); + let resolveRoute: (value: boolean) => void = () => {}; + tryRouteCliMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveRoute = resolve; + }), + ); + + const processOnceSpy = vi.spyOn(process, "once"); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number | string) => { + void code; + return undefined as never; + }) as typeof process.exit); + + try { + const runPromise = runCli(["node", "openclaw", "plugins", "marketplace", "list"]); + await vi.waitFor(() => { + expect(processOnceSpy).toHaveBeenCalledWith("SIGINT", expect.any(Function)); + }); + + const sigintHandler = processOnceSpy.mock.calls.find(([event]) => event === "SIGINT")?.[1]; + if (typeof sigintHandler !== "function") { + throw new Error("SIGINT handler was not registered"); + } + sigintHandler(); + + await vi.waitFor(() => { + expect(stopProxyMock).toHaveBeenCalledWith(handle); + }); + await vi.waitFor(() => { + expect(exitSpy).toHaveBeenCalledWith(130); + }); + + resolveRoute(true); + await runPromise; + expect(stopProxyMock).toHaveBeenCalledTimes(1); + } finally { + exitSpy.mockRestore(); + processOnceSpy.mockRestore(); + } + }); + + it("synchronously kills the managed proxy during hard process exit", async () => { + const handle = makeProxyHandle(); + startProxyMock.mockResolvedValueOnce(handle); + let resolveRoute: (value: boolean) => void = () => {}; + tryRouteCliMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveRoute = resolve; + }), + ); + + const processOnceSpy = vi.spyOn(process, "once"); + try { + const runPromise = runCli(["node", "openclaw", "plugins", "marketplace", "list"]); + await vi.waitFor(() => { + expect(processOnceSpy.mock.calls.filter(([event]) => event === "exit")).toHaveLength(2); + }); + + const exitHandler = processOnceSpy.mock.calls.find(([event]) => event === "exit")?.[1]; + if (typeof exitHandler !== "function") { + throw new Error("exit handler was not registered"); + } + exitHandler(0 as never); + + expect(handle.kill).toHaveBeenCalledWith("SIGTERM"); + resolveRoute(true); + await runPromise; + expect(stopProxyMock).not.toHaveBeenCalledWith(handle); + } finally { + processOnceSpy.mockRestore(); + } + }); + it("bootstraps env proxy before bare Crestodian startup", async () => { hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true); const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index a231b0d8e91..5bf21fd5b62 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -6,6 +6,7 @@ import { shouldEnsureCliPath, shouldStartCrestodianForBareRoot, shouldStartCrestodianForModernOnboard, + shouldStartProxyForCli, shouldUseBrowserHelpFastPath, shouldUseRootHelpFastPath, } from "./run-main-policy.js"; @@ -143,6 +144,13 @@ describe("shouldStartCrestodianForModernOnboard", () => { }); }); +describe("shouldStartProxyForCli", () => { + it("starts managed proxy routing for the --update shorthand", () => { + expect(shouldStartProxyForCli(["node", "openclaw", "--update"])).toBe(true); + expect(shouldStartProxyForCli(["node", "openclaw", "--profile", "p", "--update"])).toBe(true); + }); +}); + describe("shouldUseRootHelpFastPath", () => { it("uses the fast path for root help only", () => { expect(shouldUseRootHelpFastPath(["node", "openclaw", "--help"])).toBe(true); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 88203b46c4b..39279462125 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -4,9 +4,9 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { isValueToken } from "../infra/cli-root-options.js"; import { isTruthyEnvValue, normalizeEnv } from "../infra/env.js"; import { isMainModule } from "../infra/is-main.js"; +import type { ProxyHandle } from "../infra/net/proxy/proxy-lifecycle.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { assertSupportedRuntime } from "../infra/runtime-guard.js"; import type { PluginManifestCommandAliasRegistry } from "../plugins/manifest-command-aliases.js"; @@ -17,6 +17,10 @@ import { shouldSkipPluginCommandRegistration, } from "./command-registration-policy.js"; import { maybeRunCliInContainer, parseCliContainerArgs } from "./container-target.js"; +import { + consumeGatewayFastPathRootOptionToken, + consumeGatewayRunOptionToken, +} from "./gateway-run-argv.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js"; import { resolveMissingPluginCommandMessage as resolveMissingPluginCommandMessageFromPolicy, @@ -24,6 +28,7 @@ import { shouldEnsureCliPath, shouldStartCrestodianForBareRoot, shouldStartCrestodianForModernOnboard, + shouldStartProxyForCli, shouldUseBrowserHelpFastPath, shouldUseRootHelpFastPath, } from "./run-main-policy.js"; @@ -34,37 +39,13 @@ export { shouldEnsureCliPath, shouldStartCrestodianForBareRoot, shouldStartCrestodianForModernOnboard, + shouldStartProxyForCli, shouldUseBrowserHelpFastPath, shouldUseRootHelpFastPath, } from "./run-main-policy.js"; type Awaitable = T | Promise; -const GATEWAY_RUN_VALUE_FLAGS = new Set([ - "--port", - "--bind", - "--token", - "--auth", - "--password", - "--password-file", - "--tailscale", - "--ws-log", - "--raw-stream-path", -]); - -const GATEWAY_RUN_BOOLEAN_FLAGS = new Set([ - "--tailscale-reset-on-exit", - "--allow-unconfigured", - "--dev", - "--reset", - "--force", - "--verbose", - "--cli-backend-logs", - "--claude-cli-logs", - "--compact", - "--raw-stream", -]); - const CLI_PROXY_ENV_KEYS = [ "HTTP_PROXY", "HTTPS_PROXY", @@ -268,42 +249,6 @@ async function ensureCliEnvProxyDispatcher(): Promise { } } -function consumeGatewayRunOptionToken(args: ReadonlyArray, index: number): number { - const arg = args[index]; - if (!arg || arg === "--" || !arg.startsWith("-")) { - return 0; - } - const equalsIndex = arg.indexOf("="); - const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex); - if (GATEWAY_RUN_BOOLEAN_FLAGS.has(flag)) { - return equalsIndex === -1 ? 1 : 0; - } - if (!GATEWAY_RUN_VALUE_FLAGS.has(flag)) { - return 0; - } - if (equalsIndex !== -1) { - return arg.slice(equalsIndex + 1).trim() ? 1 : 0; - } - return isValueToken(args[index + 1]) ? 2 : 0; -} - -function consumeGatewayFastPathRootOptionToken(args: ReadonlyArray, index: number): number { - const arg = args[index]; - if (!arg || arg === "--") { - return 0; - } - if (arg === "--no-color") { - return 1; - } - if (arg.startsWith("--profile=")) { - return arg.slice("--profile=".length).trim() ? 1 : 0; - } - if (arg === "--profile") { - return isValueToken(args[index + 1]) ? 2 : 0; - } - return 0; -} - function shouldBootstrapCliProxyBeforeFastPath(env: NodeJS.ProcessEnv = process.env): boolean { if ( isTruthyEnvValue(env.OPENCLAW_DEBUG_PROXY_ENABLED) || @@ -378,6 +323,55 @@ export async function runCli(argv: string[] = process.argv) { // Enforce the minimum supported runtime before doing any work. assertSupportedRuntime(); + // Activate operator-managed proxy routing for network-capable commands. + // Local Gateway/control-plane commands keep direct loopback access while + // runtime, provider, plugin, update, and unknown plugin commands route egress. + let proxyHandle: ProxyHandle | null = null; + const stopStartedProxy = async () => { + const handle = proxyHandle; + proxyHandle = null; + if (handle) { + const { stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js"); + await stopProxy(handle); + } + }; + const killStartedProxy = () => { + const handle = proxyHandle; + proxyHandle = null; + handle?.kill("SIGTERM"); + }; + if (shouldStartProxyForCli(normalizedArgv)) { + const [{ getRuntimeConfig }, { startProxy }] = await Promise.all([ + import("../config/io.js"), + import("../infra/net/proxy/proxy-lifecycle.js"), + ]); + const config = getRuntimeConfig(); + proxyHandle = await startProxy(config?.proxy ?? undefined); + } + + let onSigterm: (() => void) | null = null; + let onSigint: (() => void) | null = null; + let onExit: (() => void) | null = null; + if (proxyHandle) { + const shutdown = (exitCode: number) => { + if (onSigterm) { + process.off("SIGTERM", onSigterm); + } + if (onSigint) { + process.off("SIGINT", onSigint); + } + void stopStartedProxy().finally(() => { + process.exit(exitCode); + }); + }; + onSigterm = () => shutdown(143); + onSigint = () => shutdown(130); + onExit = () => killStartedProxy(); + process.once("SIGTERM", onSigterm); + process.once("SIGINT", onSigint); + process.once("exit", onExit); + } + try { if (shouldUseRootHelpFastPath(normalizedArgv)) { const { outputPrecomputedRootHelpText } = await import("./root-help-metadata.js"); @@ -606,6 +600,16 @@ export async function runCli(argv: string[] = process.argv) { stopStartupProgress(); } } finally { + if (onSigterm) { + process.off("SIGTERM", onSigterm); + } + if (onSigint) { + process.off("SIGINT", onSigint); + } + if (onExit) { + process.off("exit", onExit); + } + await stopStartedProxy(); await closeCliMemoryManagers(); } } diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index f41e6b26a58..ac4f8cfa078 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -23819,6 +23819,19 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { additionalProperties: false, }, }, + proxy: { + type: "object", + properties: { + enabled: { + type: "boolean", + }, + proxyUrl: { + type: "string", + format: "uri", + }, + }, + additionalProperties: false, + }, }, required: ["commands"], additionalProperties: false, @@ -28734,6 +28747,10 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { sensitive: true, tags: ["security", "auth"], }, + "proxy.proxyUrl": { + sensitive: true, + tags: ["security"], + }, "models.providers.*.models[].baseUrl": { tags: ["models", "url-secret"], }, diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index 2401bc6e34e..25fc4ff7572 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -170,6 +170,7 @@ describe("mapSensitivePaths", () => { expect(hints["models.providers.*.headers.*"]?.sensitive).toBe(true); 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["skills.entries.*.apiKey"]?.sensitive).toBe(true); }); diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index d167e4d1c90..756bc7095fb 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -33,6 +33,7 @@ import type { PluginsConfig } from "./types.plugins.js"; import type { SecretsConfig } from "./types.secrets.js"; import type { SkillsConfig } from "./types.skills.js"; import type { ToolsConfig } from "./types.tools.js"; +import type { ProxyConfig } from "./zod-schema.proxy.js"; export type SurfaceConfigEntry = { silentReply?: SilentReplyPolicyShape; @@ -135,6 +136,8 @@ export type OpenClawConfig = { gateway?: GatewayConfig; memory?: MemoryConfig; mcp?: McpConfig; + /** Network-level SSRF protection via an operator-managed forward proxy. */ + proxy?: ProxyConfig; }; declare const openClawConfigStateBrand: unique symbol; diff --git a/src/config/zod-schema.proxy.test.ts b/src/config/zod-schema.proxy.test.ts new file mode 100644 index 00000000000..93ab96eafed --- /dev/null +++ b/src/config/zod-schema.proxy.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { ProxyConfigSchema } from "./zod-schema.proxy.js"; + +describe("ProxyConfigSchema", () => { + it("accepts undefined (optional)", () => { + expect(ProxyConfigSchema.parse(undefined)).toBeUndefined(); + }); + + it("accepts an empty object", () => { + expect(ProxyConfigSchema.parse({})).toEqual({}); + }); + + it("accepts a full valid config", () => { + const result = ProxyConfigSchema.parse({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + expect(result).toMatchObject({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + }); + + 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("does not expose bundled-proxy or unsupported upstream proxy keys", () => { + const keys = ProxyConfigSchema.unwrap().keyof().options; + expect(keys).not.toContain("binaryPath"); + expect(keys).not.toContain("extraBlockedCidrs"); + expect(keys).not.toContain("extraAllowedHosts"); + expect(keys).not.toContain("userProxy"); + }); + + it("rejects proxyUrl values that are not HTTP forward proxies", () => { + expect(() => + ProxyConfigSchema.parse({ enabled: true, proxyUrl: "socks5://127.0.0.1" }), + ).toThrow(); + expect(() => ProxyConfigSchema.parse({ enabled: true, proxyUrl: "not-a-url" })).toThrow(); + }); + + it("rejects unknown keys (strict)", () => { + expect(() => ProxyConfigSchema.parse({ unknownKey: true })).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 new file mode 100644 index 00000000000..3e4bb0ddb6f --- /dev/null +++ b/src/config/zod-schema.proxy.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { sensitive } from "./zod-schema.sensitive.js"; + +function isHttpProxyUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:"; + } catch { + return false; + } +} + +export const ProxyConfigSchema = z + .object({ + enabled: z.boolean().optional(), + proxyUrl: z + .string() + .url() + .refine(isHttpProxyUrl, { + message: "proxyUrl must use http://", + }) + .register(sensitive) + .optional(), + }) + .strict() + .optional(); + +export type ProxyConfig = z.infer; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 1c4801a3e0e..08ee6fb6d64 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -20,6 +20,7 @@ import { } from "./zod-schema.core.js"; import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js"; import { ChannelsSchema } from "./zod-schema.providers.js"; +import { ProxyConfigSchema } from "./zod-schema.proxy.js"; import { sensitive } from "./zod-schema.sensitive.js"; import { CommandsSchema, @@ -1020,6 +1021,7 @@ export const OpenClawSchema = z .strict(), ) .optional(), + proxy: ProxyConfigSchema, }) .strict() .superRefine((cfg, ctx) => { diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 67f2ef28d2e..a8cb98c798b 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -575,6 +575,18 @@ describe("buildServiceEnvironment", () => { expect(env.all_proxy).toBeUndefined(); }); + it("forwards proxy URL env fallback for installed gateway services", () => { + const env = buildServiceEnvironment({ + env: { + HOME: "/home/user", + OPENCLAW_PROXY_URL: " http://127.0.0.1:3128 ", + }, + port: 18789, + }); + + expect(env.OPENCLAW_PROXY_URL).toBe("http://127.0.0.1:3128"); + }); + it("omits PATH on Windows so Scheduled Tasks can inherit the current shell path", () => { const env = buildServiceEnvironment({ env: { @@ -648,6 +660,17 @@ describe("buildNodeServiceEnvironment", () => { expect(env.no_proxy).toBeUndefined(); }); + it("forwards proxy URL env fallback for installed node services", () => { + const env = buildNodeServiceEnvironment({ + env: { + HOME: "/home/user", + OPENCLAW_PROXY_URL: " http://127.0.0.1:3128 ", + }, + }); + + expect(env.OPENCLAW_PROXY_URL).toBe("http://127.0.0.1:3128"); + }); + it("forwards TMPDIR for node services on Linux", () => { const env = buildNodeServiceEnvironment({ env: { HOME: "/home/user", TMPDIR: "/tmp/custom" }, diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 0336be0024b..1dcbe9005eb 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -42,11 +42,13 @@ type SharedServiceEnvironmentFields = { configPath: string | undefined; tmpDir: string; minimalPath: string | undefined; + proxyEnv: Record; nodeCaCerts: string | undefined; nodeUseSystemCa: string | undefined; }; export const SERVICE_PROXY_ENV_KEYS = [ + "OPENCLAW_PROXY_URL", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", @@ -57,6 +59,13 @@ export const SERVICE_PROXY_ENV_KEYS = [ "all_proxy", ] as const; +function readServiceProxyEnvironment( + env: Record, +): Record { + const proxyUrl = normalizeOptionalString(env.OPENCLAW_PROXY_URL); + return proxyUrl ? { OPENCLAW_PROXY_URL: proxyUrl } : {}; +} + function addNonEmptyDir(dirs: string[], dir: string | undefined): void { if (dir) { dirs.push(dir); @@ -355,6 +364,7 @@ function buildCommonServiceEnvironment( NODE_USE_SYSTEM_CA: sharedEnv.nodeUseSystemCa, OPENCLAW_STATE_DIR: sharedEnv.stateDir, OPENCLAW_CONFIG_PATH: sharedEnv.configPath, + ...sharedEnv.proxyEnv, }; if (sharedEnv.minimalPath) { serviceEnv.PATH = sharedEnv.minimalPath; @@ -404,6 +414,7 @@ function resolveSharedServiceEnvironmentFields( platform === "win32" ? undefined : buildMinimalServicePath({ env, platform, extraDirs: extraPathDirs }), + proxyEnv: readServiceProxyEnvironment(env), nodeCaCerts: startupTlsEnv.NODE_EXTRA_CA_CERTS, nodeUseSystemCa: startupTlsEnv.NODE_USE_SYSTEM_CA, }; diff --git a/src/gateway/client.ts b/src/gateway/client.ts index d3d6ebb5c51..97ebc203968 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -1,4 +1,6 @@ import { randomUUID } from "node:crypto"; +import http from "node:http"; +import https from "node:https"; import { WebSocket, type ClientOptions, type CertMeta } from "ws"; import { clearDeviceAuthToken, @@ -11,9 +13,11 @@ import { publicKeyRawBase64UrlFromPem, signDevicePayload, } from "../infra/device-identity.js"; +import { dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane } from "../infra/net/proxy/proxy-lifecycle.js"; import { normalizeFingerprint } from "../infra/tls/fingerprint.js"; import { rawDataToString } from "../infra/ws.js"; import { logDebug, logError } from "../logger.js"; +import { isLoopbackIpAddress } from "../shared/net/ip.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -89,6 +93,19 @@ export type GatewayReconnectPausedInfo = { detailCode: string | null; }; +function createDirectGatewayAgent(url: string): http.Agent | https.Agent | undefined { + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + return undefined; + } + if (!isLoopbackIpAddress(hostname)) { + return undefined; + } + return url.startsWith("wss://") ? new https.Agent() : new http.Agent(); +} + export class GatewayClientRequestError extends Error { readonly gatewayCode: string; readonly details?: unknown; @@ -263,8 +280,10 @@ export class GatewayClient { return; } // Allow node screen snapshots and other large responses. + const directAgent = createDirectGatewayAgent(url); const wsOptions: FingerprintCheckingClientOptions = { maxPayload: 25 * 1024 * 1024, + ...(directAgent ? { agent: directAgent } : {}), }; if (url.startsWith("wss://") && this.opts.tlsFingerprint) { wsOptions.rejectUnauthorized = false; @@ -289,7 +308,10 @@ export class GatewayClient { return undefined; }; } - const ws = new WebSocket(url, wsOptions as ClientOptions); + const createWebSocket = () => new WebSocket(url, wsOptions as ClientOptions); + const ws = directAgent + ? dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(url, createWebSocket) + : createWebSocket(); this.ws = ws; this.socketOpened = false; this.connectNonce = null; diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 6a6ab98b498..dad49cf23de 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -86,6 +86,44 @@ describe("GatewayClient", () => { expect(last?.opts).toEqual(expect.objectContaining({ maxPayload: 25 * 1024 * 1024 })); }); + test("uses an explicit direct agent for control-plane WebSocket connections", () => { + const client = new GatewayClient({ url: "ws://127.0.0.1:1" }); + client.start(); + const last = wsMockState.last as { opts: { agent?: unknown } } | null; + + expect(last?.opts.agent).toBeDefined(); + expect(last?.opts.agent).not.toBe( + (global as unknown as { GLOBAL_AGENT?: { HTTP_PROXY?: unknown } }).GLOBAL_AGENT, + ); + }); + + test("uses an explicit direct agent for IPv6 loopback control-plane WebSocket connections", () => { + const client = new GatewayClient({ url: "ws://[::1]:1" }); + client.start(); + const last = wsMockState.last as { opts: { agent?: unknown } } | null; + + expect(last?.opts.agent).toBeDefined(); + }); + + test("does not use the direct control-plane bypass for localhost hostnames", () => { + const client = new GatewayClient({ url: "ws://localhost:1" }); + client.start(); + const last = wsMockState.last as { opts: { agent?: unknown } } | null; + + expect(last?.opts.agent).toBeUndefined(); + }); + + test("does not force a direct agent for remote Gateway WebSocket connections", () => { + const client = new GatewayClient({ + url: "wss://gateway.example.com", + tlsFingerprint: "SHA256:AA:BB", + }); + client.start(); + const last = wsMockState.last as { opts: { agent?: unknown } } | null; + + expect(last?.opts.agent).toBeUndefined(); + }); + it("returns 404 for missing static asset paths instead of SPA fallback", async () => { await withControlUiRoot({ faviconSvg: "" }, async (tmp) => { const { res } = makeControlUiResponse(); diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 0b0d3c5c8d1..eeec7181248 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -1011,6 +1011,15 @@ describe("fetchWithSsrFGuard hardening", () => { }); }); + it("uses the env proxy in strict mode when the SSRF proxy lifecycle is active", async () => { + vi.stubEnv("OPENCLAW_PROXY_ACTIVE", "1"); + + await runProxyModeDispatcherTest({ + mode: GUARDED_FETCH_MODE.STRICT, + expectEnvProxy: true, + }); + }); + it("routes through env proxy when trusted proxy mode is explicitly enabled", async () => { await runProxyModeDispatcherTest({ mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY, diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index 170ec72aaab..831a2639c35 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -2,7 +2,7 @@ import type { Dispatcher } from "undici"; import { logWarn } from "../../logger.js"; import { captureHttpExchange } from "../../proxy-capture/runtime.js"; import { buildTimeoutAbortSignal } from "../../utils/fetch-timeout.js"; -import { shouldUseEnvHttpProxyForUrl } from "./proxy-env.js"; +import { hasProxyEnvConfigured, shouldUseEnvHttpProxyForUrl } from "./proxy-env.js"; import { retainSafeHeadersForCrossOriginRedirect as retainSafeRedirectHeaders } from "./redirect-headers.js"; import { fetchWithRuntimeDispatcher, @@ -120,6 +120,10 @@ function resolveGuardedFetchMode(params: GuardedFetchOptions): GuardedFetchMode return GUARDED_FETCH_MODE.STRICT; } +function isManagedProxyActive(): boolean { + return process.env["OPENCLAW_PROXY_ACTIVE"] === "1"; +} + function assertExplicitProxySupportsPinnedDns( url: URL, dispatcherPolicy?: PinnedDispatcherPolicy, @@ -357,9 +361,17 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("server did not bind to a TCP port")); + return; + } + resolve(address.port); + }); + }); +} + +async function closeServer(server: Server | null): Promise { + if (server === null || !server.listening) { + return; + } + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); +} + +function createTunnelProxy(seenConnectTargets: string[]): Server { + const proxy = createServer((req, res) => { + const target = req.url ?? ""; + seenConnectTargets.push(target); + + let targetUrl: URL; + try { + targetUrl = new URL(target); + } catch { + res.writeHead(400, { "content-type": "text/plain" }); + res.end("absolute-form proxy URL required"); + return; + } + + const upstream = httpRequest( + { + hostname: targetUrl.hostname, + port: targetUrl.port, + path: `${targetUrl.pathname}${targetUrl.search}`, + method: req.method, + headers: { ...req.headers, host: targetUrl.host, connection: "close" }, + }, + (upstreamRes) => { + res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers); + upstreamRes.pipe(res); + }, + ); + + upstream.on("error", () => { + res.writeHead(502, { "content-type": "text/plain" }); + res.end("upstream error"); + }); + req.pipe(upstream); + }); + + proxy.on("connect", (req, clientSocket, head) => { + const target = req.url ?? ""; + seenConnectTargets.push(target); + + let targetUrl: URL; + try { + targetUrl = new URL(`http://${target}`); + } catch { + clientSocket.destroy(); + return; + } + + const upstream = net.connect(Number(targetUrl.port), targetUrl.hostname, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) { + upstream.write(head); + } + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + + upstream.on("error", () => { + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + }); + }); + + proxy.on("upgrade", (req, socket) => { + seenConnectTargets.push(req.url ?? ""); + socket.destroy(); + }); + + return proxy; +} + +async function runNodeModule( + source: string, + env: NodeJS.ProcessEnv, +): Promise<{ + code: number | null; + stdout: string; + stderr: string; +}> { + const child = spawn( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", source], + { + cwd: process.cwd(), + env, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`child process timed out\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + }, 10_000); + + child.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + child.on("close", (code) => { + clearTimeout(timeout); + resolve({ code, stdout, stderr }); + }); + }); +} + +describe("SSRF external proxy routing", () => { + let target: Server | null = null; + let httpsLikeTarget: Server | null = null; + let proxy: Server | null = null; + let wss: WebSocketServer | null = null; + + afterEach(async () => { + await new Promise((resolve) => { + if (!wss) { + resolve(); + return; + } + wss.close(() => resolve()); + }); + await closeServer(proxy); + await closeServer(httpsLikeTarget); + await closeServer(target); + wss = null; + proxy = null; + httpsLikeTarget = null; + target = null; + }); + + it("routes normal HTTP and WebSocket egress through an operator-managed proxy even when NO_PROXY includes loopback", async () => { + target = createServer((_req, res) => { + res.writeHead(218, { "content-type": "text/plain" }); + res.end("from loopback target"); + }); + wss = new WebSocketServer({ server: target }); + wss.on("connection", (ws) => { + ws.close(1000, "done"); + }); + const targetPort = await listenOnLoopback(target); + + httpsLikeTarget = createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("plain target for https CONNECT proof"); + }); + const httpsLikeTargetPort = await listenOnLoopback(httpsLikeTarget); + + const seenConnectTargets: string[] = []; + proxy = createTunnelProxy(seenConnectTargets); + const proxyPort = await listenOnLoopback(proxy); + + const child = await runNodeModule( + ` + import http from "node:http"; + import https from "node:https"; + import { fetch as undiciFetch } from "undici"; + import { WebSocket } from "ws"; + import { startProxy, stopProxy } from "./src/infra/net/proxy/proxy-lifecycle.ts"; + import { dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane } from "./src/infra/net/proxy/proxy-lifecycle.ts"; + + async function nodeHttpGet(url, options = {}) { + return new Promise((resolve, reject) => { + const req = http.get(url, options, (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + body += chunk; + }); + response.on("end", () => { + resolve({ status: response.statusCode, body }); + }); + }); + req.setTimeout(5000, () => { + req.destroy(new Error("node:http request timed out")); + }); + req.on("error", reject); + }); + } + + async function expectFailure(label, run) { + try { + await run(); + } catch { + return; + } + throw new Error(label + " unexpectedly succeeded"); + } + + async function nodeHttpsProbe(url) { + return new Promise((resolve, reject) => { + const req = https.get(url, { rejectUnauthorized: false }, (response) => { + response.resume(); + response.on("end", resolve); + }); + req.setTimeout(5000, () => { + req.destroy(new Error("node:https request timed out")); + }); + req.on("error", reject); + }); + } + + async function websocketProbe(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, { handshakeTimeout: 5000 }); + ws.once("open", () => { + ws.close(); + reject(new Error("proxied websocket unexpectedly opened")); + }); + ws.once("error", () => resolve()); + }); + } + + async function gatewayLoopbackBypassProbe(url) { + return new Promise((resolve, reject) => { + const ws = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(url, () => + new WebSocket(url, { handshakeTimeout: 5000 }), + ); + ws.once("open", () => { + ws.close(); + resolve(); + }); + ws.once("error", reject); + }); + } + + const handle = await startProxy({ enabled: true }); + if (handle === null) { + throw new Error("expected external proxy routing to start"); + } + try { + const response = await undiciFetch(process.env.OPENCLAW_TEST_TARGET_URL, { + signal: AbortSignal.timeout(5000), + }); + const body = await response.text(); + const nodeHttp = await nodeHttpGet(process.env.OPENCLAW_TEST_NODE_HTTP_TARGET_URL); + const explicitAgent = await nodeHttpGet(process.env.OPENCLAW_TEST_EXPLICIT_AGENT_TARGET_URL, { + agent: new http.Agent(), + }); + await expectFailure("node:https", () => + nodeHttpsProbe(process.env.OPENCLAW_TEST_NODE_HTTPS_TARGET_URL), + ); + await websocketProbe(process.env.OPENCLAW_TEST_WS_TARGET_URL); + await gatewayLoopbackBypassProbe(process.env.OPENCLAW_TEST_GATEWAY_BYPASS_WS_URL); + await expectFailure("non-loopback bypass", () => + gatewayLoopbackBypassProbe("wss://gateway.example.com/socket"), + ); + console.log(JSON.stringify({ + fetch: { status: response.status, body }, + nodeHttp, + explicitAgent, + })); + } finally { + await stopProxy(handle); + } + `, + { + ...process.env, + OPENCLAW_PROXY_URL: `http://127.0.0.1:${proxyPort}`, + OPENCLAW_TEST_TARGET_URL: `http://127.0.0.1:${targetPort}/private-metadata`, + OPENCLAW_TEST_NODE_HTTP_TARGET_URL: `http://127.0.0.1:${targetPort}/node-http-metadata`, + OPENCLAW_TEST_EXPLICIT_AGENT_TARGET_URL: `http://127.0.0.1:${targetPort}/explicit-agent`, + OPENCLAW_TEST_NODE_HTTPS_TARGET_URL: `https://127.0.0.1:${httpsLikeTargetPort}/https-connect-proof`, + OPENCLAW_TEST_WS_TARGET_URL: `ws://127.0.0.1:${targetPort}/websocket-proxied`, + OPENCLAW_TEST_GATEWAY_BYPASS_WS_URL: `ws://127.0.0.1:${targetPort}/gateway-bypass`, + NO_PROXY: "127.0.0.1,localhost", + no_proxy: "localhost", + GLOBAL_AGENT_NO_PROXY: "localhost", + }, + ); + + expect(child.stderr).toBe(""); + expect(child.code).toBe(0); + expect(child.stdout).toContain('"fetch":{"status":218'); + expect(child.stdout).toContain('"nodeHttp":{"status":218'); + expect(child.stdout).toContain('"explicitAgent":{"status":218'); + expect(child.stdout).toContain('"body":"from loopback target"'); + expect(seenConnectTargets).toContain(`127.0.0.1:${targetPort}`); + expect(seenConnectTargets).toContain(`127.0.0.1:${httpsLikeTargetPort}`); + expect(seenConnectTargets).toContain(`http://127.0.0.1:${targetPort}/node-http-metadata`); + expect(seenConnectTargets).toContain(`http://127.0.0.1:${targetPort}/explicit-agent`); + expect(seenConnectTargets).toContain(`http://127.0.0.1:${targetPort}/websocket-proxied`); + expect(seenConnectTargets).not.toContain(`http://127.0.0.1:${targetPort}/gateway-bypass`); + }); +}); diff --git a/src/infra/net/proxy/index.ts b/src/infra/net/proxy/index.ts new file mode 100644 index 00000000000..05ca2220276 --- /dev/null +++ b/src/infra/net/proxy/index.ts @@ -0,0 +1,25 @@ +/** + * Network proxy module — public API surface. + * + * This module routes OpenClaw process HTTP and WebSocket traffic through an + * operator-managed filtering forward proxy. The proxy must enforce + * destination filtering at connect time; OpenClaw only owns process-wide + * routing into that proxy. + * + * Integration: + * 1. Call startProxy(config?.proxy) early in protected daemon/CLI startup. + * 2. Subsequent normal HTTP and WebSocket egress routes through the + * configured operator proxy. + * 3. On shutdown, call stopProxy(handle). + * + * Fail-closed behavior: + * If proxy.enabled=true but no valid proxy URL is configured, or activation + * fails, protected commands must fail startup instead of falling back to + * direct network access. + */ + +export { startProxy, stopProxy } from "./proxy-lifecycle.js"; +export type { ProxyHandle } from "./proxy-lifecycle.js"; + +export { ProxyConfigSchema } from "../../../config/zod-schema.proxy.js"; +export type { ProxyConfig } from "../../../config/zod-schema.proxy.js"; diff --git a/src/infra/net/proxy/proxy-lifecycle.test.ts b/src/infra/net/proxy/proxy-lifecycle.test.ts new file mode 100644 index 00000000000..d1e0093c0a5 --- /dev/null +++ b/src/infra/net/proxy/proxy-lifecycle.test.ts @@ -0,0 +1,425 @@ +import http from "node:http"; +import https from "node:https"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../undici-global-dispatcher.js", () => ({ + forceResetGlobalDispatcher: vi.fn(), +})); + +vi.mock("global-agent", () => ({ + bootstrap: vi.fn(), + createGlobalProxyAgent: vi.fn(), +})); + +vi.mock("../../../logger.js", () => ({ + logInfo: vi.fn(), + logWarn: vi.fn(), +})); + +import { bootstrap as bootstrapGlobalAgent } from "global-agent"; +import { logInfo, logWarn } from "../../../logger.js"; +import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js"; +import { + _resetGlobalAgentBootstrapForTests, + dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane, + startProxy, + stopProxy, +} from "./proxy-lifecycle.js"; + +const mockForceResetGlobalDispatcher = vi.mocked(forceResetGlobalDispatcher); +const mockBootstrapGlobalAgent = vi.mocked(bootstrapGlobalAgent); +const mockLogInfo = vi.mocked(logInfo); +const mockLogWarn = vi.mocked(logWarn); + +describe("startProxy", () => { + const savedEnv: Record = {}; + const envKeysToClean = [ + "http_proxy", + "https_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "no_proxy", + "NO_PROXY", + "GLOBAL_AGENT_HTTP_PROXY", + "GLOBAL_AGENT_HTTPS_PROXY", + "GLOBAL_AGENT_FORCE_GLOBAL_AGENT", + "GLOBAL_AGENT_NO_PROXY", + "OPENCLAW_PROXY_ACTIVE", + "OPENCLAW_PROXY_URL", + ]; + const originalHttpRequest = http.request; + const originalHttpGet = http.get; + const originalHttpGlobalAgent = http.globalAgent; + const originalHttpsRequest = https.request; + const originalHttpsGet = https.get; + const originalHttpsGlobalAgent = https.globalAgent; + + beforeEach(() => { + for (const key of envKeysToClean) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + mockForceResetGlobalDispatcher.mockReset(); + mockBootstrapGlobalAgent.mockReset(); + mockLogInfo.mockReset(); + mockLogWarn.mockReset(); + _resetGlobalAgentBootstrapForTests(); + (global as Record)["GLOBAL_AGENT"] = undefined; + http.request = originalHttpRequest; + http.get = originalHttpGet; + http.globalAgent = originalHttpGlobalAgent; + https.request = originalHttpsRequest; + https.get = originalHttpsGet; + https.globalAgent = originalHttpsGlobalAgent; + }); + + afterEach(() => { + for (const key of envKeysToClean) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + (global as Record)["GLOBAL_AGENT"] = undefined; + http.request = originalHttpRequest; + http.get = originalHttpGet; + http.globalAgent = originalHttpGlobalAgent; + https.request = originalHttpsRequest; + https.get = originalHttpsGet; + https.globalAgent = originalHttpsGlobalAgent; + }); + + it("returns null silently and does not touch env when not explicitly enabled", async () => { + const handle = await startProxy(undefined); + + expect(handle).toBeNull(); + expect(process.env["http_proxy"]).toBeUndefined(); + expect(process.env["GLOBAL_AGENT_HTTP_PROXY"]).toBeUndefined(); + expect(mockForceResetGlobalDispatcher).not.toHaveBeenCalled(); + expect(mockBootstrapGlobalAgent).not.toHaveBeenCalled(); + expect(mockLogInfo).not.toHaveBeenCalled(); + expect(mockLogWarn).not.toHaveBeenCalled(); + }); + + it("throws when enabled without a proxy URL", async () => { + await expect(startProxy({ enabled: true })).rejects.toThrow( + "proxy: enabled but no HTTP proxy URL is configured", + ); + + expect(process.env["http_proxy"]).toBeUndefined(); + expect(mockLogWarn).not.toHaveBeenCalled(); + }); + + it("uses OPENCLAW_PROXY_URL when config proxyUrl is omitted", async () => { + process.env["OPENCLAW_PROXY_URL"] = "http://127.0.0.1:3128"; + + const handle = await startProxy({ enabled: true }); + + expect(handle?.proxyUrl).toBe("http://127.0.0.1:3128"); + expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); + }); + + it("prefers config proxyUrl over OPENCLAW_PROXY_URL", async () => { + process.env["OPENCLAW_PROXY_URL"] = "http://127.0.0.1:3128"; + + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3129", + }); + + expect(handle?.proxyUrl).toBe("http://127.0.0.1:3129"); + expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3129"); + }); + + it("throws for 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"); + + expect(process.env["HTTP_PROXY"]).toBeUndefined(); + expect(mockLogWarn).not.toHaveBeenCalled(); + }); + + it("sets both undici and global-agent proxy env vars", async () => { + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + + expect(handle).not.toBeNull(); + expect(process.env["http_proxy"]).toBe("http://127.0.0.1:3128"); + expect(process.env["https_proxy"]).toBe("http://127.0.0.1:3128"); + expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); + expect(process.env["HTTPS_PROXY"]).toBe("http://127.0.0.1:3128"); + expect(process.env["GLOBAL_AGENT_HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); + expect(process.env["GLOBAL_AGENT_HTTPS_PROXY"]).toBe("http://127.0.0.1:3128"); + expect(process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"]).toBe("true"); + expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1"); + }); + + it("redacts proxy credentials before logging the active proxy URL", async () => { + await startProxy({ + enabled: true, + proxyUrl: "http://user:pass@127.0.0.1:3128", + }); + + expect(mockLogInfo).toHaveBeenCalledWith( + "proxy: routing process HTTP traffic through external proxy http://127.0.0.1:3128", + ); + expect(mockLogInfo).not.toHaveBeenCalledWith(expect.stringContaining("user:pass")); + }); + + it("clears NO_PROXY so internal destinations do not bypass the filtering proxy", async () => { + process.env["NO_PROXY"] = "127.0.0.1,localhost,corp.example.com"; + process.env["no_proxy"] = "localhost"; + process.env["GLOBAL_AGENT_NO_PROXY"] = "localhost"; + + await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + + expect(process.env["no_proxy"]).toBe(""); + expect(process.env["NO_PROXY"]).toBe(""); + expect(process.env["GLOBAL_AGENT_NO_PROXY"]).toBe(""); + }); + + it("activates undici and global-agent routing", async () => { + await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + + expect(mockForceResetGlobalDispatcher).toHaveBeenCalledOnce(); + expect(mockBootstrapGlobalAgent).toHaveBeenCalledOnce(); + }); + + it("restores previous proxy env and global-agent state on stop", async () => { + process.env["HTTP_PROXY"] = "http://previous.example.com:8080"; + process.env["NO_PROXY"] = "corp.example.com"; + process.env["GLOBAL_AGENT_HTTP_PROXY"] = "http://previous-global.example.com:8080"; + process.env["GLOBAL_AGENT_HTTPS_PROXY"] = "http://previous-global.example.com:8443"; + process.env["GLOBAL_AGENT_NO_PROXY"] = "global.corp.example.com"; + (global as Record)["GLOBAL_AGENT"] = { + HTTP_PROXY: "", + HTTPS_PROXY: "", + }; + + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + + expect(handle).not.toBeNull(); + expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); + expect(process.env["NO_PROXY"]).toBe(""); + mockForceResetGlobalDispatcher.mockClear(); + + await stopProxy(handle); + + expect(process.env["HTTP_PROXY"]).toBe("http://previous.example.com:8080"); + expect(process.env["NO_PROXY"]).toBe("corp.example.com"); + expect(process.env["GLOBAL_AGENT_HTTP_PROXY"]).toBe("http://previous-global.example.com:8080"); + expect(process.env["GLOBAL_AGENT_HTTPS_PROXY"]).toBe("http://previous-global.example.com:8443"); + expect(process.env["GLOBAL_AGENT_NO_PROXY"]).toBe("global.corp.example.com"); + expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBeUndefined(); + const agent = (global as Record)["GLOBAL_AGENT"] as Record; + expect(agent["HTTP_PROXY"]).toBe("http://previous-global.example.com:8080"); + expect(agent["HTTPS_PROXY"]).toBe("http://previous-global.example.com:8443"); + expect(agent["NO_PROXY"]).toBe("global.corp.example.com"); + expect(agent["forceGlobalAgent"]).toBeUndefined(); + expect(mockForceResetGlobalDispatcher).toHaveBeenCalledOnce(); + }); + + it("restores node http and https globals on stop", async () => { + const patchedHttpRequest = vi.fn() as unknown as typeof http.request; + const patchedHttpGet = vi.fn() as unknown as typeof http.get; + const patchedHttpsRequest = vi.fn() as unknown as typeof https.request; + const patchedHttpsGet = vi.fn() as unknown as typeof https.get; + const patchedHttpAgent = new http.Agent(); + const patchedHttpsAgent = new https.Agent(); + mockBootstrapGlobalAgent.mockImplementationOnce(() => { + http.request = patchedHttpRequest; + http.get = patchedHttpGet; + http.globalAgent = patchedHttpAgent; + https.request = patchedHttpsRequest; + https.get = patchedHttpsGet; + https.globalAgent = patchedHttpsAgent; + (global as Record)["GLOBAL_AGENT"] = { + HTTP_PROXY: "", + HTTPS_PROXY: "", + }; + }); + + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + + expect(http.request).toBe(patchedHttpRequest); + + await stopProxy(handle); + + expect(http.request).toBe(originalHttpRequest); + expect(http.get).toBe(originalHttpGet); + expect(http.globalAgent).toBe(originalHttpGlobalAgent); + expect(https.request).toBe(originalHttpsRequest); + expect(https.get).toBe(originalHttpsGet); + expect(https.globalAgent).toBe(originalHttpsGlobalAgent); + expect((global as Record)["GLOBAL_AGENT"]).toBeUndefined(); + }); + + it("keeps process-wide proxy hooks active until the last overlapping handle stops", async () => { + const patchedHttpRequest = vi.fn() as unknown as typeof http.request; + const patchedHttpGet = vi.fn() as unknown as typeof http.get; + const patchedHttpsRequest = vi.fn() as unknown as typeof https.request; + const patchedHttpsGet = vi.fn() as unknown as typeof https.get; + mockBootstrapGlobalAgent.mockImplementationOnce(() => { + http.request = patchedHttpRequest; + http.get = patchedHttpGet; + https.request = patchedHttpsRequest; + https.get = patchedHttpsGet; + (global as Record)["GLOBAL_AGENT"] = { + HTTP_PROXY: "", + HTTPS_PROXY: "", + }; + }); + + const firstHandle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + const secondHandle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3129", + }); + + expect(http.request).toBe(patchedHttpRequest); + expect(https.request).toBe(patchedHttpsRequest); + expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3129"); + + await stopProxy(firstHandle); + + expect(http.request).toBe(patchedHttpRequest); + expect(https.request).toBe(patchedHttpsRequest); + expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3129"); + expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1"); + + await stopProxy(secondHandle); + + expect(http.request).toBe(originalHttpRequest); + expect(http.get).toBe(originalHttpGet); + expect(https.request).toBe(originalHttpsRequest); + expect(https.get).toBe(originalHttpsGet); + expect(process.env["HTTP_PROXY"]).toBeUndefined(); + expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBeUndefined(); + }); + + it("restores env and throws when undici activation fails", async () => { + mockForceResetGlobalDispatcher.mockImplementationOnce(() => { + throw new Error("dispatcher failed"); + }); + + await expect( + startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }), + ).rejects.toThrow("failed to activate external proxy routing"); + + expect(process.env["http_proxy"]).toBeUndefined(); + expect(process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"]).toBeUndefined(); + }); + + it("restores env and throws when global-agent bootstrap fails", async () => { + mockBootstrapGlobalAgent.mockImplementationOnce(() => { + throw new Error("bootstrap failed"); + }); + + await expect( + startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }), + ).rejects.toThrow("failed to activate external proxy routing"); + + expect(process.env["http_proxy"]).toBeUndefined(); + expect(process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"]).toBeUndefined(); + }); + + it("temporarily restores the original node HTTP stack for Gateway loopback control-plane setup", async () => { + const patchedHttpRequest = vi.fn() as unknown as typeof http.request; + const patchedHttpGet = vi.fn() as unknown as typeof http.get; + mockBootstrapGlobalAgent.mockImplementationOnce(() => { + http.request = patchedHttpRequest; + http.get = patchedHttpGet; + (global as Record)["GLOBAL_AGENT"] = { + HTTP_PROXY: "", + HTTPS_PROXY: "", + }; + }); + + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + + expect(http.request).toBe(patchedHttpRequest); + + const requestDuringBypass = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane( + "ws://127.0.0.1:18789", + () => http.request, + ); + + expect(requestDuringBypass).toBe(originalHttpRequest); + expect(http.request).toBe(patchedHttpRequest); + + await stopProxy(handle); + }); + + it("allows the Gateway control-plane bypass for literal loopback IPs only", () => { + expect( + dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane( + "ws://127.0.0.1:18789", + () => "ok", + ), + ).toBe("ok"); + expect( + dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane("ws://[::1]:18789", () => "ok"), + ).toBe("ok"); + expect(() => + dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane( + "ws://localhost:18789", + () => undefined, + ), + ).toThrow("loopback-only"); + }); + + it("rejects dangerous Gateway control-plane bypass for non-loopback URLs", () => { + expect(() => + dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane( + "wss://gateway.example.com", + () => undefined, + ), + ).toThrow("loopback-only"); + }); + + it("kill restores env synchronously during hard process exit", async () => { + process.env["NO_PROXY"] = "corp.example.com"; + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + + expect(handle).not.toBeNull(); + handle?.kill("SIGTERM"); + + expect(process.env["HTTP_PROXY"]).toBeUndefined(); + expect(process.env["NO_PROXY"]).toBe("corp.example.com"); + }); + + it("stopProxy is a no-op when handle is null", async () => { + await expect(stopProxy(null)).resolves.toBeUndefined(); + }); +}); diff --git a/src/infra/net/proxy/proxy-lifecycle.ts b/src/infra/net/proxy/proxy-lifecycle.ts new file mode 100644 index 00000000000..59e6286b106 --- /dev/null +++ b/src/infra/net/proxy/proxy-lifecycle.ts @@ -0,0 +1,421 @@ +/** + * High-level lifecycle management for OpenClaw's operator-managed network + * proxy routing. + * + * OpenClaw does not spawn or configure the filtering proxy. When enabled, it + * routes process-wide HTTP clients through the configured forward proxy URL and + * restores the previous process state on shutdown. + */ + +import http from "node:http"; +import https from "node:https"; +import { bootstrap as bootstrapGlobalAgent } from "global-agent"; +import type { ProxyConfig } from "../../../config/zod-schema.proxy.js"; +import { logInfo, logWarn } from "../../../logger.js"; +import { isLoopbackIpAddress } from "../../../shared/net/ip.js"; +import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js"; + +export type ProxyHandle = { + /** The operator-managed proxy URL injected into process.env. */ + proxyUrl: string; + /** Alias kept for CLI cleanup tests and logs. */ + injectedProxyUrl: string; + /** Original proxy-related environment values, restored on stop/crash. */ + envSnapshot: ProxyEnvSnapshot; + /** Restore process-wide proxy state. */ + stop: () => Promise; + /** Synchronously restore process-wide proxy state during hard process exit. */ + kill: (signal?: NodeJS.Signals) => void; +}; + +const PROXY_ENV_KEYS = ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"] as const; +const GLOBAL_AGENT_PROXY_KEYS = ["GLOBAL_AGENT_HTTP_PROXY", "GLOBAL_AGENT_HTTPS_PROXY"] as const; +const GLOBAL_AGENT_FORCE_KEYS = ["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"] as const; +const NO_PROXY_ENV_KEYS = ["no_proxy", "NO_PROXY", "GLOBAL_AGENT_NO_PROXY"] as const; +const PROXY_ACTIVE_KEYS = ["OPENCLAW_PROXY_ACTIVE"] as const; +const ALL_PROXY_ENV_KEYS = [ + ...PROXY_ENV_KEYS, + ...GLOBAL_AGENT_PROXY_KEYS, + ...GLOBAL_AGENT_FORCE_KEYS, + ...NO_PROXY_ENV_KEYS, + ...PROXY_ACTIVE_KEYS, +] as const; +type ProxyEnvKey = (typeof ALL_PROXY_ENV_KEYS)[number]; +type ProxyEnvSnapshot = Record; +type NodeHttpStackSnapshot = { + httpRequest: typeof http.request; + httpGet: typeof http.get; + httpGlobalAgent: typeof http.globalAgent; + httpsRequest: typeof https.request; + httpsGet: typeof https.get; + httpsGlobalAgent: typeof https.globalAgent; + hadGlobalAgent: boolean; + globalAgent: unknown; +}; +type ActiveProxyRegistration = { + proxyUrl: string; + stopped: boolean; +}; + +let globalAgentBootstrapped = false; +let nodeHttpStackSnapshot: NodeHttpStackSnapshot | null = null; +let activeProxyRegistrations: ActiveProxyRegistration[] = []; +let baseProxyEnvSnapshot: ProxyEnvSnapshot | null = null; + +export function _resetGlobalAgentBootstrapForTests(): void { + globalAgentBootstrapped = false; + nodeHttpStackSnapshot = null; + activeProxyRegistrations = []; + baseProxyEnvSnapshot = null; +} + +function captureProxyEnv(): ProxyEnvSnapshot { + return { + http_proxy: process.env["http_proxy"], + https_proxy: process.env["https_proxy"], + HTTP_PROXY: process.env["HTTP_PROXY"], + HTTPS_PROXY: process.env["HTTPS_PROXY"], + GLOBAL_AGENT_HTTP_PROXY: process.env["GLOBAL_AGENT_HTTP_PROXY"], + GLOBAL_AGENT_HTTPS_PROXY: process.env["GLOBAL_AGENT_HTTPS_PROXY"], + GLOBAL_AGENT_FORCE_GLOBAL_AGENT: process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"], + no_proxy: process.env["no_proxy"], + NO_PROXY: process.env["NO_PROXY"], + GLOBAL_AGENT_NO_PROXY: process.env["GLOBAL_AGENT_NO_PROXY"], + OPENCLAW_PROXY_ACTIVE: process.env["OPENCLAW_PROXY_ACTIVE"], + }; +} + +function injectProxyEnv(proxyUrl: string): ProxyEnvSnapshot { + const snapshot = captureProxyEnv(); + applyProxyEnv(proxyUrl); + return snapshot; +} + +function applyProxyEnv(proxyUrl: string): void { + for (const key of PROXY_ENV_KEYS) { + process.env[key] = proxyUrl; + } + for (const key of GLOBAL_AGENT_PROXY_KEYS) { + process.env[key] = proxyUrl; + } + process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"] = "true"; + process.env["OPENCLAW_PROXY_ACTIVE"] = "1"; + for (const key of NO_PROXY_ENV_KEYS) { + process.env[key] = ""; + } +} + +function restoreProxyEnv(snapshot: ProxyEnvSnapshot): void { + for (const key of ALL_PROXY_ENV_KEYS) { + const value = snapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function restoreGlobalAgentRuntime(snapshot: ProxyEnvSnapshot): void { + if ( + typeof global === "undefined" || + (global as Record)["GLOBAL_AGENT"] == null + ) { + return; + } + const agent = (global as Record)["GLOBAL_AGENT"] as Record; + agent["HTTP_PROXY"] = snapshot["GLOBAL_AGENT_HTTP_PROXY"] ?? ""; + agent["HTTPS_PROXY"] = snapshot["GLOBAL_AGENT_HTTPS_PROXY"] ?? ""; + agent["NO_PROXY"] = snapshot["GLOBAL_AGENT_NO_PROXY"] ?? null; +} + +function captureNodeHttpStack(): NodeHttpStackSnapshot { + const globalRecord = global as Record; + return { + httpRequest: http.request, + httpGet: http.get, + httpGlobalAgent: http.globalAgent, + httpsRequest: https.request, + httpsGet: https.get, + httpsGlobalAgent: https.globalAgent, + hadGlobalAgent: Object.hasOwn(globalRecord, "GLOBAL_AGENT"), + globalAgent: globalRecord["GLOBAL_AGENT"], + }; +} + +function restoreNodeHttpStack(): void { + const snapshot = nodeHttpStackSnapshot; + if (!snapshot) { + return; + } + http.request = snapshot.httpRequest; + http.get = snapshot.httpGet; + http.globalAgent = snapshot.httpGlobalAgent; + https.request = snapshot.httpsRequest; + https.get = snapshot.httpsGet; + https.globalAgent = snapshot.httpsGlobalAgent; + const globalRecord = global as Record; + if (snapshot.hadGlobalAgent) { + globalRecord["GLOBAL_AGENT"] = snapshot.globalAgent; + } else { + delete globalRecord["GLOBAL_AGENT"]; + } + nodeHttpStackSnapshot = null; + globalAgentBootstrapped = false; +} + +function bootstrapNodeHttpStack(proxyUrl: string): void { + if (!globalAgentBootstrapped) { + nodeHttpStackSnapshot = captureNodeHttpStack(); + bootstrapGlobalAgent(); + globalAgentBootstrapped = true; + } + + if ( + typeof global !== "undefined" && + (global as Record)["GLOBAL_AGENT"] != null + ) { + const agent = (global as Record)["GLOBAL_AGENT"] as Record; + agent["HTTP_PROXY"] = proxyUrl; + agent["HTTPS_PROXY"] = proxyUrl; + agent["NO_PROXY"] = process.env["GLOBAL_AGENT_NO_PROXY"]; + } +} + +function findTopActiveProxyRegistration(): ActiveProxyRegistration | null { + for (let index = activeProxyRegistrations.length - 1; index >= 0; index -= 1) { + const registration = activeProxyRegistrations[index]; + if (!registration.stopped) { + return registration; + } + } + return null; +} + +function resetUndiciDispatcherForProxyLifecycle(): void { + try { + forceResetGlobalDispatcher(); + } catch (err) { + logWarn(`proxy: failed to reset undici dispatcher: ${String(err)}`); + } +} + +function restoreGlobalAgentRuntimeForProxyLifecycle(snapshot: ProxyEnvSnapshot): void { + try { + restoreGlobalAgentRuntime(snapshot); + } catch (err) { + logWarn(`proxy: failed to reset global-agent: ${String(err)}`); + } +} + +function restoreNodeHttpStackForProxyLifecycle(): void { + try { + restoreNodeHttpStack(); + } catch (err) { + logWarn(`proxy: failed to restore node HTTP stack: ${String(err)}`); + } +} + +function reapplyActiveProxyRuntime(proxyUrl: string): void { + applyProxyEnv(proxyUrl); + resetUndiciDispatcherForProxyLifecycle(); + try { + bootstrapNodeHttpStack(proxyUrl); + } catch (err) { + logWarn(`proxy: failed to refresh node HTTP proxy hooks: ${String(err)}`); + } +} + +function restoreInactiveProxyRuntime(snapshot: ProxyEnvSnapshot): void { + restoreProxyEnv(snapshot); + resetUndiciDispatcherForProxyLifecycle(); + restoreGlobalAgentRuntimeForProxyLifecycle(snapshot); + restoreNodeHttpStackForProxyLifecycle(); +} + +function restoreAfterFailedProxyActivation( + previousActiveRegistration: ActiveProxyRegistration | null, + restoreSnapshot: ProxyEnvSnapshot, +): void { + if (previousActiveRegistration) { + reapplyActiveProxyRuntime(previousActiveRegistration.proxyUrl); + return; + } + restoreInactiveProxyRuntime(restoreSnapshot); + baseProxyEnvSnapshot = null; +} + +function stopActiveProxyRegistration(registration: ActiveProxyRegistration): void { + if (registration.stopped) { + return; + } + registration.stopped = true; + activeProxyRegistrations = activeProxyRegistrations.filter((entry) => !entry.stopped); + + const nextActiveRegistration = findTopActiveProxyRegistration(); + if (nextActiveRegistration) { + reapplyActiveProxyRuntime(nextActiveRegistration.proxyUrl); + return; + } + + const restoreSnapshot = baseProxyEnvSnapshot ?? captureProxyEnv(); + baseProxyEnvSnapshot = null; + restoreInactiveProxyRuntime(restoreSnapshot); +} + +function isSupportedProxyUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:"; + } catch { + return false; + } +} + +function resolveProxyUrl(config: ProxyConfig | undefined): string { + const candidate = config?.proxyUrl?.trim() || process.env["OPENCLAW_PROXY_URL"]?.trim(); + 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.", + ); + } + 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.", + ); + } + return candidate; +} + +function redactProxyUrlForLog(value: string): string { + try { + const url = new URL(value); + return url.origin; + } catch { + return ""; + } +} + +export async function startProxy(config: ProxyConfig | undefined): Promise { + if (config?.enabled !== true) { + return null; + } + + const proxyUrl = resolveProxyUrl(config); + const previousActiveRegistration = findTopActiveProxyRegistration(); + baseProxyEnvSnapshot ??= captureProxyEnv(); + const lifecycleBaseEnvSnapshot = baseProxyEnvSnapshot; + let injectedEnvSnapshot = captureProxyEnv(); + let registration: ActiveProxyRegistration | null = null; + + try { + injectedEnvSnapshot = injectProxyEnv(proxyUrl); + forceResetGlobalDispatcher(); + bootstrapNodeHttpStack(proxyUrl); + registration = { + proxyUrl, + stopped: false, + }; + activeProxyRegistrations.push(registration); + } catch (err) { + restoreAfterFailedProxyActivation(previousActiveRegistration, lifecycleBaseEnvSnapshot); + throw new Error(`proxy: failed to activate external proxy routing: ${String(err)}`, { + cause: err, + }); + } + + logInfo( + `proxy: routing process HTTP traffic through external proxy ${redactProxyUrlForLog(proxyUrl)}`, + ); + + const handle: ProxyHandle = { + proxyUrl, + injectedProxyUrl: proxyUrl, + envSnapshot: injectedEnvSnapshot, + stop: async () => { + if (registration) { + stopActiveProxyRegistration(registration); + } + }, + kill: () => { + if (registration) { + stopActiveProxyRegistration(registration); + } + }, + }; + + return handle; +} + +export async function stopProxy(handle: ProxyHandle | null): Promise { + if (!handle) { + return; + } + await handle.stop(); +} + +function isGatewayLoopbackControlPlaneUrl(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + if ( + url.protocol !== "ws:" && + url.protocol !== "wss:" && + url.protocol !== "http:" && + url.protocol !== "https:" + ) { + return false; + } + return isLoopbackIpAddress(url.hostname); +} + +export function dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane( + url: string, + run: () => T, +): T { + if (!isGatewayLoopbackControlPlaneUrl(url)) { + throw new Error("proxy: dangerous Gateway control-plane bypass is loopback-only"); + } + + const snapshot = nodeHttpStackSnapshot; + if (!snapshot) { + return run(); + } + + // Security-sensitive: this temporarily removes managed proxy hooks for the + // synchronous Gateway loopback WebSocket constructor only. Do not reuse this + // helper for provider, plugin, user WebUI, model server, or arbitrary egress. + const activeStack = captureNodeHttpStack(); + const globalRecord = global as Record; + try { + http.request = snapshot.httpRequest; + http.get = snapshot.httpGet; + http.globalAgent = snapshot.httpGlobalAgent; + https.request = snapshot.httpsRequest; + https.get = snapshot.httpsGet; + https.globalAgent = snapshot.httpsGlobalAgent; + if (snapshot.hadGlobalAgent) { + globalRecord["GLOBAL_AGENT"] = snapshot.globalAgent; + } else { + delete globalRecord["GLOBAL_AGENT"]; + } + return run(); + } finally { + http.request = activeStack.httpRequest; + http.get = activeStack.httpGet; + http.globalAgent = activeStack.httpGlobalAgent; + https.request = activeStack.httpsRequest; + https.get = activeStack.httpsGet; + https.globalAgent = activeStack.httpsGlobalAgent; + if (activeStack.hadGlobalAgent) { + globalRecord["GLOBAL_AGENT"] = activeStack.globalAgent; + } else { + delete globalRecord["GLOBAL_AGENT"]; + } + } +} diff --git a/src/infra/net/undici-global-dispatcher.test.ts b/src/infra/net/undici-global-dispatcher.test.ts index 9322799654f..c69844174be 100644 --- a/src/infra/net/undici-global-dispatcher.test.ts +++ b/src/infra/net/undici-global-dispatcher.test.ts @@ -15,6 +15,7 @@ const { } class EnvHttpProxyAgent { + public readonly capturedHttpProxy = process.env.HTTP_PROXY; constructor(public readonly options?: Record) {} } @@ -73,6 +74,7 @@ import { hasEnvHttpProxyAgentConfigured, resolveEnvHttpProxyAgentOptions } from let DEFAULT_UNDICI_STREAM_TIMEOUT_MS: typeof import("./undici-global-dispatcher.js").DEFAULT_UNDICI_STREAM_TIMEOUT_MS; let ensureGlobalUndiciEnvProxyDispatcher: typeof import("./undici-global-dispatcher.js").ensureGlobalUndiciEnvProxyDispatcher; let ensureGlobalUndiciStreamTimeouts: typeof import("./undici-global-dispatcher.js").ensureGlobalUndiciStreamTimeouts; +let forceResetGlobalDispatcher: typeof import("./undici-global-dispatcher.js").forceResetGlobalDispatcher; let resetGlobalUndiciStreamTimeoutsForTests: typeof import("./undici-global-dispatcher.js").resetGlobalUndiciStreamTimeoutsForTests; let undiciGlobalDispatcherModule: typeof import("./undici-global-dispatcher.js"); @@ -83,6 +85,7 @@ describe("ensureGlobalUndiciStreamTimeouts", () => { DEFAULT_UNDICI_STREAM_TIMEOUT_MS, ensureGlobalUndiciEnvProxyDispatcher, ensureGlobalUndiciStreamTimeouts, + forceResetGlobalDispatcher, resetGlobalUndiciStreamTimeoutsForTests, } = undiciGlobalDispatcherModule); }); @@ -302,6 +305,60 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => { }); }); +describe("forceResetGlobalDispatcher", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetGlobalUndiciStreamTimeoutsForTests(); + vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(false); + vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue(undefined); + }); + + it("replaces an EnvHttpProxyAgent with a direct Agent when proxy env is cleared", () => { + setCurrentDispatcher(new EnvHttpProxyAgent()); + + forceResetGlobalDispatcher(); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); + expect(getCurrentDispatcher()).toBeInstanceOf(Agent); + }); + + it("replaces a stale EnvHttpProxyAgent when restored proxy env is still configured", () => { + vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true); + vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue({ + httpProxy: "http://proxy-b.example:8080", + httpsProxy: "http://proxy-b.example:8080", + }); + setCurrentDispatcher(new EnvHttpProxyAgent()); + + forceResetGlobalDispatcher(); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); + expect(getCurrentDispatcher()).toBeInstanceOf(EnvHttpProxyAgent); + expect((getCurrentDispatcher() as { options?: Record }).options).toEqual({ + httpProxy: "http://proxy-b.example:8080", + httpsProxy: "http://proxy-b.example:8080", + }); + }); + + it("preserves ALL_PROXY-only EnvHttpProxyAgent options when resetting", () => { + vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true); + vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue({ + httpProxy: "http://proxy-all.example:3128", + httpsProxy: "http://proxy-all.example:3128", + }); + setCurrentDispatcher(new EnvHttpProxyAgent()); + + forceResetGlobalDispatcher(); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); + expect(getCurrentDispatcher()).toBeInstanceOf(EnvHttpProxyAgent); + expect((getCurrentDispatcher() as { options?: Record }).options).toEqual({ + httpProxy: "http://proxy-all.example:3128", + httpsProxy: "http://proxy-all.example:3128", + }); + }); +}); + afterAll(() => { for (const id of mockedModuleIds) { vi.doUnmock(id); diff --git a/src/infra/net/undici-global-dispatcher.ts b/src/infra/net/undici-global-dispatcher.ts index a37afa0532b..05476b43b67 100644 --- a/src/infra/net/undici-global-dispatcher.ts +++ b/src/infra/net/undici-global-dispatcher.ts @@ -163,3 +163,25 @@ export function resetGlobalUndiciStreamTimeoutsForTests(): void { lastAppliedProxyBootstrap = false; _globalUndiciStreamTimeoutMs = undefined; } + +/** + * Re-evaluate proxy env changes for undici. Installs EnvHttpProxyAgent when + * proxy env is present, and restores a direct Agent after proxy env is cleared. + */ +export function forceResetGlobalDispatcher(): void { + lastAppliedTimeoutKey = null; + lastAppliedProxyBootstrap = false; + try { + const proxyOptions = resolveEnvHttpProxyAgentOptions(); + if (hasEnvHttpProxyAgentConfigured()) { + setGlobalDispatcher( + new EnvHttpProxyAgent(proxyOptions as ConstructorParameters[0]), + ); + lastAppliedProxyBootstrap = true; + } else { + setGlobalDispatcher(new Agent()); + } + } catch { + // Best-effort reset only. + } +} diff --git a/src/types/global-agent.d.ts b/src/types/global-agent.d.ts new file mode 100644 index 00000000000..2e4d55567fa --- /dev/null +++ b/src/types/global-agent.d.ts @@ -0,0 +1,28 @@ +/** + * Local type declaration for global-agent. + * + * The package ships TypeScript types in dist/index.d.ts but omits the + * "types" field in package.json, so TypeScript cannot resolve them + * automatically. This shim re-exports the types that OpenClaw uses. + */ +declare module "global-agent" { + /** + * Bootstraps global-agent by monkey-patching node:http and node:https. + * Must be called once before any HTTP requests are made. + * Reads proxy URL from global.GLOBAL_AGENT.HTTP_PROXY / HTTPS_PROXY at runtime. + */ + export function bootstrap(): void; + + /** + * Creates a standalone proxy agent instance without setting global.GLOBAL_AGENT. + */ + export function createGlobalProxyAgent(config?: { + environmentVariableNamespace?: string; + forceGlobalAgent?: boolean; + socketConnectionTimeout?: number; + }): { + HTTP_PROXY: string | null; + HTTPS_PROXY: string | null; + NO_PROXY: string | null; + }; +}