Adopt Proxyline for managed proxy routing

Route managed HTTP/WebSocket/fetch interception through Proxyline 0.3.0, preserving Gateway loopback bypass behavior and root undici hardening.

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
This commit is contained in:
Jesse Merhi
2026-05-16 07:51:36 +10:00
committed by GitHub
parent 0ad3d25fb7
commit 6921d9072e
19 changed files with 1135 additions and 811 deletions

View File

@@ -584,6 +584,7 @@ Docs: https://docs.openclaw.ai
### Breaking
- Channels/iMessage: remove the bundled BlueBubbles channel surface and deprecate BlueBubbles-backed iMessage setup in OpenClaw. Existing `channels.bluebubbles` configs must migrate to `channels.imessage` using `imsg` on a signed-in Mac or an SSH wrapper, and non-macOS default `imsg` configs now report remote-Mac wrapper guidance.
- Proxy: replace OpenClaw managed HTTP/WebSocket/fetch interception internals with Proxyline while preserving Gateway loopback routing policy. (#79857) Thanks @jesse-merhi.
### Fixes

View File

@@ -36,16 +36,13 @@ OpenClaw process
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 `localhost` or 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.
Internally, OpenClaw uses two process-level routing hooks for this feature:
- Undici dispatcher routing covers `fetch`, undici-backed clients, and transports that provide their own undici dispatcher.
- `global-agent` routing covers Node core `node:http` and `node:https` callers, including many libraries layered on `http.request`, `https.request`, `http.get`, and `https.get`. Managed proxy mode forces that global agent so explicit Node HTTP agents do not accidentally bypass the operator proxy.
Internally, OpenClaw installs Proxyline as the process-level routing runtime for this feature. Proxyline covers `fetch`, undici-backed clients, Node core `node:http` / `node:https` callers, common WebSocket clients, and helper-created CONNECT tunnels. Managed proxy mode replaces caller-provided Node HTTP agents so explicit agents do not accidentally bypass the operator proxy.
Some plugins own custom transports that need explicit proxy wiring even when process-level routing exists. For example, Telegram's Bot API transport uses its own HTTP/1 undici dispatcher and therefore honors process proxy env plus the managed `OPENCLAW_PROXY_URL` fallback in that owner-specific transport path.
The proxy URL itself must use `http://`. HTTPS destinations are still supported through the proxy with HTTP `CONNECT`; this only means OpenClaw expects a plain HTTP forward-proxy listener such as `http://127.0.0.1:3128`.
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.
While the proxy is active, OpenClaw clears `no_proxy` and `NO_PROXY`. Those bypass lists are destination-based, so leaving `localhost` or `127.0.0.1` there would let high-risk SSRF targets skip the filtering proxy.
On shutdown, OpenClaw restores the previous proxy environment and resets cached process routing state.
@@ -84,8 +81,8 @@ proxy:
loopbackMode: gateway-only # gateway-only, proxy, or block
```
- `gateway-only` (default): OpenClaw registers the Gateway loopback authority in the active `global-agent` `NO_PROXY` controller so local Gateway WebSocket traffic can connect directly. Custom loopback Gateway ports work because the active Gateway URL's host and port are registered.
- `proxy`: OpenClaw does not register a Gateway loopback `NO_PROXY` authority, so local Gateway traffic is sent through the managed proxy. If the proxy is remote, it must provide special routing for the OpenClaw host's loopback service, such as mapping it to a proxy-reachable hostname, IP, or tunnel. Standard remote proxies resolve `127.0.0.1` and `localhost` from the proxy host, not from the OpenClaw host.
- `gateway-only` (default): OpenClaw registers the Gateway loopback authority in Proxyline's managed bypass policy so local Gateway WebSocket traffic can connect directly. Custom loopback Gateway ports work because the active Gateway URL's host and port are registered.
- `proxy`: OpenClaw does not register a Gateway loopback bypass, so local Gateway traffic is sent through the managed proxy. If the proxy is remote, it must provide special routing for the OpenClaw host's loopback service, such as mapping it to a proxy-reachable hostname, IP, or tunnel. Standard remote proxies resolve `127.0.0.1` and `localhost` from the proxy host, not from the OpenClaw host.
- `block`: OpenClaw denies loopback Gateway control-plane connections before opening a socket.
If `enabled=true` but no valid proxy URL is configured, protected commands fail startup instead of falling back to direct network access.
@@ -212,7 +209,7 @@ proxy:
## Limits
- The proxy improves coverage for process-local JavaScript HTTP and WebSocket clients, but it is not an OS-level network sandbox.
- Gateway loopback control-plane traffic defaults to direct local bypass through `proxy.loopbackMode: "gateway-only"`. OpenClaw implements that bypass by registering the active Gateway loopback authority in the managed `global-agent` `NO_PROXY` controller. Operators can set `proxy.loopbackMode: "proxy"` to send Gateway loopback traffic through the managed proxy, or `proxy.loopbackMode: "block"` to deny loopback Gateway connections. See [Gateway Loopback Mode](#gateway-loopback-mode) for the remote-proxy caveat.
- Gateway loopback control-plane traffic defaults to direct local bypass through `proxy.loopbackMode: "gateway-only"`. OpenClaw implements that bypass by registering the active Gateway loopback authority in Proxyline's managed bypass policy. Operators can set `proxy.loopbackMode: "proxy"` to send Gateway loopback traffic through the managed proxy, or `proxy.loopbackMode: "block"` to deny loopback Gateway connections. See [Gateway Loopback Mode](#gateway-loopback-mode) for the remote-proxy caveat.
- Raw `net`, `tls`, and `http2` sockets, native addons, and non-OpenClaw child processes may bypass Node-level proxy routing unless they inherit and respect proxy environment variables. Forked OpenClaw child CLIs inherit the managed proxy URL and `proxy.loopbackMode` state.
- IRC is a raw TCP/TLS channel outside operator-managed forward proxy routing. In deployments that require all egress through that forward proxy, set `channels.irc.enabled=false` unless direct IRC egress is explicitly approved.
- The local debug proxy is diagnostic tooling and its direct upstream forwarding for proxy requests and CONNECT tunnels is disabled by default while managed proxy mode is active; enable direct forwarding only for approved local diagnostics.

View File

@@ -1766,7 +1766,7 @@
"@modelcontextprotocol/sdk": "1.29.0",
"@mozilla/readability": "0.6.0",
"@openclaw/fs-safe": "0.2.4",
"@openclaw/proxyline": "0.2.0",
"@openclaw/proxyline": "0.3.0",
"ajv": "8.20.0",
"chalk": "5.6.2",
"chokidar": "5.0.0",
@@ -1775,7 +1775,6 @@
"dotenv": "17.4.2",
"express": "5.2.1",
"file-type": "22.0.1",
"global-agent": "4.1.3",
"grammy": "1.42.0",
"ipaddr.js": "2.4.0",
"jiti": "2.7.0",

98
pnpm-lock.yaml generated
View File

@@ -87,8 +87,8 @@ importers:
specifier: 0.2.4
version: 0.2.4
'@openclaw/proxyline':
specifier: 0.2.0
version: 0.2.0
specifier: 0.3.0
version: 0.3.0(undici@8.3.0)
ajv:
specifier: 8.20.0
version: 8.20.0
@@ -113,9 +113,6 @@ importers:
file-type:
specifier: 22.0.1
version: 22.0.1
global-agent:
specifier: 4.1.3
version: 4.1.3
grammy:
specifier: 1.42.0
version: 1.42.0
@@ -3340,9 +3337,11 @@ packages:
resolution: {integrity: sha512-Fo3WTQhxu0asD/rZqIKBqhX6fuZfjyHxSW5yTKfcRx+D9BRAcz0AGoVh+3ur/4XRvZkvsh3Ud8XTw006yRYLgg==}
engines: {node: '>=20.11'}
'@openclaw/proxyline@0.2.0':
resolution: {integrity: sha512-puZtDMshL/ikWQPXq6GPYT5z3994m++Rp1mMs3Kw4/J1xaAeAlCkBH21n2DhTDdWiLwAbpeFqV3OL3jfwv99EQ==}
'@openclaw/proxyline@0.3.0':
resolution: {integrity: sha512-+18F9jk948+qK70V63Nfiu4odfvdf+0T9qyKyzylmt1WLYGTtLLo88pLgtS+vN5CORJyCuC56Rdswkws5VsEig==}
engines: {node: '>=20.18.1'}
peerDependencies:
undici: '>=7.25.0 <9'
'@opentelemetry/api-logs@0.218.0':
resolution: {integrity: sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==}
@@ -5128,18 +5127,10 @@ 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==}
@@ -5289,10 +5280,6 @@ 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'}
@@ -5563,14 +5550,6 @@ packages:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
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'}
@@ -5610,9 +5589,6 @@ 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'}
@@ -6165,10 +6141,6 @@ 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'}
@@ -6463,10 +6435,6 @@ 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==}
@@ -7063,10 +7031,6 @@ 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'}
@@ -7438,10 +7402,6 @@ 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.1.0:
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
engines: {node: '>= 18'}
@@ -9810,9 +9770,9 @@ snapshots:
jszip: 3.10.1
tar: 7.5.15
'@openclaw/proxyline@0.2.0':
'@openclaw/proxyline@0.3.0(undici@8.3.0)':
dependencies:
undici: 7.25.0
undici: 8.3.0
'@opentelemetry/api-logs@0.218.0':
dependencies:
@@ -11557,20 +11517,8 @@ 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:
@@ -11721,8 +11669,6 @@ snapshots:
escape-html@1.0.3: {}
escape-string-regexp@4.0.0: {}
escodegen@2.1.0:
dependencies:
esprima: 4.0.1
@@ -12068,18 +12014,6 @@ snapshots:
minipass: 7.1.3
path-scurry: 2.0.2
global-agent@4.1.3:
dependencies:
globalthis: 1.0.4
matcher: 4.0.0
semver: 7.8.0
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
@@ -12133,10 +12067,6 @@ 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:
@@ -12761,10 +12691,6 @@ snapshots:
marked@18.0.3: {}
matcher@4.0.0:
dependencies:
escape-string-regexp: 4.0.0
math-intrinsics@1.1.0: {}
matrix-events-sdk@0.0.1: {}
@@ -13240,8 +13166,6 @@ snapshots:
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
obug@2.1.1: {}
ogg-opus-decoder@1.7.3:
@@ -13935,10 +13859,6 @@ 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
@@ -14351,8 +14271,6 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
type-fest@0.20.2: {}
type-is@2.1.0:
dependencies:
content-type: 2.0.0

View File

@@ -8,7 +8,7 @@ minimumReleaseAge: 2880
minimumReleaseAgeExclude:
- "@openclaw/fs-safe@0.2.4"
- "@openclaw/proxyline@0.2.0"
- "@openclaw/proxyline@0.3.0"
- "acpx"
- "tokenjuice"
- "@agentclientprotocol/sdk"
@@ -86,6 +86,7 @@ allowBuilds:
sharp: true
tree-sitter-bash: false
openclaw: true
"@openclaw/proxyline": true
packageExtensions:
"@earendil-works/pi-coding-agent":

View File

@@ -96,7 +96,7 @@
"class": "core-runtime",
"risk": ["file-sniffing", "untrusted-files"]
},
"global-agent": {
"@openclaw/proxyline": {
"owner": "core:proxy",
"class": "core-runtime",
"risk": ["network", "proxy"]

View File

@@ -230,20 +230,6 @@ vi.mock("../infra/net/proxy/proxy-lifecycle.js", () => ({
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(),
};

View File

@@ -5,11 +5,38 @@ import { captureEnv } from "../test-utils/env.js";
import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION } from "./protocol/index.js";
const wsInstances = vi.hoisted((): MockWebSocket[] => []);
const wsConstructorObservers = vi.hoisted((): Array<(url: string, options: unknown) => void> => []);
const clearDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
const loadDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
const storeDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
const logDebugMock = vi.hoisted(() => vi.fn());
const logErrorMock = vi.hoisted(() => vi.fn());
const {
installGlobalProxyMock,
proxylineRegisterBypassMock,
proxylineStopMock,
proxylineUnregisterBypassMock,
} = vi.hoisted(() => {
const proxylineStopMock = vi.fn();
const proxylineUnregisterBypassMock = vi.fn();
const proxylineRegisterBypassMock = vi.fn(() => proxylineUnregisterBypassMock);
return {
proxylineRegisterBypassMock,
proxylineStopMock,
proxylineUnregisterBypassMock,
installGlobalProxyMock: vi.fn(() => ({
active: true,
createNodeAgent: vi.fn(),
createUndiciDispatcher: vi.fn(),
createWebSocketAgent: vi.fn(),
explain: vi.fn(),
mode: "managed",
registerBypass: proxylineRegisterBypassMock,
stop: proxylineStopMock,
withBypass: vi.fn(),
})),
};
});
type WsEvent = "open" | "message" | "close" | "error";
type WsEventHandlers = {
@@ -39,6 +66,9 @@ class MockWebSocket {
constructor(_url: string, options?: unknown) {
this.options = options;
wsInstances.push(this);
for (const observer of wsConstructorObservers) {
observer(_url, options);
}
}
on(event: "open", handler: WsEventHandlers["open"]): void;
@@ -100,12 +130,22 @@ class MockWebSocket {
handler(code, Buffer.from(reason));
}
}
emitError(error: unknown): void {
for (const handler of this.errorHandlers) {
handler(error);
}
}
}
vi.mock("ws", () => ({
WebSocket: MockWebSocket,
}));
vi.mock("@openclaw/proxyline", () => ({
installGlobalProxy: installGlobalProxyMock,
}));
vi.mock("../infra/device-auth-store.js", async () => {
const actual = await vi.importActual<typeof import("../infra/device-auth-store.js")>(
"../infra/device-auth-store.js",
@@ -224,31 +264,33 @@ describe("GatewayClient security checks", () => {
"OPENCLAW_PROXY_ACTIVE",
"OPENCLAW_PROXY_LOOPBACK_MODE",
"HTTP_PROXY",
"GLOBAL_AGENT_HTTP_PROXY",
"GLOBAL_AGENT_FORCE_GLOBAL_AGENT",
]);
beforeEach(() => {
beforeEach(async () => {
envSnapshot.restore();
delete process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS;
delete process.env.OPENCLAW_PROXY_ACTIVE;
delete process.env.OPENCLAW_PROXY_LOOPBACK_MODE;
delete process.env.HTTP_PROXY;
delete process.env.GLOBAL_AGENT_HTTP_PROXY;
delete process.env.GLOBAL_AGENT_FORCE_GLOBAL_AGENT;
delete (global as Record<string, unknown>)["GLOBAL_AGENT"];
const { resetProxyLifecycleForTests } = await import("../infra/net/proxy/proxy-lifecycle.js");
resetProxyLifecycleForTests();
installGlobalProxyMock.mockClear();
proxylineRegisterBypassMock.mockClear();
proxylineStopMock.mockClear();
proxylineUnregisterBypassMock.mockClear();
wsInstances.length = 0;
wsConstructorObservers.length = 0;
});
afterEach(() => {
afterEach(async () => {
envSnapshot.restore();
delete process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS;
delete process.env.OPENCLAW_PROXY_ACTIVE;
delete process.env.OPENCLAW_PROXY_LOOPBACK_MODE;
delete process.env.HTTP_PROXY;
delete process.env.GLOBAL_AGENT_HTTP_PROXY;
delete process.env.GLOBAL_AGENT_FORCE_GLOBAL_AGENT;
delete (global as Record<string, unknown>)["GLOBAL_AGENT"];
const { resetProxyLifecycleForTests } = await import("../infra/net/proxy/proxy-lifecycle.js");
resetProxyLifecycleForTests();
wsConstructorObservers.length = 0;
});
it("blocks ws:// to non-loopback addresses (CWE-319)", () => {
@@ -298,7 +340,6 @@ describe("GatewayClient security checks", () => {
process.env.OPENCLAW_PROXY_ACTIVE = "1";
process.env.OPENCLAW_PROXY_LOOPBACK_MODE = "proxy";
process.env.HTTP_PROXY = "http://127.0.0.1:3128";
process.env.GLOBAL_AGENT_HTTP_PROXY = "http://127.0.0.1:3128";
const onConnectError = vi.fn();
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
@@ -309,14 +350,70 @@ describe("GatewayClient security checks", () => {
expect(onConnectError).not.toHaveBeenCalled();
expect(wsInstances.length).toBe(1);
expect(requireRecord(getLatestWs().options, "websocket options").agent).toBeUndefined();
expectRecordFields(
(global as Record<string, unknown>)["GLOBAL_AGENT"],
{
HTTP_PROXY: "http://127.0.0.1:3128",
HTTPS_PROXY: "http://127.0.0.1:3128",
},
"global agent",
expect(getLatestWs().options).not.toMatchObject({ agent: expect.any(Object) });
expect(installGlobalProxyMock).toHaveBeenCalledWith(
expect.objectContaining({
ifActive: "reuse-compatible",
mode: "managed",
proxyUrl: "http://127.0.0.1:3128",
undici: expect.objectContaining({ allowH2: false }),
}),
);
client.stop();
});
it("keeps gateway-only loopback bypass active only during WebSocket construction", () => {
process.env.OPENCLAW_PROXY_ACTIVE = "1";
process.env.OPENCLAW_PROXY_LOOPBACK_MODE = "gateway-only";
process.env.HTTP_PROXY = "http://127.0.0.1:3128";
const onConnectError = vi.fn();
const bypassActiveDuringConstruction: boolean[] = [];
wsConstructorObservers.push(() => {
bypassActiveDuringConstruction.push(
proxylineRegisterBypassMock.mock.calls.length === 1 &&
proxylineUnregisterBypassMock.mock.calls.length === 0,
);
});
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
onConnectError,
});
client.start();
expect(proxylineRegisterBypassMock).toHaveBeenCalledWith({ url: "ws://127.0.0.1:18789" });
expect(bypassActiveDuringConstruction).toEqual([true]);
expect(proxylineUnregisterBypassMock).toHaveBeenCalledOnce();
const ws = getLatestWs();
ws.emitOpen();
expect(proxylineUnregisterBypassMock).toHaveBeenCalledOnce();
expect(onConnectError).not.toHaveBeenCalled();
client.stop();
});
it("clears gateway-only loopback bypass when WebSocket connection errors before opening", () => {
process.env.OPENCLAW_PROXY_ACTIVE = "1";
process.env.OPENCLAW_PROXY_LOOPBACK_MODE = "gateway-only";
process.env.HTTP_PROXY = "http://127.0.0.1:3128";
const onConnectError = vi.fn();
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
onConnectError,
});
client.start();
expect(proxylineRegisterBypassMock).toHaveBeenCalledWith({ url: "ws://127.0.0.1:18789" });
expect(proxylineUnregisterBypassMock).toHaveBeenCalledOnce();
const ws = getLatestWs();
ws.emitError(new Error("proxy connection failed"));
expect(proxylineUnregisterBypassMock).toHaveBeenCalledOnce();
expect(onConnectError).toHaveBeenCalledWith(
expect.objectContaining({ message: "proxy connection failed" }),
);
client.stop();
});
@@ -339,7 +436,7 @@ describe("GatewayClient security checks", () => {
expect(onConnectError).not.toHaveBeenCalled();
expect(wsInstances.length).toBe(1);
expect(requireRecord(getLatestWs().options, "websocket options").agent).toBeUndefined();
expect(getLatestWs().options).not.toMatchObject({ agent: expect.any(Object) });
} finally {
client.stop();
await stopProxy(handle);
@@ -460,11 +557,12 @@ describe("GatewayClient request errors", () => {
}),
);
await expectGatewayRequestError(requestPromise, {
await expect(requestPromise).rejects.toMatchObject({
name: "GatewayClientRequestError",
gatewayCode: "UNAVAILABLE",
retryable: true,
retryAfterMs: 250,
details: { method: "chat.history" },
});
client.stop();
@@ -521,10 +619,10 @@ describe("GatewayClient request errors", () => {
expect(onConnectError).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(ws.lastClose).toEqual({ code: 1013, reason: "gateway starting" });
expect(logDebugMock.mock.calls).toEqual([
["gateway connect failed: GatewayClientRequestError: gateway starting; retry shortly"],
]);
expect(logErrorMock.mock.calls).toEqual([]);
expect(logDebugMock).toHaveBeenCalledWith(expect.stringContaining("gateway connect failed:"));
expect(logErrorMock).not.toHaveBeenCalledWith(
expect.stringContaining("gateway connect failed:"),
);
expect(wsInstances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(249);
@@ -576,7 +674,7 @@ describe("GatewayClient close handling", () => {
expect(getLatestWs().emitClose(1008, "unauthorized: device token mismatch")).toBeUndefined();
expect(logDebugMock).toHaveBeenCalledWith(
"failed clearing stale device-auth token for device dev-2: Error: disk unavailable",
expect.stringContaining("failed clearing stale device-auth token"),
);
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
client.stop();
@@ -754,10 +852,6 @@ describe("GatewayClient connect auth payload", () => {
return parseConnectRequest(ws).params?.auth ?? {};
}
function expectConnectAuthFields(ws: MockWebSocket, expected: Record<string, unknown>): void {
expectRecordFields(connectFrameFrom(ws), expected, "connect auth");
}
function connectScopesFrom(ws: MockWebSocket) {
return parseConnectRequest(ws).params?.scopes ?? [];
}
@@ -890,7 +984,9 @@ describe("GatewayClient connect auth payload", () => {
ws.emitOpen();
emitConnectChallenge(ws);
expectConnectAuthFields(ws, { token: "shared-token" });
expect(connectFrameFrom(ws)).toMatchObject({
token: "shared-token",
});
expect(connectFrameFrom(ws).deviceToken).toBeUndefined();
client.stop();
});
@@ -939,7 +1035,9 @@ describe("GatewayClient connect auth payload", () => {
const { ws, connect } = startClientWithEarlyChallenge({ client });
expectConnectAuthFields(ws, { token: "shared-token" });
expect(connectFrameFrom(ws)).toMatchObject({
token: "shared-token",
});
emitHelloOk(ws, connect.id);
client.stop();
});
@@ -981,7 +1079,9 @@ describe("GatewayClient connect auth payload", () => {
ws.emitOpen();
emitConnectChallenge(ws);
expectConnectAuthFields(ws, { password: "shared-password" }); // pragma: allowlist secret
expect(connectFrameFrom(ws)).toMatchObject({
password: "shared-password", // pragma: allowlist secret
});
expect(connectFrameFrom(ws).token).toBeUndefined();
expect(connectFrameFrom(ws).deviceToken).toBeUndefined();
client.stop();
@@ -999,7 +1099,9 @@ describe("GatewayClient connect auth payload", () => {
ws.emitOpen();
emitConnectChallenge(ws);
expectConnectAuthFields(ws, { password: "shared-password" }); // pragma: allowlist secret
expect(connectFrameFrom(ws)).toMatchObject({
password: "shared-password", // pragma: allowlist secret
});
expect(connectFrameFrom(ws).bootstrapToken).toBeUndefined();
expect(connectFrameFrom(ws).token).toBeUndefined();
client.stop();
@@ -1019,7 +1121,7 @@ describe("GatewayClient connect auth payload", () => {
ws.emitOpen();
emitConnectChallenge(ws);
expectConnectAuthFields(ws, {
expect(connectFrameFrom(ws)).toMatchObject({
token: "stored-device-token",
deviceToken: "stored-device-token",
});
@@ -1042,7 +1144,7 @@ describe("GatewayClient connect auth payload", () => {
ws.emitOpen();
emitConnectChallenge(ws);
expectConnectAuthFields(ws, {
expect(connectFrameFrom(ws)).toMatchObject({
token: "stored-device-token",
deviceToken: "stored-device-token",
});
@@ -1078,7 +1180,7 @@ describe("GatewayClient connect auth payload", () => {
"load device token params",
);
expect(loadTokenParams.deviceId).toBeTypeOf("string");
expectConnectAuthFields(ws, {
expect(connectFrameFrom(ws)).toMatchObject({
token: "stored-device-token",
deviceToken: "stored-device-token",
});
@@ -1097,7 +1199,9 @@ describe("GatewayClient connect auth payload", () => {
ws.emitOpen();
emitConnectChallenge(ws);
expectConnectAuthFields(ws, { bootstrapToken: "bootstrap-token" });
expect(connectFrameFrom(ws)).toMatchObject({
bootstrapToken: "bootstrap-token",
});
expect(connectFrameFrom(ws).token).toBeUndefined();
expect(connectFrameFrom(ws).deviceToken).toBeUndefined();
client.stop();
@@ -1119,7 +1223,7 @@ describe("GatewayClient connect auth payload", () => {
ws.emitOpen();
emitConnectChallenge(ws);
expectConnectAuthFields(ws, {
expect(connectFrameFrom(ws)).toMatchObject({
token: "explicit-device-token",
deviceToken: "explicit-device-token",
});
@@ -1142,7 +1246,7 @@ describe("GatewayClient connect auth payload", () => {
ws.emitOpen();
emitConnectChallenge(ws);
expectConnectAuthFields(ws, {
expect(connectFrameFrom(ws)).toMatchObject({
token: "stored-device-token",
deviceToken: "stored-device-token",
});
@@ -1169,14 +1273,10 @@ describe("GatewayClient connect auth payload", () => {
connectId: firstConnect.id,
failureDetails: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true },
});
expectRecordFields(
retriedAuth,
{
token: "shared-token",
deviceToken: "stored-device-token",
},
"retried connect auth",
);
expect(retriedAuth).toMatchObject({
token: "shared-token",
deviceToken: "stored-device-token",
});
const ws = getLatestWs();
expect(connectScopesFrom(ws)).toEqual(["operator.read"]);
client.stop();
@@ -1195,14 +1295,10 @@ describe("GatewayClient connect auth payload", () => {
connectId: firstConnect.id,
failureDetails: { code: "AUTH_UNAUTHORIZED", recommendedNextStep: "retry_with_device_token" },
});
expectRecordFields(
retriedAuth,
{
token: "shared-token",
deviceToken: "stored-device-token",
},
"retried connect auth",
);
expect(retriedAuth).toMatchObject({
token: "shared-token",
deviceToken: "stored-device-token",
});
client.stop();
});

View File

@@ -13,7 +13,7 @@ import {
} from "../infra/device-identity.js";
import {
ensureInheritedManagedProxyRoutingActive,
withManagedProxyGatewayLoopbackRouting,
registerManagedProxyGatewayLoopbackBypass,
} from "../infra/net/proxy/proxy-lifecycle.js";
import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
import { rawDataToString } from "../infra/ws.js";
@@ -317,10 +317,13 @@ export class GatewayClient {
return undefined;
};
}
const ws = withManagedProxyGatewayLoopbackRouting(
url,
() => new WebSocket(url, wsOptions as ClientOptions),
);
const unregisterGatewayLoopbackBypass = registerManagedProxyGatewayLoopbackBypass(url);
let ws: WebSocket;
try {
ws = new WebSocket(url, wsOptions as ClientOptions);
} finally {
unregisterGatewayLoopbackBypass?.();
}
this.ws = ws;
this.socketOpened = false;
this.connectNonce = null;

View File

@@ -58,22 +58,12 @@ vi.mock("ws", () => ({
send = vi.fn();
constructor(url: unknown, opts: unknown) {
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"];
wsMockState.last = {
url,
opts,
noProxyDuringConstruction:
typeof agent === "object" && agent !== null
? (agent as Record<string, unknown>)["NO_PROXY"]
: undefined,
httpProxyDuringConstruction:
typeof agent === "object" && agent !== null
? (agent as Record<string, unknown>)["HTTP_PROXY"]
: undefined,
httpsProxyDuringConstruction:
typeof agent === "object" && agent !== null
? (agent as Record<string, unknown>)["HTTPS_PROXY"]
: undefined,
noProxyDuringConstruction: process.env["NO_PROXY"],
httpProxyDuringConstruction: process.env["HTTP_PROXY"],
httpsProxyDuringConstruction: process.env["HTTPS_PROXY"],
};
}
},
@@ -89,7 +79,10 @@ describe("GatewayClient", () => {
beforeEach(() => {
wsMockState.last = null;
_resetActiveManagedProxyStateForTests();
delete (global as Record<string, unknown>)["GLOBAL_AGENT"];
delete process.env["NO_PROXY"];
delete process.env["no_proxy"];
delete process.env["HTTP_PROXY"];
delete process.env["HTTPS_PROXY"];
});
async function withControlUiRoot(
@@ -153,9 +146,9 @@ describe("GatewayClient", () => {
expect(last?.opts.agent).toBeUndefined();
});
test("scopes Gateway loopback NO_PROXY to WebSocket construction", () => {
const agent = { NO_PROXY: "corp.example.com" };
(global as Record<string, unknown>)["GLOBAL_AGENT"] = agent;
test("scopes Gateway loopback bypass to WebSocket connection setup without mutating NO_PROXY", () => {
process.env["NO_PROXY"] = "corp.example.com";
process.env["no_proxy"] = "corp.example.com";
const registration = registerActiveManagedProxyUrl(
new URL("http://127.0.0.1:3128"),
"gateway-only",
@@ -166,21 +159,19 @@ describe("GatewayClient", () => {
client.start();
const last = wsMockState.last as { noProxyDuringConstruction: unknown } | null;
expect(last?.noProxyDuringConstruction).toBe("corp.example.com,127.0.0.1:18789");
expect(agent.NO_PROXY).toBe("corp.example.com");
expect(last?.noProxyDuringConstruction).toBe("corp.example.com");
expect(process.env["NO_PROXY"]).toBe("corp.example.com");
expect(process.env["no_proxy"]).toBe("corp.example.com");
} finally {
stopActiveManagedProxyRegistration(registration);
delete (global as Record<string, unknown>)["GLOBAL_AGENT"];
}
});
test("uses a scoped direct construction path for IPv6 loopback in Gateway-only proxy mode", () => {
const agent = {
NO_PROXY: "corp.example.com",
HTTP_PROXY: "http://127.0.0.1:3128",
HTTPS_PROXY: "http://127.0.0.1:3128",
};
(global as Record<string, unknown>)["GLOBAL_AGENT"] = agent;
test("scopes IPv6 loopback bypass during Gateway-only proxy mode connection setup", () => {
process.env["NO_PROXY"] = "corp.example.com";
process.env["no_proxy"] = "corp.example.com";
process.env["HTTP_PROXY"] = "http://127.0.0.1:3128";
process.env["HTTPS_PROXY"] = "http://127.0.0.1:3128";
const registration = registerActiveManagedProxyUrl(
new URL("http://127.0.0.1:3128"),
"gateway-only",
@@ -195,15 +186,15 @@ describe("GatewayClient", () => {
httpsProxyDuringConstruction: unknown;
} | null;
expect(last?.noProxyDuringConstruction).toBe("corp.example.com,[::1]:18789");
expect(last?.httpProxyDuringConstruction).toBeNull();
expect(last?.httpsProxyDuringConstruction).toBeNull();
expect(agent.NO_PROXY).toBe("corp.example.com");
expect(agent.HTTP_PROXY).toBe("http://127.0.0.1:3128");
expect(agent.HTTPS_PROXY).toBe("http://127.0.0.1:3128");
expect(last?.noProxyDuringConstruction).toBe("corp.example.com");
expect(last?.httpProxyDuringConstruction).toBe("http://127.0.0.1:3128");
expect(last?.httpsProxyDuringConstruction).toBe("http://127.0.0.1:3128");
expect(process.env["NO_PROXY"]).toBe("corp.example.com");
expect(process.env["no_proxy"]).toBe("corp.example.com");
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128");
expect(process.env["HTTPS_PROXY"]).toBe("http://127.0.0.1:3128");
} finally {
stopActiveManagedProxyRegistration(registration);
delete (global as Record<string, unknown>)["GLOBAL_AGENT"];
}
});

View File

@@ -65,7 +65,7 @@ vi.mock("../health-state.js", () => ({
incrementPresenceVersion: incrementPresenceVersionMock,
}));
import { attachGatewayWsMessageHandler } from "./message-handler.js";
import { __testing, attachGatewayWsMessageHandler } from "./message-handler.js";
function createLogger() {
return {
@@ -395,3 +395,53 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
expect(connectedClient?.internal?.approvalRuntime).toBe(true);
});
});
describe("resolvePinnedClientMetadata", () => {
it.each([
["darwin", "macos"],
["win32", "windows"],
])(
"pins legacy node-host platform alias %s to paired canonical %s",
(claimedPlatform, pairedPlatform) => {
expect(
__testing.resolvePinnedClientMetadata({
clientId: "node-host",
clientMode: "node",
claimedPlatform,
claimedDeviceFamily: pairedPlatform === "macos" ? "Mac" : "Windows",
pairedPlatform,
pairedDeviceFamily: pairedPlatform === "macos" ? "Mac" : "Windows",
}),
).toEqual({
platformMismatch: false,
deviceFamilyMismatch: false,
pinnedPlatform: pairedPlatform,
pinnedDeviceFamily: pairedPlatform === "macos" ? "Mac" : "Windows",
});
},
);
it.each([
["macos", "darwin", "Mac"],
["windows", "win32", "Windows"],
])(
"pins canonical node-host platform %s over paired legacy alias %s",
(claimedPlatform, pairedPlatform, deviceFamily) => {
expect(
__testing.resolvePinnedClientMetadata({
clientId: "node-host",
clientMode: "node",
claimedPlatform,
claimedDeviceFamily: deviceFamily,
pairedPlatform,
pairedDeviceFamily: deviceFamily,
}),
).toEqual({
platformMismatch: false,
deviceFamilyMismatch: false,
pinnedPlatform: claimedPlatform,
pinnedDeviceFamily: deviceFamily,
});
},
);
});

View File

@@ -213,11 +213,16 @@ function resolvePinnedClientMetadata(params: {
const platformMismatch =
hasPinnedPlatform && claimedPlatform !== pairedPlatform && !isLegacyNodeHostPlatformPin;
const deviceFamilyMismatch = hasPinnedDeviceFamily && claimedDeviceFamily !== pairedDeviceFamily;
const pinnedPlatform =
claimedPlatform === pairedPlatform
? params.pairedPlatform
: isLegacyNodeHostPlatformPin
? normalizeLegacyNodeHostPlatformPin(pairedPlatform)
: undefined;
return {
platformMismatch,
deviceFamilyMismatch,
pinnedPlatform:
hasPinnedPlatform && claimedPlatform === pairedPlatform ? params.pairedPlatform : undefined,
pinnedPlatform: hasPinnedPlatform ? pinnedPlatform : undefined,
pinnedDeviceFamily: hasPinnedDeviceFamily ? params.pairedDeviceFamily : undefined,
};
}
@@ -1717,3 +1722,7 @@ function setSocketMaxPayload(socket: WebSocket, maxPayload: number): void {
receiver._maxPayload = maxPayload;
}
}
export const __testing = {
resolvePinnedClientMetadata,
};

View File

@@ -288,10 +288,14 @@ async function runNodeModule(
describe("SSRF external proxy routing", () => {
let target: Server | null = null;
let globalFetchTarget: Server | null = null;
let wsTarget: Server | null = null;
let gatewayBypassWsTarget: Server | null = null;
let httpsLikeTarget: Server | null = null;
let tlsTarget: Server | null = null;
let proxy: Server | null = null;
let wss: WebSocketServer | null = null;
let gatewayBypassWss: WebSocketServer | null = null;
afterEach(async () => {
await new Promise<void>((resolve) => {
@@ -301,14 +305,28 @@ describe("SSRF external proxy routing", () => {
}
wss.close(() => resolve());
});
await new Promise<void>((resolve) => {
if (!gatewayBypassWss) {
resolve();
return;
}
gatewayBypassWss.close(() => resolve());
});
await closeServer(proxy);
await closeServer(tlsTarget);
await closeServer(httpsLikeTarget);
await closeServer(gatewayBypassWsTarget);
await closeServer(wsTarget);
await closeServer(globalFetchTarget);
await closeServer(target);
gatewayBypassWss = null;
wss = null;
proxy = null;
tlsTarget = null;
httpsLikeTarget = null;
gatewayBypassWsTarget = null;
wsTarget = null;
globalFetchTarget = null;
target = null;
});
@@ -317,11 +335,24 @@ describe("SSRF external proxy routing", () => {
res.writeHead(218, { "content-type": "text/plain" });
res.end("from loopback target");
});
wss = new WebSocketServer({ server: target });
globalFetchTarget = createServer((_req, res) => {
res.writeHead(219, { "content-type": "text/plain" });
res.end("from global fetch target");
});
wsTarget = createServer();
wss = new WebSocketServer({ server: wsTarget });
wss.on("connection", (ws) => {
ws.close(1000, "done");
});
gatewayBypassWsTarget = createServer();
gatewayBypassWss = new WebSocketServer({ server: gatewayBypassWsTarget });
gatewayBypassWss.on("connection", (ws) => {
ws.close(1000, "done");
});
const targetPort = await listenOnLoopback(target);
const globalFetchTargetPort = await listenOnLoopback(globalFetchTarget);
const wsTargetPort = await listenOnLoopback(wsTarget);
const gatewayBypassWsTargetPort = await listenOnLoopback(gatewayBypassWsTarget);
httpsLikeTarget = createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
@@ -340,7 +371,7 @@ describe("SSRF external proxy routing", () => {
import { fetch as undiciFetch } from "undici";
import { WebSocket } from "ws";
import { startProxy, stopProxy } from "./src/infra/net/proxy/proxy-lifecycle.ts";
import { registerManagedProxyGatewayLoopbackNoProxy } from "./src/infra/net/proxy/proxy-lifecycle.ts";
import { registerManagedProxyGatewayLoopbackBypass } from "./src/infra/net/proxy/proxy-lifecycle.ts";
async function nodeHttpGet(url, options = {}) {
return new Promise((resolve, reject) => {
@@ -388,15 +419,15 @@ describe("SSRF external proxy routing", () => {
const ws = new WebSocket(url, { handshakeTimeout: ${PROBE_TIMEOUT_MS} });
ws.once("open", () => {
ws.close();
reject(new Error("proxied websocket unexpectedly opened"));
resolve();
});
ws.once("error", () => resolve());
ws.once("error", reject);
});
}
async function gatewayLoopbackBypassProbe(url) {
return new Promise((resolve, reject) => {
const unregister = registerManagedProxyGatewayLoopbackNoProxy(url);
const unregister = registerManagedProxyGatewayLoopbackBypass(url);
const ws = new WebSocket(url, { handshakeTimeout: ${PROBE_TIMEOUT_MS} });
const cleanup = () => unregister?.();
ws.once("open", () => {
@@ -420,6 +451,10 @@ describe("SSRF external proxy routing", () => {
signal: AbortSignal.timeout(${PROBE_TIMEOUT_MS}),
});
const body = await response.text();
const globalFetchResponse = await fetch(process.env.OPENCLAW_TEST_GLOBAL_FETCH_TARGET_URL, {
signal: AbortSignal.timeout(${PROBE_TIMEOUT_MS}),
});
const globalFetchBody = await globalFetchResponse.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(),
@@ -434,6 +469,7 @@ describe("SSRF external proxy routing", () => {
);
console.log(JSON.stringify({
fetch: { status: response.status, body },
globalFetch: { status: globalFetchResponse.status, body: globalFetchBody },
nodeHttp,
explicitAgent,
}));
@@ -445,29 +481,31 @@ describe("SSRF external proxy routing", () => {
...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_GLOBAL_FETCH_TARGET_URL: `http://127.0.0.1:${globalFetchTargetPort}/global-fetch-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`,
OPENCLAW_TEST_WS_TARGET_URL: `ws://127.0.0.1:${wsTargetPort}/websocket-proxied`,
OPENCLAW_TEST_GATEWAY_BYPASS_WS_URL: `ws://127.0.0.1:${gatewayBypassWsTargetPort}/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('"globalFetch":{"status":219');
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:${wsTargetPort}`);
expect(seenConnectTargets).toContain(`127.0.0.1:${httpsLikeTargetPort}`);
expect(seenConnectTargets).toContain(`127.0.0.1:${targetPort}`);
expect(seenConnectTargets).toContain(`127.0.0.1:${globalFetchTargetPort}`);
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`);
expect(seenConnectTargets).not.toContain(`127.0.0.1:${gatewayBypassWsTargetPort}`);
});
it("preserves the target TLS hostname for Node HTTPS requests through the managed proxy", async () => {
@@ -526,7 +564,6 @@ describe("SSRF external proxy routing", () => {
OPENCLAW_TEST_DISCORD_TLS_URL: `https://discord.com:${tlsTargetPort}/tls-proxy-proof`,
NO_PROXY: "127.0.0.1,localhost",
no_proxy: "localhost",
GLOBAL_AGENT_NO_PROXY: "localhost",
},
);

View File

@@ -1,14 +1,39 @@
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(),
const {
installGlobalProxyMock,
proxylineRegisterBypassMock,
proxylineStopMock,
proxylineUnregisterBypassMock,
} = vi.hoisted(() => {
const proxylineStopMock = vi.fn();
const proxylineUnregisterBypassMock = vi.fn();
const proxylineRegisterBypassMock = vi.fn(() => proxylineUnregisterBypassMock);
return {
proxylineRegisterBypassMock,
proxylineStopMock,
proxylineUnregisterBypassMock,
installGlobalProxyMock: vi.fn(() => ({
active: true,
createNodeAgent: vi.fn(),
createUndiciDispatcher: vi.fn(),
createWebSocketAgent: vi.fn(),
explain: vi.fn(),
mode: "managed",
registerBypass: proxylineRegisterBypassMock,
stop: proxylineStopMock,
withBypass: vi.fn(),
})),
};
});
const forceResetGlobalDispatcherMock = vi.hoisted(() => vi.fn());
vi.mock("@openclaw/proxyline", () => ({
installGlobalProxy: installGlobalProxyMock,
}));
vi.mock("global-agent", () => ({
bootstrap: vi.fn(),
createGlobalProxyAgent: vi.fn(),
vi.mock("../undici-global-dispatcher.js", () => ({
forceResetGlobalDispatcher: forceResetGlobalDispatcherMock,
}));
vi.mock("../../../logger.js", () => ({
@@ -16,20 +41,17 @@ vi.mock("../../../logger.js", () => ({
logWarn: vi.fn(),
}));
import { bootstrap as bootstrapGlobalAgent } from "global-agent";
import { logInfo, logWarn } from "../../../logger.js";
import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js";
import { _resetActiveManagedProxyStateForTests } from "./active-proxy-state.js";
import {
_resetGlobalAgentBootstrapForTests,
registerManagedProxyGatewayLoopbackNoProxy,
ensureInheritedManagedProxyRoutingActive,
resetProxyLifecycleForTests,
registerManagedProxyGatewayLoopbackBypass,
startProxy,
stopProxy,
type ProxyHandle,
} from "./proxy-lifecycle.js";
const mockForceResetGlobalDispatcher = vi.mocked(forceResetGlobalDispatcher);
const mockBootstrapGlobalAgent = vi.mocked(bootstrapGlobalAgent);
const mockLogInfo = vi.mocked(logInfo);
const mockLogWarn = vi.mocked(logWarn);
@@ -41,12 +63,12 @@ function expectProxyHandle(handle: Awaited<ReturnType<typeof startProxy>>): Prox
return handle;
}
function expectNoProxyUnregister(
unregister: ReturnType<typeof registerManagedProxyGatewayLoopbackNoProxy>,
function expectBypassUnregister(
unregister: ReturnType<typeof registerManagedProxyGatewayLoopbackBypass>,
): () => void {
expect(unregister).toBeTypeOf("function");
if (typeof unregister !== "function") {
throw new Error("Expected Gateway NO_PROXY unregister callback");
throw new Error("Expected Gateway bypass unregister callback");
}
return unregister;
}
@@ -62,48 +84,25 @@ describe("startProxy", () => {
"ALL_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_LOOPBACK_MODE",
"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();
mockBootstrapGlobalAgent.mockImplementation(() => {
const env = process.env as Record<string, string | undefined>;
const namespace = env["GLOBAL_AGENT_ENVIRONMENT_VARIABLE_NAMESPACE"] ?? "GLOBAL_AGENT_";
(global as Record<string, unknown>)["GLOBAL_AGENT"] = {
HTTP_PROXY: env[`${namespace}HTTP_PROXY`] ?? "",
HTTPS_PROXY: env[`${namespace}HTTPS_PROXY`] ?? "",
NO_PROXY: env[`${namespace}NO_PROXY`] ?? null,
};
});
mockLogInfo.mockReset();
mockLogWarn.mockReset();
_resetGlobalAgentBootstrapForTests();
resetProxyLifecycleForTests();
_resetActiveManagedProxyStateForTests();
(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;
installGlobalProxyMock.mockClear();
proxylineRegisterBypassMock.mockClear();
proxylineStopMock.mockClear();
proxylineUnregisterBypassMock.mockClear();
forceResetGlobalDispatcherMock.mockClear();
});
afterEach(() => {
@@ -114,13 +113,6 @@ describe("startProxy", () => {
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 () => {
@@ -128,9 +120,7 @@ describe("startProxy", () => {
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(installGlobalProxyMock).not.toHaveBeenCalled();
expect(mockLogInfo).not.toHaveBeenCalled();
expect(mockLogWarn).not.toHaveBeenCalled();
});
@@ -196,7 +186,7 @@ describe("startProxy", () => {
expect(mockLogWarn).not.toHaveBeenCalled();
});
it("sets both undici and global-agent proxy env vars", async () => {
it("sets process proxy env vars for inherited clients", async () => {
const handle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
@@ -207,9 +197,6 @@ describe("startProxy", () => {
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");
expect(process.env["OPENCLAW_PROXY_LOOPBACK_MODE"]).toBe("gateway-only");
});
@@ -251,7 +238,6 @@ describe("startProxy", () => {
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,
@@ -260,29 +246,105 @@ describe("startProxy", () => {
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({
it("installs and stops Proxyline managed routing", async () => {
const handle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
});
expect(mockForceResetGlobalDispatcher).toHaveBeenCalledOnce();
expect(mockBootstrapGlobalAgent).toHaveBeenCalledOnce();
expect(installGlobalProxyMock).toHaveBeenCalledWith(
expect.objectContaining({
ifActive: "replace",
mode: "managed",
proxyUrl: "http://127.0.0.1:3128",
undici: expect.objectContaining({ allowH2: false }),
}),
);
expect(forceResetGlobalDispatcherMock).toHaveBeenCalledWith({
preserveProxylineManaged: true,
});
await stopProxy(expectProxyHandle(handle));
expect(proxylineStopMock).toHaveBeenCalledOnce();
expect(forceResetGlobalDispatcherMock).toHaveBeenCalledTimes(2);
});
it("restores previous proxy env and global-agent state on stop", async () => {
it("reuses inherited Proxyline routing and replaces it when startProxy takes ownership", async () => {
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
process.env["OPENCLAW_PROXY_LOOPBACK_MODE"] = "gateway-only";
process.env["HTTP_PROXY"] = "http://127.0.0.1:3111";
ensureInheritedManagedProxyRoutingActive();
expect(installGlobalProxyMock).toHaveBeenCalledWith(
expect.objectContaining({
ifActive: "reuse-compatible",
mode: "managed",
proxyUrl: "http://127.0.0.1:3111",
undici: expect.objectContaining({ allowH2: false }),
}),
);
expect(proxylineStopMock).not.toHaveBeenCalled();
const handle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3222",
});
expect(installGlobalProxyMock).toHaveBeenCalledTimes(2);
const installCalls = installGlobalProxyMock.mock.calls as unknown[][];
expect(installCalls[1]?.[0]).toEqual(
expect.objectContaining({
ifActive: "replace",
mode: "managed",
proxyUrl: "http://127.0.0.1:3222",
undici: expect.objectContaining({ allowH2: false }),
}),
);
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3222");
await stopProxy(expectProxyHandle(handle));
expect(proxylineStopMock).toHaveBeenCalledOnce();
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3111");
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
expect(installGlobalProxyMock).toHaveBeenCalledTimes(3);
expect(installCalls[2]?.[0]).toEqual(
expect.objectContaining({
ifActive: "reuse-compatible",
mode: "managed",
proxyUrl: "http://127.0.0.1:3111",
undici: expect.objectContaining({ allowH2: false }),
}),
);
});
it("forces root undici onto the inherited managed proxy", () => {
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
process.env["OPENCLAW_PROXY_LOOPBACK_MODE"] = "gateway-only";
process.env["HTTP_PROXY"] = "http://127.0.0.1:3111";
ensureInheritedManagedProxyRoutingActive();
expect(installGlobalProxyMock).toHaveBeenCalledWith(
expect.objectContaining({
ifActive: "reuse-compatible",
mode: "managed",
proxyUrl: "http://127.0.0.1:3111",
undici: expect.objectContaining({ allowH2: false }),
}),
);
expect(forceResetGlobalDispatcherMock).toHaveBeenCalledWith({
preserveProxylineManaged: true,
});
});
it("restores previous proxy env and stops Proxyline 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,
@@ -292,78 +354,17 @@ describe("startProxy", () => {
const proxyHandle = expectProxyHandle(handle);
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128");
expect(process.env["NO_PROXY"]).toBe("");
mockForceResetGlobalDispatcher.mockClear();
await stopProxy(proxyHandle);
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("");
expect(agent["HTTPS_PROXY"]).toBe("");
expect(agent["NO_PROXY"]).toBeUndefined();
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();
expect(proxylineStopMock).toHaveBeenCalledOnce();
expect(forceResetGlobalDispatcherMock).toHaveBeenCalledTimes(2);
});
it("keeps same-url overlapping handles active until the final 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;
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",
@@ -373,26 +374,22 @@ describe("startProxy", () => {
proxyUrl: "http://127.0.0.1:3128",
});
expect(mockForceResetGlobalDispatcher).toHaveBeenCalledOnce();
expect(mockBootstrapGlobalAgent).toHaveBeenCalledOnce();
expect(http.request).toBe(patchedHttpRequest);
expect(https.request).toBe(patchedHttpsRequest);
expect(installGlobalProxyMock).toHaveBeenCalledOnce();
expect(forceResetGlobalDispatcherMock).toHaveBeenCalledOnce();
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128");
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
await stopProxy(secondHandle);
expect(http.request).toBe(patchedHttpRequest);
expect(https.request).toBe(patchedHttpsRequest);
expect(proxylineStopMock).not.toHaveBeenCalled();
expect(forceResetGlobalDispatcherMock).toHaveBeenCalledOnce();
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128");
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
await stopProxy(firstHandle);
expect(http.request).toBe(originalHttpRequest);
expect(http.get).toBe(originalHttpGet);
expect(https.request).toBe(originalHttpsRequest);
expect(https.get).toBe(originalHttpsGet);
expect(proxylineStopMock).toHaveBeenCalledOnce();
expect(forceResetGlobalDispatcherMock).toHaveBeenCalledTimes(2);
expect(process.env["HTTP_PROXY"]).toBeUndefined();
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBeUndefined();
});
@@ -437,9 +434,9 @@ describe("startProxy", () => {
await stopProxy(firstHandle);
});
it("restores env and throws when undici activation fails", async () => {
mockForceResetGlobalDispatcher.mockImplementationOnce(() => {
throw new Error("dispatcher failed");
it("restores env and throws when Proxyline activation fails", async () => {
installGlobalProxyMock.mockImplementationOnce(() => {
throw new Error("install failed");
});
await expect(
@@ -450,85 +447,96 @@ describe("startProxy", () => {
).rejects.toThrow("failed to activate external proxy routing");
expect(process.env["http_proxy"]).toBeUndefined();
expect(process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"]).toBeUndefined();
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).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("registers exact Gateway loopback authorities in global-agent NO_PROXY", async () => {
it("registers exact Gateway loopback URLs with Proxyline", async () => {
const handle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
});
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
const unregister = expectNoProxyUnregister(
registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"),
const unregister = expectBypassUnregister(
registerManagedProxyGatewayLoopbackBypass("ws://127.0.0.1:18789"),
);
expect(agent["NO_PROXY"]).toBe("127.0.0.1:18789");
expect(proxylineRegisterBypassMock).toHaveBeenCalledWith({
url: "ws://127.0.0.1:18789",
});
unregister();
expect(agent["NO_PROXY"]).toBeNull();
expect(proxylineUnregisterBypassMock).toHaveBeenCalledOnce();
await stopProxy(handle);
});
it("accepts literal loopback IPs and localhost for Gateway NO_PROXY registration", async () => {
it("delegates overlapping Gateway loopback bypass registrations to Proxyline", async () => {
const handle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
});
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
const unregisterIpv6 = expectNoProxyUnregister(
registerManagedProxyGatewayLoopbackNoProxy("ws://[::1]:18789"),
const unregisterFirst = expectBypassUnregister(
registerManagedProxyGatewayLoopbackBypass("ws://127.0.0.1:18789"),
);
expect(agent["NO_PROXY"]).toBe("[::1]:18789");
const unregisterSecond = expectBypassUnregister(
registerManagedProxyGatewayLoopbackBypass("ws://127.0.0.1:18789"),
);
expect(proxylineRegisterBypassMock).toHaveBeenCalledTimes(2);
expect(proxylineRegisterBypassMock).toHaveBeenNthCalledWith(1, {
url: "ws://127.0.0.1:18789",
});
expect(proxylineRegisterBypassMock).toHaveBeenNthCalledWith(2, {
url: "ws://127.0.0.1:18789",
});
unregisterFirst();
expect(proxylineUnregisterBypassMock).toHaveBeenCalledTimes(1);
unregisterSecond();
expect(proxylineUnregisterBypassMock).toHaveBeenCalledTimes(2);
await stopProxy(handle);
});
it("accepts literal loopback IPs and localhost for Gateway bypass registration", async () => {
const handle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
});
const unregisterIpv6 = expectBypassUnregister(
registerManagedProxyGatewayLoopbackBypass("ws://[::1]:18789"),
);
expect(proxylineRegisterBypassMock).toHaveBeenCalledWith({ url: "ws://[::1]:18789" });
unregisterIpv6();
const unregisterLocalhost = expectNoProxyUnregister(
registerManagedProxyGatewayLoopbackNoProxy("ws://localhost.:18789"),
const unregisterLocalhost = expectBypassUnregister(
registerManagedProxyGatewayLoopbackBypass("ws://localhost.:18789"),
);
expect(agent["NO_PROXY"]).toBe("localhost.:18789");
expect(proxylineRegisterBypassMock).toHaveBeenCalledWith({ url: "ws://localhost.:18789" });
unregisterLocalhost();
await stopProxy(handle);
});
it("does not register Gateway NO_PROXY for non-loopback URLs", () => {
expect(registerManagedProxyGatewayLoopbackNoProxy("wss://gateway.example.com")).toBeUndefined();
it("does not register Gateway bypass for non-loopback URLs", () => {
expect(registerManagedProxyGatewayLoopbackBypass("wss://gateway.example.com")).toBeUndefined();
});
it("allows Gateway NO_PROXY registration for custom configured loopback ports", async () => {
it("allows Gateway bypass registration for custom configured loopback ports", async () => {
const handle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
});
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
const unregister = expectNoProxyUnregister(
registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:3000"),
const unregister = expectBypassUnregister(
registerManagedProxyGatewayLoopbackBypass("ws://127.0.0.1:3000"),
);
expect(agent["NO_PROXY"]).toBe("127.0.0.1:3000");
expect(proxylineRegisterBypassMock).toHaveBeenCalledWith({ url: "ws://127.0.0.1:3000" });
unregister();
await stopProxy(handle);
});
it("blocks Gateway NO_PROXY registration when active proxy loopbackMode is block", async () => {
it("blocks Gateway bypass registration when active proxy loopbackMode is block", async () => {
const handle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
@@ -536,7 +544,7 @@ describe("startProxy", () => {
});
try {
expect(() => registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789")).toThrow(
expect(() => registerManagedProxyGatewayLoopbackBypass("ws://127.0.0.1:18789")).toThrow(
"blocked by proxy.loopbackMode",
);
} finally {
@@ -544,38 +552,39 @@ describe("startProxy", () => {
}
});
it("does not register Gateway NO_PROXY when active proxy loopbackMode is proxy", async () => {
it("does not register Gateway bypass when active proxy loopbackMode is proxy", async () => {
const handle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
loopbackMode: "proxy",
});
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
try {
const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789");
expect(agent["NO_PROXY"]).toBe("");
const unregister = registerManagedProxyGatewayLoopbackBypass("ws://127.0.0.1:18789");
expect(proxylineRegisterBypassMock).not.toHaveBeenCalled();
expect(unregister).toBeUndefined();
} finally {
await stopProxy(handle);
}
});
it("restores the active global-agent NO_PROXY value after Gateway registration", async () => {
it("does not mutate NO_PROXY while registering Gateway bypass", async () => {
const handle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
});
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
agent["NO_PROXY"] = "corp.example.com";
process.env["NO_PROXY"] = "corp.example.com";
process.env["no_proxy"] = "corp.example.com";
const unregister = expectNoProxyUnregister(
registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"),
const unregister = expectBypassUnregister(
registerManagedProxyGatewayLoopbackBypass("ws://127.0.0.1:18789"),
);
expect(agent["NO_PROXY"]).toBe("corp.example.com,127.0.0.1:18789");
expect(process.env["NO_PROXY"]).toBe("corp.example.com");
expect(process.env["no_proxy"]).toBe("corp.example.com");
unregister();
expect(agent["NO_PROXY"]).toBe("corp.example.com");
expect(process.env["NO_PROXY"]).toBe("corp.example.com");
expect(process.env["no_proxy"]).toBe("corp.example.com");
await stopProxy(handle);
});

View File

@@ -7,10 +7,11 @@
* restores the previous process state on shutdown.
*/
import http from "node:http";
import https from "node:https";
import { isIP } from "node:net";
import { bootstrap as bootstrapGlobalAgent } from "global-agent";
import {
installGlobalProxy,
type ProxylineHandle,
type ProxylineUndiciOptions,
} from "@openclaw/proxyline";
import type { ProxyConfig } from "../../../config/zod-schema.proxy.js";
export type ProxyLoopbackMode = NonNullable<NonNullable<ProxyConfig>["loopbackMode"]>;
@@ -28,10 +29,6 @@ import {
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. */
@@ -39,51 +36,22 @@ export type ProxyHandle = {
};
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 NO_PROXY_ENV_KEYS = ["no_proxy", "NO_PROXY"] as const;
const PROXY_ACTIVE_KEYS = ["OPENCLAW_PROXY_ACTIVE", "OPENCLAW_PROXY_LOOPBACK_MODE"] 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;
const ALL_PROXY_ENV_KEYS = [...PROXY_ENV_KEYS, ...NO_PROXY_ENV_KEYS, ...PROXY_ACTIVE_KEYS] as const;
type ProxyEnvKey = (typeof ALL_PROXY_ENV_KEYS)[number];
type ProxyEnvSnapshot = Record<ProxyEnvKey, string | undefined>;
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 GlobalAgentConnectConfiguration = Record<string, unknown> & {
host: string;
tls: Record<string, unknown>;
};
type GlobalAgentCreateConnection = typeof https.globalAgent.createConnection;
type GlobalAgentCreateConnectionConfiguration = Parameters<GlobalAgentCreateConnection>[0];
type GlobalAgentCreateConnectionCallback = Parameters<GlobalAgentCreateConnection>[1];
type GlobalAgentCreateConnectionResult = ReturnType<GlobalAgentCreateConnection>;
type GlobalAgentHttpsAgent = {
createConnection: GlobalAgentCreateConnection;
};
let globalAgentBootstrapped = false;
let nodeHttpStackSnapshot: NodeHttpStackSnapshot | null = null;
let baseProxyEnvSnapshot: ProxyEnvSnapshot | null = null;
let patchedGlobalAgentHttpsAgents = new WeakSet<object>();
let proxylineHandle: ProxylineHandle | null = null;
const MANAGED_PROXY_UNDICI_OPTIONS = Object.freeze({
allowH2: false,
}) satisfies ProxylineUndiciOptions;
export function _resetGlobalAgentBootstrapForTests(): void {
globalAgentBootstrapped = false;
nodeHttpStackSnapshot = null;
export function resetProxyLifecycleForTests(): void {
baseProxyEnvSnapshot = null;
patchedGlobalAgentHttpsAgents = new WeakSet<object>();
proxylineHandle?.stop();
proxylineHandle = null;
}
function captureProxyEnv(): ProxyEnvSnapshot {
@@ -92,12 +60,8 @@ function captureProxyEnv(): ProxyEnvSnapshot {
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"],
OPENCLAW_PROXY_LOOPBACK_MODE: process.env["OPENCLAW_PROXY_LOOPBACK_MODE"],
};
@@ -113,10 +77,6 @@ function applyProxyEnv(proxyUrl: string, loopbackMode: ProxyLoopbackMode): 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";
process.env["OPENCLAW_PROXY_LOOPBACK_MODE"] = loopbackMode;
for (const key of NO_PROXY_ENV_KEYS) {
@@ -135,163 +95,16 @@ function restoreProxyEnv(snapshot: ProxyEnvSnapshot): void {
}
}
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();
patchGlobalAgentHttpsConnectTlsTargetHost();
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 isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isGlobalAgentConnectConfiguration(
value: unknown,
): value is GlobalAgentConnectConfiguration {
if (!isRecord(value)) {
return false;
}
return typeof value["host"] === "string" && isRecord(value["tls"]);
}
function isGlobalAgentHttpsAgent(value: unknown): value is GlobalAgentHttpsAgent {
if (!isRecord(value)) {
return false;
}
return typeof value["createConnection"] === "function";
}
function withTlsTargetHost(
configuration: GlobalAgentCreateConnectionConfiguration,
): GlobalAgentCreateConnectionConfiguration {
if (!isGlobalAgentConnectConfiguration(configuration)) {
return configuration;
}
// Compatibility shim for https://github.com/gajus/global-agent/issues/83.
// global-agent@4.1.3 can CONNECT to the right host while leaving Node TLS
// certificate validation pointed at the proxy socket host. Keep this until
// upstream carries the CONNECT target host through to tls.connect().
const tlsOptions: Record<string, unknown> = {
...configuration.tls,
host: configuration.host,
};
if (tlsOptions["servername"] === undefined && isIP(configuration.host) === 0) {
tlsOptions["servername"] = configuration.host;
}
return {
...configuration,
tls: tlsOptions,
} as GlobalAgentCreateConnectionConfiguration;
}
function patchGlobalAgentHttpsConnectTlsTargetHost(): void {
const agent = https.globalAgent;
if (!isGlobalAgentHttpsAgent(agent) || patchedGlobalAgentHttpsAgents.has(agent)) {
return;
}
const createConnection = agent.createConnection.bind(agent);
agent.createConnection = function createConnectionWithTlsTargetHost(
this: unknown,
configuration: GlobalAgentCreateConnectionConfiguration,
callback?: GlobalAgentCreateConnectionCallback,
): GlobalAgentCreateConnectionResult {
return createConnection(withTlsTargetHost(configuration), callback);
};
patchedGlobalAgentHttpsAgents.add(agent);
}
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 restoreInactiveProxyRuntime(snapshot: ProxyEnvSnapshot): void {
try {
proxylineHandle?.stop();
} catch (err) {
logWarn(`proxy: failed to stop Proxyline: ${String(err)}`);
}
proxylineHandle = null;
restoreProxyEnv(snapshot);
resetUndiciDispatcherForProxyLifecycle();
restoreGlobalAgentRuntimeForProxyLifecycle(snapshot);
restoreNodeHttpStackForProxyLifecycle();
forceResetGlobalDispatcher();
ensureInheritedManagedProxyRoutingActive();
}
function restoreAfterFailedProxyActivation(restoreSnapshot: ProxyEnvSnapshot): void {
@@ -352,12 +165,17 @@ export function ensureInheritedManagedProxyRoutingActive(): void {
if (process.env["OPENCLAW_PROXY_ACTIVE"] !== "1") {
return;
}
const proxyUrl = process.env["GLOBAL_AGENT_HTTP_PROXY"] ?? process.env["HTTP_PROXY"];
const proxyUrl = process.env["HTTP_PROXY"];
if (!proxyUrl || !isSupportedProxyUrl(proxyUrl)) {
return;
}
bootstrapNodeHttpStack(proxyUrl);
forceResetGlobalDispatcher();
proxylineHandle = installGlobalProxy({
mode: "managed",
proxyUrl,
ifActive: "reuse-compatible",
undici: MANAGED_PROXY_UNDICI_OPTIONS,
});
forceResetGlobalDispatcher({ preserveProxylineManaged: true });
}
export async function startProxy(config: ProxyConfig | undefined): Promise<ProxyHandle | null> {
@@ -372,8 +190,6 @@ export async function startProxy(config: ProxyConfig | undefined): Promise<Proxy
const registration = registerActiveManagedProxyUrl(new URL(proxyUrl), loopbackMode);
const handle: ProxyHandle = {
proxyUrl,
injectedProxyUrl: proxyUrl,
envSnapshot: baseProxyEnvSnapshot ?? captureProxyEnv(),
stop: async () => {
stopActiveProxyRegistration(registration);
},
@@ -385,13 +201,17 @@ export async function startProxy(config: ProxyConfig | undefined): Promise<Proxy
}
baseProxyEnvSnapshot ??= captureProxyEnv();
const lifecycleBaseEnvSnapshot = baseProxyEnvSnapshot;
let injectedEnvSnapshot = captureProxyEnv();
let registration: ActiveManagedProxyRegistration | null = null;
try {
injectedEnvSnapshot = injectProxyEnv(proxyUrl, loopbackMode);
forceResetGlobalDispatcher();
bootstrapNodeHttpStack(proxyUrl);
injectProxyEnv(proxyUrl, loopbackMode);
proxylineHandle = installGlobalProxy({
mode: "managed",
proxyUrl,
ifActive: "replace",
undici: MANAGED_PROXY_UNDICI_OPTIONS,
});
forceResetGlobalDispatcher({ preserveProxylineManaged: true });
registration = registerActiveManagedProxyUrl(new URL(proxyUrl), loopbackMode);
} catch (err) {
restoreAfterFailedProxyActivation(lifecycleBaseEnvSnapshot);
@@ -406,8 +226,6 @@ export async function startProxy(config: ProxyConfig | undefined): Promise<Proxy
const handle: ProxyHandle = {
proxyUrl,
injectedProxyUrl: proxyUrl,
envSnapshot: injectedEnvSnapshot,
stop: async () => {
if (registration) {
stopActiveProxyRegistration(registration);
@@ -442,7 +260,7 @@ function isGatewayControlPlaneProtocol(protocol: string): boolean {
return protocol === "ws:" || protocol === "wss:" || protocol === "http:" || protocol === "https:";
}
function getGatewayControlPlaneNoProxyAuthority(value: string): string | null {
function getGatewayControlPlaneBypassAuthority(value: string): string | null {
const url = parseGatewayControlPlaneUrl(value);
if (
url === null ||
@@ -454,71 +272,8 @@ function getGatewayControlPlaneNoProxyAuthority(value: string): string | null {
return url.port ? `${url.hostname}:${url.port}` : url.hostname;
}
function unbracketHost(hostname: string): string {
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
}
function isGatewayControlPlaneIpv6LoopbackUrl(value: string): boolean {
const url = parseGatewayControlPlaneUrl(value);
if (
url === null ||
!isGatewayControlPlaneProtocol(url.protocol) ||
!isGatewayControlPlaneLoopbackHost(url.hostname)
) {
return false;
}
return isIP(unbracketHost(url.hostname)) === 6;
}
function readGlobalAgentNoProxy(): string {
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"];
if (!isRecord(agent)) {
return "";
}
return typeof agent["NO_PROXY"] === "string" ? agent["NO_PROXY"] : "";
}
function writeGlobalAgentNoProxy(value: string): void {
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"];
if (isRecord(agent)) {
agent["NO_PROXY"] = value === "" ? null : value;
}
}
function appendNoProxyAuthority(noProxy: string, authority: string): string {
const entries = noProxy.split(/[\s,]+/).filter(Boolean);
return entries.includes(authority) ? noProxy : [...entries, authority].join(",");
}
function disableGlobalAgentProxyForIpv6GatewayLoopback(url: string): (() => void) | undefined {
if (
getActiveManagedProxyLoopbackMode() !== "gateway-only" ||
!isGatewayControlPlaneIpv6LoopbackUrl(url)
) {
return undefined;
}
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"];
if (!isRecord(agent)) {
return undefined;
}
const previousHttpProxy = agent["HTTP_PROXY"];
const previousHttpsProxy = agent["HTTPS_PROXY"];
agent["HTTP_PROXY"] = null;
agent["HTTPS_PROXY"] = null;
let stopped = false;
return () => {
if (stopped) {
return;
}
stopped = true;
agent["HTTP_PROXY"] = previousHttpProxy;
agent["HTTPS_PROXY"] = previousHttpsProxy;
};
}
export function registerManagedProxyGatewayLoopbackNoProxy(url: string): (() => void) | undefined {
const authority = getGatewayControlPlaneNoProxyAuthority(url);
export function registerManagedProxyGatewayLoopbackBypass(url: string): (() => void) | undefined {
const authority = getGatewayControlPlaneBypassAuthority(url);
if (!authority) {
return undefined;
}
@@ -532,29 +287,7 @@ export function registerManagedProxyGatewayLoopbackNoProxy(url: string): (() =>
return undefined;
}
const previousNoProxy = readGlobalAgentNoProxy();
writeGlobalAgentNoProxy(appendNoProxyAuthority(previousNoProxy, authority));
let stopped = false;
return () => {
if (stopped) {
return;
}
stopped = true;
writeGlobalAgentNoProxy(previousNoProxy);
};
}
export function withManagedProxyGatewayLoopbackRouting<T>(url: string, run: () => T): T {
let unregisterNoProxy: (() => void) | undefined;
let restoreIpv6Bypass: (() => void) | undefined;
try {
unregisterNoProxy = registerManagedProxyGatewayLoopbackNoProxy(url);
restoreIpv6Bypass = disableGlobalAgentProxyForIpv6GatewayLoopback(url);
return run();
} finally {
restoreIpv6Bypass?.();
unregisterNoProxy?.();
}
return proxylineHandle?.registerBypass({ url });
}
function isGatewayControlPlaneLoopbackHost(hostname: string): boolean {

View File

@@ -37,3 +37,34 @@ export function resolveUndiciAutoSelectFamilyConnectOptions():
| undefined {
return createUndiciAutoSelectFamilyConnectOptions(resolveUndiciAutoSelectFamily());
}
export function withTemporaryUndiciAutoSelectFamily<T>(
autoSelectFamily: boolean | undefined,
run: () => T,
): T {
if (
autoSelectFamily === undefined ||
typeof net.getDefaultAutoSelectFamily !== "function" ||
typeof net.setDefaultAutoSelectFamily !== "function"
) {
return run();
}
let previous: boolean;
try {
previous = net.getDefaultAutoSelectFamily();
net.setDefaultAutoSelectFamily(autoSelectFamily);
} catch {
return run();
}
try {
return run();
} finally {
try {
net.setDefaultAutoSelectFamily(previous);
} catch {
// Best-effort restore; dispatcher setup is already best-effort.
}
}
}

View File

@@ -6,11 +6,14 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
const {
Agent,
EnvHttpProxyAgent,
ManagedUndiciDispatcher,
ProxyAgent,
setGlobalDispatcher,
setCurrentDispatcher,
getCurrentDispatcher,
getDefaultAutoSelectFamily,
setDefaultAutoSelectFamily,
isProxylineDispatcher,
loadUndiciGlobalDispatcherDeps,
} = vi.hoisted(() => {
class Agent {
@@ -26,6 +29,37 @@ const {
constructor(public readonly url: string) {}
}
class ManagedUndiciDispatcher {
#closed = false;
#destroyed = false;
public readonly dispatchCalls: Array<Record<string, unknown>> = [];
public readonly requestCalls: Array<Record<string, unknown>> = [];
constructor(public readonly options?: Record<string, unknown>) {}
get closed(): boolean {
return this.#closed;
}
get destroyed(): boolean {
return this.#destroyed;
}
dispatch(options: Record<string, unknown>): boolean {
this.dispatchCalls.push(options);
return true;
}
request(options: Record<string, unknown>): boolean {
this.requestCalls.push(options);
return this.dispatch({ ...options, fromRequest: true });
}
on(): this {
return this;
}
close(): void {
this.#closed = true;
}
destroy(): void {
this.#destroyed = true;
}
}
let currentDispatcher: unknown = new Agent();
const getGlobalDispatcher = vi.fn(() => currentDispatcher);
@@ -37,6 +71,10 @@ const {
};
const getCurrentDispatcher = () => currentDispatcher;
const getDefaultAutoSelectFamily = vi.fn(() => undefined as boolean | undefined);
const setDefaultAutoSelectFamily = vi.fn();
const isProxylineDispatcher = vi.fn(
(dispatcher: unknown) => dispatcher instanceof ManagedUndiciDispatcher,
);
const loadUndiciGlobalDispatcherDeps = vi.fn(() => ({
Agent,
EnvHttpProxyAgent,
@@ -47,20 +85,34 @@ const {
return {
Agent,
EnvHttpProxyAgent,
ManagedUndiciDispatcher,
ProxyAgent,
getGlobalDispatcher,
setGlobalDispatcher,
setCurrentDispatcher,
getCurrentDispatcher,
getDefaultAutoSelectFamily,
isProxylineDispatcher,
setDefaultAutoSelectFamily,
loadUndiciGlobalDispatcherDeps,
};
});
const mockedModuleIds = ["node:net", "./proxy-env.js", "./undici-runtime.js", "../wsl.js"] as const;
const mockedModuleIds = [
"@openclaw/proxyline/dispatcher-brand",
"node:net",
"./proxy-env.js",
"./undici-runtime.js",
"../wsl.js",
] as const;
vi.mock("@openclaw/proxyline/dispatcher-brand", () => ({
isProxylineDispatcher,
}));
vi.mock("node:net", () => ({
getDefaultAutoSelectFamily,
setDefaultAutoSelectFamily,
}));
vi.mock("./proxy-env.js", () => ({
@@ -104,6 +156,7 @@ describe("ensureGlobalUndiciStreamTimeouts", () => {
resetGlobalUndiciStreamTimeoutsForTests();
setCurrentDispatcher(new Agent());
getDefaultAutoSelectFamily.mockReturnValue(undefined);
vi.mocked(isWSL2Sync).mockReturnValue(false);
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(false);
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue(undefined);
});
@@ -221,6 +274,242 @@ describe("ensureGlobalUndiciStreamTimeouts", () => {
expect(undiciGlobalDispatcherModule._globalUndiciStreamTimeoutMs).toBe(1_900_000);
});
it("wraps Proxyline managed dispatcher with timed dispatch options", () => {
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
const dispatcher = new ManagedUndiciDispatcher();
setCurrentDispatcher(dispatcher);
ensureGlobalUndiciDispatcherStreamTimeouts({ timeoutMs: 1_900_000 });
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
const next = getCurrentDispatcher() as {
constructor?: { name?: string };
dispatch: (options: Record<string, unknown>, handler: Record<string, unknown>) => boolean;
on: () => unknown;
request: (options: Record<string, unknown>) => boolean;
close: () => void;
destroy: () => void;
};
expect(next).not.toBe(dispatcher);
expect(next.constructor?.name).toBe("ManagedUndiciDispatcher");
expect(next.on()).toBe(next);
next.close();
next.destroy();
expect(dispatcher.closed).toBe(true);
expect(dispatcher.destroyed).toBe(true);
expect(next.request({ origin: "https://request.example.test", path: "/", method: "GET" })).toBe(
true,
);
expect(next.dispatch({ origin: "https://example.test", path: "/", method: "GET" }, {})).toBe(
true,
);
expect(dispatcher.requestCalls).toEqual([
{
origin: "https://request.example.test",
path: "/",
method: "GET",
},
]);
expect(dispatcher.dispatchCalls).toEqual([
{
origin: "https://request.example.test",
path: "/",
method: "GET",
fromRequest: true,
bodyTimeout: 1_900_000,
headersTimeout: 1_900_000,
allowH2: false,
},
{
origin: "https://example.test",
path: "/",
method: "GET",
bodyTimeout: 1_900_000,
headersTimeout: 1_900_000,
allowH2: false,
},
]);
expect(undiciGlobalDispatcherModule._globalUndiciStreamTimeoutMs).toBe(1_900_000);
});
it("replaces a fresh Proxyline managed dispatcher after env proxy timeouts were applied", () => {
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue({
httpProxy: "http://proxyline.example:3128",
httpsProxy: "http://proxyline.example:3128",
});
setCurrentDispatcher(new EnvHttpProxyAgent());
ensureGlobalUndiciDispatcherStreamTimeouts({ timeoutMs: 1_900_000 });
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
const dispatcher = new ManagedUndiciDispatcher();
setCurrentDispatcher(dispatcher);
ensureGlobalUndiciDispatcherStreamTimeouts({ timeoutMs: 1_900_000 });
expect(setGlobalDispatcher).toHaveBeenCalledTimes(2);
const next = getCurrentDispatcher() as {
constructor?: { name?: string };
dispatch: (options: Record<string, unknown>, handler: Record<string, unknown>) => boolean;
};
expect(next.constructor?.name).toBe("ManagedUndiciDispatcher");
next.dispatch({ origin: "https://example.test", path: "/", method: "GET" }, {});
expect(dispatcher.dispatchCalls).toEqual([
{
origin: "https://example.test",
path: "/",
method: "GET",
bodyTimeout: 1_900_000,
headersTimeout: 1_900_000,
allowH2: false,
},
]);
});
it("updates an existing Proxyline timeout wrapper when run timeout changes", () => {
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
const dispatcher = new ManagedUndiciDispatcher();
setCurrentDispatcher(dispatcher);
ensureGlobalUndiciDispatcherStreamTimeouts({ timeoutMs: 1_900_000 });
const wrapped = getCurrentDispatcher();
ensureGlobalUndiciDispatcherStreamTimeouts({ timeoutMs: 2_100_000 });
expect(setGlobalDispatcher).toHaveBeenCalledTimes(2);
expect(getCurrentDispatcher()).toBe(wrapped);
const next = getCurrentDispatcher() as {
dispatch: (options: Record<string, unknown>, handler: Record<string, unknown>) => boolean;
};
next.dispatch({ origin: "https://example.test", path: "/", method: "GET" }, {});
expect(dispatcher.dispatchCalls).toEqual([
{
origin: "https://example.test",
path: "/",
method: "GET",
bodyTimeout: 2_100_000,
headersTimeout: 2_100_000,
allowH2: false,
},
]);
});
it("wraps a replaced raw Proxyline dispatcher when timeout policy is unchanged", () => {
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
setCurrentDispatcher(new ManagedUndiciDispatcher());
ensureGlobalUndiciDispatcherStreamTimeouts({ timeoutMs: 1_900_000 });
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
const replacement = new ManagedUndiciDispatcher();
setCurrentDispatcher(replacement);
ensureGlobalUndiciDispatcherStreamTimeouts({ timeoutMs: 1_900_000 });
expect(setGlobalDispatcher).toHaveBeenCalledTimes(2);
const next = getCurrentDispatcher() as {
dispatch: (options: Record<string, unknown>, handler: Record<string, unknown>) => boolean;
};
next.dispatch({ origin: "https://example.test", path: "/", method: "GET" }, {});
expect(replacement.dispatchCalls).toEqual([
{
origin: "https://example.test",
path: "/",
method: "GET",
bodyTimeout: 1_900_000,
headersTimeout: 1_900_000,
allowH2: false,
},
]);
});
it("preserves concrete dispatch timeouts through the Proxyline timeout wrapper", () => {
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
const dispatcher = new ManagedUndiciDispatcher();
setCurrentDispatcher(dispatcher);
ensureGlobalUndiciDispatcherStreamTimeouts({ timeoutMs: 1_900_000 });
const next = getCurrentDispatcher() as {
dispatch: (options: Record<string, unknown>, handler: Record<string, unknown>) => boolean;
};
next.dispatch(
{
origin: "https://example.test",
path: "/",
method: "GET",
bodyTimeout: 12_000,
headersTimeout: 0,
},
{},
);
expect(dispatcher.dispatchCalls).toEqual([
{
origin: "https://example.test",
path: "/",
method: "GET",
bodyTimeout: 12_000,
headersTimeout: 0,
allowH2: false,
},
]);
});
it("fills null dispatch timeouts through the Proxyline timeout wrapper", () => {
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
const dispatcher = new ManagedUndiciDispatcher();
setCurrentDispatcher(dispatcher);
ensureGlobalUndiciDispatcherStreamTimeouts({ timeoutMs: 1_900_000 });
const next = getCurrentDispatcher() as {
dispatch: (options: Record<string, unknown>, handler: Record<string, unknown>) => boolean;
};
next.dispatch(
{
origin: "https://example.test",
path: "/",
method: "GET",
bodyTimeout: null,
headersTimeout: null,
},
{},
);
expect(dispatcher.dispatchCalls).toEqual([
{
origin: "https://example.test",
path: "/",
method: "GET",
bodyTimeout: 1_900_000,
headersTimeout: 1_900_000,
allowH2: false,
},
]);
});
it("temporarily applies the WSL2 family-selection policy around Proxyline dispatch", () => {
getDefaultAutoSelectFamily.mockReturnValue(true);
vi.mocked(isWSL2Sync).mockReturnValue(true);
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
const dispatcher = new ManagedUndiciDispatcher();
setCurrentDispatcher(dispatcher);
ensureGlobalUndiciDispatcherStreamTimeouts({ timeoutMs: 1_900_000 });
const next = getCurrentDispatcher() as {
dispatch: (options: Record<string, unknown>, handler: Record<string, unknown>) => boolean;
};
next.dispatch({ origin: "https://example.test", path: "/", method: "GET" }, {});
expect(setDefaultAutoSelectFamily).toHaveBeenNthCalledWith(1, false);
expect(setDefaultAutoSelectFamily).toHaveBeenNthCalledWith(2, true);
expect(dispatcher.dispatchCalls).toEqual([
{
origin: "https://example.test",
path: "/",
method: "GET",
bodyTimeout: 1_900_000,
headersTimeout: 1_900_000,
allowH2: false,
},
]);
});
it("is idempotent for unchanged dispatcher kind and network policy", () => {
getDefaultAutoSelectFamily.mockReturnValue(true);
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
@@ -293,6 +582,7 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
vi.clearAllMocks();
resetGlobalUndiciStreamTimeoutsForTests();
setCurrentDispatcher(new Agent());
vi.mocked(isWSL2Sync).mockReturnValue(false);
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(false);
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue(undefined);
});
@@ -336,6 +626,15 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
expect(setGlobalDispatcher).not.toHaveBeenCalled();
});
it("treats Proxyline managed dispatchers as already proxy-backed during bootstrap", () => {
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
setCurrentDispatcher(new ManagedUndiciDispatcher());
ensureGlobalUndiciEnvProxyDispatcher();
expect(setGlobalDispatcher).not.toHaveBeenCalled();
});
it("retries proxy bootstrap after an unsupported dispatcher later becomes a default Agent", () => {
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
setCurrentDispatcher(new ProxyAgent("http://proxy.test:8080"));
@@ -359,6 +658,30 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
});
it("reinstalls env proxy when resolved proxy options change", () => {
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue({
httpProxy: "http://old-proxy.example:3128",
httpsProxy: "http://old-proxy.example:3128",
});
ensureGlobalUndiciEnvProxyDispatcher();
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue({
httpProxy: "http://new-proxy.example:3128",
httpsProxy: "http://new-proxy.example:3128",
});
ensureGlobalUndiciEnvProxyDispatcher();
expect(setGlobalDispatcher).toHaveBeenCalledTimes(2);
expect((getCurrentDispatcher() as { options?: Record<string, unknown> }).options).toEqual({
httpProxy: "http://new-proxy.example:3128",
httpsProxy: "http://new-proxy.example:3128",
allowH2: false,
});
});
it("reinstalls env proxy if an external change later reverts the dispatcher to Agent", () => {
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
@@ -379,6 +702,7 @@ describe("forceResetGlobalDispatcher", () => {
resetGlobalUndiciStreamTimeoutsForTests();
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(false);
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue(undefined);
vi.mocked(isWSL2Sync).mockReturnValue(false);
});
it("does not import undici when proxy env is cleared", () => {
@@ -403,6 +727,9 @@ describe("forceResetGlobalDispatcher", () => {
expect(loadUndiciGlobalDispatcherDeps).toHaveBeenCalledTimes(1);
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
expect(getCurrentDispatcher()).toBeInstanceOf(Agent);
expect((getCurrentDispatcher() as { options?: Record<string, unknown> }).options).toEqual({
allowH2: false,
});
});
it("replaces a stale EnvHttpProxyAgent when restored proxy env is still configured", () => {
@@ -442,6 +769,21 @@ describe("forceResetGlobalDispatcher", () => {
allowH2: false,
});
});
it("preserves Proxyline managed dispatcher when requested", () => {
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue({
httpProxy: "http://proxy-a.example:8080",
httpsProxy: "http://proxy-a.example:8080",
});
const dispatcher = new ManagedUndiciDispatcher();
setCurrentDispatcher(dispatcher);
forceResetGlobalDispatcher({ preserveProxylineManaged: true });
expect(setGlobalDispatcher).not.toHaveBeenCalled();
expect(getCurrentDispatcher()).toBe(dispatcher);
});
});
afterAll(() => {

View File

@@ -1,7 +1,9 @@
import { isProxylineDispatcher } from "@openclaw/proxyline/dispatcher-brand";
import { hasEnvHttpProxyAgentConfigured, resolveEnvHttpProxyAgentOptions } from "./proxy-env.js";
import {
createUndiciAutoSelectFamilyConnectOptions,
resolveUndiciAutoSelectFamily,
withTemporaryUndiciAutoSelectFamily,
} from "./undici-family-policy.js";
import {
loadUndiciGlobalDispatcherDeps,
@@ -21,9 +23,101 @@ const HTTP1_ONLY_DISPATCHER_OPTIONS = Object.freeze({
export let _globalUndiciStreamTimeoutMs: number | undefined;
let lastAppliedTimeoutKey: string | null = null;
let lastAppliedProxyBootstrap = false;
let lastAppliedProxyBootstrapKey: string | null = null;
type DispatcherKind = "agent" | "env-proxy" | "unsupported";
type DispatcherKind = "agent" | "env-proxy" | "proxyline-managed" | "unsupported";
type SupportedDispatcherKind = Exclude<DispatcherKind, "unsupported">;
type UndiciDispatcher = Parameters<UndiciGlobalDispatcherDeps["setGlobalDispatcher"]>[0];
type UndiciDispatchOptions = Parameters<UndiciDispatcher["dispatch"]>[0];
type UndiciDispatchHandler = Parameters<UndiciDispatcher["dispatch"]>[1];
type CurrentDispatcherInfo = {
kind: SupportedDispatcherKind;
dispatcher: UndiciDispatcher;
};
type TimedProxylineManagedDispatcherState = {
autoSelectFamily: boolean | undefined;
timeoutMs: number;
dispatch: UndiciDispatcher["dispatch"];
};
const UNDICI_DISPATCH_HELPER_METHODS = new Set<PropertyKey>([
"compose",
"connect",
"pipeline",
"request",
"stream",
"upgrade",
]);
const UNDICI_DISPATCHER_LIFECYCLE_METHODS = new Set<PropertyKey>(["close", "destroy"]);
const timedProxylineManagedDispatchers = new WeakMap<
object,
TimedProxylineManagedDispatcherState
>();
function isTimedProxylineManagedDispatcher(dispatcher: unknown): dispatcher is UndiciDispatcher {
return typeof dispatcher === "object" && dispatcher !== null
? timedProxylineManagedDispatchers.has(dispatcher)
: false;
}
function withDefaultDispatchTimeout(
timeout: UndiciDispatchOptions["bodyTimeout"],
timeoutMs: number,
): UndiciDispatchOptions["bodyTimeout"] {
return timeout == null ? timeoutMs : timeout;
}
function createTimedProxylineManagedDispatcher(
dispatcher: UndiciDispatcher,
timeoutMs: number,
autoSelectFamily: boolean | undefined,
): UndiciDispatcher {
const existingState = timedProxylineManagedDispatchers.get(dispatcher);
if (existingState) {
existingState.autoSelectFamily = autoSelectFamily;
existingState.timeoutMs = timeoutMs;
return dispatcher;
}
const state: TimedProxylineManagedDispatcherState = {
autoSelectFamily,
timeoutMs,
dispatch(options: UndiciDispatchOptions, handler: UndiciDispatchHandler): boolean {
return withTemporaryUndiciAutoSelectFamily(state.autoSelectFamily, () =>
dispatcher.dispatch(
{
...options,
bodyTimeout: withDefaultDispatchTimeout(options.bodyTimeout, state.timeoutMs),
headersTimeout: withDefaultDispatchTimeout(options.headersTimeout, state.timeoutMs),
...HTTP1_ONLY_DISPATCHER_OPTIONS,
},
handler,
),
);
},
};
const proxy = new Proxy(dispatcher, {
get(target, property, receiver) {
if (property === "dispatch") {
return state.dispatch;
}
const value = Reflect.get(target, property, receiver);
if (typeof value !== "function") {
return value;
}
if (UNDICI_DISPATCHER_LIFECYCLE_METHODS.has(property)) {
return value.bind(target);
}
if (UNDICI_DISPATCH_HELPER_METHODS.has(property)) {
return (...args: unknown[]) => Reflect.apply(value, receiver, args);
}
return value;
},
});
timedProxylineManagedDispatchers.set(proxy, state);
return proxy;
}
function resolveDispatcherKind(dispatcher: unknown): DispatcherKind {
const ctorName = (dispatcher as { constructor?: { name?: string } })?.constructor?.name;
@@ -33,6 +127,9 @@ function resolveDispatcherKind(dispatcher: unknown): DispatcherKind {
if (ctorName.includes("EnvHttpProxyAgent")) {
return "env-proxy";
}
if (isTimedProxylineManagedDispatcher(dispatcher) || isProxylineDispatcher(dispatcher)) {
return "proxyline-managed";
}
if (ctorName.includes("ProxyAgent")) {
return "unsupported";
}
@@ -52,6 +149,24 @@ function resolveDispatcherKey(params: {
return `${params.kind}:${params.timeoutMs}:${autoSelectToken}`;
}
function resolveEnvProxyDispatcherOptions(): ConstructorParameters<
UndiciGlobalDispatcherDeps["EnvHttpProxyAgent"]
>[0] {
return {
...resolveEnvHttpProxyAgentOptions(),
...HTTP1_ONLY_DISPATCHER_OPTIONS,
} as ConstructorParameters<UndiciGlobalDispatcherDeps["EnvHttpProxyAgent"]>[0];
}
function resolveEnvProxyBootstrapKey(
options: ConstructorParameters<UndiciGlobalDispatcherDeps["EnvHttpProxyAgent"]>[0],
): string {
const entries = Object.entries((options ?? {}) as Record<string, unknown>)
.filter(([, value]) => value !== undefined)
.toSorted(([a], [b]) => a.localeCompare(b));
return JSON.stringify(entries);
}
function resolveStreamTimeoutMs(opts?: { timeoutMs?: number }): number | null {
const timeoutMsRaw = opts?.timeoutMs ?? DEFAULT_UNDICI_STREAM_TIMEOUT_MS;
if (!Number.isFinite(timeoutMsRaw)) {
@@ -62,7 +177,13 @@ function resolveStreamTimeoutMs(opts?: { timeoutMs?: number }): number | null {
function resolveCurrentDispatcherKind(
runtime: Pick<UndiciGlobalDispatcherDeps, "getGlobalDispatcher">,
): Exclude<DispatcherKind, "unsupported"> | null {
): SupportedDispatcherKind | null {
return resolveCurrentDispatcherInfo(runtime)?.kind ?? null;
}
function resolveCurrentDispatcherInfo(
runtime: Pick<UndiciGlobalDispatcherDeps, "getGlobalDispatcher">,
): CurrentDispatcherInfo | null {
let dispatcher: unknown;
try {
dispatcher = runtime.getGlobalDispatcher();
@@ -71,7 +192,13 @@ function resolveCurrentDispatcherKind(
}
const currentKind = resolveDispatcherKind(dispatcher);
return currentKind === "unsupported" ? null : currentKind;
if (currentKind === "unsupported") {
return null;
}
return {
kind: currentKind,
dispatcher: dispatcher as UndiciDispatcher,
};
}
export function ensureGlobalUndiciEnvProxyDispatcher(): void {
@@ -81,28 +208,26 @@ export function ensureGlobalUndiciEnvProxyDispatcher(): void {
}
const runtime = loadUndiciGlobalDispatcherDeps();
const { EnvHttpProxyAgent, setGlobalDispatcher } = runtime;
if (lastAppliedProxyBootstrap) {
if (resolveCurrentDispatcherKind(runtime) === "env-proxy") {
return;
}
lastAppliedProxyBootstrap = false;
}
const proxyOptions = resolveEnvProxyDispatcherOptions();
const nextBootstrapKey = resolveEnvProxyBootstrapKey(proxyOptions);
const currentKind = resolveCurrentDispatcherKind(runtime);
if (currentKind === null) {
return;
}
if (currentKind === "env-proxy") {
lastAppliedProxyBootstrap = true;
if (currentKind === "proxyline-managed") {
lastAppliedProxyBootstrapKey = nextBootstrapKey;
return;
}
if (currentKind === "env-proxy" && lastAppliedProxyBootstrapKey === null) {
lastAppliedProxyBootstrapKey = nextBootstrapKey;
return;
}
if (currentKind === "env-proxy" && lastAppliedProxyBootstrapKey === nextBootstrapKey) {
return;
}
try {
setGlobalDispatcher(
new EnvHttpProxyAgent({
...resolveEnvHttpProxyAgentOptions(),
...HTTP1_ONLY_DISPATCHER_OPTIONS,
}),
);
lastAppliedProxyBootstrap = true;
setGlobalDispatcher(new EnvHttpProxyAgent(proxyOptions));
lastAppliedProxyBootstrapKey = nextBootstrapKey;
} catch {
// Best-effort bootstrap only.
}
@@ -110,19 +235,30 @@ export function ensureGlobalUndiciEnvProxyDispatcher(): void {
function applyGlobalDispatcherStreamTimeouts(params: {
runtime: UndiciGlobalDispatcherDeps;
kind: Exclude<DispatcherKind, "unsupported">;
dispatcher: UndiciDispatcher;
kind: SupportedDispatcherKind;
timeoutMs: number;
}): void {
const { runtime, kind, timeoutMs } = params;
const { runtime, dispatcher, kind, timeoutMs } = params;
const autoSelectFamily = resolveUndiciAutoSelectFamily();
const nextKey = resolveDispatcherKey({ kind, timeoutMs, autoSelectFamily });
if (lastAppliedTimeoutKey === nextKey) {
const nextKey = resolveDispatcherKey({
kind,
timeoutMs,
autoSelectFamily,
});
const needsProxylineWrapper =
kind === "proxyline-managed" && !isTimedProxylineManagedDispatcher(dispatcher);
if (lastAppliedTimeoutKey === nextKey && !needsProxylineWrapper) {
return;
}
const connect = createUndiciAutoSelectFamilyConnectOptions(autoSelectFamily);
try {
if (kind === "env-proxy") {
if (kind === "proxyline-managed") {
runtime.setGlobalDispatcher(
createTimedProxylineManagedDispatcher(dispatcher, timeoutMs, autoSelectFamily),
);
} else if (kind === "env-proxy") {
const proxyOptions = {
...resolveEnvHttpProxyAgentOptions(),
bodyTimeout: timeoutMs,
@@ -158,15 +294,20 @@ export function ensureGlobalUndiciStreamTimeouts(opts?: { timeoutMs?: number }):
return;
}
const runtime = loadUndiciGlobalDispatcherDeps();
const kind = resolveCurrentDispatcherKind(runtime);
if (kind === null) {
const current = resolveCurrentDispatcherInfo(runtime);
if (current === null) {
return;
}
if (kind !== "env-proxy") {
if (current.kind !== "env-proxy" && current.kind !== "proxyline-managed") {
return;
}
applyGlobalDispatcherStreamTimeouts({ runtime, kind, timeoutMs });
applyGlobalDispatcherStreamTimeouts({
runtime,
dispatcher: current.dispatcher,
kind: current.kind,
timeoutMs,
});
}
export function ensureGlobalUndiciDispatcherStreamTimeouts(opts?: { timeoutMs?: number }): void {
@@ -176,30 +317,36 @@ export function ensureGlobalUndiciDispatcherStreamTimeouts(opts?: { timeoutMs?:
}
_globalUndiciStreamTimeoutMs = timeoutMs;
const runtime = loadUndiciGlobalDispatcherDeps();
const kind = resolveCurrentDispatcherKind(runtime);
if (kind === null) {
const current = resolveCurrentDispatcherInfo(runtime);
if (current === null) {
return;
}
applyGlobalDispatcherStreamTimeouts({ runtime, kind, timeoutMs });
applyGlobalDispatcherStreamTimeouts({
runtime,
dispatcher: current.dispatcher,
kind: current.kind,
timeoutMs,
});
}
export function resetGlobalUndiciStreamTimeoutsForTests(): void {
lastAppliedTimeoutKey = null;
lastAppliedProxyBootstrap = false;
lastAppliedProxyBootstrapKey = null;
_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.
* Re-evaluate proxy env changes for root undici imports. Installs
* EnvHttpProxyAgent when proxy env is present, and restores a direct Agent
* after proxy env is cleared.
*/
export function forceResetGlobalDispatcher(): void {
export function forceResetGlobalDispatcher(opts?: { preserveProxylineManaged?: boolean }): void {
lastAppliedTimeoutKey = null;
if (!hasEnvHttpProxyAgentConfigured()) {
if (!lastAppliedProxyBootstrap) {
if (lastAppliedProxyBootstrapKey === null) {
return;
}
lastAppliedProxyBootstrap = false;
lastAppliedProxyBootstrapKey = null;
try {
const { Agent, setGlobalDispatcher } = loadUndiciGlobalDispatcherDeps();
setGlobalDispatcher(new Agent(HTTP1_ONLY_DISPATCHER_OPTIONS));
@@ -208,17 +355,19 @@ export function forceResetGlobalDispatcher(): void {
}
return;
}
lastAppliedProxyBootstrap = false;
try {
const { EnvHttpProxyAgent, setGlobalDispatcher } = loadUndiciGlobalDispatcherDeps();
const proxyOptions = resolveEnvHttpProxyAgentOptions();
setGlobalDispatcher(
new EnvHttpProxyAgent({
...proxyOptions,
...HTTP1_ONLY_DISPATCHER_OPTIONS,
} as ConstructorParameters<UndiciGlobalDispatcherDeps["EnvHttpProxyAgent"]>[0]),
);
lastAppliedProxyBootstrap = true;
const runtime = loadUndiciGlobalDispatcherDeps();
const { EnvHttpProxyAgent, setGlobalDispatcher } = runtime;
const proxyOptions = resolveEnvProxyDispatcherOptions();
if (opts?.preserveProxylineManaged) {
const current = resolveCurrentDispatcherInfo(runtime);
if (current?.kind === "proxyline-managed") {
lastAppliedProxyBootstrapKey = resolveEnvProxyBootstrapKey(proxyOptions);
return;
}
}
setGlobalDispatcher(new EnvHttpProxyAgent(proxyOptions));
lastAppliedProxyBootstrapKey = resolveEnvProxyBootstrapKey(proxyOptions);
} catch {
// Best-effort reset only.
}

View File

@@ -1,28 +0,0 @@
/**
* 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;
};
}