UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

312 lines (311 loc) 11 kB
import { a as normalizeLowercaseStringOrEmpty, s as normalizeOptionalLowercaseString } from "../../string-coerce-mnp54Vah.js"; import { C as resolveExpiresAtMsFromDurationMs, o as asDateTimestampMs } from "../../number-coercion-CJQ8TR--.js"; import { g as sortUniqueStrings, l as normalizeStringEntries } from "../../string-normalization-WNUDCpXX.js"; import "../../number-runtime-DBLVDypr.js"; import "../../string-coerce-runtime-CEGJWkQ_.js"; import { t as definePluginEntry } from "../../plugin-entry-C7DUzV0e.js"; import "../../runtime-api-DqxgQbnC.js"; //#region extensions/phone-control/index.ts const STATE_VERSION = 2; const ARM_STATE_NAMESPACE = "armed"; const ARM_STATE_KEY = "current"; const PHONE_ADMIN_SCOPE = "operator.admin"; const GROUP_COMMANDS = { camera: ["camera.snap", "camera.clip"], screen: ["screen.record"], writes: [ "calendar.add", "contacts.add", "reminders.add", "sms.send" ] }; const PHONE_CONTROL_COMMANDS = Object.values(GROUP_COMMANDS).flat(); function uniqSorted(values) { return sortUniqueStrings(normalizeStringEntries(values)); } function resolveCommandsForGroup(group) { if (group === "all") return uniqSorted(Object.values(GROUP_COMMANDS).flat()); return uniqSorted(GROUP_COMMANDS[group]); } function formatGroupList() { return [ "camera", "screen", "writes", "all" ].join(", "); } function parseDurationMs(input) { const raw = normalizeOptionalLowercaseString(input); 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 durationMs = n * (unit === "s" ? 1e3 : unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5); return Number.isSafeInteger(durationMs) ? durationMs : null; } function formatDuration(ms) { const s = Math.max(0, Math.floor(ms / 1e3)); 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`; return `${Math.floor(h / 24)}d`; } function openArmStateStore(api) { return api.runtime.state.openKeyedStore({ namespace: ARM_STATE_NAMESPACE, maxEntries: 1 }); } async function readArmState(api) { return await openArmStateStore(api).lookup(ARM_STATE_KEY) ?? null; } async function writeArmState(api, state) { const store = openArmStateStore(api); if (!state) { await store.delete(ARM_STATE_KEY); return; } await store.register(ARM_STATE_KEY, state); } function normalizeDenyList(cfg) { return uniqSorted([...cfg.gateway?.nodes?.denyCommands ?? []]); } function normalizeAllowList(cfg) { return uniqSorted([...cfg.gateway?.nodes?.allowCommands ?? []]); } function hasPhoneControlAllowOverride(cfg) { const allow = new Set(normalizeAllowList(cfg)); return PHONE_CONTROL_COMMANDS.some((cmd) => allow.has(cmd)); } function patchConfigNodeLists(cfg, next) { return { ...cfg, gateway: { ...cfg.gateway, nodes: { ...cfg.gateway?.nodes, allowCommands: next.allowCommands, denyCommands: next.denyCommands } } }; } async function disarmNow(params) { const { api, reason } = params; const state = await readArmState(api); if (!state) return { changed: false, restored: [], removed: [] }; const cfg = api.runtime.config.current(); const allow = new Set(normalizeAllowList(cfg)); const deny = new Set(normalizeDenyList(cfg)); const removed = []; const restored = []; 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) await api.runtime.config.mutateConfigFile({ afterWrite: { mode: "auto" }, mutate: (draft) => { const next = patchConfigNodeLists(draft, { allowCommands: uniqSorted([...allow]), denyCommands: uniqSorted([...deny]) }); Object.assign(draft, next); } }); await writeArmState(api, null); api.logger.info(`phone-control: disarmed (${reason})`); return { changed: removed.length > 0 || restored.length > 0, removed: uniqSorted(removed), restored: uniqSorted(restored) }; } function formatHelp() { 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) { const value = normalizeOptionalLowercaseString(raw) ?? ""; if (!value) return null; if (value === "camera" || value === "screen" || value === "writes" || value === "all") return value; return null; } function lacksAdminToMutatePhoneControl(params) { const { senderIsOwner, gatewayClientScopes } = params; if (Array.isArray(gatewayClientScopes)) return !gatewayClientScopes.includes(PHONE_ADMIN_SCOPE); return senderIsOwner !== true; } function resolveArmExpiryStatus(state, nowRaw = Date.now()) { if (state.expiresAtMs == null) return "manual disarm required"; const now = asDateTimestampMs(nowRaw); if (now === void 0) return "expiry unavailable"; const expiresAt = asDateTimestampMs(state.expiresAtMs); if (expiresAt === void 0 || expiresAt <= now) return "expired"; return `expires in ${formatDuration(expiresAt - now)}`; } function isArmStateExpired(state, nowRaw = Date.now()) { if (state.expiresAtMs == null) return false; const now = asDateTimestampMs(nowRaw); if (now === void 0) return false; const expiresAt = asDateTimestampMs(state.expiresAtMs); return expiresAt === void 0 || expiresAt <= now; } function formatStatus(state) { if (!state) return "Phone control: disarmed."; const until = resolveArmExpiryStatus(state); const cmds = uniqSorted(state.version === 1 ? state.removedFromDeny : state.armedCommands.length > 0 ? state.armedCommands : [...state.addedToAllow, ...state.removedFromDeny]); return `Phone control: armed (${until}).\nTemporarily allowed: ${cmds.length > 0 ? cmds.join(", ") : "none"}`; } var phone_control_default = definePluginEntry({ id: "phone-control", name: "Phone Control", description: "Temporary allowlist control for phone automation commands", register(api) { let expiryInterval = null; let initialExpiryTick = null; api.registerService({ id: "phone-control-expiry", start: async (ctx) => { const tick = async () => { const state = await readArmState(api); if (!state || state.expiresAtMs == null) return; if (!isArmStateExpired(state)) return; await disarmNow({ api, reason: "expired" }); }; expiryInterval = setInterval(() => { tick().catch(() => {}); }, 15e3); expiryInterval.unref?.(); if (hasPhoneControlAllowOverride(ctx.config)) await tick().catch(() => {}); else { initialExpiryTick = setImmediate(() => { initialExpiryTick = null; tick().catch(() => {}); }); initialExpiryTick.unref?.(); } }, stop: async () => { if (initialExpiryTick) { clearImmediate(initialExpiryTick); initialExpiryTick = null; } if (expiryInterval) { clearInterval(expiryInterval); expiryInterval = null; } } }); api.registerCommand({ name: "phone", description: "Arm/disarm high-risk phone node commands (camera/screen/writes).", acceptsArgs: true, exposeSenderIsOwner: true, handler: async (ctx) => { const tokens = (ctx.args?.trim() ?? "").split(/\s+/).filter(Boolean); const action = normalizeLowercaseStringOrEmpty(tokens[0]); if (!action || action === "help") return { text: `${formatStatus(await readArmState(api))}\n\n${formatHelp()}` }; if (action === "status") return { text: formatStatus(await readArmState(api)) }; if (action === "disarm") { if (lacksAdminToMutatePhoneControl({ senderIsOwner: ctx.senderIsOwner, gatewayClientScopes: ctx.gatewayClientScopes })) return { text: "⚠️ /phone disarm requires operator.admin." }; const res = await disarmNow({ api, reason: "manual" }); if (!res.changed) return { text: "Phone control: disarmed." }; const restoredLabel = res.restored.length > 0 ? res.restored.join(", ") : "none"; return { text: `Phone control: disarmed.\nRemoved allowlist: ${res.removed.length > 0 ? res.removed.join(", ") : "none"}\nRestored denylist: ${restoredLabel}` }; } if (action === "arm") { if (lacksAdminToMutatePhoneControl({ senderIsOwner: ctx.senderIsOwner, gatewayClientScopes: ctx.gatewayClientScopes })) return { text: "⚠️ /phone arm requires operator.admin." }; const group = parseGroup(tokens[1]); if (!group) return { text: `Usage: /phone arm <group> [duration]\nGroups: ${formatGroupList()}` }; const durationMs = tokens[2] === void 0 ? 10 * 6e4 : parseDurationMs(tokens[2]); if (durationMs === null) return { text: "Invalid duration. Use values like 30s, 10m, 2h, or 1d." }; const armedAtMs = asDateTimestampMs(Date.now()); const expiresAtMs = armedAtMs === void 0 ? void 0 : resolveExpiresAtMsFromDurationMs(durationMs, { nowMs: armedAtMs }); if (armedAtMs === void 0 || expiresAtMs === void 0) return { text: "Invalid duration. Use values like 30s, 10m, 2h, or 1d." }; const commands = resolveCommandsForGroup(group); const cfg = api.runtime.config.current(); const allowSet = new Set(normalizeAllowList(cfg)); const denySet = new Set(normalizeDenyList(cfg)); const addedToAllow = []; const removedFromDeny = []; for (const cmd of commands) { if (!allowSet.has(cmd)) { allowSet.add(cmd); addedToAllow.push(cmd); } if (denySet.delete(cmd)) removedFromDeny.push(cmd); } await api.runtime.config.mutateConfigFile({ afterWrite: { mode: "auto" }, mutate: (draft) => { const next = patchConfigNodeLists(draft, { allowCommands: uniqSorted([...allowSet]), denyCommands: uniqSorted([...denySet]) }); Object.assign(draft, next); } }); await writeArmState(api, { version: STATE_VERSION, armedAtMs, expiresAtMs, group, armedCommands: uniqSorted(commands), addedToAllow: uniqSorted(addedToAllow), removedFromDeny: uniqSorted(removedFromDeny) }); const allowedLabel = uniqSorted(commands).join(", "); return { text: `Phone control: armed for ${formatDuration(durationMs)}.\nTemporarily allowed: ${allowedLabel}\nTo disarm early: /phone disarm` }; } return { text: formatHelp() }; } }); } }); //#endregion export { phone_control_default as default };