/** * The macOS adapter for `cotal daemon install`: a LaunchAgent in the signed-in user's GUI domain. * * The plist goes to `~/Library/LaunchAgents`, the per-user agents directory launchd loads at login, * and `launchctl bootstrap gui/` loads it now. `RunAtLoad` starts the daemon whenever the job * is loaded, at install and at each login, and `KeepAlive` starts it again whenever it exits, spaced * by launchd's default ten-second throttle. Its output goes to `daemon.log` in the daemon's own * state directory. It runs in the user's session, so it never needs root. * * Ownership is the receipt's. The plist is created at an absent name and its digest recorded before * launchd sees it, and a label launchd already has refuses the install. `uninstall` boots out the * label only while that exact plist is unchanged. `launchctl print` output is documented as not an * interface, so it is never parsed; only its exit status is read, where 113 means the domain has no * such service. * * A LaunchDaemon in the system domain is the separate headless profile and is not offered here. */ import { createHash } from "node:crypto"; import { lstatSync, readFileSync, rmSync } from "node:fs"; import { homedir, userInfo } from "node:os"; import { basename, isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import { ensureDirNoSymlink, realDirNoSymlink } from "@cotal-ai/core"; import { darwinLaunchdJob, type DarwinLaunchdLookup } from "@cotal-ai/seat"; import { CommandFailure } from "@cotal-ai/workspace"; import { assertSystemdSource, createSystemdSource } from "@cotal-ai/cli"; import type { NativePlan, NativeRun, NativeService, UnitReceipt } from "./install.js"; const LAUNCHCTL = "/bin/launchctl"; /** `launchctl print`'s exit status when the domain has no service with that label. */ const NOT_LOADED = 113; /** `ai.cotal.daemon`, or `ai.cotal.daemon.<16 hex>` for an explicit daemon home, derived as its * socket directory is. */ export function launchAgentLabel(daemonHome: string | undefined): string { if (daemonHome === undefined) return "ai.cotal.daemon"; return `ai.cotal.daemon.${createHash("sha256").update(daemonHome).digest("hex").slice(0, 16)}`; } /** XML character data: bounded, without controls or code points XML cannot carry, and escaped. */ export function xmlText(value: string): string { if (!value || value.length > 4096 || /[\u0000-\u001f\u007f]/u.test(value) || [...value].some((scalar) => { const cp = scalar.codePointAt(0)!; return cp >= 0xd800 && cp <= 0xdfff || cp === 0xfffe || cp === 0xffff; })) throw new CommandFailure("refused", "USAGE_INVALID", "the daemon job needs bounded XML text without control or invalid Unicode characters; no job bytes were prepared"); return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } export interface LaunchAgentInput { readonly label: string; readonly argv: readonly string[]; readonly searchPath: string; readonly workingDirectory: string; readonly logPath: string; } /** The plist this install writes. launchd execs `ProgramArguments` directly, with no shell. */ export function renderLaunchAgent(input: LaunchAgentInput): { text: string; sha256: string } { if ([input.argv[0] ?? "", input.workingDirectory, input.logPath].some((path) => !isAbsolute(path))) throw new CommandFailure("refused", "USAGE_INVALID", "the LaunchAgent needs absolute executable, working and log paths"); const str = (value: string) => `${xmlText(value)}`; input.argv.forEach(xmlText); const daemon = input.argv.indexOf("daemon"); const cli = input.argv[daemon - 1]; if (daemon < 2 || input.argv[daemon + 1] !== "serve" || !cli || !isAbsolute(cli) || !input.argv.slice(1, daemon - 1).every((flag) => /^--[A-Za-z][A-Za-z0-9-]*(?:=[^\s"]*)?$/.test(flag))) { throw new CommandFailure("refused", "UNSUPPORTED", "the LaunchAgent needs the durable Node daemon serve entry; no launcher was substituted"); } // launchd's job dictionary omits WorkingDirectory. Carry it in the exact argument vector instead; // this entry changes directory before importing the CLI, including any cwd-sensitive setup. // Resolve our entry from this module, not argv's CLI symlink (a global install may live in bin/). const bootstrap = fileURLToPath(new URL("../daemon-cwd.js", import.meta.url)); const argv = [...input.argv.slice(0, daemon - 1), bootstrap, input.workingDirectory, ...input.argv.slice(daemon)]; const entry = (key: string, value: string) => `${key}${str(value)}`; const text = [ '', '', '', entry("Label", input.label), `ProgramArguments${argv.map(str).join("")}`, `EnvironmentVariables${entry("PATH", input.searchPath)}${entry("COTAL_DAEMON_NATIVE_SERVICE", "1")}`, entry("StandardOutPath", input.logPath), entry("StandardErrorPath", input.logPath), "RunAtLoad", "ExitTimeOut90", "KeepAlive", "", "", ].join("\n"); if (Buffer.byteLength(text) > 64 * 1024) throw new CommandFailure("refused", "USAGE_INVALID", "the LaunchAgent exceeds its bounded source size"); return { text, sha256: createHash("sha256").update(text).digest("hex") }; } /** A boolean key's value in the bytes that will be written, so a receipt never states a policy the * plist does not carry. */ function plistFlag(text: string, key: string): string { const value = new RegExp(`${key}<(true|false)/>`).exec(text)?.[1]; return value === undefined ? "absent" : `${key}=${value}`; } function uid(): number { const id = process.getuid?.(); if (id === undefined) throw new CommandFailure("refused", "UNSUPPORTED", "this platform has no user id for a launchd GUI domain"); if (id === 0) throw new CommandFailure("refused", "TARGET_UNAVAILABLE", "a LaunchAgent runs in the signed-in user's GUI domain, and this is root; nothing was written", { remedy: "run \`cotal daemon install\` as the desktop user who will run the daemon, without sudo" }); return id; } /** The user's real home, owned by them, with its real `Library`. */ function libraryOf(id: number): string { const home = homedir(); let stat: ReturnType; try { stat = lstatSync(home); } catch { throw new CommandFailure("refused", "TARGET_UNAVAILABLE", "the macOS account home could not be inspected; no LaunchAgent location was adopted"); } if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== id) throw new CommandFailure("refused", "CONFLICT", "the macOS account home is not a real directory owned by this user; no LaunchAgent location was adopted"); let library: string | null; try { library = realDirNoSymlink(home, "Library"); } catch { library = null; } if (!library) throw new CommandFailure("refused", "CONFLICT", "the macOS account has no real Library directory; no LaunchAgent location was adopted"); return library; } export function launchAgentService(run: NativeRun, lookup: (label: string) => DarwinLaunchdLookup = darwinLaunchdJob): NativeService { const loaded = (id: number, label: string): boolean => { const read = run(LAUNCHCTL, ["print", `gui/${id}/${label}`]); if (read.status === 0) return true; if (read.status === NOT_LOADED) return false; throw new CommandFailure("refused", "TARGET_UNAVAILABLE", `launchd's GUI domain gui/${id} did not answer for ${label} (${read.detail}); nothing was changed`, { remedy: "run this from a logged-in macOS desktop session of the user who will run the daemon" }); }; const step = (args: readonly string[]): void => { const result = run(LAUNCHCTL, args); if (result.status !== 0) throw new CommandFailure("unknown", "OPERATION_UNKNOWN", `launchctl ${args.join(" ")} did not confirm completion (${result.detail}); it was not retried`, { remedy: "inspect it with launchctl print, then remove the recorded plist with \`cotal daemon uninstall\`" }); }; /** Whether the recorded job is loaded, refused unless it is this daemon's label and unchanged plist. */ const owned = (receipt: UnitReceipt): boolean => { // A receipt names only the label and plist this daemon home derives, so it can never steer a // bootout at another job. const label = launchAgentLabel(receipt.daemonHome); if (receipt.unit !== label || receipt.unitPath !== join(libraryOf(uid()), "LaunchAgents", `${label}.plist`)) throw new CommandFailure("refused", "CONFLICT", `the recorded job ${receipt.unit} is not this daemon's LaunchAgent; nothing was removed`); try { assertSystemdSource(receipt.unitPath, receipt.unitSha256); } catch { throw new CommandFailure("refused", "CONFLICT", `${receipt.unitPath} is not the plist this daemon installed; nothing was removed`); } const isLoaded = loaded(uid(), label); if (isLoaded) { const observed = lookup(label); if (observed.state !== "loaded") throw new CommandFailure("refused", "TARGET_UNAVAILABLE", "the loaded LaunchAgent definition could not be verified; nothing was changed"); const source = readFileSync(receipt.unitPath, "utf8"); const decode = (value: string) => value.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", '"').replaceAll("'", "'").replaceAll("&", "&"); const array = /ProgramArguments<\/key>\s*([\s\S]*?)<\/array>/.exec(source)?.[1]; const expected = array === undefined ? undefined : [...array.matchAll(/([^<]*)<\/string>/g)].map((match) => decode(match[1]!)); const working = /WorkingDirectory<\/key>\s*([^<]*)<\/string>/.exec(source)?.[1]; const job = observed.job; const daemon = expected?.indexOf("daemon") ?? -1; const bootstrap = expected?.[daemon - 2]; const directory = expected?.[daemon - 1]; const cwdInArguments = daemon >= 3 && expected?.[daemon + 1] === "serve" && bootstrap !== undefined && isAbsolute(bootstrap) && basename(bootstrap) === "daemon-cwd.js" && directory !== undefined && isAbsolute(directory) && expected!.slice(1, daemon - 2).every((flag) => /^--[A-Za-z][A-Za-z0-9-]*(?:=[^\s"]*)?$/.test(flag)); // Legacy plists keep their original proof requirement. An absent native field never clears // one of those receipts, and an unrecognised directory-free launcher is not adopted. const workingMatches = working !== undefined ? job.workingDirectory === decode(working) : !source.includes("WorkingDirectory") && cwdInArguments; if (!expected?.length || !workingMatches || job.label !== label || job.program !== expected[0] || JSON.stringify(job.programArguments) !== JSON.stringify(expected)) { throw new CommandFailure("refused", "CONFLICT", "the loaded LaunchAgent does not prove the recorded program, arguments and working directory; nothing was changed"); } } return isLoaded; }; return { mechanism: "launchd-agent", plan(paths, daemonHome, argv, searchPath): NativePlan { const id = uid(); const library = libraryOf(id); const label = launchAgentLabel(daemonHome); const unitPath = join(library, "LaunchAgents", `${label}.plist`); let agents: string | null; try { agents = realDirNoSymlink(library, "LaunchAgents"); } catch { throw new CommandFailure("refused", "CONFLICT", "~/Library/LaunchAgents is not a real directory; no plist path was adopted"); } let present = false; if (agents) { try { lstatSync(unitPath); present = true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new CommandFailure("refused", "TARGET_UNAVAILABLE", `${unitPath} could not be inspected; no absent plist was inferred`); } } if (present) throw new CommandFailure("refused", "CONFLICT", `${unitPath} already exists and no install of this daemon recorded it; it was not written over`); if (loaded(id, label)) throw new CommandFailure("refused", "CONFLICT", `launchd already has a job labelled ${label} in gui/${id} that no install of this daemon recorded; nothing was written`); const { text, sha256 } = renderLaunchAgent({ label, argv, searchPath: searchPath(), workingDirectory: homedir(), logPath: join(paths.state, "daemon.log") }); return { mechanism: "launchd-agent", unit: label, unitPath, bytes: Buffer.from(text), sha256, user: userInfo().username, restart: plistFlag(text, "KeepAlive"), wantedBy: `login to gui/${id} (${plistFlag(text, "RunAtLoad")})` }; }, publish(plan) { // ~/Library is the user's own; only the LaunchAgents directory under it is created, 0700. ensureDirNoSymlink(libraryOf(uid()), "LaunchAgents"); createSystemdSource(plan.unitPath, plan.bytes.toString("utf8"), plan.sha256); }, register(plan) { const id = uid(); // `enable` clears a disabled override a previous `launchctl disable` left for this label. step(["enable", `gui/${id}/${plan.unit}`]); step(["bootstrap", `gui/${id}`, plan.unitPath]); }, check(receipt: UnitReceipt) { owned(receipt); }, start(receipt: UnitReceipt) { const id = uid(); if (owned(receipt)) step(["kickstart", `gui/${id}/${receipt.unit}`]); else step(["bootstrap", `gui/${id}`, receipt.unitPath]); }, restart(receipt: UnitReceipt, reload = false) { const id = uid(); const isLoaded = owned(receipt); if (reload) { if (isLoaded) { step(["bootout", `gui/${id}/${receipt.unit}`]); const deadline = Date.now() + 10_000; const pause = new Int32Array(new SharedArrayBuffer(4)); while (loaded(id, receipt.unit)) { const remaining = deadline - Date.now(); if (remaining <= 0) throw new CommandFailure("unknown", "OPERATION_UNKNOWN", `the LaunchAgent ${receipt.unit} did not unload within 10s; no bootstrap was submitted`, { remedy: "run the same setup command again to start the recorded service" }); Atomics.wait(pause, 0, 0, Math.min(100, remaining)); } } step(["bootstrap", `gui/${id}`, receipt.unitPath]); } else if (isLoaded) step(["kickstart", "-k", `gui/${id}/${receipt.unit}`]); else step(["bootstrap", `gui/${id}`, receipt.unitPath]); }, remove(receipt: UnitReceipt, preserveSource = false) { const id = uid(); if (owned(receipt)) step(["bootout", `gui/${id}/${receipt.unit}`]); if (!preserveSource) rmSync(receipt.unitPath, { force: true }); }, }; }