diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a5a2aa2cdb..e4bbe041591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,15 @@ Docs: https://docs.openclaw.ai ### Added - Gateway: add `agents.create`, `agents.update`, `agents.delete` RPC methods for web UI agent management. (#11045) Thanks @advaitpaliwal. +- Gateway: add node command allowlists (default-deny unknown node commands; configurable via `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`). (#11755) Thanks @mbelinky. +- Plugins: add `device-pair` (Telegram `/pair` flow) and `phone-control` (iOS/Android node controls). (#11755) Thanks @mbelinky. ### Fixes - Exec approvals: format forwarded command text as inline/fenced monospace for safer approval scanning across channels. (#11937) - Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @seans-openclawbot. - Discord: support forum/media `thread create` starter messages, wire `message thread create --message`, and harden thread-create routing. (#10062) Thanks @jarvis89757. +- Gateway: stabilize chat routing by canonicalizing node session keys for node-originated chat methods. (#11755) Thanks @mbelinky. - Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh. - Cron: route text-only isolated agent announces through the shared subagent announce flow; add exponential backoff for repeated errors; preserve future `nextRunAtMs` on restart; include current-boundary schedule matches; prevent stale threadId reuse across targets; and add per-job execution timeout. (#11641) Thanks @tyler6204. - Subagents: stabilize announce timing, preserve compaction metrics across retries, clamp overflow-prone long timeouts, and cap impossible context usage token totals. (#11551) Thanks @tyler6204. diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 19813155f56..bdd52975071 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -52,6 +52,23 @@ Treat these as sensitive (they gate access to your assistant). Nodes connect to the Gateway as **devices** with `role: node`. The Gateway creates a device pairing request that must be approved. +### Pair via Telegram (recommended for iOS) + +If you use the `device-pair` plugin, you can do first-time device pairing entirely from Telegram: + +1. In Telegram, message your bot: `/pair` +2. The bot replies with two messages: an instruction message and a separate **setup code** message (easy to copy/paste in Telegram). +3. On your phone, open the OpenClaw iOS app → Settings → Gateway. +4. Paste the setup code and connect. +5. Back in Telegram: `/pair approve` + +The setup code is a base64-encoded JSON payload that contains: + +- `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`) +- `token`: a short-lived pairing token + +Treat the setup code like a password while it is valid. + ### Approve a node device ```bash diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index f4d7971c602..31a61fc042e 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -157,10 +157,21 @@ More help: [Channel troubleshooting](/channels/troubleshooting). Notes: - Custom commands are **menu entries only**; OpenClaw does not implement them unless you handle them elsewhere. +- Some commands can be handled by plugins/skills without being registered in Telegram’s command menu. These still work when typed (they just won't show up in `/commands` / the menu). - Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars). - Custom commands **cannot override native commands**. Conflicts are ignored and logged. - If `commands.native` is disabled, only custom commands are registered (or cleared if none). +### Device pairing commands (`device-pair` plugin) + +If the `device-pair` plugin is installed, it adds a Telegram-first flow for pairing a new phone: + +1. `/pair` generates a setup code (sent as a separate message for easy copy/paste). +2. Paste the setup code in the iOS app to connect. +3. `/pair approve` approves the latest pending device request. + +More details: [Pairing](/channels/pairing#pair-via-telegram-recommended-for-ios). + ## Limits - Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000). diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts new file mode 100644 index 00000000000..0360205c73c --- /dev/null +++ b/extensions/device-pair/index.ts @@ -0,0 +1,497 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import os from "node:os"; +import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk"; + +const DEFAULT_GATEWAY_PORT = 18789; + +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; + const isIpv4 = family === "IPv4" || 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; + const isIpv4 = family === "IPv4" || 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.", + "", + "1) Open the iOS app → Settings → Gateway", + "2) Paste the setup code below and tap Connect", + "3) Back here, run /pair approve", + "", + "Setup code:", + setupCode, + "", + `Gateway: ${payload.url}`, + `Auth: ${authLabel}`, + ].join("\n"); +} + +function formatSetupInstructions(): string { + return [ + "Pairing setup code generated.", + "", + "1) Open the iOS app → Settings → Gateway", + "2) Paste the setup code from my next message and tap Connect", + "3) Back here, run /pair approve", + ].join("\n"); +} + +type PendingPairingRequest = { + requestId: string; + deviceId: string; + displayName?: string; + platform?: string; + remoteIp?: string; + ts?: number; +}; + +function formatPendingRequests(pending: PendingPairingRequest[]): string { + if (pending.length === 0) { + return "No pending device pairing requests."; + } + const lines: string[] = ["Pending device pairing requests:"]; + for (const req of pending) { + const label = req.displayName?.trim() || req.deviceId; + const platform = req.platform?.trim(); + const ip = req.remoteIp?.trim(); + const parts = [ + `- ${req.requestId}`, + label ? `name=${label}` : null, + platform ? `platform=${platform}` : null, + ip ? `ip=${ip}` : null, + ].filter(Boolean); + lines.push(parts.join(" · ")); + } + return lines.join("\n"); +} + +export default function register(api: OpenClawPluginApi) { + api.registerCommand({ + name: "pair", + description: "Generate setup codes and approve device pairing requests.", + acceptsArgs: true, + handler: async (ctx) => { + const args = ctx.args?.trim() ?? ""; + const tokens = args.split(/\s+/).filter(Boolean); + const action = tokens[0]?.toLowerCase() ?? ""; + api.logger.info?.( + `device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${ + action || "new" + }`, + ); + + if (action === "status" || action === "pending") { + const list = await listDevicePairing(); + return { text: formatPendingRequests(list.pending) }; + } + + if (action === "approve") { + const requested = tokens[1]?.trim(); + const list = await listDevicePairing(); + if (list.pending.length === 0) { + return { text: "No pending device pairing requests." }; + } + + let pending: (typeof list.pending)[number] | undefined; + if (requested) { + if (requested.toLowerCase() === "latest") { + pending = [...list.pending].toSorted((a, b) => (b.ts ?? 0) - (a.ts ?? 0))[0]; + } else { + pending = list.pending.find((entry) => entry.requestId === requested); + } + } else if (list.pending.length === 1) { + pending = list.pending[0]; + } else { + return { + text: + `${formatPendingRequests(list.pending)}\n\n` + + "Multiple pending requests found. Approve one explicitly:\n" + + "/pair approve \n" + + "Or approve the most recent:\n" + + "/pair approve latest", + }; + } + if (!pending) { + return { text: "Pairing request not found." }; + } + const approved = await approveDevicePairing(pending.requestId); + if (!approved) { + return { text: "Pairing request not found." }; + } + const label = approved.device.displayName?.trim() || approved.device.deviceId; + const platform = approved.device.platform?.trim(); + const platformLabel = platform ? ` (${platform})` : ""; + return { text: `✅ Paired ${label}${platformLabel}.` }; + } + + const auth = resolveAuth(api.config); + if (auth.error) { + return { text: `Error: ${auth.error}` }; + } + + 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 { + const runtimeKeys = Object.keys(api.runtime ?? {}); + const channelKeys = Object.keys(api.runtime?.channel ?? {}); + api.logger.debug?.( + `device-pair: runtime keys=${runtimeKeys.join(",") || "none"} channel keys=${ + channelKeys.join(",") || "none" + }`, + ); + const send = api.runtime?.channel?.telegram?.sendMessageTelegram; + if (!send) { + throw new Error( + `telegram runtime unavailable (runtime keys: ${runtimeKeys.join(",")}; channel keys: ${channelKeys.join( + ",", + )})`, + ); + } + await send(target, formatSetupInstructions(), { + ...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}), + ...(ctx.accountId ? { accountId: ctx.accountId } : {}), + }); + api.logger.info?.( + `device-pair: telegram split send ok target=${target} account=${ctx.accountId ?? "none"} thread=${ + ctx.messageThreadId ?? "none" + }`, + ); + return { text: encodeSetupCode(payload) }; + } catch (err) { + api.logger.warn?.( + `device-pair: telegram split send failed, falling back to single message (${String( + (err as Error)?.message ?? err, + )})`, + ); + } + } + + return { + text: formatSetupReply(payload, authLabel), + }; + }, + }); +} diff --git a/extensions/device-pair/openclaw.plugin.json b/extensions/device-pair/openclaw.plugin.json new file mode 100644 index 00000000000..b72a075bd49 --- /dev/null +++ b/extensions/device-pair/openclaw.plugin.json @@ -0,0 +1,20 @@ +{ + "id": "device-pair", + "name": "Device Pairing", + "description": "Generate setup codes and approve device pairing requests.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "publicUrl": { + "type": "string" + } + } + }, + "uiHints": { + "publicUrl": { + "label": "Gateway URL", + "help": "Public WebSocket URL used for /pair setup codes (ws/wss or http/https)." + } + } +} diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts new file mode 100644 index 00000000000..627a2317fad --- /dev/null +++ b/extensions/phone-control/index.ts @@ -0,0 +1,420 @@ +import type { OpenClawPluginApi, OpenClawPluginService } from "openclaw/plugin-sdk"; +import fs from "node:fs/promises"; +import path from "node:path"; + +type ArmGroup = "camera" | "screen" | "writes" | "all"; + +type ArmStateFileV1 = { + version: 1; + armedAtMs: number; + expiresAtMs: number | null; + removedFromDeny: string[]; +}; + +type ArmStateFileV2 = { + version: 2; + armedAtMs: number; + expiresAtMs: number | null; + group: ArmGroup; + armedCommands: string[]; + addedToAllow: string[]; + removedFromDeny: string[]; +}; + +type ArmStateFile = ArmStateFileV1 | ArmStateFileV2; + +const STATE_VERSION = 2; +const STATE_REL_PATH = ["plugins", "phone-control", "armed.json"] as const; + +const GROUP_COMMANDS: Record, string[]> = { + camera: ["camera.snap", "camera.clip"], + screen: ["screen.record"], + writes: ["calendar.add", "contacts.add", "reminders.add"], +}; + +function uniqSorted(values: string[]): string[] { + return [...new Set(values.map((v) => v.trim()).filter(Boolean))].toSorted(); +} + +function resolveCommandsForGroup(group: ArmGroup): string[] { + if (group === "all") { + return uniqSorted(Object.values(GROUP_COMMANDS).flat()); + } + return uniqSorted(GROUP_COMMANDS[group]); +} + +function formatGroupList(): string { + return ["camera", "screen", "writes", "all"].join(", "); +} + +function parseDurationMs(input: string | undefined): number | null { + if (!input) { + return null; + } + const raw = input.trim().toLowerCase(); + if (!raw) { + return null; + } + const m = raw.match(/^(\d+)(s|m|h|d)$/); + if (!m) { + return null; + } + const n = Number.parseInt(m[1] ?? "", 10); + if (!Number.isFinite(n) || n <= 0) { + return null; + } + const unit = m[2]; + const mult = unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000; + return n * mult; +} + +function formatDuration(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + if (s < 60) { + return `${s}s`; + } + const m = Math.floor(s / 60); + if (m < 60) { + return `${m}m`; + } + const h = Math.floor(m / 60); + if (h < 48) { + return `${h}h`; + } + const d = Math.floor(h / 24); + return `${d}d`; +} + +function resolveStatePath(stateDir: string): string { + return path.join(stateDir, ...STATE_REL_PATH); +} + +async function readArmState(statePath: string): Promise { + try { + const raw = await fs.readFile(statePath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (parsed.version !== 1 && parsed.version !== 2) { + return null; + } + if (typeof parsed.armedAtMs !== "number") { + return null; + } + if (!(parsed.expiresAtMs === null || typeof parsed.expiresAtMs === "number")) { + return null; + } + + if (parsed.version === 1) { + if ( + !Array.isArray(parsed.removedFromDeny) || + !parsed.removedFromDeny.every((v) => typeof v === "string") + ) { + return null; + } + return parsed as ArmStateFile; + } + + const group = typeof parsed.group === "string" ? parsed.group : ""; + if (group !== "camera" && group !== "screen" && group !== "writes" && group !== "all") { + return null; + } + if ( + !Array.isArray(parsed.armedCommands) || + !parsed.armedCommands.every((v) => typeof v === "string") + ) { + return null; + } + if ( + !Array.isArray(parsed.addedToAllow) || + !parsed.addedToAllow.every((v) => typeof v === "string") + ) { + return null; + } + if ( + !Array.isArray(parsed.removedFromDeny) || + !parsed.removedFromDeny.every((v) => typeof v === "string") + ) { + return null; + } + return parsed as ArmStateFile; + } catch { + return null; + } +} + +async function writeArmState(statePath: string, state: ArmStateFile | null): Promise { + await fs.mkdir(path.dirname(statePath), { recursive: true }); + if (!state) { + try { + await fs.unlink(statePath); + } catch { + // ignore + } + return; + } + await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); +} + +function normalizeDenyList(cfg: OpenClawPluginApi["config"]): string[] { + return uniqSorted([...(cfg.gateway?.nodes?.denyCommands ?? [])]); +} + +function normalizeAllowList(cfg: OpenClawPluginApi["config"]): string[] { + return uniqSorted([...(cfg.gateway?.nodes?.allowCommands ?? [])]); +} + +function patchConfigNodeLists( + cfg: OpenClawPluginApi["config"], + next: { allowCommands: string[]; denyCommands: string[] }, +): OpenClawPluginApi["config"] { + return { + ...cfg, + gateway: { + ...cfg.gateway, + nodes: { + ...cfg.gateway?.nodes, + allowCommands: next.allowCommands, + denyCommands: next.denyCommands, + }, + }, + }; +} + +async function disarmNow(params: { + api: OpenClawPluginApi; + stateDir: string; + statePath: string; + reason: string; +}): Promise<{ changed: boolean; restored: string[]; removed: string[] }> { + const { api, stateDir, statePath, reason } = params; + const state = await readArmState(statePath); + if (!state) { + return { changed: false, restored: [], removed: [] }; + } + const cfg = api.runtime.config.loadConfig(); + const allow = new Set(normalizeAllowList(cfg)); + const deny = new Set(normalizeDenyList(cfg)); + const removed: string[] = []; + const restored: string[] = []; + + if (state.version === 1) { + for (const cmd of state.removedFromDeny) { + if (!deny.has(cmd)) { + deny.add(cmd); + restored.push(cmd); + } + } + } else { + for (const cmd of state.addedToAllow) { + if (allow.delete(cmd)) { + removed.push(cmd); + } + } + for (const cmd of state.removedFromDeny) { + if (!deny.has(cmd)) { + deny.add(cmd); + restored.push(cmd); + } + } + } + + if (removed.length > 0 || restored.length > 0) { + const next = patchConfigNodeLists(cfg, { + allowCommands: uniqSorted([...allow]), + denyCommands: uniqSorted([...deny]), + }); + await api.runtime.config.writeConfigFile(next); + } + await writeArmState(statePath, null); + api.logger.info(`phone-control: disarmed (${reason}) stateDir=${stateDir}`); + return { + changed: removed.length > 0 || restored.length > 0, + removed: uniqSorted(removed), + restored: uniqSorted(restored), + }; +} + +function formatHelp(): string { + return [ + "Phone control commands:", + "", + "/phone status", + "/phone arm [duration]", + "/phone disarm", + "", + "Groups:", + `- ${formatGroupList()}`, + "", + "Duration format: 30s | 10m | 2h | 1d (default: 10m).", + "", + "Notes:", + "- This only toggles what the gateway is allowed to invoke on phone nodes.", + "- iOS will still ask for permissions (camera, photos, contacts, etc.) on first use.", + ].join("\n"); +} + +function parseGroup(raw: string | undefined): ArmGroup | null { + const value = (raw ?? "").trim().toLowerCase(); + if (!value) { + return null; + } + if (value === "camera" || value === "screen" || value === "writes" || value === "all") { + return value; + } + return null; +} + +function formatStatus(state: ArmStateFile | null): string { + if (!state) { + return "Phone control: disarmed."; + } + const until = + state.expiresAtMs == null + ? "manual disarm required" + : `expires in ${formatDuration(Math.max(0, state.expiresAtMs - Date.now()))}`; + const cmds = uniqSorted( + state.version === 1 + ? state.removedFromDeny + : state.armedCommands.length > 0 + ? state.armedCommands + : [...state.addedToAllow, ...state.removedFromDeny], + ); + const cmdLabel = cmds.length > 0 ? cmds.join(", ") : "none"; + return `Phone control: armed (${until}).\nTemporarily allowed: ${cmdLabel}`; +} + +export default function register(api: OpenClawPluginApi) { + let expiryInterval: ReturnType | null = null; + + const timerService: OpenClawPluginService = { + id: "phone-control-expiry", + start: async (ctx) => { + const statePath = resolveStatePath(ctx.stateDir); + const tick = async () => { + const state = await readArmState(statePath); + if (!state || state.expiresAtMs == null) { + return; + } + if (Date.now() < state.expiresAtMs) { + return; + } + await disarmNow({ + api, + stateDir: ctx.stateDir, + statePath, + reason: "expired", + }); + }; + + // Best effort; don't crash the gateway if state is corrupt. + await tick().catch(() => {}); + + expiryInterval = setInterval(() => { + tick().catch(() => {}); + }, 15_000); + expiryInterval.unref?.(); + + return; + }, + stop: async () => { + if (expiryInterval) { + clearInterval(expiryInterval); + expiryInterval = null; + } + return; + }, + }; + + api.registerService(timerService); + + api.registerCommand({ + name: "phone", + description: "Arm/disarm high-risk phone node commands (camera/screen/writes).", + acceptsArgs: true, + handler: async (ctx) => { + const args = ctx.args?.trim() ?? ""; + const tokens = args.split(/\s+/).filter(Boolean); + const action = tokens[0]?.toLowerCase() ?? ""; + + const stateDir = api.runtime.state.resolveStateDir(); + const statePath = resolveStatePath(stateDir); + + if (!action || action === "help") { + const state = await readArmState(statePath); + return { text: `${formatStatus(state)}\n\n${formatHelp()}` }; + } + + if (action === "status") { + const state = await readArmState(statePath); + return { text: formatStatus(state) }; + } + + if (action === "disarm") { + const res = await disarmNow({ + api, + stateDir, + statePath, + reason: "manual", + }); + if (!res.changed) { + return { text: "Phone control: disarmed." }; + } + const restoredLabel = res.restored.length > 0 ? res.restored.join(", ") : "none"; + const removedLabel = res.removed.length > 0 ? res.removed.join(", ") : "none"; + return { + text: `Phone control: disarmed.\nRemoved allowlist: ${removedLabel}\nRestored denylist: ${restoredLabel}`, + }; + } + + if (action === "arm") { + const group = parseGroup(tokens[1]); + if (!group) { + return { text: `Usage: /phone arm [duration]\nGroups: ${formatGroupList()}` }; + } + const durationMs = parseDurationMs(tokens[2]) ?? 10 * 60_000; + const expiresAtMs = Date.now() + durationMs; + + const commands = resolveCommandsForGroup(group); + const cfg = api.runtime.config.loadConfig(); + const allowSet = new Set(normalizeAllowList(cfg)); + const denySet = new Set(normalizeDenyList(cfg)); + + const addedToAllow: string[] = []; + const removedFromDeny: string[] = []; + for (const cmd of commands) { + if (!allowSet.has(cmd)) { + allowSet.add(cmd); + addedToAllow.push(cmd); + } + if (denySet.delete(cmd)) { + removedFromDeny.push(cmd); + } + } + const next = patchConfigNodeLists(cfg, { + allowCommands: uniqSorted([...allowSet]), + denyCommands: uniqSorted([...denySet]), + }); + await api.runtime.config.writeConfigFile(next); + + await writeArmState(statePath, { + version: STATE_VERSION, + armedAtMs: Date.now(), + expiresAtMs, + group, + armedCommands: uniqSorted(commands), + addedToAllow: uniqSorted(addedToAllow), + removedFromDeny: uniqSorted(removedFromDeny), + }); + + const allowedLabel = uniqSorted(commands).join(", "); + return { + text: + `Phone control: armed for ${formatDuration(durationMs)}.\n` + + `Temporarily allowed: ${allowedLabel}\n` + + `To disarm early: /phone disarm`, + }; + } + + return { text: formatHelp() }; + }, + }); +} diff --git a/extensions/phone-control/openclaw.plugin.json b/extensions/phone-control/openclaw.plugin.json new file mode 100644 index 00000000000..4d73c85e43b --- /dev/null +++ b/extensions/phone-control/openclaw.plugin.json @@ -0,0 +1,10 @@ +{ + "id": "phone-control", + "name": "Phone Control", + "description": "Arm/disarm high-risk phone node commands (camera/screen/writes) with an optional auto-expiry.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts new file mode 100644 index 00000000000..d47705719a2 --- /dev/null +++ b/extensions/talk-voice/index.ts @@ -0,0 +1,150 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; + +type ElevenLabsVoice = { + voice_id: string; + name?: string; + category?: string; + description?: string; +}; + +function mask(s: string, keep: number = 6): string { + const trimmed = s.trim(); + if (trimmed.length <= keep) { + return "***"; + } + return `${trimmed.slice(0, keep)}…`; +} + +function isLikelyVoiceId(value: string): boolean { + const v = value.trim(); + if (v.length < 10 || v.length > 64) { + return false; + } + return /^[a-zA-Z0-9_-]+$/.test(v); +} + +async function listVoices(apiKey: string): Promise { + const res = await fetch("https://api.elevenlabs.io/v1/voices", { + headers: { + "xi-api-key": apiKey, + }, + }); + if (!res.ok) { + throw new Error(`ElevenLabs voices API error (${res.status})`); + } + const json = (await res.json()) as { voices?: ElevenLabsVoice[] }; + return Array.isArray(json.voices) ? json.voices : []; +} + +function formatVoiceList(voices: ElevenLabsVoice[], limit: number): string { + const sliced = voices.slice(0, Math.max(1, Math.min(limit, 50))); + const lines: string[] = []; + lines.push(`Voices: ${voices.length}`); + lines.push(""); + for (const v of sliced) { + const name = (v.name ?? "").trim() || "(unnamed)"; + const category = (v.category ?? "").trim(); + const meta = category ? ` · ${category}` : ""; + lines.push(`- ${name}${meta}`); + lines.push(` id: ${v.voice_id}`); + } + if (voices.length > sliced.length) { + lines.push(""); + lines.push(`(showing first ${sliced.length})`); + } + return lines.join("\n"); +} + +function findVoice(voices: ElevenLabsVoice[], query: string): ElevenLabsVoice | null { + const q = query.trim(); + if (!q) { + return null; + } + const lower = q.toLowerCase(); + const byId = voices.find((v) => v.voice_id === q); + if (byId) { + return byId; + } + const exactName = voices.find((v) => (v.name ?? "").trim().toLowerCase() === lower); + if (exactName) { + return exactName; + } + const partial = voices.find((v) => (v.name ?? "").trim().toLowerCase().includes(lower)); + return partial ?? null; +} + +export default function register(api: OpenClawPluginApi) { + api.registerCommand({ + name: "voice", + description: "List/set ElevenLabs Talk voice (affects iOS Talk playback).", + acceptsArgs: true, + handler: async (ctx) => { + const args = ctx.args?.trim() ?? ""; + const tokens = args.split(/\s+/).filter(Boolean); + const action = (tokens[0] ?? "status").toLowerCase(); + + const cfg = api.runtime.config.loadConfig(); + const apiKey = (cfg.talk?.apiKey ?? "").trim(); + if (!apiKey) { + return { + text: + "Talk voice is not configured.\n\n" + + "Missing: talk.apiKey (ElevenLabs API key).\n" + + "Set it on the gateway, then retry.", + }; + } + + const currentVoiceId = (cfg.talk?.voiceId ?? "").trim(); + + if (action === "status") { + return { + text: + "Talk voice status:\n" + + `- talk.voiceId: ${currentVoiceId ? currentVoiceId : "(unset)"}\n` + + `- talk.apiKey: ${mask(apiKey)}`, + }; + } + + if (action === "list") { + const limit = Number.parseInt(tokens[1] ?? "12", 10); + const voices = await listVoices(apiKey); + return { text: formatVoiceList(voices, Number.isFinite(limit) ? limit : 12) }; + } + + if (action === "set") { + const query = tokens.slice(1).join(" ").trim(); + if (!query) { + return { text: "Usage: /voice set " }; + } + const voices = await listVoices(apiKey); + const chosen = findVoice(voices, query); + if (!chosen) { + const hint = isLikelyVoiceId(query) ? query : `"${query}"`; + return { text: `No voice found for ${hint}. Try: /voice list` }; + } + + const nextConfig = { + ...cfg, + talk: { + ...cfg.talk, + voiceId: chosen.voice_id, + }, + }; + await api.runtime.config.writeConfigFile(nextConfig); + + const name = (chosen.name ?? "").trim() || "(unnamed)"; + return { text: `✅ Talk voice set to ${name}\n${chosen.voice_id}` }; + } + + return { + text: [ + "Voice commands:", + "", + "/voice status", + "/voice list [limit]", + "/voice set ", + ].join("\n"), + }; + }, + }); +} diff --git a/extensions/talk-voice/openclaw.plugin.json b/extensions/talk-voice/openclaw.plugin.json new file mode 100644 index 00000000000..88ef17397d2 --- /dev/null +++ b/extensions/talk-voice/openclaw.plugin.json @@ -0,0 +1,10 @@ +{ + "id": "talk-voice", + "name": "Talk Voice", + "description": "Manage Talk voice selection (list/set).", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/scripts/dev/gateway-smoke.ts b/scripts/dev/gateway-smoke.ts new file mode 100644 index 00000000000..e217adf5eed --- /dev/null +++ b/scripts/dev/gateway-smoke.ts @@ -0,0 +1,163 @@ +import { randomUUID } from "node:crypto"; +import WebSocket from "ws"; + +type GatewayReqFrame = { type: "req"; id: string; method: string; params?: unknown }; +type GatewayResFrame = { type: "res"; id: string; ok: boolean; payload?: unknown; error?: unknown }; +type GatewayEventFrame = { type: "event"; event: string; seq?: number; payload?: unknown }; +type GatewayFrame = GatewayReqFrame | GatewayResFrame | GatewayEventFrame | { type: string }; + +const args = process.argv.slice(2); +const getArg = (flag: string) => { + const idx = args.indexOf(flag); + if (idx !== -1 && idx + 1 < args.length) { + return args[idx + 1]; + } + return undefined; +}; + +const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL; +const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN; + +if (!urlRaw || !token) { + // eslint-disable-next-line no-console + console.error( + "Usage: bun scripts/dev/gateway-smoke.ts --url --token \n" + + "Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN", + ); + process.exit(1); +} + +const url = new URL(urlRaw.includes("://") ? urlRaw : `wss://${urlRaw}`); +if (!url.port) { + url.port = url.protocol === "wss:" ? "443" : "80"; +} + +const randomId = () => randomUUID(); + +async function main() { + const ws = new WebSocket(url.toString(), { handshakeTimeout: 8000 }); + const pending = new Map< + string, + { + resolve: (res: GatewayResFrame) => void; + reject: (err: Error) => void; + timeout: ReturnType; + } + >(); + + const request = (method: string, params?: unknown, timeoutMs = 12000) => + new Promise((resolve, reject) => { + const id = randomId(); + const frame: GatewayReqFrame = { type: "req", id, method, params }; + const timeout = setTimeout(() => { + pending.delete(id); + reject(new Error(`timeout waiting for ${method}`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timeout }); + ws.send(JSON.stringify(frame)); + }); + + const waitOpen = () => + new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("ws open timeout")), 8000); + ws.once("open", () => { + clearTimeout(t); + resolve(); + }); + ws.once("error", (err) => { + clearTimeout(t); + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); + + const toText = (data: WebSocket.RawData) => { + if (typeof data === "string") { + return data; + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + if (Array.isArray(data)) { + return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8"); + } + return Buffer.from(data as Buffer).toString("utf8"); + }; + + ws.on("message", (data) => { + const text = toText(data); + let frame: GatewayFrame | null = null; + try { + frame = JSON.parse(text) as GatewayFrame; + } catch { + return; + } + if (!frame || typeof frame !== "object" || !("type" in frame)) { + return; + } + if (frame.type === "res") { + const res = frame as GatewayResFrame; + const waiter = pending.get(res.id); + if (waiter) { + pending.delete(res.id); + clearTimeout(waiter.timeout); + waiter.resolve(res); + } + return; + } + if (frame.type === "event") { + const evt = frame as GatewayEventFrame; + if (evt.event === "connect.challenge") { + return; + } + return; + } + }); + + await waitOpen(); + + // Match iOS "operator" session defaults: token auth, no device identity. + const connectRes = await request("connect", { + minProtocol: 3, + maxProtocol: 3, + client: { + id: "openclaw-ios", + displayName: "openclaw gateway smoke test", + version: "dev", + platform: "dev", + mode: "ui", + instanceId: "openclaw-dev-smoke", + }, + locale: "en-US", + userAgent: "gateway-smoke", + role: "operator", + scopes: ["operator.read", "operator.write", "operator.admin"], + caps: [], + auth: { token }, + }); + + if (!connectRes.ok) { + // eslint-disable-next-line no-console + console.error("connect failed:", connectRes.error); + process.exit(2); + } + + const healthRes = await request("health"); + if (!healthRes.ok) { + // eslint-disable-next-line no-console + console.error("health failed:", healthRes.error); + process.exit(3); + } + + const historyRes = await request("chat.history", { sessionKey: "main" }, 15000); + if (!historyRes.ok) { + // eslint-disable-next-line no-console + console.error("chat.history failed:", historyRes.error); + process.exit(4); + } + + // eslint-disable-next-line no-console + console.log("ok: connected + health + chat.history"); + ws.close(); +} + +await main(); diff --git a/scripts/dev/ios-node-e2e.ts b/scripts/dev/ios-node-e2e.ts new file mode 100644 index 00000000000..7b64b6e2d61 --- /dev/null +++ b/scripts/dev/ios-node-e2e.ts @@ -0,0 +1,373 @@ +import { randomUUID } from "node:crypto"; +import WebSocket from "ws"; + +type GatewayReqFrame = { type: "req"; id: string; method: string; params?: unknown }; +type GatewayResFrame = { type: "res"; id: string; ok: boolean; payload?: unknown; error?: unknown }; +type GatewayEventFrame = { type: "event"; event: string; seq?: number; payload?: unknown }; +type GatewayFrame = GatewayReqFrame | GatewayResFrame | GatewayEventFrame | { type: string }; + +type NodeListPayload = { + ts?: number; + nodes?: Array<{ + nodeId: string; + displayName?: string; + platform?: string; + connected?: boolean; + paired?: boolean; + commands?: string[]; + permissions?: unknown; + }>; +}; + +type NodeListNode = NonNullable[number]; + +const args = process.argv.slice(2); +const getArg = (flag: string) => { + const idx = args.indexOf(flag); + if (idx !== -1 && idx + 1 < args.length) { + return args[idx + 1]; + } + return undefined; +}; + +const hasFlag = (flag: string) => args.includes(flag); + +const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL; +const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN; +const nodeHint = getArg("--node"); +const dangerous = hasFlag("--dangerous") || process.env.OPENCLAW_RUN_DANGEROUS === "1"; +const jsonOut = hasFlag("--json"); + +if (!urlRaw || !token) { + // eslint-disable-next-line no-console + console.error( + "Usage: bun scripts/dev/ios-node-e2e.ts --url --token [--node ] [--dangerous] [--json]\n" + + "Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN", + ); + process.exit(1); +} + +const url = new URL(urlRaw.includes("://") ? urlRaw : `wss://${urlRaw}`); +if (!url.port) { + url.port = url.protocol === "wss:" ? "443" : "80"; +} + +const randomId = () => randomUUID(); + +const isoNow = () => new Date().toISOString(); +const isoMinusMs = (ms: number) => new Date(Date.now() - ms).toISOString(); + +type TestCase = { + id: string; + command: string; + params?: unknown; + timeoutMs?: number; + dangerous?: boolean; +}; + +function formatErr(err: unknown): string { + if (!err) { + return "error"; + } + if (typeof err === "string") { + return err; + } + if (err instanceof Error) { + return err.message || String(err); + } + try { + return JSON.stringify(err); + } catch { + return Object.prototype.toString.call(err); + } +} + +function pickIosNode(list: NodeListPayload, hint?: string): NodeListNode | null { + const nodes = (list.nodes ?? []).filter((n) => n && n.connected); + const ios = nodes.filter((n) => (n.platform ?? "").toLowerCase().includes("ios")); + if (ios.length === 0) { + return null; + } + if (!hint) { + return ios[0] ?? null; + } + const h = hint.toLowerCase(); + return ( + ios.find((n) => n.nodeId.toLowerCase() === h) ?? + ios.find((n) => (n.displayName ?? "").toLowerCase().includes(h)) ?? + ios.find((n) => n.nodeId.toLowerCase().includes(h)) ?? + ios[0] ?? + null + ); +} + +async function main() { + const ws = new WebSocket(url.toString(), { handshakeTimeout: 8000 }); + const pending = new Map< + string, + { + resolve: (res: GatewayResFrame) => void; + reject: (err: Error) => void; + timeout: ReturnType; + } + >(); + + const request = (method: string, params?: unknown, timeoutMs = 12_000) => + new Promise((resolve, reject) => { + const id = randomId(); + const frame: GatewayReqFrame = { type: "req", id, method, params }; + const timeout = setTimeout(() => { + pending.delete(id); + reject(new Error(`timeout waiting for ${method}`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timeout }); + ws.send(JSON.stringify(frame)); + }); + + const waitOpen = () => + new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("ws open timeout")), 8000); + ws.once("open", () => { + clearTimeout(t); + resolve(); + }); + ws.once("error", (err) => { + clearTimeout(t); + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); + + const toText = (data: WebSocket.RawData) => { + if (typeof data === "string") { + return data; + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + if (Array.isArray(data)) { + return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8"); + } + return Buffer.from(data as Buffer).toString("utf8"); + }; + + ws.on("message", (data) => { + const text = toText(data); + let frame: GatewayFrame | null = null; + try { + frame = JSON.parse(text) as GatewayFrame; + } catch { + return; + } + if (!frame || typeof frame !== "object" || !("type" in frame)) { + return; + } + if (frame.type === "res") { + const res = frame as GatewayResFrame; + const waiter = pending.get(res.id); + if (waiter) { + pending.delete(res.id); + clearTimeout(waiter.timeout); + waiter.resolve(res); + } + return; + } + if (frame.type === "event") { + // Ignore; caller can extend to watch node.pair.* etc. + return; + } + }); + + await waitOpen(); + + const connectRes = await request("connect", { + minProtocol: 3, + maxProtocol: 3, + client: { + id: "cli", + displayName: "openclaw ios node e2e", + version: "dev", + platform: "dev", + mode: "cli", + instanceId: "openclaw-dev-ios-node-e2e", + }, + locale: "en-US", + userAgent: "ios-node-e2e", + role: "operator", + scopes: ["operator.read", "operator.write", "operator.admin"], + caps: [], + auth: { token }, + }); + + if (!connectRes.ok) { + // eslint-disable-next-line no-console + console.error("connect failed:", connectRes.error); + process.exit(2); + } + + const healthRes = await request("health"); + if (!healthRes.ok) { + // eslint-disable-next-line no-console + console.error("health failed:", healthRes.error); + process.exit(3); + } + + const nodesRes = await request("node.list"); + if (!nodesRes.ok) { + // eslint-disable-next-line no-console + console.error("node.list failed:", nodesRes.error); + process.exit(4); + } + + const listPayload = (nodesRes.payload ?? {}) as NodeListPayload; + let node = pickIosNode(listPayload, nodeHint); + if (!node) { + const waitSeconds = Number.parseInt(getArg("--wait-seconds") ?? "25", 10); + const deadline = Date.now() + Math.max(1, waitSeconds) * 1000; + while (!node && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 1000)); + const res = await request("node.list").catch(() => null); + if (!res?.ok) { + continue; + } + node = pickIosNode((res.payload ?? {}) as NodeListPayload, nodeHint); + } + } + if (!node) { + // eslint-disable-next-line no-console + console.error("No connected iOS nodes found. (Is the iOS app connected to the gateway?)"); + process.exit(5); + } + + const tests: TestCase[] = [ + { id: "device.info", command: "device.info" }, + { id: "device.status", command: "device.status" }, + { + id: "system.notify", + command: "system.notify", + params: { title: "OpenClaw E2E", body: `ios-node-e2e @ ${isoNow()}`, delivery: "system" }, + }, + { + id: "contacts.search", + command: "contacts.search", + params: { query: null, limit: 5 }, + }, + { + id: "calendar.events", + command: "calendar.events", + params: { startISO: isoMinusMs(6 * 60 * 60 * 1000), endISO: isoNow(), limit: 10 }, + }, + { + id: "reminders.list", + command: "reminders.list", + params: { status: "incomplete", limit: 10 }, + }, + { + id: "motion.pedometer", + command: "motion.pedometer", + params: { startISO: isoMinusMs(60 * 60 * 1000), endISO: isoNow() }, + }, + { + id: "photos.latest", + command: "photos.latest", + params: { limit: 1, maxWidth: 512, quality: 0.7 }, + }, + { + id: "camera.snap", + command: "camera.snap", + params: { facing: "back", maxWidth: 768, quality: 0.7, format: "jpeg" }, + dangerous: true, + timeoutMs: 20_000, + }, + { + id: "screen.record", + command: "screen.record", + params: { durationMs: 2_000, fps: 15, includeAudio: false }, + dangerous: true, + timeoutMs: 30_000, + }, + ]; + + const run = tests.filter((t) => dangerous || !t.dangerous); + + const results: Array<{ + id: string; + ok: boolean; + error?: unknown; + payload?: unknown; + }> = []; + + for (const t of run) { + const invokeRes = await request( + "node.invoke", + { + nodeId: node.nodeId, + command: t.command, + params: t.params, + timeoutMs: t.timeoutMs ?? 12_000, + idempotencyKey: randomUUID(), + }, + (t.timeoutMs ?? 12_000) + 2_000, + ).catch((err) => { + results.push({ id: t.id, ok: false, error: formatErr(err) }); + return null; + }); + + if (!invokeRes) { + continue; + } + + if (!invokeRes.ok) { + results.push({ id: t.id, ok: false, error: invokeRes.error }); + continue; + } + + results.push({ id: t.id, ok: true, payload: invokeRes.payload }); + } + + if (jsonOut) { + // eslint-disable-next-line no-console + console.log( + JSON.stringify( + { + gateway: url.toString(), + node: { + nodeId: node.nodeId, + displayName: node.displayName, + platform: node.platform, + }, + dangerous, + results, + }, + null, + 2, + ), + ); + } else { + const pad = (s: string, n: number) => (s.length >= n ? s : s + " ".repeat(n - s.length)); + const rows = results.map((r) => ({ + cmd: r.id, + ok: r.ok ? "ok" : "fail", + note: r.ok ? "" : formatErr(r.error ?? "error"), + })); + const width = Math.min(64, Math.max(12, ...rows.map((r) => r.cmd.length))); + // eslint-disable-next-line no-console + console.log(`node: ${node.displayName ?? node.nodeId} (${node.platform ?? "unknown"})`); + // eslint-disable-next-line no-console + console.log(`dangerous: ${dangerous ? "on" : "off"}`); + // eslint-disable-next-line no-console + console.log(""); + for (const r of rows) { + // eslint-disable-next-line no-console + console.log(`${pad(r.cmd, width)} ${pad(r.ok, 4)} ${r.note}`); + } + } + + const failed = results.filter((r) => !r.ok); + ws.close(); + + if (failed.length > 0) { + process.exit(10); + } +} + +await main(); diff --git a/scripts/dev/ios-pull-gateway-log.sh b/scripts/dev/ios-pull-gateway-log.sh new file mode 100755 index 00000000000..3fa6dbe1864 --- /dev/null +++ b/scripts/dev/ios-pull-gateway-log.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEVICE_UDID="${1:-00008130-000630CE0146001C}" +BUNDLE_ID="${2:-ai.openclaw.ios.dev.mariano.test}" +DEST="${3:-/tmp/openclaw-gateway.log}" + +xcrun devicectl device copy from \ + --device "$DEVICE_UDID" \ + --domain-type appDataContainer \ + --domain-identifier "$BUNDLE_ID" \ + --source Documents/openclaw-gateway.log \ + --destination "$DEST" >/dev/null + +echo "Pulled to: $DEST" +tail -n 200 "$DEST" + diff --git a/scripts/dev/test-device-pair-telegram.ts b/scripts/dev/test-device-pair-telegram.ts new file mode 100644 index 00000000000..e33a060ecd4 --- /dev/null +++ b/scripts/dev/test-device-pair-telegram.ts @@ -0,0 +1,62 @@ +import { loadConfig } from "../../src/config/config.js"; +import { matchPluginCommand, executePluginCommand } from "../../src/plugins/commands.js"; +import { loadOpenClawPlugins } from "../../src/plugins/loader.js"; +import { sendMessageTelegram } from "../../src/telegram/send.js"; + +const args = process.argv.slice(2); +const getArg = (flag: string, short?: string) => { + const idx = args.indexOf(flag); + if (idx !== -1 && idx + 1 < args.length) { + return args[idx + 1]; + } + if (short) { + const sidx = args.indexOf(short); + if (sidx !== -1 && sidx + 1 < args.length) { + return args[sidx + 1]; + } + } + return undefined; +}; + +const chatId = getArg("--chat", "-c"); +const accountId = getArg("--account", "-a"); +if (!chatId) { + // eslint-disable-next-line no-console + console.error( + "Usage: bun scripts/dev/test-device-pair-telegram.ts --chat [--account ]", + ); + process.exit(1); +} + +const cfg = loadConfig(); +loadOpenClawPlugins({ config: cfg }); + +const match = matchPluginCommand("/pair"); +if (!match) { + // eslint-disable-next-line no-console + console.error("/pair plugin command not registered."); + process.exit(1); +} + +const result = await executePluginCommand({ + command: match.command, + args: match.args, + senderId: chatId, + channel: "telegram", + channelId: "telegram", + isAuthorizedSender: true, + commandBody: "/pair", + config: cfg, + from: `telegram:${chatId}`, + to: `telegram:${chatId}`, + accountId: accountId, +}); + +if (result.text) { + await sendMessageTelegram(chatId, result.text, { + accountId: accountId, + }); +} + +// eslint-disable-next-line no-console +console.log("Sent split /pair messages to", chatId, accountId ? `(${accountId})` : ""); diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index 6cfc4f0f1fd..7371b102605 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -35,9 +35,15 @@ export const handlePluginCommand: CommandHandler = async ( args: match.args, senderId: command.senderId, channel: command.channel, + channelId: command.channelId, isAuthorizedSender: command.isAuthorizedSender, commandBody: command.commandBodyNormalized, config: cfg, + from: command.from, + to: command.to, + accountId: params.ctx.AccountId ?? undefined, + messageThreadId: + typeof params.ctx.MessageThreadId === "number" ? params.ctx.MessageThreadId : undefined, }); return { diff --git a/src/gateway/node-command-policy.test.ts b/src/gateway/node-command-policy.test.ts new file mode 100644 index 00000000000..f96bd0eaf16 --- /dev/null +++ b/src/gateway/node-command-policy.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_DANGEROUS_NODE_COMMANDS, + resolveNodeCommandAllowlist, +} from "./node-command-policy.js"; + +describe("resolveNodeCommandAllowlist", () => { + it("includes iOS service commands by default", () => { + const allow = resolveNodeCommandAllowlist( + {}, + { + platform: "ios 26.0", + deviceFamily: "iPhone", + }, + ); + + expect(allow.has("device.info")).toBe(true); + expect(allow.has("device.status")).toBe(true); + expect(allow.has("system.notify")).toBe(true); + expect(allow.has("contacts.search")).toBe(true); + expect(allow.has("calendar.events")).toBe(true); + expect(allow.has("reminders.list")).toBe(true); + expect(allow.has("photos.latest")).toBe(true); + expect(allow.has("motion.activity")).toBe(true); + + for (const cmd of DEFAULT_DANGEROUS_NODE_COMMANDS) { + expect(allow.has(cmd)).toBe(false); + } + }); + + it("can explicitly allow dangerous commands via allowCommands", () => { + const allow = resolveNodeCommandAllowlist( + { + gateway: { + nodes: { + allowCommands: ["camera.snap", "screen.record"], + }, + }, + }, + { platform: "ios", deviceFamily: "iPhone" }, + ); + expect(allow.has("camera.snap")).toBe(true); + expect(allow.has("screen.record")).toBe(true); + expect(allow.has("camera.clip")).toBe(false); + }); +}); diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index f22611404cb..ca2ad13cbe6 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -12,13 +12,32 @@ const CANVAS_COMMANDS = [ "canvas.a2ui.reset", ]; -const CAMERA_COMMANDS = ["camera.list", "camera.snap", "camera.clip"]; +const CAMERA_COMMANDS = ["camera.list"]; +const CAMERA_DANGEROUS_COMMANDS = ["camera.snap", "camera.clip"]; -const SCREEN_COMMANDS = ["screen.record"]; +const SCREEN_DANGEROUS_COMMANDS = ["screen.record"]; const LOCATION_COMMANDS = ["location.get"]; -const SMS_COMMANDS = ["sms.send"]; +const DEVICE_COMMANDS = ["device.info", "device.status"]; + +const CONTACTS_COMMANDS = ["contacts.search"]; +const CONTACTS_DANGEROUS_COMMANDS = ["contacts.add"]; + +const CALENDAR_COMMANDS = ["calendar.events"]; +const CALENDAR_DANGEROUS_COMMANDS = ["calendar.add"]; + +const REMINDERS_COMMANDS = ["reminders.list"]; +const REMINDERS_DANGEROUS_COMMANDS = ["reminders.add"]; + +const PHOTOS_COMMANDS = ["photos.latest"]; + +const MOTION_COMMANDS = ["motion.activity", "motion.pedometer"]; + +const SMS_DANGEROUS_COMMANDS = ["sms.send"]; + +// iOS nodes don't implement system.run/which, but they do support notifications. +const IOS_SYSTEM_COMMANDS = ["system.notify"]; const SYSTEM_COMMANDS = [ "system.run", @@ -29,32 +48,56 @@ const SYSTEM_COMMANDS = [ "browser.proxy", ]; +// "High risk" node commands. These can be enabled by explicitly adding them to +// `gateway.nodes.allowCommands` (and ensuring they're not blocked by denyCommands). +export const DEFAULT_DANGEROUS_NODE_COMMANDS = [ + ...CAMERA_DANGEROUS_COMMANDS, + ...SCREEN_DANGEROUS_COMMANDS, + ...CONTACTS_DANGEROUS_COMMANDS, + ...CALENDAR_DANGEROUS_COMMANDS, + ...REMINDERS_DANGEROUS_COMMANDS, + ...SMS_DANGEROUS_COMMANDS, +]; + const PLATFORM_DEFAULTS: Record = { - ios: [...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS], + ios: [ + ...CANVAS_COMMANDS, + ...CAMERA_COMMANDS, + ...LOCATION_COMMANDS, + ...DEVICE_COMMANDS, + ...CONTACTS_COMMANDS, + ...CALENDAR_COMMANDS, + ...REMINDERS_COMMANDS, + ...PHOTOS_COMMANDS, + ...MOTION_COMMANDS, + ...IOS_SYSTEM_COMMANDS, + ], android: [ ...CANVAS_COMMANDS, ...CAMERA_COMMANDS, - ...SCREEN_COMMANDS, ...LOCATION_COMMANDS, - ...SMS_COMMANDS, + ...DEVICE_COMMANDS, + ...CONTACTS_COMMANDS, + ...CALENDAR_COMMANDS, + ...REMINDERS_COMMANDS, + ...PHOTOS_COMMANDS, + ...MOTION_COMMANDS, ], macos: [ ...CANVAS_COMMANDS, ...CAMERA_COMMANDS, - ...SCREEN_COMMANDS, ...LOCATION_COMMANDS, + ...DEVICE_COMMANDS, + ...CONTACTS_COMMANDS, + ...CALENDAR_COMMANDS, + ...REMINDERS_COMMANDS, + ...PHOTOS_COMMANDS, + ...MOTION_COMMANDS, ...SYSTEM_COMMANDS, ], linux: [...SYSTEM_COMMANDS], windows: [...SYSTEM_COMMANDS], - unknown: [ - ...CANVAS_COMMANDS, - ...CAMERA_COMMANDS, - ...SCREEN_COMMANDS, - ...LOCATION_COMMANDS, - ...SMS_COMMANDS, - ...SYSTEM_COMMANDS, - ], + unknown: [...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...LOCATION_COMMANDS, ...SYSTEM_COMMANDS], }; function normalizePlatformId(platform?: string, deviceFamily?: string): string { diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 21b91086114..695fa0079f5 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -368,7 +368,8 @@ export const chatHandlers: GatewayRequestHandlers = { return; } } - const { cfg, entry } = loadSessionEntry(p.sessionKey); + const rawSessionKey = p.sessionKey; + const { cfg, entry, canonicalKey: sessionKey } = loadSessionEntry(rawSessionKey); const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideMs: p.timeoutMs, @@ -379,7 +380,7 @@ export const chatHandlers: GatewayRequestHandlers = { const sendPolicy = resolveSendPolicy({ cfg, entry, - sessionKey: p.sessionKey, + sessionKey, channel: entry?.channel, chatType: entry?.chatType, }); @@ -404,7 +405,7 @@ export const chatHandlers: GatewayRequestHandlers = { broadcast: context.broadcast, nodeSendToSession: context.nodeSendToSession, }, - { sessionKey: p.sessionKey, stopReason: "stop" }, + { sessionKey: rawSessionKey, stopReason: "stop" }, ); respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds }); return; @@ -432,7 +433,7 @@ export const chatHandlers: GatewayRequestHandlers = { context.chatAbortControllers.set(clientRunId, { controller: abortController, sessionId: entry?.sessionId ?? clientRunId, - sessionKey: p.sessionKey, + sessionKey: rawSessionKey, startedAtMs: now, expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }), }); @@ -459,7 +460,7 @@ export const chatHandlers: GatewayRequestHandlers = { BodyForCommands: commandBody, RawBody: parsedMessage, CommandBody: commandBody, - SessionKey: p.sessionKey, + SessionKey: sessionKey, Provider: INTERNAL_MESSAGE_CHANNEL, Surface: INTERNAL_MESSAGE_CHANNEL, OriginatingChannel: INTERNAL_MESSAGE_CHANNEL, @@ -473,7 +474,7 @@ export const chatHandlers: GatewayRequestHandlers = { }; const agentId = resolveSessionAgentId({ - sessionKey: p.sessionKey, + sessionKey, config: cfg, }); const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ @@ -532,9 +533,8 @@ export const chatHandlers: GatewayRequestHandlers = { .trim(); let message: Record | undefined; if (combinedReply) { - const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry( - p.sessionKey, - ); + const { storePath: latestStorePath, entry: latestEntry } = + loadSessionEntry(sessionKey); const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId; const appended = appendAssistantTranscriptMessage({ message: combinedReply, @@ -562,7 +562,7 @@ export const chatHandlers: GatewayRequestHandlers = { broadcastChatFinal({ context, runId: clientRunId, - sessionKey: p.sessionKey, + sessionKey: rawSessionKey, message, }); } @@ -587,7 +587,7 @@ export const chatHandlers: GatewayRequestHandlers = { broadcastChatError({ context, runId: clientRunId, - sessionKey: p.sessionKey, + sessionKey: rawSessionKey, errorMessage: String(err), }); }) @@ -632,7 +632,8 @@ export const chatHandlers: GatewayRequestHandlers = { }; // Load session to find transcript file - const { storePath, entry } = loadSessionEntry(p.sessionKey); + const rawSessionKey = p.sessionKey; + const { storePath, entry } = loadSessionEntry(rawSessionKey); const sessionId = entry?.sessionId; if (!sessionId || !storePath) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "session not found")); @@ -687,13 +688,13 @@ export const chatHandlers: GatewayRequestHandlers = { // Broadcast to webchat for immediate UI update const chatPayload = { runId: `inject-${messageId}`, - sessionKey: p.sessionKey, + sessionKey: rawSessionKey, seq: 0, state: "final" as const, message: transcriptEntry.message, }; context.broadcast("chat", chatPayload); - context.nodeSendToSession(p.sessionKey, "chat", chatPayload); + context.nodeSendToSession(rawSessionKey, "chat", chatPayload); respond(true, { ok: true, messageId }); }, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index cbbbf65aa7e..ed452165bf5 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -122,6 +122,11 @@ export { resolveAckReaction } from "../agents/identity.js"; export type { ReplyPayload } from "../auto-reply/types.js"; export type { ChunkMode } from "../auto-reply/chunk.js"; export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js"; +export { + approveDevicePairing, + listDevicePairing, + rejectDevicePairing, +} from "../infra/device-pairing.js"; export { resolveToolsBySender } from "../config/group-policy.js"; export { buildPendingHistoryContextFromMap, diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index fa7f328e2e7..ff41b14d6fe 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -229,9 +229,14 @@ export async function executePluginCommand(params: { args?: string; senderId?: string; channel: string; + channelId?: PluginCommandContext["channelId"]; isAuthorizedSender: boolean; commandBody: string; config: OpenClawConfig; + from?: PluginCommandContext["from"]; + to?: PluginCommandContext["to"]; + accountId?: PluginCommandContext["accountId"]; + messageThreadId?: PluginCommandContext["messageThreadId"]; }): Promise { const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params; @@ -250,10 +255,15 @@ export async function executePluginCommand(params: { const ctx: PluginCommandContext = { senderId, channel, + channelId: params.channelId, isAuthorizedSender, args: sanitizedArgs, commandBody, config, + from: params.from, + to: params.to, + accountId: params.accountId, + messageThreadId: params.messageThreadId, }; // Lock registry during execution to prevent concurrent modifications diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 72344daa33f..5e41de9a86b 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -13,7 +13,11 @@ export type NormalizedPluginsConfig = { entries: Record; }; -export const BUNDLED_ENABLED_BY_DEFAULT = new Set(); +export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ + "device-pair", + "phone-control", + "talk-voice", +]); const normalizeList = (value: unknown): string[] => { if (!Array.isArray(value)) { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 4dbab48ff14..6ddcb9eef98 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -5,7 +5,7 @@ import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-prof import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { ChannelDock } from "../channels/dock.js"; -import type { ChannelPlugin } from "../channels/plugins/types.js"; +import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js"; import type { createVpsAwareOAuthHandlers } from "../commands/oauth-flow.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderConfig } from "../config/types.js"; @@ -140,6 +140,8 @@ export type PluginCommandContext = { senderId?: string; /** The channel/surface (e.g., "telegram", "discord") */ channel: string; + /** Provider channel id (e.g., "telegram") */ + channelId?: ChannelId; /** Whether the sender is on the allowlist */ isAuthorizedSender: boolean; /** Raw command arguments after the command name */ @@ -148,6 +150,14 @@ export type PluginCommandContext = { commandBody: string; /** Current OpenClaw configuration */ config: OpenClawConfig; + /** Raw "From" value (channel-scoped id) */ + from?: string; + /** Raw "To" value (channel-scoped id) */ + to?: string; + /** Account id for multi-account channels */ + accountId?: string; + /** Thread/topic id if available */ + messageThreadId?: number; }; /** diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 8f6c7b2b30c..26eee0e4513 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -675,6 +675,10 @@ export const registerTelegramNativeCommands = ({ isForum, messageThreadId, }); + const from = isGroup + ? buildTelegramGroupFrom(chatId, threadSpec.id) + : `telegram:${chatId}`; + const to = `telegram:${chatId}`; const result = await executePluginCommand({ command: match.command, @@ -684,6 +688,10 @@ export const registerTelegramNativeCommands = ({ isAuthorizedSender: commandAuthorized, commandBody, config: cfg, + from, + to, + accountId, + messageThreadId: threadSpec.id, }); const tableMode = resolveMarkdownTableMode({ cfg, diff --git a/src/wizard/onboarding.gateway-config.test.ts b/src/wizard/onboarding.gateway-config.test.ts index 42a474f979d..7c861175a3f 100644 --- a/src/wizard/onboarding.gateway-config.test.ts +++ b/src/wizard/onboarding.gateway-config.test.ts @@ -64,5 +64,13 @@ describe("configureGatewayForOnboarding", () => { }); expect(result.settings.gatewayToken).toBe("generated-token"); + expect(result.nextConfig.gateway?.nodes?.denyCommands).toEqual([ + "camera.snap", + "camera.clip", + "screen.record", + "calendar.add", + "contacts.add", + "reminders.add", + ]); }); }); diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts index 16f80135c1a..aef746a72d1 100644 --- a/src/wizard/onboarding.gateway-config.ts +++ b/src/wizard/onboarding.gateway-config.ts @@ -10,6 +10,20 @@ import type { WizardPrompter } from "./prompts.js"; import { normalizeGatewayTokenInput, randomToken } from "../commands/onboard-helpers.js"; import { findTailscaleBinary } from "../infra/tailscale.js"; +// These commands are "high risk" (privacy writes/recording) and should be +// explicitly armed by the user when they want to use them. +// +// This only affects what the gateway will accept via node.invoke; the iOS app +// still prompts for OS permissions (camera/photos/contacts/etc) on first use. +const DEFAULT_DANGEROUS_NODE_DENY_COMMANDS = [ + "camera.snap", + "camera.clip", + "screen.record", + "calendar.add", + "contacts.add", + "reminders.add", +]; + type ConfigureGatewayOptions = { flow: WizardFlow; baseConfig: OpenClawConfig; @@ -236,6 +250,27 @@ export async function configureGatewayForOnboarding( }, }; + // If this is a new gateway setup (no existing gateway settings), start with a + // denylist for high-risk node commands. Users can arm these temporarily via + // /phone arm ... (phone-control plugin). + if ( + !quickstartGateway.hasExisting && + nextConfig.gateway?.nodes?.denyCommands === undefined && + nextConfig.gateway?.nodes?.allowCommands === undefined && + nextConfig.gateway?.nodes?.browser === undefined + ) { + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + nodes: { + ...nextConfig.gateway?.nodes, + denyCommands: [...DEFAULT_DANGEROUS_NODE_DENY_COMMANDS], + }, + }, + }; + } + return { nextConfig, settings: {