mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Gateway/Plugins: device pairing + phone control plugins (#11755)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
497
extensions/device-pair/index.ts
Normal file
497
extensions/device-pair/index.ts
Normal 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),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
20
extensions/device-pair/openclaw.plugin.json
Normal file
20
extensions/device-pair/openclaw.plugin.json
Normal 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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
420
extensions/phone-control/index.ts
Normal file
420
extensions/phone-control/index.ts
Normal 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() };
|
||||
},
|
||||
});
|
||||
}
|
||||
10
extensions/phone-control/openclaw.plugin.json
Normal file
10
extensions/phone-control/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
150
extensions/talk-voice/index.ts
Normal file
150
extensions/talk-voice/index.ts
Normal 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"),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
10
extensions/talk-voice/openclaw.plugin.json
Normal file
10
extensions/talk-voice/openclaw.plugin.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "talk-voice",
|
||||
"name": "Talk Voice",
|
||||
"description": "Manage Talk voice selection (list/set).",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
163
scripts/dev/gateway-smoke.ts
Normal file
163
scripts/dev/gateway-smoke.ts
Normal 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
373
scripts/dev/ios-node-e2e.ts
Normal 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();
|
||||
17
scripts/dev/ios-pull-gateway-log.sh
Executable file
17
scripts/dev/ios-pull-gateway-log.sh
Executable 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"
|
||||
|
||||
62
scripts/dev/test-device-pair-telegram.ts
Normal file
62
scripts/dev/test-device-pair-telegram.ts
Normal 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})` : "");
|
||||
@@ -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 {
|
||||
|
||||
46
src/gateway/node-command-policy.test.ts
Normal file
46
src/gateway/node-command-policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user