@moondeck/station 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +115 -8
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
  326. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,1048 @@
1
+ /**
2
+ * macOS service install via launchd (ADR-028).
3
+ *
4
+ * Writes ~/Library/LaunchAgents/io.moondeck.station.plist (KeepAlive +
5
+ * RunAtLoad), logs to ~/Library/Logs/Moondeck/, loads with `launchctl bootstrap gui/<uid>`.
6
+ * The service's PATH is the operator's PATH at install time (so node/pnpm/
7
+ * docker/git resolve); .env.local is loaded by the host process itself.
8
+ */
9
+
10
+ import { lstatSync, mkdirSync, readlinkSync, rmSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { basename, dirname, resolve } from "node:path";
13
+
14
+ import { STATION } from "../../src/station-identity";
15
+ import type {
16
+ InstallContext,
17
+ Installer,
18
+ ServiceDefinitionState,
19
+ ServiceManagerState,
20
+ } from "./types";
21
+ import {
22
+ escapeXml,
23
+ executeStep,
24
+ printFile,
25
+ readPrivateJournal,
26
+ removeFileDurable,
27
+ restoreDefinitionDurable,
28
+ snapshotDefinition,
29
+ step,
30
+ stepFailure,
31
+ writeDefinitionDurable,
32
+ type DirectorySync,
33
+ type StepExecutor,
34
+ type StepResult,
35
+ } from "./util";
36
+
37
+ export const LABEL = STATION.launchdLabel;
38
+
39
+ /** The per-user launchd domain the LaunchAgent is bootstrapped into. */
40
+ export function launchdDomain(uid: number = process.getuid?.() ?? 0): string {
41
+ return `gui/${uid}`;
42
+ }
43
+
44
+ /** The job as `launchctl` addresses it — `gui/<uid>/io.moondeck.station`. The
45
+ * service's own activation respawn (`src/activation-respawn.ts`) targets the
46
+ * same string, so the label and domain cannot drift between the CLI and the
47
+ * running host. */
48
+ export function launchdJobTarget(uid: number = process.getuid?.() ?? 0): string {
49
+ return `${launchdDomain(uid)}/${LABEL}`;
50
+ }
51
+
52
+ const domain = launchdDomain();
53
+ const target = launchdJobTarget();
54
+ const logDir = resolve(homedir(), "Library", "Logs", STATION.logDirName);
55
+
56
+ export function plistPath(): string {
57
+ return resolve(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
58
+ }
59
+
60
+ export function darwinInstallJournalPath(definition = plistPath()): string {
61
+ return resolve(dirname(definition), `.${basename(definition)}.install-journal.json`);
62
+ }
63
+
64
+ export interface PlistInputs {
65
+ execPath: string;
66
+ args: string[];
67
+ cwd: string;
68
+ path: string;
69
+ home: string;
70
+ outLog: string;
71
+ errLog: string;
72
+ runAtLoad?: boolean;
73
+ }
74
+
75
+ export function renderPlist(p: PlistInputs): string {
76
+ const progArgs = [p.execPath, ...p.args]
77
+ .map((a) => ` <string>${escapeXml(a)}</string>`)
78
+ .join("\n");
79
+ return `<?xml version="1.0" encoding="UTF-8"?>
80
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
81
+ <plist version="1.0">
82
+ <dict>
83
+ <key>Label</key>
84
+ <string>${LABEL}</string>
85
+ <key>ProgramArguments</key>
86
+ <array>
87
+ ${progArgs}
88
+ </array>
89
+ <key>WorkingDirectory</key>
90
+ <string>${escapeXml(p.cwd)}</string>
91
+ <key>EnvironmentVariables</key>
92
+ <dict>
93
+ <key>PATH</key>
94
+ <string>${escapeXml(p.path)}</string>
95
+ <key>HOME</key>
96
+ <string>${escapeXml(p.home)}</string>
97
+ </dict>
98
+ <key>RunAtLoad</key>
99
+ <${p.runAtLoad === false ? "false" : "true"}/>
100
+ <key>KeepAlive</key>
101
+ <true/>
102
+ <key>ThrottleInterval</key>
103
+ <integer>10</integer>
104
+ <key>StandardOutPath</key>
105
+ <string>${escapeXml(p.outLog)}</string>
106
+ <key>StandardErrorPath</key>
107
+ <string>${escapeXml(p.errLog)}</string>
108
+ </dict>
109
+ </plist>
110
+ `;
111
+ }
112
+
113
+ function inputs(ctx: InstallContext, logs = logDir): PlistInputs {
114
+ return {
115
+ execPath: ctx.execPath,
116
+ args: ctx.args,
117
+ cwd: ctx.cwd,
118
+ path: ctx.envPath ?? process.env.PATH ?? "",
119
+ home: homedir(),
120
+ outLog: resolve(logs, STATION.logFileName.replace(/\.log$/, ".out.log")),
121
+ errLog: resolve(logs, STATION.logFileName),
122
+ runAtLoad: ctx.startOnInstall !== false,
123
+ };
124
+ }
125
+
126
+ export interface DarwinInstallDeps extends DarwinServiceDeps {
127
+ logDirectory?: string;
128
+ }
129
+
130
+ export async function installDarwinService(
131
+ ctx: InstallContext,
132
+ deps: DarwinInstallDeps = {},
133
+ ): Promise<void> {
134
+ const definition = deps.definitionPath ?? plistPath();
135
+ const logs = deps.logDirectory ?? logDir;
136
+ const execute = deps.execute ?? executeStep;
137
+ const delay = deps.delay ?? defaultDelay;
138
+ const plist = renderPlist(inputs(ctx, logs));
139
+ if (ctx.dryRun) {
140
+ printFile(definition, plist);
141
+ step(true, "mkdir", ["-p", logs]);
142
+ step(true, "launchctl", ["bootout", target]);
143
+ if (ctx.startOnInstall !== false) {
144
+ step(true, "launchctl", ["bootstrap", domain, definition]);
145
+ await enableDarwinService(true);
146
+ } else {
147
+ step(true, "launchctl", ["disable", target]);
148
+ }
149
+ return;
150
+ }
151
+ const journalPath = deps.journalPath ?? darwinInstallJournalPath(definition);
152
+ const installIntent = ctx.definitionInstallIntent;
153
+ assertDefinitionInstallIntent(installIntent);
154
+ await reconcileDarwinDefinitionInstall(
155
+ { ...deps, definitionPath: definition, journalPath },
156
+ installIntent?.nonce,
157
+ );
158
+ mkdirSync(logs, { recursive: true });
159
+ mkdirSync(dirname(definition), { recursive: true });
160
+ const previous = snapshotDefinition(definition);
161
+ const prior = inspectLaunchdJob(execute);
162
+ if (!previous.exists && prior.loaded) {
163
+ throw new Error("launchd reports the host job loaded without a recoverable plist");
164
+ }
165
+ const journal: DarwinInstallJournal = {
166
+ schemaVersion: 1,
167
+ phase: "installed",
168
+ installIntent: installIntent ?? null,
169
+ prior: serializeDefinition(previous),
170
+ manager: { ...prior, enabled: inspectLaunchdEnabled(execute) },
171
+ };
172
+ writeDefinitionDurable(journalPath, `${JSON.stringify(journal)}\n`, 0o600);
173
+ try {
174
+ writeDefinitionDurable(
175
+ definition,
176
+ plist,
177
+ 0o644,
178
+ deps.definitionDirectorySync,
179
+ );
180
+ await assertLaunchdUnloaded(false, execute, delay);
181
+ if (ctx.startOnInstall === false) {
182
+ await setDarwinServiceEnabled(false, false, { execute });
183
+ if (!installIntent) removeFileDurable(journalPath);
184
+ return;
185
+ }
186
+ await enableDarwinService(false, { execute });
187
+ await bootstrapWithRetry(execute, delay, definition);
188
+ await kickstartDarwinAndVerify(execute, delay);
189
+ if (!installIntent) removeFileDurable(journalPath);
190
+ } catch (error) {
191
+ try {
192
+ const rollbackDeps = {
193
+ ...deps,
194
+ definitionPath: definition,
195
+ journalPath,
196
+ };
197
+ if (installIntent) {
198
+ await prepareDarwinDefinitionInstallRollback(
199
+ rollbackDeps,
200
+ installIntent.nonce,
201
+ );
202
+ } else {
203
+ await reconcileDarwinDefinitionInstall(rollbackDeps);
204
+ }
205
+ } catch (rollbackError) {
206
+ throw new Error(
207
+ `${errorMessage(error)}; service-definition rollback failed: ${errorMessage(rollbackError)}`,
208
+ { cause: error },
209
+ );
210
+ }
211
+ throw error;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * `bootout` unloads the old job asynchronously, so an immediate `bootstrap`
217
+ * can race it ("Bootstrap failed: 5: Input/output error"). Retry a few times
218
+ * so re-running `install` is idempotent instead of leaving nothing loaded.
219
+ */
220
+ async function bootstrapWithRetry(
221
+ execute: StepExecutor = executeStep,
222
+ delay: (milliseconds: number) => Promise<void> = defaultDelay,
223
+ definition = plistPath(),
224
+ ): Promise<void> {
225
+ let last: StepResult | null = null;
226
+ const args = ["bootstrap", domain, definition];
227
+ for (let attempt = 0; attempt < 6; attempt += 1) {
228
+ last = execute(false, "launchctl", args);
229
+ if (last.status === 0) return;
230
+ if (attempt < 5) await delay(500);
231
+ }
232
+ throw stepFailure("launchctl", args, last!);
233
+ }
234
+
235
+ export interface DarwinServiceDeps {
236
+ execute?: StepExecutor;
237
+ removeFile?: (path: string) => void;
238
+ delay?: (milliseconds: number) => Promise<void>;
239
+ definitionPath?: string;
240
+ journalPath?: string;
241
+ definitionDirectorySync?: DirectorySync;
242
+ }
243
+
244
+ const LAUNCHD_ABSENT =
245
+ /could not find (?:specified )?service|service(?: .+)? not found|no such process/i;
246
+ const defaultDelay = (milliseconds: number): Promise<void> =>
247
+ new Promise((resolve) => setTimeout(resolve, milliseconds));
248
+
249
+ interface DarwinInstallJournal {
250
+ readonly schemaVersion: 1;
251
+ readonly phase: "installed" | "definition-restored";
252
+ readonly installIntent: {
253
+ readonly nonce: string;
254
+ readonly ownerPid: number;
255
+ } | null;
256
+ readonly prior:
257
+ | { readonly exists: false }
258
+ | {
259
+ readonly exists: true;
260
+ readonly bytesBase64: string;
261
+ readonly mode: number;
262
+ };
263
+ readonly manager: {
264
+ readonly loaded: boolean;
265
+ readonly running: boolean;
266
+ readonly enabled: boolean;
267
+ };
268
+ }
269
+
270
+ function assertDefinitionInstallIntent(
271
+ intent: InstallContext["definitionInstallIntent"],
272
+ ): void {
273
+ if (
274
+ intent &&
275
+ (!/^[a-f0-9]{64}$/.test(intent.nonce) ||
276
+ !Number.isSafeInteger(intent.ownerPid) ||
277
+ intent.ownerPid < 1)
278
+ ) {
279
+ throw new Error("service definition install intent is invalid");
280
+ }
281
+ }
282
+
283
+ function serializeDefinition(
284
+ snapshot: ReturnType<typeof snapshotDefinition>,
285
+ ): DarwinInstallJournal["prior"] {
286
+ return snapshot.exists
287
+ ? {
288
+ exists: true,
289
+ bytesBase64: snapshot.bytes.toString("base64"),
290
+ mode: snapshot.mode,
291
+ }
292
+ : { exists: false };
293
+ }
294
+
295
+ function parseDarwinInstallJournal(raw: string): DarwinInstallJournal {
296
+ let value: unknown;
297
+ try {
298
+ value = JSON.parse(raw);
299
+ } catch {
300
+ throw new Error("launchd service install journal is invalid");
301
+ }
302
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
303
+ throw new Error("launchd service install journal is invalid");
304
+ }
305
+ const record = value as Record<string, unknown>;
306
+ if (
307
+ Object.keys(record).sort().join("\0") !==
308
+ ["installIntent", "manager", "phase", "prior", "schemaVersion"].join(
309
+ "\0",
310
+ ) ||
311
+ record.schemaVersion !== 1 ||
312
+ (record.phase !== "installed" && record.phase !== "definition-restored") ||
313
+ !record.manager ||
314
+ typeof record.manager !== "object" ||
315
+ Array.isArray(record.manager) ||
316
+ !record.prior ||
317
+ typeof record.prior !== "object" ||
318
+ Array.isArray(record.prior) ||
319
+ (record.installIntent !== null &&
320
+ (typeof record.installIntent !== "object" || Array.isArray(record.installIntent)))
321
+ ) {
322
+ throw new Error("launchd service install journal is invalid");
323
+ }
324
+ const manager = record.manager as Record<string, unknown>;
325
+ const prior = record.prior as Record<string, unknown>;
326
+ const installIntent = record.installIntent as Record<string, unknown> | null;
327
+ if (
328
+ Object.keys(manager).sort().join("\0") !==
329
+ ["enabled", "loaded", "running"].join("\0") ||
330
+ typeof manager.enabled !== "boolean" ||
331
+ typeof manager.loaded !== "boolean" ||
332
+ typeof manager.running !== "boolean" ||
333
+ (manager.running && !manager.loaded) ||
334
+ typeof prior.exists !== "boolean" ||
335
+ (installIntent !== null &&
336
+ (Object.keys(installIntent).sort().join("\0") !== ["nonce", "ownerPid"].join("\0") ||
337
+ typeof installIntent.nonce !== "string" ||
338
+ !/^[a-f0-9]{64}$/.test(installIntent.nonce) ||
339
+ typeof installIntent.ownerPid !== "number" ||
340
+ !Number.isSafeInteger(installIntent.ownerPid) ||
341
+ installIntent.ownerPid < 1))
342
+ ) {
343
+ throw new Error("launchd service install journal is invalid");
344
+ }
345
+ if (prior.exists === false) {
346
+ if (Object.keys(prior).join("\0") !== "exists" || manager.loaded) {
347
+ throw new Error("launchd service install journal is invalid");
348
+ }
349
+ return {
350
+ schemaVersion: 1,
351
+ phase: record.phase,
352
+ installIntent: installIntent as DarwinInstallJournal["installIntent"],
353
+ prior: { exists: false },
354
+ manager: manager as DarwinInstallJournal["manager"],
355
+ };
356
+ }
357
+ if (
358
+ Object.keys(prior).sort().join("\0") !==
359
+ ["bytesBase64", "exists", "mode"].join("\0") ||
360
+ typeof prior.bytesBase64 !== "string" ||
361
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
362
+ prior.bytesBase64,
363
+ ) ||
364
+ typeof prior.mode !== "number" ||
365
+ !Number.isInteger(prior.mode) ||
366
+ prior.mode < 0 ||
367
+ prior.mode > 0o7777
368
+ ) {
369
+ throw new Error("launchd service install journal is invalid");
370
+ }
371
+ const bytes = Buffer.from(prior.bytesBase64, "base64");
372
+ if (bytes.toString("base64") !== prior.bytesBase64) {
373
+ throw new Error("launchd service install journal is invalid");
374
+ }
375
+ return {
376
+ schemaVersion: 1,
377
+ phase: record.phase,
378
+ installIntent: installIntent as DarwinInstallJournal["installIntent"],
379
+ prior: {
380
+ exists: true,
381
+ bytesBase64: prior.bytesBase64,
382
+ mode: prior.mode,
383
+ },
384
+ manager: manager as DarwinInstallJournal["manager"],
385
+ };
386
+ }
387
+
388
+ function inspectLaunchdJob(execute: StepExecutor): {
389
+ loaded: boolean;
390
+ running: boolean;
391
+ } {
392
+ const args = ["print", target];
393
+ const result = execute(false, "launchctl", args);
394
+ if (result.status === 0) {
395
+ return { loaded: true, running: launchdReportsRunning(result) };
396
+ }
397
+ if (probeConfirmsUnloaded(result)) return { loaded: false, running: false };
398
+ throw stepFailure("launchctl", args, result, "could not determine prior job state");
399
+ }
400
+
401
+ function inspectLaunchdEnabled(execute: StepExecutor): boolean {
402
+ const args = ["print-disabled", domain];
403
+ const result = execute(false, "launchctl", args);
404
+ if (result.status !== 0) throw stepFailure("launchctl", args, result);
405
+ const escaped = LABEL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
406
+ const match = new RegExp(`"${escaped}"\\s*=>\\s*(true|false)`).exec(result.stdout);
407
+ return match?.[1] !== "true";
408
+ }
409
+
410
+ function errorMessage(error: unknown): string {
411
+ return error instanceof Error ? error.message : String(error);
412
+ }
413
+
414
+ function probeConfirmsUnloaded(result: StepResult): boolean {
415
+ return (
416
+ typeof result.status === "number" &&
417
+ result.status !== 0 &&
418
+ LAUNCHD_ABSENT.test(`${result.stdout}\n${result.stderr}`)
419
+ );
420
+ }
421
+
422
+ function resultDetail(result: StepResult): string {
423
+ return (
424
+ result.stderr ||
425
+ result.stdout ||
426
+ result.error?.message ||
427
+ `exit status ${String(result.status)}`
428
+ ).trim();
429
+ }
430
+
431
+ async function assertLaunchdUnloaded(
432
+ dryRun: boolean,
433
+ execute: StepExecutor,
434
+ delay: (milliseconds: number) => Promise<void>,
435
+ ): Promise<void> {
436
+ const bootoutArgs = ["bootout", target];
437
+ const result = execute(dryRun, "launchctl", bootoutArgs);
438
+ if (dryRun) return;
439
+
440
+ const printArgs = ["print", target];
441
+ let probe = execute(false, "launchctl", printArgs);
442
+ for (let attempt = 0; attempt < 6; attempt += 1) {
443
+ if (probeConfirmsUnloaded(probe)) return;
444
+ if (result.status !== 0 || probe.status !== 0 || attempt === 5) break;
445
+ await delay(500);
446
+ probe = execute(false, "launchctl", printArgs);
447
+ }
448
+
449
+ const context = `could not verify that the service unloaded: ${resultDetail(probe)}`;
450
+ if (result.status === 0) {
451
+ throw new Error(`\`launchctl ${bootoutArgs.join(" ")}\` ${context}`);
452
+ }
453
+ throw stepFailure("launchctl", bootoutArgs, result, context);
454
+ }
455
+
456
+ export async function reconcileDarwinDefinitionInstall(
457
+ deps: DarwinInstallDeps = {},
458
+ installIntent?: string,
459
+ ): Promise<void> {
460
+ const definition = deps.definitionPath ?? plistPath();
461
+ const journalPath = deps.journalPath ?? darwinInstallJournalPath(definition);
462
+ const raw = readPrivateJournal(journalPath);
463
+ if (raw === null) return;
464
+ const journal = parseDarwinInstallJournal(raw);
465
+ if (journal.installIntent !== null) {
466
+ assertJournalAccess(journal.installIntent, installIntent);
467
+ throw new Error(
468
+ "public service definition rollback requires runtime command restoration between prepare and resume",
469
+ );
470
+ }
471
+ await prepareDarwinDefinitionInstallRollback(deps);
472
+ await resumeDarwinDefinitionInstallRollback(deps);
473
+ }
474
+
475
+ export async function prepareDarwinDefinitionInstallRollback(
476
+ deps: DarwinInstallDeps = {},
477
+ installIntent?: string,
478
+ ): Promise<void> {
479
+ const definition = deps.definitionPath ?? plistPath();
480
+ const journalPath = deps.journalPath ?? darwinInstallJournalPath(definition);
481
+ const raw = readPrivateJournal(journalPath);
482
+ if (raw === null) return;
483
+ const journal = parseDarwinInstallJournal(raw);
484
+ assertJournalAccess(journal.installIntent, installIntent);
485
+ const execute = deps.execute ?? executeStep;
486
+ const delay = deps.delay ?? defaultDelay;
487
+
488
+ await assertLaunchdUnloaded(false, execute, delay);
489
+ // Disable while the target definition/command graph is still intact. A
490
+ // reboot after the following rename must not launch the restored definition
491
+ // through a not-yet-restored shared shim.
492
+ await setDarwinServiceEnabled(false, false, { execute });
493
+ const prior = journal.prior.exists
494
+ ? {
495
+ exists: true as const,
496
+ bytes: Buffer.from(journal.prior.bytesBase64, "base64"),
497
+ mode: journal.prior.mode,
498
+ }
499
+ : ({ exists: false } as const);
500
+ restoreDefinitionDurable(definition, prior, deps.definitionDirectorySync);
501
+
502
+ if (journal.phase !== "definition-restored") {
503
+ writeDefinitionDurable(
504
+ journalPath,
505
+ `${JSON.stringify({ ...journal, phase: "definition-restored" })}\n`,
506
+ 0o600,
507
+ );
508
+ }
509
+ }
510
+
511
+ export async function resumeDarwinDefinitionInstallRollback(
512
+ deps: DarwinInstallDeps = {},
513
+ installIntent?: string,
514
+ ): Promise<void> {
515
+ const definition = deps.definitionPath ?? plistPath();
516
+ const journalPath = deps.journalPath ?? darwinInstallJournalPath(definition);
517
+ const raw = readPrivateJournal(journalPath);
518
+ if (raw === null) return;
519
+ const journal = parseDarwinInstallJournal(raw);
520
+ assertJournalAccess(journal.installIntent, installIntent);
521
+ if (journal.phase !== "definition-restored") {
522
+ throw new Error("launchd service definition rollback has not been prepared");
523
+ }
524
+ const execute = deps.execute ?? executeStep;
525
+ const delay = deps.delay ?? defaultDelay;
526
+
527
+ // Reassert the no-daemon boundary in case a supervisor raced the command
528
+ // restoration between prepare and resume.
529
+ await assertLaunchdUnloaded(false, execute, delay);
530
+ const prior = journal.prior.exists
531
+ ? {
532
+ exists: true as const,
533
+ bytes: Buffer.from(journal.prior.bytesBase64, "base64"),
534
+ mode: journal.prior.mode,
535
+ }
536
+ : ({ exists: false } as const);
537
+ restoreDefinitionDurable(definition, prior, deps.definitionDirectorySync);
538
+
539
+ if (prior.exists) {
540
+ const enabledArgs = [journal.manager.enabled ? "enable" : "disable", target];
541
+ const enabled = execute(false, "launchctl", enabledArgs);
542
+ if (enabled.status !== 0) throw stepFailure("launchctl", enabledArgs, enabled);
543
+ }
544
+
545
+ if (journal.manager.loaded) {
546
+ await bootstrapWithRetry(execute, delay, definition);
547
+ if (journal.manager.running) {
548
+ const kickstartArgs = ["kickstart", target];
549
+ const kickstart = execute(false, "launchctl", kickstartArgs);
550
+ if (kickstart.status !== 0) {
551
+ throw stepFailure("launchctl", kickstartArgs, kickstart);
552
+ }
553
+ const printArgs = ["print", target];
554
+ let probe = execute(false, "launchctl", printArgs);
555
+ for (let attempt = 0; attempt < 6; attempt += 1) {
556
+ if (launchdReportsRunning(probe)) break;
557
+ if (attempt === 5) {
558
+ throw new Error(
559
+ `\`launchctl ${kickstartArgs.join(" ")}\` did not restore the prior service: ${resultDetail(probe)}`,
560
+ );
561
+ }
562
+ await delay(500);
563
+ probe = execute(false, "launchctl", printArgs);
564
+ }
565
+ }
566
+ }
567
+ removeFileDurable(journalPath);
568
+ }
569
+
570
+ export async function completeDarwinDefinitionInstall(
571
+ deps: DarwinInstallDeps = {},
572
+ installIntent?: string,
573
+ ): Promise<void> {
574
+ const definition = deps.definitionPath ?? plistPath();
575
+ const journalPath = deps.journalPath ?? darwinInstallJournalPath(definition);
576
+ const raw = readPrivateJournal(journalPath);
577
+ if (raw === null) return;
578
+ const journal = parseDarwinInstallJournal(raw);
579
+ assertJournalAccess(journal.installIntent, installIntent);
580
+ if (journal.phase !== "installed") {
581
+ throw new Error("cannot commit a service definition whose rollback was prepared");
582
+ }
583
+ removeFileDurable(journalPath);
584
+ }
585
+
586
+ function assertJournalAccess(
587
+ owner: DarwinInstallJournal["installIntent"],
588
+ installIntent?: string,
589
+ ): void {
590
+ if (owner === null) {
591
+ if (installIntent) {
592
+ throw new Error("service definition journal does not belong to this install intent");
593
+ }
594
+ return;
595
+ }
596
+ if (owner.nonce !== installIntent) {
597
+ throw new Error(
598
+ `service definition update is owned by managed installer PID ${owner.ownerPid}`,
599
+ );
600
+ }
601
+ }
602
+
603
+ export async function darwinDefinitionState(
604
+ managedCommand: string,
605
+ definition = plistPath(),
606
+ expectedManagedTarget = resolve(
607
+ homedir(),
608
+ STATION.runtimeDirName,
609
+ "runtime",
610
+ "current",
611
+ "bin",
612
+ "uai-host",
613
+ ),
614
+ execute: StepExecutor = executeStep,
615
+ ): Promise<ServiceDefinitionState> {
616
+ const snapshot = snapshotDefinition(definition);
617
+ const loadedCommand = inspectLaunchdLoadedCommand(execute, managedCommand);
618
+ if (!snapshot.exists && loadedCommand.loaded) {
619
+ throw new Error(
620
+ "launchd reports the host job loaded without a recoverable plist",
621
+ );
622
+ }
623
+ if (!snapshot.exists) {
624
+ // launchd's print-disabled map treats an absent label as enabled by
625
+ // default. There is no definition to enable, so normalize a truly missing
626
+ // service to the all-false lifecycle state expected by install admission.
627
+ return {
628
+ ownership: "missing",
629
+ manager: { loaded: false, active: false, enabled: false },
630
+ };
631
+ }
632
+ const manager: ServiceManagerState = {
633
+ loaded: loadedCommand.loaded,
634
+ // This LaunchAgent is KeepAlive+RunAtLoad. A loaded-but-currently-waiting
635
+ // job is launchd restart/throttle pending and must be quiesced/preserved as
636
+ // active even when no daemon answers at the instant of inspection.
637
+ active: loadedCommand.loaded,
638
+ enabled: inspectLaunchdEnabled(execute),
639
+ };
640
+ const args = parsePlistProgramArguments(snapshot.bytes.toString("utf8"));
641
+ if (
642
+ args?.length === 2 &&
643
+ args[0] === managedCommand &&
644
+ args[1] === "run" &&
645
+ managedCommandIsOwned(managedCommand, expectedManagedTarget) &&
646
+ loadedCommand.matches !== false
647
+ ) {
648
+ return { ownership: "managed", manager };
649
+ }
650
+ return { ownership: "legacy", manager };
651
+ }
652
+
653
+ export async function darwinDefinitionOwnership(
654
+ managedCommand: string,
655
+ definition = plistPath(),
656
+ expectedManagedTarget = resolve(
657
+ homedir(),
658
+ STATION.runtimeDirName,
659
+ "runtime",
660
+ "current",
661
+ "bin",
662
+ "uai-host",
663
+ ),
664
+ execute: StepExecutor = executeStep,
665
+ ): Promise<"missing" | "managed" | "legacy"> {
666
+ return (
667
+ await darwinDefinitionState(
668
+ managedCommand,
669
+ definition,
670
+ expectedManagedTarget,
671
+ execute,
672
+ )
673
+ ).ownership;
674
+ }
675
+
676
+ function inspectLaunchdLoadedCommand(
677
+ execute: StepExecutor,
678
+ managedCommand: string,
679
+ ): { loaded: boolean; matches: boolean | null } {
680
+ const args = ["print", target];
681
+ const result = execute(false, "launchctl", args);
682
+ if (probeConfirmsUnloaded(result)) {
683
+ return { loaded: false, matches: null };
684
+ }
685
+ if (result.status !== 0) {
686
+ throw stepFailure(
687
+ "launchctl",
688
+ args,
689
+ result,
690
+ "could not determine the loaded host command",
691
+ );
692
+ }
693
+ const programLines = result.stdout
694
+ .split(/\r?\n/)
695
+ .map((line) => /^\s*program = (.*)$/.exec(line)?.[1])
696
+ .filter((value): value is string => value !== undefined);
697
+ const argumentBlocks = [
698
+ ...result.stdout.matchAll(/^\s*arguments = \{\s*$([\s\S]*?)^\s*\}\s*$/gm),
699
+ ];
700
+ if (programLines.length !== 1 || argumentBlocks.length !== 1) {
701
+ return {
702
+ loaded: true,
703
+ matches: false,
704
+ };
705
+ }
706
+ const loadedArgs = (argumentBlocks[0]?.[1] ?? "")
707
+ .split(/\r?\n/)
708
+ .map((line) => line.trim())
709
+ .filter(Boolean);
710
+ return {
711
+ loaded: true,
712
+ matches:
713
+ programLines[0] === managedCommand &&
714
+ loadedArgs.length === 2 &&
715
+ loadedArgs[0] === managedCommand &&
716
+ loadedArgs[1] === "run",
717
+ };
718
+ }
719
+
720
+ function parsePlistProgramArguments(contents: string): string[] | null {
721
+ const matches = [
722
+ ...contents.matchAll(
723
+ /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/g,
724
+ ),
725
+ ];
726
+ if (matches.length !== 1) return null;
727
+ const body = matches[0]?.[1] ?? "";
728
+ const args: string[] = [];
729
+ const strings = /<string>([\s\S]*?)<\/string>/g;
730
+ let cursor = 0;
731
+ for (const match of body.matchAll(strings)) {
732
+ const index = match.index ?? 0;
733
+ if (body.slice(cursor, index).trim() !== "") return null;
734
+ const decoded = decodeXmlString(match[1] ?? "");
735
+ if (decoded === null) return null;
736
+ args.push(decoded);
737
+ cursor = index + match[0].length;
738
+ }
739
+ return body.slice(cursor).trim() === "" ? args : null;
740
+ }
741
+
742
+ function decodeXmlString(value: string): string | null {
743
+ const decoded = value.replace(
744
+ /&(amp|lt|gt|quot|apos);/g,
745
+ (_entity, name: string) =>
746
+ ({ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" })[name] ?? "",
747
+ );
748
+ return /&(?:#\d+|#x[0-9a-f]+|[a-z][a-z0-9]*);/i.test(decoded)
749
+ ? null
750
+ : decoded;
751
+ }
752
+
753
+ function managedCommandIsOwned(command: string, expectedTarget: string): boolean {
754
+ try {
755
+ const stat = lstatSync(command);
756
+ if (!stat.isSymbolicLink()) return false;
757
+ const actual = resolve(dirname(command), readlinkSync(command));
758
+ return actual === expectedTarget;
759
+ } catch (error) {
760
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
761
+ throw error;
762
+ }
763
+ }
764
+
765
+ export async function uninstallDarwinService(
766
+ dryRun = false,
767
+ deps: DarwinServiceDeps = {},
768
+ ): Promise<void> {
769
+ if (!dryRun) await reconcileDarwinDefinitionInstall(deps);
770
+ await assertLaunchdUnloaded(
771
+ dryRun,
772
+ deps.execute ?? executeStep,
773
+ deps.delay ?? defaultDelay,
774
+ );
775
+ if (dryRun) {
776
+ console.log(`$ rm -f ${plistPath()}`);
777
+ return;
778
+ }
779
+ (deps.removeFile ?? ((path) => rmSync(path, { force: true })))(
780
+ deps.definitionPath ?? plistPath(),
781
+ );
782
+ }
783
+
784
+ export async function enableDarwinService(
785
+ dryRun = false,
786
+ deps: DarwinServiceDeps = {},
787
+ ): Promise<void> {
788
+ await setDarwinServiceEnabled(true, dryRun, deps);
789
+ }
790
+
791
+ async function setDarwinServiceEnabled(
792
+ enabled: boolean,
793
+ dryRun = false,
794
+ deps: DarwinServiceDeps = {},
795
+ ): Promise<void> {
796
+ const args = [enabled ? "enable" : "disable", target];
797
+ const result = (deps.execute ?? executeStep)(dryRun, "launchctl", args);
798
+ if (result.status !== 0) {
799
+ throw stepFailure("launchctl", args, result);
800
+ }
801
+ }
802
+
803
+ function launchdReportsRunning(result: StepResult): boolean {
804
+ return (
805
+ result.status === 0 &&
806
+ /(?:^|\n)\s*state = running(?:\s|$)/.test(result.stdout)
807
+ );
808
+ }
809
+
810
+ async function kickstartDarwinAndVerify(
811
+ execute: StepExecutor,
812
+ delay: (milliseconds: number) => Promise<void>,
813
+ ): Promise<void> {
814
+ const kickstartArgs = ["kickstart", target];
815
+ const kickstart = execute(false, "launchctl", kickstartArgs);
816
+ if (kickstart.status !== 0) {
817
+ throw stepFailure("launchctl", kickstartArgs, kickstart);
818
+ }
819
+
820
+ const printArgs = ["print", target];
821
+ let probe = execute(false, "launchctl", printArgs);
822
+ for (let attempt = 0; attempt < 6; attempt += 1) {
823
+ if (launchdReportsRunning(probe)) return;
824
+ if (attempt === 5) break;
825
+ await delay(500);
826
+ probe = execute(false, "launchctl", printArgs);
827
+ }
828
+ throw new Error(
829
+ `\`launchctl ${kickstartArgs.join(" ")}\` did not leave the service running: ${resultDetail(probe)}`,
830
+ );
831
+ }
832
+
833
+ export async function startDarwinService(
834
+ dryRun = false,
835
+ deps: DarwinServiceDeps = {},
836
+ ): Promise<void> {
837
+ if (!dryRun) await reconcileDarwinDefinitionInstall(deps);
838
+ const execute = deps.execute ?? executeStep;
839
+ await enableDarwinService(dryRun, deps);
840
+
841
+ const bootstrapArgs = ["bootstrap", domain, deps.definitionPath ?? plistPath()];
842
+ const bootstrap = execute(dryRun, "launchctl", bootstrapArgs);
843
+ if (dryRun) {
844
+ execute(true, "launchctl", ["kickstart", target]);
845
+ execute(true, "launchctl", ["print", target]);
846
+ return;
847
+ }
848
+ if (bootstrap.status !== 0) {
849
+ const loaded = execute(false, "launchctl", ["print", target]);
850
+ if (loaded.status !== 0) {
851
+ throw stepFailure(
852
+ "launchctl",
853
+ bootstrapArgs,
854
+ bootstrap,
855
+ `service is not already loaded: ${resultDetail(loaded)}`,
856
+ );
857
+ }
858
+ }
859
+
860
+ await kickstartDarwinAndVerify(execute, deps.delay ?? defaultDelay);
861
+ }
862
+
863
+ export async function stopDarwinService(
864
+ dryRun = false,
865
+ deps: DarwinServiceDeps = {},
866
+ ): Promise<void> {
867
+ if (!dryRun) await reconcileDarwinDefinitionInstall(deps);
868
+ await assertLaunchdUnloaded(
869
+ dryRun,
870
+ deps.execute ?? executeStep,
871
+ deps.delay ?? defaultDelay,
872
+ );
873
+ }
874
+
875
+ export async function stopDarwinServiceForInstall(
876
+ installIntent: string,
877
+ priorManager: ServiceManagerState,
878
+ deps: DarwinServiceDeps = {},
879
+ ): Promise<void> {
880
+ assertServiceManagerState(priorManager);
881
+ const definition = deps.definitionPath ?? plistPath();
882
+ const journalPath = deps.journalPath ?? darwinInstallJournalPath(definition);
883
+ const raw = readPrivateJournal(journalPath);
884
+ if (raw !== null) {
885
+ const journal = parseDarwinInstallJournal(raw);
886
+ assertJournalAccess(journal.installIntent, installIntent);
887
+ }
888
+ // Disable first so launchd cannot relaunch a waiting/KeepAlive predecessor
889
+ // between a successful unload and the runtime's durable stopped marker.
890
+ await setDarwinServiceEnabled(false, false, deps);
891
+ await assertLaunchdUnloaded(
892
+ false,
893
+ deps.execute ?? executeStep,
894
+ deps.delay ?? defaultDelay,
895
+ );
896
+ }
897
+
898
+ export async function resumeDarwinServiceForInstall(
899
+ installIntent: string,
900
+ priorManager: ServiceManagerState,
901
+ deps: DarwinServiceDeps = {},
902
+ ): Promise<void> {
903
+ assertServiceManagerState(priorManager);
904
+ const definition = deps.definitionPath ?? plistPath();
905
+ const journalPath = deps.journalPath ?? darwinInstallJournalPath(definition);
906
+ const raw = readPrivateJournal(journalPath);
907
+ if (raw !== null) {
908
+ const journal = parseDarwinInstallJournal(raw);
909
+ assertJournalAccess(journal.installIntent, installIntent);
910
+ if (journal.phase === "installed") {
911
+ throw new Error("cannot resume the prior service before definition rollback");
912
+ }
913
+ await resumeDarwinDefinitionInstallRollback(deps, installIntent);
914
+ }
915
+ const execute = deps.execute ?? executeStep;
916
+ const delay = deps.delay ?? defaultDelay;
917
+ const definitionPath = deps.definitionPath ?? plistPath();
918
+ await setDarwinServiceEnabled(priorManager.enabled, false, { execute });
919
+ if (priorManager.loaded) {
920
+ await bootstrapWithRetry(execute, delay, definitionPath);
921
+ }
922
+ if (priorManager.active) {
923
+ await kickstartDarwinAndVerify(execute, delay);
924
+ }
925
+ }
926
+
927
+ function assertServiceManagerState(state: ServiceManagerState): void {
928
+ if (
929
+ typeof state.loaded !== "boolean" ||
930
+ typeof state.active !== "boolean" ||
931
+ typeof state.enabled !== "boolean" ||
932
+ (state.active && !state.loaded)
933
+ ) {
934
+ throw new Error("managed service lifecycle snapshot is invalid");
935
+ }
936
+ }
937
+
938
+ export async function restartDarwinService(
939
+ dryRun = false,
940
+ deps: DarwinServiceDeps = {},
941
+ ): Promise<void> {
942
+ if (!dryRun) await reconcileDarwinDefinitionInstall(deps);
943
+ const args = ["kickstart", "-k", target];
944
+ const result = (deps.execute ?? executeStep)(dryRun, "launchctl", args);
945
+ if (result.status !== 0) {
946
+ throw stepFailure("launchctl", args, result);
947
+ }
948
+ }
949
+
950
+ /** What `launchctl print gui/<uid>/com.runuai.host` says about the job. */
951
+ export interface LaunchdJobState {
952
+ readonly loaded: boolean;
953
+ /** `state = …` verbatim: `running`, `not running`, `waiting`, … */
954
+ readonly state: string | null;
955
+ readonly pid: number | null;
956
+ /** `last exit code = …` verbatim, e.g. `75: EX_TEMPFAIL` or `(never exited)`. */
957
+ readonly lastExitCode: string | null;
958
+ /** `pended nondemand spawn = …` — launchd holds a spawn it has not executed
959
+ * (live 2026-09-06: `inefficient`, with the job `not running` after an
960
+ * exit-75 activation restart that KeepAlive never honoured). */
961
+ readonly pendedNondemandSpawn: string | null;
962
+ /** Why the probe failed, when it did not confirm a loaded job. */
963
+ readonly detail: string | null;
964
+ }
965
+
966
+ function launchdPrintField(stdout: string, name: string): string | null {
967
+ // Top-level keys are indented by exactly one tab; nested dicts (endpoints,
968
+ // event triggers) reuse names like `state = active` two tabs deep, so the
969
+ // job's own key is preferred and the loose match is only a fallback. The
970
+ // whitespace class is horizontal only, so `state` never matches the tail
971
+ // of `spawn type` or another longer key.
972
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
973
+ const value = `(.+?)[ \\t]*(?:\\r?\\n|$)`;
974
+ return (
975
+ new RegExp(`(?:^|\\n)\\t${escaped} = ${value}`).exec(stdout)?.[1] ??
976
+ new RegExp(`(?:^|\\n)[ \\t]*${escaped} = ${value}`).exec(stdout)?.[1] ??
977
+ null
978
+ );
979
+ }
980
+
981
+ export function parseLaunchdJobPrint(stdout: string): LaunchdJobState {
982
+ const pid = launchdPrintField(stdout, "pid");
983
+ return {
984
+ loaded: true,
985
+ state: launchdPrintField(stdout, "state"),
986
+ pid: pid !== null && /^\d+$/.test(pid) ? Number(pid) : null,
987
+ lastExitCode: launchdPrintField(stdout, "last exit code"),
988
+ pendedNondemandSpawn: launchdPrintField(stdout, "pended nondemand spawn"),
989
+ detail: null,
990
+ };
991
+ }
992
+
993
+ /** One `launchctl print` of the host job, parsed. Read-only: safe to call
994
+ * from the running service (a boot after an activation logs it so a pended
995
+ * respawn is visible in the service log rather than only in `launchctl`). */
996
+ export function describeLaunchdJob(execute: StepExecutor = executeStep): LaunchdJobState {
997
+ const result = execute(false, "launchctl", ["print", target]);
998
+ if (result.status !== 0) {
999
+ return {
1000
+ loaded: false,
1001
+ state: null,
1002
+ pid: null,
1003
+ lastExitCode: null,
1004
+ pendedNondemandSpawn: null,
1005
+ detail: resultDetail(result) || null,
1006
+ };
1007
+ }
1008
+ return parseLaunchdJobPrint(result.stdout);
1009
+ }
1010
+
1011
+ export function formatLaunchdJobState(job: LaunchdJobState): string {
1012
+ if (!job.loaded) return `not loaded${job.detail ? ` (${job.detail})` : ""}`;
1013
+ const parts = [`state = ${job.state ?? "unknown"}`];
1014
+ if (job.pid !== null) parts.push(`pid = ${job.pid}`);
1015
+ if (job.lastExitCode !== null) parts.push(`last exit code = ${job.lastExitCode}`);
1016
+ if (job.pendedNondemandSpawn !== null) {
1017
+ parts.push(`pended nondemand spawn = ${job.pendedNondemandSpawn}`);
1018
+ }
1019
+ return parts.join(", ");
1020
+ }
1021
+
1022
+ async function status(): Promise<string> {
1023
+ const job = describeLaunchdJob();
1024
+ if (!job.loaded) return "not installed / not loaded";
1025
+ return `${job.state ?? "unknown"}${job.pid !== null ? ` (pid ${job.pid})` : ""}`;
1026
+ }
1027
+
1028
+ export const installer: Installer = {
1029
+ install: installDarwinService,
1030
+ uninstall: uninstallDarwinService,
1031
+ start: startDarwinService,
1032
+ stop: stopDarwinService,
1033
+ stopForInstall: (installIntent, priorManager) =>
1034
+ stopDarwinServiceForInstall(installIntent, priorManager),
1035
+ resumeServiceForInstall: (installIntent, priorManager) =>
1036
+ resumeDarwinServiceForInstall(installIntent, priorManager),
1037
+ restart: restartDarwinService,
1038
+ status,
1039
+ reconcileDefinitionInstall: (installIntent) =>
1040
+ reconcileDarwinDefinitionInstall({}, installIntent),
1041
+ prepareDefinitionInstallRollback: (installIntent) =>
1042
+ prepareDarwinDefinitionInstallRollback({}, installIntent),
1043
+ resumeDefinitionInstallRollback: (installIntent) =>
1044
+ resumeDarwinDefinitionInstallRollback({}, installIntent),
1045
+ completeDefinitionInstall: (installIntent) =>
1046
+ completeDarwinDefinitionInstall({}, installIntent),
1047
+ definitionState: darwinDefinitionState,
1048
+ };