UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

247 lines (246 loc) 10.3 kB
import { c as isRecord } from "./record-coerce-DItp3I4t.js"; import { r as isMissingPathError } from "./errno-CkbDOfLk.js"; import { w as resolveStateDir } from "./paths-D2sRr1a_.js"; import "./errors-Db3Ymjlb.js"; import { n as createPluginStateKeyedStore } from "./plugin-state-store-C6hmUuuk.js"; import { t as archiveLegacyStateSource } from "./doctor-state-migration-fs-CfVap4xL.js"; import path from "node:path"; import fs from "node:fs/promises"; //#region src/commands/doctor-retired-phone-control.ts const PHONE_CONTROL_PLUGIN_ID = "phone-control"; const ARM_STATE_NAMESPACE = "armed"; const RETIRED_ARM_GROUPS = /* @__PURE__ */ new Set([ "camera", "screen", "computer", "mobile-ui", "writes", "all" ]); const RETIRED_PHONE_CONTROL_SEEDED_DENY_COMMANDS = [ "camera.snap", "camera.clip", "screen.record", "computer.act", "mobile.ui.observe", "mobile.ui.act", "contacts.add", "calendar.add", "reminders.add", "sms.send", "sms.search", "health.summary" ]; function resolveLegacyArmStatePath(env) { return path.join(resolveStateDir(env), "plugins", PHONE_CONTROL_PLUGIN_ID, "armed.json"); } function resolveStateDatabasePath(env) { return path.join(resolveStateDir(env), "state", "openclaw.sqlite"); } async function inspectStatePath(filePath, label) { try { return (await fs.stat(filePath)).isFile() ? { status: "file" } : { status: "unsafe", warning: `${label} at ${filePath} is not a regular file.` }; } catch (error) { if (isMissingPathError(error)) return { status: "missing" }; return { status: "unsafe", warning: `Could not inspect ${label} at ${filePath}: ${String(error)}` }; } } function isStringArray(value) { return Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.trim() !== ""); } function isTimestamp(value) { return Number.isSafeInteger(value) && Number(value) >= 0; } function isRetiredArmState(value) { if (!isRecord(value) || !isTimestamp(value.armedAtMs)) return false; if (value.expiresAtMs !== null && !isTimestamp(value.expiresAtMs)) return false; if (value.version === 1) return isStringArray(value.removedFromDeny); if (value.version !== 2 && value.version !== 3) return false; if (typeof value.group !== "string" || !RETIRED_ARM_GROUPS.has(value.group) || !isStringArray(value.armedCommands) || !isStringArray(value.addedToAllow) || !isStringArray(value.removedFromDeny)) return false; if (value.version === 2) return true; return typeof value.generation === "string" && value.generation.trim() !== "" && (value.phase === "preparing" || value.phase === "active") && isStringArray(value.persistentAllows); } function readStringArrayField(value, field) { if (!value || typeof value !== "object" || Array.isArray(value)) return []; const entries = value[field]; return Array.isArray(entries) ? entries.filter((entry) => typeof entry === "string" && entry.trim() !== "") : []; } function openRetiredArmStateStore(env) { return createPluginStateKeyedStore(PHONE_CONTROL_PLUGIN_ID, { namespace: ARM_STATE_NAMESPACE, maxEntries: 1, overflowPolicy: "reject-new", env }); } async function readRetiredArmStates(env) { const legacyPath = resolveLegacyArmStatePath(env); const databasePath = resolveStateDatabasePath(env); const [legacyInspection, databaseInspection] = await Promise.all([inspectStatePath(legacyPath, "retired Phone Control lease state"), inspectStatePath(databasePath, "OpenClaw state database")]); const warnings = []; const inspectionUnsafe = legacyInspection.status === "unsafe" || databaseInspection.status === "unsafe"; if (legacyInspection.status === "unsafe") warnings.push(legacyInspection.warning); if (databaseInspection.status === "unsafe") warnings.push(databaseInspection.warning); let legacyState; let legacyStateValid = false; if (legacyInspection.status === "file") try { legacyState = JSON.parse(await fs.readFile(legacyPath, "utf8")); legacyStateValid = isRetiredArmState(legacyState); if (!legacyStateValid) warnings.push(`Retired Phone Control lease state at ${legacyPath} is malformed.`); } catch (error) { warnings.push(`Could not read retired Phone Control lease state at ${legacyPath}: ${String(error)}`); } let sqliteReadFailed = false; let sqliteEntries = []; if (databaseInspection.status === "file") try { sqliteEntries = await openRetiredArmStateStore(env).entries(); if (sqliteEntries.length > 1) { sqliteReadFailed = true; warnings.push("Retired Phone Control lease journal contains multiple records."); } else if (sqliteEntries.length === 1 && !isRetiredArmState(sqliteEntries[0]?.value)) { sqliteReadFailed = true; warnings.push("Retired Phone Control lease journal contains a malformed record."); } } catch (error) { sqliteReadFailed = true; warnings.push(`Could not read retired Phone Control lease journal: ${String(error)}`); } const hasCanonicalState = sqliteEntries.length === 1 && !sqliteReadFailed; const cleanupSafe = !inspectionUnsafe && !sqliteReadFailed && (hasCanonicalState || legacyInspection.status !== "file" || legacyStateValid); return { states: cleanupSafe ? hasCanonicalState ? sqliteEntries.map((entry) => entry.value) : legacyStateValid ? [legacyState] : [] : [], cleanupPending: inspectionUnsafe || legacyInspection.status !== "missing" || sqliteEntries.length > 0 || sqliteReadFailed, cleanupSafe, warnings }; } function isExactSeededDenyList(values) { const normalized = [...values].toSorted(); const expected = [...RETIRED_PHONE_CONTROL_SEEDED_DENY_COMMANDS].toSorted(); return normalized.length === expected.length && normalized.every((value, index) => value === expected[index]); } function withCommandLists(cfg, params) { const commands = { ...cfg.gateway?.nodes?.commands }; if (params.allow === void 0 || params.allow.length === 0) delete commands.allow; else commands.allow = params.allow; if (params.deny === void 0 || params.deny.length === 0) delete commands.deny; else commands.deny = params.deny; const nodes = { ...cfg.gateway?.nodes }; delete nodes.commands; return { ...cfg, gateway: { ...cfg.gateway, nodes: { ...nodes, ...Object.keys(commands).length > 0 ? { commands } : {} } } }; } /** Plans canonical config cleanup while retaining the journal until the config write succeeds. */ async function prepareRetiredPhoneControlCleanup(params) { const residue = await readRetiredArmStates(params.env ?? process.env); if (!residue.cleanupSafe) return { config: params.cfg, configChanges: [], cleanupPending: residue.cleanupPending, cleanupSafe: false, warnings: residue.warnings }; const leaseAddedAllows = new Set(residue.states.flatMap((state) => readStringArrayField(state, "addedToAllow"))); const leaseRemovedDenies = residue.states.flatMap((state) => readStringArrayField(state, "removedFromDeny")); const currentAllow = params.cfg.gateway?.nodes?.commands?.allow; const currentDeny = params.cfg.gateway?.nodes?.commands?.deny; const reconstructedDeny = [...currentDeny ?? []]; const reconstructedDenySet = new Set(reconstructedDeny); for (const command of leaseRemovedDenies) if (!reconstructedDenySet.has(command)) { reconstructedDeny.push(command); reconstructedDenySet.add(command); } const removeSeededDeny = currentDeny !== void 0 && isExactSeededDenyList(reconstructedDeny); const leaseShadowedAllows = removeSeededDeny ? new Set(leaseRemovedDenies) : void 0; const nextAllow = currentAllow?.filter((command) => !leaseAddedAllows.has(command.trim()) && !leaseShadowedAllows?.has(command.trim())); const nextDeny = removeSeededDeny ? reconstructedDeny.filter((command) => nextAllow?.includes(command)) : reconstructedDeny; const allowChanged = Boolean(currentAllow && nextAllow?.length !== currentAllow.length); const denyChanged = reconstructedDeny.length !== (currentDeny?.length ?? 0); if (!allowChanged && !denyChanged && !removeSeededDeny) return { config: params.cfg, configChanges: [], cleanupPending: residue.cleanupPending, cleanupSafe: residue.cleanupSafe, warnings: residue.warnings }; const configChanges = []; if (allowChanged) configChanges.push("Removed stale Phone Control lease-only command allow entries."); if (denyChanged && !removeSeededDeny) configChanges.push("Restored command deny entries removed by Phone Control leases."); if (removeSeededDeny) configChanges.push("Removed the retired Phone Control setup deny seed."); return { config: withCommandLists(params.cfg, { allow: nextAllow, deny: nextDeny }), configChanges, cleanupPending: residue.cleanupPending, cleanupSafe: residue.cleanupSafe, warnings: residue.warnings }; } /** Drops SQLite journal rows and archives the legacy file after config persistence. */ async function finalizeRetiredPhoneControlCleanup(params) { const env = params.env ?? process.env; const changes = []; const warnings = []; const legacyPath = resolveLegacyArmStatePath(env); const legacyInspection = await inspectStatePath(legacyPath, "retired Phone Control lease state"); if (legacyInspection.status === "unsafe") { warnings.push(legacyInspection.warning); return { changes, warnings }; } if (legacyInspection.status === "file") { await archiveLegacyStateSource({ filePath: legacyPath, label: "retired Phone Control lease state", changes, warnings }); const postArchiveInspection = await inspectStatePath(legacyPath, "retired Phone Control lease state"); if (postArchiveInspection.status === "unsafe") warnings.push(postArchiveInspection.warning); if (postArchiveInspection.status !== "missing") return { changes, warnings }; } const databaseInspection = await inspectStatePath(resolveStateDatabasePath(env), "OpenClaw state database"); if (databaseInspection.status === "unsafe") { warnings.push(databaseInspection.warning); return { changes, warnings }; } if (databaseInspection.status === "file") try { const store = openRetiredArmStateStore(env); if ((await store.entries()).length > 0) { await store.clear(); changes.push("Dropped the retired Phone Control lease journal."); } } catch (error) { warnings.push(`Failed to drop the retired Phone Control lease journal: ${String(error)}`); } return { changes, warnings }; } //#endregion export { finalizeRetiredPhoneControlCleanup, prepareRetiredPhoneControlCleanup };