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:
parent
d629945255
commit
e13a94df72
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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.
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user