From f83476364d5739f8b74b7d030af94acee7fdac22 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Mon, 16 Feb 2026 13:48:37 +0000 Subject: [PATCH] plugin-sdk/device-pair: keep mainline exports and behavior --- extensions/device-pair/index.ts | 441 ++++++++++++++++++++++++-------- src/plugin-sdk/index.ts | 51 +++- 2 files changed, 370 insertions(+), 122 deletions(-) diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index edbf7249a01..3f9049fdc4d 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,30 +1,327 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { - approveDevicePairing, - encodePairingSetupCode, - listDevicePairing, - renderQrPngBase64, - resolvePairingSetupFromConfig, -} from "openclaw/plugin-sdk"; -import qrcode from "qrcode-terminal"; +import os from "node:os"; +import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk"; -function renderQrAscii(data: string): Promise { - return new Promise((resolve) => { - qrcode.generate(data, { small: true }, (output: string) => { - resolve(output); - }); - }); -} +const DEFAULT_GATEWAY_PORT = 18789; type DevicePairPluginConfig = { publicUrl?: string; }; -function formatSetupReply( - payload: { url: string; token?: string; password?: string }, - authLabel: string, -): string { - const setupCode = encodePairingSetupCode(payload); +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); return [ "Pairing setup code generated.", "", @@ -138,101 +435,25 @@ export default function register(api: OpenClawPluginApi) { return { text: `✅ Paired ${label}${platformLabel}.` }; } - 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 auth = resolveAuth(api.config); + if (auth.error) { + return { text: `Error: ${auth.error}` }; } - const payload = resolved.payload; - const authLabel = resolved.authLabel; - 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 urlResult = await resolveGatewayUrl(api); + if (!urlResult.url) { + return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` }; } + 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 { @@ -260,7 +481,7 @@ export default function register(api: OpenClawPluginApi) { ctx.messageThreadId ?? "none" }`, ); - return { text: encodePairingSetupCode(payload) }; + return { text: encodeSetupCode(payload) }; } catch (err) { api.logger.warn?.( `device-pair: telegram split send failed, falling back to single message (${String( diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index e3045190f2c..662a4fec95e 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -56,6 +56,8 @@ export type { ChannelThreadingContext, ChannelThreadingToolContext, ChannelToolSend, + BaseProbeResult, + BaseTokenResolution, } from "../channels/plugins/types.js"; export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { @@ -78,6 +80,18 @@ 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 { @@ -118,11 +132,18 @@ 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"; @@ -135,6 +156,8 @@ 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, @@ -219,7 +242,10 @@ export { listWhatsAppDirectoryPeersFromConfig, } from "../channels/plugins/directory-config.js"; export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js"; -export { formatAllowlistMatchMeta } from "../channels/plugins/allowlist-match.js"; +export { + formatAllowlistMatchMeta, + resolveAllowlistMatchSimple, +} from "../channels/plugins/allowlist-match.js"; export { optionalStringEnum, stringEnum } from "../agents/schema/typebox.js"; export type { PollInput } from "../polls.js"; @@ -307,6 +333,12 @@ export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, } from "../channels/plugins/normalize/imessage.js"; +export { + parseChatAllowTargetPrefixes, + parseChatTargetPrefixesOrThrow, + resolveServicePrefixedAllowTarget, + resolveServicePrefixedTarget, +} from "../imessage/target-parsing-helpers.js"; // Channel: Slack export { @@ -317,6 +349,7 @@ 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, @@ -337,6 +370,10 @@ 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 @@ -360,6 +397,7 @@ 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 { @@ -403,14 +441,3 @@ 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";