CLI/Gateway: restore qr flow with --remote support (clean) (#18091)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 4bee77ce06
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-16 14:48:14 +00:00
committed by GitHub
parent fec4be8dec
commit 599c890221
8 changed files with 957 additions and 0 deletions

7
src/cli/clawbot-cli.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { Command } from "commander";
import { registerQrCli } from "./qr-cli.js";
export function registerClawbotCli(program: Command) {
const clawbot = program.command("clawbot").description("Legacy clawbot command aliases");
registerQrCli(clawbot);
}

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

@@ -168,6 +168,22 @@ const entries: SubCliEntry[] = [
mod.registerWebhooksCli(program);
},
},
{
name: "qr",
description: "Generate iOS pairing QR/setup code",
register: async (program) => {
const mod = await import("../qr-cli.js");
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",

213
src/cli/qr-cli.test.ts Normal file
View File

@@ -0,0 +1,213 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { encodePairingSetupCode } from "../pairing/setup-code.js";
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const loadConfig = vi.fn();
const runCommandWithTimeout = vi.fn();
const qrGenerate = vi.fn((_input, _opts, cb: (output: string) => void) => {
cb("ASCII-QR");
});
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
vi.mock("../config/config.js", () => ({ loadConfig }));
vi.mock("../process/exec.js", () => ({ runCommandWithTimeout }));
vi.mock("qrcode-terminal", () => ({
default: {
generate: qrGenerate,
},
}));
const { registerQrCli } = await import("./qr-cli.js");
describe("registerQrCli", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("prints setup code only when requested", async () => {
loadConfig.mockReturnValue({
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: { mode: "token", token: "tok" },
},
});
const program = new Command();
registerQrCli(program);
await program.parseAsync(["qr", "--setup-code-only"], { from: "user" });
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
token: "tok",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(qrGenerate).not.toHaveBeenCalled();
});
it("renders ASCII QR by default", async () => {
loadConfig.mockReturnValue({
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: { mode: "token", token: "tok" },
},
});
const program = new Command();
registerQrCli(program);
await program.parseAsync(["qr"], { from: "user" });
expect(qrGenerate).toHaveBeenCalledTimes(1);
const output = runtime.log.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
expect(output).toContain("Pairing QR");
expect(output).toContain("ASCII-QR");
expect(output).toContain("Gateway:");
expect(output).toContain("openclaw devices approve <requestId>");
});
it("accepts --token override when config has no auth", async () => {
loadConfig.mockReturnValue({
gateway: {
bind: "custom",
customBindHost: "gateway.local",
},
});
const program = new Command();
registerQrCli(program);
await program.parseAsync(["qr", "--setup-code-only", "--token", "override-token"], {
from: "user",
});
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
token: "override-token",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
});
it("exits with error when gateway config is not pairable", async () => {
loadConfig.mockReturnValue({
gateway: {
bind: "loopback",
auth: { mode: "token", token: "tok" },
},
});
const program = new Command();
registerQrCli(program);
await expect(program.parseAsync(["qr"], { from: "user" })).rejects.toThrow("exit");
const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
expect(output).toContain("only bound to loopback");
});
it("uses gateway.remote.url when --remote is set (ignores device-pair publicUrl)", async () => {
loadConfig.mockReturnValue({
gateway: {
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",
},
},
},
},
});
const program = new Command();
registerQrCli(program);
await program.parseAsync(["qr", "--setup-code-only", "--remote"], { from: "user" });
const expected = encodePairingSetupCode({
url: "wss://remote.example.com:444",
token: "remote-tok",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
});
it("reports gateway.remote.url as source in --remote json output", async () => {
loadConfig.mockReturnValue({
gateway: {
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",
},
},
},
},
});
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 {
setupCode?: string;
gatewayUrl?: string;
auth?: string;
urlSource?: string;
};
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
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();
});
});

172
src/cli/qr-cli.ts Normal file
View File

@@ -0,0 +1,172 @@
import type { Command } from "commander";
import qrcode from "qrcode-terminal";
import { loadConfig } from "../config/config.js";
import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime } from "../runtime.js";
import { theme } from "../terminal/theme.js";
type QrCliOptions = {
json?: boolean;
setupCodeOnly?: boolean;
ascii?: boolean;
remote?: boolean;
url?: string;
publicUrl?: string;
token?: string;
password?: string;
};
function renderQrAscii(data: string): Promise<string> {
return new Promise((resolve) => {
qrcode.generate(data, { small: true }, (output: string) => {
resolve(output);
});
});
}
function readDevicePairPublicUrlFromConfig(cfg: ReturnType<typeof loadConfig>): string | undefined {
const value = cfg.plugins?.entries?.["device-pair"]?.config?.["publicUrl"];
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function registerQrCli(program: Command) {
program
.command("qr")
.description("Generate an iOS pairing QR code and setup code")
.option(
"--remote",
"Use gateway.remote.url and gateway.remote token/password (ignores device-pair publicUrl)",
false,
)
.option("--url <url>", "Override gateway URL used in the setup payload")
.option("--public-url <url>", "Override gateway public URL used in the setup payload")
.option("--token <token>", "Override gateway token for setup payload")
.option("--password <password>", "Override gateway password for setup payload")
.option("--setup-code-only", "Print only the setup code", false)
.option("--no-ascii", "Skip ASCII QR rendering")
.option("--json", "Output JSON", false)
.action(async (opts: QrCliOptions) => {
try {
if (opts.token && opts.password) {
throw new Error("Use either --token or --password, not both.");
}
const loaded = loadConfig();
const cfg = {
...loaded,
gateway: {
...loaded.gateway,
auth: {
...loaded.gateway?.auth,
},
},
};
const token = typeof opts.token === "string" ? opts.token.trim() : "";
const password = typeof opts.password === "string" ? opts.password.trim() : "";
const wantsRemote = opts.remote === true;
if (token) {
cfg.gateway.auth.mode = "token";
cfg.gateway.auth.token = token;
}
if (password) {
cfg.gateway.auth.mode = "password";
cfg.gateway.auth.password = password;
}
if (wantsRemote && !token && !password) {
const remoteToken =
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : "";
const remotePassword =
typeof cfg.gateway?.remote?.password === "string"
? cfg.gateway.remote.password.trim()
: "";
if (remoteToken) {
cfg.gateway.auth.mode = "token";
cfg.gateway.auth.token = remoteToken;
cfg.gateway.auth.password = undefined;
} else if (remotePassword) {
cfg.gateway.auth.mode = "password";
cfg.gateway.auth.password = remotePassword;
cfg.gateway.auth.token = undefined;
}
}
const explicitUrl =
typeof opts.url === "string" && opts.url.trim()
? opts.url.trim()
: typeof opts.publicUrl === "string" && opts.publicUrl.trim()
? opts.publicUrl.trim()
: undefined;
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,
}),
});
if (!resolved.ok) {
throw new Error(resolved.error);
}
const setupCode = encodePairingSetupCode(resolved.payload);
if (opts.setupCodeOnly) {
defaultRuntime.log(setupCode);
return;
}
if (opts.json) {
defaultRuntime.log(
JSON.stringify(
{
setupCode,
gatewayUrl: resolved.payload.url,
auth: resolved.authLabel,
urlSource: resolved.urlSource,
},
null,
2,
),
);
return;
}
const lines: string[] = [
theme.heading("Pairing QR"),
"Scan this with the OpenClaw iOS app (Onboarding -> Scan QR).",
"",
];
if (opts.ascii !== false) {
const qrAscii = await renderQrAscii(setupCode);
lines.push(qrAscii.trimEnd(), "");
}
lines.push(
`${theme.muted("Setup code:")} ${setupCode}`,
`${theme.muted("Gateway:")} ${resolved.payload.url}`,
`${theme.muted("Auth:")} ${resolved.authLabel}`,
`${theme.muted("Source:")} ${resolved.urlSource}`,
"",
"Approve after scan with:",
` ${theme.command("openclaw devices list")}`,
` ${theme.command("openclaw devices approve <requestId>")}`,
);
defaultRuntime.log(lines.join("\n"));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}