From 7137478b451b3f45f97d77b5170a669f4bb2ad6e Mon Sep 17 00:00:00 2001 From: Bryan Marty Date: Tue, 10 Mar 2026 13:49:08 +0000 Subject: [PATCH] fix(gateway): classify loopback URL overrides by effective tunnel target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a loopback gatewayUrl override is on a port different from the configured local gateway port, it cannot be the local gateway itself — it must be an SSH tunnel endpoint. Previously such URLs were either rejected (explicit gatewayUrl path) or misclassified as 'local' (env URL fallback path), causing deliveryContext to be forwarded to the remote gateway and misrouting post-restart wake messages. Fix: - validateGatewayUrlOverrideForAgentTools: instead of throwing for loopback URLs not matching localAllowed, detect the non-local-port loopback case and return target='remote' (tunnel endpoint). - resolveGatewayTarget env URL fallback: compare the loopback URL's port against resolveGatewayPort(cfg); classify as 'remote' when the port differs, regardless of gateway.mode/remote.url config. Both cases (gateway.mode=local/unset and mode=remote without a non-loopback remote.url) are now handled correctly. Add tests covering: - loopback env URL on non-local port → 'remote' (no remote config) - loopback env URL on non-local port → 'remote' (mode=remote, no url) - loopback explicit gatewayUrl on non-local port → 'remote' (no config) - loopback explicit gatewayUrl on non-local port → 'remote' (mode=remote) Fixes #34580 CR chatgpt-codex-connector[bot] --- src/agents/tools/gateway.test.ts | 26 ++++++++++++++++++++++ src/agents/tools/gateway.ts | 38 +++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts index 21e7d65a26b..63d37bf2b48 100644 --- a/src/agents/tools/gateway.test.ts +++ b/src/agents/tools/gateway.test.ts @@ -117,6 +117,20 @@ describe("resolveGatewayTarget – env URL override classification", () => { expect(resolveGatewayTarget()).toBe("local"); }); + it("classifies loopback env URL on non-local port as 'remote' even without remote config (SSH tunnel, different port)", () => { + // ssh -N -L 9000:remote-host:9000 with gateway.mode=local (default): the loopback port + // (9000) differs from the local gateway port (18789) so it cannot be the local gateway. + process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:9000"; + setConfig({}); + expect(resolveGatewayTarget()).toBe("remote"); + }); + + it("classifies loopback env URL on non-local port as 'remote' even when mode=remote but no remote.url (SSH tunnel, different port)", () => { + process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:9000"; + setConfig({ gateway: { mode: "remote" } }); + expect(resolveGatewayTarget()).toBe("remote"); + }); + it("classifies loopback env URL as 'local' when mode=remote but remote.url is a loopback address (local-only setup)", () => { process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789"; setConfig({ gateway: { mode: "remote", remote: { url: "ws://127.0.0.1:18789" } } }); @@ -218,4 +232,16 @@ describe("resolveGatewayTarget – explicit gatewayUrl override", () => { }); expect(resolveGatewayTarget({ gatewayUrl: "ws://127.0.0.1:18789" })).toBe("remote"); }); + + it("returns 'remote' for loopback explicit gatewayUrl on non-local port (SSH tunnel, no remote config)", () => { + // ssh -N -L 9000:remote-host:9000 with no remote config: loopback on port 9000 ≠ 18789 + // so it cannot be the local gateway — must be a tunnel endpoint → classify as "remote". + setConfig({}); + expect(resolveGatewayTarget({ gatewayUrl: "ws://127.0.0.1:9000" })).toBe("remote"); + }); + + it("returns 'remote' for loopback explicit gatewayUrl on non-local port (SSH tunnel, mode=remote no remote.url)", () => { + setConfig({ gateway: { mode: "remote" } }); + expect(resolveGatewayTarget({ gatewayUrl: "ws://localhost:9000" })).toBe("remote"); + }); }); diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index a08c5a3868a..e8c3728df67 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -77,6 +77,11 @@ function isNonLoopbackRemoteUrlConfigured(cfg: ReturnType): b } } +function isLoopbackHostname(hostname: string): boolean { + const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + return h === "127.0.0.1" || h === "localhost" || h === "::1"; +} + function validateGatewayUrlOverrideForAgentTools(params: { cfg: ReturnType; urlOverride: string; @@ -117,6 +122,17 @@ function validateGatewayUrlOverrideForAgentTools(params: { if (remoteKey && parsed.key === remoteKey) { return { url: parsed.origin, target: "remote" }; } + // Loopback URL on a non-local port → must be an SSH tunnel endpoint → classify as remote. + // The `localAllowed` set only covers the configured gateway port. Any loopback on a different + // port cannot be the local gateway itself, so it must be a forwarded tunnel to a remote server. + // This handles cases where gateway.mode=local (or unset) but the user is SSH-forwarding + // via a non-default port: ssh -N -L :remote-host:. + // Classifying as "remote" suppresses deliveryContext so the remote gateway uses its own + // extractDeliveryInfo rather than receiving the caller's local chat route in the sentinel. + const urlForTunnelCheck = new URL(params.urlOverride.trim()); // already validated above + if (isLoopbackHostname(urlForTunnelCheck.hostname)) { + return { url: parsed.origin, target: "remote" }; + } throw new Error( [ "gatewayUrl override rejected.", @@ -163,6 +179,9 @@ function resolveGatewayOverrideToken(params: { * 3. Tunneled loopback URLs (ssh -N -L ...) when gateway.mode=remote with a non-loopback * remote.url is configured: classifying as "local" would forward deliveryContext to the * remote server, causing post-restart wake messages to be misrouted to the caller's chat. + * 4. Loopback URLs on a non-local port (ssh -N -L :...) with local mode or no remote + * URL configured: the non-local port cannot be the local gateway, so it must be a tunnel; + * classifying as "local" would forward deliveryContext to the remote server (misrouting). */ export function resolveGatewayTarget(opts?: GatewayCallOptions): GatewayOverrideTarget | undefined { const cfg = loadConfig(); @@ -189,9 +208,22 @@ export function resolveGatewayTarget(opts?: GatewayCallOptions): GatewayOverride const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ""); const isLoopback = host === "127.0.0.1" || host === "localhost" || host === "::1"; if (isLoopback) { - // When gateway.mode=remote is configured with a non-loopback remote URL, this - // loopback is likely an SSH tunnel endpoint — classify as "remote". - return isNonLoopbackRemoteUrlConfigured(cfg) ? "remote" : "local"; + // Classify as "remote" when: + // (a) gateway.mode=remote with a non-loopback remote URL — the loopback is + // an SSH tunnel endpoint (ssh -N -L :remote-host:), OR + // (b) the loopback port differs from the configured local gateway port — a + // non-local-port loopback cannot be the local gateway, so it must be a tunnel, + // regardless of whether gateway.mode=remote is configured. + const localPort = resolveGatewayPort(cfg); + const envPort = parsed.port + ? Number(parsed.port) + : parsed.protocol === "wss:" + ? 443 + : 80; + if (envPort !== localPort || isNonLoopbackRemoteUrlConfigured(cfg)) { + return "remote"; + } + return "local"; } return "remote"; } catch {