diff --git a/src/cli/clawbot-cli.ts b/src/cli/clawbot-cli.ts new file mode 100644 index 00000000000..b4c82a5582a --- /dev/null +++ b/src/cli/clawbot-cli.ts @@ -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); +} diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 48fc169654f..38af67e542e 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -168,6 +168,14 @@ 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: "pairing", description: "Pairing helpers", diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index c9e42e3c6c7..819754b4248 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -118,14 +118,14 @@ describe("registerQrCli", () => { it("uses gateway.remote.url when --remote is set (ignores device-pair publicUrl)", async () => { loadConfig.mockReturnValue({ gateway: { - auth: { mode: "token", token: "local-tok" }, remote: { url: "wss://remote.example.com:444", token: "remote-tok" }, + auth: { mode: "token", token: "local-tok" }, }, plugins: { entries: { "device-pair": { config: { - publicUrl: "ws://plugin.example.com:18789", + publicUrl: "wss://wrong.example.com:443", }, }, }, @@ -134,7 +134,6 @@ describe("registerQrCli", () => { const program = new Command(); registerQrCli(program); - await program.parseAsync(["qr", "--setup-code-only", "--remote"], { from: "user" }); const expected = encodePairingSetupCode({ @@ -144,21 +143,35 @@ describe("registerQrCli", () => { expect(runtime.log).toHaveBeenCalledWith(expected); }); - it("errors when --remote is set but no remote URL is configured", async () => { + it("reports gateway.remote.url as source in --remote json output", async () => { loadConfig.mockReturnValue({ gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { mode: "token", token: "tok" }, + 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" }); - 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"); + 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"); }); }); diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index 8b0c35cb326..201cc88de97 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -40,7 +40,7 @@ export function registerQrCli(program: Command) { .description("Generate an iOS pairing QR code and setup code") .option( "--remote", - "Prefer gateway.remote.url (or tailscale serve/funnel) for the setup payload (ignores device-pair publicUrl)", + "Use gateway.remote.url and gateway.remote token/password (ignores device-pair publicUrl)", false, ) .option("--url ", "Override gateway URL used in the setup payload") @@ -69,6 +69,7 @@ export function registerQrCli(program: Command) { 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; @@ -77,8 +78,6 @@ export function registerQrCli(program: Command) { cfg.gateway.auth.mode = "password"; cfg.gateway.auth.password = password; } - - const wantsRemote = opts.remote === true; if (wantsRemote && !token && !password) { const remoteToken = typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : ""; @@ -86,9 +85,6 @@ export function registerQrCli(program: Command) { typeof cfg.gateway?.remote?.password === "string" ? cfg.gateway.remote.password.trim() : ""; - - // In remote mode, prefer the client-side remote credentials. These are expected to match the - // remote gateway's auth settings (gateway.remote.token/password ~= gateway.auth.token/password). if (remoteToken) { cfg.gateway.auth.mode = "token"; cfg.gateway.auth.token = remoteToken; @@ -100,30 +96,34 @@ export function registerQrCli(program: Command) { } } - 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 publicUrl = + const explicitUrl = typeof opts.url === "string" && opts.url.trim() ? opts.url.trim() : typeof opts.publicUrl === "string" && opts.publicUrl.trim() ? opts.publicUrl.trim() - : wantsRemote - ? undefined - : readDevicePairPublicUrlFromConfig(cfg); + : 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 resolved = await resolvePairingSetupFromConfig(cfg, { publicUrl, - forceSecure: wantsRemote || undefined, 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 new file mode 100644 index 00000000000..d6905c622a6 --- /dev/null +++ b/src/pairing/setup-code.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "./setup-code.js"; + +describe("pairing setup code", () => { + it("encodes payload as base64url JSON", () => { + const code = encodePairingSetupCode({ + url: "wss://gateway.example.com:443", + token: "abc", + }); + + expect(code).toBe("eyJ1cmwiOiJ3c3M6Ly9nYXRld2F5LmV4YW1wbGUuY29tOjQ0MyIsInRva2VuIjoiYWJjIn0"); + }); + + it("resolves custom bind + token auth", async () => { + const resolved = await resolvePairingSetupFromConfig({ + gateway: { + bind: "custom", + customBindHost: "gateway.local", + port: 19001, + auth: { mode: "token", token: "tok_123" }, + }, + }); + + expect(resolved).toEqual({ + ok: true, + payload: { + url: "ws://gateway.local:19001", + token: "tok_123", + password: undefined, + }, + authLabel: "token", + urlSource: "gateway.bind=custom", + }); + }); + + it("honors env token override", async () => { + const resolved = await resolvePairingSetupFromConfig( + { + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { mode: "token", token: "old" }, + }, + }, + { + env: { + OPENCLAW_GATEWAY_TOKEN: "new-token", + }, + }, + ); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) { + throw new Error("expected setup resolution to succeed"); + } + expect(resolved.payload.token).toBe("new-token"); + }); + + it("errors when gateway is loopback only", async () => { + const resolved = await resolvePairingSetupFromConfig({ + gateway: { + bind: "loopback", + auth: { mode: "token", token: "tok" }, + }, + }); + + expect(resolved.ok).toBe(false); + if (resolved.ok) { + throw new Error("expected setup resolution to fail"); + } + expect(resolved.error).toContain("only bound to loopback"); + }); + + it("uses tailscale serve DNS when available", 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" }, + auth: { mode: "password", password: "secret" }, + }, + }, + { + runCommandWithTimeout, + }, + ); + + expect(resolved).toEqual({ + ok: true, + payload: { + url: "wss://mb-server.tailnet.ts.net", + token: undefined, + password: "secret", + }, + authLabel: "password", + urlSource: "gateway.tailscale.mode=serve", + }); + }); +}); diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts new file mode 100644 index 00000000000..3881e03d4f0 --- /dev/null +++ b/src/pairing/setup-code.ts @@ -0,0 +1,401 @@ +import os from "node:os"; +import type { OpenClawConfig } from "../config/types.js"; + +const DEFAULT_GATEWAY_PORT = 18789; + +export type PairingSetupPayload = { + url: string; + token?: string; + password?: string; +}; + +export type PairingSetupCommandResult = { + code: number | null; + stdout: string; + stderr?: string; +}; + +export type PairingSetupCommandRunner = ( + argv: string[], + opts: { timeoutMs: number }, +) => Promise; + +export type ResolvePairingSetupOptions = { + env?: NodeJS.ProcessEnv; + publicUrl?: string; + forceSecure?: boolean; + runCommandWithTimeout?: PairingSetupCommandRunner; + networkInterfaces?: () => ReturnType; +}; + +export type PairingSetupResolution = + | { + ok: true; + payload: PairingSetupPayload; + authLabel: "token" | "password"; + urlSource: string; + } + | { + ok: false; + error: string; + }; + +type ResolveUrlResult = { + url?: string; + source?: string; + error?: string; +}; + +type ResolveAuthResult = { + token?: string; + password?: string; + label?: "token" | "password"; + error?: string; +}; + +function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + try { + const parsed = new URL(trimmed); + const scheme = parsed.protocol.replace(":", ""); + if (!scheme) { + return null; + } + const resolvedScheme = scheme === "http" ? "ws" : scheme === "https" ? "wss" : scheme; + if (resolvedScheme !== "ws" && resolvedScheme !== "wss") { + return null; + } + const host = parsed.hostname; + if (!host) { + return null; + } + const port = parsed.port ? `:${parsed.port}` : ""; + return `${resolvedScheme}://${host}${port}`; + } catch { + // Fall through to host:port parsing. + } + + const withoutPath = trimmed.split("/")[0] ?? ""; + if (!withoutPath) { + return null; + } + return `${schemeFallback}://${withoutPath}`; +} + +function resolveGatewayPort(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): number { + const envRaw = env.OPENCLAW_GATEWAY_PORT?.trim() || env.CLAWDBOT_GATEWAY_PORT?.trim(); + if (envRaw) { + const parsed = Number.parseInt(envRaw, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + const configPort = cfg.gateway?.port; + if (typeof configPort === "number" && Number.isFinite(configPort) && configPort > 0) { + return configPort; + } + return DEFAULT_GATEWAY_PORT; +} + +function resolveScheme( + cfg: OpenClawConfig, + opts?: { + forceSecure?: boolean; + }, +): "ws" | "wss" { + if (opts?.forceSecure) { + return "wss"; + } + return cfg.gateway?.tls?.enabled === true ? "wss" : "ws"; +} + +function parseIPv4Octets(address: string): [number, number, number, number] | null { + const parts = address.split("."); + if (parts.length !== 4) { + return null; + } + const octets = parts.map((part) => Number.parseInt(part, 10)); + if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { + return null; + } + return [octets[0], octets[1], octets[2], octets[3]]; +} + +function isPrivateIPv4(address: string): boolean { + const octets = parseIPv4Octets(address); + if (!octets) { + return false; + } + const [a, b] = octets; + if (a === 10) { + return true; + } + if (a === 172 && b >= 16 && b <= 31) { + return true; + } + if (a === 192 && b === 168) { + return true; + } + return false; +} + +function isTailnetIPv4(address: string): boolean { + const octets = parseIPv4Octets(address); + if (!octets) { + return false; + } + const [a, b] = octets; + return a === 100 && b >= 64 && b <= 127; +} + +function pickLanIPv4( + networkInterfaces: () => ReturnType, +): string | null { + const nets = networkInterfaces(); + for (const entries of Object.values(nets)) { + if (!entries) { + continue; + } + for (const entry of entries) { + const family = entry?.family; + const isIpv4 = family === "IPv4"; + if (!entry || entry.internal || !isIpv4) { + continue; + } + const address = entry.address?.trim() ?? ""; + if (!address) { + continue; + } + if (isPrivateIPv4(address)) { + return address; + } + } + } + return null; +} + +function pickTailnetIPv4( + networkInterfaces: () => ReturnType, +): string | null { + const nets = networkInterfaces(); + for (const entries of Object.values(nets)) { + if (!entries) { + continue; + } + for (const entry of entries) { + const family = entry?.family; + const isIpv4 = family === "IPv4"; + if (!entry || entry.internal || !isIpv4) { + continue; + } + const address = entry.address?.trim() ?? ""; + if (!address) { + continue; + } + if (isTailnetIPv4(address)) { + return address; + } + } + } + return null; +} + +function parsePossiblyNoisyJsonObject(raw: string): Record { + const start = raw.indexOf("{"); + const end = raw.lastIndexOf("}"); + if (start === -1 || end <= start) { + return {}; + } + try { + return JSON.parse(raw.slice(start, end + 1)) as Record; + } catch { + return {}; + } +} + +async function resolveTailnetHost( + runCommandWithTimeout?: PairingSetupCommandRunner, +): Promise { + if (!runCommandWithTimeout) { + return null; + } + const candidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; + for (const candidate of candidates) { + try { + const result = await runCommandWithTimeout([candidate, "status", "--json"], { + timeoutMs: 5000, + }); + if (result.code !== 0) { + continue; + } + const raw = result.stdout.trim(); + if (!raw) { + continue; + } + const parsed = parsePossiblyNoisyJsonObject(raw); + const self = + typeof parsed.Self === "object" && parsed.Self !== null + ? (parsed.Self as Record) + : undefined; + const dns = typeof self?.DNSName === "string" ? self.DNSName : undefined; + if (dns && dns.length > 0) { + return dns.replace(/\.$/, ""); + } + const ips = Array.isArray(self?.TailscaleIPs) ? (self.TailscaleIPs as string[]) : []; + if (ips.length > 0) { + return ips[0] ?? null; + } + } catch { + continue; + } + } + return null; +} + +function resolveAuth(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): ResolveAuthResult { + const mode = cfg.gateway?.auth?.mode; + const token = + env.OPENCLAW_GATEWAY_TOKEN?.trim() || + env.CLAWDBOT_GATEWAY_TOKEN?.trim() || + cfg.gateway?.auth?.token?.trim(); + const password = + env.OPENCLAW_GATEWAY_PASSWORD?.trim() || + env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || + cfg.gateway?.auth?.password?.trim(); + + if (mode === "password") { + if (!password) { + return { error: "Gateway auth is set to password, but no password is configured." }; + } + return { password, label: "password" }; + } + if (mode === "token") { + if (!token) { + return { error: "Gateway auth is set to token, but no token is configured." }; + } + return { token, label: "token" }; + } + if (token) { + return { token, label: "token" }; + } + if (password) { + return { password, label: "password" }; + } + return { error: "Gateway auth is not configured (no token or password)." }; +} + +async function resolveGatewayUrl( + cfg: OpenClawConfig, + opts: { + env: NodeJS.ProcessEnv; + publicUrl?: string; + forceSecure?: boolean; + runCommandWithTimeout?: PairingSetupCommandRunner; + networkInterfaces: () => ReturnType; + }, +): Promise { + const scheme = resolveScheme(cfg, { forceSecure: opts.forceSecure }); + const port = resolveGatewayPort(cfg, opts.env); + + if (typeof opts.publicUrl === "string" && opts.publicUrl.trim()) { + const url = normalizeUrl(opts.publicUrl, scheme); + if (url) { + return { url, source: "plugins.entries.device-pair.config.publicUrl" }; + } + return { error: "Configured publicUrl is invalid." }; + } + + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + if (tailscaleMode === "serve" || tailscaleMode === "funnel") { + const host = await resolveTailnetHost(opts.runCommandWithTimeout); + if (!host) { + return { error: "Tailscale Serve is enabled, but MagicDNS could not be resolved." }; + } + 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" }; + } + } + + const bind = cfg.gateway?.bind ?? "loopback"; + if (bind === "custom") { + const host = cfg.gateway?.customBindHost?.trim(); + if (host) { + return { url: `${scheme}://${host}:${port}`, source: "gateway.bind=custom" }; + } + return { error: "gateway.bind=custom requires gateway.customBindHost." }; + } + + if (bind === "tailnet") { + const host = pickTailnetIPv4(opts.networkInterfaces); + if (host) { + return { url: `${scheme}://${host}:${port}`, source: "gateway.bind=tailnet" }; + } + return { error: "gateway.bind=tailnet set, but no tailnet IP was found." }; + } + + if (bind === "lan") { + const host = pickLanIPv4(opts.networkInterfaces); + if (host) { + return { url: `${scheme}://${host}:${port}`, source: "gateway.bind=lan" }; + } + return { error: "gateway.bind=lan set, but no private LAN IP was found." }; + } + + return { + error: + "Gateway is only bound to loopback. Set gateway.bind=lan, enable tailscale serve, or configure plugins.entries.device-pair.config.publicUrl.", + }; +} + +export function encodePairingSetupCode(payload: PairingSetupPayload): string { + const json = JSON.stringify(payload); + const base64 = Buffer.from(json, "utf8").toString("base64"); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +export async function resolvePairingSetupFromConfig( + cfg: OpenClawConfig, + options: ResolvePairingSetupOptions = {}, +): Promise { + const env = options.env ?? process.env; + const auth = resolveAuth(cfg, env); + if (auth.error) { + return { ok: false, error: auth.error }; + } + + const urlResult = await resolveGatewayUrl(cfg, { + env, + publicUrl: options.publicUrl, + forceSecure: options.forceSecure, + runCommandWithTimeout: options.runCommandWithTimeout, + networkInterfaces: options.networkInterfaces ?? os.networkInterfaces, + }); + + if (!urlResult.url) { + return { ok: false, error: urlResult.error ?? "Gateway URL unavailable." }; + } + + if (!auth.label) { + return { ok: false, error: "Gateway auth is not configured (no token or password)." }; + } + + return { + ok: true, + payload: { + url: urlResult.url, + token: auth.token, + password: auth.password, + }, + authLabel: auth.label, + urlSource: urlResult.source ?? "unknown", + }; +}