fix(gateway): preserve local classification for loopback custom-port overrides

This commit is contained in:
Bryan Marty 2026-03-10 16:46:52 +00:00
parent 7137478b45
commit 29206a9bc6
No known key found for this signature in database
2 changed files with 39 additions and 33 deletions

View File

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

View File

@ -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 <forwarded-port>:remote-host:<remote-port>.
// 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 <port>:remote-host:<remote-port>) → "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 <local_port>:remote-host:<remote_port>), 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 <local_port>:remote-host:<remote_port>). 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";