From 599c890221f0226d1369a38e031b8e55abde5d31 Mon Sep 17 00:00:00 2001 From: Mariano Date: Mon, 16 Feb 2026 14:48:14 +0000 Subject: [PATCH] CLI/Gateway: restore qr flow with --remote support (clean) (#18091) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 4bee77ce06bf30b3ecfdeb2e48f8aae8060f8e93 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/clawbot-cli.ts | 7 + src/cli/program/register.subclis.e2e.test.ts | 1 + src/cli/program/register.subclis.ts | 16 + src/cli/qr-cli.test.ts | 213 ++++++++++ src/cli/qr-cli.ts | 172 ++++++++ src/pairing/setup-code.test.ts | 138 +++++++ src/pairing/setup-code.ts | 409 +++++++++++++++++++ 8 files changed, 957 insertions(+) create mode 100644 src/cli/clawbot-cli.ts create mode 100644 src/cli/qr-cli.test.ts create mode 100644 src/cli/qr-cli.ts create mode 100644 src/pairing/setup-code.test.ts create mode 100644 src/pairing/setup-code.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b540aa70..9a0a44317 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/clawbot-cli.ts b/src/cli/clawbot-cli.ts new file mode 100644 index 000000000..b4c82a558 --- /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.e2e.test.ts b/src/cli/program/register.subclis.e2e.test.ts index c3c0c25df..052818c3c 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 48fc16965..6d0078979 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -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", diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts new file mode 100644 index 000000000..087cb622d --- /dev/null +++ b/src/cli/qr-cli.test.ts @@ -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 "); + }); + + 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(); + }); +}); diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts new file mode 100644 index 000000000..06de0291a --- /dev/null +++ b/src/cli/qr-cli.ts @@ -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 { + return new Promise((resolve) => { + qrcode.generate(data, { small: true }, (output: string) => { + resolve(output); + }); + }); +} + +function readDevicePairPublicUrlFromConfig(cfg: ReturnType): 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 ", "Override gateway URL used in the setup payload") + .option("--public-url ", "Override gateway public URL used in the setup payload") + .option("--token ", "Override gateway token for setup payload") + .option("--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 ")}`, + ); + + defaultRuntime.log(lines.join("\n")); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); +} diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts new file mode 100644 index 000000000..c82198729 --- /dev/null +++ b/src/pairing/setup-code.test.ts @@ -0,0 +1,138 @@ +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", + }); + }); + + 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 new file mode 100644 index 000000000..8d47ec441 --- /dev/null +++ b/src/pairing/setup-code.ts @@ -0,0 +1,409 @@ +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; + preferRemoteUrl?: boolean; + 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; + preferRemoteUrl?: boolean; + 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 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); + if (!host) { + return { error: "Tailscale Serve is enabled, but MagicDNS could not be resolved." }; + } + return { url: `wss://${host}`, source: `gateway.tailscale.mode=${tailscaleMode}` }; + } + + if (remoteUrl) { + return { url: remoteUrl, 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, + preferRemoteUrl: options.preferRemoteUrl, + 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", + }; +}