From aab3c4d2f039d3b2c39362ad1c641755e8e4b2e0 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sun, 15 Feb 2026 18:35:13 +0000 Subject: [PATCH 1/3] CLI: restore qr --remote --- src/cli/qr-cli.test.ts | 164 ++++++++++++++++++++++++++++++++++++++++ src/cli/qr-cli.ts | 167 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 src/cli/qr-cli.test.ts create mode 100644 src/cli/qr-cli.ts diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts new file mode 100644 index 0000000000..32f01b34d3 --- /dev/null +++ b/src/cli/qr-cli.test.ts @@ -0,0 +1,164 @@ +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" }, + auth: { mode: "token", token: "tok" }, + }, + plugins: { + entries: { + "device-pair": { + config: { + publicUrl: "ws://plugin.example.com:18789", + }, + }, + }, + }, + }); + + 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: "tok", + }); + expect(runtime.log).toHaveBeenCalledWith(expected); + }); + + it("errors when --remote is set but no remote URL is configured", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { mode: "token", token: "tok" }, + }, + }); + + const program = new Command(); + registerQrCli(program); + + 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"); + }); +}); diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts new file mode 100644 index 0000000000..aef119aa77 --- /dev/null +++ b/src/cli/qr-cli.ts @@ -0,0 +1,167 @@ +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", + "Prefer gateway.remote.url (or tailscale serve/funnel) for the setup payload (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() : ""; + if (token) { + cfg.gateway.auth.mode = "token"; + cfg.gateway.auth.token = token; + } + if (password) { + cfg.gateway.auth.mode = "password"; + cfg.gateway.auth.password = password; + } + + const wantsRemote = opts.remote === true; + 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 = + typeof opts.url === "string" && opts.url.trim() + ? opts.url.trim() + : typeof opts.publicUrl === "string" && opts.publicUrl.trim() + ? opts.publicUrl.trim() + : wantsRemote + ? undefined + : readDevicePairPublicUrlFromConfig(cfg); + + const resolved = await resolvePairingSetupFromConfig(cfg, { + publicUrl, + forceSecure: wantsRemote || undefined, + 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); + } + }); +} From 5ccabe9e639c28c6a1b63f44e86a428ae4d2cb58 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Mon, 16 Feb 2026 12:56:50 +0000 Subject: [PATCH 2/3] CLI: prefer gateway.remote auth for qr --remote --- src/cli/qr-cli.test.ts | 6 +++--- src/cli/qr-cli.ts | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 32f01b34d3..c9e42e3c6c 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -118,8 +118,8 @@ describe("registerQrCli", () => { it("uses gateway.remote.url when --remote is set (ignores device-pair publicUrl)", async () => { loadConfig.mockReturnValue({ gateway: { - remote: { url: "wss://remote.example.com:444" }, - auth: { mode: "token", token: "tok" }, + auth: { mode: "token", token: "local-tok" }, + remote: { url: "wss://remote.example.com:444", token: "remote-tok" }, }, plugins: { entries: { @@ -139,7 +139,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "wss://remote.example.com:444", - token: "tok", + token: "remote-tok", }); expect(runtime.log).toHaveBeenCalledWith(expected); }); diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index aef119aa77..8b0c35cb32 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -79,6 +79,27 @@ export function registerQrCli(program: Command) { } const wantsRemote = opts.remote === true; + 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() + : ""; + + // 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; + cfg.gateway.auth.password = undefined; + } else if (remotePassword) { + cfg.gateway.auth.mode = "password"; + cfg.gateway.auth.password = remotePassword; + cfg.gateway.auth.token = undefined; + } + } + if (wantsRemote && !opts.url && !opts.publicUrl) { const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const remoteUrl = cfg.gateway?.remote?.url; From 6e8ed7af3a99b910c6ad87bfab0a1845385fe59a Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Mon, 16 Feb 2026 13:03:50 +0000 Subject: [PATCH 3/3] CLI: add qr setup-code flow with --remote support --- extensions/device-pair/index.ts | 441 +++++++--------------------- src/cli/clawbot-cli.ts | 7 + src/cli/program/register.subclis.ts | 8 + src/cli/qr-cli.test.ts | 35 ++- src/cli/qr-cli.ts | 46 +-- src/pairing/setup-code.test.ts | 104 +++++++ src/pairing/setup-code.ts | 401 +++++++++++++++++++++++++ src/plugin-sdk/index.ts | 51 +--- 8 files changed, 689 insertions(+), 404 deletions(-) create mode 100644 src/cli/clawbot-cli.ts create mode 100644 src/pairing/setup-code.test.ts create mode 100644 src/pairing/setup-code.ts diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 3f9049fdc4..edbf7249a0 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,327 +1,30 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import os from "node:os"; -import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk"; +import { + approveDevicePairing, + encodePairingSetupCode, + listDevicePairing, + renderQrPngBase64, + resolvePairingSetupFromConfig, +} from "openclaw/plugin-sdk"; +import qrcode from "qrcode-terminal"; -const DEFAULT_GATEWAY_PORT = 18789; +function renderQrAscii(data: string): Promise { + return new Promise((resolve) => { + qrcode.generate(data, { small: true }, (output: string) => { + resolve(output); + }); + }); +} type DevicePairPluginConfig = { publicUrl?: string; }; -type SetupPayload = { - url: string; - token?: string; - password?: string; -}; - -type ResolveUrlResult = { - url?: string; - source?: string; - error?: string; -}; - -type ResolveAuthResult = { - token?: string; - password?: string; - label?: string; - 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: OpenClawPluginApi["config"]): number { - const envRaw = - process.env.OPENCLAW_GATEWAY_PORT?.trim() || process.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: OpenClawPluginApi["config"], - opts?: { forceSecure?: boolean }, -): "ws" | "wss" { - if (opts?.forceSecure) { - return "wss"; - } - return cfg.gateway?.tls?.enabled === true ? "wss" : "ws"; -} - -function isPrivateIPv4(address: string): boolean { - const parts = address.split("."); - if (parts.length != 4) { - return false; - } - const octets = parts.map((part) => Number.parseInt(part, 10)); - if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { - 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 parts = address.split("."); - if (parts.length !== 4) { - return false; - } - const octets = parts.map((part) => Number.parseInt(part, 10)); - if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { - return false; - } - const [a, b] = octets; - return a === 100 && b >= 64 && b <= 127; -} - -function pickLanIPv4(): string | null { - const nets = os.networkInterfaces(); - for (const entries of Object.values(nets)) { - if (!entries) { - continue; - } - for (const entry of entries) { - const family = entry?.family; - // Check for IPv4 (string "IPv4" on Node 18+, number 4 on older) - const isIpv4 = family === "IPv4" || String(family) === "4"; - if (!entry || entry.internal || !isIpv4) { - continue; - } - const address = entry.address?.trim() ?? ""; - if (!address) { - continue; - } - if (isPrivateIPv4(address)) { - return address; - } - } - } - return null; -} - -function pickTailnetIPv4(): string | null { - const nets = os.networkInterfaces(); - for (const entries of Object.values(nets)) { - if (!entries) { - continue; - } - for (const entry of entries) { - const family = entry?.family; - // Check for IPv4 (string "IPv4" on Node 18+, number 4 on older) - const isIpv4 = family === "IPv4" || String(family) === "4"; - if (!entry || entry.internal || !isIpv4) { - continue; - } - const address = entry.address?.trim() ?? ""; - if (!address) { - continue; - } - if (isTailnetIPv4(address)) { - return address; - } - } - } - return null; -} - -async function resolveTailnetHost(api: OpenClawPluginApi): Promise { - const candidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; - for (const candidate of candidates) { - try { - const result = await api.runtime.system.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 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 {}; - } -} - -function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult { - const mode = cfg.gateway?.auth?.mode; - const token = - process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || - process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || - cfg.gateway?.auth?.token?.trim(); - const password = - process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || - process.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(api: OpenClawPluginApi): Promise { - const cfg = api.config; - const pluginCfg = (api.pluginConfig ?? {}) as DevicePairPluginConfig; - const scheme = resolveScheme(cfg); - const port = resolveGatewayPort(cfg); - - if (typeof pluginCfg.publicUrl === "string" && pluginCfg.publicUrl.trim()) { - const url = normalizeUrl(pluginCfg.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(api); - 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(); - 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(); - 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.", - }; -} - -function encodeSetupCode(payload: SetupPayload): string { - const json = JSON.stringify(payload); - const base64 = Buffer.from(json, "utf8").toString("base64"); - return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); -} - -function formatSetupReply(payload: SetupPayload, authLabel: string): string { - const setupCode = encodeSetupCode(payload); +function formatSetupReply( + payload: { url: string; token?: string; password?: string }, + authLabel: string, +): string { + const setupCode = encodePairingSetupCode(payload); return [ "Pairing setup code generated.", "", @@ -435,25 +138,101 @@ export default function register(api: OpenClawPluginApi) { return { text: `✅ Paired ${label}${platformLabel}.` }; } - const auth = resolveAuth(api.config); - if (auth.error) { - return { text: `Error: ${auth.error}` }; + const pluginCfg = (api.pluginConfig ?? {}) as DevicePairPluginConfig; + const resolved = await resolvePairingSetupFromConfig(api.config, { + publicUrl: pluginCfg.publicUrl, + runCommandWithTimeout: api.runtime?.system?.runCommandWithTimeout + ? async (argv, opts) => + await api.runtime.system.runCommandWithTimeout(argv, { + timeoutMs: opts.timeoutMs, + }) + : undefined, + }); + if (!resolved.ok) { + return { text: `Error: ${resolved.error}` }; } + const payload = resolved.payload; + const authLabel = resolved.authLabel; - const urlResult = await resolveGatewayUrl(api); - if (!urlResult.url) { - return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` }; + if (action === "qr") { + const setupCode = encodePairingSetupCode(payload); + const [qrBase64, qrAscii] = await Promise.all([ + renderQrPngBase64(setupCode), + renderQrAscii(setupCode), + ]); + const dataUrl = `data:image/png;base64,${qrBase64}`; + + const channel = ctx.channel; + const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; + + if (channel === "telegram" && target) { + try { + const send = api.runtime?.channel?.telegram?.sendMessageTelegram; + if (send) { + await send(target, "Scan this QR code with the OpenClaw iOS app:", { + ...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}), + ...(ctx.accountId ? { accountId: ctx.accountId } : {}), + mediaUrl: dataUrl, + }); + return { + text: [ + `Gateway: ${payload.url}`, + `Auth: ${authLabel}`, + "", + "After scanning, come back here and run `/pair approve` to complete pairing.", + ].join("\n"), + }; + } + } catch (err) { + api.logger.warn?.( + `device-pair: telegram QR send failed, falling back (${String( + (err as Error)?.message ?? err, + )})`, + ); + } + } + + // Render based on channel capability + api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`); + const infoLines = [ + `Gateway: ${payload.url}`, + `Auth: ${authLabel}`, + "", + "After scanning, run `/pair approve` to complete pairing.", + ]; + + // TUI (gateway-client) needs ASCII, WebUI can render markdown images + const isTui = target === "gateway-client" || channel !== "webchat"; + + if (!isTui) { + // WebUI: markdown image only + return { + text: [ + "Scan this QR code with the OpenClaw iOS app:", + "", + `![Pairing QR](${dataUrl})`, + "", + ...infoLines, + ].join("\n"), + }; + } + + // CLI/TUI: ASCII QR only + return { + text: [ + "Scan this QR code with the OpenClaw iOS app:", + "", + "```", + qrAscii, + "```", + "", + ...infoLines, + ].join("\n"), + }; } - const payload: SetupPayload = { - url: urlResult.url, - token: auth.token, - password: auth.password, - }; - const channel = ctx.channel; const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; - const authLabel = auth.label ?? "auth"; if (channel === "telegram" && target) { try { @@ -481,7 +260,7 @@ export default function register(api: OpenClawPluginApi) { ctx.messageThreadId ?? "none" }`, ); - return { text: encodeSetupCode(payload) }; + return { text: encodePairingSetupCode(payload) }; } catch (err) { api.logger.warn?.( `device-pair: telegram split send failed, falling back to single message (${String( diff --git a/src/cli/clawbot-cli.ts b/src/cli/clawbot-cli.ts new file mode 100644 index 0000000000..b4c82a5582 --- /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 48fc169654..38af67e542 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 c9e42e3c6c..819754b424 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 8b0c35cb32..201cc88de9 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 0000000000..d6905c622a --- /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 0000000000..3881e03d4f --- /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", + }; +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 662a4fec95..e3045190f2 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -56,8 +56,6 @@ export type { ChannelThreadingContext, ChannelThreadingToolContext, ChannelToolSend, - BaseProbeResult, - BaseTokenResolution, } from "../channels/plugins/types.js"; export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { @@ -80,18 +78,6 @@ export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { OpenClawConfig } from "../config/config.js"; /** @deprecated Use OpenClawConfig instead */ export type { OpenClawConfig as ClawdbotConfig } from "../config/config.js"; - -export type { FileLockHandle, FileLockOptions } from "./file-lock.js"; -export { acquireFileLock, withFileLock } from "./file-lock.js"; -export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js"; -export type { AgentMediaPayload } from "./agent-media-payload.js"; -export { buildAgentMediaPayload } from "./agent-media-payload.js"; -export { - buildBaseChannelStatusSummary, - collectStatusIssuesFromLastError, - createDefaultChannelRuntimeState, -} from "./status-helpers.js"; -export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export type { ChannelDock } from "../channels/dock.js"; export { getChatChannelMeta } from "../channels/registry.js"; export type { @@ -132,18 +118,11 @@ export { MarkdownTableModeSchema, normalizeAllowFrom, requireOpenAllowFrom, - TtsAutoSchema, - TtsConfigSchema, - TtsModeSchema, - TtsProviderSchema, } from "../config/zod-schema.core.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export type { RuntimeEnv } from "../runtime.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -export { formatAllowFromLowercase } from "./allow-from.js"; -export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; -export { chunkTextForOutbound } from "./text-chunking.js"; export type { ChatType } from "../channels/chat-type.js"; /** @deprecated Use ChatType instead */ export type { RoutePeerKind } from "../routing/resolve-route.js"; @@ -156,8 +135,6 @@ export { listDevicePairing, rejectDevicePairing, } from "../infra/device-pairing.js"; -export { createDedupeCache } from "../infra/dedupe.js"; -export type { DedupeCache } from "../infra/dedupe.js"; export { formatErrorMessage } from "../infra/errors.js"; export { DEFAULT_WEBHOOK_BODY_TIMEOUT_MS, @@ -242,10 +219,7 @@ export { listWhatsAppDirectoryPeersFromConfig, } from "../channels/plugins/directory-config.js"; export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js"; -export { - formatAllowlistMatchMeta, - resolveAllowlistMatchSimple, -} from "../channels/plugins/allowlist-match.js"; +export { formatAllowlistMatchMeta } from "../channels/plugins/allowlist-match.js"; export { optionalStringEnum, stringEnum } from "../agents/schema/typebox.js"; export type { PollInput } from "../polls.js"; @@ -333,12 +307,6 @@ export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, } from "../channels/plugins/normalize/imessage.js"; -export { - parseChatAllowTargetPrefixes, - parseChatTargetPrefixesOrThrow, - resolveServicePrefixedAllowTarget, - resolveServicePrefixedTarget, -} from "../imessage/target-parsing-helpers.js"; // Channel: Slack export { @@ -349,7 +317,6 @@ export { resolveSlackReplyToMode, type ResolvedSlackAccount, } from "../slack/accounts.js"; -export { extractSlackToolSend, listSlackMessageActions } from "../slack/message-actions.js"; export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js"; export { looksLikeSlackTargetId, @@ -370,10 +337,6 @@ export { normalizeTelegramMessagingTarget, } from "../channels/plugins/normalize/telegram.js"; export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.js"; -export { - parseTelegramReplyToMessageId, - parseTelegramThreadId, -} from "../telegram/outbound-params.js"; export { type TelegramProbe } from "../telegram/probe.js"; // Channel: Signal @@ -397,7 +360,6 @@ export { type ResolvedWhatsAppAccount, } from "../web/accounts.js"; export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; -export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js"; export { whatsappOnboardingAdapter } from "../channels/plugins/onboarding/whatsapp.js"; export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp-heartbeat.js"; export { @@ -441,3 +403,14 @@ export type { ProcessedLineMessage } from "../line/markdown-to-line.js"; // Media utilities export { loadWebMedia, type WebMediaResult } from "../web/media.js"; + +// QR code utilities +export { renderQrPngBase64 } from "../web/qr-image.js"; +export { encodePairingSetupCode, resolvePairingSetupFromConfig } from "../pairing/setup-code.js"; +export type { + PairingSetupCommandResult, + PairingSetupCommandRunner, + PairingSetupPayload, + PairingSetupResolution, + ResolvePairingSetupOptions, +} from "../pairing/setup-code.js";