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 {