/** Detects system-domain launchd ownership before mutating a user LaunchAgent. */ import fs from "node:fs/promises"; import path from "node:path"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; import { isMissingPathError } from "../infra/errors.js"; import { execFileUtf8 } from "./exec-file.js"; import { execLaunchctl, formatLaunchctlResultDetail, isLaunchctlNotLoaded, launchctlInspectionReason, type LaunchctlResult, } from "./launchd-exec.js"; import type { ServiceInspectionReason } from "./service-inspection-error.js"; const SYSTEM_LAUNCH_DAEMON_DIR = "/Library/LaunchDaemons"; const PLUTIL_PATH = "/usr/bin/plutil"; type SystemLaunchDaemonOwnership = | { status: "absent"; serviceTarget: string } | { status: "loaded"; serviceTarget: string } | { status: "installed"; serviceTarget: string; plistPath: string } | { status: "unverifiable"; serviceTarget: string; operation: "launchctl" | "filesystem"; detail: string; reason?: ServiceInspectionReason; }; type SystemLaunchDaemonConflict = Exclude; function formatUnknownError(error: unknown): string { const raw = error instanceof Error ? error.message : String(error); return truncateUtf16Safe(sanitizeForLog(raw), 500); } function quotePosixArgument(value: string): string { return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; } /** * Renders the package-independent ownership probe used by detached restart helpers. * The caller must refuse activation when `openclaw_system_launchd_conflict` is non-empty. */ export function renderSystemLaunchDaemonOwnershipShellProbe(label: string): string { const serviceTarget = `system/${label}`; return `openclaw_system_launchd_conflict="" openclaw_system_launchd_detail="" openclaw_system_launchd_target=${quotePosixArgument(serviceTarget)} openclaw_system_launchd_dir=${quotePosixArgument(SYSTEM_LAUNCH_DAEMON_DIR)} openclaw_system_launchd_label=${quotePosixArgument(label)} openclaw_query_system_launchd() { openclaw_system_launchd_probe=$(launchctl print "$openclaw_system_launchd_target" 2>&1) openclaw_system_launchd_probe_status=$? # POSIX shell status 126/127 means execution failed; >128 can represent a signal. # Partial absence output cannot establish that the ownership query completed. if [ "$openclaw_system_launchd_probe_status" -eq 0 ]; then openclaw_system_launchd_conflict="$openclaw_system_launchd_target" openclaw_system_launchd_detail="loaded system LaunchDaemon $openclaw_system_launchd_target" elif [ "$openclaw_system_launchd_probe_status" -eq 126 ] || [ "$openclaw_system_launchd_probe_status" -eq 127 ] || [ "$openclaw_system_launchd_probe_status" -gt 128 ] || ! printf '%s' "$openclaw_system_launchd_probe" | /usr/bin/grep -Eiq 'could not find service|no such process|not found'; then openclaw_system_launchd_conflict="$openclaw_system_launchd_target" openclaw_system_launchd_detail="could not verify $openclaw_system_launchd_target (exit $openclaw_system_launchd_probe_status): $openclaw_system_launchd_probe" fi } openclaw_query_system_launchd if [ -z "$openclaw_system_launchd_conflict" ]; then if [ ! -e "$openclaw_system_launchd_dir" ]; then : elif [ ! -r "$openclaw_system_launchd_dir" ] || [ ! -x "$openclaw_system_launchd_dir" ]; then openclaw_system_launchd_conflict="$openclaw_system_launchd_dir" openclaw_system_launchd_detail="could not inspect $openclaw_system_launchd_dir" else openclaw_system_launchd_entries="" if openclaw_system_launchd_entries=$(/usr/bin/mktemp "\${TMPDIR:-/tmp}/openclaw-launchd-scan.XXXXXX" 2>&1); then if /usr/bin/find "$openclaw_system_launchd_dir" -mindepth 1 -maxdepth 1 -name '*.plist' -print0 >"$openclaw_system_launchd_entries"; then while IFS= read -r -d '' openclaw_system_launchd_plist; do # Unreadable plists are treated as foreign: loaded same-label daemons are caught by the # bracketing launchctl probes; an unloaded unreadable same-label plist is an accepted operator-created edge (#120481). if [ ! -r "$openclaw_system_launchd_plist" ]; then continue fi if openclaw_system_launchd_plist_label=$(/usr/bin/plutil -extract Label raw -o - -- "$openclaw_system_launchd_plist" 2>&1); then if [ "$openclaw_system_launchd_plist_label" != "$openclaw_system_launchd_label" ]; then continue fi openclaw_system_launchd_conflict="$openclaw_system_launchd_plist" openclaw_system_launchd_detail="installed same-label system LaunchDaemon plist $openclaw_system_launchd_plist" break elif /usr/bin/plutil -lint -- "$openclaw_system_launchd_plist" >/dev/null 2>&1; then continue else openclaw_system_launchd_conflict="$openclaw_system_launchd_plist" openclaw_system_launchd_detail="could not inspect system LaunchDaemon plist $openclaw_system_launchd_plist: $openclaw_system_launchd_plist_label" break fi done <"$openclaw_system_launchd_entries" else openclaw_system_launchd_conflict="$openclaw_system_launchd_dir" openclaw_system_launchd_detail="could not enumerate $openclaw_system_launchd_dir" fi /bin/rm -f "$openclaw_system_launchd_entries" else openclaw_system_launchd_conflict="$openclaw_system_launchd_dir" openclaw_system_launchd_detail="could not create a secure system LaunchDaemon scan snapshot: $openclaw_system_launchd_entries" fi fi fi if [ -z "$openclaw_system_launchd_conflict" ]; then openclaw_query_system_launchd fi `; } type LaunchDaemonPlistLabelResult = | { status: "ok"; label: string } | { status: "unlabeled" } | { status: "missing" } | { status: "unreadable" } | { status: "unverifiable"; detail: string }; /** Reads the top-level Label through the native parser for XML and binary plists. */ export async function readLaunchDaemonPlistLabel( plistPath: string, ): Promise { const converted = await execFileUtf8(PLUTIL_PATH, [ "-convert", "json", "-o", "-", "--", plistPath, ]); if (converted.code === 0) { try { const plist = JSON.parse(converted.stdout) as { Label?: unknown } | null; const label = plist?.Label; return typeof label === "string" && label.length > 0 ? { status: "ok", label } : { status: "unlabeled" }; } catch (error) { return { status: "unverifiable", detail: formatUnknownError(error) }; } } try { await fs.access(plistPath, fs.constants.R_OK); } catch (error) { if (isMissingPathError(error)) { return { status: "missing" }; } const code = (error as NodeJS.ErrnoException | undefined)?.code; if (code === "EACCES" || code === "EPERM") { return { status: "unreadable" }; } return { status: "unverifiable", detail: formatUnknownError(error) }; } return { status: "unverifiable", detail: formatLaunchctlResultDetail(converted) || "plutil could not decode the plist", }; } type InstalledSystemLaunchDaemonScan = | { status: "absent" } | { status: "installed"; plistPath: string } | { status: "unverifiable"; detail: string }; async function findInstalledSystemLaunchDaemon( label: string, ): Promise { let entries: string[]; try { entries = await fs.readdir(SYSTEM_LAUNCH_DAEMON_DIR); } catch (error) { if (isMissingPathError(error)) { return { status: "absent" }; } return { status: "unverifiable", detail: formatUnknownError(error) }; } for (const entry of entries.filter((candidate) => candidate.endsWith(".plist")).toSorted()) { const plistPath = path.posix.join(SYSTEM_LAUNCH_DAEMON_DIR, entry); const result = await readLaunchDaemonPlistLabel(plistPath); if (result.status === "ok" && result.label === label) { return { status: "installed", plistPath }; } // Unreadable plists are treated as foreign: loaded same-label daemons are caught by the // bracketing launchctl probes; an unloaded unreadable same-label plist is an accepted operator-created edge (#120481). if (result.status === "unreadable") { continue; } if (result.status === "unverifiable") { return { status: "unverifiable", detail: `${plistPath}: ${result.detail}` }; } } return { status: "absent" }; } function classifySystemLaunchDaemonQuery( serviceTarget: string, result: LaunchctlResult, ): SystemLaunchDaemonOwnership { if (result.code === 0) { return { status: "loaded", serviceTarget }; } return isLaunchctlNotLoaded(result) ? { status: "absent", serviceTarget } : { status: "unverifiable", serviceTarget, operation: "launchctl", detail: formatLaunchctlResultDetail(result) || `exit code ${result.code}`, reason: launchctlInspectionReason(result, serviceTarget), }; } export async function inspectSystemLaunchDaemonOwnership( label: string, options: { scanInstalledPlists?: boolean; timeoutMs?: number } = {}, ): Promise { const serviceTarget = `system/${label}`; if (process.platform !== "darwin") { return { status: "absent", serviceTarget }; } const initialQuery = classifySystemLaunchDaemonQuery( serviceTarget, await execLaunchctl(["print", serviceTarget], options.timeoutMs), ); if (initialQuery.status !== "absent") { return initialQuery; } if (options.scanInstalledPlists === false) { return { status: "absent", serviceTarget }; } const installed = await findInstalledSystemLaunchDaemon(label); if (installed.status === "installed") { return { status: "installed", serviceTarget, plistPath: installed.plistPath }; } if (installed.status === "unverifiable") { return { status: "unverifiable", serviceTarget, operation: "filesystem", detail: installed.detail, }; } // Close the query-to-directory-snapshot race at the last responsible moment. // Arbitrary root installers cannot share a lock with this unprivileged process; // activation paths therefore repeat this complete probe immediately before use. return classifySystemLaunchDaemonQuery( serviceTarget, await execLaunchctl(["print", serviceTarget], options.timeoutMs), ); } export function formatSystemLaunchDaemonOwnershipSummary( ownership: SystemLaunchDaemonConflict, ): string { switch (ownership.status) { case "loaded": return `System LaunchDaemon ${ownership.serviceTarget} already owns this gateway label.`; case "installed": return `System LaunchDaemon plist ${ownership.plistPath} already owns this gateway label.`; case "unverifiable": return `System LaunchDaemon ownership for ${ownership.serviceTarget} could not be verified: ${ownership.detail}`; default: { const exhaustive: never = ownership; throw new Error(`Unexpected system LaunchDaemon ownership: ${String(exhaustive)}`); } } } function formatSystemLaunchDaemonOwnershipError(ownership: SystemLaunchDaemonConflict): string { const recovery = ownership.status === "loaded" ? `Keep it as the sole gateway manager, or unload it with \`sudo launchctl bootout ${ownership.serviceTarget}\` and remove its plist before retrying.` : ownership.status === "installed" ? `Keep it as the sole gateway manager, or remove or relocate ${quotePosixArgument(ownership.plistPath)} before retrying.` : "Fix the reported launchctl or filesystem access error, then retry."; return [ formatSystemLaunchDaemonOwnershipSummary(ownership), "Refusing to create or activate a user LaunchAgent for the same label because duplicate KeepAlive managers can restart-loop the gateway.", "OpenClaw does not manage system LaunchDaemons, and --force does not override system ownership.", recovery, ].join("\n"); } class SystemLaunchDaemonOwnershipError extends Error { readonly code = "SYSTEM_LAUNCH_DAEMON_OWNERSHIP"; constructor(readonly ownership: SystemLaunchDaemonConflict) { super(formatSystemLaunchDaemonOwnershipError(ownership)); this.name = "SystemLaunchDaemonOwnershipError"; } } export function isSystemLaunchDaemonOwnershipError( error: unknown, ): error is SystemLaunchDaemonOwnershipError { return error instanceof SystemLaunchDaemonOwnershipError; } export async function assertNoSystemLaunchDaemonOwnership(label: string): Promise { const ownership = await inspectSystemLaunchDaemonOwnership(label); if (ownership.status !== "absent") { // System-domain ownership is host-wide. A gui-domain manager with the same // label can create two independent KeepAlive loops for one gateway port. throw new SystemLaunchDaemonOwnershipError(ownership); } }