diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b540aa70f..9a0a443179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ Docs: https://docs.openclaw.ai - TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07. - TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry. - CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07. +- 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) ## 2026.2.14 diff --git a/src/cli/program/register.subclis.e2e.test.ts b/src/cli/program/register.subclis.e2e.test.ts index c3c0c25df0..052818c3c7 100644 --- a/src/cli/program/register.subclis.e2e.test.ts +++ b/src/cli/program/register.subclis.e2e.test.ts @@ -62,6 +62,7 @@ describe("registerSubCliCommands", () => { const names = program.commands.map((cmd) => cmd.name()); expect(names).toContain("acp"); expect(names).toContain("gateway"); + expect(names).toContain("clawbot"); expect(registerAcpCli).not.toHaveBeenCalled(); }); diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 38af67e542..6d0078979b 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -176,6 +176,14 @@ const entries: SubCliEntry[] = [ mod.registerQrCli(program); }, }, + { + name: "clawbot", + description: "Legacy clawbot command aliases", + register: async (program) => { + const mod = await import("../clawbot-cli.js"); + mod.registerClawbotCli(program); + }, + }, { name: "pairing", description: "Pairing helpers", diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 819754b424..087cb622de 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -174,4 +174,40 @@ describe("registerQrCli", () => { expect(payload.auth).toBe("token"); expect(payload.urlSource).toBe("gateway.remote.url"); }); + + it("prefers gateway.remote.url over tailscale when --remote is set", async () => { + loadConfig.mockReturnValue({ + gateway: { + tailscale: { mode: "serve" }, + remote: { url: "wss://remote.example.com:444", token: "remote-tok" }, + auth: { mode: "token", token: "local-tok" }, + }, + plugins: { + entries: { + "device-pair": { + config: { + publicUrl: "wss://wrong.example.com:443", + }, + }, + }, + }, + }); + runCommandWithTimeout.mockResolvedValue({ + code: 0, + stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}', + stderr: "", + }); + + const program = new Command(); + registerQrCli(program); + await program.parseAsync(["qr", "--json", "--remote"], { from: "user" }); + + const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as { + gatewayUrl?: string; + urlSource?: string; + }; + expect(payload.gatewayUrl).toBe("wss://remote.example.com:444"); + expect(payload.urlSource).toBe("gateway.remote.url"); + expect(runCommandWithTimeout).not.toHaveBeenCalled(); + }); }); diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index 201cc88de9..06de0291a4 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -102,28 +102,12 @@ export function registerQrCli(program: Command) { : typeof opts.publicUrl === "string" && opts.publicUrl.trim() ? opts.publicUrl.trim() : undefined; - if (wantsRemote && !explicitUrl) { - const existing = cfg.plugins?.entries?.["device-pair"]; - if (existing && typeof existing === "object") { - cfg.plugins = { - ...cfg.plugins, - entries: { - ...cfg.plugins?.entries, - "device-pair": { - ...existing, - config: { - ...existing.config, - publicUrl: undefined, - }, - }, - }, - }; - } - } - const publicUrl = explicitUrl ?? readDevicePairPublicUrlFromConfig(cfg); + const publicUrl = + explicitUrl ?? (wantsRemote ? undefined : readDevicePairPublicUrlFromConfig(cfg)); const resolved = await resolvePairingSetupFromConfig(cfg, { publicUrl, + preferRemoteUrl: wantsRemote, runCommandWithTimeout: async (argv, runOpts) => await runCommandWithTimeout(argv, { timeoutMs: runOpts.timeoutMs, diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index d6905c622a..c82198729a 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -101,4 +101,38 @@ describe("pairing setup code", () => { urlSource: "gateway.tailscale.mode=serve", }); }); + + it("prefers gateway.remote.url over tailscale when requested", async () => { + const runCommandWithTimeout = vi.fn(async () => ({ + code: 0, + stdout: '{"Self":{"DNSName":"mb-server.tailnet.ts.net."}}', + stderr: "", + })); + + const resolved = await resolvePairingSetupFromConfig( + { + gateway: { + tailscale: { mode: "serve" }, + remote: { url: "wss://remote.example.com:444" }, + auth: { mode: "token", token: "tok_123" }, + }, + }, + { + preferRemoteUrl: true, + runCommandWithTimeout, + }, + ); + + expect(resolved).toEqual({ + ok: true, + payload: { + url: "wss://remote.example.com:444", + token: "tok_123", + password: undefined, + }, + authLabel: "token", + urlSource: "gateway.remote.url", + }); + expect(runCommandWithTimeout).not.toHaveBeenCalled(); + }); }); diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index 3881e03d4f..8d47ec441a 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -23,6 +23,7 @@ export type PairingSetupCommandRunner = ( export type ResolvePairingSetupOptions = { env?: NodeJS.ProcessEnv; publicUrl?: string; + preferRemoteUrl?: boolean; forceSecure?: boolean; runCommandWithTimeout?: PairingSetupCommandRunner; networkInterfaces?: () => ReturnType; @@ -292,6 +293,7 @@ async function resolveGatewayUrl( opts: { env: NodeJS.ProcessEnv; publicUrl?: string; + preferRemoteUrl?: boolean; forceSecure?: boolean; runCommandWithTimeout?: PairingSetupCommandRunner; networkInterfaces: () => ReturnType; @@ -308,6 +310,15 @@ async function resolveGatewayUrl( return { error: "Configured publicUrl is invalid." }; } + const remoteUrlRaw = cfg.gateway?.remote?.url; + const remoteUrl = + typeof remoteUrlRaw === "string" && remoteUrlRaw.trim() + ? normalizeUrl(remoteUrlRaw, scheme) + : null; + if (opts.preferRemoteUrl && remoteUrl) { + return { url: remoteUrl, source: "gateway.remote.url" }; + } + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; if (tailscaleMode === "serve" || tailscaleMode === "funnel") { const host = await resolveTailnetHost(opts.runCommandWithTimeout); @@ -317,12 +328,8 @@ async function resolveGatewayUrl( return { url: `wss://${host}`, source: `gateway.tailscale.mode=${tailscaleMode}` }; } - const remoteUrl = cfg.gateway?.remote?.url; - if (typeof remoteUrl === "string" && remoteUrl.trim()) { - const url = normalizeUrl(remoteUrl, scheme); - if (url) { - return { url, source: "gateway.remote.url" }; - } + if (remoteUrl) { + return { url: remoteUrl, source: "gateway.remote.url" }; } const bind = cfg.gateway?.bind ?? "loopback"; @@ -375,6 +382,7 @@ export async function resolvePairingSetupFromConfig( const urlResult = await resolveGatewayUrl(cfg, { env, publicUrl: options.publicUrl, + preferRemoteUrl: options.preferRemoteUrl, forceSecure: options.forceSecure, runCommandWithTimeout: options.runCommandWithTimeout, networkInterfaces: options.networkInterfaces ?? os.networkInterfaces,