Skip to content
This repository was archived by the owner on May 5, 2026. It is now read-only.

Commit 7c51cd2

Browse files
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.
1 parent 21b3eb5 commit 7c51cd2

5 files changed

Lines changed: 80 additions & 10 deletions

File tree

extensions/device-pair/index.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ type ApprovedPairingDevice = ApprovedPairingResult["device"];
7070
const INTERNAL_PAIRING_SCOPES = ["operator.write", "operator.pairing"];
7171

7272
function createApi(params?: {
73+
config?: OpenClawPluginApi["config"];
7374
runtime?: OpenClawPluginApi["runtime"];
7475
pluginConfig?: Record<string, unknown>;
7576
registerCommand?: (command: OpenClawPluginCommandDefinition) => void;
@@ -78,7 +79,7 @@ function createApi(params?: {
7879
id: "device-pair",
7980
name: "device-pair",
8081
source: "test",
81-
config: {
82+
config: params?.config ?? {
8283
gateway: {
8384
auth: {
8485
mode: "token",
@@ -96,6 +97,7 @@ function createApi(params?: {
9697
}
9798

9899
function registerPairCommand(params?: {
100+
config?: OpenClawPluginApi["config"];
99101
runtime?: OpenClawPluginApi["runtime"];
100102
pluginConfig?: Record<string, unknown>;
101103
}): OpenClawPluginCommandDefinition {
@@ -649,6 +651,36 @@ describe("device-pair /pair default setup code", () => {
649651
expect(result).toEqual({ text: "Error: Configured publicUrl is invalid." });
650652
});
651653

654+
it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => {
655+
const command = registerPairCommand({
656+
config: {
657+
gateway: {
658+
bind: "custom",
659+
customBindHost: "127.0.0.1",
660+
remote: { url: "http://localhost:notaport" },
661+
auth: {
662+
mode: "token",
663+
token: "gateway-token",
664+
},
665+
},
666+
},
667+
pluginConfig: {
668+
publicUrl: undefined,
669+
},
670+
});
671+
const result = await command.handler(
672+
createCommandContext({
673+
channel: "webchat",
674+
args: "",
675+
commandBody: "/pair",
676+
gatewayClientScopes: ["operator.write", "operator.pairing"],
677+
}),
678+
);
679+
680+
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
681+
expect(result).toEqual({ text: "Error: Configured gateway.remote.url is invalid." });
682+
});
683+
652684
it.each([
653685
"http://localhost:notaport",
654686
"http:gateway.example.test",

extensions/device-pair/index.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,12 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResu
321321
return { error: "Configured publicUrl is invalid." };
322322
}
323323

324+
const configuredRemoteUrl = normalizeOptionalString(cfg.gateway?.remote?.url);
325+
const remoteUrl = configuredRemoteUrl ? normalizeUrl(configuredRemoteUrl, scheme) : null;
326+
if (configuredRemoteUrl && !remoteUrl) {
327+
return { error: "Configured gateway.remote.url is invalid." };
328+
}
329+
324330
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
325331
if (tailscaleMode === "serve" || tailscaleMode === "funnel") {
326332
const host = await resolveTailnetHost();
@@ -330,12 +336,8 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResu
330336
return { url: `wss://${host}`, source: `gateway.tailscale.mode=${tailscaleMode}` };
331337
}
332338

333-
const remoteUrl = normalizeOptionalString(cfg.gateway?.remote?.url);
334339
if (remoteUrl) {
335-
const url = normalizeUrl(remoteUrl, scheme);
336-
if (url) {
337-
return { url, source: "gateway.remote.url" };
338-
}
340+
return { url: remoteUrl, source: "gateway.remote.url" };
339341
}
340342

341343
const bindResult = resolveGatewayBindUrl({

src/cli/qr-cli.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,23 @@ describe("registerQrCli", () => {
413413
);
414414
});
415415

416+
it("rejects invalid gateway.remote.url before printing remote setup codes", async () => {
417+
loadConfig.mockReturnValue({
418+
gateway: {
419+
bind: "custom",
420+
customBindHost: "127.0.0.1",
421+
remote: { url: "http://localhost:notaport", token: "remote-tok" },
422+
auth: { mode: "token", token: "local-tok" },
423+
},
424+
});
425+
426+
await expectQrExit(["--setup-code-only", "--remote"]);
427+
428+
const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
429+
expect(output).toContain("Configured gateway.remote.url is invalid.");
430+
expect(runtime.log).not.toHaveBeenCalled();
431+
});
432+
416433
it("logs remote secret diagnostics in non-json output mode", async () => {
417434
loadConfig.mockReturnValue(createRemoteQrConfig());
418435
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({

src/pairing/setup-code.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,24 @@ describe("pairing setup code", () => {
222222
});
223223
});
224224

225+
it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => {
226+
await expectResolvedSetupFailureCase({
227+
config: {
228+
gateway: {
229+
bind: "custom",
230+
customBindHost: "127.0.0.1",
231+
remote: { url: "http://localhost:notaport" },
232+
auth: { mode: "token", token: "tok_123" },
233+
},
234+
},
235+
options: {
236+
preferRemoteUrl: true,
237+
},
238+
expectedError: "Configured gateway.remote.url is invalid.",
239+
});
240+
expect(issueDeviceBootstrapTokenMock).not.toHaveBeenCalled();
241+
});
242+
225243
it.each([
226244
"localhost:notaport",
227245
"http://localhost:notaport",

src/pairing/setup-code.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,11 @@ async function resolveGatewayUrl(
285285
}
286286

287287
const remoteUrlRaw = cfg.gateway?.remote?.url;
288-
const remoteUrl =
289-
typeof remoteUrlRaw === "string" && remoteUrlRaw.trim()
290-
? normalizeUrl(remoteUrlRaw, scheme)
291-
: null;
288+
const hasRemoteUrl = typeof remoteUrlRaw === "string" && remoteUrlRaw.trim();
289+
const remoteUrl = hasRemoteUrl ? normalizeUrl(remoteUrlRaw, scheme) : null;
290+
if (hasRemoteUrl && !remoteUrl) {
291+
return { error: "Configured gateway.remote.url is invalid." };
292+
}
292293
if (opts.preferRemoteUrl && remoteUrl) {
293294
return { url: remoteUrl, source: "gateway.remote.url" };
294295
}

0 commit comments

Comments
 (0)