diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index f824cadad96d..2b91f1f9cbca 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -635,7 +635,6 @@ src/cron/service/ops.test.ts src/cron/service/timer.regression.test.ts src/cron/store.test.ts src/daemon/launchd.test.ts -src/daemon/launchd.ts src/daemon/schtasks.startup-fallback.test.ts src/daemon/systemd.test.ts src/daemon/systemd.ts diff --git a/src/daemon/launchd-install.ts b/src/daemon/launchd-install.ts new file mode 100644 index 000000000000..b1b0f36091dd --- /dev/null +++ b/src/daemon/launchd-install.ts @@ -0,0 +1,423 @@ +/** Transactional LaunchAgent installation, staging, rollback, and removal. */ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveLegacyGatewayLaunchAgentLabels } from "./constants.js"; +import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js"; +import { + execLaunchctl, + formatLaunchctlResultDetail, + isLaunchctlNotLoaded, +} from "./launchd-exec.js"; +import { assertValidLaunchAgentLabel, resolveLaunchAgentLabel } from "./launchd-label.js"; +import { + bootstrapLaunchAgentOrThrow, + probeLaunchAgentState, + resolveLaunchAgentGuiDomain, +} from "./launchd-runtime.js"; +import { + LAUNCH_AGENT_ENV_FILE_MODE, + LAUNCH_AGENT_ENV_WRAPPER_MODE, + LAUNCH_AGENT_PLIST_MODE, + publishLaunchAgentPlist, + readExistingLaunchAgentPlist, + resolveLaunchAgentEnvFilePath, + resolveLaunchAgentEnvWrapperPath, + resolveLaunchAgentPlistPath, + resolveLaunchAgentPlistPathForLabel, + writeLaunchAgentPlist, +} from "./launchd-service-files.js"; +import { assertNoSystemLaunchDaemonOwnership } from "./launchd-system.js"; +import { formatLine, toPosixPath, writeFormattedLines } from "./output.js"; +import { resolveDaemonHomeDir } from "./paths.js"; +import type { + GatewayServiceEnv, + GatewayServiceInstallArgs, + GatewayServiceManageArgs, +} from "./service-types.js"; + +export async function uninstallLaunchAgent({ + env, + stdout, +}: GatewayServiceManageArgs): Promise { + assertExternalLaunchAgentMutation(env, "uninstall"); + const domain = resolveLaunchAgentGuiDomain(); + const label = resolveLaunchAgentLabel(env); + const plistPath = resolveLaunchAgentPlistPath(env); + const bootout = await execLaunchctl(["bootout", domain, plistPath]); + if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { + throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`); + } + + try { + await fs.lstat(plistPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw createLaunchAgentRemovalError(error); + } + stdout.write(`LaunchAgent not found at ${plistPath}\n`); + return; + } + + const home = toPosixPath(resolveDaemonHomeDir(env)); + const trashDir = path.posix.join(home, ".Trash"); + const dest = path.join(trashDir, `${label}.plist`); + try { + await fs.mkdir(trashDir, { recursive: true }); + await fs.rename(plistPath, dest); + stdout.write(`${formatLine("Moved LaunchAgent to Trash", dest)}\n`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + try { + await fs.lstat(plistPath); + } catch (accessError) { + if ((accessError as NodeJS.ErrnoException).code === "ENOENT") { + stdout.write(`LaunchAgent not found at ${plistPath}\n`); + return; + } + throw createLaunchAgentRemovalError(accessError); + } + } + throw createLaunchAgentRemovalError(error); + } +} + +function createLaunchAgentRemovalError(error: unknown): Error { + const code = (error as NodeJS.ErrnoException).code; + return new Error( + `LaunchAgent removal failed${code ? ` (${code})` : ""}. Check permissions and retry.`, + ); +} +function currentGatewayLaunchAgentLabel( + targetEnv: Record, +): string | undefined { + const configuredCurrentLabel = process.env.OPENCLAW_LAUNCHD_LABEL?.trim(); + const candidates = new Set([ + resolveLaunchAgentLabel(targetEnv), + ...(configuredCurrentLabel ? [assertValidLaunchAgentLabel(configuredCurrentLabel)] : []), + ]); + return [...candidates].find((label) => + isCurrentProcessLaunchdServiceLabel(label, process.env, { + allowConfiguredLabelFallback: false, + }), + ); +} + +function assertExternalLaunchAgentMutation( + env: Record, + action: "install" | "uninstall", +): void { + const currentLabel = currentGatewayLaunchAgentLabel(env); + if (!currentLabel) { + return; + } + throw new Error( + `Refusing to ${action} LaunchAgent ${resolveLaunchAgentLabel(env)} from inside ${currentLabel}; run this command from an external shell.`, + ); +} + +export async function stageLaunchAgent({ + stdout, + ...args +}: GatewayServiceInstallArgs): Promise<{ plistPath: string }> { + const { plistPath, stdoutPath } = await writeLaunchAgentPlist({ ...args, stdout }); + writeFormattedLines( + stdout, + [ + { label: "Staged LaunchAgent", value: plistPath }, + { label: "Logs", value: stdoutPath }, + ], + { leadingBlankLine: true }, + ); + return { plistPath }; +} + +type LaunchAgentInstallSnapshot = { + plistContents: Buffer | null; + envFileContents: Buffer | null; + wrapperContents: Buffer | null; + legacy: Array<{ + label: string; + plistPath: string; + contents: Buffer | null; + loaded: boolean; + }>; + loaded: boolean; +}; + +async function snapshotLaunchAgentLoadedState( + plistContents: Buffer | null, + serviceTarget: string, +): Promise { + const probe = await probeLaunchAgentState(serviceTarget); + if (probe.state === "unknown") { + throw new Error( + `launchctl print could not determine whether ${serviceTarget} is loaded: ${probe.detail ?? "unknown error"}`, + ); + } + const loaded = probe.state !== "not-loaded"; + if (loaded && plistContents === null) { + // launchd can retain a definition after its plist is deleted. Booting that + // job out would destroy the only copy, so no exact rollback is possible. + throw new Error( + `LaunchAgent ${serviceTarget} is loaded but its plist is missing; refusing an install that cannot restore the current definition if activation fails.`, + ); + } + return loaded; +} + +async function restoreLaunchAgentOwnedFile(params: { + path: string; + contents: Buffer | null; + mode: number; +}): Promise { + if (params.contents === null) { + await fs.unlink(params.path).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + }); + return; + } + const temporaryPath = `${params.path}.openclaw-${randomUUID()}.rollback`; + try { + await fs.writeFile(temporaryPath, params.contents.toString("utf8"), { + flag: "wx", + mode: params.mode, + }); + await fs.rename(temporaryPath, params.path); + await fs.chmod(params.path, params.mode).catch(() => undefined); + } finally { + await fs.unlink(temporaryPath).catch(() => undefined); + } +} + +async function restoreLaunchAgentInstallArtifacts(params: { + env: GatewayServiceEnv; + label: string; + plistPath: string; + snapshot: LaunchAgentInstallSnapshot; +}): Promise { + await restoreLaunchAgentOwnedFile({ + path: resolveLaunchAgentEnvFilePath(params.env, params.label), + contents: params.snapshot.envFileContents, + mode: LAUNCH_AGENT_ENV_FILE_MODE, + }); + await restoreLaunchAgentOwnedFile({ + path: resolveLaunchAgentEnvWrapperPath(params.env, params.label), + contents: params.snapshot.wrapperContents, + mode: LAUNCH_AGENT_ENV_WRAPPER_MODE, + }); + for (const legacy of params.snapshot.legacy) { + await restoreLaunchAgentOwnedFile({ + path: legacy.plistPath, + contents: legacy.contents, + mode: LAUNCH_AGENT_PLIST_MODE, + }); + } + if (params.snapshot.plistContents === null) { + await fs.unlink(params.plistPath).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + }); + return; + } + await publishLaunchAgentPlist({ + label: params.label, + plistPath: params.plistPath, + contents: params.snapshot.plistContents.toString("utf8"), + }); +} + +async function restoreLaunchAgentInstall(params: { + domain: string; + env: GatewayServiceEnv; + label: string; + plistPath: string; + snapshot: LaunchAgentInstallSnapshot; +}): Promise { + const serviceTarget = `${params.domain}/${params.label}`; + // A failed bootstrap may leave no registered job. Restore files directly in + // that state; only a loaded replacement must be removed before rollback. + const currentState = await probeLaunchAgentState(serviceTarget); + if (currentState.state === "unknown") { + throw new Error( + `launchctl print could not determine whether ${serviceTarget} is loaded during LaunchAgent rollback: ${currentState.detail ?? "unknown error"}`, + ); + } + if (currentState.state !== "not-loaded") { + const bootout = await execLaunchctl(["bootout", serviceTarget]); + if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { + throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`); + } + } + await restoreLaunchAgentInstallArtifacts({ + env: params.env, + label: params.label, + plistPath: params.plistPath, + snapshot: params.snapshot, + }); + if (params.snapshot.loaded && params.snapshot.plistContents !== null) { + await bootstrapLaunchAgentOrThrow({ + domain: params.domain, + serviceTarget, + plistPath: params.plistPath, + actionHint: "openclaw gateway start", + retryPendingTeardown: true, + }); + } + for (const legacy of params.snapshot.legacy) { + if (!legacy.loaded || legacy.contents === null) { + continue; + } + await bootstrapLaunchAgentOrThrow({ + domain: params.domain, + serviceTarget: `${params.domain}/${legacy.label}`, + plistPath: legacy.plistPath, + actionHint: "openclaw gateway start", + retryPendingTeardown: true, + }); + } +} + +async function deactivateLaunchAgentDefinition(domain: string, plistPath: string): Promise { + for (const args of [ + ["bootout", domain, plistPath], + ["unload", plistPath], + ]) { + const result = await execLaunchctl(args); + if (result.code !== 0 && !isLaunchctlNotLoaded(result)) { + throw new Error( + `launchctl ${args[0]} failed during LaunchAgent install: ${formatLaunchctlResultDetail(result)}`, + ); + } + } +} + +async function activateLaunchAgent(params: { + env: GatewayServiceEnv; + plistPath: string; + snapshot: LaunchAgentInstallSnapshot; +}) { + const domain = resolveLaunchAgentGuiDomain(); + const label = resolveLaunchAgentLabel(params.env); + try { + // Recheck immediately before activation so a system daemon installed after + // the plist write cannot race us into two KeepAlive managers. + await assertNoSystemLaunchDaemonOwnership(label); + for (const legacy of params.snapshot.legacy) { + if (legacy.loaded) { + await deactivateLaunchAgentDefinition(domain, legacy.plistPath); + } + } + // Plist-form bootout reports EIO for a valid definition that was never loaded. + // The pre-publication snapshot is the authoritative cutover fact. + if (params.snapshot.loaded) { + await deactivateLaunchAgentDefinition(domain, params.plistPath); + } + // launchd can persist "disabled" state even after bootout + plist removal; clear it before bootstrap. + await bootstrapLaunchAgentOrThrow({ + domain, + serviceTarget: `${domain}/${label}`, + plistPath: params.plistPath, + actionHint: "openclaw gateway install --force", + retryPendingTeardown: true, + }); + for (const legacy of params.snapshot.legacy) { + await fs.unlink(legacy.plistPath).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + }); + } + } catch (error) { + try { + await restoreLaunchAgentInstall({ + domain, + env: params.env, + label, + plistPath: params.plistPath, + snapshot: params.snapshot, + }); + } catch (rollbackError) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${detail}\nThe previous LaunchAgent supervision could not be restored.`, { + cause: rollbackError, + }); + } + throw error; + } +} + +export async function installLaunchAgent( + args: GatewayServiceInstallArgs, +): Promise<{ plistPath: string }> { + assertExternalLaunchAgentMutation(args.env, "install"); + const targetPlistPath = resolveLaunchAgentPlistPath(args.env); + const previousContents = await readExistingLaunchAgentPlist(targetPlistPath); + const label = resolveLaunchAgentLabel(args.env); + const domain = resolveLaunchAgentGuiDomain(); + // Plist, generated environment files, and launchd registration form one cutover. + // Capture every prior owner before publication so any later failure can restore it. + const legacy = await Promise.all( + resolveLegacyGatewayLaunchAgentLabels(args.env.OPENCLAW_PROFILE).map(async (legacyLabel) => { + const plistPath = resolveLaunchAgentPlistPathForLabel(args.env, legacyLabel); + const contents = await readExistingLaunchAgentPlist(plistPath); + return { + label: legacyLabel, + plistPath, + contents, + loaded: await snapshotLaunchAgentLoadedState(contents, `${domain}/${legacyLabel}`), + }; + }), + ); + const snapshot: LaunchAgentInstallSnapshot = { + plistContents: previousContents, + envFileContents: await readExistingLaunchAgentPlist( + resolveLaunchAgentEnvFilePath(args.env, label), + ), + wrapperContents: await readExistingLaunchAgentPlist( + resolveLaunchAgentEnvWrapperPath(args.env, label), + ), + legacy, + loaded: await snapshotLaunchAgentLoadedState(previousContents, `${domain}/${label}`), + }; + let plistPath: string; + let stdoutPath: string; + try { + ({ plistPath, stdoutPath } = await writeLaunchAgentPlist(args)); + } catch (error) { + try { + await restoreLaunchAgentInstallArtifacts({ + env: args.env, + label, + plistPath: targetPlistPath, + snapshot, + }); + } catch (rollbackError) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${detail}\nThe previous LaunchAgent files could not be restored.`, { + cause: rollbackError, + }); + } + throw error; + } + await activateLaunchAgent({ + env: args.env, + plistPath, + snapshot, + }); + // `bootstrap` already loads RunAtLoad agents. Avoid `kickstart -k` here: + // on slow macOS guests it SIGTERMs the freshly booted gateway and pushes the + // real listener startup past setup's health deadline. + writeFormattedLines( + args.stdout, + [ + { label: "Installed LaunchAgent", value: plistPath }, + { label: "Logs", value: stdoutPath }, + ], + { leadingBlankLine: true }, + ); + return { plistPath }; +} diff --git a/src/daemon/launchd-lifecycle.ts b/src/daemon/launchd-lifecycle.ts new file mode 100644 index 000000000000..241a8571bcd6 --- /dev/null +++ b/src/daemon/launchd-lifecycle.ts @@ -0,0 +1,404 @@ +/** LaunchAgent bootstrap recovery plus start and restart lifecycle controls. */ +import { spawnSync } from "node:child_process"; +import { formatPortDiagnostics } from "../infra/ports-format.js"; +import { inspectPortUsage } from "../infra/ports-inspect.js"; +import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js"; +import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js"; +import { + execLaunchctl, + formatLaunchctlResultDetail, + isLaunchctlNotLoaded, +} from "./launchd-exec.js"; +import { resolveLaunchAgentLabel } from "./launchd-label.js"; +import { scheduleDetachedLaunchdRestartHandoff } from "./launchd-restart-handoff.js"; +import { + bootstrapLaunchAgentOrThrow, + isLaunchctlAlreadyLoaded, + isUnsupportedGuiDomain, + parseLaunchctlPrint, + readLaunchAgentRuntime, + resolveLaunchAgentGatewayContext, + resolveLaunchAgentGuiDomain, +} from "./launchd-runtime.js"; +import { + resolveLaunchAgentPlistPath, + rewriteLaunchAgentPlistForRestart, +} from "./launchd-service-files.js"; +import { + assertNoSystemLaunchDaemonOwnership, + isSystemLaunchDaemonOwnershipError, +} from "./launchd-system.js"; +import { formatLine } from "./output.js"; +import { createGatewayLifecycleMutationReporter } from "./service-mutation.js"; +import type { + GatewayServiceControlArgs, + GatewayServiceEnv, + GatewayServiceRestartResult, +} from "./service-types.js"; + +const LAUNCHCTL_PROTECTED_PID_TIMEOUT_MS = 2_000; +function readLaunchAgentPidForCleanupSync(serviceTarget: string): number { + const probe = spawnSync("launchctl", ["print", serviceTarget], { + encoding: "utf8", + timeout: LAUNCHCTL_PROTECTED_PID_TIMEOUT_MS, + }); + const result = { + stdout: probe.stdout ?? "", + stderr: probe.error?.message ?? probe.stderr ?? "", + code: probe.error ? 1 : (probe.status ?? 1), + }; + if (result.code !== 0) { + throw new Error(`launchctl print failed: ${formatLaunchctlResultDetail(result)}`); + } + const pid = parseLaunchctlPrint(result.stdout || result.stderr || "").pid; + if (pid === undefined) { + throw new Error("launchctl print did not report a running pid"); + } + return pid; +} + +type LaunchAgentBootstrapRepairResult = + | { ok: true; status: "repaired" | "already-loaded" } + | { + ok: false; + status: "bootstrap-failed" | "kickstart-failed"; + detail?: string; + } + | { + ok: false; + status: "system-launchdaemon-conflict" | "system-launchdaemon-unverifiable"; + detail: string; + } + | { ok: false; status: "gui-session-unavailable"; detail: string; domain: string }; + +export async function repairLaunchAgentBootstrap(args: { + env?: Record; + warn?: (message: string) => void; +}): Promise { + const env = args.env ?? (process.env as Record); + const domain = resolveLaunchAgentGuiDomain(); + const label = resolveLaunchAgentLabel(env); + const plistPath = resolveLaunchAgentPlistPath(env); + const serviceTarget = `${domain}/${label}`; + try { + await assertNoSystemLaunchDaemonOwnership(label); + } catch (error) { + if (!isSystemLaunchDaemonOwnershipError(error)) { + throw error; + } + return { + ok: false, + status: + error.ownership.status === "unverifiable" + ? "system-launchdaemon-unverifiable" + : "system-launchdaemon-conflict", + detail: error.message, + }; + } + // Rewrite first so legacy inline environment secrets move into the private + // env file before the plist becomes world-readable for launchd. + const warn = args.warn ?? ((message: string) => console.warn(formatLine("Warning", message))); + await rewriteLaunchAgentPlistForRestart({ env, label, plistPath, warn }); + await execLaunchctl(["enable", serviceTarget]); + const boot = await execLaunchctl(["bootstrap", domain, plistPath]); + let repairStatus: "repaired" | "already-loaded" = "repaired"; + if (boot.code !== 0) { + const detail = (boot.stderr || boot.stdout).trim(); + if (isUnsupportedGuiDomain(detail)) { + return { + ok: false, + status: "gui-session-unavailable", + detail, + domain, + }; + } + if (!isLaunchctlAlreadyLoaded(boot)) { + return { ok: false, status: "bootstrap-failed", detail: detail || undefined }; + } + repairStatus = "already-loaded"; + } + if (repairStatus === "repaired") { + return { ok: true, status: repairStatus }; + } + + // Service is already bootstrapped. Only kickstart if it is not actively running — + // kickstarting a healthy running service causes unnecessary session disconnects. + const runtime = await readLaunchAgentRuntime(env); + if (runtime.status === "running") { + return { ok: true, status: repairStatus }; + } + + const kick = await execLaunchctl(["kickstart", serviceTarget]); + if (kick.code !== 0) { + return { + ok: false, + status: "kickstart-failed", + detail: (kick.stderr || kick.stdout).trim() || undefined, + }; + } + return { ok: true, status: repairStatus }; +} +type LaunchAgentRestoreResult = { loaded: true } | { loaded: false; detail: string }; + +function writeLaunchAgentActionLine( + stdout: NodeJS.WritableStream, + label: string, + value: string, +): void { + try { + stdout.write(`${formatLine(label, value)}\n`); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException)?.code !== "EPIPE") { + throw err; + } + } +} + +async function ensureLaunchAgentLoadedAfterFailure(params: { + domain: string; + serviceTarget: string; + plistPath: string; + onMutation?: (mode: "enable" | "bootstrap") => void; +}): Promise { + const probe = await execLaunchctl(["print", params.serviceTarget]); + if (probe.code === 0) { + return { loaded: true }; + } + try { + await bootstrapLaunchAgentOrThrow({ + domain: params.domain, + serviceTarget: params.serviceTarget, + plistPath: params.plistPath, + actionHint: "openclaw gateway start", + onMutation: params.onMutation, + }); + return { loaded: true }; + } catch (error) { + // A failed restore is not recoverable by launchd: the label is gone, so + // KeepAlive has nothing to respawn. Report it instead of dropping it. + return { loaded: false, detail: error instanceof Error ? error.message : String(error) }; + } +} + +function formatLaunchAgentLeftUnloadedError(params: { + domain: string; + serviceTarget: string; + plistPath: string; + failure: string; + restoreDetail: string; +}): string { + return [ + params.failure, + `LaunchAgent ${params.serviceTarget} is not loaded and could not be restored: ${params.restoreDetail}`, + "The gateway is down and launchd has no job left to respawn it.", + `Fix: run \`openclaw gateway start\`, or \`launchctl bootstrap ${params.domain} ${params.plistPath}\`.`, + ].join("\n"); +} + +export async function startLaunchAgent({ + stdout, + env, + onMutation, +}: GatewayServiceControlArgs): Promise { + const serviceEnv = env ?? (process.env as GatewayServiceEnv); + const domain = resolveLaunchAgentGuiDomain(); + const label = resolveLaunchAgentLabel(serviceEnv); + const plistPath = resolveLaunchAgentPlistPath(serviceEnv); + const serviceTarget = `${domain}/${label}`; + const reportMutation = createGatewayLifecycleMutationReporter(onMutation); + await assertNoSystemLaunchDaemonOwnership(label); + + // Enable is an independent mutation; audit it even if the later launch fails. + const enable = await execLaunchctl(["enable", serviceTarget]); + const enabled = enable.code === 0; + if (enabled) { + reportMutation("enable"); + } + + const start = await execLaunchctl(["kickstart", serviceTarget]); + if (start.code === 0) { + reportMutation("kickstart"); + } else if (isLaunchctlNotLoaded(start)) { + await bootstrapLaunchAgentOrThrow({ + domain, + serviceTarget, + plistPath, + actionHint: "openclaw gateway start", + onMutation: reportMutation, + skipEnable: enabled, + }); + } else { + throw new Error(`launchctl kickstart failed: ${start.stderr || start.stdout}`.trim()); + } + + writeLaunchAgentActionLine(stdout, "Started LaunchAgent", serviceTarget); +} + +export async function restartLaunchAgent({ + stdout, + env, + warn, + onMutation, +}: GatewayServiceControlArgs): Promise { + const serviceEnv = env ?? (process.env as GatewayServiceEnv); + const domain = resolveLaunchAgentGuiDomain(); + const label = resolveLaunchAgentLabel(serviceEnv); + const plistPath = resolveLaunchAgentPlistPath(serviceEnv); + const serviceTarget = `${domain}/${label}`; + const reportMutation = createGatewayLifecycleMutationReporter(onMutation); + await assertNoSystemLaunchDaemonOwnership(label); + + // Restart requests issued from inside the managed gateway process tree need a + // detached handoff. A direct `kickstart -k` would terminate the caller before + // it can finish the restart command. + if (isCurrentProcessLaunchdServiceLabel(label)) { + const plistReloadNeeded = await rewriteLaunchAgentPlistForRestart({ + env: serviceEnv, + label, + plistPath, + stdout, + warn, + }); + const handoff = scheduleDetachedLaunchdRestartHandoff({ + env: serviceEnv, + mode: plistReloadNeeded ? "reload" : "kickstart", + waitForPid: process.pid, + }); + if (!handoff.ok) { + throw new Error(`launchd restart handoff failed: ${handoff.error}`); + } + reportMutation(plistReloadNeeded ? "handoff-reload" : "handoff-kickstart"); + writeLaunchAgentActionLine(stdout, "Scheduled LaunchAgent restart", serviceTarget); + return { outcome: "scheduled" }; + } + + const { port: cleanupPort, probeHosts } = await resolveLaunchAgentGatewayContext(serviceEnv); + if (cleanupPort !== null) { + cleanStaleGatewayProcessesSync(cleanupPort, { + // Resolve after lsof captures its listener snapshot. A KeepAlive respawn + // during enumeration must be protected before candidate filtering/signals. + resolveProtectedPid: () => readLaunchAgentPidForCleanupSync(serviceTarget), + }); + const diagnostics = await inspectPortUsage(cleanupPort, { + probeHosts, + }).catch(() => null); + if (diagnostics?.status === "busy") { + const runtime = await readLaunchAgentRuntime(serviceEnv); + const managedPid = runtime.pid; + // Only the current supervised PID may keep the port busy before a + // disruptive restart. Re-read after cleanup to close over a concurrent + // launchd respawn rather than trusting the protected pre-cleanup PID. + const ownedByLaunchAgent = + managedPid !== undefined && + diagnostics.listeners.length > 0 && + diagnostics.listeners.every((listener) => listener.pid === managedPid); + if (!ownedByLaunchAgent) { + throw new Error( + [ + `gateway port ${cleanupPort} is busy but is not verifiably owned by LaunchAgent ${label}`, + ...formatPortDiagnostics(diagnostics), + ].join("\n"), + ); + } + } + } + const plistReloadNeeded = await rewriteLaunchAgentPlistForRestart({ + env: serviceEnv, + label, + plistPath, + stdout, + warn, + }); + + // `openclaw gateway restart` is an explicit operator request to bring the + // LaunchAgent back, so clear any persisted disabled state before restart. + const enable = await execLaunchctl(["enable", serviceTarget]); + if (enable.code === 0) { + reportMutation("enable"); + } + + if (plistReloadNeeded) { + const bootout = await execLaunchctl(["bootout", serviceTarget]); + if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { + throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`); + } + if (bootout.code === 0) { + reportMutation("bootout"); + } + try { + await bootstrapLaunchAgentOrThrow({ + domain, + serviceTarget, + plistPath, + actionHint: "openclaw gateway restart", + onMutation: reportMutation, + retryPendingTeardown: true, + }); + } catch (error) { + // bootout already removed the job from the domain, so a failed bootstrap + // leaves the gateway down with no KeepAlive respawn to recover it. Restore + // the job before surfacing the original failure, as the kickstart path does. + const restored = await ensureLaunchAgentLoadedAfterFailure({ + domain, + serviceTarget, + plistPath, + onMutation: reportMutation, + }); + if (restored.loaded) { + throw error; + } + throw new Error( + formatLaunchAgentLeftUnloadedError({ + domain, + serviceTarget, + plistPath, + failure: error instanceof Error ? error.message : String(error), + restoreDetail: restored.detail, + }), + { cause: error }, + ); + } + writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget); + return { outcome: "completed" }; + } + + const start = await execLaunchctl(["kickstart", "-k", serviceTarget]); + if (start.code === 0) { + reportMutation("kickstart"); + writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget); + return { outcome: "completed" }; + } + + if (!isLaunchctlNotLoaded(start)) { + const restored = await ensureLaunchAgentLoadedAfterFailure({ + domain, + serviceTarget, + plistPath, + onMutation: reportMutation, + }); + const failure = `launchctl kickstart failed: ${start.stderr || start.stdout}`.trim(); + if (restored.loaded) { + throw new Error(failure); + } + throw new Error( + formatLaunchAgentLeftUnloadedError({ + domain, + serviceTarget, + plistPath, + failure, + restoreDetail: restored.detail, + }), + ); + } + + // If the service was previously booted out, re-register the rewritten plist and retry. + await bootstrapLaunchAgentOrThrow({ + domain, + serviceTarget, + plistPath, + actionHint: "openclaw gateway restart", + onMutation: reportMutation, + }); + writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget); + return { outcome: "completed" }; +} diff --git a/src/daemon/launchd-runtime.ts b/src/daemon/launchd-runtime.ts new file mode 100644 index 000000000000..3986c149b749 --- /dev/null +++ b/src/daemon/launchd-runtime.ts @@ -0,0 +1,366 @@ +/** launchctl state parsing, inspection, and bootstrap primitives. */ +import fs from "node:fs/promises"; +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; +import { parseTcpPort, parseTcpPortFromArgs } from "../infra/tcp-port.js"; +import { sleep } from "../utils.js"; +import { resolveGatewayServiceProbeHosts } from "./gateway-service-probe-hosts.js"; +import { + execLaunchctl, + formatLaunchctlResultDetail, + isLaunchctlNotLoaded, +} from "./launchd-exec.js"; +import { resolveLaunchAgentLabel } from "./launchd-label.js"; +import { LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS } from "./launchd-plist.js"; +import { + resolveLaunchAgentPlistPath, + readLaunchAgentProgramArguments, +} from "./launchd-service-files.js"; +import { + formatSystemLaunchDaemonOwnershipSummary, + inspectSystemLaunchDaemonOwnership, +} from "./launchd-system.js"; +import { parseKeyValueOutput } from "./runtime-parse.js"; +import type { GatewayServiceRuntime } from "./service-runtime.js"; +import type { GatewayServiceEnv, GatewayServiceEnvArgs } from "./service-types.js"; + +// launchd reserves the label until the outgoing job actually exits, and it +// SIGKILLs that job once ExitTimeOut elapses. Bound the bootstrap retry by that +// same deadline plus slack so a drain-on-SIGTERM gateway cannot outlast it. +const LAUNCH_AGENT_BOOTSTRAP_TEARDOWN_TIMEOUT_MS = (LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS + 10) * 1_000; +const LAUNCH_AGENT_BOOTSTRAP_TEARDOWN_POLL_MS = 500; +export async function resolveLaunchAgentGatewayContext(env: GatewayServiceEnv): Promise<{ + port: number | null; + probeHosts: readonly string[]; +}> { + const command = await readLaunchAgentProgramArguments(env).catch(() => null); + const fromArgs = parseTcpPortFromArgs(command?.programArguments); + if (fromArgs !== null) { + return { + port: fromArgs, + probeHosts: await resolveGatewayServiceProbeHosts({ env, command }), + }; + } + const fromServiceEnv = parseTcpPort(command?.environment?.OPENCLAW_GATEWAY_PORT ?? ""); + if (fromServiceEnv !== null) { + return { + port: fromServiceEnv, + probeHosts: await resolveGatewayServiceProbeHosts({ env, command }), + }; + } + return { + port: parseTcpPort(env.OPENCLAW_GATEWAY_PORT ?? ""), + probeHosts: await resolveGatewayServiceProbeHosts({ env, command }), + }; +} + +export function resolveLaunchAgentGuiDomain(): string { + if (typeof process.getuid !== "function") { + return "gui/501"; + } + return `gui/${process.getuid()}`; +} + +function throwBootstrapGuiSessionError(params: { + detail: string; + domain: string; + actionHint: string; +}) { + throw new Error(formatLaunchAgentGuiSessionError(params)); +} + +export function formatLaunchAgentGuiSessionError(params: { + detail: string; + domain: string; + actionHint: string; +}): string { + return [ + `launchctl bootstrap failed: ${params.detail}`, + `LaunchAgent ${params.actionHint} requires a logged-in macOS GUI session for this user (${params.domain}).`, + "This usually means you are running from SSH/headless context or as the wrong user (including sudo).", + `Fix: sign in to the macOS desktop as the target user and rerun \`${params.actionHint}\`.`, + "For headless VM setups, enable auto-login for the target user so macOS creates the GUI session after boot.", + "Headless deployments should use a dedicated logged-in user session or a custom LaunchDaemon (not shipped): https://docs.openclaw.ai/gateway", + ].join("\n"); +} + +export async function bootstrapLaunchAgentOrThrow(params: { + domain: string; + serviceTarget: string; + plistPath: string; + actionHint: string; + onMutation?: (mode: "enable" | "bootstrap") => void; + skipEnable?: boolean; + // Opt-in for callers that just issued `bootout` on this label. Only those can + // race a pending teardown, so start/install/recovery paths keep failing fast + // on an unrelated EIO instead of waiting out the teardown deadline. + retryPendingTeardown?: boolean; +}) { + // `disable` state survives bootout and plist rewrites; explicit start/repair + // paths must clear it before asking launchd to load the job again. + if (!params.skipEnable) { + const enable = await execLaunchctl(["enable", params.serviceTarget]); + if (enable.code === 0) { + params.onMutation?.("enable"); + } + } + const teardownDeadline = Date.now() + LAUNCH_AGENT_BOOTSTRAP_TEARDOWN_TIMEOUT_MS; + for (;;) { + const boot = await execLaunchctl(["bootstrap", params.domain, params.plistPath]); + if (boot.code === 0) { + params.onMutation?.("bootstrap"); + return; + } + const detail = (boot.stderr || boot.stdout).trim(); + if (isUnsupportedGuiDomain(detail)) { + throwBootstrapGuiSessionError({ + detail, + domain: params.domain, + actionHint: params.actionHint, + }); + } + if (isLaunchctlOperationAlreadyInProgress(detail)) { + const state = await probeLaunchAgentState(params.serviceTarget); + if (state.state === "running" || state.state === "stopped") { + params.onMutation?.("bootstrap"); + return; + } + } + const remainingMs = teardownDeadline - Date.now(); + if ( + !params.retryPendingTeardown || + !isLaunchctlBootstrapPendingTeardown(boot) || + remainingMs <= 0 + ) { + throw new Error(`launchctl bootstrap failed: ${detail}`); + } + await sleep(Math.min(LAUNCH_AGENT_BOOTSTRAP_TEARDOWN_POLL_MS, remainingMs)); + } +} +type LaunchctlPrintInfo = { + state?: string; + pid?: number; + lastExitStatus?: number; + lastExitReason?: string; +}; + +export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo { + const entries = parseKeyValueOutput(output, "="); + const info: LaunchctlPrintInfo = {}; + const state = entries.state; + if (state) { + info.state = state; + } + const pidValue = entries.pid; + if (pidValue) { + const pid = parseStrictPositiveInteger(pidValue); + if (pid !== undefined) { + info.pid = pid; + } + } + const exitStatusValue = entries["last exit status"]; + if (exitStatusValue) { + const status = parseStrictInteger(exitStatusValue); + if (status !== undefined) { + info.lastExitStatus = status; + } + } + const exitReason = entries["last exit reason"]; + if (exitReason) { + info.lastExitReason = exitReason; + } + return info; +} + +export function parseLaunchAgentEnabled(output: string, label: string): boolean { + const labelPrefix = `"${label}"`; + for (const line of output.split("\n")) { + const entry = line.trim(); + if (!entry.startsWith(labelPrefix)) { + continue; + } + const state = entry.slice(labelPrefix.length).trim(); + if (state === "=> enabled") { + return true; + } + if (state === "=> disabled") { + return false; + } + throw new Error(`launchctl print-disabled returned an unrecognized state for ${label}`); + } + // No persisted override means launchd uses the plist's normal enabled state. + return true; +} + +export async function isLaunchAgentEnabled(args: GatewayServiceEnvArgs): Promise { + const domain = resolveLaunchAgentGuiDomain(); + const label = resolveLaunchAgentLabel(args.env); + const res = await execLaunchctl(["print-disabled", domain]); + if (res.code !== 0) { + throw new Error(`launchctl print-disabled failed: ${formatLaunchctlResultDetail(res)}`); + } + return parseLaunchAgentEnabled(res.stdout || res.stderr || "", label); +} + +export async function isLaunchAgentLoaded(args: GatewayServiceEnvArgs): Promise { + const domain = resolveLaunchAgentGuiDomain(); + const label = resolveLaunchAgentLabel(args.env); + const res = await execLaunchctl(["print", `${domain}/${label}`]); + if (res.code === 0) { + return true; + } + if (isLaunchctlNotLoaded(res)) { + return false; + } + throw new Error(`launchctl print failed: ${formatLaunchctlResultDetail(res)}`); +} + +export async function launchAgentPlistExists(env: GatewayServiceEnv): Promise { + try { + const plistPath = resolveLaunchAgentPlistPath(env); + await fs.access(plistPath); + return true; + } catch { + return false; + } +} + +export async function readLaunchAgentRuntime( + env: Record, +): Promise { + const domain = resolveLaunchAgentGuiDomain(); + const label = resolveLaunchAgentLabel(env); + const [res, systemOwnership] = await Promise.all([ + execLaunchctl(["print", `${domain}/${label}`]), + inspectSystemLaunchDaemonOwnership(label, { scanInstalledPlists: false }), + ]); + if (systemOwnership.status !== "absent") { + return { + status: "unknown", + detail: formatSystemLaunchDaemonOwnershipSummary(systemOwnership), + systemLaunchDaemon: { + status: systemOwnership.status, + serviceTarget: systemOwnership.serviceTarget, + ...(systemOwnership.status === "installed" ? { plistPath: systemOwnership.plistPath } : {}), + }, + }; + } + if (res.code !== 0) { + const plistExists = await launchAgentPlistExists(env); + const detail = (res.stderr || res.stdout).trim() || undefined; + const missingGuiSession = plistExists && isUnsupportedGuiDomain(detail ?? ""); + return { + status: "unknown", + detail, + ...(plistExists + ? { missingSupervision: true, ...(missingGuiSession ? { missingGuiSession } : {}) } + : { missingUnit: true }), + }; + } + const parsed = parseLaunchctlPrint(res.stdout || res.stderr || ""); + const plistExists = await launchAgentPlistExists(env); + const state = normalizeLowercaseStringOrEmpty(parsed.state); + const status = state === "running" || parsed.pid ? "running" : state ? "stopped" : "unknown"; + return { + status, + state: parsed.state, + pid: parsed.pid, + lastExitStatus: parsed.lastExitStatus, + lastExitReason: parsed.lastExitReason, + cachedLabel: !plistExists, + }; +} + +export function isLaunchctlAlreadyLoaded(res: { + stdout: string; + stderr: string; + code: number; +}): boolean { + const detail = normalizeLowercaseStringOrEmpty(res.stderr || res.stdout); + return res.code === 130 || detail.includes("already exists in domain"); +} + +export function isUnsupportedGuiDomain(detail: string): boolean { + const normalized = normalizeLowercaseStringOrEmpty(detail); + return ( + normalized.includes("domain does not support specified action") || + normalized.includes("could not find domain for user gui") || + normalized.includes("bootstrap failed: 125") + ); +} + +function isLaunchctlOperationAlreadyInProgress(detail: string): boolean { + const normalized = normalizeLowercaseStringOrEmpty(detail); + return ( + normalized.includes("operation already in progress") || + normalized.includes("bootstrap failed: 37") + ); +} + +function isLaunchctlBootstrapPendingTeardown(res: { + stdout: string; + stderr: string; + code: number; +}): boolean { + // `bootout` returns once launchd accepts the request, not once the job is gone, + // so bootstrapping the same label mid-teardown answers EIO. The plist is valid + // here, so this is a timing conflict to retry rather than a real I/O fault. + // + // launchd answers the same EIO for a label that is simply still registered + // ("already exists in domain"). That job is not tearing down, so waiting for a + // teardown that never comes only delays the failure. + if (isLaunchctlAlreadyLoaded(res)) { + return false; + } + const normalized = normalizeLowercaseStringOrEmpty(res.stderr || res.stdout); + return normalized.includes("bootstrap failed: 5") || normalized.includes("input/output error"); +} +type LaunchAgentProbeResult = + | { state: "running" } + | { state: "stopped" } + | { state: "not-loaded" } + | { state: "unknown"; detail?: string }; + +export async function probeLaunchAgentState( + serviceTarget: string, +): Promise { + // `launchctl print` output is not a stable API, so this is only a stop + // confirmation probe. Unknown output falls back to bootout instead of success. + const probe = await execLaunchctl(["print", serviceTarget]); + if (probe.code !== 0) { + if (isLaunchctlNotLoaded(probe)) { + return { state: "not-loaded" }; + } + return { + state: "unknown", + detail: formatLaunchctlResultDetail(probe) || undefined, + }; + } + const runtime = parseLaunchctlPrint(probe.stdout || probe.stderr || ""); + if ( + normalizeLowercaseStringOrEmpty(runtime.state) === "running" || + (typeof runtime.pid === "number" && runtime.pid > 1) + ) { + return { state: "running" }; + } + return { state: "stopped" }; +} + +export async function waitForLaunchAgentStopped( + serviceTarget: string, +): Promise { + let lastUnknown: LaunchAgentProbeResult | null = null; + for (let attempt = 0; attempt < 10; attempt += 1) { + const probe = await probeLaunchAgentState(serviceTarget); + if (probe.state === "stopped" || probe.state === "not-loaded") { + return probe; + } + if (probe.state === "unknown") { + lastUnknown = probe; + } + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + } + return lastUnknown ?? { state: "running" }; +} diff --git a/src/daemon/launchd-service-files.ts b/src/daemon/launchd-service-files.ts new file mode 100644 index 000000000000..f96a74a79307 --- /dev/null +++ b/src/daemon/launchd-service-files.ts @@ -0,0 +1,439 @@ +/** LaunchAgent plist, environment-file, and atomic publication ownership. */ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { normalizeEnvVarKey } from "../infra/host-env-security.js"; +import { GATEWAY_LAUNCH_AGENT_LABEL, resolveGatewayServiceDescription } from "./constants.js"; +import { resolveLaunchAgentLabel } from "./launchd-label.js"; +import { + LAUNCH_AGENT_ENV_WRAPPER_SHELL, + buildLaunchAgentPlist as buildLaunchAgentPlistImpl, + readLaunchAgentProgramArgumentsFromFile, +} from "./launchd-plist.js"; +import { assertNoSystemLaunchDaemonOwnership } from "./launchd-system.js"; +import { formatLine, toPosixPath } from "./output.js"; +import { resolveDaemonHomeDir, resolveGatewayStateDir } from "./paths.js"; +import { resolveGatewaySupervisorLogPaths } from "./restart-logs.js"; +import type { + GatewayServiceCommandConfig, + GatewayServiceEnv, + GatewayServiceInstallArgs, +} from "./service-types.js"; + +const LAUNCH_AGENT_DIR_MODE = 0o755; +// launchd rejects user LaunchAgent plists without group/other read access on +// current macOS. Secrets stay in the separate 0600 environment file. +export const LAUNCH_AGENT_PLIST_MODE = 0o644; +const LAUNCH_AGENT_PRIVATE_DIR_MODE = 0o700; +export const LAUNCH_AGENT_ENV_FILE_MODE = 0o600; +export const LAUNCH_AGENT_ENV_WRAPPER_MODE = 0o700; +const LAUNCH_AGENT_ENV_DIR_NAME = "service-env"; +const LAUNCH_AGENT_STDERR_PATH = "/dev/null"; +export function resolveLaunchAgentPlistPathForLabel( + env: Record, + label: string, +): string { + const home = toPosixPath(resolveDaemonHomeDir(env)); + return path.posix.join(home, "Library", "LaunchAgents", `${label}.plist`); +} + +function resolveLaunchAgentEnvDir(env: GatewayServiceEnv): string { + return path.join(resolveGatewayStateDir(env), LAUNCH_AGENT_ENV_DIR_NAME); +} + +export function resolveLaunchAgentEnvFilePath(env: GatewayServiceEnv, label: string): string { + return path.join(resolveLaunchAgentEnvDir(env), `${label}.env`); +} + +export function resolveLaunchAgentEnvWrapperPath(env: GatewayServiceEnv, label: string): string { + return path.join(resolveLaunchAgentEnvDir(env), `${label}-env-wrapper.sh`); +} + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function collectLaunchAgentEnvironmentEntries( + environment: GatewayServiceEnv | undefined, +): Array<[string, string]> { + const entries: Array<[string, string]> = []; + for (const [rawKey, rawValue] of Object.entries(environment ?? {})) { + const key = normalizeEnvVarKey(rawKey, { portable: true }); + const value = rawValue?.trim(); + if (!key || !value) { + continue; + } + entries.push([key, value]); + } + return entries.toSorted(([left], [right]) => left.localeCompare(right)); +} + +function buildLaunchAgentEnvironmentFile(entries: Array<[string, string]>): string { + return [ + "# Generated by OpenClaw. Do not edit while the gateway service is installed.", + ...entries.map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), + "", + ].join("\n"); +} + +function buildLaunchAgentEnvironmentWrapper(): string { + return `#!/bin/sh +set -eu +env_file="$1" +shift +if [ -f "$env_file" ]; then + . "$env_file" +fi +exec "$@" +`; +} + +async function resolveLaunchAgentEnvironmentWrapperOverwriteWarnings(params: { + wrapperPath: string; + generatedWrapper: string; +}): Promise { + const existingWrapper = await fs.readFile(params.wrapperPath, "utf8").catch(() => null); + if (existingWrapper === null || existingWrapper === params.generatedWrapper) { + return []; + } + return [ + `Existing generated LaunchAgent env wrapper at ${params.wrapperPath} contains custom behavior and will be overwritten; move custom behavior to openclaw gateway install --wrapper or OPENCLAW_WRAPPER.`, + ]; +} + +function writeLaunchAgentOverwriteWarnings( + stdout: NodeJS.WritableStream | undefined, + warn: ((message: string) => void) | undefined, + warnings: readonly string[], +): void { + for (const warning of warnings) { + if (warn) { + warn(warning); + continue; + } + if (!stdout) { + continue; + } + stdout.write(`${formatLine("Warning", warning)}\n`); + } +} + +function isLaunchAgentEnvironmentWrapperArgs(params: { + programArguments: string[]; + envFilePath: string; + wrapperPath: string; +}): boolean { + return ( + (params.programArguments[0] === params.wrapperPath && + params.programArguments[1] === params.envFilePath) || + (params.programArguments[0] === LAUNCH_AGENT_ENV_WRAPPER_SHELL && + params.programArguments[1] === params.wrapperPath && + params.programArguments[2] === params.envFilePath) + ); +} + +async function prepareLaunchAgentProgramArguments(params: { + env: GatewayServiceEnv; + label: string; + programArguments: string[]; + environment: GatewayServiceEnv | undefined; + stdout?: NodeJS.WritableStream; + warn?: (message: string) => void; +}): Promise<{ + programArguments: string[]; + inlineEnvironment?: GatewayServiceEnv; +}> { + const entries = collectLaunchAgentEnvironmentEntries(params.environment); + if (entries.length === 0) { + return { programArguments: params.programArguments }; + } + + // Environment values with secrets live in an owner-only env file instead of + // inline plist XML, which can be harder to rotate and audit. + const envDir = resolveLaunchAgentEnvDir(params.env); + const envFilePath = resolveLaunchAgentEnvFilePath(params.env, params.label); + const wrapperPath = resolveLaunchAgentEnvWrapperPath(params.env, params.label); + const generatedWrapper = buildLaunchAgentEnvironmentWrapper(); + await ensureSecureDirectory(envDir, LAUNCH_AGENT_PRIVATE_DIR_MODE); + await fs.writeFile(envFilePath, buildLaunchAgentEnvironmentFile(entries), { + encoding: "utf8", + mode: LAUNCH_AGENT_ENV_FILE_MODE, + }); + await fs.chmod(envFilePath, LAUNCH_AGENT_ENV_FILE_MODE).catch(() => undefined); + const overwriteWarnings = await resolveLaunchAgentEnvironmentWrapperOverwriteWarnings({ + wrapperPath, + generatedWrapper, + }); + writeLaunchAgentOverwriteWarnings(params.stdout, params.warn, overwriteWarnings); + await fs.writeFile(wrapperPath, generatedWrapper, { + encoding: "utf8", + mode: LAUNCH_AGENT_ENV_WRAPPER_MODE, + }); + await fs.chmod(wrapperPath, LAUNCH_AGENT_ENV_WRAPPER_MODE).catch(() => undefined); + + if ( + isLaunchAgentEnvironmentWrapperArgs({ + programArguments: params.programArguments, + envFilePath, + wrapperPath, + }) + ) { + return { programArguments: params.programArguments }; + } + + return { + programArguments: [ + LAUNCH_AGENT_ENV_WRAPPER_SHELL, + wrapperPath, + envFilePath, + ...params.programArguments, + ], + }; +} + +export function resolveLaunchAgentPlistPath(env: GatewayServiceEnv): string { + const label = resolveLaunchAgentLabel(env); + return resolveLaunchAgentPlistPathForLabel(env, label); +} + +function resolveLaunchAgentEnvironmentReadOptions(env: GatewayServiceEnv, label: string) { + return { + expectedEnvironmentWrapperPath: resolveLaunchAgentEnvWrapperPath(env, label), + expectedEnvironmentFilePath: resolveLaunchAgentEnvFilePath(env, label), + generatedEnvironmentLabel: label, + }; +} + +export async function readLaunchAgentProgramArguments( + env: GatewayServiceEnv, +): Promise { + const label = resolveLaunchAgentLabel(env); + const plistPath = resolveLaunchAgentPlistPath(env); + return readLaunchAgentProgramArgumentsFromFile( + plistPath, + resolveLaunchAgentEnvironmentReadOptions(env, label), + ); +} + +function buildLaunchAgentPlist({ + label = GATEWAY_LAUNCH_AGENT_LABEL, + comment, + programArguments, + workingDirectory, + stdoutPath, + stderrPath, + environment, +}: { + label?: string; + comment?: string; + programArguments: string[]; + workingDirectory?: string; + stdoutPath: string; + stderrPath: string; + environment?: Record; +}): string { + return buildLaunchAgentPlistImpl({ + label, + comment, + programArguments, + workingDirectory, + stdoutPath, + stderrPath, + environment, + }); +} +async function ensureLaunchAgentPlistReadable(plistPath: string): Promise { + await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined); +} + +export async function readExistingLaunchAgentPlist(plistPath: string): Promise { + try { + return await fs.readFile(plistPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + +export async function publishLaunchAgentPlist(params: { + label: string; + plistPath: string; + contents: string; +}): Promise { + const previousContents = await readExistingLaunchAgentPlist(params.plistPath); + const temporaryPath = `${params.plistPath}.openclaw-${randomUUID()}.tmp`; + await fs.writeFile(temporaryPath, params.contents, { + encoding: "utf8", + flag: "wx", + mode: LAUNCH_AGENT_PLIST_MODE, + }); + try { + // The temporary filename does not end in .plist, so launchd cannot discover + // it before the final ownership check and atomic publication. + await assertNoSystemLaunchDaemonOwnership(params.label); + await fs.rename(temporaryPath, params.plistPath); + try { + await assertNoSystemLaunchDaemonOwnership(params.label); + } catch (ownershipError) { + try { + if (previousContents === null) { + await fs.unlink(params.plistPath); + } else { + const rollbackPath = `${params.plistPath}.openclaw-${randomUUID()}.rollback`; + try { + await fs.writeFile(rollbackPath, previousContents, { + flag: "wx", + mode: LAUNCH_AGENT_PLIST_MODE, + }); + await fs.rename(rollbackPath, params.plistPath); + } finally { + await fs.unlink(rollbackPath).catch(() => undefined); + } + } + } catch (rollbackError) { + const ownershipDetail = + ownershipError instanceof Error ? ownershipError.message : String(ownershipError); + throw new Error( + `${ownershipDetail}\nThe previous LaunchAgent plist at ${params.plistPath} could not be restored.`, + { cause: rollbackError }, + ); + } + throw ownershipError; + } + } finally { + await fs.unlink(temporaryPath).catch(() => undefined); + } + await ensureLaunchAgentPlistReadable(params.plistPath); +} + +async function ensureSecureDirectory( + targetPath: string, + dirMode = LAUNCH_AGENT_DIR_MODE, +): Promise { + await fs.mkdir(targetPath, { recursive: true, mode: dirMode }); + try { + const stat = await fs.stat(targetPath); + const mode = stat.mode & 0o777; + const forbiddenMode = dirMode === LAUNCH_AGENT_PRIVATE_DIR_MODE ? 0o077 : 0o022; + const tightenedMode = mode & ~forbiddenMode; + if (tightenedMode !== mode) { + await fs.chmod(targetPath, tightenedMode); + } + } catch { + // Best effort: keep install working even if chmod/stat is unavailable. + } +} + +async function ensureLaunchAgentEnvironmentDirectories( + environment: Record | undefined, +): Promise { + const tmpDir = environment?.TMPDIR?.trim(); + if (tmpDir) { + await ensureSecureDirectory(tmpDir, LAUNCH_AGENT_PRIVATE_DIR_MODE); + } +} + +export async function writeLaunchAgentPlist({ + env, + programArguments, + workingDirectory, + environment, + description, + stdout, + warn, +}: GatewayServiceInstallArgs): Promise<{ plistPath: string; stdoutPath: string }> { + const label = resolveLaunchAgentLabel(env); + await assertNoSystemLaunchDaemonOwnership(label); + + const { logDir, stdoutPath } = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" }); + await ensureSecureDirectory(logDir); + + const plistPath = resolveLaunchAgentPlistPathForLabel(env, label); + const home = toPosixPath(resolveDaemonHomeDir(env)); + const libraryDir = path.posix.join(home, "Library"); + await ensureSecureDirectory(home); + await ensureSecureDirectory(libraryDir); + await ensureSecureDirectory(path.dirname(plistPath)); + await ensureLaunchAgentEnvironmentDirectories(environment); + const prepared = await prepareLaunchAgentProgramArguments({ + env, + label, + programArguments, + environment, + stdout, + warn, + }); + + const serviceDescription = resolveGatewayServiceDescription({ env, description }); + const plist = buildLaunchAgentPlist({ + label, + comment: serviceDescription, + programArguments: prepared.programArguments, + workingDirectory, + stdoutPath, + stderrPath: LAUNCH_AGENT_STDERR_PATH, + environment: prepared.inlineEnvironment, + }); + await publishLaunchAgentPlist({ label, plistPath, contents: plist }); + return { plistPath, stdoutPath }; +} +export async function rewriteLaunchAgentPlistForRestart({ + env, + label, + plistPath, + stdout, + warn, +}: { + env: GatewayServiceEnv; + label: string; + plistPath: string; + stdout?: NodeJS.WritableStream; + warn?: (message: string) => void; +}): Promise { + const existing = await readLaunchAgentProgramArgumentsFromFile( + plistPath, + resolveLaunchAgentEnvironmentReadOptions(env, label), + ); + if (!existing?.programArguments.length) { + return false; + } + + const { logDir, stdoutPath } = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" }); + await ensureSecureDirectory(logDir); + + const serviceDescription = resolveGatewayServiceDescription({ + env, + }); + // Restart rewrites must retire install provenance from legacy plists instead + // of copying it into the next canonical definition. + const canonicalEnvironment = { + ...existing.environment, + OPENCLAW_SERVICE_VERSION: undefined, + }; + const prepared = await prepareLaunchAgentProgramArguments({ + env, + label, + programArguments: existing.programArguments, + environment: canonicalEnvironment, + stdout, + warn, + }); + const plist = buildLaunchAgentPlist({ + label, + comment: serviceDescription, + programArguments: prepared.programArguments, + workingDirectory: existing.workingDirectory, + stdoutPath, + stderrPath: LAUNCH_AGENT_STDERR_PATH, + environment: prepared.inlineEnvironment, + }); + const previousPlist = await fs.readFile(plistPath, "utf8").catch(() => ""); + if (previousPlist === plist) { + await ensureLaunchAgentPlistReadable(plistPath); + return false; + } + await publishLaunchAgentPlist({ label, plistPath, contents: plist }); + return true; +} diff --git a/src/daemon/launchd-stop.ts b/src/daemon/launchd-stop.ts new file mode 100644 index 000000000000..aad3be7c7de1 --- /dev/null +++ b/src/daemon/launchd-stop.ts @@ -0,0 +1,209 @@ +/** LaunchAgent stop semantics and in-service maintenance parking. */ +import { formatPortDiagnostics } from "../infra/ports-format.js"; +import { inspectPortUsage } from "../infra/ports-inspect.js"; +import { probePortUsage } from "../infra/ports-probe.js"; +import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js"; +import { sleep } from "../utils.js"; +import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js"; +import { + execLaunchctl, + formatLaunchctlResultDetail, + isLaunchctlNotLoaded, +} from "./launchd-exec.js"; +import { resolveLaunchAgentLabel } from "./launchd-label.js"; +import { LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS } from "./launchd-plist.js"; +import { scheduleDetachedLaunchdMaintenancePark } from "./launchd-restart-handoff.js"; +import { + resolveLaunchAgentGatewayContext, + resolveLaunchAgentGuiDomain, + waitForLaunchAgentStopped, +} from "./launchd-runtime.js"; +import { formatLine } from "./output.js"; +import { createGatewayLifecycleMutationReporter } from "./service-mutation.js"; +import type { GatewayServiceControlArgs, GatewayServiceEnv } from "./service-types.js"; + +const LAUNCH_AGENT_STOP_PORT_RELEASE_TIMEOUT_MS = LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS * 1_000; +const LAUNCH_AGENT_STOP_PORT_RELEASE_POLL_MS = 100; +async function bootoutLaunchAgentOrThrow(params: { + serviceTarget: string; + warning: string; + stdout: NodeJS.WritableStream; + onMutation?: () => void; +}): Promise { + const bootout = await execLaunchctl(["bootout", params.serviceTarget]); + if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { + throw new Error( + `${params.warning}; launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`, + ); + } + params.onMutation?.(); + params.stdout.write(`${formatLine("Warning", params.warning)}\n`); +} +async function waitForGatewayPortRelease( + port: number, + probeHosts: readonly string[], +): Promise { + const deadline = Date.now() + LAUNCH_AGENT_STOP_PORT_RELEASE_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(Math.min(LAUNCH_AGENT_STOP_PORT_RELEASE_POLL_MS, deadline - Date.now())); + const status = await probePortUsage(port, probeHosts); + if (status === "free") { + return true; + } + } + return false; +} + +async function assertGatewayPortReleasedAfterStop(env: GatewayServiceEnv): Promise { + const { port, probeHosts } = await resolveLaunchAgentGatewayContext(env); + if (port === null) { + return; + } + cleanStaleGatewayProcessesSync(port); + const diagnostics = await inspectPortUsage(port, { + probeHosts, + }).catch(() => null); + if (diagnostics?.status !== "busy") { + return; + } + if (await waitForGatewayPortRelease(port, probeHosts)) { + return; + } + throw new Error( + [ + `gateway port ${port} is still busy after LaunchAgent stop`, + ...formatPortDiagnostics(diagnostics), + ].join("\n"), + ); +} + +export async function stopLaunchAgent({ + stdout, + env, + disable: persistDisable, + onMutation, +}: GatewayServiceControlArgs): Promise { + const serviceEnv = env ?? (process.env as GatewayServiceEnv); + const domain = resolveLaunchAgentGuiDomain(); + const label = resolveLaunchAgentLabel(serviceEnv); + const serviceTarget = `${domain}/${label}`; + const reportMutation = createGatewayLifecycleMutationReporter(onMutation); + + if ( + isCurrentProcessLaunchdServiceLabel(label, process.env, { allowConfiguredLabelFallback: false }) + ) { + throw new Error( + `Refusing to stop LaunchAgent ${label} from inside the same launchd service; run this command from an external shell.`, + ); + } + + if (!persistDisable) { + // Default: bootout only. Removes the job from the current launchd domain without + // persisting a disable, so KeepAlive auto-recovery survives future crashes and + // `openclaw gateway start` re-enables cleanly without a manual `launchctl enable`. + const bootout = await execLaunchctl(["bootout", serviceTarget]); + if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { + throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`); + } + reportMutation("bootout"); + await assertGatewayPortReleasedAfterStop(serviceEnv); + stdout.write(`${formatLine("Stopped LaunchAgent", serviceTarget)}\n`); + return; + } + + // --disable: persistently suppress KeepAlive/RunAtLoad before stopping. + // Without this, launchd can relaunch the process as soon as `stop` exits. + const disableResult = await execLaunchctl(["disable", serviceTarget]); + if (disableResult.code !== 0) { + await bootoutLaunchAgentOrThrow({ + serviceTarget, + stdout, + warning: `launchctl disable failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(disableResult)}`, + onMutation: () => reportMutation("disable-bootout"), + }); + await assertGatewayPortReleasedAfterStop(serviceEnv); + stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`); + return; + } + reportMutation("disable"); + + // `launchctl stop` targets the plain label (not the fully-qualified service target). + const stop = await execLaunchctl(["stop", label]); + if (stop.code !== 0 && !isLaunchctlNotLoaded(stop)) { + await bootoutLaunchAgentOrThrow({ + serviceTarget, + stdout, + warning: `launchctl stop failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(stop)}`, + onMutation: () => reportMutation("disable-bootout"), + }); + await assertGatewayPortReleasedAfterStop(serviceEnv); + stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`); + return; + } + + reportMutation("disable-stop"); + + const stopState = await waitForLaunchAgentStopped(serviceTarget); + if (stopState.state !== "stopped" && stopState.state !== "not-loaded") { + const warning = + stopState.state === "unknown" + ? `launchctl print could not confirm stop; used bootout fallback and left service unloaded: ${stopState.detail ?? "unknown error"}` + : "launchctl stop did not fully stop the service; used bootout fallback and left service unloaded"; + await bootoutLaunchAgentOrThrow({ + serviceTarget, + stdout, + warning, + onMutation: () => reportMutation("disable-bootout"), + }); + await assertGatewayPortReleasedAfterStop(serviceEnv); + stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`); + return; + } + + await assertGatewayPortReleasedAfterStop(serviceEnv); + stdout.write(`${formatLine("Stopped LaunchAgent", serviceTarget)}\n`); +} + +export async function parkCurrentLaunchAgentForMaintenance( + params: { + env?: GatewayServiceEnv; + } = {}, +): Promise { + const serviceEnv = params.env ?? (process.env as GatewayServiceEnv); + const domain = resolveLaunchAgentGuiDomain(); + const label = resolveLaunchAgentLabel(serviceEnv); + if ( + !isCurrentProcessLaunchdServiceLabel(label, process.env, { + allowConfiguredLabelFallback: false, + }) + ) { + return false; + } + const serviceTarget = `${domain}/${label}`; + // Disable before exit so KeepAlive cannot spawn a replacement before the + // detached handoff can boot the current job out of launchd. + const disable = await execLaunchctl(["disable", serviceTarget]); + if (disable.code !== 0) { + throw new Error( + `launchctl disable failed while parking ${serviceTarget}: ${formatLaunchctlResultDetail(disable)}`, + ); + } + const handoff = scheduleDetachedLaunchdMaintenancePark({ + env: serviceEnv, + waitForPid: process.pid, + }); + const handoffError = !handoff.ok + ? handoff.error + : (await handoff.value) + ? undefined + : "helper failed to spawn"; + if (handoffError) { + const rollback = await execLaunchctl(["enable", serviceTarget]); + const rollbackDetail = + rollback.code === 0 + ? "restored launchd enable state" + : `launchctl enable rollback failed: ${formatLaunchctlResultDetail(rollback)}`; + throw new Error(`launchd maintenance park handoff failed: ${handoffError}; ${rollbackDetail}`); + } + return true; +} diff --git a/src/daemon/launchd-update-jobs.ts b/src/daemon/launchd-update-jobs.ts new file mode 100644 index 000000000000..be17cd4d3fed --- /dev/null +++ b/src/daemon/launchd-update-jobs.ts @@ -0,0 +1,264 @@ +/** Discovery and shutdown of stale OpenClaw launchd updater jobs. */ +import path from "node:path"; +import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; +import { + GATEWAY_SERVICE_KIND, + GATEWAY_SERVICE_MARKER, + resolveGatewayLaunchAgentLabel, +} from "./constants.js"; +import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js"; +import { execLaunchctl } from "./launchd-exec.js"; +import { assertValidLaunchAgentLabel } from "./launchd-label.js"; +import { readLaunchAgentProgramArgumentsFromFile } from "./launchd-plist.js"; +import { resolveLaunchAgentGuiDomain } from "./launchd-runtime.js"; +import { resolveLaunchAgentPlistPathForLabel } from "./launchd-service-files.js"; + +const OPENCLAW_UPDATE_LAUNCHD_LABEL_PREFIX = "ai.openclaw.update."; +const OPENCLAW_MANUAL_UPDATE_LAUNCHD_LABEL_PATTERN = /^ai\.openclaw\.manual-update\.\d+$/; +const OPENCLAW_PROFILE_UPDATE_LAUNCHD_LABEL_PATTERN = + /^ai\.openclaw\.[A-Za-z0-9._-]+\.update\.[A-Za-z0-9._-]+$/; +const OPENCLAW_DIRECT_CLI_NAMES = new Set(["openclaw", "openclaw.mjs"]); +const OPENCLAW_NODE_RUNTIME_NAMES = new Set(["bun", "bun.exe", "node", "node.exe"]); +const OPENCLAW_SCRIPT_NAMES = new Set(["openclaw.mjs"]); +export type StaleOpenClawUpdateLaunchdJob = { + label: string; + pid?: number; + lastExitStatus?: number; +}; + +type OpenClawUpdateLaunchdLabelCandidate = { + label: string; + requiresMetadata: boolean; +}; + +function normalizeOpenClawUpdateLaunchdLabel(label: unknown): string | null { + if (typeof label !== "string") { + return null; + } + const trimmed = label.trim(); + if (trimmed.startsWith(OPENCLAW_UPDATE_LAUNCHD_LABEL_PREFIX)) { + return trimmed; + } + // Manual update jobs include a timestamp-like suffix and should be cleaned up + // without matching arbitrary ai.openclaw labels. + return OPENCLAW_MANUAL_UPDATE_LAUNCHD_LABEL_PATTERN.test(trimmed) ? trimmed : null; +} + +function normalizeOpenClawUpdateLaunchdLabelCandidate( + label: unknown, +): OpenClawUpdateLaunchdLabelCandidate | null { + const normalized = normalizeOpenClawUpdateLaunchdLabel(label); + if (normalized) { + return { label: normalized, requiresMetadata: false }; + } + if (typeof label !== "string") { + return null; + } + const trimmed = label.trim(); + return OPENCLAW_PROFILE_UPDATE_LAUNCHD_LABEL_PATTERN.test(trimmed) + ? { label: trimmed, requiresMetadata: true } + : null; +} + +function isCurrentGatewayLaunchdLabel(label: string, env: NodeJS.ProcessEnv): boolean { + const gatewayProfileLabel = resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE); + if (label === gatewayProfileLabel) { + return true; + } + if ( + env.OPENCLAW_SERVICE_MARKER?.trim() !== GATEWAY_SERVICE_MARKER || + env.OPENCLAW_SERVICE_KIND?.trim() !== GATEWAY_SERVICE_KIND + ) { + return false; + } + const configuredLabel = env.OPENCLAW_LAUNCHD_LABEL?.trim(); + return Boolean(configuredLabel && label === configuredLabel); +} + +function resolveCurrentOpenClawUpdateLaunchdJobLabel( + env: NodeJS.ProcessEnv = process.env, +): OpenClawUpdateLaunchdLabelCandidate | null { + for (const label of [ + env.LAUNCH_JOB_LABEL, + env.LAUNCH_JOB_NAME, + env.XPC_SERVICE_NAME, + env.OPENCLAW_LAUNCHD_LABEL, + ]) { + const candidate = normalizeOpenClawUpdateLaunchdLabelCandidate(label); + if (candidate) { + if (isCurrentGatewayLaunchdLabel(candidate.label, env)) { + continue; + } + return candidate; + } + } + return null; +} + +export function parseLaunchctlListOpenClawUpdateJobs( + output: string, +): StaleOpenClawUpdateLaunchdJob[] { + return parseLaunchctlListOpenClawUpdateJobCandidates(output) + .filter((job) => !job.requiresMetadata) + .map(({ requiresMetadata: _requiresMetadata, ...job }) => job); +} + +function parseLaunchctlListOpenClawUpdateJobCandidates( + output: string, +): Array { + const jobs: Array = []; + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) { + continue; + } + const parts = line.split(/\s+/); + const [pidRaw, statusRaw, ...labelParts] = parts; + const candidate = normalizeOpenClawUpdateLaunchdLabelCandidate(labelParts.join(" ")); + if (!candidate) { + continue; + } + const pid = pidRaw === "-" ? undefined : parseStrictPositiveInteger(pidRaw ?? ""); + const lastExitStatus = parseStrictInteger(statusRaw ?? ""); + jobs.push({ + label: candidate.label, + requiresMetadata: candidate.requiresMetadata, + ...(pid !== undefined ? { pid } : {}), + ...(lastExitStatus !== undefined ? { lastExitStatus } : {}), + }); + } + return jobs.toSorted((a, b) => a.label.localeCompare(b.label)); +} + +function hasOpenClawUpdateLaunchdMarker(env: Record | undefined) { + return env?.OPENCLAW_UPDATE_RUN_HANDOFF?.trim() === "1"; +} + +function isOpenClawUpdateCommandPrefix(programArguments: string[], updateIndex: number): boolean { + if (updateIndex === 1) { + const cliName = path.basename(programArguments[0] ?? "").toLowerCase(); + return OPENCLAW_DIRECT_CLI_NAMES.has(cliName); + } + if (updateIndex !== 2) { + return false; + } + const runtimeName = path.basename(programArguments[0] ?? "").toLowerCase(); + const entryName = path.basename(programArguments[1] ?? "").toLowerCase(); + return OPENCLAW_NODE_RUNTIME_NAMES.has(runtimeName) && OPENCLAW_SCRIPT_NAMES.has(entryName); +} + +function isOpenClawUpdateProgramArguments(programArguments: string[] | undefined): boolean { + if (!Array.isArray(programArguments) || programArguments.length === 0) { + return false; + } + const updateIndex = programArguments.findIndex((arg) => arg.trim() === "update"); + if (updateIndex < 0 || !programArguments.slice(updateIndex + 1).includes("--yes")) { + return false; + } + return ( + isOpenClawUpdateCommandPrefix(programArguments, updateIndex) && + !programArguments.some((arg) => arg.trim() === "gateway") + ); +} + +async function isLaunchdJobConfirmedOpenClawUpdater(params: { + label: string; + env: NodeJS.ProcessEnv; +}): Promise { + const plistPath = resolveLaunchAgentPlistPathForLabel(params.env, params.label); + const command = await readLaunchAgentProgramArgumentsFromFile(plistPath); + return ( + hasOpenClawUpdateLaunchdMarker(command?.environment) || + isOpenClawUpdateProgramArguments(command?.programArguments) + ); +} + +export async function findStaleOpenClawUpdateLaunchdJobs( + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (process.platform !== "darwin") { + return []; + } + const result = await execLaunchctl(["list"]); + if (result.code !== 0) { + return []; + } + // Never report the active gateway label as stale even when a wrapper exposes + // update-like launchd metadata through the current environment. + const jobs: StaleOpenClawUpdateLaunchdJob[] = []; + for (const job of parseLaunchctlListOpenClawUpdateJobCandidates(result.stdout)) { + if (isCurrentGatewayLaunchdLabel(job.label, env)) { + continue; + } + if ( + job.requiresMetadata && + !(await isLaunchdJobConfirmedOpenClawUpdater({ label: job.label, env })) + ) { + continue; + } + jobs.push({ + label: job.label, + ...(job.pid !== undefined ? { pid: job.pid } : {}), + ...(job.lastExitStatus !== undefined ? { lastExitStatus: job.lastExitStatus } : {}), + }); + } + return jobs; +} + +async function disableOpenClawUpdateLaunchdJobCandidate(params: { + candidate: OpenClawUpdateLaunchdLabelCandidate; + env: NodeJS.ProcessEnv; + trustCurrentEnvMarker: boolean; +}): Promise { + if (process.platform !== "darwin") { + return false; + } + if ( + params.candidate.requiresMetadata && + !( + (params.trustCurrentEnvMarker && hasOpenClawUpdateLaunchdMarker(params.env)) || + (await isLaunchdJobConfirmedOpenClawUpdater({ + label: params.candidate.label, + env: params.env, + })) + ) + ) { + return false; + } + const serviceTarget = `${resolveLaunchAgentGuiDomain()}/${assertValidLaunchAgentLabel(params.candidate.label)}`; + const result = await execLaunchctl(["disable", serviceTarget]); + return result.code === 0; +} + +export async function disableOpenClawUpdateLaunchdJob( + label: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const candidate = normalizeOpenClawUpdateLaunchdLabelCandidate(label); + if (!candidate) { + return false; + } + return await disableOpenClawUpdateLaunchdJobCandidate({ + candidate, + env, + trustCurrentEnvMarker: false, + }); +} + +export async function disableCurrentOpenClawUpdateLaunchdJob( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const candidate = resolveCurrentOpenClawUpdateLaunchdJobLabel(env); + if (!candidate) { + return false; + } + return await disableOpenClawUpdateLaunchdJobCandidate({ + candidate, + env, + // Detached handoffs preserve the configured label, so only launchd-backed + // current-process identity may turn the ambient marker into proof. + trustCurrentEnvMarker: isCurrentProcessLaunchdServiceLabel(candidate.label, env, { + allowConfiguredLabelFallback: false, + }), + }); +} diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 0678944d77a2..8d2a33226fb1 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -1,2013 +1,30 @@ /** macOS LaunchAgent installer, runtime inspection, and lifecycle controls. */ -import { spawnSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { normalizeEnvVarKey } from "../infra/host-env-security.js"; -import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; -import { formatPortDiagnostics } from "../infra/ports-format.js"; -import { inspectPortUsage } from "../infra/ports-inspect.js"; -import { probePortUsage } from "../infra/ports-probe.js"; -import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js"; -import { parseTcpPort, parseTcpPortFromArgs } from "../infra/tcp-port.js"; -import { sleep } from "../utils.js"; -import { - GATEWAY_LAUNCH_AGENT_LABEL, - GATEWAY_SERVICE_KIND, - GATEWAY_SERVICE_MARKER, - resolveGatewayServiceDescription, - resolveGatewayLaunchAgentLabel, - resolveLegacyGatewayLaunchAgentLabels, -} from "./constants.js"; -import { resolveGatewayServiceProbeHosts } from "./gateway-service-probe-hosts.js"; -import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js"; -import { - execLaunchctl, - formatLaunchctlResultDetail, - isLaunchctlNotLoaded, -} from "./launchd-exec.js"; -import { assertValidLaunchAgentLabel, resolveLaunchAgentLabel } from "./launchd-label.js"; -import { - LAUNCH_AGENT_ENV_WRAPPER_SHELL, - buildLaunchAgentPlist as buildLaunchAgentPlistImpl, - LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS, - readLaunchAgentProgramArgumentsFromFile, -} from "./launchd-plist.js"; -import { - scheduleDetachedLaunchdMaintenancePark, - scheduleDetachedLaunchdRestartHandoff, -} from "./launchd-restart-handoff.js"; -import { - assertNoSystemLaunchDaemonOwnership, - formatSystemLaunchDaemonOwnershipSummary, - inspectSystemLaunchDaemonOwnership, - isSystemLaunchDaemonOwnershipError, -} from "./launchd-system.js"; -import { formatLine, toPosixPath, writeFormattedLines } from "./output.js"; -import { resolveDaemonHomeDir, resolveGatewayStateDir } from "./paths.js"; -import { resolveGatewaySupervisorLogPaths } from "./restart-logs.js"; -import { parseKeyValueOutput } from "./runtime-parse.js"; -import { createGatewayLifecycleMutationReporter } from "./service-mutation.js"; -import type { GatewayServiceRuntime } from "./service-runtime.js"; -import type { - GatewayServiceCommandConfig, - GatewayServiceControlArgs, - GatewayServiceEnv, - GatewayServiceEnvArgs, - GatewayServiceInstallArgs, - GatewayServiceManageArgs, - GatewayServiceRestartResult, -} from "./service-types.js"; - export { isLaunchctlNotLoaded } from "./launchd-exec.js"; +export { installLaunchAgent, stageLaunchAgent, uninstallLaunchAgent } from "./launchd-install.js"; +export { + repairLaunchAgentBootstrap, + restartLaunchAgent, + startLaunchAgent, +} from "./launchd-lifecycle.js"; export { resolveLaunchAgentLabel } from "./launchd-label.js"; - -const LAUNCH_AGENT_DIR_MODE = 0o755; -// launchd rejects user LaunchAgent plists without group/other read access on -// current macOS. Secrets stay in the separate 0600 environment file. -const LAUNCH_AGENT_PLIST_MODE = 0o644; -const LAUNCH_AGENT_PRIVATE_DIR_MODE = 0o700; -const LAUNCH_AGENT_ENV_FILE_MODE = 0o600; -const LAUNCH_AGENT_ENV_WRAPPER_MODE = 0o700; -const LAUNCH_AGENT_ENV_DIR_NAME = "service-env"; -const LAUNCH_AGENT_STDERR_PATH = "/dev/null"; -const OPENCLAW_UPDATE_LAUNCHD_LABEL_PREFIX = "ai.openclaw.update."; -const OPENCLAW_MANUAL_UPDATE_LAUNCHD_LABEL_PATTERN = /^ai\.openclaw\.manual-update\.\d+$/; -const OPENCLAW_PROFILE_UPDATE_LAUNCHD_LABEL_PATTERN = - /^ai\.openclaw\.[A-Za-z0-9._-]+\.update\.[A-Za-z0-9._-]+$/; -const OPENCLAW_DIRECT_CLI_NAMES = new Set(["openclaw", "openclaw.mjs"]); -const OPENCLAW_NODE_RUNTIME_NAMES = new Set(["bun", "bun.exe", "node", "node.exe"]); -const OPENCLAW_SCRIPT_NAMES = new Set(["openclaw.mjs"]); -const LAUNCH_AGENT_STOP_PORT_RELEASE_TIMEOUT_MS = LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS * 1_000; -const LAUNCH_AGENT_STOP_PORT_RELEASE_POLL_MS = 100; -// launchd reserves the label until the outgoing job actually exits, and it -// SIGKILLs that job once ExitTimeOut elapses. Bound the bootstrap retry by that -// same deadline plus slack so a drain-on-SIGTERM gateway cannot outlast it. -const LAUNCH_AGENT_BOOTSTRAP_TEARDOWN_TIMEOUT_MS = (LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS + 10) * 1_000; -const LAUNCH_AGENT_BOOTSTRAP_TEARDOWN_POLL_MS = 500; -const LAUNCHCTL_PROTECTED_PID_TIMEOUT_MS = 2_000; - -export type StaleOpenClawUpdateLaunchdJob = { - label: string; - pid?: number; - lastExitStatus?: number; -}; - -type OpenClawUpdateLaunchdLabelCandidate = { - label: string; - requiresMetadata: boolean; -}; - -function normalizeOpenClawUpdateLaunchdLabel(label: unknown): string | null { - if (typeof label !== "string") { - return null; - } - const trimmed = label.trim(); - if (trimmed.startsWith(OPENCLAW_UPDATE_LAUNCHD_LABEL_PREFIX)) { - return trimmed; - } - // Manual update jobs include a timestamp-like suffix and should be cleaned up - // without matching arbitrary ai.openclaw labels. - return OPENCLAW_MANUAL_UPDATE_LAUNCHD_LABEL_PATTERN.test(trimmed) ? trimmed : null; -} - -function normalizeOpenClawUpdateLaunchdLabelCandidate( - label: unknown, -): OpenClawUpdateLaunchdLabelCandidate | null { - const normalized = normalizeOpenClawUpdateLaunchdLabel(label); - if (normalized) { - return { label: normalized, requiresMetadata: false }; - } - if (typeof label !== "string") { - return null; - } - const trimmed = label.trim(); - return OPENCLAW_PROFILE_UPDATE_LAUNCHD_LABEL_PATTERN.test(trimmed) - ? { label: trimmed, requiresMetadata: true } - : null; -} - -function isCurrentGatewayLaunchdLabel(label: string, env: NodeJS.ProcessEnv): boolean { - const gatewayProfileLabel = resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE); - if (label === gatewayProfileLabel) { - return true; - } - if ( - env.OPENCLAW_SERVICE_MARKER?.trim() !== GATEWAY_SERVICE_MARKER || - env.OPENCLAW_SERVICE_KIND?.trim() !== GATEWAY_SERVICE_KIND - ) { - return false; - } - const configuredLabel = env.OPENCLAW_LAUNCHD_LABEL?.trim(); - return Boolean(configuredLabel && label === configuredLabel); -} - -function resolveCurrentOpenClawUpdateLaunchdJobLabel( - env: NodeJS.ProcessEnv = process.env, -): OpenClawUpdateLaunchdLabelCandidate | null { - for (const label of [ - env.LAUNCH_JOB_LABEL, - env.LAUNCH_JOB_NAME, - env.XPC_SERVICE_NAME, - env.OPENCLAW_LAUNCHD_LABEL, - ]) { - const candidate = normalizeOpenClawUpdateLaunchdLabelCandidate(label); - if (candidate) { - if (isCurrentGatewayLaunchdLabel(candidate.label, env)) { - continue; - } - return candidate; - } - } - return null; -} - -function resolveLaunchAgentPlistPathForLabel( - env: Record, - label: string, -): string { - const home = toPosixPath(resolveDaemonHomeDir(env)); - return path.posix.join(home, "Library", "LaunchAgents", `${label}.plist`); -} - -function resolveLaunchAgentEnvDir(env: GatewayServiceEnv): string { - return path.join(resolveGatewayStateDir(env), LAUNCH_AGENT_ENV_DIR_NAME); -} - -function resolveLaunchAgentEnvFilePath(env: GatewayServiceEnv, label: string): string { - return path.join(resolveLaunchAgentEnvDir(env), `${label}.env`); -} - -function resolveLaunchAgentEnvWrapperPath(env: GatewayServiceEnv, label: string): string { - return path.join(resolveLaunchAgentEnvDir(env), `${label}-env-wrapper.sh`); -} - -function shellSingleQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} - -function collectLaunchAgentEnvironmentEntries( - environment: GatewayServiceEnv | undefined, -): Array<[string, string]> { - const entries: Array<[string, string]> = []; - for (const [rawKey, rawValue] of Object.entries(environment ?? {})) { - const key = normalizeEnvVarKey(rawKey, { portable: true }); - const value = rawValue?.trim(); - if (!key || !value) { - continue; - } - entries.push([key, value]); - } - return entries.toSorted(([left], [right]) => left.localeCompare(right)); -} - -function buildLaunchAgentEnvironmentFile(entries: Array<[string, string]>): string { - return [ - "# Generated by OpenClaw. Do not edit while the gateway service is installed.", - ...entries.map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), - "", - ].join("\n"); -} - -function buildLaunchAgentEnvironmentWrapper(): string { - return `#!/bin/sh -set -eu -env_file="$1" -shift -if [ -f "$env_file" ]; then - . "$env_file" -fi -exec "$@" -`; -} - -async function resolveLaunchAgentEnvironmentWrapperOverwriteWarnings(params: { - wrapperPath: string; - generatedWrapper: string; -}): Promise { - const existingWrapper = await fs.readFile(params.wrapperPath, "utf8").catch(() => null); - if (existingWrapper === null || existingWrapper === params.generatedWrapper) { - return []; - } - return [ - `Existing generated LaunchAgent env wrapper at ${params.wrapperPath} contains custom behavior and will be overwritten; move custom behavior to openclaw gateway install --wrapper or OPENCLAW_WRAPPER.`, - ]; -} - -function writeLaunchAgentOverwriteWarnings( - stdout: NodeJS.WritableStream | undefined, - warn: ((message: string) => void) | undefined, - warnings: readonly string[], -): void { - for (const warning of warnings) { - if (warn) { - warn(warning); - continue; - } - if (!stdout) { - continue; - } - stdout.write(`${formatLine("Warning", warning)}\n`); - } -} - -function isLaunchAgentEnvironmentWrapperArgs(params: { - programArguments: string[]; - envFilePath: string; - wrapperPath: string; -}): boolean { - return ( - (params.programArguments[0] === params.wrapperPath && - params.programArguments[1] === params.envFilePath) || - (params.programArguments[0] === LAUNCH_AGENT_ENV_WRAPPER_SHELL && - params.programArguments[1] === params.wrapperPath && - params.programArguments[2] === params.envFilePath) - ); -} - -async function prepareLaunchAgentProgramArguments(params: { - env: GatewayServiceEnv; - label: string; - programArguments: string[]; - environment: GatewayServiceEnv | undefined; - stdout?: NodeJS.WritableStream; - warn?: (message: string) => void; -}): Promise<{ - programArguments: string[]; - inlineEnvironment?: GatewayServiceEnv; -}> { - const entries = collectLaunchAgentEnvironmentEntries(params.environment); - if (entries.length === 0) { - return { programArguments: params.programArguments }; - } - - // Environment values with secrets live in an owner-only env file instead of - // inline plist XML, which can be harder to rotate and audit. - const envDir = resolveLaunchAgentEnvDir(params.env); - const envFilePath = resolveLaunchAgentEnvFilePath(params.env, params.label); - const wrapperPath = resolveLaunchAgentEnvWrapperPath(params.env, params.label); - const generatedWrapper = buildLaunchAgentEnvironmentWrapper(); - await ensureSecureDirectory(envDir, LAUNCH_AGENT_PRIVATE_DIR_MODE); - await fs.writeFile(envFilePath, buildLaunchAgentEnvironmentFile(entries), { - encoding: "utf8", - mode: LAUNCH_AGENT_ENV_FILE_MODE, - }); - await fs.chmod(envFilePath, LAUNCH_AGENT_ENV_FILE_MODE).catch(() => undefined); - const overwriteWarnings = await resolveLaunchAgentEnvironmentWrapperOverwriteWarnings({ - wrapperPath, - generatedWrapper, - }); - writeLaunchAgentOverwriteWarnings(params.stdout, params.warn, overwriteWarnings); - await fs.writeFile(wrapperPath, generatedWrapper, { - encoding: "utf8", - mode: LAUNCH_AGENT_ENV_WRAPPER_MODE, - }); - await fs.chmod(wrapperPath, LAUNCH_AGENT_ENV_WRAPPER_MODE).catch(() => undefined); - - if ( - isLaunchAgentEnvironmentWrapperArgs({ - programArguments: params.programArguments, - envFilePath, - wrapperPath, - }) - ) { - return { programArguments: params.programArguments }; - } - - return { - programArguments: [ - LAUNCH_AGENT_ENV_WRAPPER_SHELL, - wrapperPath, - envFilePath, - ...params.programArguments, - ], - }; -} - -export function resolveLaunchAgentPlistPath(env: GatewayServiceEnv): string { - const label = resolveLaunchAgentLabel(env); - return resolveLaunchAgentPlistPathForLabel(env, label); -} - -function resolveLaunchAgentEnvironmentReadOptions(env: GatewayServiceEnv, label: string) { - return { - expectedEnvironmentWrapperPath: resolveLaunchAgentEnvWrapperPath(env, label), - expectedEnvironmentFilePath: resolveLaunchAgentEnvFilePath(env, label), - generatedEnvironmentLabel: label, - }; -} - -export async function readLaunchAgentProgramArguments( - env: GatewayServiceEnv, -): Promise { - const label = resolveLaunchAgentLabel(env); - const plistPath = resolveLaunchAgentPlistPath(env); - return readLaunchAgentProgramArgumentsFromFile( - plistPath, - resolveLaunchAgentEnvironmentReadOptions(env, label), - ); -} - -function buildLaunchAgentPlist({ - label = GATEWAY_LAUNCH_AGENT_LABEL, - comment, - programArguments, - workingDirectory, - stdoutPath, - stderrPath, - environment, -}: { - label?: string; - comment?: string; - programArguments: string[]; - workingDirectory?: string; - stdoutPath: string; - stderrPath: string; - environment?: Record; -}): string { - return buildLaunchAgentPlistImpl({ - label, - comment, - programArguments, - workingDirectory, - stdoutPath, - stderrPath, - environment, - }); -} - -function readLaunchAgentPidForCleanupSync(serviceTarget: string): number { - const probe = spawnSync("launchctl", ["print", serviceTarget], { - encoding: "utf8", - timeout: LAUNCHCTL_PROTECTED_PID_TIMEOUT_MS, - }); - const result = { - stdout: probe.stdout ?? "", - stderr: probe.error?.message ?? probe.stderr ?? "", - code: probe.error ? 1 : (probe.status ?? 1), - }; - if (result.code !== 0) { - throw new Error(`launchctl print failed: ${formatLaunchctlResultDetail(result)}`); - } - const pid = parseLaunchctlPrint(result.stdout || result.stderr || "").pid; - if (pid === undefined) { - throw new Error("launchctl print did not report a running pid"); - } - return pid; -} - -export function parseLaunchctlListOpenClawUpdateJobs( - output: string, -): StaleOpenClawUpdateLaunchdJob[] { - return parseLaunchctlListOpenClawUpdateJobCandidates(output) - .filter((job) => !job.requiresMetadata) - .map(({ requiresMetadata: _requiresMetadata, ...job }) => job); -} - -function parseLaunchctlListOpenClawUpdateJobCandidates( - output: string, -): Array { - const jobs: Array = []; - for (const rawLine of output.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line) { - continue; - } - const parts = line.split(/\s+/); - const [pidRaw, statusRaw, ...labelParts] = parts; - const candidate = normalizeOpenClawUpdateLaunchdLabelCandidate(labelParts.join(" ")); - if (!candidate) { - continue; - } - const pid = pidRaw === "-" ? undefined : parseStrictPositiveInteger(pidRaw ?? ""); - const lastExitStatus = parseStrictInteger(statusRaw ?? ""); - jobs.push({ - label: candidate.label, - requiresMetadata: candidate.requiresMetadata, - ...(pid !== undefined ? { pid } : {}), - ...(lastExitStatus !== undefined ? { lastExitStatus } : {}), - }); - } - return jobs.toSorted((a, b) => a.label.localeCompare(b.label)); -} - -function hasOpenClawUpdateLaunchdMarker(env: Record | undefined) { - return env?.OPENCLAW_UPDATE_RUN_HANDOFF?.trim() === "1"; -} - -function isOpenClawUpdateCommandPrefix(programArguments: string[], updateIndex: number): boolean { - if (updateIndex === 1) { - const cliName = path.basename(programArguments[0] ?? "").toLowerCase(); - return OPENCLAW_DIRECT_CLI_NAMES.has(cliName); - } - if (updateIndex !== 2) { - return false; - } - const runtimeName = path.basename(programArguments[0] ?? "").toLowerCase(); - const entryName = path.basename(programArguments[1] ?? "").toLowerCase(); - return OPENCLAW_NODE_RUNTIME_NAMES.has(runtimeName) && OPENCLAW_SCRIPT_NAMES.has(entryName); -} - -function isOpenClawUpdateProgramArguments(programArguments: string[] | undefined): boolean { - if (!Array.isArray(programArguments) || programArguments.length === 0) { - return false; - } - const updateIndex = programArguments.findIndex((arg) => arg.trim() === "update"); - if (updateIndex < 0 || !programArguments.slice(updateIndex + 1).includes("--yes")) { - return false; - } - return ( - isOpenClawUpdateCommandPrefix(programArguments, updateIndex) && - !programArguments.some((arg) => arg.trim() === "gateway") - ); -} - -async function isLaunchdJobConfirmedOpenClawUpdater(params: { - label: string; - env: NodeJS.ProcessEnv; -}): Promise { - const plistPath = resolveLaunchAgentPlistPathForLabel(params.env, params.label); - const command = await readLaunchAgentProgramArgumentsFromFile(plistPath); - return ( - hasOpenClawUpdateLaunchdMarker(command?.environment) || - isOpenClawUpdateProgramArguments(command?.programArguments) - ); -} - -export async function findStaleOpenClawUpdateLaunchdJobs( - env: NodeJS.ProcessEnv = process.env, -): Promise { - if (process.platform !== "darwin") { - return []; - } - const result = await execLaunchctl(["list"]); - if (result.code !== 0) { - return []; - } - // Never report the active gateway label as stale even when a wrapper exposes - // update-like launchd metadata through the current environment. - const jobs: StaleOpenClawUpdateLaunchdJob[] = []; - for (const job of parseLaunchctlListOpenClawUpdateJobCandidates(result.stdout)) { - if (isCurrentGatewayLaunchdLabel(job.label, env)) { - continue; - } - if ( - job.requiresMetadata && - !(await isLaunchdJobConfirmedOpenClawUpdater({ label: job.label, env })) - ) { - continue; - } - jobs.push({ - label: job.label, - ...(job.pid !== undefined ? { pid: job.pid } : {}), - ...(job.lastExitStatus !== undefined ? { lastExitStatus: job.lastExitStatus } : {}), - }); - } - return jobs; -} - -async function disableOpenClawUpdateLaunchdJobCandidate(params: { - candidate: OpenClawUpdateLaunchdLabelCandidate; - env: NodeJS.ProcessEnv; - trustCurrentEnvMarker: boolean; -}): Promise { - if (process.platform !== "darwin") { - return false; - } - if ( - params.candidate.requiresMetadata && - !( - (params.trustCurrentEnvMarker && hasOpenClawUpdateLaunchdMarker(params.env)) || - (await isLaunchdJobConfirmedOpenClawUpdater({ - label: params.candidate.label, - env: params.env, - })) - ) - ) { - return false; - } - const serviceTarget = `${resolveGuiDomain()}/${assertValidLaunchAgentLabel(params.candidate.label)}`; - const result = await execLaunchctl(["disable", serviceTarget]); - return result.code === 0; -} - -export async function disableOpenClawUpdateLaunchdJob( - label: string, - env: NodeJS.ProcessEnv = process.env, -): Promise { - const candidate = normalizeOpenClawUpdateLaunchdLabelCandidate(label); - if (!candidate) { - return false; - } - return await disableOpenClawUpdateLaunchdJobCandidate({ - candidate, - env, - trustCurrentEnvMarker: false, - }); -} - -export async function disableCurrentOpenClawUpdateLaunchdJob( - env: NodeJS.ProcessEnv = process.env, -): Promise { - const candidate = resolveCurrentOpenClawUpdateLaunchdJobLabel(env); - if (!candidate) { - return false; - } - return await disableOpenClawUpdateLaunchdJobCandidate({ - candidate, - env, - // Detached handoffs preserve the configured label, so only launchd-backed - // current-process identity may turn the ambient marker into proof. - trustCurrentEnvMarker: isCurrentProcessLaunchdServiceLabel(candidate.label, env, { - allowConfiguredLabelFallback: false, - }), - }); -} - -async function resolveLaunchAgentGatewayContext(env: GatewayServiceEnv): Promise<{ - port: number | null; - probeHosts: readonly string[]; -}> { - const command = await readLaunchAgentProgramArguments(env).catch(() => null); - const fromArgs = parseTcpPortFromArgs(command?.programArguments); - if (fromArgs !== null) { - return { - port: fromArgs, - probeHosts: await resolveGatewayServiceProbeHosts({ env, command }), - }; - } - const fromServiceEnv = parseTcpPort(command?.environment?.OPENCLAW_GATEWAY_PORT ?? ""); - if (fromServiceEnv !== null) { - return { - port: fromServiceEnv, - probeHosts: await resolveGatewayServiceProbeHosts({ env, command }), - }; - } - return { - port: parseTcpPort(env.OPENCLAW_GATEWAY_PORT ?? ""), - probeHosts: await resolveGatewayServiceProbeHosts({ env, command }), - }; -} - -function resolveGuiDomain(): string { - if (typeof process.getuid !== "function") { - return "gui/501"; - } - return `gui/${process.getuid()}`; -} - -function throwBootstrapGuiSessionError(params: { - detail: string; - domain: string; - actionHint: string; -}) { - throw new Error(formatLaunchAgentGuiSessionError(params)); -} - -export function formatLaunchAgentGuiSessionError(params: { - detail: string; - domain: string; - actionHint: string; -}): string { - return [ - `launchctl bootstrap failed: ${params.detail}`, - `LaunchAgent ${params.actionHint} requires a logged-in macOS GUI session for this user (${params.domain}).`, - "This usually means you are running from SSH/headless context or as the wrong user (including sudo).", - `Fix: sign in to the macOS desktop as the target user and rerun \`${params.actionHint}\`.`, - "For headless VM setups, enable auto-login for the target user so macOS creates the GUI session after boot.", - "Headless deployments should use a dedicated logged-in user session or a custom LaunchDaemon (not shipped): https://docs.openclaw.ai/gateway", - ].join("\n"); -} - -function writeLaunchAgentActionLine( - stdout: NodeJS.WritableStream, - label: string, - value: string, -): void { - try { - stdout.write(`${formatLine(label, value)}\n`); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException)?.code !== "EPIPE") { - throw err; - } - } -} - -async function bootstrapLaunchAgentOrThrow(params: { - domain: string; - serviceTarget: string; - plistPath: string; - actionHint: string; - onMutation?: (mode: "enable" | "bootstrap") => void; - skipEnable?: boolean; - // Opt-in for callers that just issued `bootout` on this label. Only those can - // race a pending teardown, so start/install/recovery paths keep failing fast - // on an unrelated EIO instead of waiting out the teardown deadline. - retryPendingTeardown?: boolean; -}) { - // `disable` state survives bootout and plist rewrites; explicit start/repair - // paths must clear it before asking launchd to load the job again. - if (!params.skipEnable) { - const enable = await execLaunchctl(["enable", params.serviceTarget]); - if (enable.code === 0) { - params.onMutation?.("enable"); - } - } - const teardownDeadline = Date.now() + LAUNCH_AGENT_BOOTSTRAP_TEARDOWN_TIMEOUT_MS; - for (;;) { - const boot = await execLaunchctl(["bootstrap", params.domain, params.plistPath]); - if (boot.code === 0) { - params.onMutation?.("bootstrap"); - return; - } - const detail = (boot.stderr || boot.stdout).trim(); - if (isUnsupportedGuiDomain(detail)) { - throwBootstrapGuiSessionError({ - detail, - domain: params.domain, - actionHint: params.actionHint, - }); - } - if (isLaunchctlOperationAlreadyInProgress(detail)) { - const state = await probeLaunchAgentState(params.serviceTarget); - if (state.state === "running" || state.state === "stopped") { - params.onMutation?.("bootstrap"); - return; - } - } - const remainingMs = teardownDeadline - Date.now(); - if ( - !params.retryPendingTeardown || - !isLaunchctlBootstrapPendingTeardown(boot) || - remainingMs <= 0 - ) { - throw new Error(`launchctl bootstrap failed: ${detail}`); - } - await sleep(Math.min(LAUNCH_AGENT_BOOTSTRAP_TEARDOWN_POLL_MS, remainingMs)); - } -} - -async function ensureLaunchAgentPlistReadable(plistPath: string): Promise { - await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined); -} - -async function readExistingLaunchAgentPlist(plistPath: string): Promise { - try { - return await fs.readFile(plistPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - throw error; - } -} - -async function publishLaunchAgentPlist(params: { - label: string; - plistPath: string; - contents: string; -}): Promise { - const previousContents = await readExistingLaunchAgentPlist(params.plistPath); - const temporaryPath = `${params.plistPath}.openclaw-${randomUUID()}.tmp`; - await fs.writeFile(temporaryPath, params.contents, { - encoding: "utf8", - flag: "wx", - mode: LAUNCH_AGENT_PLIST_MODE, - }); - try { - // The temporary filename does not end in .plist, so launchd cannot discover - // it before the final ownership check and atomic publication. - await assertNoSystemLaunchDaemonOwnership(params.label); - await fs.rename(temporaryPath, params.plistPath); - try { - await assertNoSystemLaunchDaemonOwnership(params.label); - } catch (ownershipError) { - try { - if (previousContents === null) { - await fs.unlink(params.plistPath); - } else { - const rollbackPath = `${params.plistPath}.openclaw-${randomUUID()}.rollback`; - try { - await fs.writeFile(rollbackPath, previousContents, { - flag: "wx", - mode: LAUNCH_AGENT_PLIST_MODE, - }); - await fs.rename(rollbackPath, params.plistPath); - } finally { - await fs.unlink(rollbackPath).catch(() => undefined); - } - } - } catch (rollbackError) { - const ownershipDetail = - ownershipError instanceof Error ? ownershipError.message : String(ownershipError); - throw new Error( - `${ownershipDetail}\nThe previous LaunchAgent plist at ${params.plistPath} could not be restored.`, - { cause: rollbackError }, - ); - } - throw ownershipError; - } - } finally { - await fs.unlink(temporaryPath).catch(() => undefined); - } - await ensureLaunchAgentPlistReadable(params.plistPath); -} - -async function ensureSecureDirectory( - targetPath: string, - dirMode = LAUNCH_AGENT_DIR_MODE, -): Promise { - await fs.mkdir(targetPath, { recursive: true, mode: dirMode }); - try { - const stat = await fs.stat(targetPath); - const mode = stat.mode & 0o777; - const forbiddenMode = dirMode === LAUNCH_AGENT_PRIVATE_DIR_MODE ? 0o077 : 0o022; - const tightenedMode = mode & ~forbiddenMode; - if (tightenedMode !== mode) { - await fs.chmod(targetPath, tightenedMode); - } - } catch { - // Best effort: keep install working even if chmod/stat is unavailable. - } -} - -async function ensureLaunchAgentEnvironmentDirectories( - environment: Record | undefined, -): Promise { - const tmpDir = environment?.TMPDIR?.trim(); - if (tmpDir) { - await ensureSecureDirectory(tmpDir, LAUNCH_AGENT_PRIVATE_DIR_MODE); - } -} - -type LaunchctlPrintInfo = { - state?: string; - pid?: number; - lastExitStatus?: number; - lastExitReason?: string; -}; - -export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo { - const entries = parseKeyValueOutput(output, "="); - const info: LaunchctlPrintInfo = {}; - const state = entries.state; - if (state) { - info.state = state; - } - const pidValue = entries.pid; - if (pidValue) { - const pid = parseStrictPositiveInteger(pidValue); - if (pid !== undefined) { - info.pid = pid; - } - } - const exitStatusValue = entries["last exit status"]; - if (exitStatusValue) { - const status = parseStrictInteger(exitStatusValue); - if (status !== undefined) { - info.lastExitStatus = status; - } - } - const exitReason = entries["last exit reason"]; - if (exitReason) { - info.lastExitReason = exitReason; - } - return info; -} - -export function parseLaunchAgentEnabled(output: string, label: string): boolean { - const labelPrefix = `"${label}"`; - for (const line of output.split("\n")) { - const entry = line.trim(); - if (!entry.startsWith(labelPrefix)) { - continue; - } - const state = entry.slice(labelPrefix.length).trim(); - if (state === "=> enabled") { - return true; - } - if (state === "=> disabled") { - return false; - } - throw new Error(`launchctl print-disabled returned an unrecognized state for ${label}`); - } - // No persisted override means launchd uses the plist's normal enabled state. - return true; -} - -export async function isLaunchAgentEnabled(args: GatewayServiceEnvArgs): Promise { - const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel(args.env); - const res = await execLaunchctl(["print-disabled", domain]); - if (res.code !== 0) { - throw new Error(`launchctl print-disabled failed: ${formatLaunchctlResultDetail(res)}`); - } - return parseLaunchAgentEnabled(res.stdout || res.stderr || "", label); -} - -export async function isLaunchAgentLoaded(args: GatewayServiceEnvArgs): Promise { - const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel(args.env); - const res = await execLaunchctl(["print", `${domain}/${label}`]); - if (res.code === 0) { - return true; - } - if (isLaunchctlNotLoaded(res)) { - return false; - } - throw new Error(`launchctl print failed: ${formatLaunchctlResultDetail(res)}`); -} - -export async function launchAgentPlistExists(env: GatewayServiceEnv): Promise { - try { - const plistPath = resolveLaunchAgentPlistPath(env); - await fs.access(plistPath); - return true; - } catch { - return false; - } -} - -export async function readLaunchAgentRuntime( - env: Record, -): Promise { - const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel(env); - const [res, systemOwnership] = await Promise.all([ - execLaunchctl(["print", `${domain}/${label}`]), - inspectSystemLaunchDaemonOwnership(label, { scanInstalledPlists: false }), - ]); - if (systemOwnership.status !== "absent") { - return { - status: "unknown", - detail: formatSystemLaunchDaemonOwnershipSummary(systemOwnership), - systemLaunchDaemon: { - status: systemOwnership.status, - serviceTarget: systemOwnership.serviceTarget, - ...(systemOwnership.status === "installed" ? { plistPath: systemOwnership.plistPath } : {}), - }, - }; - } - if (res.code !== 0) { - const plistExists = await launchAgentPlistExists(env); - const detail = (res.stderr || res.stdout).trim() || undefined; - const missingGuiSession = plistExists && isUnsupportedGuiDomain(detail ?? ""); - return { - status: "unknown", - detail, - ...(plistExists - ? { missingSupervision: true, ...(missingGuiSession ? { missingGuiSession } : {}) } - : { missingUnit: true }), - }; - } - const parsed = parseLaunchctlPrint(res.stdout || res.stderr || ""); - const plistExists = await launchAgentPlistExists(env); - const state = normalizeLowercaseStringOrEmpty(parsed.state); - const status = state === "running" || parsed.pid ? "running" : state ? "stopped" : "unknown"; - return { - status, - state: parsed.state, - pid: parsed.pid, - lastExitStatus: parsed.lastExitStatus, - lastExitReason: parsed.lastExitReason, - cachedLabel: !plistExists, - }; -} - -type LaunchAgentBootstrapRepairResult = - | { ok: true; status: "repaired" | "already-loaded" } - | { - ok: false; - status: "bootstrap-failed" | "kickstart-failed"; - detail?: string; - } - | { - ok: false; - status: "system-launchdaemon-conflict" | "system-launchdaemon-unverifiable"; - detail: string; - } - | { ok: false; status: "gui-session-unavailable"; detail: string; domain: string }; - -function isLaunchctlAlreadyLoaded(res: { stdout: string; stderr: string; code: number }): boolean { - const detail = normalizeLowercaseStringOrEmpty(res.stderr || res.stdout); - return res.code === 130 || detail.includes("already exists in domain"); -} - -export async function repairLaunchAgentBootstrap(args: { - env?: Record; - warn?: (message: string) => void; -}): Promise { - const env = args.env ?? (process.env as Record); - const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel(env); - const plistPath = resolveLaunchAgentPlistPath(env); - const serviceTarget = `${domain}/${label}`; - try { - await assertNoSystemLaunchDaemonOwnership(label); - } catch (error) { - if (!isSystemLaunchDaemonOwnershipError(error)) { - throw error; - } - return { - ok: false, - status: - error.ownership.status === "unverifiable" - ? "system-launchdaemon-unverifiable" - : "system-launchdaemon-conflict", - detail: error.message, - }; - } - // Rewrite first so legacy inline environment secrets move into the private - // env file before the plist becomes world-readable for launchd. - const warn = args.warn ?? ((message: string) => console.warn(formatLine("Warning", message))); - await rewriteLaunchAgentPlistForRestart({ env, label, plistPath, warn }); - await execLaunchctl(["enable", serviceTarget]); - const boot = await execLaunchctl(["bootstrap", domain, plistPath]); - let repairStatus: "repaired" | "already-loaded" = "repaired"; - if (boot.code !== 0) { - const detail = (boot.stderr || boot.stdout).trim(); - if (isUnsupportedGuiDomain(detail)) { - return { - ok: false, - status: "gui-session-unavailable", - detail, - domain, - }; - } - if (!isLaunchctlAlreadyLoaded(boot)) { - return { ok: false, status: "bootstrap-failed", detail: detail || undefined }; - } - repairStatus = "already-loaded"; - } - if (repairStatus === "repaired") { - return { ok: true, status: repairStatus }; - } - - // Service is already bootstrapped. Only kickstart if it is not actively running — - // kickstarting a healthy running service causes unnecessary session disconnects. - const runtime = await readLaunchAgentRuntime(env); - if (runtime.status === "running") { - return { ok: true, status: repairStatus }; - } - - const kick = await execLaunchctl(["kickstart", serviceTarget]); - if (kick.code !== 0) { - return { - ok: false, - status: "kickstart-failed", - detail: (kick.stderr || kick.stdout).trim() || undefined, - }; - } - return { ok: true, status: repairStatus }; -} - -export async function uninstallLaunchAgent({ - env, - stdout, -}: GatewayServiceManageArgs): Promise { - assertExternalLaunchAgentMutation(env, "uninstall"); - const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel(env); - const plistPath = resolveLaunchAgentPlistPath(env); - const bootout = await execLaunchctl(["bootout", domain, plistPath]); - if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { - throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`); - } - - try { - await fs.lstat(plistPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw createLaunchAgentRemovalError(error); - } - stdout.write(`LaunchAgent not found at ${plistPath}\n`); - return; - } - - const home = toPosixPath(resolveDaemonHomeDir(env)); - const trashDir = path.posix.join(home, ".Trash"); - const dest = path.join(trashDir, `${label}.plist`); - try { - await fs.mkdir(trashDir, { recursive: true }); - await fs.rename(plistPath, dest); - stdout.write(`${formatLine("Moved LaunchAgent to Trash", dest)}\n`); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - try { - await fs.lstat(plistPath); - } catch (accessError) { - if ((accessError as NodeJS.ErrnoException).code === "ENOENT") { - stdout.write(`LaunchAgent not found at ${plistPath}\n`); - return; - } - throw createLaunchAgentRemovalError(accessError); - } - } - throw createLaunchAgentRemovalError(error); - } -} - -function createLaunchAgentRemovalError(error: unknown): Error { - const code = (error as NodeJS.ErrnoException).code; - return new Error( - `LaunchAgent removal failed${code ? ` (${code})` : ""}. Check permissions and retry.`, - ); -} - -function isUnsupportedGuiDomain(detail: string): boolean { - const normalized = normalizeLowercaseStringOrEmpty(detail); - return ( - normalized.includes("domain does not support specified action") || - normalized.includes("could not find domain for user gui") || - normalized.includes("bootstrap failed: 125") - ); -} - -function isLaunchctlOperationAlreadyInProgress(detail: string): boolean { - const normalized = normalizeLowercaseStringOrEmpty(detail); - return ( - normalized.includes("operation already in progress") || - normalized.includes("bootstrap failed: 37") - ); -} - -function isLaunchctlBootstrapPendingTeardown(res: { - stdout: string; - stderr: string; - code: number; -}): boolean { - // `bootout` returns once launchd accepts the request, not once the job is gone, - // so bootstrapping the same label mid-teardown answers EIO. The plist is valid - // here, so this is a timing conflict to retry rather than a real I/O fault. - // - // launchd answers the same EIO for a label that is simply still registered - // ("already exists in domain"). That job is not tearing down, so waiting for a - // teardown that never comes only delays the failure. - if (isLaunchctlAlreadyLoaded(res)) { - return false; - } - const normalized = normalizeLowercaseStringOrEmpty(res.stderr || res.stdout); - return normalized.includes("bootstrap failed: 5") || normalized.includes("input/output error"); -} - -async function bootoutLaunchAgentOrThrow(params: { - serviceTarget: string; - warning: string; - stdout: NodeJS.WritableStream; - onMutation?: () => void; -}): Promise { - const bootout = await execLaunchctl(["bootout", params.serviceTarget]); - if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { - throw new Error( - `${params.warning}; launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`, - ); - } - params.onMutation?.(); - params.stdout.write(`${formatLine("Warning", params.warning)}\n`); -} - -type LaunchAgentProbeResult = - | { state: "running" } - | { state: "stopped" } - | { state: "not-loaded" } - | { state: "unknown"; detail?: string }; - -async function probeLaunchAgentState(serviceTarget: string): Promise { - // `launchctl print` output is not a stable API, so this is only a stop - // confirmation probe. Unknown output falls back to bootout instead of success. - const probe = await execLaunchctl(["print", serviceTarget]); - if (probe.code !== 0) { - if (isLaunchctlNotLoaded(probe)) { - return { state: "not-loaded" }; - } - return { - state: "unknown", - detail: formatLaunchctlResultDetail(probe) || undefined, - }; - } - const runtime = parseLaunchctlPrint(probe.stdout || probe.stderr || ""); - if ( - normalizeLowercaseStringOrEmpty(runtime.state) === "running" || - (typeof runtime.pid === "number" && runtime.pid > 1) - ) { - return { state: "running" }; - } - return { state: "stopped" }; -} - -async function waitForLaunchAgentStopped(serviceTarget: string): Promise { - let lastUnknown: LaunchAgentProbeResult | null = null; - for (let attempt = 0; attempt < 10; attempt += 1) { - const probe = await probeLaunchAgentState(serviceTarget); - if (probe.state === "stopped" || probe.state === "not-loaded") { - return probe; - } - if (probe.state === "unknown") { - lastUnknown = probe; - } - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - } - return lastUnknown ?? { state: "running" }; -} - -async function waitForGatewayPortRelease( - port: number, - probeHosts: readonly string[], -): Promise { - const deadline = Date.now() + LAUNCH_AGENT_STOP_PORT_RELEASE_TIMEOUT_MS; - while (Date.now() < deadline) { - await sleep(Math.min(LAUNCH_AGENT_STOP_PORT_RELEASE_POLL_MS, deadline - Date.now())); - const status = await probePortUsage(port, probeHosts); - if (status === "free") { - return true; - } - } - return false; -} - -async function assertGatewayPortReleasedAfterStop(env: GatewayServiceEnv): Promise { - const { port, probeHosts } = await resolveLaunchAgentGatewayContext(env); - if (port === null) { - return; - } - cleanStaleGatewayProcessesSync(port); - const diagnostics = await inspectPortUsage(port, { - probeHosts, - }).catch(() => null); - if (diagnostics?.status !== "busy") { - return; - } - if (await waitForGatewayPortRelease(port, probeHosts)) { - return; - } - throw new Error( - [ - `gateway port ${port} is still busy after LaunchAgent stop`, - ...formatPortDiagnostics(diagnostics), - ].join("\n"), - ); -} - -export async function stopLaunchAgent({ - stdout, - env, - disable: persistDisable, - onMutation, -}: GatewayServiceControlArgs): Promise { - const serviceEnv = env ?? (process.env as GatewayServiceEnv); - const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel(serviceEnv); - const serviceTarget = `${domain}/${label}`; - const reportMutation = createGatewayLifecycleMutationReporter(onMutation); - - if ( - isCurrentProcessLaunchdServiceLabel(label, process.env, { allowConfiguredLabelFallback: false }) - ) { - throw new Error( - `Refusing to stop LaunchAgent ${label} from inside the same launchd service; run this command from an external shell.`, - ); - } - - if (!persistDisable) { - // Default: bootout only. Removes the job from the current launchd domain without - // persisting a disable, so KeepAlive auto-recovery survives future crashes and - // `openclaw gateway start` re-enables cleanly without a manual `launchctl enable`. - const bootout = await execLaunchctl(["bootout", serviceTarget]); - if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { - throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`); - } - reportMutation("bootout"); - await assertGatewayPortReleasedAfterStop(serviceEnv); - stdout.write(`${formatLine("Stopped LaunchAgent", serviceTarget)}\n`); - return; - } - - // --disable: persistently suppress KeepAlive/RunAtLoad before stopping. - // Without this, launchd can relaunch the process as soon as `stop` exits. - const disableResult = await execLaunchctl(["disable", serviceTarget]); - if (disableResult.code !== 0) { - await bootoutLaunchAgentOrThrow({ - serviceTarget, - stdout, - warning: `launchctl disable failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(disableResult)}`, - onMutation: () => reportMutation("disable-bootout"), - }); - await assertGatewayPortReleasedAfterStop(serviceEnv); - stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`); - return; - } - reportMutation("disable"); - - // `launchctl stop` targets the plain label (not the fully-qualified service target). - const stop = await execLaunchctl(["stop", label]); - if (stop.code !== 0 && !isLaunchctlNotLoaded(stop)) { - await bootoutLaunchAgentOrThrow({ - serviceTarget, - stdout, - warning: `launchctl stop failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(stop)}`, - onMutation: () => reportMutation("disable-bootout"), - }); - await assertGatewayPortReleasedAfterStop(serviceEnv); - stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`); - return; - } - - reportMutation("disable-stop"); - - const stopState = await waitForLaunchAgentStopped(serviceTarget); - if (stopState.state !== "stopped" && stopState.state !== "not-loaded") { - const warning = - stopState.state === "unknown" - ? `launchctl print could not confirm stop; used bootout fallback and left service unloaded: ${stopState.detail ?? "unknown error"}` - : "launchctl stop did not fully stop the service; used bootout fallback and left service unloaded"; - await bootoutLaunchAgentOrThrow({ - serviceTarget, - stdout, - warning, - onMutation: () => reportMutation("disable-bootout"), - }); - await assertGatewayPortReleasedAfterStop(serviceEnv); - stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`); - return; - } - - await assertGatewayPortReleasedAfterStop(serviceEnv); - stdout.write(`${formatLine("Stopped LaunchAgent", serviceTarget)}\n`); -} - -export async function parkCurrentLaunchAgentForMaintenance( - params: { - env?: GatewayServiceEnv; - } = {}, -): Promise { - const serviceEnv = params.env ?? (process.env as GatewayServiceEnv); - const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel(serviceEnv); - if ( - !isCurrentProcessLaunchdServiceLabel(label, process.env, { - allowConfiguredLabelFallback: false, - }) - ) { - return false; - } - const serviceTarget = `${domain}/${label}`; - // Disable before exit so KeepAlive cannot spawn a replacement before the - // detached handoff can boot the current job out of launchd. - const disable = await execLaunchctl(["disable", serviceTarget]); - if (disable.code !== 0) { - throw new Error( - `launchctl disable failed while parking ${serviceTarget}: ${formatLaunchctlResultDetail(disable)}`, - ); - } - const handoff = scheduleDetachedLaunchdMaintenancePark({ - env: serviceEnv, - waitForPid: process.pid, - }); - const handoffError = !handoff.ok - ? handoff.error - : (await handoff.value) - ? undefined - : "helper failed to spawn"; - if (handoffError) { - const rollback = await execLaunchctl(["enable", serviceTarget]); - const rollbackDetail = - rollback.code === 0 - ? "restored launchd enable state" - : `launchctl enable rollback failed: ${formatLaunchctlResultDetail(rollback)}`; - throw new Error(`launchd maintenance park handoff failed: ${handoffError}; ${rollbackDetail}`); - } - return true; -} - -async function writeLaunchAgentPlist({ - env, - programArguments, - workingDirectory, - environment, - description, - stdout, - warn, -}: GatewayServiceInstallArgs): Promise<{ plistPath: string; stdoutPath: string }> { - const label = resolveLaunchAgentLabel(env); - await assertNoSystemLaunchDaemonOwnership(label); - - const { logDir, stdoutPath } = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" }); - await ensureSecureDirectory(logDir); - - const plistPath = resolveLaunchAgentPlistPathForLabel(env, label); - const home = toPosixPath(resolveDaemonHomeDir(env)); - const libraryDir = path.posix.join(home, "Library"); - await ensureSecureDirectory(home); - await ensureSecureDirectory(libraryDir); - await ensureSecureDirectory(path.dirname(plistPath)); - await ensureLaunchAgentEnvironmentDirectories(environment); - const prepared = await prepareLaunchAgentProgramArguments({ - env, - label, - programArguments, - environment, - stdout, - warn, - }); - - const serviceDescription = resolveGatewayServiceDescription({ env, description }); - const plist = buildLaunchAgentPlist({ - label, - comment: serviceDescription, - programArguments: prepared.programArguments, - workingDirectory, - stdoutPath, - stderrPath: LAUNCH_AGENT_STDERR_PATH, - environment: prepared.inlineEnvironment, - }); - await publishLaunchAgentPlist({ label, plistPath, contents: plist }); - return { plistPath, stdoutPath }; -} - -function currentGatewayLaunchAgentLabel( - targetEnv: Record, -): string | undefined { - const configuredCurrentLabel = process.env.OPENCLAW_LAUNCHD_LABEL?.trim(); - const candidates = new Set([ - resolveLaunchAgentLabel(targetEnv), - ...(configuredCurrentLabel ? [assertValidLaunchAgentLabel(configuredCurrentLabel)] : []), - ]); - return [...candidates].find((label) => - isCurrentProcessLaunchdServiceLabel(label, process.env, { - allowConfiguredLabelFallback: false, - }), - ); -} - -function assertExternalLaunchAgentMutation( - env: Record, - action: "install" | "uninstall", -): void { - const currentLabel = currentGatewayLaunchAgentLabel(env); - if (!currentLabel) { - return; - } - throw new Error( - `Refusing to ${action} LaunchAgent ${resolveLaunchAgentLabel(env)} from inside ${currentLabel}; run this command from an external shell.`, - ); -} - -export async function stageLaunchAgent({ - stdout, - ...args -}: GatewayServiceInstallArgs): Promise<{ plistPath: string }> { - const { plistPath, stdoutPath } = await writeLaunchAgentPlist({ ...args, stdout }); - writeFormattedLines( - stdout, - [ - { label: "Staged LaunchAgent", value: plistPath }, - { label: "Logs", value: stdoutPath }, - ], - { leadingBlankLine: true }, - ); - return { plistPath }; -} - -type LaunchAgentInstallSnapshot = { - plistContents: Buffer | null; - envFileContents: Buffer | null; - wrapperContents: Buffer | null; - legacy: Array<{ - label: string; - plistPath: string; - contents: Buffer | null; - loaded: boolean; - }>; - loaded: boolean; -}; - -async function snapshotLaunchAgentLoadedState( - plistContents: Buffer | null, - serviceTarget: string, -): Promise { - const probe = await probeLaunchAgentState(serviceTarget); - if (probe.state === "unknown") { - throw new Error( - `launchctl print could not determine whether ${serviceTarget} is loaded: ${probe.detail ?? "unknown error"}`, - ); - } - const loaded = probe.state !== "not-loaded"; - if (loaded && plistContents === null) { - // launchd can retain a definition after its plist is deleted. Booting that - // job out would destroy the only copy, so no exact rollback is possible. - throw new Error( - `LaunchAgent ${serviceTarget} is loaded but its plist is missing; refusing an install that cannot restore the current definition if activation fails.`, - ); - } - return loaded; -} - -async function restoreLaunchAgentOwnedFile(params: { - path: string; - contents: Buffer | null; - mode: number; -}): Promise { - if (params.contents === null) { - await fs.unlink(params.path).catch((error: unknown) => { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - }); - return; - } - const temporaryPath = `${params.path}.openclaw-${randomUUID()}.rollback`; - try { - await fs.writeFile(temporaryPath, params.contents.toString("utf8"), { - flag: "wx", - mode: params.mode, - }); - await fs.rename(temporaryPath, params.path); - await fs.chmod(params.path, params.mode).catch(() => undefined); - } finally { - await fs.unlink(temporaryPath).catch(() => undefined); - } -} - -async function restoreLaunchAgentInstallArtifacts(params: { - env: GatewayServiceEnv; - label: string; - plistPath: string; - snapshot: LaunchAgentInstallSnapshot; -}): Promise { - await restoreLaunchAgentOwnedFile({ - path: resolveLaunchAgentEnvFilePath(params.env, params.label), - contents: params.snapshot.envFileContents, - mode: LAUNCH_AGENT_ENV_FILE_MODE, - }); - await restoreLaunchAgentOwnedFile({ - path: resolveLaunchAgentEnvWrapperPath(params.env, params.label), - contents: params.snapshot.wrapperContents, - mode: LAUNCH_AGENT_ENV_WRAPPER_MODE, - }); - for (const legacy of params.snapshot.legacy) { - await restoreLaunchAgentOwnedFile({ - path: legacy.plistPath, - contents: legacy.contents, - mode: LAUNCH_AGENT_PLIST_MODE, - }); - } - if (params.snapshot.plistContents === null) { - await fs.unlink(params.plistPath).catch((error: unknown) => { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - }); - return; - } - await publishLaunchAgentPlist({ - label: params.label, - plistPath: params.plistPath, - contents: params.snapshot.plistContents.toString("utf8"), - }); -} - -async function restoreLaunchAgentInstall(params: { - domain: string; - env: GatewayServiceEnv; - label: string; - plistPath: string; - snapshot: LaunchAgentInstallSnapshot; -}): Promise { - const serviceTarget = `${params.domain}/${params.label}`; - // A failed bootstrap may leave no registered job. Restore files directly in - // that state; only a loaded replacement must be removed before rollback. - const currentState = await probeLaunchAgentState(serviceTarget); - if (currentState.state === "unknown") { - throw new Error( - `launchctl print could not determine whether ${serviceTarget} is loaded during LaunchAgent rollback: ${currentState.detail ?? "unknown error"}`, - ); - } - if (currentState.state !== "not-loaded") { - const bootout = await execLaunchctl(["bootout", serviceTarget]); - if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { - throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`); - } - } - await restoreLaunchAgentInstallArtifacts({ - env: params.env, - label: params.label, - plistPath: params.plistPath, - snapshot: params.snapshot, - }); - if (params.snapshot.loaded && params.snapshot.plistContents !== null) { - await bootstrapLaunchAgentOrThrow({ - domain: params.domain, - serviceTarget, - plistPath: params.plistPath, - actionHint: "openclaw gateway start", - retryPendingTeardown: true, - }); - } - for (const legacy of params.snapshot.legacy) { - if (!legacy.loaded || legacy.contents === null) { - continue; - } - await bootstrapLaunchAgentOrThrow({ - domain: params.domain, - serviceTarget: `${params.domain}/${legacy.label}`, - plistPath: legacy.plistPath, - actionHint: "openclaw gateway start", - retryPendingTeardown: true, - }); - } -} - -async function deactivateLaunchAgentDefinition(domain: string, plistPath: string): Promise { - for (const args of [ - ["bootout", domain, plistPath], - ["unload", plistPath], - ]) { - const result = await execLaunchctl(args); - if (result.code !== 0 && !isLaunchctlNotLoaded(result)) { - throw new Error( - `launchctl ${args[0]} failed during LaunchAgent install: ${formatLaunchctlResultDetail(result)}`, - ); - } - } -} - -async function activateLaunchAgent(params: { - env: GatewayServiceEnv; - plistPath: string; - snapshot: LaunchAgentInstallSnapshot; -}) { - const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel(params.env); - try { - // Recheck immediately before activation so a system daemon installed after - // the plist write cannot race us into two KeepAlive managers. - await assertNoSystemLaunchDaemonOwnership(label); - for (const legacy of params.snapshot.legacy) { - if (legacy.loaded) { - await deactivateLaunchAgentDefinition(domain, legacy.plistPath); - } - } - // Plist-form bootout reports EIO for a valid definition that was never loaded. - // The pre-publication snapshot is the authoritative cutover fact. - if (params.snapshot.loaded) { - await deactivateLaunchAgentDefinition(domain, params.plistPath); - } - // launchd can persist "disabled" state even after bootout + plist removal; clear it before bootstrap. - await bootstrapLaunchAgentOrThrow({ - domain, - serviceTarget: `${domain}/${label}`, - plistPath: params.plistPath, - actionHint: "openclaw gateway install --force", - retryPendingTeardown: true, - }); - for (const legacy of params.snapshot.legacy) { - await fs.unlink(legacy.plistPath).catch((error: unknown) => { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - }); - } - } catch (error) { - try { - await restoreLaunchAgentInstall({ - domain, - env: params.env, - label, - plistPath: params.plistPath, - snapshot: params.snapshot, - }); - } catch (rollbackError) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`${detail}\nThe previous LaunchAgent supervision could not be restored.`, { - cause: rollbackError, - }); - } - throw error; - } -} - -export async function installLaunchAgent( - args: GatewayServiceInstallArgs, -): Promise<{ plistPath: string }> { - assertExternalLaunchAgentMutation(args.env, "install"); - const targetPlistPath = resolveLaunchAgentPlistPath(args.env); - const previousContents = await readExistingLaunchAgentPlist(targetPlistPath); - const label = resolveLaunchAgentLabel(args.env); - const domain = resolveGuiDomain(); - // Plist, generated environment files, and launchd registration form one cutover. - // Capture every prior owner before publication so any later failure can restore it. - const legacy = await Promise.all( - resolveLegacyGatewayLaunchAgentLabels(args.env.OPENCLAW_PROFILE).map(async (legacyLabel) => { - const plistPath = resolveLaunchAgentPlistPathForLabel(args.env, legacyLabel); - const contents = await readExistingLaunchAgentPlist(plistPath); - return { - label: legacyLabel, - plistPath, - contents, - loaded: await snapshotLaunchAgentLoadedState(contents, `${domain}/${legacyLabel}`), - }; - }), - ); - const snapshot: LaunchAgentInstallSnapshot = { - plistContents: previousContents, - envFileContents: await readExistingLaunchAgentPlist( - resolveLaunchAgentEnvFilePath(args.env, label), - ), - wrapperContents: await readExistingLaunchAgentPlist( - resolveLaunchAgentEnvWrapperPath(args.env, label), - ), - legacy, - loaded: await snapshotLaunchAgentLoadedState(previousContents, `${domain}/${label}`), - }; - let plistPath: string; - let stdoutPath: string; - try { - ({ plistPath, stdoutPath } = await writeLaunchAgentPlist(args)); - } catch (error) { - try { - await restoreLaunchAgentInstallArtifacts({ - env: args.env, - label, - plistPath: targetPlistPath, - snapshot, - }); - } catch (rollbackError) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`${detail}\nThe previous LaunchAgent files could not be restored.`, { - cause: rollbackError, - }); - } - throw error; - } - await activateLaunchAgent({ - env: args.env, - plistPath, - snapshot, - }); - // `bootstrap` already loads RunAtLoad agents. Avoid `kickstart -k` here: - // on slow macOS guests it SIGTERMs the freshly booted gateway and pushes the - // real listener startup past setup's health deadline. - writeFormattedLines( - args.stdout, - [ - { label: "Installed LaunchAgent", value: plistPath }, - { label: "Logs", value: stdoutPath }, - ], - { leadingBlankLine: true }, - ); - return { plistPath }; -} - -async function rewriteLaunchAgentPlistForRestart({ - env, - label, - plistPath, - stdout, - warn, -}: { - env: GatewayServiceEnv; - label: string; - plistPath: string; - stdout?: NodeJS.WritableStream; - warn?: (message: string) => void; -}): Promise { - const existing = await readLaunchAgentProgramArgumentsFromFile( - plistPath, - resolveLaunchAgentEnvironmentReadOptions(env, label), - ); - if (!existing?.programArguments.length) { - return false; - } - - const { logDir, stdoutPath } = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" }); - await ensureSecureDirectory(logDir); - - const serviceDescription = resolveGatewayServiceDescription({ - env, - }); - // Restart rewrites must retire install provenance from legacy plists instead - // of copying it into the next canonical definition. - const canonicalEnvironment = { - ...existing.environment, - OPENCLAW_SERVICE_VERSION: undefined, - }; - const prepared = await prepareLaunchAgentProgramArguments({ - env, - label, - programArguments: existing.programArguments, - environment: canonicalEnvironment, - stdout, - warn, - }); - const plist = buildLaunchAgentPlist({ - label, - comment: serviceDescription, - programArguments: prepared.programArguments, - workingDirectory: existing.workingDirectory, - stdoutPath, - stderrPath: LAUNCH_AGENT_STDERR_PATH, - environment: prepared.inlineEnvironment, - }); - const previousPlist = await fs.readFile(plistPath, "utf8").catch(() => ""); - if (previousPlist === plist) { - await ensureLaunchAgentPlistReadable(plistPath); - return false; - } - await publishLaunchAgentPlist({ label, plistPath, contents: plist }); - return true; -} - -type LaunchAgentRestoreResult = { loaded: true } | { loaded: false; detail: string }; - -async function ensureLaunchAgentLoadedAfterFailure(params: { - domain: string; - serviceTarget: string; - plistPath: string; - onMutation?: (mode: "enable" | "bootstrap") => void; -}): Promise { - const probe = await execLaunchctl(["print", params.serviceTarget]); - if (probe.code === 0) { - return { loaded: true }; - } - try { - await bootstrapLaunchAgentOrThrow({ - domain: params.domain, - serviceTarget: params.serviceTarget, - plistPath: params.plistPath, - actionHint: "openclaw gateway start", - onMutation: params.onMutation, - }); - return { loaded: true }; - } catch (error) { - // A failed restore is not recoverable by launchd: the label is gone, so - // KeepAlive has nothing to respawn. Report it instead of dropping it. - return { loaded: false, detail: error instanceof Error ? error.message : String(error) }; - } -} - -function formatLaunchAgentLeftUnloadedError(params: { - domain: string; - serviceTarget: string; - plistPath: string; - failure: string; - restoreDetail: string; -}): string { - return [ - params.failure, - `LaunchAgent ${params.serviceTarget} is not loaded and could not be restored: ${params.restoreDetail}`, - "The gateway is down and launchd has no job left to respawn it.", - `Fix: run \`openclaw gateway start\`, or \`launchctl bootstrap ${params.domain} ${params.plistPath}\`.`, - ].join("\n"); -} - -export async function startLaunchAgent({ - stdout, - env, - onMutation, -}: GatewayServiceControlArgs): Promise { - const serviceEnv = env ?? (process.env as GatewayServiceEnv); - const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel(serviceEnv); - const plistPath = resolveLaunchAgentPlistPath(serviceEnv); - const serviceTarget = `${domain}/${label}`; - const reportMutation = createGatewayLifecycleMutationReporter(onMutation); - await assertNoSystemLaunchDaemonOwnership(label); - - // Enable is an independent mutation; audit it even if the later launch fails. - const enable = await execLaunchctl(["enable", serviceTarget]); - const enabled = enable.code === 0; - if (enabled) { - reportMutation("enable"); - } - - const start = await execLaunchctl(["kickstart", serviceTarget]); - if (start.code === 0) { - reportMutation("kickstart"); - } else if (isLaunchctlNotLoaded(start)) { - await bootstrapLaunchAgentOrThrow({ - domain, - serviceTarget, - plistPath, - actionHint: "openclaw gateway start", - onMutation: reportMutation, - skipEnable: enabled, - }); - } else { - throw new Error(`launchctl kickstart failed: ${start.stderr || start.stdout}`.trim()); - } - - writeLaunchAgentActionLine(stdout, "Started LaunchAgent", serviceTarget); -} - -export async function restartLaunchAgent({ - stdout, - env, - warn, - onMutation, -}: GatewayServiceControlArgs): Promise { - const serviceEnv = env ?? (process.env as GatewayServiceEnv); - const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel(serviceEnv); - const plistPath = resolveLaunchAgentPlistPath(serviceEnv); - const serviceTarget = `${domain}/${label}`; - const reportMutation = createGatewayLifecycleMutationReporter(onMutation); - await assertNoSystemLaunchDaemonOwnership(label); - - // Restart requests issued from inside the managed gateway process tree need a - // detached handoff. A direct `kickstart -k` would terminate the caller before - // it can finish the restart command. - if (isCurrentProcessLaunchdServiceLabel(label)) { - const plistReloadNeeded = await rewriteLaunchAgentPlistForRestart({ - env: serviceEnv, - label, - plistPath, - stdout, - warn, - }); - const handoff = scheduleDetachedLaunchdRestartHandoff({ - env: serviceEnv, - mode: plistReloadNeeded ? "reload" : "kickstart", - waitForPid: process.pid, - }); - if (!handoff.ok) { - throw new Error(`launchd restart handoff failed: ${handoff.error}`); - } - reportMutation(plistReloadNeeded ? "handoff-reload" : "handoff-kickstart"); - writeLaunchAgentActionLine(stdout, "Scheduled LaunchAgent restart", serviceTarget); - return { outcome: "scheduled" }; - } - - const { port: cleanupPort, probeHosts } = await resolveLaunchAgentGatewayContext(serviceEnv); - if (cleanupPort !== null) { - cleanStaleGatewayProcessesSync(cleanupPort, { - // Resolve after lsof captures its listener snapshot. A KeepAlive respawn - // during enumeration must be protected before candidate filtering/signals. - resolveProtectedPid: () => readLaunchAgentPidForCleanupSync(serviceTarget), - }); - const diagnostics = await inspectPortUsage(cleanupPort, { - probeHosts, - }).catch(() => null); - if (diagnostics?.status === "busy") { - const runtime = await readLaunchAgentRuntime(serviceEnv); - const managedPid = runtime.pid; - // Only the current supervised PID may keep the port busy before a - // disruptive restart. Re-read after cleanup to close over a concurrent - // launchd respawn rather than trusting the protected pre-cleanup PID. - const ownedByLaunchAgent = - managedPid !== undefined && - diagnostics.listeners.length > 0 && - diagnostics.listeners.every((listener) => listener.pid === managedPid); - if (!ownedByLaunchAgent) { - throw new Error( - [ - `gateway port ${cleanupPort} is busy but is not verifiably owned by LaunchAgent ${label}`, - ...formatPortDiagnostics(diagnostics), - ].join("\n"), - ); - } - } - } - const plistReloadNeeded = await rewriteLaunchAgentPlistForRestart({ - env: serviceEnv, - label, - plistPath, - stdout, - warn, - }); - - // `openclaw gateway restart` is an explicit operator request to bring the - // LaunchAgent back, so clear any persisted disabled state before restart. - const enable = await execLaunchctl(["enable", serviceTarget]); - if (enable.code === 0) { - reportMutation("enable"); - } - - if (plistReloadNeeded) { - const bootout = await execLaunchctl(["bootout", serviceTarget]); - if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { - throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`); - } - if (bootout.code === 0) { - reportMutation("bootout"); - } - try { - await bootstrapLaunchAgentOrThrow({ - domain, - serviceTarget, - plistPath, - actionHint: "openclaw gateway restart", - onMutation: reportMutation, - retryPendingTeardown: true, - }); - } catch (error) { - // bootout already removed the job from the domain, so a failed bootstrap - // leaves the gateway down with no KeepAlive respawn to recover it. Restore - // the job before surfacing the original failure, as the kickstart path does. - const restored = await ensureLaunchAgentLoadedAfterFailure({ - domain, - serviceTarget, - plistPath, - onMutation: reportMutation, - }); - if (restored.loaded) { - throw error; - } - throw new Error( - formatLaunchAgentLeftUnloadedError({ - domain, - serviceTarget, - plistPath, - failure: error instanceof Error ? error.message : String(error), - restoreDetail: restored.detail, - }), - { cause: error }, - ); - } - writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget); - return { outcome: "completed" }; - } - - const start = await execLaunchctl(["kickstart", "-k", serviceTarget]); - if (start.code === 0) { - reportMutation("kickstart"); - writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget); - return { outcome: "completed" }; - } - - if (!isLaunchctlNotLoaded(start)) { - const restored = await ensureLaunchAgentLoadedAfterFailure({ - domain, - serviceTarget, - plistPath, - onMutation: reportMutation, - }); - const failure = `launchctl kickstart failed: ${start.stderr || start.stdout}`.trim(); - if (restored.loaded) { - throw new Error(failure); - } - throw new Error( - formatLaunchAgentLeftUnloadedError({ - domain, - serviceTarget, - plistPath, - failure, - restoreDetail: restored.detail, - }), - ); - } - - // If the service was previously booted out, re-register the rewritten plist and retry. - await bootstrapLaunchAgentOrThrow({ - domain, - serviceTarget, - plistPath, - actionHint: "openclaw gateway restart", - onMutation: reportMutation, - }); - writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget); - return { outcome: "completed" }; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ +export { + formatLaunchAgentGuiSessionError, + isLaunchAgentEnabled, + isLaunchAgentLoaded, + launchAgentPlistExists, + parseLaunchAgentEnabled, + parseLaunchctlPrint, + readLaunchAgentRuntime, +} from "./launchd-runtime.js"; +export { + readLaunchAgentProgramArguments, + resolveLaunchAgentPlistPath, +} from "./launchd-service-files.js"; +export { parkCurrentLaunchAgentForMaintenance, stopLaunchAgent } from "./launchd-stop.js"; +export { + disableCurrentOpenClawUpdateLaunchdJob, + disableOpenClawUpdateLaunchdJob, + findStaleOpenClawUpdateLaunchdJobs, + parseLaunchctlListOpenClawUpdateJobs, + type StaleOpenClawUpdateLaunchdJob, +} from "./launchd-update-jobs.js";