fix: classify env gateway URL overrides with callGateway semantics in resolveGatewayTarget

When OPENCLAW_GATEWAY_URL/CLAWDBOT_GATEWAY_URL is set to a valid remote URL
that doesn't match gateway.remote.url (or has a non-root path like /ws),
validateGatewayUrlOverrideForAgentTools throws and the old code silently fell
through to config-based resolution, returning undefined (local). But
callGateway/buildGatewayConnectionDetails still uses the env URL verbatim, so
the actual call goes remote while resolveGatewayTarget returned local — causing
gateway-tool to forward live deliveryContext into remote config.apply /
config.patch / update.run writes, which can misroute or leak post-restart wake
messages across hosts.

Fix: when validateGatewayUrlOverrideForAgentTools throws for an env URL
override, fall back to hostname-based loopback detection instead of silently
treating the target as local. Only truly malformed URLs (that new URL() cannot
parse) fall through to config-based resolution.

Adds tests for:
- env-only remote URL not matching gateway.remote.url → 'remote'
- env URL with no configured remote URL → 'remote'
- env URL with /ws path → 'remote'
- loopback env URL with /ws path → 'local'
This commit is contained in:
Bryan Marty 2026-03-10 07:45:53 +00:00
parent 3c914b8f38
commit d629945255
No known key found for this signature in database
2 changed files with 46 additions and 1 deletions

View File

@ -109,6 +109,39 @@ describe("resolveGatewayTarget env URL override classification", () => {
expect(resolveGatewayTarget()).toBe("remote");
});
it("classifies env-only remote URL (not matching gateway.remote.url) as 'remote'", () => {
// callGateway uses the env URL as-is even when validateGatewayUrlOverrideForAgentTools
// rejects it (different host than configured gateway.remote.url). Must not leak
// deliveryContext into a remote call by falling back to 'local'.
process.env.OPENCLAW_GATEWAY_URL = "wss://other-host.example.com";
setConfig({
gateway: { mode: "remote", remote: { url: "wss://remote.example.com" } },
});
expect(resolveGatewayTarget()).toBe("remote");
});
it("classifies env-only remote URL with no configured gateway.remote.url as 'remote'", () => {
// callGateway picks up the env URL even when gateway.remote.url is absent.
process.env.OPENCLAW_GATEWAY_URL = "wss://remote.example.com";
setConfig({});
expect(resolveGatewayTarget()).toBe("remote");
});
it("classifies env URL with /ws path (rejected by allowlist) as 'remote'", () => {
// URLs with non-root paths are rejected by validateGatewayUrlOverrideForAgentTools but
// callGateway/buildGatewayConnectionDetails still use them verbatim. Classify correctly.
process.env.OPENCLAW_GATEWAY_URL = "wss://remote.example.com/ws";
setConfig({});
expect(resolveGatewayTarget()).toBe("remote");
});
it("classifies loopback env URL with /ws path (rejected by allowlist) as 'local'", () => {
// Even with a non-root path, loopback targets remain local.
process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789/ws";
setConfig({});
expect(resolveGatewayTarget()).toBe("local");
});
it("OPENCLAW_GATEWAY_URL takes precedence over env CLAWDBOT_GATEWAY_URL", () => {
process.env.OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789";
process.env.CLAWDBOT_GATEWAY_URL = "wss://remote.example.com";

View File

@ -144,7 +144,19 @@ export function resolveGatewayTarget(opts?: GatewayCallOptions): GatewayOverride
urlOverride: envUrlOverride,
}).target;
} catch {
// Malformed or security-rejected env URL; fall through to config-based resolution.
// URL rejected by the agent-tools allowlist (e.g. non-loopback URL not matching
// gateway.remote.url, or URL with a non-root path like /ws). callGateway /
// buildGatewayConnectionDetails will still use this env URL as-is, so we must
// classify based on the actual target host — not silently fall back to local.
try {
const parsed = new URL(envUrlOverride.trim());
// 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";
} catch {
// Truly malformed URL; callGateway will also fail. Fall through to config-based resolution.
}
}
}
// No env override. When mode=remote with a configured remote URL → truly remote.