From 68e39cf2c34404b1fe6ae111a1d2fbd7ba0f9dd6 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:38:07 +0000 Subject: [PATCH] CLI: restore and harden qr --remote pairing behavior (#18166) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: a79fc2a3c69234cdad1635c0cd25669fcdb4e11b Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + src/cli/qr-cli.test.ts | 18 ++++++++++++++++++ src/cli/qr-cli.ts | 11 +++++++++++ 3 files changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f84b3253b..b9058a58c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Auto-reply/TTS: keep tool-result media delivery enabled in group chats and native command sessions (while still suppressing tool summary text) so `NO_REPLY` follow-ups do not drop successful TTS audio. (#17991) Thanks @zerone0x. - Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope. - CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091) +- CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky. ## 2026.2.15 diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 263a640b4..361186e21 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -183,6 +183,24 @@ describe("registerQrCli", () => { expect(payload.urlSource).toBe("gateway.remote.url"); }); + it("errors when --remote is set but no remote URL is configured", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { mode: "token", token: "tok" }, + }, + }); + + const program = new Command(); + registerQrCli(program); + + await expect(program.parseAsync(["qr", "--remote"], { from: "user" })).rejects.toThrow("exit"); + + const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); + expect(output).toContain("qr --remote requires"); + }); + it("prefers gateway.remote.url over tailscale when --remote is set", async () => { loadConfig.mockReturnValue({ gateway: { diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index 06de0291a..947a24b2d 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -95,6 +95,17 @@ export function registerQrCli(program: Command) { cfg.gateway.auth.token = undefined; } } + if (wantsRemote && !opts.url && !opts.publicUrl) { + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + const remoteUrl = cfg.gateway?.remote?.url; + const hasRemoteUrl = typeof remoteUrl === "string" && remoteUrl.trim().length > 0; + const hasTailscaleServe = tailscaleMode === "serve" || tailscaleMode === "funnel"; + if (!hasRemoteUrl && !hasTailscaleServe) { + throw new Error( + "qr --remote requires gateway.remote.url (or gateway.tailscale.mode=serve/funnel).", + ); + } + } const explicitUrl = typeof opts.url === "string" && opts.url.trim()