diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts index 7669261f7b6..21e7d65a26b 100644 --- a/src/agents/tools/gateway.test.ts +++ b/src/agents/tools/gateway.test.ts @@ -80,18 +80,49 @@ describe("resolveGatewayTarget – env URL override classification", () => { expect(resolveGatewayTarget()).toBeUndefined(); }); - it("classifies OPENCLAW_GATEWAY_URL loopback env override as 'local'", () => { + it("classifies OPENCLAW_GATEWAY_URL loopback env override as 'local' (no remote config)", () => { process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789"; setConfig({}); expect(resolveGatewayTarget()).toBe("local"); }); - it("classifies CLAWDBOT_GATEWAY_URL loopback env override as 'local'", () => { + it("classifies CLAWDBOT_GATEWAY_URL loopback env override as 'local' (no remote config)", () => { process.env.CLAWDBOT_GATEWAY_URL = "ws://localhost:18789"; setConfig({}); expect(resolveGatewayTarget()).toBe("local"); }); + it("classifies loopback env URL as 'remote' when mode=remote with non-loopback remote URL (SSH tunnel, same port)", () => { + // Common SSH tunnel pattern: ssh -N -L 18789:remote-host:18789 + // OPENCLAW_GATEWAY_URL points to the local tunnel endpoint but gateway is remote. + process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789"; + setConfig({ + gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } }, + }); + expect(resolveGatewayTarget()).toBe("remote"); + }); + + it("classifies loopback env URL with different port as 'remote' when mode=remote with non-loopback remote URL (SSH tunnel, different port)", () => { + // SSH tunnel to a non-default port: ssh -N -L 9000:remote-host:9000 + process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:9000"; + setConfig({ + gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } }, + }); + expect(resolveGatewayTarget()).toBe("remote"); + }); + + it("classifies loopback env URL as 'local' when mode=remote but remote.url is absent (callGateway falls back to local)", () => { + process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789"; + setConfig({ gateway: { mode: "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)", () => { + process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789"; + setConfig({ gateway: { mode: "remote", remote: { url: "ws://127.0.0.1:18789" } } }); + expect(resolveGatewayTarget()).toBe("local"); + }); + it("classifies OPENCLAW_GATEWAY_URL matching gateway.remote.url as 'remote'", () => { process.env.OPENCLAW_GATEWAY_URL = "wss://remote.example.com"; setConfig({ @@ -148,7 +179,16 @@ describe("resolveGatewayTarget – env URL override classification", () => { setConfig({ gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } }, }); - // OPENCLAW_GATEWAY_URL wins (loopback) → "local" + // OPENCLAW_GATEWAY_URL wins (loopback); mode=remote with non-loopback remote.url + // means this loopback is an SSH tunnel → "remote" + expect(resolveGatewayTarget()).toBe("remote"); + }); + + it("OPENCLAW_GATEWAY_URL takes precedence over env CLAWDBOT_GATEWAY_URL (no remote config → 'local')", () => { + process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789"; + process.env.CLAWDBOT_GATEWAY_URL = "wss://remote.example.com"; + setConfig({}); + // OPENCLAW_GATEWAY_URL wins (loopback), no remote config → "local" expect(resolveGatewayTarget()).toBe("local"); }); }); @@ -161,7 +201,7 @@ describe("resolveGatewayTarget – explicit gatewayUrl override", () => { delete process.env.CLAWDBOT_GATEWAY_URL; }); - it("returns 'local' for loopback explicit gatewayUrl", () => { + it("returns 'local' for loopback explicit gatewayUrl (no remote config)", () => { expect(resolveGatewayTarget({ gatewayUrl: "ws://127.0.0.1:18789" })).toBe("local"); }); @@ -171,4 +211,11 @@ describe("resolveGatewayTarget – explicit gatewayUrl override", () => { }); expect(resolveGatewayTarget({ gatewayUrl: "wss://remote.example.com" })).toBe("remote"); }); + + it("returns 'remote' for loopback explicit gatewayUrl when mode=remote with non-loopback remote URL (SSH tunnel)", () => { + setConfig({ + gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } }, + }); + expect(resolveGatewayTarget({ gatewayUrl: "ws://127.0.0.1:18789" })).toBe("remote"); + }); }); diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index fab886252e3..a08c5a3868a 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -53,6 +53,30 @@ function canonicalizeToolGatewayWsUrl(raw: string): { origin: string; key: strin return { origin, key }; } +/** + * Returns true when gateway.mode=remote is configured with a non-loopback remote URL. + * This indicates the user is connecting to a remote gateway, possibly via SSH port forwarding + * (ssh -N -L :remote-host:). In that case, a loopback gatewayUrl + * is a tunnel endpoint and should be classified as "remote" so deliveryContext is suppressed. + */ +function isNonLoopbackRemoteUrlConfigured(cfg: ReturnType): boolean { + if (cfg.gateway?.mode !== "remote") { + return false; + } + const remoteUrl = + typeof cfg.gateway?.remote?.url === "string" ? cfg.gateway.remote.url.trim() : ""; + if (!remoteUrl) { + return false; + } + try { + const parsed = new URL(remoteUrl); + const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + return !(host === "127.0.0.1" || host === "localhost" || host === "::1"); + } catch { + return false; + } +} + function validateGatewayUrlOverrideForAgentTools(params: { cfg: ReturnType; urlOverride: string; @@ -82,7 +106,13 @@ function validateGatewayUrlOverrideForAgentTools(params: { const parsed = canonicalizeToolGatewayWsUrl(params.urlOverride); if (localAllowed.has(parsed.key)) { - return { url: parsed.origin, target: "local" }; + // A loopback URL on the configured port is normally the local gateway, but when + // gateway.mode=remote is configured with a non-loopback remote URL, the user is + // likely using SSH port forwarding (ssh -N -L ...) and this loopback is a tunnel + // endpoint pointing to a remote gateway. Classify as "remote" so deliveryContext + // is not forwarded to the remote server, which would misroute post-restart wake messages. + const target = isNonLoopbackRemoteUrlConfigured(cfg) ? "remote" : "local"; + return { url: parsed.origin, target }; } if (remoteKey && parsed.key === remoteKey) { return { url: parsed.origin, target: "remote" }; @@ -117,17 +147,22 @@ function resolveGatewayOverrideToken(params: { * Resolves whether a GatewayCallOptions points to a local or remote gateway. * Returns "remote" when a remote gatewayUrl override is present, OR when * gateway.mode=remote is configured with a gateway.remote.url set. - * Returns "local" for explicit loopback URL overrides (127.0.0.1, localhost, [::1]). + * Returns "local" for explicit loopback URL overrides (127.0.0.1, localhost, [::1]) + * UNLESS gateway.mode=remote is configured with a non-loopback remote URL, which indicates + * the loopback is an SSH tunnel endpoint — in that case returns "remote". * Returns undefined when no override is present and the effective target is the local gateway * (including the gateway.mode=remote + missing gateway.remote.url fallback-to-local case). * * This mirrors the URL resolution path used by callGateway/buildGatewayConnectionDetails so * that deliveryContext suppression decisions are based on the actual connection target, not just - * the configured mode. Two mismatches fixed vs the previous version: + * the configured mode. Mismatches fixed vs the previous version: * 1. gateway.mode=remote without gateway.remote.url: callGateway falls back to local loopback; * classifying that as "remote" would incorrectly suppress deliveryContext. * 2. Env URL overrides (OPENCLAW_GATEWAY_URL / CLAWDBOT_GATEWAY_URL) are picked up by * callGateway but were ignored here, causing incorrect local/remote classification. + * 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. */ export function resolveGatewayTarget(opts?: GatewayCallOptions): GatewayOverrideTarget | undefined { const cfg = loadConfig(); @@ -153,7 +188,12 @@ export function resolveGatewayTarget(opts?: GatewayCallOptions): GatewayOverride // Normalize IPv6 brackets: "[::1]" → "::1" const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ""); const isLoopback = host === "127.0.0.1" || host === "localhost" || host === "::1"; - return isLoopback ? "local" : "remote"; + 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"; + } + return "remote"; } catch { // Truly malformed URL; callGateway will also fail. Fall through to config-based resolution. }