fix(gateway): preserve local classification for loopback custom-port overrides
This commit is contained in:
parent
7137478b45
commit
29206a9bc6
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user