fix(gateway): bypass proxies for localhost control plane

This commit is contained in:
Peter Steinberger
2026-04-29 11:59:22 +01:00
parent af31fc938a
commit bdcd543ed7
6 changed files with 184 additions and 39 deletions

View File

@@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai
- Gateway: expose `gateway.handshakeTimeoutMs` in config, schema, and docs while preserving `OPENCLAW_HANDSHAKE_TIMEOUT_MS` precedence, so loaded or low-powered hosts can tune local WebSocket pre-auth handshakes without patching dist files. Supersedes #51282; refs #73592 and #73652. Thanks @henry-the-frog.
- Gateway/TUI/status: align configured and env-based WebSocket handshake budgets across local clients, probes, and fallback RPCs while preserving explicit status timeouts and paired-device auth fallback, so slow local gateways are not marked unreachable by a shorter client watchdog. Refs #73524, #73535, #73592, and #73602. Thanks @harshcatsystems-collab, @DJBlackhawk, and @Vksh07.
- Gateway/startup: return retryable `UNAVAILABLE` during the sidecar startup window and keep CLI/TUI/status clients retrying inside their existing timeout budget, so early connects no longer surface as terminal handshake failures. Fixes #73652. Thanks @spenceryang1996-dot.
- Gateway/proxy: bypass inherited proxy environment for local Gateway control-plane WebSockets to `localhost` as well as loopback IPs, so Windows/WSL proxy settings cannot intercept local CLI/TUI Gateway connections. Supersedes #73474; refs #73602. Thanks @DhtIsCoding.
- Doctor/Gateway: use a lightweight `status` RPC without channel summary work for doctor Gateway liveness, so slow health snapshots do not falsely drive service restart repair. Fixes #64400; supersedes #64511. Thanks @CHE10X and @EronFan.
- Agents/auth: scope external CLI credential discovery to configured providers during model auth status and startup prewarm, so opencode-only and other single-provider gateways do not block on unrelated Claude CLI Keychain probes. Fixes #73908. Thanks @Ailuras.
- Agents/model selection: resolve slash-form aliases before provider/model parsing and keep alias-resolved primary models subject to transient provider cooldowns, so cron and persisted sessions do not retry cooled-down raw aliases. Fixes #73573 and #73657. Thanks @akai-shuuichi and @hashslingers.

View File

@@ -36,7 +36,7 @@ OpenClaw process
WebSocket clients -> operator-managed filtering proxy -> public internet
```
The public contract is the routing behavior, not the internal Node hooks used to implement it. OpenClaw Gateway control-plane WebSocket clients use a narrow direct path for local loopback Gateway RPC traffic when the Gateway URL uses a literal loopback IP such as `127.0.0.1` or `[::1]`. That control-plane path must be able to reach loopback Gateways even when the operator proxy blocks loopback destinations. Normal runtime HTTP and WebSocket requests still use the configured proxy.
The 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.
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`.
@@ -150,6 +150,6 @@ proxy:
- The proxy improves coverage for process-local JavaScript HTTP and WebSocket clients, but it does not replace application-level `fetchWithSsrFGuard`.
- Raw `net`, `tls`, and `http2` sockets, native addons, and child processes may bypass Node-level proxy routing unless they inherit and respect proxy environment variables.
- User local WebUIs and local model servers should be allowlisted in the operator proxy policy when needed; OpenClaw does not expose a general local-network bypass for them.
- Gateway control-plane proxy bypass is intentionally limited to literal loopback IP URLs. Use `ws://127.0.0.1:18789` or `ws://[::1]:18789` for local direct Gateway control-plane connections; `localhost` hostnames route like ordinary hostname-based traffic.
- Gateway control-plane proxy bypass is intentionally limited to `localhost` and literal loopback IP URLs. Use `ws://127.0.0.1:18789`, `ws://[::1]:18789`, or `ws://localhost:18789` for local direct Gateway control-plane connections; other hostnames route like ordinary hostname-based traffic.
- OpenClaw does not inspect, test, or certify your proxy policy.
- Treat proxy policy changes as security-sensitive operational changes.

View File

@@ -17,7 +17,6 @@ import { dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane } from "../
import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
import { rawDataToString } from "../infra/ws.js";
import { logDebug, logError } from "../logger.js";
import { isLoopbackIpAddress } from "../shared/net/ip.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -101,7 +100,7 @@ function createDirectGatewayAgent(url: string): http.Agent | https.Agent | undef
} catch {
return undefined;
}
if (!isLoopbackIpAddress(hostname)) {
if (!isLoopbackHost(hostname)) {
return undefined;
}
return url.startsWith("wss://") ? new https.Agent() : new http.Agent();

View File

@@ -105,12 +105,12 @@ describe("GatewayClient", () => {
expect(last?.opts.agent).toBeDefined();
});
test("does not use the direct control-plane bypass for localhost hostnames", () => {
test("uses the direct control-plane bypass for localhost hostnames", () => {
const client = new GatewayClient({ url: "ws://localhost:1" });
client.start();
const last = wsMockState.last as { opts: { agent?: unknown } } | null;
expect(last?.opts.agent).toBeUndefined();
expect(last?.opts.agent).toBeDefined();
});
test("does not force a direct agent for remote Gateway WebSocket connections", () => {

View File

@@ -38,6 +38,8 @@ describe("startProxy", () => {
"https_proxy",
"HTTP_PROXY",
"HTTPS_PROXY",
"all_proxy",
"ALL_PROXY",
"no_proxy",
"NO_PROXY",
"GLOBAL_AGENT_HTTP_PROXY",
@@ -378,7 +380,7 @@ describe("startProxy", () => {
await stopProxy(handle);
});
it("allows the Gateway control-plane bypass for literal loopback IPs only", () => {
it("allows the Gateway control-plane bypass for literal loopback IPs and localhost", () => {
expect(
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
"ws://127.0.0.1:18789",
@@ -388,12 +390,18 @@ describe("startProxy", () => {
expect(
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane("ws://[::1]:18789", () => "ok"),
).toBe("ok");
expect(() =>
expect(
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
"ws://localhost:18789",
() => undefined,
() => "ok",
),
).toThrow("loopback-only");
).toBe("ok");
expect(
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
"ws://localhost.:18789",
() => "ok",
),
).toBe("ok");
});
it("rejects dangerous Gateway control-plane bypass for non-loopback URLs", () => {
@@ -405,6 +413,92 @@ describe("startProxy", () => {
).toThrow("loopback-only");
});
it("temporarily clears inherited proxy env for Gateway control-plane setup", () => {
process.env["http_proxy"] = "http://lower-http.example.com:8080";
process.env["https_proxy"] = "http://lower-https.example.com:8080";
process.env["HTTP_PROXY"] = "http://upper-http.example.com:8080";
process.env["HTTPS_PROXY"] = "http://upper-https.example.com:8080";
process.env["all_proxy"] = "http://lower-all.example.com:8080";
process.env["ALL_PROXY"] = "http://upper-all.example.com:8080";
process.env["NO_PROXY"] = "localhost";
process.env["no_proxy"] = "127.0.0.1";
process.env["GLOBAL_AGENT_HTTP_PROXY"] = "http://global-http.example.com:8080";
process.env["GLOBAL_AGENT_HTTPS_PROXY"] = "http://global-https.example.com:8080";
process.env["GLOBAL_AGENT_NO_PROXY"] = "localhost";
process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"] = "true";
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
const during = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
"ws://localhost:18789",
() => ({
httpProxy: process.env["HTTP_PROXY"],
httpsProxy: process.env["HTTPS_PROXY"],
allProxy: process.env["ALL_PROXY"],
lowerAllProxy: process.env["all_proxy"],
noProxy: process.env["NO_PROXY"],
globalProxy: process.env["GLOBAL_AGENT_HTTP_PROXY"],
proxyActive: process.env["OPENCLAW_PROXY_ACTIVE"],
}),
);
expect(during).toEqual({
httpProxy: undefined,
httpsProxy: undefined,
allProxy: undefined,
lowerAllProxy: undefined,
noProxy: undefined,
globalProxy: undefined,
proxyActive: undefined,
});
expect(process.env["HTTP_PROXY"]).toBe("http://upper-http.example.com:8080");
expect(process.env["HTTPS_PROXY"]).toBe("http://upper-https.example.com:8080");
expect(process.env["ALL_PROXY"]).toBe("http://upper-all.example.com:8080");
expect(process.env["all_proxy"]).toBe("http://lower-all.example.com:8080");
expect(process.env["NO_PROXY"]).toBe("localhost");
expect(process.env["GLOBAL_AGENT_HTTP_PROXY"]).toBe("http://global-http.example.com:8080");
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
});
it("temporarily clears managed proxy env while restoring the original HTTP stack", async () => {
const patchedHttpRequest = vi.fn() as unknown as typeof http.request;
mockBootstrapGlobalAgent.mockImplementationOnce(() => {
http.request = patchedHttpRequest;
(global as Record<string, unknown>)["GLOBAL_AGENT"] = {
HTTP_PROXY: "",
HTTPS_PROXY: "",
};
});
const handle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
});
process.env["ALL_PROXY"] = "http://inherited-all.example.com:8080";
const during = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
"ws://127.0.0.1:18789",
() => ({
httpRequest: http.request,
httpProxy: process.env["HTTP_PROXY"],
allProxy: process.env["ALL_PROXY"],
proxyActive: process.env["OPENCLAW_PROXY_ACTIVE"],
}),
);
expect(during).toEqual({
httpRequest: originalHttpRequest,
httpProxy: undefined,
allProxy: undefined,
proxyActive: undefined,
});
expect(http.request).toBe(patchedHttpRequest);
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128");
expect(process.env["ALL_PROXY"]).toBe("http://inherited-all.example.com:8080");
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
await stopProxy(handle);
});
it("kill restores env synchronously during hard process exit", async () => {
process.env["NO_PROXY"] = "corp.example.com";
const handle = await startProxy({

View File

@@ -40,8 +40,19 @@ const ALL_PROXY_ENV_KEYS = [
...NO_PROXY_ENV_KEYS,
...PROXY_ACTIVE_KEYS,
] as const;
const GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS = [
...ALL_PROXY_ENV_KEYS,
"all_proxy",
"ALL_PROXY",
] as const;
type ProxyEnvKey = (typeof ALL_PROXY_ENV_KEYS)[number];
type ProxyEnvSnapshot = Record<ProxyEnvKey, string | undefined>;
type GatewayControlPlaneProxyBypassEnvKey =
(typeof GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS)[number];
type GatewayControlPlaneProxyBypassEnvSnapshot = Record<
GatewayControlPlaneProxyBypassEnvKey,
string | undefined
>;
type NodeHttpStackSnapshot = {
httpRequest: typeof http.request;
httpGet: typeof http.get;
@@ -116,6 +127,39 @@ function restoreProxyEnv(snapshot: ProxyEnvSnapshot): void {
}
}
function captureGatewayControlPlaneProxyBypassEnv(): GatewayControlPlaneProxyBypassEnvSnapshot {
const snapshot = {} as GatewayControlPlaneProxyBypassEnvSnapshot;
for (const key of GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS) {
snapshot[key] = process.env[key];
}
return snapshot;
}
function restoreGatewayControlPlaneProxyBypassEnv(
snapshot: GatewayControlPlaneProxyBypassEnvSnapshot,
): void {
for (const key of GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS) {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
function withoutGatewayControlPlaneProxyEnv<T>(run: () => T): T {
const snapshot = captureGatewayControlPlaneProxyBypassEnv();
for (const key of GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS) {
delete process.env[key];
}
try {
return run();
} finally {
restoreGatewayControlPlaneProxyBypassEnv(snapshot);
}
}
function restoreGlobalAgentRuntime(snapshot: ProxyEnvSnapshot): void {
if (
typeof global === "undefined" ||
@@ -371,7 +415,12 @@ function isGatewayLoopbackControlPlaneUrl(value: string): boolean {
) {
return false;
}
return isLoopbackIpAddress(url.hostname);
return isGatewayControlPlaneLoopbackHost(url.hostname);
}
function isGatewayControlPlaneLoopbackHost(hostname: string): boolean {
const normalizedHost = hostname.trim().toLowerCase().replace(/\.+$/, "");
return normalizedHost === "localhost" || isLoopbackIpAddress(hostname);
}
export function dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane<T>(
@@ -384,38 +433,40 @@ export function dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane<T>(
const snapshot = nodeHttpStackSnapshot;
if (!snapshot) {
return run();
return withoutGatewayControlPlaneProxyEnv(run);
}
// Security-sensitive: this temporarily removes managed proxy hooks for the
// synchronous Gateway loopback WebSocket constructor only. Do not reuse this
// helper for provider, plugin, user WebUI, model server, or arbitrary egress.
const activeStack = captureNodeHttpStack();
const globalRecord = global as Record<string, unknown>;
try {
http.request = snapshot.httpRequest;
http.get = snapshot.httpGet;
http.globalAgent = snapshot.httpGlobalAgent;
https.request = snapshot.httpsRequest;
https.get = snapshot.httpsGet;
https.globalAgent = snapshot.httpsGlobalAgent;
if (snapshot.hadGlobalAgent) {
globalRecord["GLOBAL_AGENT"] = snapshot.globalAgent;
} else {
delete globalRecord["GLOBAL_AGENT"];
return withoutGatewayControlPlaneProxyEnv(() => {
const activeStack = captureNodeHttpStack();
const globalRecord = global as Record<string, unknown>;
try {
http.request = snapshot.httpRequest;
http.get = snapshot.httpGet;
http.globalAgent = snapshot.httpGlobalAgent;
https.request = snapshot.httpsRequest;
https.get = snapshot.httpsGet;
https.globalAgent = snapshot.httpsGlobalAgent;
if (snapshot.hadGlobalAgent) {
globalRecord["GLOBAL_AGENT"] = snapshot.globalAgent;
} else {
delete globalRecord["GLOBAL_AGENT"];
}
return run();
} finally {
http.request = activeStack.httpRequest;
http.get = activeStack.httpGet;
http.globalAgent = activeStack.httpGlobalAgent;
https.request = activeStack.httpsRequest;
https.get = activeStack.httpsGet;
https.globalAgent = activeStack.httpsGlobalAgent;
if (activeStack.hadGlobalAgent) {
globalRecord["GLOBAL_AGENT"] = activeStack.globalAgent;
} else {
delete globalRecord["GLOBAL_AGENT"];
}
}
return run();
} finally {
http.request = activeStack.httpRequest;
http.get = activeStack.httpGet;
http.globalAgent = activeStack.httpGlobalAgent;
https.request = activeStack.httpsRequest;
https.get = activeStack.httpsGet;
https.globalAgent = activeStack.httpsGlobalAgent;
if (activeStack.hadGlobalAgent) {
globalRecord["GLOBAL_AGENT"] = activeStack.globalAgent;
} else {
delete globalRecord["GLOBAL_AGENT"];
}
}
});
}