Gateway/Plugins: device pairing + phone control plugins (#11755)

This commit is contained in:
Mariano Belinky
2026-02-08 18:07:13 +01:00
parent 2f91bf550f
commit 730f86dd5c
24 changed files with 1960 additions and 31 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 Telegrams 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`, `_` (132 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).

View File

@@ -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<string | null> {
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<string, unknown>)
: 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<string, unknown> {
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<string, unknown>;
} 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<ResolveUrlResult> {
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 <requestId>\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),
};
},
});
}

View File

@@ -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)."
}
}
}

View File

@@ -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<Exclude<ArmGroup, "all">, 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<ArmStateFile | null> {
try {
const raw = await fs.readFile(statePath, "utf8");
const parsed = JSON.parse(raw) as Partial<ArmStateFile>;
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<void> {
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 <group> [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<typeof setInterval> | 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 <group> [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() };
},
});
}

View File

@@ -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": {}
}
}

View File

@@ -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<ElevenLabsVoice[]> {
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 <voiceId|name>" };
}
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 <voiceId|name>",
].join("\n"),
};
},
});
}

View File

@@ -0,0 +1,10 @@
{
"id": "talk-voice",
"name": "Talk Voice",
"description": "Manage Talk voice selection (list/set).",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -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 <wss://host[:port]> --token <gateway.auth.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<typeof setTimeout>;
}
>();
const request = (method: string, params?: unknown, timeoutMs = 12000) =>
new Promise<GatewayResFrame>((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<void>((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();

373
scripts/dev/ios-node-e2e.ts Normal file
View File

@@ -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<NodeListPayload["nodes"]>[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 <wss://host[:port]> --token <gateway.auth.token> [--node <id|name-substring>] [--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<typeof setTimeout>;
}
>();
const request = (method: string, params?: unknown, timeoutMs = 12_000) =>
new Promise<GatewayResFrame>((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<void>((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();

View File

@@ -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"

View File

@@ -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 <telegram-chat-id> [--account <accountId>]",
);
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})` : "");

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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<string, string[]> = {
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 {

View File

@@ -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<string, unknown> | 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 });
},

View File

@@ -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,

View File

@@ -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<PluginCommandResult> {
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

View File

@@ -13,7 +13,11 @@ export type NormalizedPluginsConfig = {
entries: Record<string, { enabled?: boolean; config?: unknown }>;
};
export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>();
export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
"device-pair",
"phone-control",
"talk-voice",
]);
const normalizeList = (value: unknown): string[] => {
if (!Array.isArray(value)) {

View File

@@ -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;
};
/**

View File

@@ -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,

View File

@@ -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",
]);
});
});

View File

@@ -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: {