From 7c51cd2baf1cca8de20b0133bc6bb00d2dc95dea Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:45:33 +0100 Subject: [PATCH] fix(device-pair): reject invalid remote setup URLs Fail setup-code generation when gateway.remote.url is configured but malformed, instead of falling back to a bind-derived URL and issuing a bootstrap token. --- extensions/device-pair/index.test.ts | 34 +++++++++++++++++++++++++++- extensions/device-pair/index.ts | 12 ++++++---- src/cli/qr-cli.test.ts | 17 ++++++++++++++ src/pairing/setup-code.test.ts | 18 +++++++++++++++ src/pairing/setup-code.ts | 9 ++++---- 5 files changed, 80 insertions(+), 10 deletions(-) diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 5be5df03669..e6f79fe31a3 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -70,6 +70,7 @@ type ApprovedPairingDevice = ApprovedPairingResult["device"]; const INTERNAL_PAIRING_SCOPES = ["operator.write", "operator.pairing"]; function createApi(params?: { + config?: OpenClawPluginApi["config"]; runtime?: OpenClawPluginApi["runtime"]; pluginConfig?: Record; registerCommand?: (command: OpenClawPluginCommandDefinition) => void; @@ -78,7 +79,7 @@ function createApi(params?: { id: "device-pair", name: "device-pair", source: "test", - config: { + config: params?.config ?? { gateway: { auth: { mode: "token", @@ -96,6 +97,7 @@ function createApi(params?: { } function registerPairCommand(params?: { + config?: OpenClawPluginApi["config"]; runtime?: OpenClawPluginApi["runtime"]; pluginConfig?: Record; }): OpenClawPluginCommandDefinition { @@ -649,6 +651,36 @@ describe("device-pair /pair default setup code", () => { expect(result).toEqual({ text: "Error: Configured publicUrl is invalid." }); }); + it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => { + const command = registerPairCommand({ + config: { + gateway: { + bind: "custom", + customBindHost: "127.0.0.1", + remote: { url: "http://localhost:notaport" }, + auth: { + mode: "token", + token: "gateway-token", + }, + }, + }, + pluginConfig: { + publicUrl: undefined, + }, + }); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: ["operator.write", "operator.pairing"], + }), + ); + + expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); + expect(result).toEqual({ text: "Error: Configured gateway.remote.url is invalid." }); + }); + it.each([ "http://localhost:notaport", "http:gateway.example.test", diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 93804e77bc0..3bac58bdb24 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -321,6 +321,12 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise { ); }); + it("rejects invalid gateway.remote.url before printing remote setup codes", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "custom", + customBindHost: "127.0.0.1", + remote: { url: "http://localhost:notaport", token: "remote-tok" }, + auth: { mode: "token", token: "local-tok" }, + }, + }); + + await expectQrExit(["--setup-code-only", "--remote"]); + + const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); + expect(output).toContain("Configured gateway.remote.url is invalid."); + expect(runtime.log).not.toHaveBeenCalled(); + }); + it("logs remote secret diagnostics in non-json output mode", async () => { loadConfig.mockReturnValue(createRemoteQrConfig()); resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 98503b8da7c..50899103e60 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -222,6 +222,24 @@ describe("pairing setup code", () => { }); }); + it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => { + await expectResolvedSetupFailureCase({ + config: { + gateway: { + bind: "custom", + customBindHost: "127.0.0.1", + remote: { url: "http://localhost:notaport" }, + auth: { mode: "token", token: "tok_123" }, + }, + }, + options: { + preferRemoteUrl: true, + }, + expectedError: "Configured gateway.remote.url is invalid.", + }); + expect(issueDeviceBootstrapTokenMock).not.toHaveBeenCalled(); + }); + it.each([ "localhost:notaport", "http://localhost:notaport", diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index ebf6a4ed4f4..49d9d8101f2 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -285,10 +285,11 @@ async function resolveGatewayUrl( } const remoteUrlRaw = cfg.gateway?.remote?.url; - const remoteUrl = - typeof remoteUrlRaw === "string" && remoteUrlRaw.trim() - ? normalizeUrl(remoteUrlRaw, scheme) - : null; + const hasRemoteUrl = typeof remoteUrlRaw === "string" && remoteUrlRaw.trim(); + const remoteUrl = hasRemoteUrl ? normalizeUrl(remoteUrlRaw, scheme) : null; + if (hasRemoteUrl && !remoteUrl) { + return { error: "Configured gateway.remote.url is invalid." }; + } if (opts.preferRemoteUrl && remoteUrl) { return { url: remoteUrl, source: "gateway.remote.url" }; }