@moondeck/station 0.0.1 → 1.0.1

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 (330) 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/0031_first_turn_receipts.sql +9 -0
  36. package/db/migrations/meta/_journal.json +230 -0
  37. package/db/schema.ts +871 -0
  38. package/images/standard/Dockerfile +347 -0
  39. package/images/standard/README.md +167 -0
  40. package/images/standard/container/code-server-settings.json +63 -0
  41. package/images/standard/container/corepack-version +1 -0
  42. package/images/standard/container/uai-init +704 -0
  43. package/images/standard/container/uai-materialize-runtimes +1527 -0
  44. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  45. package/images/standard/tool-versions +2 -0
  46. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  47. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  48. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  49. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  50. package/lib/agent-cli.ts +1507 -0
  51. package/lib/agent-errors.ts +22 -0
  52. package/lib/agent.ts +672 -0
  53. package/lib/agents/attach-compatibility.ts +51 -0
  54. package/lib/agents/claude.ts +918 -0
  55. package/lib/agents/codex-token-usage.fixture.json +130 -0
  56. package/lib/agents/codex.ts +1392 -0
  57. package/lib/agents/cursor-period-usage.ts +253 -0
  58. package/lib/agents/cursor.ts +562 -0
  59. package/lib/agents/dispatch.ts +78 -0
  60. package/lib/agents/durable-proc.ts +334 -0
  61. package/lib/agents/factory.ts +57 -0
  62. package/lib/agents/grok.ts +381 -0
  63. package/lib/agents/kimi.ts +329 -0
  64. package/lib/agents/machine-durable.ts +604 -0
  65. package/lib/agents/mock.ts +133 -0
  66. package/lib/agents/mode.ts +64 -0
  67. package/lib/agents/opencode.ts +508 -0
  68. package/lib/agents/output-source.ts +3 -0
  69. package/lib/agents/proc.ts +221 -0
  70. package/lib/agents/rate-limit.ts +337 -0
  71. package/lib/agents/registry.ts +182 -0
  72. package/lib/agents/session-state.ts +197 -0
  73. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  74. package/lib/agents/transport.ts +741 -0
  75. package/lib/agents/types.ts +254 -0
  76. package/lib/agents/uai.ts +106 -0
  77. package/lib/agents/usage.ts +44 -0
  78. package/lib/apple-one-off.ts +277 -0
  79. package/lib/apple-runtime-recycle.ts +237 -0
  80. package/lib/apple-uninstall-teardown.ts +224 -0
  81. package/lib/assignment-evidence.ts +94 -0
  82. package/lib/attachments.ts +48 -0
  83. package/lib/browser-testing.ts +1537 -0
  84. package/lib/capture-flow.ts +332 -0
  85. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  86. package/lib/cloud-credential-host.ts +512 -0
  87. package/lib/cloud-credential-leases.ts +2587 -0
  88. package/lib/cloud-credential-runtime.ts +1357 -0
  89. package/lib/cloud-state.ts +56 -0
  90. package/lib/codex-auth.ts +456 -0
  91. package/lib/command-db.ts +310 -0
  92. package/lib/compose-network-recovery.ts +283 -0
  93. package/lib/connection-secret-redaction.ts +25 -0
  94. package/lib/container-runtime.ts +1470 -0
  95. package/lib/credential-cleanup-inventory.ts +490 -0
  96. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  97. package/lib/credential-consumers/engines.ts +797 -0
  98. package/lib/credential-consumers/files.ts +934 -0
  99. package/lib/credential-consumers/github.ts +702 -0
  100. package/lib/credential-consumers/mcp.ts +389 -0
  101. package/lib/credential-consumers/task-files.ts +127 -0
  102. package/lib/credential-consumers/task-secrets.ts +10 -0
  103. package/lib/credential-store-pickup.ts +149 -0
  104. package/lib/credential-store-scope.ts +112 -0
  105. package/lib/credential-store.ts +932 -0
  106. package/lib/credentials-activation-authority.ts +709 -0
  107. package/lib/credentials-purge.ts +1006 -0
  108. package/lib/custom-connection-capture.ts +395 -0
  109. package/lib/custom-connection-launch.ts +384 -0
  110. package/lib/custom-connection-runtime.ts +141 -0
  111. package/lib/db.ts +80 -0
  112. package/lib/disk-pressure.ts +42 -0
  113. package/lib/docker-exec.ts +156 -0
  114. package/lib/engine-accounts.ts +1970 -0
  115. package/lib/engine-connect.ts +321 -0
  116. package/lib/engine-credential-env.ts +52 -0
  117. package/lib/engine-login.ts +2933 -0
  118. package/lib/engines.ts +1342 -0
  119. package/lib/enrollment-state.ts +251 -0
  120. package/lib/env-file.ts +246 -0
  121. package/lib/env-schema.ts +937 -0
  122. package/lib/env.ts +195 -0
  123. package/lib/first-turn-receipts.ts +62 -0
  124. package/lib/fs-aliases.ts +49 -0
  125. package/lib/git-clone-credentials.ts +334 -0
  126. package/lib/git-diff.ts +530 -0
  127. package/lib/git-identity.ts +715 -0
  128. package/lib/github-git-auth.ts +207 -0
  129. package/lib/github-origin.ts +28 -0
  130. package/lib/github-tokens.ts +2439 -0
  131. package/lib/host-cloud-url.ts +62 -0
  132. package/lib/host-config.ts +279 -0
  133. package/lib/host-env.ts +424 -0
  134. package/lib/host-logs.ts +965 -0
  135. package/lib/internal-connections.ts +5 -0
  136. package/lib/keyed-promise-tail.ts +23 -0
  137. package/lib/launch-mode.ts +25 -0
  138. package/lib/lease.ts +239 -0
  139. package/lib/legacy-host.ts +769 -0
  140. package/lib/legacy-runtime-retirement.ts +424 -0
  141. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  142. package/lib/machine-exec.ts +238 -0
  143. package/lib/machine-gateway-tunnel.ts +344 -0
  144. package/lib/machine-keys.ts +65 -0
  145. package/lib/machine-project-env-loader.ts +152 -0
  146. package/lib/machine-provider-aws.ts +923 -0
  147. package/lib/machine-provider-local.ts +338 -0
  148. package/lib/machine-provider.ts +90 -0
  149. package/lib/managed-activation-watcher.ts +72 -0
  150. package/lib/managed-install-owner-watcher.ts +55 -0
  151. package/lib/managed-operation-drain.ts +57 -0
  152. package/lib/managed-runtime.ts +4296 -0
  153. package/lib/managed-update-scheduler.ts +125 -0
  154. package/lib/mcp-capture.ts +865 -0
  155. package/lib/mcp-config-lock.ts +2 -0
  156. package/lib/mcp-connections.ts +959 -0
  157. package/lib/mcp-gateway.ts +2594 -0
  158. package/lib/obs.ts +512 -0
  159. package/lib/openrouter-budget.ts +247 -0
  160. package/lib/orchestrator.ts +11409 -0
  161. package/lib/person-capture.ts +327 -0
  162. package/lib/preview-ports.ts +85 -0
  163. package/lib/preview-sidecar.ts +331 -0
  164. package/lib/project-env-authority.ts +1319 -0
  165. package/lib/project-env-feature.ts +74 -0
  166. package/lib/project-env-legacy-tags.ts +130 -0
  167. package/lib/project-env-loader.ts +242 -0
  168. package/lib/project-env-replica.ts +2094 -0
  169. package/lib/project-env-schema-capture.ts +342 -0
  170. package/lib/project-env-staging.ts +148 -0
  171. package/lib/project-env-task-policy.ts +176 -0
  172. package/lib/project-env-task-replica.ts +1752 -0
  173. package/lib/release-feed.ts +61 -0
  174. package/lib/release-manifest.ts +710 -0
  175. package/lib/release-trust.ts +46 -0
  176. package/lib/repo-clone.ts +38 -0
  177. package/lib/routing/shadow.ts +261 -0
  178. package/lib/runner-boot.ts +10 -0
  179. package/lib/runtime-activation-tail.ts +279 -0
  180. package/lib/runtime-archive.ts +1216 -0
  181. package/lib/runtime-authority.ts +166 -0
  182. package/lib/runtime-guard.ts +36 -0
  183. package/lib/runtime-provider-state.ts +169 -0
  184. package/lib/runtime-reconnect.ts +86 -0
  185. package/lib/runtime-state.ts +1033 -0
  186. package/lib/safe-http.ts +364 -0
  187. package/lib/secrets-proxy/address.ts +95 -0
  188. package/lib/secrets-proxy/audit.ts +30 -0
  189. package/lib/secrets-proxy/ca-store.ts +200 -0
  190. package/lib/secrets-proxy/ca.ts +470 -0
  191. package/lib/secrets-proxy/container-steps.ts +138 -0
  192. package/lib/secrets-proxy/der.ts +273 -0
  193. package/lib/secrets-proxy/engine.ts +1633 -0
  194. package/lib/secrets-proxy/host.ts +93 -0
  195. package/lib/secrets-proxy/hostname.ts +57 -0
  196. package/lib/secrets-proxy/placeholders.ts +196 -0
  197. package/lib/secrets-proxy/policy.ts +150 -0
  198. package/lib/secrets-proxy/readiness.ts +148 -0
  199. package/lib/secrets-proxy/runtime.ts +582 -0
  200. package/lib/secrets-proxy/scrub.ts +559 -0
  201. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  202. package/lib/secrets-proxy/task-scripts.ts +262 -0
  203. package/lib/secrets-proxy/task-trust.ts +605 -0
  204. package/lib/secrets-proxy/trust-watch.ts +80 -0
  205. package/lib/secrets.ts +71 -0
  206. package/lib/shared-files-s3.ts +976 -0
  207. package/lib/shared-files.ts +156 -0
  208. package/lib/sidecar-assign.ts +49 -0
  209. package/lib/sidecar-guard.ts +58 -0
  210. package/lib/sidecar-resume.ts +178 -0
  211. package/lib/sidecar.ts +69 -0
  212. package/lib/signin-box-credentials.ts +465 -0
  213. package/lib/signin-box.ts +114 -0
  214. package/lib/skill-index-example.fixture.ts +56 -0
  215. package/lib/skill-layout.ts +417 -0
  216. package/lib/skills.ts +489 -0
  217. package/lib/ssh.ts +401 -0
  218. package/lib/standard-image-cleanup.ts +117 -0
  219. package/lib/standard-image-pin.ts +207 -0
  220. package/lib/standard-image.ts +1697 -0
  221. package/lib/stopped-task-status-queue.ts +120 -0
  222. package/lib/task-container-cli.ts +269 -0
  223. package/lib/task-credential-cleanup.ts +752 -0
  224. package/lib/task-diff-sandbox.ts +464 -0
  225. package/lib/task-diff.ts +91 -0
  226. package/lib/task-env-forwarder.ts +228 -0
  227. package/lib/task-env-source.ts +249 -0
  228. package/lib/task-environment/anchor.ts +31 -0
  229. package/lib/task-environment/apple-container.ts +860 -0
  230. package/lib/task-environment/docker.ts +1344 -0
  231. package/lib/task-environment/index.ts +466 -0
  232. package/lib/task-environment/legacy-adoption.ts +467 -0
  233. package/lib/task-environment/machine-environment.ts +61 -0
  234. package/lib/task-environment/machine-task-up.ts +1227 -0
  235. package/lib/task-environment/machine.ts +974 -0
  236. package/lib/task-environment/registry.ts +59 -0
  237. package/lib/task-environment/self-identity.ts +20 -0
  238. package/lib/task-environment/self.ts +566 -0
  239. package/lib/task-environment/types.ts +642 -0
  240. package/lib/task-environment/workspace-files.ts +685 -0
  241. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  242. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  243. package/lib/task-files-machine-retirement.ts +116 -0
  244. package/lib/task-files-readiness.ts +127 -0
  245. package/lib/task-gateway-port.ts +22 -0
  246. package/lib/task-gateway-protocol.ts +190 -0
  247. package/lib/task-gateway-recovery.ts +47 -0
  248. package/lib/task-gateway-state.ts +512 -0
  249. package/lib/task-identity.ts +19 -0
  250. package/lib/task-inventory.ts +682 -0
  251. package/lib/task-machine-credentials-activation.ts +95 -0
  252. package/lib/task-machine-swap-safety.ts +729 -0
  253. package/lib/task-person-credentials.ts +76 -0
  254. package/lib/task-project-ops-host.ts +147 -0
  255. package/lib/task-project-ops.ts +1062 -0
  256. package/lib/task-secret-staging.ts +16 -0
  257. package/lib/task-status.ts +46 -0
  258. package/lib/task-token.ts +46 -0
  259. package/lib/token-broker-adopt.ts +344 -0
  260. package/lib/token-broker-lambda.ts +105 -0
  261. package/lib/token-broker-local.ts +392 -0
  262. package/lib/token-broker-revision.ts +310 -0
  263. package/lib/token-broker-ssm.ts +525 -0
  264. package/lib/token-broker.ts +218 -0
  265. package/lib/token-lanes/openai-oauth.ts +123 -0
  266. package/lib/token-lanes/registry.ts +45 -0
  267. package/lib/token-lanes/types.ts +163 -0
  268. package/lib/token-lanes/xai-oauth.ts +165 -0
  269. package/lib/transcript.ts +128 -0
  270. package/lib/tunnel-registry.ts +220 -0
  271. package/lib/tunnel-runtime.ts +235 -0
  272. package/lib/ulid.ts +16 -0
  273. package/lib/user-bus-env.ts +116 -0
  274. package/lib/user-credentials-store.ts +435 -0
  275. package/lib/vnode-pressure.ts +142 -0
  276. package/package.json +115 -8
  277. package/runner/runner.mjs +442 -0
  278. package/scripts/agent/_common.sh +633 -0
  279. package/scripts/agent/env-hook.sh +626 -0
  280. package/scripts/agent/moondeck-cli.sh +5 -0
  281. package/scripts/agent/task-down.sh +291 -0
  282. package/scripts/agent/task-status.sh +70 -0
  283. package/scripts/agent/task-up.sh +3761 -0
  284. package/scripts/agent/uai-env-exec.sh +26 -0
  285. package/scripts/install/darwin.ts +1048 -0
  286. package/scripts/install/linux.ts +958 -0
  287. package/scripts/install/types.ts +78 -0
  288. package/scripts/install/util.ts +254 -0
  289. package/scripts/install/win.ts +143 -0
  290. package/src/activation-respawn.ts +155 -0
  291. package/src/apple-tunnel-route.ts +104 -0
  292. package/src/bridge-diagnostics.ts +39 -0
  293. package/src/capture-wire.ts +102 -0
  294. package/src/cli.ts +2585 -0
  295. package/src/cloud-credentials.ts +2144 -0
  296. package/src/cloud-origins.ts +23 -0
  297. package/src/credential-location.ts +248 -0
  298. package/src/credential-wire-validation.ts +25 -0
  299. package/src/custom-connections.ts +237 -0
  300. package/src/engine-account-paths.ts +11 -0
  301. package/src/engine-models.ts +39 -0
  302. package/src/engine-usage.ts +104 -0
  303. package/src/enrollment-next-steps.ts +24 -0
  304. package/src/event-outbox.ts +185 -0
  305. package/src/index.ts +2907 -0
  306. package/src/load-env.ts +97 -0
  307. package/src/machine-tunnel-route.ts +156 -0
  308. package/src/main.ts +6363 -0
  309. package/src/never-proxied-domains.ts +31 -0
  310. package/src/outbound-frame.ts +31 -0
  311. package/src/paths.ts +81 -0
  312. package/src/project-env-latest.ts +130 -0
  313. package/src/project-env-policy.ts +396 -0
  314. package/src/project-env-wording.ts +53 -0
  315. package/src/protocol.ts +5550 -0
  316. package/src/runtime-bootstrap.ts +165 -0
  317. package/src/station-identity.ts +96 -0
  318. package/src/task-env-names.ts +28 -0
  319. package/src/task-secrets.ts +75 -0
  320. package/src/tunnel-dial.ts +102 -0
  321. package/src/ui/server.ts +852 -0
  322. package/src/ui/types.ts +173 -0
  323. package/src/workspace-path-policy.ts +41 -0
  324. package/ui/app.js +1102 -0
  325. package/ui/index.html +90 -0
  326. package/ui/style.css +685 -0
  327. package/ui/uai-favicon.svg +21 -0
  328. package/ui/uai-logo-black.svg +9 -0
  329. package/ui/uai-wheel.svg +9 -0
  330. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Per-OS service-installer contract (ADR-028).
3
+ *
4
+ * `host-agent/src/cli.ts` dispatches `install`/`uninstall`/`start`/`stop`/
5
+ * `restart`/`status` to one of `darwin.ts` / `linux.ts` / `win.ts` based on
6
+ * `process.platform`. Each module exports an `installer: Installer`.
7
+ *
8
+ * Bash-3.2 constraint does NOT apply here — these are TypeScript.
9
+ */
10
+
11
+ export interface InstallContext {
12
+ /** Absolute command the service runs (e.g. the `uai-host` binary). */
13
+ execPath: string;
14
+ /** Arguments for that command (e.g. ["run"]). */
15
+ args: string[];
16
+ /** Absolute working directory for the service. */
17
+ cwd: string;
18
+ /** PATH captured for the service. Managed installs prepend the stable shim. */
19
+ envPath?: string;
20
+ /** Register the service without launching it (fresh, unenrolled hosts). */
21
+ startOnInstall?: boolean;
22
+ /** Public install transaction which owns this definition replacement. */
23
+ definitionInstallIntent?: {
24
+ readonly nonce: string;
25
+ readonly ownerPid: number;
26
+ };
27
+ /**
28
+ * When true, print the unit file + the load commands instead of executing.
29
+ * Used by CI tests (no launchd/systemd/winsw side effects).
30
+ */
31
+ dryRun: boolean;
32
+ }
33
+
34
+ export interface ServiceManagerState {
35
+ /** The canonical job/unit is currently loaded by the service manager. */
36
+ readonly loaded: boolean;
37
+ /** A daemon is active or the manager has an in-flight activation/restart. */
38
+ readonly active: boolean;
39
+ /** The job/unit is enabled for a future login or reboot. */
40
+ readonly enabled: boolean;
41
+ }
42
+
43
+ export interface ServiceDefinitionState {
44
+ readonly ownership: "missing" | "managed" | "legacy";
45
+ readonly manager: ServiceManagerState;
46
+ }
47
+
48
+ export interface Installer {
49
+ /** Write the unit/plist/xml and load it. */
50
+ install(ctx: InstallContext): Promise<void>;
51
+ /** Unload + remove the unit. */
52
+ uninstall(dryRun?: boolean): Promise<void>;
53
+ start(dryRun?: boolean): Promise<void>;
54
+ stop(dryRun?: boolean): Promise<void>;
55
+ /** Owner-authorized stop used before a first managed conversion. */
56
+ stopForInstall(
57
+ installIntent: string,
58
+ priorManager: ServiceManagerState,
59
+ ): Promise<void>;
60
+ /** Owner-authorized idempotent resume after command-graph rollback. */
61
+ resumeServiceForInstall(
62
+ installIntent: string,
63
+ priorManager: ServiceManagerState,
64
+ ): Promise<void>;
65
+ restart(dryRun?: boolean): Promise<void>;
66
+ /** Human-readable one-line status (e.g. "running (pid 1234)"). */
67
+ status(): Promise<string>;
68
+ /** Resume/rollback an interrupted transactional definition replacement. */
69
+ reconcileDefinitionInstall(installIntent?: string): Promise<void>;
70
+ /** Restore definition/static manager state without launching the prior command. */
71
+ prepareDefinitionInstallRollback(installIntent: string): Promise<void>;
72
+ /** Resume the recorded prior service after runtime commands have been restored. */
73
+ resumeDefinitionInstallRollback(installIntent: string): Promise<void>;
74
+ /** Commit a definition replacement after the enclosing install attests. */
75
+ completeDefinitionInstall(installIntent?: string): Promise<void>;
76
+ /** Classify the canonical definition and snapshot its manager lifecycle state. */
77
+ definitionState(managedCommand: string): Promise<ServiceDefinitionState>;
78
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Shared install helpers (ADR-028). A `step` runs a command, or in dry-run
3
+ * prints it (so CI can assert the install plan without launchd/systemd/winsw
4
+ * side effects).
5
+ */
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { randomBytes } from "node:crypto";
9
+ import {
10
+ closeSync,
11
+ constants,
12
+ fchmodSync,
13
+ fstatSync,
14
+ fsyncSync,
15
+ mkdirSync,
16
+ openSync,
17
+ readSync,
18
+ readFileSync,
19
+ renameSync,
20
+ rmSync,
21
+ writeFileSync,
22
+ } from "node:fs";
23
+ import { dirname, basename, join } from "node:path";
24
+
25
+ import { systemUserBusProbe, withUserBusEnv } from "../../lib/user-bus-env";
26
+
27
+ export interface StepResult {
28
+ status: number | null;
29
+ stdout: string;
30
+ stderr: string;
31
+ error?: Error;
32
+ }
33
+
34
+ export type StepExecutor = (
35
+ dryRun: boolean,
36
+ cmd: string,
37
+ args: string[],
38
+ ) => StepResult;
39
+
40
+ export type DefinitionSnapshot =
41
+ | { readonly exists: false }
42
+ | { readonly exists: true; readonly bytes: Buffer; readonly mode: number };
43
+
44
+ export const MAX_SERVICE_DEFINITION_BYTES = 1024 * 1024;
45
+ export const MAX_SERVICE_INSTALL_JOURNAL_BYTES = 2 * 1024 * 1024;
46
+
47
+ export type DirectorySync = (directory: string) => void;
48
+
49
+ function readBounded(fd: number, maximumBytes: number, description: string): Buffer {
50
+ const bytes = Buffer.allocUnsafe(maximumBytes + 1);
51
+ let length = 0;
52
+ while (length < bytes.length) {
53
+ const read = readSync(fd, bytes, length, bytes.length - length, null);
54
+ if (read === 0) break;
55
+ length += read;
56
+ }
57
+ if (length > maximumBytes) {
58
+ throw new Error(`${description} exceeds ${maximumBytes} bytes`);
59
+ }
60
+ return bytes.subarray(0, length);
61
+ }
62
+
63
+ /** Read a service definition without following a surprising symlink. */
64
+ export function snapshotDefinition(file: string): DefinitionSnapshot {
65
+ let fd: number;
66
+ try {
67
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
68
+ } catch (error) {
69
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false };
70
+ throw error;
71
+ }
72
+ try {
73
+ const stat = fstatSync(fd);
74
+ if (!stat.isFile()) {
75
+ throw new Error(`service definition is not a regular file: ${file}`);
76
+ }
77
+ if (stat.size > MAX_SERVICE_DEFINITION_BYTES) {
78
+ throw new Error(`service definition exceeds ${MAX_SERVICE_DEFINITION_BYTES} bytes: ${file}`);
79
+ }
80
+ const bytes = readBounded(
81
+ fd,
82
+ MAX_SERVICE_DEFINITION_BYTES,
83
+ `service definition ${file}`,
84
+ );
85
+ return {
86
+ exists: true,
87
+ bytes,
88
+ mode: stat.mode & 0o7777,
89
+ };
90
+ } finally {
91
+ closeSync(fd);
92
+ }
93
+ }
94
+
95
+ /** Atomically replace a definition and sync both its bytes and directory entry. */
96
+ export function writeDefinitionDurable(
97
+ file: string,
98
+ contents: string | Buffer,
99
+ mode = 0o644,
100
+ syncDirectoryEntry: DirectorySync = syncDirectoryDurable,
101
+ ): void {
102
+ const directory = dirname(file);
103
+ mkdirSync(directory, { recursive: true });
104
+ const temporary = join(
105
+ directory,
106
+ `.${basename(file)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
107
+ );
108
+ let fd: number | undefined;
109
+ try {
110
+ fd = openSync(
111
+ temporary,
112
+ constants.O_WRONLY |
113
+ constants.O_CREAT |
114
+ constants.O_EXCL |
115
+ (constants.O_NOFOLLOW ?? 0),
116
+ 0o600,
117
+ );
118
+ writeFileSync(fd, contents);
119
+ fchmodSync(fd, mode);
120
+ fsyncSync(fd);
121
+ closeSync(fd);
122
+ fd = undefined;
123
+ renameSync(temporary, file);
124
+ syncDirectoryEntry(directory);
125
+ } finally {
126
+ if (fd !== undefined) closeSync(fd);
127
+ rmSync(temporary, { force: true });
128
+ }
129
+ }
130
+
131
+ export function restoreDefinitionDurable(
132
+ file: string,
133
+ snapshot: DefinitionSnapshot,
134
+ syncDirectoryEntry: DirectorySync = syncDirectoryDurable,
135
+ ): void {
136
+ if (snapshot.exists) {
137
+ writeDefinitionDurable(file, snapshot.bytes, snapshot.mode, syncDirectoryEntry);
138
+ return;
139
+ }
140
+ rmSync(file, { force: true });
141
+ syncDirectoryEntry(dirname(file));
142
+ }
143
+
144
+ export function readPrivateJournal(file: string): string | null {
145
+ let fd: number;
146
+ try {
147
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
148
+ } catch (error) {
149
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
150
+ throw error;
151
+ }
152
+ try {
153
+ const stat = fstatSync(fd);
154
+ if (!stat.isFile()) throw new Error(`service install journal is not regular: ${file}`);
155
+ if ((stat.mode & 0o077) !== 0) {
156
+ throw new Error(`service install journal permissions are too broad: ${file}`);
157
+ }
158
+ if (stat.size > MAX_SERVICE_INSTALL_JOURNAL_BYTES) {
159
+ throw new Error(
160
+ `service install journal exceeds ${MAX_SERVICE_INSTALL_JOURNAL_BYTES} bytes: ${file}`,
161
+ );
162
+ }
163
+ return readBounded(
164
+ fd,
165
+ MAX_SERVICE_INSTALL_JOURNAL_BYTES,
166
+ `service install journal ${file}`,
167
+ ).toString("utf8");
168
+ } finally {
169
+ closeSync(fd);
170
+ }
171
+ }
172
+
173
+ export function removeFileDurable(file: string): void {
174
+ rmSync(file, { force: true });
175
+ syncDirectoryDurable(dirname(file));
176
+ }
177
+
178
+ export function syncDirectoryDurable(directory: string): void {
179
+ const fd = openSync(directory, constants.O_RDONLY);
180
+ try {
181
+ fsyncSync(fd);
182
+ } finally {
183
+ closeSync(fd);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Run a service-manager command without deciding whether its failure is safe.
189
+ * Stop/uninstall need the complete result so they can distinguish an absent
190
+ * unit from a manager failure or a daemon which is still live.
191
+ */
192
+ export const executeStep: StepExecutor = (dryRun, cmd, args) => {
193
+ if (dryRun) {
194
+ console.log(`$ ${cmd} ${args.join(" ")}`);
195
+ return { status: 0, stdout: "", stderr: "" };
196
+ }
197
+ // `systemctl --user` needs the user's bus; a stripped environment loses it
198
+ // (lib/user-bus-env.ts). Everything else inherits this process's env as is.
199
+ const userManager = cmd === "systemctl" && args.includes("--user");
200
+ const res = spawnSync(cmd, args, {
201
+ encoding: "utf8",
202
+ ...(userManager ? { env: withUserBusEnv(process.env, systemUserBusProbe()) } : {}),
203
+ });
204
+ return {
205
+ status: res.status,
206
+ stdout: res.stdout ?? "",
207
+ stderr: res.stderr ?? "",
208
+ error: res.error,
209
+ };
210
+ };
211
+
212
+ export function stepFailure(
213
+ cmd: string,
214
+ args: string[],
215
+ result: StepResult,
216
+ context?: string,
217
+ ): Error {
218
+ const detail = (
219
+ result.stderr ||
220
+ result.stdout ||
221
+ result.error?.message ||
222
+ `exit status ${String(result.status)}`
223
+ ).trim();
224
+ return new Error(
225
+ `\`${cmd} ${args.join(" ")}\` failed: ${detail}${context ? ` (${context})` : ""}`,
226
+ );
227
+ }
228
+
229
+ export function step(
230
+ dryRun: boolean,
231
+ cmd: string,
232
+ args: string[],
233
+ opts: { ignoreError?: boolean } = {},
234
+ ): string {
235
+ const result = executeStep(dryRun, cmd, args);
236
+ if (result.status !== 0 && !opts.ignoreError) {
237
+ throw stepFailure(cmd, args, result);
238
+ }
239
+ return result.stdout;
240
+ }
241
+
242
+ /** Print a unit/plist/xml file's path + contents (dry-run). */
243
+ export function printFile(path: string, content: string): void {
244
+ console.log(`# ${path}`);
245
+ console.log(content);
246
+ }
247
+
248
+ export function escapeXml(s: string): string {
249
+ return s
250
+ .replace(/&/g, "&amp;")
251
+ .replace(/</g, "&lt;")
252
+ .replace(/>/g, "&gt;")
253
+ .replace(/"/g, "&quot;");
254
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Windows service install via winsw (ADR-028).
3
+ *
4
+ * Downloads a pinned winsw binary to %LOCALAPPDATA%\Moondeck\service\moondeck-station.exe,
5
+ * writes moondeck-station.xml beside it (winsw reads <basename>.xml), and registers
6
+ * with `moondeck-station.exe install`. Logs to %LOCALAPPDATA%\Moondeck\service\logs\.
7
+ *
8
+ * Untested on non-Windows hosts (dev is macOS); the dry-run plan + the XML
9
+ * render are covered by tests.
10
+ */
11
+
12
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
13
+ import { writeFile } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ import { STATION } from "../../src/station-identity";
18
+ import type { InstallContext, Installer } from "./types";
19
+ import { escapeXml, printFile, step } from "./util";
20
+
21
+ // Pinned winsw release (bump deliberately).
22
+ const WINSW_URL =
23
+ "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe";
24
+
25
+ function serviceDir(): string {
26
+ const base = process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local");
27
+ return join(base, STATION.logDirName, "service");
28
+ }
29
+ export function exePath(): string {
30
+ return join(serviceDir(), `${STATION.command}.exe`);
31
+ }
32
+ export function xmlPath(): string {
33
+ return join(serviceDir(), `${STATION.command}.xml`);
34
+ }
35
+ function logDir(): string {
36
+ return join(serviceDir(), "logs");
37
+ }
38
+
39
+ export interface XmlInputs {
40
+ execPath: string;
41
+ args: string[];
42
+ cwd: string;
43
+ path: string;
44
+ logDir: string;
45
+ }
46
+
47
+ export function renderXml(p: XmlInputs): string {
48
+ return `<service>
49
+ <id>${STATION.command}</id>
50
+ <name>${STATION.displayName}</name>
51
+ <description>${STATION.displayName} (ADR-028)</description>
52
+ <executable>${escapeXml(p.execPath)}</executable>
53
+ <arguments>${escapeXml(p.args.join(" "))}</arguments>
54
+ <workingdirectory>${escapeXml(p.cwd)}</workingdirectory>
55
+ <env name="PATH" value="${escapeXml(p.path)}" />
56
+ <onfailure action="restart" delay="10 sec" />
57
+ <log mode="roll" />
58
+ <logpath>${escapeXml(p.logDir)}</logpath>
59
+ </service>
60
+ `;
61
+ }
62
+
63
+ function inputs(ctx: InstallContext): XmlInputs {
64
+ return {
65
+ execPath: ctx.execPath,
66
+ args: ctx.args,
67
+ cwd: ctx.cwd,
68
+ path: process.env.PATH ?? "",
69
+ logDir: logDir(),
70
+ };
71
+ }
72
+
73
+ async function downloadWinsw(dest: string): Promise<void> {
74
+ const res = await fetch(WINSW_URL);
75
+ if (!res.ok) throw new Error(`winsw download failed: ${res.status}`);
76
+ const buf = Buffer.from(await res.arrayBuffer());
77
+ await writeFile(dest, buf);
78
+ }
79
+
80
+ async function install(ctx: InstallContext): Promise<void> {
81
+ const xml = renderXml(inputs(ctx));
82
+ if (ctx.dryRun) {
83
+ printFile(xmlPath(), xml);
84
+ console.log(`# download winsw -> ${exePath()}`);
85
+ console.log(`# ${WINSW_URL}`);
86
+ step(true, exePath(), ["install"]);
87
+ step(true, exePath(), ["start"]);
88
+ return;
89
+ }
90
+ mkdirSync(logDir(), { recursive: true });
91
+ await downloadWinsw(exePath());
92
+ writeFileSync(xmlPath(), xml);
93
+ step(false, exePath(), ["install"]);
94
+ step(false, exePath(), ["start"]);
95
+ }
96
+
97
+ async function uninstall(dryRun = false): Promise<void> {
98
+ step(dryRun, exePath(), ["stop"], { ignoreError: true });
99
+ step(dryRun, exePath(), ["uninstall"], { ignoreError: true });
100
+ if (dryRun) {
101
+ console.log(`$ rm -f ${exePath()} ${xmlPath()}`);
102
+ return;
103
+ }
104
+ rmSync(exePath(), { force: true });
105
+ rmSync(xmlPath(), { force: true });
106
+ }
107
+
108
+ async function start(dryRun = false): Promise<void> {
109
+ step(dryRun, exePath(), ["start"]);
110
+ }
111
+
112
+ async function stop(dryRun = false): Promise<void> {
113
+ step(dryRun, exePath(), ["stop"], { ignoreError: true });
114
+ }
115
+
116
+ async function restart(dryRun = false): Promise<void> {
117
+ step(dryRun, exePath(), ["restart"]);
118
+ }
119
+
120
+ async function status(): Promise<string> {
121
+ return step(false, exePath(), ["status"], { ignoreError: true }).trim() || "unknown";
122
+ }
123
+
124
+ export const installer: Installer = {
125
+ install,
126
+ uninstall,
127
+ start,
128
+ stop,
129
+ stopForInstall: async () => undefined,
130
+ resumeServiceForInstall: async () => undefined,
131
+ restart,
132
+ status,
133
+ // Standalone managed runtimes currently reject Windows before service
134
+ // installation, so there is no Windows definition journal to reconcile.
135
+ reconcileDefinitionInstall: async () => undefined,
136
+ prepareDefinitionInstallRollback: async () => undefined,
137
+ resumeDefinitionInstallRollback: async () => undefined,
138
+ completeDefinitionInstall: async () => undefined,
139
+ definitionState: async () => ({
140
+ ownership: "missing",
141
+ manager: { loaded: false, active: false, enabled: false },
142
+ }),
143
+ };
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The macOS activation restart must not depend on launchd KeepAlive.
3
+ *
4
+ * A staged runtime activation (the in-app Update, `uai-host update`, the
5
+ * remote hostRestart) drains in-flight work and exits with
6
+ * MANAGED_UPDATE_RESTART_EXIT_CODE (75) so the service supervisor relaunches
7
+ * `runtime/current`. On Linux `Restart=on-failure` does exactly that. On
8
+ * macOS the LaunchAgent is `KeepAlive: true` — and twice on 2026-09-06 launchd
9
+ * did NOT respawn the job: `launchctl print gui/501/com.runuai.host` sat at
10
+ * `state = not running`, `last exit code = 75: EX_TEMPFAIL`,
11
+ * `pended nondemand spawn = inefficient` until the operator ran
12
+ * `uai-host start` (a plain `launchctl kickstart` of the job), which recovered
13
+ * it instantly.
14
+ *
15
+ * So the exiting process schedules that kickstart itself, from a child that
16
+ * survives it: a detached (`setsid`, so it leaves the job's process group and
17
+ * launchd's exit-time group cleanup cannot reach it), unref'd, stdio-less
18
+ * shell that sleeps a few seconds and then kickstarts the job. Plain
19
+ * `kickstart` first — a no-op when KeepAlive did respawn, so a fresh instance
20
+ * is never killed mid-boot — and `kickstart -k` as the fallback should launchd
21
+ * refuse the plain one (kill whatever is there and start again; by then
22
+ * nothing of ours should be running).
23
+ *
24
+ * It is scheduled only where it can matter: darwin, not under the desktop
25
+ * app's supervisor (which relaunches the agent on exit itself), and only when
26
+ * launchd is this process's parent — a `uai-host run` in a terminal, or a test
27
+ * spawning the CLI, has nothing to kick and must never touch a live service.
28
+ */
29
+
30
+ import { spawn as nodeSpawn } from "node:child_process";
31
+
32
+ import { launchdJobTarget } from "../scripts/install/darwin";
33
+
34
+ /** Long enough for a KeepAlive respawn, if launchd honours it, to have
35
+ * happened first (so the plain kickstart is a no-op); short enough that the
36
+ * host is back within seconds when it does not. */
37
+ export const ACTIVATION_RESPAWN_DELAY_SECONDS = 5;
38
+
39
+ export const ACTIVATION_RESPAWN_SHELL = "/bin/sh";
40
+ /** Absolute: the detached shell inherits the service environment, and a
41
+ * launchd-spawned job's PATH is whatever the plist captured at install. */
42
+ export const ACTIVATION_RESPAWN_LAUNCHCTL = "/bin/launchctl";
43
+ /** `$1` = delay in seconds, `$2` = the launchd job target. Positional, never
44
+ * interpolated, so the command line stays inert whatever the inputs. */
45
+ export const ACTIVATION_RESPAWN_SCRIPT = `sleep "$1"; ${ACTIVATION_RESPAWN_LAUNCHCTL} kickstart "$2" || exec ${ACTIVATION_RESPAWN_LAUNCHCTL} kickstart -k "$2"`;
46
+
47
+ export interface ActivationRespawnChild {
48
+ on(event: "error", listener: (error: Error) => void): unknown;
49
+ unref(): void;
50
+ }
51
+
52
+ export interface ActivationRespawnSpawnOptions {
53
+ readonly detached: true;
54
+ readonly stdio: "ignore";
55
+ }
56
+
57
+ export type ActivationRespawnSpawn = (
58
+ command: string,
59
+ args: readonly string[],
60
+ options: ActivationRespawnSpawnOptions,
61
+ ) => ActivationRespawnChild;
62
+
63
+ export interface ActivationRespawnSeams {
64
+ platform?: NodeJS.Platform;
65
+ /** `process.ppid`; launchd is pid 1 and the parent of every job it runs. */
66
+ parentPid?: number;
67
+ /** `process.getuid()`; `undefined` models a platform without uids. */
68
+ uid?: number | undefined;
69
+ /** `process.versions.electron` — the desktop app runs the agent as a
70
+ * supervised child and restarts it on exit itself. */
71
+ underElectron?: boolean;
72
+ delaySeconds?: number;
73
+ spawn?: ActivationRespawnSpawn;
74
+ }
75
+
76
+ export type ActivationRespawnPlan =
77
+ | {
78
+ readonly scheduled: true;
79
+ readonly target: string;
80
+ readonly command: string;
81
+ readonly args: readonly string[];
82
+ readonly delaySeconds: number;
83
+ }
84
+ | { readonly scheduled: false; readonly reason: string };
85
+
86
+ /**
87
+ * Call immediately before `process.exit(MANAGED_UPDATE_RESTART_EXIT_CODE)`:
88
+ * the drain is complete, so the delay counts from the moment the old process
89
+ * is gone. `spawn` forks synchronously, so the child exists when this returns
90
+ * and the caller may exit at once.
91
+ */
92
+ export function scheduleActivationRespawn(
93
+ seams: ActivationRespawnSeams = {},
94
+ ): ActivationRespawnPlan {
95
+ const platform = seams.platform ?? process.platform;
96
+ if (platform !== "darwin") {
97
+ return {
98
+ scheduled: false,
99
+ reason: `${platform} relies on its service supervisor's restart policy`,
100
+ };
101
+ }
102
+ if (seams.underElectron ?? Boolean(process.versions.electron)) {
103
+ return {
104
+ scheduled: false,
105
+ reason: "the desktop app's supervisor relaunches the agent",
106
+ };
107
+ }
108
+ const parentPid = seams.parentPid ?? process.ppid;
109
+ if (parentPid !== 1) {
110
+ return {
111
+ scheduled: false,
112
+ reason: `not launchd-spawned (parent pid ${parentPid}); nothing to kick`,
113
+ };
114
+ }
115
+ const uid = "uid" in seams ? seams.uid : process.getuid?.();
116
+ if (uid === undefined) {
117
+ return { scheduled: false, reason: "no uid to address the launchd domain" };
118
+ }
119
+ const delaySeconds = seams.delaySeconds ?? ACTIVATION_RESPAWN_DELAY_SECONDS;
120
+ const target = launchdJobTarget(uid);
121
+ const args = [
122
+ "-c",
123
+ ACTIVATION_RESPAWN_SCRIPT,
124
+ "moondeck-station-activation-respawn",
125
+ String(delaySeconds),
126
+ target,
127
+ ];
128
+ const spawn = seams.spawn ?? (nodeSpawn as unknown as ActivationRespawnSpawn);
129
+ let child: ActivationRespawnChild;
130
+ try {
131
+ child = spawn(ACTIVATION_RESPAWN_SHELL, args, {
132
+ detached: true,
133
+ stdio: "ignore",
134
+ });
135
+ } catch (error) {
136
+ return {
137
+ scheduled: false,
138
+ reason: `could not spawn the respawn shell: ${
139
+ error instanceof Error ? error.message : String(error)
140
+ }`,
141
+ };
142
+ }
143
+ // A spawn that fails after the fork leaves KeepAlive as the only respawn —
144
+ // the pre-0.9.99 behaviour — and must not become an uncaught 'error' in a
145
+ // process that is about to exit anyway.
146
+ child.on("error", () => {});
147
+ child.unref();
148
+ return {
149
+ scheduled: true,
150
+ target,
151
+ command: ACTIVATION_RESPAWN_SHELL,
152
+ args,
153
+ delaySeconds,
154
+ };
155
+ }