@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,860 @@
1
+ /**
2
+ * ADR-106 Apple container TaskEnvironment provider.
3
+ *
4
+ * One lightweight VM per task container via Apple's `container` toolchain,
5
+ * invoked ONLY through the authenticated bundled CLI path resolved from the
6
+ * capability-3 descriptor — never from PATH. Container IPs are host-routable
7
+ * over vmnet, so editor/preview routing dials the container address directly
8
+ * and this provider publishes no host ports at all.
9
+ *
10
+ * CLI semantics encoded here were probed against container 1.2.2:
11
+ * - `inspect <name>` exits 1 with "container not found" for absence and
12
+ * otherwise prints a JSON array with {id, status:{state, networks[]},
13
+ * configuration:{labels, image, mounts}}; `id` IS the container name.
14
+ * - `stop` is idempotent (exit 0 for an already-stopped container).
15
+ * - `cp` uses docker-style `name:path` addressing.
16
+ * - `exec` supports -i/-d, --env K=V and bare K inheritance, --user,
17
+ * --workdir.
18
+ */
19
+
20
+ import { spawn as nodeSpawn } from "node:child_process";
21
+ import { lstatSync } from "node:fs";
22
+ import path from "node:path";
23
+
24
+ import type { TaskDownResult } from "../agent";
25
+ import {
26
+ hostWorkspaceRead,
27
+ hostWorkspaceWrite,
28
+ } from "./workspace-files";
29
+ import { assertSafeHostTaskId } from "../task-identity";
30
+ import { applyProjectEnvProfile } from "../runtime-authority";
31
+ import { rootAnchoredRequest } from "./anchor";
32
+ import {
33
+ capturedCliExec,
34
+ cliExecEnv,
35
+ streamingCliExec,
36
+ } from "./docker";
37
+ import {
38
+ assertTaskEnvironmentExecRequest,
39
+ assertTaskEnvironmentProcessRequest,
40
+ refusePrivilegedExec,
41
+ assertTaskEnvironmentSessionRequest,
42
+ type TaskEnvironmentCallOptions,
43
+ type TaskEnvironmentReadOptions,
44
+ type TaskEnvironmentCopyRequest,
45
+ type TaskEnvironmentDescriptor,
46
+ type TaskEnvironmentDetachedSessionRequest,
47
+ type TaskEnvironmentExecRequest,
48
+ type TaskEnvironmentExecResult,
49
+ type TaskEnvironmentHandle,
50
+ type TaskEnvironmentLocator,
51
+ type TaskEnvironmentPort,
52
+ type TaskEnvironmentProcess,
53
+ type ProjectEnvMode,
54
+ type TaskEnvironmentProvider,
55
+ type TaskEnvironmentRecoveryContext,
56
+ type TaskEnvironmentRecoveryResult,
57
+ type TaskEnvironmentSessionRequest,
58
+ type TaskEnvironmentSpawnRequest,
59
+ type TaskEnvironmentStatus,
60
+ } from "./types";
61
+ import type { TaskWriteEnvironment } from "../credential-consumers/task-files";
62
+
63
+ export const APPLE_TASK_ENVIRONMENT_PROVIDER = "apple-container";
64
+ const APPLE_LOCATOR_SCHEMA_VERSION = 1;
65
+ const MAX_LOCATOR_COMPONENT = 512;
66
+ const INSPECT_OUTPUT_LIMIT = 256 * 1024;
67
+ export const APPLE_TASK_LABEL = "io.moondeck.task";
68
+
69
+ export interface AppleMachineIdentity {
70
+ backend: "apple-container";
71
+ /** The per-user state root that owns every container (application
72
+ * support directory of the bundled toolchain). */
73
+ endpoint: string;
74
+ /** Host-minted durable installation id — the daemon-ID equivalent for a
75
+ * runtime with no daemon identity of its own. */
76
+ engineId: string;
77
+ }
78
+
79
+ interface AppleTaskEnvironmentLocatorValue {
80
+ schemaVersion: typeof APPLE_LOCATOR_SCHEMA_VERSION;
81
+ taskId: string;
82
+ containerName: string;
83
+ hostWorktreePath: string;
84
+ machine: AppleMachineIdentity;
85
+ }
86
+
87
+ export interface AppleCliResult {
88
+ status: number | null;
89
+ stdout: string;
90
+ stderr: string;
91
+ truncated?: boolean;
92
+ }
93
+
94
+ export type AppleCliRunner = (
95
+ args: readonly string[],
96
+ options: {
97
+ timeoutMs: number;
98
+ maxOutputBytes: number;
99
+ /** The CLI child's own environment, when an exec names a credential
100
+ * bare (cliExecEnv). Absent, it inherits the runner's. */
101
+ env?: NodeJS.ProcessEnv;
102
+ },
103
+ ) => Promise<AppleCliResult>;
104
+
105
+ export interface AppleContainerTaskEnvironmentDeps<
106
+ TInput = never,
107
+ TCredentials = never,
108
+ TResult = never,
109
+ > {
110
+ /** Absolute path of the authenticated bundled `container` CLI. Late-bound
111
+ * so an HMR reload or runtime re-resolution cannot strand a stale path. */
112
+ containerCliPath: () => string;
113
+ machineIdentity: () => AppleMachineIdentity;
114
+ /** Captured (non-streaming) CLI operations. Tests inject a fake. */
115
+ run?: AppleCliRunner;
116
+ spawn?: typeof nodeSpawn;
117
+ /** Provisioning is wired only where the concrete task-up contract exists
118
+ * (lib/task-environment/index.ts); this module stays agnostic of it. An
119
+ * absent group keeps provision() fail-closed. */
120
+ provisioning?: AppleContainerProvisioningDeps<TInput, TCredentials, TResult>;
121
+ /** Complete teardown through task-down.sh (container + derived image +
122
+ * workspace + durable row). Production wiring always supplies it. */
123
+ taskEnvironmentDown?: (taskId: string) => Promise<TaskDownResult>;
124
+ /** Late-bound recovery implementation (orchestrator-owned, like Docker's). */
125
+ recoveryDriver?: () => AppleTaskEnvironmentRecoveryDriver | null;
126
+ }
127
+
128
+ /** The operational surface every handle needs; provisioning stays separate so
129
+ * a reconstruct-only registration keeps its narrower obligations. */
130
+ interface ResolvedAppleContainerDeps {
131
+ containerCliPath: () => string;
132
+ machineIdentity: () => AppleMachineIdentity;
133
+ run: AppleCliRunner;
134
+ spawn: typeof nodeSpawn;
135
+ taskEnvironmentDown?: (taskId: string) => Promise<TaskDownResult>;
136
+ recoveryDriver?: () => AppleTaskEnvironmentRecoveryDriver | null;
137
+ }
138
+
139
+ export type AppleTaskEnvironmentRecoveryDriver = (
140
+ descriptor: TaskEnvironmentDescriptor,
141
+ context: TaskEnvironmentRecoveryContext,
142
+ ) => Promise<TaskEnvironmentRecoveryResult>;
143
+
144
+ export interface AppleContainerProvisioningDeps<TInput, TCredentials, TResult> {
145
+ /** Runs task-up with the selection environment already published by the
146
+ * runtime pin (UAI_TASK_RUNTIME / UAI_CONTAINER_CLI). */
147
+ taskUp: (
148
+ input: TInput,
149
+ credentials?: TCredentials,
150
+ envMode?: ProjectEnvMode,
151
+ runtimeEnvironment?: () => TaskWriteEnvironment,
152
+ ) => Promise<TResult>;
153
+ inputTaskId: (input: TInput) => string;
154
+ /** The host worktree path this task will occupy, derivable before task-up
155
+ * runs so a crash mid-provision still leaves a recoverable locator. */
156
+ hostWorktreePath: (taskId: string) => string;
157
+ resultWorktreePath: (result: TResult) => string;
158
+ }
159
+
160
+ export function appleTaskContainerName(taskId: string): string {
161
+ assertSafeHostTaskId(taskId);
162
+ return `moondeck-task-${taskId}-app`;
163
+ }
164
+
165
+ /** ADR-118: the task-private block volume carrying the pnpm store. Must
166
+ * match apple_store_volume_for_task in scripts/agent/_common.sh. */
167
+ export function appleTaskStoreVolumeName(taskId: string): string {
168
+ assertSafeHostTaskId(taskId);
169
+ return `moondeck-store-${taskId}`;
170
+ }
171
+
172
+ export function createAppleContainerTaskEnvironmentProvider<
173
+ TInput = never,
174
+ TCredentials = never,
175
+ TResult = never,
176
+ >(
177
+ deps: AppleContainerTaskEnvironmentDeps<TInput, TCredentials, TResult>,
178
+ ): TaskEnvironmentProvider<TInput, TCredentials, TResult, TaskDownResult> {
179
+ const resolved = {
180
+ containerCliPath: deps.containerCliPath,
181
+ machineIdentity: deps.machineIdentity,
182
+ run: deps.run ?? defaultRunner(deps.containerCliPath),
183
+ spawn: deps.spawn ?? nodeSpawn,
184
+ taskEnvironmentDown: deps.taskEnvironmentDown,
185
+ recoveryDriver: deps.recoveryDriver,
186
+ };
187
+ const provisioning = deps.provisioning;
188
+ return {
189
+ kind: APPLE_TASK_ENVIRONMENT_PROVIDER,
190
+ async provision(request) {
191
+ if (provisioning === undefined) {
192
+ throw new Error("apple-container provisioning is not available yet");
193
+ }
194
+ if (request.taskId !== provisioning.inputTaskId(request.input)) {
195
+ throw new Error("task environment provision identity mismatch");
196
+ }
197
+ const machine = validateAppleMachineIdentity(resolved.machineIdentity());
198
+ const preparedLocator = appleTaskEnvironmentLocator(
199
+ request.taskId,
200
+ provisioning.hostWorktreePath(request.taskId),
201
+ machine,
202
+ );
203
+ await request.onPrepared?.(preparedLocator);
204
+ const result = await provisioning.taskUp(
205
+ request.input,
206
+ request.credentials,
207
+ request.envMode ?? "loader",
208
+ // ADR-154 §12: the container as task-up has created it, for the
209
+ // runtime-ready writes before uai-init.
210
+ () => new AppleContainerTaskEnvironmentHandle(preparedLocator, resolved, request.envMode ?? "loader"),
211
+ );
212
+ const prepared = parseAppleTaskEnvironmentLocator(preparedLocator);
213
+ // task-up composes its worktree path in shell from the operator's
214
+ // UAI_WORKSPACE_ROOT verbatim; resolve before comparing so a benign
215
+ // trailing slash cannot fail the proof after the container is up, while
216
+ // a genuinely different directory still fails closed.
217
+ if (
218
+ path.resolve(provisioning.resultWorktreePath(result)) !==
219
+ prepared.hostWorktreePath
220
+ ) {
221
+ throw new Error(
222
+ "task environment returned an unexpected worktree path",
223
+ );
224
+ }
225
+ return {
226
+ handle: new AppleContainerTaskEnvironmentHandle(
227
+ preparedLocator,
228
+ resolved,
229
+ request.envMode ?? "loader",
230
+ ),
231
+ result,
232
+ };
233
+ },
234
+ async reconstruct(locator, envMode = "baked") {
235
+ const persisted = parseAppleTaskEnvironmentLocator(locator);
236
+ const current = validateAppleMachineIdentity(resolved.machineIdentity());
237
+ if (
238
+ persisted.machine.endpoint !== current.endpoint ||
239
+ persisted.machine.engineId !== current.engineId
240
+ ) {
241
+ throw new Error(
242
+ "task environment belongs to a different container backend",
243
+ );
244
+ }
245
+ return new AppleContainerTaskEnvironmentHandle(locator, resolved, envMode);
246
+ },
247
+ };
248
+ }
249
+
250
+ export function appleTaskEnvironmentLocator(
251
+ taskId: string,
252
+ hostWorktreePath: string,
253
+ machine: AppleMachineIdentity,
254
+ ): TaskEnvironmentLocator {
255
+ const value: AppleTaskEnvironmentLocatorValue = {
256
+ schemaVersion: APPLE_LOCATOR_SCHEMA_VERSION,
257
+ taskId,
258
+ containerName: appleTaskContainerName(taskId),
259
+ hostWorktreePath,
260
+ machine: validateAppleMachineIdentity(machine),
261
+ };
262
+ validateAppleLocatorValue(value);
263
+ return {
264
+ schemaVersion: 1,
265
+ provider: APPLE_TASK_ENVIRONMENT_PROVIDER,
266
+ value,
267
+ };
268
+ }
269
+
270
+ export function parseAppleTaskEnvironmentLocator(
271
+ locator: TaskEnvironmentLocator,
272
+ ): AppleTaskEnvironmentLocatorValue {
273
+ if (
274
+ locator.schemaVersion !== 1 ||
275
+ locator.provider !== APPLE_TASK_ENVIRONMENT_PROVIDER
276
+ ) {
277
+ throw new Error(
278
+ "task environment locator does not belong to Apple container",
279
+ );
280
+ }
281
+ return validateAppleLocatorValue(locator.value);
282
+ }
283
+
284
+ class AppleContainerTaskEnvironmentHandle
285
+ implements TaskEnvironmentHandle<TaskDownResult>
286
+ {
287
+ readonly descriptor: TaskEnvironmentDescriptor;
288
+ readonly durableIdentity: string;
289
+ /** ADR-154 §6: anchored requests start under the engine supervisor. */
290
+ readonly anchors = true;
291
+ readonly #value: AppleTaskEnvironmentLocatorValue;
292
+
293
+ constructor(
294
+ locator: TaskEnvironmentLocator,
295
+ private readonly deps: ResolvedAppleContainerDeps,
296
+ envMode: ProjectEnvMode,
297
+ ) {
298
+ this.#value = parseAppleTaskEnvironmentLocator(locator);
299
+ this.durableIdentity = this.#value.containerName;
300
+ this.descriptor = {
301
+ taskId: this.#value.taskId,
302
+ locator,
303
+ envMode,
304
+ workspacePath: "/workspace",
305
+ };
306
+ }
307
+
308
+ async readWorkspaceFile(filePath: string, options?: TaskEnvironmentReadOptions): Promise<Uint8Array | null> {
309
+ options?.admit?.();
310
+ return hostWorkspaceRead(
311
+ this.descriptor,
312
+ path.join(this.#value.hostWorktreePath, "workspace"),
313
+ filePath,
314
+ options,
315
+ );
316
+ }
317
+
318
+ async writeWorkspaceFile(filePath: string, bytes: Uint8Array, options?: TaskEnvironmentCallOptions): Promise<void> {
319
+ options?.admit?.();
320
+ await hostWorkspaceWrite(
321
+ this.descriptor,
322
+ path.join(this.#value.hostWorktreePath, "workspace"),
323
+ filePath,
324
+ bytes,
325
+ );
326
+ }
327
+
328
+ exec(request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult> {
329
+ assertTaskEnvironmentExecRequest(request);
330
+ refusePrivilegedExec(request);
331
+ const profiled = applyProjectEnvProfile(
332
+ rootAnchoredRequest(request),
333
+ this.descriptor.envMode ?? "baked",
334
+ "/usr/bin/zsh",
335
+ );
336
+ request.admit?.();
337
+ const invocation = appleExecArgs(
338
+ this.#value.containerName,
339
+ profiled,
340
+ "interactive",
341
+ );
342
+ return capturedCliExec(
343
+ this.deps.spawn,
344
+ this.deps.containerCliPath(),
345
+ invocation.args,
346
+ profiled,
347
+ invocation.env,
348
+ );
349
+ }
350
+
351
+ async spawn(
352
+ request: TaskEnvironmentSpawnRequest,
353
+ ): Promise<TaskEnvironmentProcess> {
354
+ assertTaskEnvironmentProcessRequest(request);
355
+ const profiled = applyProjectEnvProfile(
356
+ rootAnchoredRequest(request),
357
+ this.descriptor.envMode ?? "baked",
358
+ "/usr/bin/zsh",
359
+ );
360
+ const invocation = appleExecArgs(
361
+ this.#value.containerName,
362
+ profiled,
363
+ "interactive",
364
+ );
365
+ request.admit?.();
366
+ return streamingCliExec(
367
+ this.deps.spawn,
368
+ this.deps.containerCliPath(),
369
+ invocation.args,
370
+ profiled,
371
+ invocation.env,
372
+ );
373
+ }
374
+
375
+ async spawnSession(
376
+ request: TaskEnvironmentSessionRequest,
377
+ ): Promise<TaskEnvironmentProcess> {
378
+ assertTaskEnvironmentSessionRequest(request);
379
+ const profiled = applyProjectEnvProfile(
380
+ rootAnchoredRequest(request),
381
+ this.descriptor.envMode ?? "baked",
382
+ "/usr/bin/zsh",
383
+ );
384
+ const invocation = appleExecArgs(
385
+ this.#value.containerName,
386
+ profiled,
387
+ "interactive",
388
+ );
389
+ request.admit?.();
390
+ return streamingCliExec(
391
+ this.deps.spawn,
392
+ this.deps.containerCliPath(),
393
+ invocation.args,
394
+ profiled,
395
+ invocation.env,
396
+ );
397
+ }
398
+
399
+ async launchDetachedSession(
400
+ request: TaskEnvironmentDetachedSessionRequest,
401
+ ): Promise<TaskEnvironmentExecResult> {
402
+ assertTaskEnvironmentSessionRequest(request);
403
+ const profiled = applyProjectEnvProfile(
404
+ rootAnchoredRequest(request),
405
+ this.descriptor.envMode ?? "baked",
406
+ "/usr/bin/zsh",
407
+ );
408
+ const invocation = appleExecArgs(
409
+ this.#value.containerName,
410
+ profiled,
411
+ "detached",
412
+ );
413
+ request.admit?.();
414
+ const result = await this.deps.run(invocation.args, {
415
+ timeoutMs: request.launchTimeoutMs,
416
+ maxOutputBytes: request.maxOutputBytes,
417
+ ...(invocation.env === undefined ? {} : { env: invocation.env }),
418
+ });
419
+ return {
420
+ exitCode: result.status,
421
+ signal: null,
422
+ stdout: Buffer.from(result.stdout),
423
+ stderr: Buffer.from(result.stderr),
424
+ stdoutTruncated: result.truncated === true,
425
+ stderrTruncated: result.truncated === true,
426
+ };
427
+ }
428
+
429
+ async copy(request: TaskEnvironmentCopyRequest): Promise<void> {
430
+ validateCopyRequest(request);
431
+ const args =
432
+ request.direction === "into"
433
+ ? [
434
+ "cp",
435
+ request.source,
436
+ `${this.#value.containerName}:${request.destination}`,
437
+ ]
438
+ : [
439
+ "cp",
440
+ `${this.#value.containerName}:${request.source}`,
441
+ request.destination,
442
+ ];
443
+ request.admit?.();
444
+ const result = await this.deps.run(args, {
445
+ timeoutMs: 120_000,
446
+ maxOutputBytes: 1024 * 1024,
447
+ });
448
+ requireCliSuccess(result, "copy task environment data");
449
+ }
450
+
451
+ async ports(): Promise<TaskEnvironmentPort[]> {
452
+ // Direct-IP model: nothing is published on the host. Editor/preview
453
+ // routing resolves the container's vmnet address per connection through
454
+ // the provider-private inspection below.
455
+ return [];
456
+ }
457
+
458
+ async status(): Promise<TaskEnvironmentStatus> {
459
+ const inspected = await this.inspect();
460
+ // Workspace presence is a DISK fact, independent of container state: an
461
+ // absent container preserves its worktree (the cloud's stopped-vs-error
462
+ // decision hangs on exactly this), and a present container can have lost
463
+ // it. Only a real non-symlink directory counts.
464
+ const workspacePresent = ((): boolean => {
465
+ try {
466
+ const stat = lstatSync(
467
+ path.join(this.#value.hostWorktreePath, "workspace"),
468
+ );
469
+ return stat.isDirectory() && !stat.isSymbolicLink();
470
+ } catch {
471
+ return false;
472
+ }
473
+ })();
474
+ if (inspected.kind === "absent") {
475
+ return { state: "absent", instances: [], workspacePresent };
476
+ }
477
+ if (inspected.kind === "unknown") {
478
+ return { state: "unknown", detail: inspected.detail };
479
+ }
480
+ if (inspected.taskLabel !== this.#value.taskId) {
481
+ // A same-name container that does not carry this task's label is a
482
+ // foreign object, not this environment. It must never be started,
483
+ // executed into, or treated as evidence of any state.
484
+ return {
485
+ state: "unknown",
486
+ detail: "the task container name is held by a foreign container",
487
+ };
488
+ }
489
+ // Only exact terminal states map. A transitional answer ("starting",
490
+ // "stopping", ...) reported as stopped would let recovery issue a
491
+ // redundant start and persist stopped while the container goes live.
492
+ if (inspected.state !== "running" && inspected.state !== "stopped") {
493
+ return {
494
+ state: "unknown",
495
+ detail: `the task container is in a transitional state (${inspected.state})`,
496
+ };
497
+ }
498
+ return {
499
+ state: inspected.state,
500
+ instances: [this.#value.containerName],
501
+ workspacePresent,
502
+ };
503
+ }
504
+
505
+ async recover(
506
+ context: TaskEnvironmentRecoveryContext,
507
+ ): Promise<TaskEnvironmentRecoveryResult> {
508
+ const driver = this.deps.recoveryDriver?.();
509
+ if (driver) return driver(this.descriptor, context);
510
+ // Without a driver (provider tests), only a live environment may claim
511
+ // recovery; stopped/absent is deferred so it can never read as healthy.
512
+ const status = await this.status();
513
+ if (status.state === "unknown") {
514
+ return { outcome: "deferred", detail: status.detail };
515
+ }
516
+ if (status.state === "running") {
517
+ return { outcome: "recovered", status };
518
+ }
519
+ return {
520
+ outcome: "deferred",
521
+ detail: `task environment is ${status.state} and no recovery driver is bound`,
522
+ };
523
+ }
524
+
525
+ async stop(): Promise<void> {
526
+ // Idempotent by CLI contract (verified: exit 0 on already-stopped).
527
+ const result = await this.deps.run(
528
+ ["stop", this.#value.containerName],
529
+ { timeoutMs: 60_000, maxOutputBytes: 256 * 1024 },
530
+ );
531
+ if (
532
+ result.status !== 0 &&
533
+ !isNotFound(result, this.#value.containerName)
534
+ ) {
535
+ requireCliSuccess(result, "stop task environment");
536
+ }
537
+ }
538
+
539
+ async teardown(): Promise<TaskDownResult> {
540
+ // task-down.sh owns complete destruction — container, derived image, the
541
+ // task directory with its writable Git data, and the durable row — with
542
+ // its own name-anchored absence proof. The container-only path below
543
+ // exists for provider tests; production wiring always injects the script.
544
+ if (this.deps.taskEnvironmentDown !== undefined) {
545
+ return this.deps.taskEnvironmentDown(this.#value.taskId);
546
+ }
547
+ await this.stop();
548
+ const removed = await this.deps.run(
549
+ ["rm", this.#value.containerName],
550
+ { timeoutMs: 60_000, maxOutputBytes: 256 * 1024 },
551
+ );
552
+ const alreadyGone = isNotFound(removed, this.#value.containerName);
553
+ if (removed.status !== 0 && !alreadyGone) {
554
+ requireCliSuccess(removed, "remove task environment");
555
+ }
556
+ const after = await this.inspect();
557
+ if (after.kind !== "absent") {
558
+ throw new Error(
559
+ after.kind === "unknown"
560
+ ? `task environment teardown is unconfirmed: ${after.detail}`
561
+ : "task environment teardown left the container present",
562
+ );
563
+ }
564
+ return alreadyGone ? { alreadyGone: true } : { status: "removed" };
565
+ }
566
+
567
+ /** Provider-private identity-bound inspection: the routing layer uses the
568
+ * returned vmnet address only together with the task-label proof. */
569
+ async inspectRoute(): Promise<
570
+ | { kind: "running"; ipv4Address: string }
571
+ | { kind: "unavailable" }
572
+ | { kind: "unknown"; detail: string }
573
+ > {
574
+ const inspected = await this.inspect();
575
+ if (inspected.kind === "absent") return { kind: "unavailable" };
576
+ if (inspected.kind === "unknown") return inspected;
577
+ if (
578
+ inspected.state !== "running" ||
579
+ inspected.taskLabel !== this.#value.taskId ||
580
+ inspected.ipv4Address === null
581
+ ) {
582
+ return { kind: "unavailable" };
583
+ }
584
+ return { kind: "running", ipv4Address: inspected.ipv4Address };
585
+ }
586
+
587
+ async inspect(): Promise<
588
+ | { kind: "present"; state: string; taskLabel: string | null; ipv4Address: string | null }
589
+ | { kind: "absent" }
590
+ | { kind: "unknown"; detail: string }
591
+ > {
592
+ const result = await this.deps.run(
593
+ ["inspect", this.#value.containerName],
594
+ { timeoutMs: 15_000, maxOutputBytes: INSPECT_OUTPUT_LIMIT },
595
+ );
596
+ if (result.status !== 0) {
597
+ if (isNotFound(result, this.#value.containerName)) {
598
+ return { kind: "absent" };
599
+ }
600
+ return {
601
+ kind: "unknown",
602
+ detail: boundedDetail(result.stderr) || "container inspect failed",
603
+ };
604
+ }
605
+ if (result.truncated === true) {
606
+ return { kind: "unknown", detail: "container inspect output overflowed" };
607
+ }
608
+ let parsed: unknown;
609
+ try {
610
+ parsed = JSON.parse(result.stdout);
611
+ } catch {
612
+ return { kind: "unknown", detail: "container inspect output is not JSON" };
613
+ }
614
+ if (!Array.isArray(parsed) || parsed.length !== 1) {
615
+ return { kind: "unknown", detail: "container inspect shape is invalid" };
616
+ }
617
+ const entry = parsed[0] as {
618
+ id?: unknown;
619
+ status?: { state?: unknown; networks?: unknown };
620
+ configuration?: { labels?: unknown };
621
+ };
622
+ if (entry.id !== this.#value.containerName) {
623
+ return { kind: "unknown", detail: "container inspect identity mismatch" };
624
+ }
625
+ const state =
626
+ typeof entry.status?.state === "string" ? entry.status.state : null;
627
+ if (state === null) {
628
+ return { kind: "unknown", detail: "container inspect state is missing" };
629
+ }
630
+ const labels = entry.configuration?.labels;
631
+ const taskLabel =
632
+ labels && typeof labels === "object" && !Array.isArray(labels)
633
+ ? typeof (labels as Record<string, unknown>)[APPLE_TASK_LABEL] === "string"
634
+ ? ((labels as Record<string, unknown>)[APPLE_TASK_LABEL] as string)
635
+ : null
636
+ : null;
637
+ return {
638
+ kind: "present",
639
+ state,
640
+ taskLabel,
641
+ ipv4Address: firstIpv4(entry.status?.networks),
642
+ };
643
+ }
644
+ }
645
+
646
+ /** The argv of one `container exec`, and the environment the CLI must run
647
+ * with to resolve the credentials it names (cliExecEnv). */
648
+ function appleExecArgs(
649
+ containerName: string,
650
+ request:
651
+ | TaskEnvironmentExecRequest
652
+ | TaskEnvironmentSpawnRequest
653
+ | TaskEnvironmentSessionRequest
654
+ | TaskEnvironmentDetachedSessionRequest,
655
+ mode: "interactive" | "detached",
656
+ ): { args: string[]; env: NodeJS.ProcessEnv | undefined } {
657
+ const args = ["exec", mode === "detached" ? "-d" : "-i"];
658
+ if (request.cwd) args.push("--workdir", request.cwd);
659
+ if (request.user) args.push("--user", request.user);
660
+ const execEnv = cliExecEnv(request);
661
+ args.push(...execEnv.args, containerName, ...request.argv);
662
+ return { args, env: execEnv.env };
663
+ }
664
+
665
+ export function appleCliRunner(
666
+ containerCliPath: () => string,
667
+ ): AppleCliRunner {
668
+ return defaultRunner(containerCliPath);
669
+ }
670
+
671
+ function defaultRunner(containerCliPath: () => string): AppleCliRunner {
672
+ return (args, options) =>
673
+ new Promise((resolve) => {
674
+ const child = nodeSpawn(containerCliPath(), [...args], {
675
+ stdio: ["ignore", "pipe", "pipe"],
676
+ ...(options.env === undefined ? {} : { env: options.env }),
677
+ });
678
+ const stdout: Buffer[] = [];
679
+ const stderr: Buffer[] = [];
680
+ let bytes = 0;
681
+ let truncated = false;
682
+ const collect = (target: Buffer[]) => (chunk: Buffer) => {
683
+ bytes += chunk.length;
684
+ if (bytes > options.maxOutputBytes) {
685
+ truncated = true;
686
+ child.kill("SIGKILL");
687
+ // The overflow kill needs the same bounded settlement as the
688
+ // deadline kill: an unreapable child must not strand the caller.
689
+ const settle = setTimeout(() => finish(null), 5_000);
690
+ settle.unref?.();
691
+ } else {
692
+ target.push(chunk);
693
+ }
694
+ };
695
+ child.stdout.on("data", collect(stdout));
696
+ child.stderr.on("data", collect(stderr));
697
+ let settled = false;
698
+ const timer = setTimeout(() => {
699
+ child.kill("SIGKILL");
700
+ // A SIGKILLed CLI normally closes at once, but an unreapable child
701
+ // must not strand activation/recovery on a promise that never
702
+ // settles — the caller's deadline has already expired.
703
+ const settle = setTimeout(() => finish(null), 5_000);
704
+ settle.unref?.();
705
+ }, options.timeoutMs);
706
+ timer.unref?.();
707
+ const finish = (status: number | null): void => {
708
+ if (settled) return;
709
+ settled = true;
710
+ clearTimeout(timer);
711
+ resolve({
712
+ status,
713
+ stdout: Buffer.concat(stdout).toString("utf8"),
714
+ stderr: Buffer.concat(stderr).toString("utf8"),
715
+ truncated,
716
+ });
717
+ };
718
+ child.once("error", (error) => {
719
+ stderr.push(Buffer.from(error.message));
720
+ finish(null);
721
+ });
722
+ child.once("close", (code) => finish(code));
723
+ });
724
+ }
725
+
726
+ function validateAppleMachineIdentity(
727
+ value: AppleMachineIdentity,
728
+ ): AppleMachineIdentity {
729
+ if (value.backend !== "apple-container") {
730
+ throw new Error("Apple container machine backend is invalid");
731
+ }
732
+ const endpoint = exactComponent(value.endpoint, "machine endpoint");
733
+ if (!endpoint.startsWith("/")) {
734
+ throw new Error("Apple container machine endpoint must be absolute");
735
+ }
736
+ const engineId = exactComponent(value.engineId, "installation id");
737
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(engineId)) {
738
+ throw new Error("Apple container installation id is invalid");
739
+ }
740
+ return { backend: "apple-container", endpoint, engineId };
741
+ }
742
+
743
+ function validateAppleLocatorValue(
744
+ value: unknown,
745
+ ): AppleTaskEnvironmentLocatorValue {
746
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
747
+ throw new Error("Apple container locator must be an object");
748
+ }
749
+ const record = value as Record<string, unknown>;
750
+ const allowed = new Set([
751
+ "schemaVersion",
752
+ "taskId",
753
+ "containerName",
754
+ "hostWorktreePath",
755
+ "machine",
756
+ ]);
757
+ if (Object.keys(record).some((key) => !allowed.has(key))) {
758
+ throw new Error("Apple container locator has unexpected fields");
759
+ }
760
+ if (record.schemaVersion !== APPLE_LOCATOR_SCHEMA_VERSION) {
761
+ throw new Error("unsupported Apple container locator schema");
762
+ }
763
+ const taskId = exactComponent(record.taskId, "task id");
764
+ assertSafeHostTaskId(taskId);
765
+ if (record.containerName !== appleTaskContainerName(taskId)) {
766
+ throw new Error("Apple container locator name does not match its task");
767
+ }
768
+ const hostWorktreePath = exactComponent(
769
+ record.hostWorktreePath,
770
+ "worktree path",
771
+ 4096,
772
+ );
773
+ if (!hostWorktreePath.startsWith("/")) {
774
+ throw new Error("Apple container locator worktree path must be absolute");
775
+ }
776
+ const machine = validateAppleMachineIdentity(
777
+ record.machine as AppleMachineIdentity,
778
+ );
779
+ return {
780
+ schemaVersion: APPLE_LOCATOR_SCHEMA_VERSION,
781
+ taskId,
782
+ containerName: record.containerName,
783
+ hostWorktreePath,
784
+ machine,
785
+ };
786
+ }
787
+
788
+ function validateCopyRequest(request: TaskEnvironmentCopyRequest): void {
789
+ if (request.direction !== "into" && request.direction !== "out") {
790
+ throw new Error("task environment copy direction is invalid");
791
+ }
792
+ for (const [label, value] of [
793
+ ["source", request.source],
794
+ ["destination", request.destination],
795
+ ] as const) {
796
+ if (
797
+ typeof value !== "string" ||
798
+ value.length === 0 ||
799
+ value.length > 4096 ||
800
+ /[\0\r\n]/.test(value) ||
801
+ value.includes(":")
802
+ ) {
803
+ throw new Error(`task environment copy ${label} is invalid`);
804
+ }
805
+ }
806
+ }
807
+
808
+ function requireCliSuccess(result: AppleCliResult, operation: string): void {
809
+ if (result.status === 0) return;
810
+ const detail = boundedDetail(result.stderr || result.stdout);
811
+ throw new Error(`${operation} failed${detail ? `: ${detail}` : ""}`);
812
+ }
813
+
814
+ /** The CLI exits nonzero for both a missing container and a broken
815
+ * apiserver; only the not-found error that names this exact container proves
816
+ * absence. Anything else is unknown and must never authorize destruction. */
817
+ function isNotFound(result: AppleCliResult, containerName: string): boolean {
818
+ // Substring matching would let `container not found: <name>-shadow` prove
819
+ // the absence of <name>. Require the complete bounded error line and no
820
+ // stdout at all.
821
+ return (
822
+ result.status !== 0 &&
823
+ result.stdout.trim() === "" &&
824
+ result.stderr
825
+ .split("\n")
826
+ .some((line) => line.trim() === `Error: container not found: ${containerName}`)
827
+ );
828
+ }
829
+
830
+ function boundedDetail(text: string): string {
831
+ return text.trim().slice(0, 512);
832
+ }
833
+
834
+ function firstIpv4(networks: unknown): string | null {
835
+ if (!Array.isArray(networks)) return null;
836
+ for (const network of networks) {
837
+ const address = (network as { ipv4Address?: unknown })?.ipv4Address;
838
+ if (typeof address === "string") {
839
+ const bare = address.split("/")[0]!;
840
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bare)) return bare;
841
+ }
842
+ }
843
+ return null;
844
+ }
845
+
846
+ function exactComponent(
847
+ value: unknown,
848
+ label: string,
849
+ maxLength = MAX_LOCATOR_COMPONENT,
850
+ ): string {
851
+ if (
852
+ typeof value !== "string" ||
853
+ value.length === 0 ||
854
+ value.length > maxLength ||
855
+ /[\0\r\n]/.test(value)
856
+ ) {
857
+ throw new Error(`Apple container ${label} is invalid`);
858
+ }
859
+ return value;
860
+ }