fix: classify tunneled loopback gateway URLs as remote when mode=remote is configured

When gateway.mode=remote is configured with a non-loopback remote.url,
a loopback gatewayUrl (ws://127.0.0.1:...) is likely an SSH port-forward
tunnel endpoint (ssh -N -L <local>:remote-host:<remote>). Previously,
resolveGatewayTarget() classified any loopback URL as 'local', causing
gateway-tool to forward live deliveryContext into remote config.apply /
config.patch / update.run writes. Because server handlers prefer
params.deliveryContext, post-restart wake messages were misrouted to the
caller's local chat context instead of the remote session.

Fix both classification sites:
1. validateGatewayUrlOverrideForAgentTools: when a loopback URL hits
   localAllowed, check isNonLoopbackRemoteUrlConfigured(cfg); if true,
   return 'remote' (tunnel) rather than 'local'.
2. resolveGatewayTarget fallback (rejected URL path): same check for
   the isLoopback branch — prefer 'remote' when mode=remote with a
   non-loopback remote.url is present.

Add isNonLoopbackRemoteUrlConfigured() helper (returns true iff
gateway.mode=remote AND gateway.remote.url is a non-loopback hostname).

Tests: add SSH tunnel cases in gateway.test.ts; update the
'OPENCLAW_GATEWAY_URL takes precedence' test which now correctly
returns 'remote' when mode=remote with non-loopback remote.url; add
a variant without remote config to cover the 'local' precedence case.
This commit is contained in:
Bryan Marty 2026-03-10 10:47:45 +00:00
parent d629945255
commit e13a94df72
No known key found for this signature in database
2 changed files with 95 additions and 8 deletions

View File

@ -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");
});
});

View File

@ -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 <local_port>:remote-host:<remote_port>). In that case, a loopback gatewayUrl
* is a tunnel endpoint and should be classified as "remote" so deliveryContext is suppressed.
*/
function isNonLoopbackRemoteUrlConfigured(cfg: ReturnType<typeof loadConfig>): 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<typeof loadConfig>;
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.
}