diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6b9e62d2f..7e5cd0de02d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ Docs: https://docs.openclaw.ai - Contributor PRs: require external pull requests to include after-fix real behavior proof from a real OpenClaw setup, with terminal screenshots, console output, redacted runtime logs, linked artifacts, and copied live output treated as valid evidence while unit tests, mocks, lint, typechecks, snapshots, and CI remain supplemental only. - Plugins/catalog: add an `@tencent-weixin/openclaw-weixin` external entry pinned to `2.4.1` so onboarding and `openclaw channels add` can install the Tencent Weixin (personal WeChat) channel by default. (#77269) Thanks @pumpkinxing1. - Developer tooling: add checked-in VS Code Gateway debugging configs and an opt-in `OUTPUT_SOURCE_MAPS=1` source-map build path for breakpoints in TypeScript source. (#45710) Thanks @SwissArmyBud. +- Managed proxy: add `proxy.loopbackMode` for Gateway loopback control-plane traffic, allowing operators to keep the default Gateway loopback bypass, force loopback Gateway traffic through the proxy, or block it. (#77018) Thanks @jesse-merhi. ### Fixes diff --git a/docs/security/network-proxy.md b/docs/security/network-proxy.md index 190c9df8895..04fc076a80b 100644 --- a/docs/security/network-proxy.md +++ b/docs/security/network-proxy.md @@ -75,6 +75,21 @@ OPENCLAW_PROXY_URL=http://127.0.0.1:3128 openclaw gateway run `proxy.proxyUrl` takes precedence over `OPENCLAW_PROXY_URL`. +### Gateway Loopback Mode + +Local Gateway control-plane clients usually connect to a loopback WebSocket such as `ws://127.0.0.1:18789`. Use `proxy.loopbackMode` to choose how that traffic behaves while the managed proxy is active: + +```yaml +proxy: + enabled: true + proxyUrl: http://127.0.0.1:3128 + 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. +- `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. For managed gateway services started with `openclaw gateway start`, prefer storing the URL in config: @@ -199,7 +214,8 @@ proxy: ## Limits - The proxy improves coverage for process-local JavaScript HTTP and WebSocket clients, but it is not an OS-level network sandbox. -- 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. +- 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. +- 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. - 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. diff --git a/src/acp/server.startup.test.ts b/src/acp/server.startup.test.ts index acadc23e427..7c10f761c3d 100644 --- a/src/acp/server.startup.test.ts +++ b/src/acp/server.startup.test.ts @@ -15,10 +15,15 @@ type ResolveGatewayClientBootstrap = (params: unknown) => Promise<{ urlSource: string; auth: GatewayClientAuth; }>; +type GatewayClientOptions = GatewayClientCallbacks & + GatewayClientAuth & { + url?: string; + }; const mockState = vi.hoisted(() => ({ gateways: [] as MockGatewayClient[], gatewayAuth: [] as GatewayClientAuth[], + gatewayOptions: [] as GatewayClientOptions[], agentSideConnectionCtor: vi.fn(), agentStart: vi.fn(), routeLogsToStderr: vi.fn(), @@ -37,8 +42,9 @@ const mockState = vi.hoisted(() => ({ class MockGatewayClient { private callbacks: GatewayClientCallbacks; - constructor(opts: GatewayClientCallbacks & GatewayClientAuth) { + constructor(opts: GatewayClientOptions) { this.callbacks = opts; + mockState.gatewayOptions.push(opts); mockState.gatewayAuth.push({ token: opts.token, password: opts.password }); mockState.gateways.push(this); } @@ -196,6 +202,7 @@ describe("serveAcpGateway startup", () => { beforeEach(async () => { mockState.gateways.length = 0; mockState.gatewayAuth.length = 0; + mockState.gatewayOptions.length = 0; mockState.agentSideConnectionCtor.mockReset(); mockState.agentStart.mockReset(); mockState.routeLogsToStderr.mockReset(); @@ -324,6 +331,36 @@ describe("serveAcpGateway startup", () => { } }); + it("passes the configured Gateway URL into the ACP gateway client", async () => { + mockState.resolveGatewayClientBootstrap.mockResolvedValue({ + url: "ws://127.0.0.1:19999", + urlSource: "cli --url", + auth: { + token: undefined, + password: undefined, + }, + }); + const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); + + try { + const servePromise = serveAcpGateway({ + gatewayUrl: "ws://127.0.0.1:19999", + }); + await Promise.resolve(); + + expect(mockState.gatewayOptions[0]).toEqual( + expect.objectContaining({ + url: "ws://127.0.0.1:19999", + }), + ); + + await emitHelloAndWaitForAgentSideConnection(); + await stopServeWithSigint(signalHandlers, servePromise); + } finally { + onceSpy.mockRestore(); + } + }); + it("does not proxy the standalone ACP control-plane Gateway connection", async () => { const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); diff --git a/src/config/zod-schema.proxy.test.ts b/src/config/zod-schema.proxy.test.ts index 93ab96eafed..6cb4f403ee9 100644 --- a/src/config/zod-schema.proxy.test.ts +++ b/src/config/zod-schema.proxy.test.ts @@ -14,13 +14,27 @@ describe("ProxyConfigSchema", () => { const result = ProxyConfigSchema.parse({ enabled: true, proxyUrl: "http://127.0.0.1:3128", + loopbackMode: "gateway-only", }); expect(result).toMatchObject({ enabled: true, proxyUrl: "http://127.0.0.1:3128", + loopbackMode: "gateway-only", }); }); + it("accepts loopbackMode policy values", () => { + expect(ProxyConfigSchema.parse({ loopbackMode: "gateway-only" })?.loopbackMode).toBe( + "gateway-only", + ); + expect(ProxyConfigSchema.parse({ loopbackMode: "proxy" })?.loopbackMode).toBe("proxy"); + expect(ProxyConfigSchema.parse({ loopbackMode: "block" })?.loopbackMode).toBe("block"); + }); + + it("rejects unknown loopbackMode values", () => { + expect(() => ProxyConfigSchema.parse({ loopbackMode: "bypass" })).toThrow(); + }); + it("rejects HTTPS proxy URLs because the node:http routing layer requires HTTP proxies", () => { expect(() => ProxyConfigSchema.parse({ diff --git a/src/config/zod-schema.proxy.ts b/src/config/zod-schema.proxy.ts index 3e4bb0ddb6f..81e1b260e63 100644 --- a/src/config/zod-schema.proxy.ts +++ b/src/config/zod-schema.proxy.ts @@ -10,17 +10,19 @@ function isHttpProxyUrl(value: string): boolean { } } +export const ProxyLoopbackModeSchema = z.enum(["gateway-only", "proxy", "block"]); + export const ProxyConfigSchema = z .object({ enabled: z.boolean().optional(), proxyUrl: z - .string() .url() .refine(isHttpProxyUrl, { message: "proxyUrl must use http://", }) .register(sensitive) .optional(), + loopbackMode: ProxyLoopbackModeSchema.optional(), }) .strict() .optional(); diff --git a/src/gateway/client-bootstrap.test.ts b/src/gateway/client-bootstrap.test.ts index 4dd3a70a947..3900608026c 100644 --- a/src/gateway/client-bootstrap.test.ts +++ b/src/gateway/client-bootstrap.test.ts @@ -5,8 +5,8 @@ const mockState = vi.hoisted(() => ({ resolveGatewayConnectionAuth: vi.fn(), })); -vi.mock("./call.js", () => ({ - buildGatewayConnectionDetails: (...args: unknown[]) => +vi.mock("./connection-details.js", () => ({ + buildGatewayConnectionDetailsWithResolvers: (...args: unknown[]) => mockState.buildGatewayConnectionDetails(...args), })); @@ -37,7 +37,7 @@ describe("resolveGatewayClientBootstrap", () => { }); it("passes cli override context into shared auth resolution", async () => { - mockState.buildGatewayConnectionDetails.mockReturnValue({ + mockState.buildGatewayConnectionDetails.mockReturnValueOnce({ url: "wss://override.example/ws", urlSource: "cli --url", }); diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 98c96246f78..1571ee5c7bd 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -33,8 +33,10 @@ class MockWebSocket { terminateCalls = 0; autoCloseOnClose = true; readyState = MockWebSocket.CONNECTING; + readonly options: unknown; - constructor(_url: string, _options?: unknown) { + constructor(_url: string, options?: unknown) { + this.options = options; wsInstances.push(this); } @@ -179,17 +181,36 @@ beforeAll(async () => { }); describe("GatewayClient security checks", () => { - const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]); + const envSnapshot = captureEnv([ + "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS", + "OPENCLAW_PROXY_ACTIVE", + "OPENCLAW_PROXY_LOOPBACK_MODE", + "HTTP_PROXY", + "GLOBAL_AGENT_HTTP_PROXY", + "GLOBAL_AGENT_FORCE_GLOBAL_AGENT", + ]); beforeEach(() => { 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)["GLOBAL_AGENT"]; wsInstances.length = 0; }); afterEach(() => { 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)["GLOBAL_AGENT"]; }); it("blocks ws:// to non-loopback addresses (CWE-319)", () => { @@ -232,9 +253,82 @@ describe("GatewayClient security checks", () => { expect(onConnectError).not.toHaveBeenCalled(); expect(wsInstances.length).toBe(1); // WebSocket created + expect(getLatestWs().options).not.toHaveProperty("agent"); client.stop(); }); + it("bootstraps inherited managed proxy routing before proxy-mode loopback WebSocket creation", () => { + 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", + onConnectError, + }); + + client.start(); + + expect(onConnectError).not.toHaveBeenCalled(); + expect(wsInstances.length).toBe(1); + expect(getLatestWs().options).not.toMatchObject({ agent: expect.any(Object) }); + expect((global as Record)["GLOBAL_AGENT"]).toEqual( + expect.objectContaining({ + HTTP_PROXY: "http://127.0.0.1:3128", + HTTPS_PROXY: "http://127.0.0.1:3128", + }), + ); + client.stop(); + }); + + it("proxies ws:// loopback addresses when active proxy loopbackMode is proxy", async () => { + const { startProxy, stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js"); + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + loopbackMode: "proxy", + }); + const onConnectError = vi.fn(); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + onConnectError, + }); + + try { + client.start(); + + expect(onConnectError).not.toHaveBeenCalled(); + expect(wsInstances.length).toBe(1); + expect(getLatestWs().options).not.toMatchObject({ agent: expect.any(Object) }); + } finally { + client.stop(); + await stopProxy(handle); + } + }); + + it("blocks ws:// loopback addresses when active proxy loopbackMode is block", async () => { + const { startProxy, stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js"); + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + loopbackMode: "block", + }); + const onConnectError = vi.fn(); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + onConnectError, + }); + + try { + expect(() => client.start()).toThrow("blocked by proxy.loopbackMode"); + expect(wsInstances.length).toBe(0); + } finally { + client.stop(); + await stopProxy(handle); + } + }); + it("allows wss:// to any address", () => { const onConnectError = vi.fn(); const client = new GatewayClient({ diff --git a/src/gateway/client.ts b/src/gateway/client.ts index f83291bff35..717d9868873 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -1,6 +1,4 @@ import { randomUUID } from "node:crypto"; -import http from "node:http"; -import https from "node:https"; import { WebSocket, type ClientOptions, type CertMeta } from "ws"; import { clearDeviceAuthToken, @@ -13,7 +11,10 @@ import { publicKeyRawBase64UrlFromPem, signDevicePayload, } from "../infra/device-identity.js"; -import { dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane } from "../infra/net/proxy/proxy-lifecycle.js"; +import { + ensureInheritedManagedProxyRoutingActive, + withManagedProxyGatewayLoopbackRouting, +} from "../infra/net/proxy/proxy-lifecycle.js"; import { normalizeFingerprint } from "../infra/tls/fingerprint.js"; import { rawDataToString } from "../infra/ws.js"; import { logDebug, logError } from "../logger.js"; @@ -87,25 +88,14 @@ type FingerprintCheckingClientOptions = Omit Error | undefined; }; +const DEFAULT_GATEWAY_CLIENT_URL = "ws://127.0.0.1:18789"; + export type GatewayReconnectPausedInfo = { code: number; reason: string; detailCode: string | null; }; -function createDirectGatewayAgent(url: string): http.Agent | https.Agent | undefined { - let hostname: string; - try { - hostname = new URL(url).hostname; - } catch { - return undefined; - } - if (!isLoopbackHost(hostname)) { - return undefined; - } - return url.startsWith("wss://") ? new https.Agent() : new http.Agent(); -} - export class GatewayClientRequestError extends Error { readonly gatewayCode: string; readonly details?: unknown; @@ -261,7 +251,7 @@ export class GatewayClient { this.clearConnectChallengeTimeout(); this.connectNonce = null; this.connectSent = false; - const url = this.opts.url ?? "ws://127.0.0.1:18789"; + const url = this.opts.url ?? DEFAULT_GATEWAY_CLIENT_URL; if (this.opts.tlsFingerprint && !url.startsWith("wss://")) { this.opts.onConnectError?.(new Error("gateway tls fingerprint requires wss:// gateway url")); return; @@ -293,10 +283,9 @@ export class GatewayClient { return; } // Allow node screen snapshots and other large responses. - const directAgent = createDirectGatewayAgent(url); + ensureInheritedManagedProxyRoutingActive(); const wsOptions: FingerprintCheckingClientOptions = { maxPayload: 25 * 1024 * 1024, - ...(directAgent ? { agent: directAgent } : {}), }; if (url.startsWith("wss://") && this.opts.tlsFingerprint) { wsOptions.rejectUnauthorized = false; @@ -321,10 +310,10 @@ export class GatewayClient { return undefined; }; } - const createWebSocket = () => new WebSocket(url, wsOptions as ClientOptions); - const ws = directAgent - ? dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(url, createWebSocket) - : createWebSocket(); + const ws = withManagedProxyGatewayLoopbackRouting( + url, + () => new WebSocket(url, wsOptions as ClientOptions), + ); this.ws = ws; this.socketOpened = false; this.connectNonce = null; diff --git a/src/gateway/client.watchdog.test.ts b/src/gateway/client.watchdog.test.ts index 3b46ec1c29e..52547aa6a57 100644 --- a/src/gateway/client.watchdog.test.ts +++ b/src/gateway/client.watchdog.test.ts @@ -21,6 +21,12 @@ async function getFreePort(): Promise { }); } +function isIpv6UnavailableError(err: unknown): boolean { + const code = + typeof err === "object" && err !== null ? (err as { code?: unknown }).code : undefined; + return code === "EAFNOSUPPORT" || code === "EADDRNOTAVAIL"; +} + function createOpenGatewayClient(requestTimeoutMs: number): { client: GatewayClient; send: ReturnType; @@ -156,6 +162,63 @@ describe("GatewayClient", () => { } }, 4000); + test("connects to IPv6 loopback while managed proxy Gateway-only mode is active", async () => { + wss = new WebSocketServer({ host: "::1", port: 0 }); + const bind = await new Promise<{ port: number } | null>((resolve, reject) => { + wss?.once("listening", () => { + const address = wss?.address(); + if (address === undefined || address === null || typeof address === "string") { + reject(new Error("IPv6 WebSocket server did not bind to a TCP port")); + return; + } + resolve({ port: address.port }); + }); + wss?.once("error", (err) => { + if (isIpv6UnavailableError(err)) { + wss = null; + resolve(null); + return; + } + reject(err); + }); + }); + if (bind === null) { + return; + } + + const { startProxy, stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js"); + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:9", + loopbackMode: "gateway-only", + }); + const onConnectError = vi.fn(); + const connected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("IPv6 loopback Gateway client did not connect")); + }, 2000); + wss?.once("connection", (socket) => { + clearTimeout(timeout); + socket.close(1000, "done"); + resolve(); + }); + }); + const client = new GatewayClient({ + url: `ws://[::1]:${bind.port}`, + connectChallengeTimeoutMs: 1000, + onConnectError, + }); + + try { + expect(() => client.start()).not.toThrow(); + await connected; + expect(onConnectError).not.toHaveBeenCalled(); + } finally { + client.stop(); + await stopProxy(handle); + } + }, 5000); + test("lets pending requests own their timeout when ticks are missing", async () => { vi.useFakeTimers(); try { diff --git a/src/gateway/connection-details.ts b/src/gateway/connection-details.ts index 705e3b97700..1f17489b7a4 100644 --- a/src/gateway/connection-details.ts +++ b/src/gateway/connection-details.ts @@ -23,6 +23,7 @@ export function buildGatewayConnectionDetailsWithResolvers( url?: string; configPath?: string; urlSource?: "cli" | "env"; + ignoreEnvUrlOverride?: boolean; } = {}, resolvers: GatewayConnectionDetailResolvers = {}, ): GatewayConnectionDetails { @@ -40,9 +41,10 @@ export function buildGatewayConnectionDetailsWithResolvers( const scheme = tlsEnabled ? "wss" : "ws"; const localUrl = `${scheme}://127.0.0.1:${localPort}`; const cliUrlOverride = normalizeOptionalString(options.url); - const envUrlOverride = cliUrlOverride - ? undefined - : normalizeOptionalString(process.env.OPENCLAW_GATEWAY_URL); + const envUrlOverride = + cliUrlOverride || options.ignoreEnvUrlOverride + ? undefined + : normalizeOptionalString(process.env.OPENCLAW_GATEWAY_URL); const urlOverride = cliUrlOverride ?? envUrlOverride; const remoteUrl = normalizeOptionalString(remote?.url); const remoteMisconfigured = isRemoteMode && !urlOverride && !remoteUrl; diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index b74b438d2f4..b08c3655a5d 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -8,6 +8,11 @@ import { resetDiagnosticEventsForTest, type DiagnosticEventPayload, } from "../infra/diagnostic-events.js"; +import { + _resetActiveManagedProxyStateForTests, + registerActiveManagedProxyUrl, + stopActiveManagedProxyRegistration, +} from "../infra/net/proxy/active-proxy-state.js"; import { defaultVoiceWakeTriggers } from "../infra/voicewake.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; import { @@ -35,7 +40,13 @@ function makeControlUiResponse() { } const wsMockState = vi.hoisted(() => ({ - last: null as { url: unknown; opts: unknown } | null, + last: null as { + url: unknown; + opts: unknown; + noProxyDuringConstruction: unknown; + httpProxyDuringConstruction: unknown; + httpsProxyDuringConstruction: unknown; + } | null, })); vi.mock("ws", () => ({ @@ -45,7 +56,23 @@ vi.mock("ws", () => ({ send = vi.fn(); constructor(url: unknown, opts: unknown) { - wsMockState.last = { url, opts }; + const agent = (global as Record)["GLOBAL_AGENT"]; + wsMockState.last = { + url, + opts, + noProxyDuringConstruction: + typeof agent === "object" && agent !== null + ? (agent as Record)["NO_PROXY"] + : undefined, + httpProxyDuringConstruction: + typeof agent === "object" && agent !== null + ? (agent as Record)["HTTP_PROXY"] + : undefined, + httpsProxyDuringConstruction: + typeof agent === "object" && agent !== null + ? (agent as Record)["HTTPS_PROXY"] + : undefined, + }; } }, })); @@ -59,6 +86,8 @@ describe("GatewayClient", () => { beforeEach(() => { wsMockState.last = null; + _resetActiveManagedProxyStateForTests(); + delete (global as Record)["GLOBAL_AGENT"]; }); async function withControlUiRoot( @@ -86,31 +115,28 @@ describe("GatewayClient", () => { expect(last?.opts).toEqual(expect.objectContaining({ maxPayload: 25 * 1024 * 1024 })); }); - test("uses an explicit direct agent for control-plane WebSocket connections", () => { + test("does not pass an explicit direct agent for loopback control-plane WebSocket connections", () => { const client = new GatewayClient({ url: "ws://127.0.0.1:1" }); client.start(); const last = wsMockState.last as { opts: { agent?: unknown } } | null; - expect(last?.opts.agent).toBeDefined(); - expect(last?.opts.agent).not.toBe( - (global as unknown as { GLOBAL_AGENT?: { HTTP_PROXY?: unknown } }).GLOBAL_AGENT, - ); + expect(last?.opts.agent).toBeUndefined(); }); - test("uses an explicit direct agent for IPv6 loopback control-plane WebSocket connections", () => { + test("does not pass an explicit direct agent for IPv6 loopback control-plane WebSocket connections", () => { const client = new GatewayClient({ url: "ws://[::1]:1" }); client.start(); const last = wsMockState.last as { opts: { agent?: unknown } } | null; - expect(last?.opts.agent).toBeDefined(); + expect(last?.opts.agent).toBeUndefined(); }); - test("uses the direct control-plane bypass for localhost hostnames", () => { + test("does not pass an explicit direct agent 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).toBeDefined(); + expect(last?.opts.agent).toBeUndefined(); }); test("does not force a direct agent for remote Gateway WebSocket connections", () => { @@ -124,6 +150,60 @@ 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)["GLOBAL_AGENT"] = agent; + const registration = registerActiveManagedProxyUrl( + new URL("http://127.0.0.1:3128"), + "gateway-only", + ); + + try { + const client = new GatewayClient({ url: "ws://127.0.0.1:18789" }); + 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"); + } finally { + stopActiveManagedProxyRegistration(registration); + delete (global as Record)["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)["GLOBAL_AGENT"] = agent; + const registration = registerActiveManagedProxyUrl( + new URL("http://127.0.0.1:3128"), + "gateway-only", + ); + + try { + const client = new GatewayClient({ url: "ws://[::1]:18789" }); + client.start(); + const last = wsMockState.last as { + noProxyDuringConstruction: unknown; + httpProxyDuringConstruction: unknown; + 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"); + } finally { + stopActiveManagedProxyRegistration(registration); + delete (global as Record)["GLOBAL_AGENT"]; + } + }); + it("returns 404 for missing static asset paths instead of SPA fallback", async () => { await withControlUiRoot({ faviconSvg: "" }, async (tmp) => { const { res } = makeControlUiResponse(); diff --git a/src/infra/net/proxy/active-proxy-state.ts b/src/infra/net/proxy/active-proxy-state.ts index d884397a4b7..80581fbe465 100644 --- a/src/infra/net/proxy/active-proxy-state.ts +++ b/src/infra/net/proxy/active-proxy-state.ts @@ -1,14 +1,42 @@ +import type { ProxyConfig } from "../../../config/zod-schema.proxy.js"; + export type ActiveManagedProxyUrl = Readonly; +export type ActiveManagedProxyLoopbackMode = NonNullable["loopbackMode"]>; + export type ActiveManagedProxyRegistration = { proxyUrl: ActiveManagedProxyUrl; + loopbackMode: ActiveManagedProxyLoopbackMode; stopped: boolean; }; let activeProxyUrl: ActiveManagedProxyUrl | undefined; +let activeProxyLoopbackMode: ActiveManagedProxyLoopbackMode | undefined; let activeProxyRegistrationCount = 0; -export function registerActiveManagedProxyUrl(proxyUrl: URL): ActiveManagedProxyRegistration { +function parseActiveManagedProxyLoopbackMode( + value: string | undefined, +): ActiveManagedProxyLoopbackMode | undefined { + if (value === "gateway-only" || value === "proxy" || value === "block") { + return value; + } + return undefined; +} + +function readInheritedActiveManagedProxyLoopbackMode(): ActiveManagedProxyLoopbackMode | undefined { + if (process.env["OPENCLAW_PROXY_ACTIVE"] !== "1") { + return undefined; + } + return ( + parseActiveManagedProxyLoopbackMode(process.env["OPENCLAW_PROXY_LOOPBACK_MODE"]) ?? + "gateway-only" + ); +} + +export function registerActiveManagedProxyUrl( + proxyUrl: URL, + loopbackMode: ActiveManagedProxyLoopbackMode = "gateway-only", +): ActiveManagedProxyRegistration { const normalizedProxyUrl = new URL(proxyUrl.href); if (activeProxyUrl !== undefined) { if (activeProxyUrl.href !== normalizedProxyUrl.href) { @@ -17,13 +45,20 @@ export function registerActiveManagedProxyUrl(proxyUrl: URL): ActiveManagedProxy "stop the current proxy before changing proxy.proxyUrl.", ); } + if (activeProxyLoopbackMode !== loopbackMode) { + throw new Error( + "proxy: cannot activate a managed proxy with a different proxy.loopbackMode while another proxy is active; " + + "stop the current proxy before changing proxy.loopbackMode.", + ); + } activeProxyRegistrationCount += 1; - return { proxyUrl: activeProxyUrl, stopped: false }; + return { proxyUrl: activeProxyUrl, loopbackMode, stopped: false }; } activeProxyUrl = normalizedProxyUrl; + activeProxyLoopbackMode = loopbackMode; activeProxyRegistrationCount = 1; - return { proxyUrl: activeProxyUrl, stopped: false }; + return { proxyUrl: activeProxyUrl, loopbackMode, stopped: false }; } export function stopActiveManagedProxyRegistration( @@ -39,14 +74,20 @@ export function stopActiveManagedProxyRegistration( activeProxyRegistrationCount = Math.max(0, activeProxyRegistrationCount - 1); if (activeProxyRegistrationCount === 0) { activeProxyUrl = undefined; + activeProxyLoopbackMode = undefined; } } +export function getActiveManagedProxyLoopbackMode(): ActiveManagedProxyLoopbackMode | undefined { + return activeProxyLoopbackMode ?? readInheritedActiveManagedProxyLoopbackMode(); +} + export function getActiveManagedProxyUrl(): ActiveManagedProxyUrl | undefined { return activeProxyUrl; } export function _resetActiveManagedProxyStateForTests(): void { activeProxyUrl = undefined; + activeProxyLoopbackMode = undefined; activeProxyRegistrationCount = 0; } diff --git a/src/infra/net/proxy/external-proxy.e2e.test.ts b/src/infra/net/proxy/external-proxy.e2e.test.ts index cd50a4e9416..e58499e6d01 100644 --- a/src/infra/net/proxy/external-proxy.e2e.test.ts +++ b/src/infra/net/proxy/external-proxy.e2e.test.ts @@ -340,7 +340,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 { dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane } from "./src/infra/net/proxy/proxy-lifecycle.ts"; + import { registerManagedProxyGatewayLoopbackNoProxy } from "./src/infra/net/proxy/proxy-lifecycle.ts"; async function nodeHttpGet(url, options = {}) { return new Promise((resolve, reject) => { @@ -396,14 +396,18 @@ describe("SSRF external proxy routing", () => { async function gatewayLoopbackBypassProbe(url) { return new Promise((resolve, reject) => { - const ws = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(url, () => - new WebSocket(url, { handshakeTimeout: ${PROBE_TIMEOUT_MS} }), - ); + const unregister = registerManagedProxyGatewayLoopbackNoProxy(url); + const ws = new WebSocket(url, { handshakeTimeout: ${PROBE_TIMEOUT_MS} }); + const cleanup = () => unregister?.(); ws.once("open", () => { ws.close(); + cleanup(); resolve(); }); - ws.once("error", reject); + ws.once("error", (err) => { + cleanup(); + reject(err); + }); }); } diff --git a/src/infra/net/proxy/proxy-lifecycle.test.ts b/src/infra/net/proxy/proxy-lifecycle.test.ts index bbb60417856..91d46a45cce 100644 --- a/src/infra/net/proxy/proxy-lifecycle.test.ts +++ b/src/infra/net/proxy/proxy-lifecycle.test.ts @@ -22,7 +22,7 @@ import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js"; import { _resetActiveManagedProxyStateForTests } from "./active-proxy-state.js"; import { _resetGlobalAgentBootstrapForTests, - dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane, + registerManagedProxyGatewayLoopbackNoProxy, startProxy, stopProxy, } from "./proxy-lifecycle.js"; @@ -48,6 +48,7 @@ describe("startProxy", () => { "GLOBAL_AGENT_FORCE_GLOBAL_AGENT", "GLOBAL_AGENT_NO_PROXY", "OPENCLAW_PROXY_ACTIVE", + "OPENCLAW_PROXY_LOOPBACK_MODE", "OPENCLAW_PROXY_URL", ]; const originalHttpRequest = http.request; @@ -64,6 +65,15 @@ describe("startProxy", () => { } mockForceResetGlobalDispatcher.mockReset(); mockBootstrapGlobalAgent.mockReset(); + mockBootstrapGlobalAgent.mockImplementation(() => { + const env = process.env as Record; + const namespace = env["GLOBAL_AGENT_ENVIRONMENT_VARIABLE_NAMESPACE"] ?? "GLOBAL_AGENT_"; + (global as Record)["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(); @@ -177,6 +187,25 @@ describe("startProxy", () => { 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"); + }); + + it("persists loopbackMode in env for forked child CLIs", async () => { + const { getActiveManagedProxyLoopbackMode } = await import("./active-proxy-state.js"); + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + loopbackMode: "block", + }); + + expect(process.env["OPENCLAW_PROXY_LOOPBACK_MODE"]).toBe("block"); + expect(getActiveManagedProxyLoopbackMode()).toBe("block"); + + await stopProxy(handle); + process.env["OPENCLAW_PROXY_ACTIVE"] = "1"; + process.env["OPENCLAW_PROXY_LOOPBACK_MODE"] = "proxy"; + + expect(getActiveManagedProxyLoopbackMode()).toBe("proxy"); }); it("redacts proxy credentials before logging the active proxy URL", async () => { @@ -246,9 +275,9 @@ describe("startProxy", () => { expect(process.env["GLOBAL_AGENT_NO_PROXY"]).toBe("global.corp.example.com"); expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBeUndefined(); const agent = (global as Record)["GLOBAL_AGENT"] as Record; - expect(agent["HTTP_PROXY"]).toBe("http://previous-global.example.com:8080"); - expect(agent["HTTPS_PROXY"]).toBe("http://previous-global.example.com:8443"); - expect(agent["NO_PROXY"]).toBe("global.corp.example.com"); + expect(agent["HTTP_PROXY"]).toBe(""); + expect(agent["HTTPS_PROXY"]).toBe(""); + expect(agent["NO_PROXY"]).toBeUndefined(); expect(agent["forceGlobalAgent"]).toBeUndefined(); expect(mockForceResetGlobalDispatcher).toHaveBeenCalledOnce(); }); @@ -359,6 +388,27 @@ describe("startProxy", () => { await stopProxy(firstHandle); }); + it("rejects overlapping handles with the same proxy URL but different loopback modes", async () => { + const firstHandle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + loopbackMode: "gateway-only", + }); + + await expect( + startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + loopbackMode: "block", + }), + ).rejects.toThrow("cannot activate a managed proxy with a different proxy.loopbackMode"); + + expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); + expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1"); + + await stopProxy(firstHandle); + }); + it("restores env and throws when undici activation fails", async () => { mockForceResetGlobalDispatcher.mockImplementationOnce(() => { throw new Error("dispatcher failed"); @@ -391,152 +441,111 @@ describe("startProxy", () => { expect(process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"]).toBeUndefined(); }); - it("temporarily restores the original node HTTP stack for Gateway loopback control-plane setup", async () => { - const patchedHttpRequest = vi.fn() as unknown as typeof http.request; - const patchedHttpGet = vi.fn() as unknown as typeof http.get; - mockBootstrapGlobalAgent.mockImplementationOnce(() => { - http.request = patchedHttpRequest; - http.get = patchedHttpGet; - (global as Record)["GLOBAL_AGENT"] = { - HTTP_PROXY: "", - HTTPS_PROXY: "", - }; - }); - + it("registers exact Gateway loopback authorities in global-agent NO_PROXY", async () => { const handle = await startProxy({ enabled: true, proxyUrl: "http://127.0.0.1:3128", }); + const agent = (global as Record)["GLOBAL_AGENT"] as Record; - expect(http.request).toBe(patchedHttpRequest); + const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"); - const requestDuringBypass = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane( - "ws://127.0.0.1:18789", - () => http.request, - ); + expect(unregister).toBeTypeOf("function"); + expect(agent["NO_PROXY"]).toBe("127.0.0.1:18789"); - expect(requestDuringBypass).toBe(originalHttpRequest); - expect(http.request).toBe(patchedHttpRequest); + unregister?.(); + expect(agent["NO_PROXY"]).toBeNull(); + await stopProxy(handle); + }); + + it("accepts literal loopback IPs and localhost for Gateway NO_PROXY registration", async () => { + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + const agent = (global as Record)["GLOBAL_AGENT"] as Record; + + const unregisterIpv6 = registerManagedProxyGatewayLoopbackNoProxy("ws://[::1]:18789"); + expect(unregisterIpv6).toBeTypeOf("function"); + expect(agent["NO_PROXY"]).toBe("[::1]:18789"); + unregisterIpv6?.(); + + const unregisterLocalhost = registerManagedProxyGatewayLoopbackNoProxy("ws://localhost.:18789"); + expect(unregisterLocalhost).toBeTypeOf("function"); + expect(agent["NO_PROXY"]).toBe("localhost.:18789"); + unregisterLocalhost?.(); await stopProxy(handle); }); - it("allows the Gateway control-plane bypass for literal loopback IPs and localhost", () => { - expect( - dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane( - "ws://127.0.0.1:18789", - () => "ok", - ), - ).toBe("ok"); - expect( - dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane("ws://[::1]:18789", () => "ok"), - ).toBe("ok"); - expect( - dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane( - "ws://localhost:18789", - () => "ok", - ), - ).toBe("ok"); - expect( - dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane( - "ws://localhost.:18789", - () => "ok", - ), - ).toBe("ok"); + it("does not register Gateway NO_PROXY for non-loopback URLs", () => { + expect(registerManagedProxyGatewayLoopbackNoProxy("wss://gateway.example.com")).toBeUndefined(); }); - it("rejects dangerous Gateway control-plane bypass for non-loopback URLs", () => { - expect(() => - dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane( - "wss://gateway.example.com", - () => undefined, - ), - ).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)["GLOBAL_AGENT"] = { - HTTP_PROXY: "", - HTTPS_PROXY: "", - }; - }); - + it("allows Gateway NO_PROXY registration for custom configured loopback ports", async () => { const handle = await startProxy({ enabled: true, proxyUrl: "http://127.0.0.1:3128", }); - process.env["ALL_PROXY"] = "http://inherited-all.example.com:8080"; + const agent = (global as Record)["GLOBAL_AGENT"] as Record; - 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"], - }), - ); + const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:3000"); - expect(during).toEqual({ - httpRequest: originalHttpRequest, - httpProxy: undefined, - allProxy: undefined, - proxyActive: undefined, + expect(unregister).toBeTypeOf("function"); + expect(agent["NO_PROXY"]).toBe("127.0.0.1:3000"); + + unregister?.(); + await stopProxy(handle); + }); + + it("blocks Gateway NO_PROXY registration when active proxy loopbackMode is block", async () => { + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + loopbackMode: "block", }); - 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"); + try { + expect(() => registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789")).toThrow( + "blocked by proxy.loopbackMode", + ); + } finally { + await stopProxy(handle); + } + }); + + it("does not register Gateway NO_PROXY 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)["GLOBAL_AGENT"] as Record; + + try { + const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"); + expect(agent["NO_PROXY"]).toBe(""); + expect(unregister).toBeUndefined(); + } finally { + await stopProxy(handle); + } + }); + + it("restores the active global-agent NO_PROXY value after Gateway registration", async () => { + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + const agent = (global as Record)["GLOBAL_AGENT"] as Record; + agent["NO_PROXY"] = "corp.example.com"; + + const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"); + + expect(unregister).toBeTypeOf("function"); + expect(agent["NO_PROXY"]).toBe("corp.example.com,127.0.0.1:18789"); + + unregister?.(); + expect(agent["NO_PROXY"]).toBe("corp.example.com"); await stopProxy(handle); }); diff --git a/src/infra/net/proxy/proxy-lifecycle.ts b/src/infra/net/proxy/proxy-lifecycle.ts index 98f57d1fcec..97fb8143b0f 100644 --- a/src/infra/net/proxy/proxy-lifecycle.ts +++ b/src/infra/net/proxy/proxy-lifecycle.ts @@ -12,10 +12,13 @@ import https from "node:https"; import { isIP } from "node:net"; import { bootstrap as bootstrapGlobalAgent } from "global-agent"; import type { ProxyConfig } from "../../../config/zod-schema.proxy.js"; + +export type ProxyLoopbackMode = NonNullable["loopbackMode"]>; import { logInfo, logWarn } from "../../../logger.js"; import { isLoopbackIpAddress } from "../../../shared/net/ip.js"; import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js"; import { + getActiveManagedProxyLoopbackMode, getActiveManagedProxyUrl, registerActiveManagedProxyUrl, stopActiveManagedProxyRegistration, @@ -39,7 +42,7 @@ const PROXY_ENV_KEYS = ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY" const GLOBAL_AGENT_PROXY_KEYS = ["GLOBAL_AGENT_HTTP_PROXY", "GLOBAL_AGENT_HTTPS_PROXY"] as const; const GLOBAL_AGENT_FORCE_KEYS = ["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"] as const; const NO_PROXY_ENV_KEYS = ["no_proxy", "NO_PROXY", "GLOBAL_AGENT_NO_PROXY"] as const; -const PROXY_ACTIVE_KEYS = ["OPENCLAW_PROXY_ACTIVE"] as const; +const PROXY_ACTIVE_KEYS = ["OPENCLAW_PROXY_ACTIVE", "OPENCLAW_PROXY_LOOPBACK_MODE"] as const; const ALL_PROXY_ENV_KEYS = [ ...PROXY_ENV_KEYS, ...GLOBAL_AGENT_PROXY_KEYS, @@ -47,19 +50,8 @@ 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; -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; @@ -107,16 +99,17 @@ function captureProxyEnv(): ProxyEnvSnapshot { 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"], }; } -function injectProxyEnv(proxyUrl: string): ProxyEnvSnapshot { +function injectProxyEnv(proxyUrl: string, loopbackMode: ProxyLoopbackMode): ProxyEnvSnapshot { const snapshot = captureProxyEnv(); - applyProxyEnv(proxyUrl); + applyProxyEnv(proxyUrl, loopbackMode); return snapshot; } -function applyProxyEnv(proxyUrl: string): void { +function applyProxyEnv(proxyUrl: string, loopbackMode: ProxyLoopbackMode): void { for (const key of PROXY_ENV_KEYS) { process.env[key] = proxyUrl; } @@ -125,6 +118,7 @@ function applyProxyEnv(proxyUrl: string): void { } 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) { process.env[key] = ""; } @@ -141,39 +135,6 @@ 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(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" || @@ -387,15 +348,28 @@ function redactProxyUrlForLog(value: string): string { } } +export function ensureInheritedManagedProxyRoutingActive(): void { + if (process.env["OPENCLAW_PROXY_ACTIVE"] !== "1") { + return; + } + const proxyUrl = process.env["GLOBAL_AGENT_HTTP_PROXY"] ?? process.env["HTTP_PROXY"]; + if (!proxyUrl || !isSupportedProxyUrl(proxyUrl)) { + return; + } + bootstrapNodeHttpStack(proxyUrl); + forceResetGlobalDispatcher(); +} + export async function startProxy(config: ProxyConfig | undefined): Promise { if (config?.enabled !== true) { return null; } const proxyUrl = resolveProxyUrl(config); + const loopbackMode = config.loopbackMode ?? "gateway-only"; const activeProxyUrl = getActiveManagedProxyUrl(); if (activeProxyUrl) { - const registration = registerActiveManagedProxyUrl(new URL(proxyUrl)); + const registration = registerActiveManagedProxyUrl(new URL(proxyUrl), loopbackMode); const handle: ProxyHandle = { proxyUrl, injectedProxyUrl: proxyUrl, @@ -415,10 +389,10 @@ export async function startProxy(config: ProxyConfig | undefined): Promise { await handle.stop(); } -function isGatewayLoopbackControlPlaneUrl(value: string): boolean { - let url: URL; +function parseGatewayControlPlaneUrl(value: string): URL | null { try { - url = new URL(value); + return new URL(value); } catch { - return false; + return null; } +} + +function isGatewayControlPlaneProtocol(protocol: string): boolean { + return protocol === "ws:" || protocol === "wss:" || protocol === "http:" || protocol === "https:"; +} + +function getGatewayControlPlaneNoProxyAuthority(value: string): string | null { + const url = parseGatewayControlPlaneUrl(value); if ( - url.protocol !== "ws:" && - url.protocol !== "wss:" && - url.protocol !== "http:" && - url.protocol !== "https:" + url === null || + !isGatewayControlPlaneProtocol(url.protocol) || + !isGatewayControlPlaneLoopbackHost(url.hostname) + ) { + return 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 isGatewayControlPlaneLoopbackHost(url.hostname); + return isIP(unbracketHost(url.hostname)) === 6; +} + +function readGlobalAgentNoProxy(): string { + const agent = (global as Record)["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)["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)["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); + if (!authority) { + return undefined; + } + const loopbackMode = getActiveManagedProxyLoopbackMode(); + if (loopbackMode === "block") { + throw new Error( + "proxy: Gateway loopback control-plane connections are blocked by proxy.loopbackMode", + ); + } + if (loopbackMode === "proxy") { + return undefined; + } + + const previousNoProxy = readGlobalAgentNoProxy(); + writeGlobalAgentNoProxy(appendNoProxyAuthority(previousNoProxy, authority)); + let stopped = false; + return () => { + if (stopped) { + return; + } + stopped = true; + writeGlobalAgentNoProxy(previousNoProxy); + }; +} + +export function withManagedProxyGatewayLoopbackRouting(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?.(); + } } function isGatewayControlPlaneLoopbackHost(hostname: string): boolean { const normalizedHost = hostname.trim().toLowerCase().replace(/\.+$/, ""); return normalizedHost === "localhost" || isLoopbackIpAddress(hostname); } - -export function dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane( - url: string, - run: () => T, -): T { - if (!isGatewayLoopbackControlPlaneUrl(url)) { - throw new Error("proxy: dangerous Gateway control-plane bypass is loopback-only"); - } - - const snapshot = nodeHttpStackSnapshot; - if (!snapshot) { - return 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. - return withoutGatewayControlPlaneProxyEnv(() => { - const activeStack = captureNodeHttpStack(); - const globalRecord = global as Record; - try { - http.request = snapshot.httpRequest; - http.get = snapshot.httpGet; - http.globalAgent = snapshot.httpGlobalAgent; - https.request = snapshot.httpsRequest; - https.get = snapshot.httpsGet; - https.globalAgent = snapshot.httpsGlobalAgent; - if (snapshot.hadGlobalAgent) { - globalRecord["GLOBAL_AGENT"] = snapshot.globalAgent; - } else { - delete globalRecord["GLOBAL_AGENT"]; - } - return run(); - } finally { - http.request = activeStack.httpRequest; - http.get = activeStack.httpGet; - http.globalAgent = activeStack.httpGlobalAgent; - https.request = activeStack.httpsRequest; - https.get = activeStack.httpsGet; - https.globalAgent = activeStack.httpsGlobalAgent; - if (activeStack.hadGlobalAgent) { - globalRecord["GLOBAL_AGENT"] = activeStack.globalAgent; - } else { - delete globalRecord["GLOBAL_AGENT"]; - } - } - }); -} diff --git a/src/node-host/runner.test.ts b/src/node-host/runner.test.ts new file mode 100644 index 00000000000..f0449ce1833 --- /dev/null +++ b/src/node-host/runner.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayClientOptions } from "../gateway/client.js"; +import { runNodeHost } from "./runner.js"; + +const mocks = vi.hoisted(() => ({ + capturedGatewayClientOptions: [] as GatewayClientOptions[], + ensureNodeHostConfig: vi.fn(async () => ({ + version: 1, + nodeId: "node-test", + })), + saveNodeHostConfig: vi.fn(async () => undefined), + getRuntimeConfig: vi.fn(() => ({ + gateway: { + handshakeTimeoutMs: 1_000, + }, + })), + startGatewayClientWhenEventLoopReady: vi.fn(async () => ({ + ready: false, + aborted: false, + elapsedMs: 0, + })), +})); + +vi.mock("../config/config.js", () => ({ + getRuntimeConfig: mocks.getRuntimeConfig, +})); + +vi.mock("../gateway/client-start-readiness.js", () => ({ + startGatewayClientWhenEventLoopReady: mocks.startGatewayClientWhenEventLoopReady, +})); + +vi.mock("../gateway/client.js", () => ({ + GatewayClient: function GatewayClient(opts: GatewayClientOptions) { + mocks.capturedGatewayClientOptions.push(opts); + }, +})); + +vi.mock("../gateway/connection-auth.js", () => ({ + resolveGatewayConnectionAuth: vi.fn(async () => ({})), +})); + +vi.mock("../infra/device-identity.js", () => ({ + loadOrCreateDeviceIdentity: vi.fn(() => ({ + id: "device-test", + publicKey: "public-key-test", + privateKey: "private-key-test", + })), +})); + +vi.mock("../infra/machine-name.js", () => ({ + getMachineDisplayName: vi.fn(async () => "test-node"), +})); + +vi.mock("../infra/path-env.js", () => ({ + ensureOpenClawCliOnPath: vi.fn(), +})); + +vi.mock("./config.js", () => ({ + ensureNodeHostConfig: mocks.ensureNodeHostConfig, + saveNodeHostConfig: mocks.saveNodeHostConfig, +})); + +vi.mock("./plugin-node-host.js", () => ({ + ensureNodeHostPluginRegistry: vi.fn(async () => undefined), + listRegisteredNodeHostCapsAndCommands: vi.fn(() => ({ + caps: [], + commands: [], + })), +})); + +describe("runNodeHost", () => { + it("passes the resolved Gateway URL to the Gateway client", async () => { + await expect( + runNodeHost({ + gatewayHost: "127.0.0.1", + gatewayPort: 18789, + }), + ).rejects.toThrow("event loop readiness timeout"); + + expect(mocks.capturedGatewayClientOptions).toHaveLength(1); + expect(mocks.capturedGatewayClientOptions[0]).toEqual( + expect.objectContaining({ + url: "ws://127.0.0.1:18789", + }), + ); + }); +}); diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 2b9d31b0ef8..2a9d74bd4dc 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -541,6 +541,34 @@ describe("GatewayChatClient", () => { ).toBe(30_000); }); + it("surfaces loopback block-mode start failures through disconnect handler", async () => { + vi.useFakeTimers(); + const { startProxy, stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js"); + const proxyHandle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + loopbackMode: "block", + }); + const onDisconnected = vi.fn(); + const client = new GatewayChatClient({ + url: "ws://127.0.0.1:18789", + token: "test-token", + allowInsecureLocalOperatorUi: true, + }); + client.onDisconnected = onDisconnected; + + try { + client.start(); + await vi.advanceTimersByTimeAsync(2); + + expect(onDisconnected).toHaveBeenCalledWith( + expect.stringContaining("blocked by proxy.loopbackMode"), + ); + } finally { + await stopProxy(proxyHandle); + } + }); + it("retries startup-unavailable chat history until the gateway finishes booting", async () => { vi.useFakeTimers(); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index a298b864c72..390ca4d9963 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -51,7 +51,7 @@ type ResolvedGatewayConnection = { token?: string; password?: string; preauthHandshakeTimeoutMs?: number; - allowInsecureLocalOperatorUi?: boolean; + allowInsecureLocalOperatorUi: boolean; }; function throwGatewayAuthResolutionError(reason: string): never { @@ -163,11 +163,15 @@ export class GatewayChatClient implements TuiBackend { start() { void startGatewayClientWhenEventLoopReady(this.client, { clientOptions: { preauthHandshakeTimeoutMs: this.connection.preauthHandshakeTimeoutMs }, - }).then((readiness) => { - if (!readiness.ready && !readiness.aborted) { - this.onDisconnected?.("gateway event loop readiness timeout"); - } - }); + }) + .then((readiness) => { + if (!readiness.ready && !readiness.aborted) { + this.onDisconnected?.("gateway event loop readiness timeout"); + } + }) + .catch((err: unknown) => { + this.onDisconnected?.(err instanceof Error ? err.message : String(err)); + }); } stop() {