mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 06:31:11 +00:00
feat(security): support operator-managed network proxy routing (#70044)
* feat: support operator-managed proxy routing * docs: add network proxy changelog entry * fix(proxy): restrict gateway bypass to loopback IPs * fix(cli): harden container proxy URL checks * docs(proxy): clarify gateway bypass scope * docs: remove proxy changelog entry * fix(proxy): clear startup CI guard failures * fix(proxy): harden gateway proxy policy parsing * fix(proxy): honor update shorthand proxy policy * fix(cli): redact proxy URL suffixes * test(proxy): keep gateway help off proxy startup * fix(proxy): keep overlapping lifecycle active * docs: add proxy changelog entry --------- Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
parent
025081dbc5
commit
2633b14914
36 changed files with 2737 additions and 96 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1521,6 +1521,7 @@
|
|||
{
|
||||
"group": "Security",
|
||||
"pages": [
|
||||
"security/network-proxy",
|
||||
"security/formal-verification",
|
||||
"security/THREAT-MODEL-ATLAS",
|
||||
"security/CONTRIBUTING-THREAT-MODEL"
|
||||
|
|
|
|||
155
docs/security/network-proxy.md
Normal file
155
docs/security/network-proxy.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
84
pnpm-lock.yaml
generated
84
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ResolveGatewayClientBootstrap>(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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<typeof import("./command-catalog.js")>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 "<invalid URL>";
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
|
|
|
|||
104
src/cli/gateway-run-argv.ts
Normal file
104
src/cli/gateway-run-argv.ts
Normal file
|
|
@ -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<string>, 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<string>,
|
||||
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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<unknown>>(async () => null),
|
||||
);
|
||||
const stopProxyMock = vi.hoisted(() => vi.fn<(handle: unknown) => Promise<void>>(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<boolean>((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<boolean>((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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> = T | Promise<T>;
|
||||
|
||||
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<void> {
|
|||
}
|
||||
}
|
||||
|
||||
function consumeGatewayRunOptionToken(args: ReadonlyArray<string>, 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<string>, 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
56
src/config/zod-schema.proxy.test.ts
Normal file
56
src/config/zod-schema.proxy.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
28
src/config/zod-schema.proxy.ts
Normal file
28
src/config/zod-schema.proxy.ts
Normal file
|
|
@ -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<typeof ProxyConfigSchema>;
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -42,11 +42,13 @@ type SharedServiceEnvironmentFields = {
|
|||
configPath: string | undefined;
|
||||
tmpDir: string;
|
||||
minimalPath: string | undefined;
|
||||
proxyEnv: Record<string, string | undefined>;
|
||||
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<string, string | undefined>,
|
||||
): Record<string, string | undefined> {
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: "<svg/>" }, async (tmp) => {
|
||||
const { res } = makeControlUiResponse();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<G
|
|||
const canUseTrustedEnvProxy =
|
||||
mode === GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY &&
|
||||
shouldUseEnvHttpProxyForUrl(parsedUrl.toString());
|
||||
const canUseManagedProxy =
|
||||
mode === GUARDED_FETCH_MODE.STRICT && isManagedProxyActive() && hasProxyEnvConfigured();
|
||||
const timeoutMs = resolveDispatcherTimeoutMs(params.timeoutMs);
|
||||
if (canUseTrustedEnvProxy) {
|
||||
dispatcher = createHttp1EnvHttpProxyAgent(undefined, timeoutMs);
|
||||
} else if (canUseManagedProxy) {
|
||||
await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
|
||||
lookupFn: params.lookupFn,
|
||||
policy: params.policy,
|
||||
});
|
||||
dispatcher = createHttp1EnvHttpProxyAgent(undefined, timeoutMs);
|
||||
} else if (usesTrustedExplicitProxyMode) {
|
||||
// Explicit proxy targets are still checked against the caller's hostname
|
||||
// policy, but the proxy does the DNS resolution for the final target.
|
||||
|
|
|
|||
328
src/infra/net/proxy/external-proxy.e2e.test.ts
Normal file
328
src/infra/net/proxy/external-proxy.e2e.test.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { createServer, request as httpRequest, type Server } from "node:http";
|
||||
import * as net from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
async function listenOnLoopback(server: Server): Promise<number> {
|
||||
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<void> {
|
||||
if (server === null || !server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((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<void>((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`);
|
||||
});
|
||||
});
|
||||
25
src/infra/net/proxy/index.ts
Normal file
25
src/infra/net/proxy/index.ts
Normal file
|
|
@ -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";
|
||||
425
src/infra/net/proxy/proxy-lifecycle.test.ts
Normal file
425
src/infra/net/proxy/proxy-lifecycle.test.ts
Normal file
|
|
@ -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<string, string | undefined> = {};
|
||||
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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
|
||||
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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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();
|
||||
});
|
||||
});
|
||||
421
src/infra/net/proxy/proxy-lifecycle.ts
Normal file
421
src/infra/net/proxy/proxy-lifecycle.ts
Normal file
|
|
@ -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<void>;
|
||||
/** 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<ProxyEnvKey, string | undefined>;
|
||||
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<string, unknown>)["GLOBAL_AGENT"] == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>)["GLOBAL_AGENT"] != null
|
||||
) {
|
||||
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
|
||||
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 "<invalid proxy URL>";
|
||||
}
|
||||
}
|
||||
|
||||
export async function startProxy(config: ProxyConfig | undefined): Promise<ProxyHandle | null> {
|
||||
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<void> {
|
||||
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<T>(
|
||||
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<string, unknown>;
|
||||
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"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ const {
|
|||
}
|
||||
|
||||
class EnvHttpProxyAgent {
|
||||
public readonly capturedHttpProxy = process.env.HTTP_PROXY;
|
||||
constructor(public readonly options?: Record<string, unknown>) {}
|
||||
}
|
||||
|
||||
|
|
@ -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<string, unknown> }).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<string, unknown> }).options).toEqual({
|
||||
httpProxy: "http://proxy-all.example:3128",
|
||||
httpsProxy: "http://proxy-all.example:3128",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const id of mockedModuleIds) {
|
||||
vi.doUnmock(id);
|
||||
|
|
|
|||
|
|
@ -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<typeof EnvHttpProxyAgent>[0]),
|
||||
);
|
||||
lastAppliedProxyBootstrap = true;
|
||||
} else {
|
||||
setGlobalDispatcher(new Agent());
|
||||
}
|
||||
} catch {
|
||||
// Best-effort reset only.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
src/types/global-agent.d.ts
vendored
Normal file
28
src/types/global-agent.d.ts
vendored
Normal file
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue