From 29206a9bc6d3514a237dc58986242f44fc36fb2e Mon Sep 17 00:00:00 2001 From: Bryan Marty Date: Tue, 10 Mar 2026 16:46:52 +0000 Subject: [PATCH] fix(gateway): preserve local classification for loopback custom-port overrides --- src/agents/tools/gateway.test.ts | 35 +++++++++++++++++++----------- src/agents/tools/gateway.ts | 37 ++++++++++++++------------------ 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts index 63d37bf2b48..8f12e33c462 100644 --- a/src/agents/tools/gateway.test.ts +++ b/src/agents/tools/gateway.test.ts @@ -117,18 +117,21 @@ 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. + it("classifies loopback env URL on non-local port as 'local' without remote config (local gateway on custom port)", () => { + // ws://127.0.0.1:9000 with no non-loopback remote URL configured: cannot prove this is + // an SSH tunnel — it may simply be a local gateway on a custom port. Preserve "local" + // so deliveryContext is not suppressed and heartbeat wake-up routing stays correct. process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:9000"; setConfig({}); - expect(resolveGatewayTarget()).toBe("remote"); + expect(resolveGatewayTarget()).toBe("local"); }); - it("classifies loopback env URL on non-local port as 'remote' even when mode=remote but no remote.url (SSH tunnel, different port)", () => { + it("classifies loopback env URL on non-local port as 'local' when mode=remote but no remote.url (no tunnel evidence)", () => { + // mode=remote without a non-loopback remote.url is insufficient to prove SSH tunnel; + // treat as local gateway on custom port. process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:9000"; setConfig({ gateway: { mode: "remote" } }); - expect(resolveGatewayTarget()).toBe("remote"); + expect(resolveGatewayTarget()).toBe("local"); }); it("classifies loopback env URL as 'local' when mode=remote but remote.url is a loopback address (local-only setup)", () => { @@ -233,15 +236,23 @@ 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". + it("returns 'local' for loopback explicit gatewayUrl on non-local port without remote config (local gateway on custom port)", () => { + // ws://127.0.0.1:9000 with no non-loopback remote URL: cannot distinguish SSH tunnel + // from local gateway on a custom port — preserve "local" so deliveryContext is kept. setConfig({}); - expect(resolveGatewayTarget({ gatewayUrl: "ws://127.0.0.1:9000" })).toBe("remote"); + expect(resolveGatewayTarget({ gatewayUrl: "ws://127.0.0.1:9000" })).toBe("local"); }); - it("returns 'remote' for loopback explicit gatewayUrl on non-local port (SSH tunnel, mode=remote no remote.url)", () => { + it("returns 'local' for loopback explicit gatewayUrl on non-local port when mode=remote but no remote.url (no tunnel evidence)", () => { + // mode=remote without a non-loopback remote.url cannot prove SSH tunnel; treat as local. setConfig({ gateway: { mode: "remote" } }); - expect(resolveGatewayTarget({ gatewayUrl: "ws://localhost:9000" })).toBe("remote"); + expect(resolveGatewayTarget({ gatewayUrl: "ws://localhost:9000" })).toBe("local"); + }); + + it("returns 'remote' for loopback explicit gatewayUrl on non-local port when mode=remote with non-loopback remote.url (SSH tunnel)", () => { + // With a non-loopback remote URL configured, a custom-port loopback is unambiguously + // an SSH tunnel endpoint — classify as "remote" to suppress deliveryContext. + setConfig({ gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } } }); + expect(resolveGatewayTarget({ gatewayUrl: "ws://127.0.0.1:9000" })).toBe("remote"); }); }); diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index e8c3728df67..84aa6101f40 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -122,16 +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. + // Loopback URL on a non-configured port — could be either: + // (a) An SSH tunnel endpoint (ssh -N -L :remote-host:) → "remote" + // (b) A local gateway running on a custom/non-default port → "local" + // We can only distinguish (a) from (b) when a non-loopback remote URL is configured: + // that proves gateway.mode=remote with an external host, so a loopback URL on any port + // must be a forwarded tunnel. Without that evidence, treat the loopback as local so that + // deliveryContext is not suppressed and heartbeat wake-up routing stays correct. const urlForTunnelCheck = new URL(params.urlOverride.trim()); // already validated above if (isLoopbackHostname(urlForTunnelCheck.hostname)) { - return { url: parsed.origin, target: "remote" }; + const target = isNonLoopbackRemoteUrlConfigured(cfg) ? "remote" : "local"; + return { url: parsed.origin, target }; } throw new Error( [ @@ -208,19 +209,13 @@ 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) { - // 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)) { + // Classify as "remote" only when a non-loopback remote URL is configured, + // which proves the loopback is an SSH tunnel endpoint + // (ssh -N -L :remote-host:). Without that evidence + // a loopback URL on any port — including a non-default port — could be a + // local gateway on a custom port, so we preserve "local" classification to + // keep deliveryContext intact and avoid heartbeat-stale routing regressions. + if (isNonLoopbackRemoteUrlConfigured(cfg)) { return "remote"; } return "local";