fix: honor qr remote precedence and wire clawbot alias

This commit is contained in:
Mariano Belinky
2026-02-16 14:10:56 +00:00
parent d5cdac5d65
commit 4bee77ce06
7 changed files with 97 additions and 25 deletions

View File

@@ -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

View File

@@ -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();
});

View File

@@ -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",

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -23,6 +23,7 @@ export type PairingSetupCommandRunner = (
export type ResolvePairingSetupOptions = {
env?: NodeJS.ProcessEnv;
publicUrl?: string;
preferRemoteUrl?: boolean;
forceSecure?: boolean;
runCommandWithTimeout?: PairingSetupCommandRunner;
networkInterfaces?: () => ReturnType<typeof os.networkInterfaces>;
@@ -292,6 +293,7 @@ async function resolveGatewayUrl(
opts: {
env: NodeJS.ProcessEnv;
publicUrl?: string;
preferRemoteUrl?: boolean;
forceSecure?: boolean;
runCommandWithTimeout?: PairingSetupCommandRunner;
networkInterfaces: () => ReturnType<typeof os.networkInterfaces>;
@@ -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,