@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,3761 @@
1
+ #!/usr/bin/env bash
2
+ # task-up <taskId> — bring up the task stack end-to-end (ADR-022).
3
+ #
4
+ # The "standard image" model: every host runs one prebuilt image,
5
+ # `moondeck-standard:dev`. A task selects 0..N projects (0 = empty workspace /
6
+ # scratchpad). Each selected project contributes a git worktree under the
7
+ # task's workspace, plus a union of preview ports and env keys.
8
+ #
9
+ # Steps:
10
+ # 1. Read task (-> branch) + the task's selected projects.
11
+ # 2. Acquire lock; mark status=starting.
12
+ # 3. Per project: ensure a bare mirror, fetch, add a worktree on the task
13
+ # branch, seed .tool-versions when missing.
14
+ # 4. Generate .uai/docker-compose.yml in bash (and a derived .uai/Dockerfile
15
+ # when any project declares `extra`).
16
+ # 5. Build the derived image when needed, then materialize every runtime and
17
+ # package-manager default needed by the selected projects through the
18
+ # exact image the task will run.
19
+ # 6. docker compose -p task-<id> up -d (without rebuilding).
20
+ # 7. docker cp AI auth into the container, then docker exec uai-init.
21
+ # 8. Discover code-server + preview ports; mark status=running; unlock.
22
+ #
23
+ # The AIs are NOT started here — the chat orchestrator drives them and
24
+ # delivers the task's initial prompt once the task is running.
25
+
26
+ set -euo pipefail
27
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
28
+ # shellcheck source=_common.sh
29
+ . "$SCRIPT_DIR/_common.sh"
30
+
31
+ [ "$#" -eq 1 ] || emit_err "BAD_ARGS" "usage: task-up <taskId>" "args"
32
+
33
+ task_id="$1"
34
+ UAI_TASK_ID="$task_id"
35
+ setup_err_trap
36
+
37
+ # Pinned constants (all clusters must agree — see ADR-022).
38
+ STANDARD_IMAGE="moondeck-standard:dev"
39
+ ASDF_VOLUME="moondeck-asdf-data"
40
+ # Keep in sync with lib/standard-image.ts. Apple tasks execute the env
41
+ # forwarder from the shared volume's read-only lower mount, never from the
42
+ # node-writable overlay at /opt/asdf-data.
43
+ STANDARD_DEFAULT_NODE_VERSION="22.11.0"
44
+ TASK_NPM_PREFIX="/home/node/.local/share/uai-npm"
45
+ TASK_NPM_CACHE="/home/node/.cache/uai-npm"
46
+ # Host-proved shims and system controls always win. A task-global package with
47
+ # a new command name remains discoverable at the final path component, while a
48
+ # package named node/claude/git/sh cannot intercept managed or agent startup.
49
+ TASK_RUNTIME_PATH="/opt/asdf-data/shims:/opt/asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$TASK_NPM_PREFIX/bin"
50
+ # ADR-053: host-wide Playwright browser cache (Chromium downloads once).
51
+ PW_VOLUME="uai-playwright"
52
+ # ADR-106: on the Apple runtime the cache is a host DIRECTORY instead. Apple
53
+ # block volumes allow exactly ONE writable attachment, and session start
54
+ # writes browsers from inside every task — a shared writable volume let the
55
+ # first task exclude every later `container run` (live 2026-08-17). Virtiofs
56
+ # directory shares carry no exclusivity, like /workspace itself.
57
+ PW_BROWSERS_DIR="$UAI_WORKSPACE_ROOT/pw-browsers"
58
+ DERIVED_IMAGE="moondeck-task-${task_id}"
59
+ # ADR-118 (apple only): the pnpm store rides a task-private block volume. On
60
+ # virtiofs every store file pins a host fd for the VM's lifetime — one
61
+ # monorepo install held ~130k host descriptors and two tasks exhausted the
62
+ # system file table (live 2026-08-25). A block volume costs one fd total.
63
+ # One volume per task, never shared: block volumes allow exactly ONE
64
+ # writable attachment, and the store was per-task on the old path anyway.
65
+ TASK_STORE_VOLUME=$(apple_store_volume_for_task "$task_id")
66
+ TASK_STORE_MOUNT="/opt/uai/store"
67
+ TASK_STORE_DIR="$TASK_STORE_MOUNT/pnpm"
68
+
69
+ # ADR-106: runtime selection is shared with task-down/task-status via
70
+ # _common.sh; `docker` keeps the historical Compose path byte-for-byte, while
71
+ # `apple-container` drives the bundled Apple CLI directly (no Compose, no
72
+ # published ports — tasks are reached at their vmnet IP).
73
+ validate_task_runtime_selection
74
+ # Per-container VM sizing (Apple runtime only; Docker tasks share the daemon
75
+ # VM). Overridable for constrained hosts; ADR-106 picked these defaults for a
76
+ # dev task with language servers + builds.
77
+ APPLE_TASK_MEMORY="${UAI_APPLE_TASK_MEMORY:-8192M}"
78
+ APPLE_TASK_CPUS="${UAI_APPLE_TASK_CPUS:-4}"
79
+
80
+ # ADR-153 makes the task's project-env delivery mode explicit. A loader task's
81
+ # stdin is exactly one base64-encoded task-generation forwarder secret; it is
82
+ # never put in argv, the environment, or a file. A legacy baked task retains
83
+ # the old JSON-object stdin contract until its container is recreated.
84
+ UAI_PROJECT_ENV_MODE=${UAI_PROJECT_ENV_MODE:-baked}
85
+ case "$UAI_PROJECT_ENV_MODE" in
86
+ baked | loader) ;;
87
+ *)
88
+ emit_err "BAD_ARGS" \
89
+ "task project env mode must be baked or loader" \
90
+ "read task environment mode"
91
+ ;;
92
+ esac
93
+
94
+ task_stdin_payload=$(cat)
95
+ task_gateway_secret_b64=""
96
+ if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
97
+ task_gateway_secret_b64=$task_stdin_payload
98
+ project_env_json='{}'
99
+ # A 32-byte value has one canonical standard-base64 shape. Validate that
100
+ # shape here without decoding it into a shell variable (which cannot carry
101
+ # NUL); the only decode happens later, directly into the forwarder's stdin.
102
+ if [[ ! "$task_gateway_secret_b64" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then
103
+ emit_err "BAD_ARGS" \
104
+ "loader task gateway secret must decode to exactly 32 bytes" \
105
+ "read task gateway secret"
106
+ fi
107
+ else
108
+ project_env_json=$task_stdin_payload
109
+ [ -n "$project_env_json" ] || project_env_json='{}'
110
+ if ! printf '%s' "$project_env_json" | jq -e 'type == "object"' >/dev/null 2>&1; then
111
+ emit_err "BAD_ARGS" "task environment payload must be a JSON object" "read task environment"
112
+ fi
113
+ project_env_json=$(printf '%s' "$project_env_json" | jq -c '.')
114
+ fi
115
+ unset task_stdin_payload
116
+
117
+ # -----------------------------------------------------------------------------
118
+ # Paths (mirror host-agent/lib/env.ts).
119
+ # -----------------------------------------------------------------------------
120
+
121
+ task_dir="$UAI_WORKSPACE_ROOT/tasks/$task_id"
122
+ task_workspace="$task_dir/workspace"
123
+ # The HOST control tree. Deliberately not bind-mounted: the container sees only
124
+ # $task_workspace. Anything an agent must reach belongs under $task_workspace/.moondeck.
125
+ task_uai_dir="$task_dir/.uai"
126
+ projects_root="$UAI_WORKSPACE_ROOT/projects"
127
+ compose_project=$(compose_project_for_task "$task_id")
128
+ app_container=$(app_container_for_task "$task_id")
129
+ [ "$TASK_RUNTIME_MODE" != "apple-container" ] \
130
+ || app_container=$(apple_app_container_for_task "$task_id")
131
+
132
+ reject_unsafe_host_directory() {
133
+ local path="$1" label="$2"
134
+ if [ -L "$path" ] || { [ -e "$path" ] && [ ! -d "$path" ]; }; then
135
+ emit_err "WORKTREE_FAILED" \
136
+ "Moondeck refused $label because it is not a trusted directory: $path" \
137
+ "validate task workspace paths"
138
+ fi
139
+ }
140
+
141
+ reject_unsafe_runtime_manifest() {
142
+ local path="$1" project_slug="$2" project_id="$3"
143
+ if [ -L "$path" ]; then
144
+ emit_err "WORKTREE_FAILED" \
145
+ "Moondeck refused a symbolic-link .tool-versions for $project_slug" \
146
+ "validate task worktree ($project_id)"
147
+ fi
148
+ # Never open an untrusted special file: `-f` is a metadata-only check, so a
149
+ # FIFO/socket/device cannot block the host while it validates a resumed
150
+ # container-writable workspace.
151
+ if [ -e "$path" ] && [ ! -f "$path" ]; then
152
+ emit_err "WORKTREE_FAILED" \
153
+ "Moondeck refused a non-regular .tool-versions for $project_slug" \
154
+ "validate task worktree ($project_id)"
155
+ fi
156
+ }
157
+
158
+ reject_workspace_runtime_shadow() {
159
+ local path="$task_workspace/.tool-versions"
160
+ # The workspace root is not a project. Any entry here would shadow the
161
+ # image defaults for a no-manifest project (and even a scratchpad command),
162
+ # while the trusted materializer proves only the selected project paths.
163
+ # Metadata-only predicates keep FIFO/device/socket cases nonblocking.
164
+ if [ -L "$path" ] || [ -e "$path" ]; then
165
+ emit_err "WORKTREE_FAILED" \
166
+ "Moondeck refused a workspace-root .tool-versions because it could override the runtimes proved for this task" \
167
+ "validate task workspace runtime authority"
168
+ fi
169
+ }
170
+
171
+ # The first check of a task-up used to be the refusal above. A fresh task
172
+ # never trips it (the workspace is empty), but a RESUMED one can: the
173
+ # workspace persists across an idle park, agents' shells start in /workspace,
174
+ # and `asdf local nodejs 22` from there writes exactly this file — after
175
+ # which the task could never wake again without someone deleting the file on
176
+ # the host (client runner, 2026-09-14). The entry is the task's own
177
+ # container-writable content, so move it aside instead: a rename is
178
+ # metadata-only (never opens a FIFO/device/socket), the runtime proof is
179
+ # unchanged because nothing is left at the root, and the content survives
180
+ # under .moondeck/refused/ where the agent can re-pin it in a project. The
181
+ # post-materialization recheck below keeps refusing — by then the root must
182
+ # be empty by construction, so an entry there is a bug, not a user file.
183
+ quarantine_workspace_runtime_shadow() {
184
+ local path="$task_workspace/.tool-versions" aside_dir aside attempt note
185
+ if [ -L "$path" ] || [ -e "$path" ]; then
186
+ aside_dir="$task_workspace/.moondeck/refused"
187
+ reject_unsafe_host_directory "$task_workspace/.moondeck" "the task control directory"
188
+ reject_unsafe_host_directory "$aside_dir" "the refused-files directory"
189
+ mkdir -p "$aside_dir"
190
+ # Never onto an entry the task left there: mv moves into a directory, or
191
+ # one a link leads to, outside the workspace (#cb3j0z). The task's
192
+ # container is gone, so nothing races these checks.
193
+ aside="$aside_dir/tool-versions.$(date +%s)"
194
+ attempt=0
195
+ while [ -L "$aside" ] || [ -e "$aside" ]; do
196
+ attempt=$((attempt + 1))
197
+ aside="$aside_dir/tool-versions.$(date +%s).$attempt"
198
+ done
199
+ if ! mv -f "$path" "$aside" 2>/dev/null || [ -L "$path" ] || [ -e "$path" ]; then
200
+ reject_workspace_runtime_shadow
201
+ fi
202
+ # Nor written through a link or special file in the note's place: that
203
+ # is removed first (rm never follows a link), and the note is written
204
+ # only to nothing or a regular file.
205
+ note="$aside_dir/README.txt"
206
+ if [ -L "$note" ] || { [ -e "$note" ] && [ ! -f "$note" ]; }; then
207
+ rm -f "$note" 2>/dev/null || true
208
+ fi
209
+ if [ ! -L "$note" ] && { [ ! -e "$note" ] || [ -f "$note" ]; }; then
210
+ cat > "$note" <<'NOTE'
211
+ Moondeck moved a /workspace/.tool-versions aside before starting this task.
212
+
213
+ A .tool-versions at the workspace root is outside the runtime proof and would
214
+ override the runtimes proved for every project in the task, so the host refuses
215
+ to start a container while one exists. The file is kept here, untouched, under
216
+ its original content. Pin runtimes in a project's own .tool-versions instead
217
+ (/workspace/<project>/.tool-versions), which the materializer proves.
218
+ NOTE
219
+ fi
220
+ log "moved a workspace-root .tool-versions aside to ${aside#"$task_workspace"/} (it would override the runtimes proved for this task; pin per project instead)"
221
+ fi
222
+ }
223
+
224
+ mapped_host_port() {
225
+ local container="$1" container_port="$2"
226
+ docker port "$container" "$container_port" 2>/dev/null \
227
+ | awk -F: 'NR==1 {print $NF}' \
228
+ | tr -d '[:space:]' \
229
+ || true
230
+ }
231
+
232
+ # A failed Docker call cannot be followed by an unbounded health probe: the
233
+ # same stuck socket would defeat the operation's own watchdog. This portable
234
+ # Bash 3.2 wrapper gives `docker info` 15 seconds, writes completion before its
235
+ # exact wrapper PID disappears, and reaps every helper on success or timeout.
236
+ docker_daemon_reachable_bounded() {
237
+ local probe_dir wrapper_pid watchdog_pid probe_rc=0 watchdog_sleep_pid
238
+
239
+ probe_dir=$(mktemp -d "$task_uai_dir/.docker-info-watchdog.XXXXXX")
240
+ (
241
+ runtime_probe_pid=""
242
+ finish_runtime_probe() {
243
+ local wrapper_rc=$?
244
+ trap - EXIT
245
+ : > "$probe_dir/completed"
246
+ exit "$wrapper_rc"
247
+ }
248
+ terminate_runtime_probe() {
249
+ local signal_rc="$1"
250
+ trap - TERM INT
251
+ if [ -n "$runtime_probe_pid" ]; then
252
+ kill -TERM "$runtime_probe_pid" >/dev/null 2>&1 || true
253
+ sleep 2
254
+ kill -KILL "$runtime_probe_pid" >/dev/null 2>&1 || true
255
+ wait "$runtime_probe_pid" 2>/dev/null || true
256
+ fi
257
+ exit "$signal_rc"
258
+ }
259
+ trap finish_runtime_probe EXIT
260
+ trap 'terminate_runtime_probe 143' TERM
261
+ trap 'terminate_runtime_probe 130' INT
262
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
263
+ "$CONTAINER_CLI" system status >/dev/null 2>&1 &
264
+ else
265
+ docker info --format '{{.ServerVersion}}' >/dev/null 2>&1 &
266
+ fi
267
+ runtime_probe_pid=$!
268
+ if wait "$runtime_probe_pid"; then exit 0; else exit $?; fi
269
+ ) &
270
+ wrapper_pid=$!
271
+
272
+ (
273
+ watchdog_sleep_pid=""
274
+ cancel_probe_watchdog() {
275
+ trap - TERM INT
276
+ if [ -n "$watchdog_sleep_pid" ]; then
277
+ kill -TERM "$watchdog_sleep_pid" >/dev/null 2>&1 || true
278
+ wait "$watchdog_sleep_pid" 2>/dev/null || true
279
+ fi
280
+ exit 0
281
+ }
282
+ trap cancel_probe_watchdog TERM INT
283
+ sleep 15 &
284
+ watchdog_sleep_pid=$!
285
+ if ! wait "$watchdog_sleep_pid"; then exit 0; fi
286
+ watchdog_sleep_pid=""
287
+ if [ ! -f "$probe_dir/completed" ] \
288
+ && kill -0 "$wrapper_pid" >/dev/null 2>&1; then
289
+ : > "$probe_dir/timed-out"
290
+ kill -TERM "$wrapper_pid" >/dev/null 2>&1 || true
291
+ sleep 5 &
292
+ watchdog_sleep_pid=$!
293
+ wait "$watchdog_sleep_pid" 2>/dev/null || true
294
+ watchdog_sleep_pid=""
295
+ if [ ! -f "$probe_dir/completed" ]; then
296
+ kill -KILL "$wrapper_pid" >/dev/null 2>&1 || true
297
+ fi
298
+ fi
299
+ ) &
300
+ watchdog_pid=$!
301
+
302
+ if wait "$wrapper_pid"; then probe_rc=0; else probe_rc=$?; fi
303
+ kill -TERM "$watchdog_pid" >/dev/null 2>&1 || true
304
+ wait "$watchdog_pid" 2>/dev/null || true
305
+ [ ! -f "$probe_dir/timed-out" ] || probe_rc=124
306
+ rm -f "$probe_dir/completed" "$probe_dir/timed-out"
307
+ rmdir "$probe_dir" 2>/dev/null || true
308
+ return "$probe_rc"
309
+ }
310
+
311
+ # A Docker-dependent step can fail for its own, degradable reason (for example,
312
+ # uai-init exits nonzero while the container remains healthy) or because the
313
+ # daemon disappeared after TypeScript admitted task-up. Only the latter is
314
+ # retryable host infrastructure state. Re-probe before translating a failed
315
+ # Docker step into an operation-specific error or warning so task-up never
316
+ # publishes status=running against an unknown backend.
317
+ require_docker_after_failed_step() {
318
+ local step_label="$1" runtime_name="Docker"
319
+ [ "$TASK_RUNTIME_MODE" != "apple-container" ] \
320
+ || runtime_name="the Apple container runtime"
321
+ if ! docker_daemon_reachable_bounded; then
322
+ emit_err "CONTAINER_INIT_FAILED" \
323
+ "The container runtime stopped responding during task startup. Retry after $runtime_name is running again." \
324
+ "$step_label"
325
+ fi
326
+ }
327
+
328
+ # Stop an app that failed a post-create authority proof and verify it is no
329
+ # longer running before any credential, dependency, editor, or agent work can
330
+ # begin. Preserve the container/worktree for diagnosis; a retry recreates the
331
+ # container from Uai's current Compose contract.
332
+ quarantine_untrusted_app_container() {
333
+ local authority_running=""
334
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
335
+ # `container stop` is idempotent; proven absence quarantines as well as a
336
+ # stopped state does. An inspect that fails WITHOUT naming this container
337
+ # as not-found proves nothing and must not read as stopped.
338
+ if ! crt stop --time 30 "$app_container" >/dev/null 2>&1; then
339
+ require_docker_after_failed_step "quarantine untrusted task container"
340
+ fi
341
+ apple_quarantine_proven=""
342
+ quarantine_state=$(crt inspect "$app_container" 2>/dev/null \
343
+ | jq -r '.[0].status.state // empty' 2>/dev/null) || quarantine_state=""
344
+ if [ -n "$quarantine_state" ] && [ "$quarantine_state" != "running" ]; then
345
+ apple_quarantine_proven=1
346
+ elif [ -z "$quarantine_state" ] \
347
+ && apple_container_proven_absent "$app_container"; then
348
+ apple_quarantine_proven=1
349
+ fi
350
+ if [ -z "$apple_quarantine_proven" ]; then
351
+ crt stop --signal KILL "$app_container" >/dev/null 2>&1 || true
352
+ quarantine_state=$(crt inspect "$app_container" 2>/dev/null \
353
+ | jq -r '.[0].status.state // empty' 2>/dev/null) || quarantine_state=""
354
+ if [ -n "$quarantine_state" ] && [ "$quarantine_state" != "running" ]; then
355
+ apple_quarantine_proven=1
356
+ elif [ -z "$quarantine_state" ] \
357
+ && apple_container_proven_absent "$app_container"; then
358
+ apple_quarantine_proven=1
359
+ fi
360
+ fi
361
+ [ -n "$apple_quarantine_proven" ]
362
+ return
363
+ fi
364
+ if ! docker stop --time 30 "$app_container" >/dev/null 2>&1; then
365
+ require_docker_after_failed_step "quarantine untrusted task container"
366
+ fi
367
+ authority_running=$(docker ps -q --filter "name=^/${app_container}$") \
368
+ || require_docker_after_failed_step "verify task-container quarantine"
369
+ if [ -n "$authority_running" ]; then
370
+ docker kill "$app_container" >/dev/null 2>&1 || true
371
+ authority_running=$(docker ps -q --filter "name=^/${app_container}$") \
372
+ || require_docker_after_failed_step "verify task-container quarantine"
373
+ fi
374
+ [ -z "$authority_running" ]
375
+ }
376
+
377
+ # Run the maintenance container under two independent deadlines. The inner GNU
378
+ # timeout bounds the actual writer (including the shared-volume lock wait); the
379
+ # host watchdog is slightly looser and covers a Docker CLI that hangs before it
380
+ # creates or starts the container. Bash 3.2 has no `wait -n` or `timeout`, so
381
+ # keep and reap both exact child PIDs explicitly.
382
+ run_runtime_materializer() {
383
+ local runtime_image="$1" wrapper_pid watchdog_pid materialization_rc=0
384
+ local watchdog_dir watchdog_sleep_pid
385
+
386
+ watchdog_dir=$(mktemp -d "$task_uai_dir/.runtime-materializer-watchdog.XXXXXX")
387
+
388
+ # The completion marker is written from the wrapper's EXIT trap before its
389
+ # PID can disappear. The watchdog consults it before every signal, closing
390
+ # the wait/reap/PID-reuse race around a just-finished Docker client.
391
+ (
392
+ runtime_docker_pid=""
393
+ finish_runtime_wrapper() {
394
+ local wrapper_rc=$?
395
+ trap - EXIT
396
+ : > "$watchdog_dir/completed"
397
+ exit "$wrapper_rc"
398
+ }
399
+ terminate_runtime_wrapper() {
400
+ local signal_rc="$1"
401
+ trap - TERM INT
402
+ if [ -n "$runtime_docker_pid" ]; then
403
+ # Do not reap before the grace period: the exact child PID therefore
404
+ # cannot be reused between TERM and KILL.
405
+ kill -TERM "$runtime_docker_pid" >/dev/null 2>&1 || true
406
+ sleep 10
407
+ kill -KILL "$runtime_docker_pid" >/dev/null 2>&1 || true
408
+ wait "$runtime_docker_pid" 2>/dev/null || true
409
+ fi
410
+ exit "$signal_rc"
411
+ }
412
+ trap finish_runtime_wrapper EXIT
413
+ trap 'terminate_runtime_wrapper 143' TERM
414
+ trap 'terminate_runtime_wrapper 130' INT
415
+
416
+ # ADR-108: on the Apple runtime this function is never called —
417
+ # materialization runs inside the app container against its in-guest
418
+ # overlay. This maintenance-container path is Docker-only.
419
+ docker run --rm --user node --group-add "$task_container_gid" \
420
+ --workdir /workspace \
421
+ -v "$task_workspace:/workspace:ro" \
422
+ -v "$ASDF_VOLUME:/opt/asdf-data:rw" \
423
+ -v "$RUNTIME_MATERIALIZER_SOURCE:/usr/local/bin/uai-materialize-runtimes:ro" \
424
+ -v "$COREPACK_VERSION_SOURCE:/usr/local/share/uai/corepack-version:ro" \
425
+ -v "$runtime_projects_file:/run/uai/runtime-projects:ro" \
426
+ --env LD_PRELOAD= \
427
+ --env LD_AUDIT= \
428
+ --env LD_LIBRARY_PATH= \
429
+ --env BASH_ENV= \
430
+ --env ENV= \
431
+ --entrypoint /usr/bin/env \
432
+ "$runtime_image" \
433
+ -i \
434
+ HOME=/home/node \
435
+ ASDF_DIR=/opt/asdf \
436
+ ASDF_DATA_DIR=/opt/asdf-data \
437
+ PATH=/opt/asdf-data/shims:/opt/asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
438
+ /usr/bin/timeout --signal=TERM --kill-after=10s 600s \
439
+ /usr/local/bin/uai-materialize-runtimes >&2 &
440
+ runtime_docker_pid=$!
441
+ if wait "$runtime_docker_pid"; then
442
+ exit 0
443
+ else
444
+ exit $?
445
+ fi
446
+ ) &
447
+ wrapper_pid=$!
448
+
449
+ (
450
+ watchdog_sleep_pid=""
451
+ cancel_runtime_watchdog() {
452
+ trap - TERM INT
453
+ if [ -n "$watchdog_sleep_pid" ]; then
454
+ kill -TERM "$watchdog_sleep_pid" >/dev/null 2>&1 || true
455
+ wait "$watchdog_sleep_pid" 2>/dev/null || true
456
+ fi
457
+ exit 0
458
+ }
459
+ trap cancel_runtime_watchdog TERM INT
460
+
461
+ sleep 620 &
462
+ watchdog_sleep_pid=$!
463
+ if ! wait "$watchdog_sleep_pid"; then
464
+ exit 0
465
+ fi
466
+ watchdog_sleep_pid=""
467
+
468
+ if [ ! -f "$watchdog_dir/completed" ] \
469
+ && kill -0 "$wrapper_pid" >/dev/null 2>&1; then
470
+ : > "$watchdog_dir/timed-out"
471
+ kill -TERM "$wrapper_pid" >/dev/null 2>&1 || true
472
+ # The wrapper gives its exact Docker child 10 seconds before KILL. This
473
+ # slightly looser guard is only a backstop for a wedged signal handler.
474
+ sleep 15 &
475
+ watchdog_sleep_pid=$!
476
+ wait "$watchdog_sleep_pid" 2>/dev/null || true
477
+ watchdog_sleep_pid=""
478
+ if [ ! -f "$watchdog_dir/completed" ]; then
479
+ kill -KILL "$wrapper_pid" >/dev/null 2>&1 || true
480
+ fi
481
+ fi
482
+ ) &
483
+ watchdog_pid=$!
484
+
485
+ cancel_runtime_materializer() {
486
+ local signal_rc="$1"
487
+ trap - TERM INT
488
+ if [ ! -f "$watchdog_dir/completed" ]; then
489
+ kill -TERM "$wrapper_pid" >/dev/null 2>&1 || true
490
+ fi
491
+ kill -TERM "$watchdog_pid" >/dev/null 2>&1 || true
492
+ wait "$watchdog_pid" 2>/dev/null || true
493
+ wait "$wrapper_pid" 2>/dev/null || true
494
+ rm -f "$watchdog_dir/completed" "$watchdog_dir/timed-out"
495
+ rmdir "$watchdog_dir" 2>/dev/null || true
496
+ exit "$signal_rc"
497
+ }
498
+ trap 'cancel_runtime_materializer 143' TERM
499
+ trap 'cancel_runtime_materializer 130' INT
500
+
501
+ if wait "$wrapper_pid"; then
502
+ materialization_rc=0
503
+ else
504
+ materialization_rc=$?
505
+ fi
506
+ kill -TERM "$watchdog_pid" >/dev/null 2>&1 || true
507
+ wait "$watchdog_pid" 2>/dev/null || true
508
+
509
+ trap - TERM INT
510
+ if [ -f "$watchdog_dir/timed-out" ]; then
511
+ materialization_rc=124
512
+ fi
513
+ rm -f "$watchdog_dir/completed" "$watchdog_dir/timed-out"
514
+ rmdir "$watchdog_dir" 2>/dev/null || true
515
+ return "$materialization_rc"
516
+ }
517
+
518
+ # -----------------------------------------------------------------------------
519
+ # 1. Read rows.
520
+ # -----------------------------------------------------------------------------
521
+
522
+ step "TASK_NOT_FOUND" "read task row"
523
+ task_json=$(db_get_task "$task_id")
524
+ [ "$(jq 'length' <<<"$task_json")" -ge 1 ] \
525
+ || emit_err "TASK_NOT_FOUND" "no such task: $task_id" "read task row"
526
+
527
+ task_branch=$(jq -r '.[0].branch' <<<"$task_json")
528
+ UAI_RETRY_STATUS=$(jq -r '.[0].status // ""' <<<"$task_json")
529
+
530
+ # The standard container runs as node:node (1000:1000), while Docker Desktop
531
+ # hosts commonly run as 501:20 and Linux hosts may use another uid/gid. Share
532
+ # only this task's writable trees through the host's effective group. A root
533
+ # host is the exception: never add the unprivileged container user to gid 0;
534
+ # root can chgrp the task trees directly to node's gid instead.
535
+ task_container_gid=$(id -g)
536
+ if [ "$(id -u)" = "0" ]; then
537
+ task_container_gid=1000
538
+ elif [ "$task_container_gid" = "0" ]; then
539
+ task_container_gid=""
540
+ for candidate_gid in $(id -G); do
541
+ if [ "$candidate_gid" != "0" ]; then
542
+ task_container_gid="$candidate_gid"
543
+ break
544
+ fi
545
+ done
546
+ fi
547
+ case "$task_container_gid" in
548
+ ''|*[!0-9]*)
549
+ emit_err "WORKTREE_FAILED" \
550
+ "Moondeck could not resolve a safe host group for the task container" \
551
+ "resolve task container filesystem group"
552
+ ;;
553
+ esac
554
+
555
+ step "PROJECT_NOT_FOUND" "read task projects"
556
+ projects_json=$(db_get_projects_for_task "$task_id")
557
+ project_count=$(jq 'length' <<<"$projects_json")
558
+ if ! jq -e '
559
+ map(.slug) as $slugs
560
+ | all($slugs[];
561
+ type == "string"
562
+ and test("^[a-z0-9-]{1,64}$"))
563
+ and (($slugs | unique | length) == ($slugs | length))
564
+ ' <<<"$projects_json" >/dev/null; then
565
+ emit_err "WORKTREE_FAILED" \
566
+ "Moondeck refused an unsafe or duplicate project workspace slug" \
567
+ "validate task projects"
568
+ fi
569
+ runtime_project_slugs=$(jq -r '.[].slug' <<<"$projects_json")
570
+
571
+ # -----------------------------------------------------------------------------
572
+ # 2. Lock + status=starting.
573
+ # -----------------------------------------------------------------------------
574
+
575
+ step "LOCKED" "acquire task lock"
576
+ lock_task "$task_id"
577
+
578
+ step "DB_UPDATE_FAILED" "mark task starting"
579
+ db_update_task "$task_id" \
580
+ "status='starting'" \
581
+ "started_at=(unixepoch()*1000)" \
582
+ "code_server_port=NULL" \
583
+ "preview_ports='[]'" \
584
+ "worktree_path='$(sql_escape "$task_dir")'" \
585
+ "compose_project='$(sql_escape "$compose_project")'"
586
+
587
+ # `.uai` is not container-mounted, but validate these host-owned parents before
588
+ # using the previous compose file. The workspace itself is checked after the
589
+ # old container is removed so it cannot race the check.
590
+ reject_unsafe_host_directory "$task_dir" "the task storage root"
591
+ reject_unsafe_host_directory "$task_uai_dir" "the task runtime directory"
592
+
593
+ # A previous task container can write both its workspace and its task-private
594
+ # Git repository. Remove it before the host parses either repository on resume.
595
+ # This also cuts off legacy containers that still have the old shared
596
+ # `repo.git` bind mount before we inspect their worktree layout below.
597
+ step "COMPOSE_DOWN_FAILED" "remove prior task containers"
598
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
599
+ # Selection fencing guarantees an apple-mode host has no Docker task rows,
600
+ # so the only possible prior container is this task's fixed name. `inspect`
601
+ # exiting nonzero afterwards is the absence proof.
602
+ crt stop --time 10 "$app_container" >/dev/null 2>&1 || true
603
+ crt delete --force "$app_container" >/dev/null 2>&1 || true
604
+ if crt inspect "$app_container" >/dev/null 2>&1; then
605
+ emit_err "COMPOSE_DOWN_FAILED" \
606
+ "the previous task container is still present; Moondeck will not let the host access its writable Git repository until that container is removed" \
607
+ "remove prior task containers"
608
+ fi
609
+ if ! apple_container_proven_absent "$app_container"; then
610
+ emit_err "COMPOSE_DOWN_FAILED" \
611
+ "could not verify removal of the previous task container" \
612
+ "remove prior task containers"
613
+ fi
614
+ else
615
+ uai_require_compose_plugin
616
+ if [ -f "$task_uai_dir/docker-compose.yml" ]; then
617
+ docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" \
618
+ down --remove-orphans >/dev/null 2>&1 || true
619
+ else
620
+ docker compose -p "$compose_project" down --remove-orphans \
621
+ >/dev/null 2>&1 || true
622
+ fi
623
+ prior_containers=""
624
+ if ! prior_containers=$(docker ps -aq \
625
+ --filter "label=com.docker.compose.project=$compose_project"); then
626
+ emit_err "COMPOSE_DOWN_FAILED" \
627
+ "could not verify removal of the previous task container" \
628
+ "remove prior task containers"
629
+ fi
630
+ if [ -n "$prior_containers" ]; then
631
+ emit_err "COMPOSE_DOWN_FAILED" \
632
+ "the previous task container is still running or stopped; Moondeck will not let the host access its writable Git repository until that container is removed" \
633
+ "remove prior task containers"
634
+ fi
635
+ fi
636
+ reject_unsafe_host_directory "$task_workspace" "the task workspace"
637
+ mkdir -p "$task_workspace"
638
+ quarantine_workspace_runtime_shadow
639
+
640
+ # Git resolves symlinked ancestors when it writes a linked worktree's absolute
641
+ # `.git` pointer. Keep the configured (lexical) task paths as Uai's stable host
642
+ # paths, but also remember their physical aliases for validation and bind-mount
643
+ # destinations. The task root itself was rejected above if it was a symlink;
644
+ # `pwd -P` therefore resolves only trusted ancestors such as a migrated
645
+ # UAI_WORKSPACE_ROOT (`~/.uai-host-app/workspace -> ~/.uai`).
646
+ step "WORKTREE_FAILED" "resolve task workspace paths"
647
+ if ! task_dir_physical=$(cd "$task_dir" && pwd -P) \
648
+ || [ -z "$task_dir_physical" ]; then
649
+ emit_err "WORKTREE_FAILED" \
650
+ "Moondeck could not resolve the task workspace's physical host path" \
651
+ "resolve task workspace paths"
652
+ fi
653
+ task_workspace_physical="$task_dir_physical/workspace"
654
+ task_uai_dir_physical="$task_dir_physical/.uai"
655
+
656
+ # -----------------------------------------------------------------------------
657
+ # 3. Bare mirrors + worktrees, one per selected project.
658
+ # -----------------------------------------------------------------------------
659
+
660
+ # ADR-154 §12: a task on cloud credentials tells the runner where it is
661
+ # with an event file in this private directory (event-clone-done, then
662
+ # event-runtime-ready), and finds the runner's answer to runtime-ready there
663
+ # too. Never on stderr, which a subprocess shares; the path is unset here so
664
+ # no process task-up starts inherits it; nothing blocks.
665
+ uai_handshake_dir="${UAI_TASK_HANDSHAKE_DIR:-}"
666
+ unset UAI_TASK_HANDSHAKE_DIR
667
+ # A task on cloud credentials: every host Git operation runs with nothing of
668
+ # the runner's own (no home, so no .netrc, ~/.gitconfig or ~/.ssh/config),
669
+ # by exactly the route its lease names, and never falls back to another.
670
+ uai_leased_git=0
671
+ if [ -n "$uai_handshake_dir" ]; then
672
+ uai_leased_git=1
673
+ fi
674
+ # ADR-154 §6: no default slot. A task on cloud credentials gets none of the
675
+ # runner owner's own engine sign-ins: not their tokens in its env (the
676
+ # runner strips them from ours), not their config directories in its home.
677
+ uai_on_cloud_credentials=$uai_leased_git
678
+
679
+ # Prepare the optional SSH identity. Connected users fetch over HTTPS with
680
+ # their per-host GitHub credential; SSH is used for Git transport only when the
681
+ # task owner has not connected GitHub. `IdentitiesOnly=yes` +
682
+ # `IdentityAgent=none` keep that fallback from silently selecting a different
683
+ # personal key or blocking on an agent approval prompt.
684
+ #
685
+ # ADR-029: use only the TASK CREATOR's per-user key (written to
686
+ # $UAI_TASK_IDENTITY_DIR by the orchestrator) so clone + push run as them. A
687
+ # task must never borrow the operator's shared identity: that would authorize
688
+ # one user as a different principal.
689
+ #
690
+ # ADR-154 §12: a task on cloud credentials gets no key file. The runner
691
+ # holds the owner's key in its own memory behind a private agent socket for
692
+ # exactly as long as this clone ($UAI_TASK_SSH_AUTH_SOCK, with the public
693
+ # key naming the identity); ssh asks that agent and nothing else.
694
+ if [ -n "${UAI_TASK_IDENTITY_DIR:-}" ] && [ -f "${UAI_TASK_IDENTITY_DIR}/id_ed25519" ]; then
695
+ uai_identity_key="$UAI_TASK_IDENTITY_DIR/id_ed25519"
696
+ log "git over SSH using the task creator's per-user key"
697
+ else
698
+ uai_identity_key=""
699
+ fi
700
+ uai_task_ssh_auth_sock="${UAI_TASK_SSH_AUTH_SOCK:-}"
701
+ uai_task_ssh_public_key="${UAI_TASK_SSH_PUBLIC_KEY:-}"
702
+ unset UAI_TASK_SSH_AUTH_SOCK UAI_TASK_SSH_PUBLIC_KEY
703
+ uai_has_ssh_identity=0
704
+ uai_leased_ssh=0
705
+ uai_known_hosts="$UAI_DATA_DIR/identity/known_hosts"
706
+ if [ -n "$uai_identity_key" ] && [ -f "$uai_identity_key" ]; then
707
+ uai_has_ssh_identity=1
708
+ GIT_SSH_COMMAND="ssh -i $uai_identity_key -o IdentitiesOnly=yes -o IdentityAgent=none -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
709
+ [ -f "$uai_known_hosts" ] && GIT_SSH_COMMAND="$GIT_SSH_COMMAND -o UserKnownHostsFile=$uai_known_hosts"
710
+ export GIT_SSH_COMMAND
711
+ log "git over SSH using $uai_identity_key (1Password agent bypassed)"
712
+ elif [ -n "$uai_task_ssh_auth_sock" ] && [ -S "$uai_task_ssh_auth_sock" ] && [ -f "$uai_task_ssh_public_key" ]; then
713
+ uai_has_ssh_identity=1
714
+ uai_leased_ssh=1
715
+ GIT_SSH_COMMAND="ssh -F /dev/null -i $uai_task_ssh_public_key -o IdentitiesOnly=yes -o IdentityAgent=$uai_task_ssh_auth_sock -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
716
+ [ -f "$uai_known_hosts" ] && GIT_SSH_COMMAND="$GIT_SSH_COMMAND -o UserKnownHostsFile=$uai_known_hosts"
717
+ export GIT_SSH_COMMAND
718
+ log "git over SSH using the task owner's key, held in the station's memory"
719
+ else
720
+ log "no SSH identity for the task owner — SSH transport fallback unavailable"
721
+ fi
722
+
723
+ # Only a non-secret path reaches this script. The task owner's token lives in
724
+ # a private per-task `git credential-cache` daemon, or, for a task on cloud
725
+ # credentials (ADR-154 §12), in the runner's own memory behind the helper
726
+ # $UAI_GITHUB_CREDENTIAL_HELPER asks for exactly as long as this clone. It is
727
+ # never placed in the environment, argv, remote URL, mirror config, Compose,
728
+ # or logs. Only the daemon's token is installed in the container below; a
729
+ # task on cloud credentials has its GitHub configuration written by the
730
+ # runner, which records it for cleanup.
731
+ github_credential_socket="${UAI_GITHUB_CREDENTIAL_SOCKET:-}"
732
+ github_credential_helper="${UAI_GITHUB_CREDENTIAL_HELPER:-}"
733
+ unset UAI_GITHUB_CREDENTIAL_SOCKET UAI_GITHUB_CREDENTIAL_HELPER
734
+ if [ -n "$github_credential_helper" ]; then
735
+ github_git_helper="$github_credential_helper"
736
+ elif [ -n "$github_credential_socket" ]; then
737
+ github_git_helper="cache --socket=$github_credential_socket"
738
+ else
739
+ github_git_helper=""
740
+ fi
741
+ if [ -n "$github_git_helper" ]; then
742
+ uai_git_transport="https"
743
+ else
744
+ # Refined after each GitHub fetch below: select SSH only when it was the
745
+ # mode that actually worked. A present-but-unregistered key must not turn a
746
+ # successfully anonymous public clone into broken SSH inside the container.
747
+ uai_git_transport="anonymous"
748
+ fi
749
+ uai_used_ssh_transport=0
750
+
751
+ # ADR-154 §12: a task on cloud credentials runs every host Git operation in an
752
+ # environment rebuilt from an allowlist (env -i), never the runner process's
753
+ # own: an ambient GIT_CONFIG_COUNT and its numbered pairs, GIT_EXEC_PATH, an
754
+ # askpass or an agent socket could otherwise redirect or answer its route.
755
+ # What passes is what finds git and ssh and reaches the network (the PATH, the
756
+ # temporary directory, proxies, the CA bundle), never a credential, a
757
+ # configuration or a route.
758
+ uai_git_passthrough=()
759
+ if [ "$uai_leased_git" = "1" ]; then
760
+ for uai_git_name in PATH TMPDIR http_proxy https_proxy all_proxy no_proxy \
761
+ HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY SSL_CERT_FILE SSL_CERT_DIR; do
762
+ if [ -n "${!uai_git_name:-}" ]; then
763
+ uai_git_passthrough+=("$uai_git_name=${!uai_git_name}")
764
+ fi
765
+ done
766
+ fi
767
+
768
+ # Runs git with the NAME=VALUE assignments before `--`, then `git "$@"`: for a
769
+ # task on cloud credentials in those, the allowlist and no home alone;
770
+ # otherwise on top of this process's environment, which the wrappers below
771
+ # narrow themselves.
772
+ uai_git() {
773
+ local uai_assign=()
774
+ while [ "$#" -gt 0 ] && [ "$1" != "--" ]; do
775
+ uai_assign+=("$1")
776
+ shift
777
+ done
778
+ shift
779
+ if [ "$uai_leased_git" = "1" ]; then
780
+ env -i ${uai_git_passthrough[@]+"${uai_git_passthrough[@]}"} \
781
+ HOME=/nonexistent XDG_CONFIG_HOME=/nonexistent LC_ALL=C \
782
+ GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \
783
+ ${uai_assign[@]+"${uai_assign[@]}"} git "$@"
784
+ else
785
+ env ${uai_assign[@]+"${uai_assign[@]}"} git "$@"
786
+ fi
787
+ }
788
+
789
+ # ADR-154 §12: a task on cloud credentials that GitHub refused says which of
790
+ # its owner's credentials was refused (the lease's GitHub sign-in or SSH key)
791
+ # and where to fix that: Uai's Settings, never this host.
792
+ emit_leased_github_denial() {
793
+ case "$1" in
794
+ token)
795
+ emit_err "GITHUB_TOKEN_ACCESS_DENIED" \
796
+ "GitHub denied the task owner's GitHub sign-in access to $2. Confirm that the Moondeck GitHub App is installed on the owning account or organization, this repository is included in its grant, and the owner can read it. Then start the task again." \
797
+ "$3"
798
+ ;;
799
+ ssh)
800
+ emit_err "GITHUB_SSH_ACCESS_DENIED" \
801
+ "GitHub denied the task owner's SSH key access to $2. Register that key with an account that can read the repository (and authorize it for SAML SSO when required), or connect GitHub in Settings. Then start the task again." \
802
+ "$3"
803
+ ;;
804
+ *)
805
+ emit_err "GITHUB_CONNECTION_REQUIRED" \
806
+ "The task owner has neither a GitHub sign-in nor an SSH key in Moondeck that can read $2. Connect GitHub in Settings, then start the task again." \
807
+ "$3"
808
+ ;;
809
+ esac
810
+ }
811
+
812
+ git_with_github_credential() (
813
+ unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
814
+ GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
815
+ GIT_TRACE_PACKET GIT_TRACE_REDACT
816
+ export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
817
+ # If a container ever poisoned a shared mirror's URL config, fail closed
818
+ # instead of silently switching this operation to an SSH principal.
819
+ uai_git GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 -- \
820
+ -c core.hooksPath=/dev/null \
821
+ -c core.fsmonitor=false \
822
+ -c http.extraHeader= \
823
+ -c http.sslVerify=true \
824
+ -c credential.helper= \
825
+ -c "credential.helper=$github_git_helper" \
826
+ "$@"
827
+ )
828
+
829
+ # Command-scoped rewrite: the shared origin remains canonical HTTPS while this
830
+ # one task uses its SSH fallback identity.
831
+ git_with_github_ssh() (
832
+ unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
833
+ GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
834
+ GIT_TRACE_PACKET GIT_TRACE_REDACT
835
+ export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
836
+ # The SSH command chosen above: the task owner's identity, nothing else.
837
+ uai_git "GIT_SSH_COMMAND=${GIT_SSH_COMMAND:-/bin/false}" GIT_TERMINAL_PROMPT=0 -- \
838
+ -c core.hooksPath=/dev/null \
839
+ -c core.fsmonitor=false \
840
+ -c 'url.git@github.com:.insteadOf=https://github.com/' \
841
+ "$@"
842
+ )
843
+
844
+ # Clear every ambient credential helper so a public fallback cannot borrow the
845
+ # host operator's GitHub identity.
846
+ git_anonymous_https() (
847
+ unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
848
+ GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
849
+ GIT_TRACE_PACKET GIT_TRACE_REDACT
850
+ export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
851
+ uai_git GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 -- \
852
+ -c core.hooksPath=/dev/null \
853
+ -c core.fsmonitor=false \
854
+ -c http.extraHeader= \
855
+ -c http.sslVerify=true \
856
+ -c credential.helper= \
857
+ "$@"
858
+ )
859
+
860
+ # Host-only Git control plane. New task containers see only their self-contained
861
+ # private repo; the shared mirror is a setup-time fetch cache and is never
862
+ # mounted. Before every host operation we still rebuild its config from trusted
863
+ # project data and discard hooks, both to heal legacy tasks and to fail closed
864
+ # after a crash.
865
+ git_host_control() (
866
+ unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
867
+ GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
868
+ GIT_TRACE_PACKET GIT_TRACE_REDACT GIT_CONFIG_COUNT \
869
+ GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1
870
+ export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
871
+ # This helper is for local repository bookkeeping and public/local remotes.
872
+ # It must never inherit the operator's SSH agent or credential helper. Every
873
+ # authenticated GitHub fetch goes through one of the explicit helpers above.
874
+ uai_git GIT_CONFIG_COUNT=3 \
875
+ GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null \
876
+ GIT_CONFIG_KEY_1=core.fsmonitor GIT_CONFIG_VALUE_1=false \
877
+ GIT_CONFIG_KEY_2=advice.defaultBranchName GIT_CONFIG_VALUE_2=false \
878
+ GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 -- \
879
+ -c credential.helper= \
880
+ "$@"
881
+ )
882
+
883
+ # ADR-153 §4: record, once, the base a project's task worktree starts from, in
884
+ # the host control tree the container never sees, before anything in the task
885
+ # runs. The runner reads that commit's .env.schema from the host cache; a
886
+ # resume keeps the first record and never consults the worktree, which the
887
+ # task can rewrite. "oid <commit>" or "empty" (a branchless repository).
888
+ record_env_schema_base() {
889
+ local project_id="$1" base="$2" dir="$task_uai_dir/env-schemas" tmp
890
+ [ -e "$dir/$project_id.base" ] && return 0
891
+ mkdir -p "$dir" || return 1
892
+ chmod 700 "$dir" || return 1
893
+ tmp=$(mktemp "$dir/.base.XXXXXX") || return 1
894
+ if ! printf '%s\n' "$base" > "$tmp" || ! mv -f "$tmp" "$dir/$project_id.base"; then
895
+ rm -f "$tmp"
896
+ return 1
897
+ fi
898
+ }
899
+
900
+ assert_bare_repo_tree_safe() {
901
+ local repo="$1" code="$2" label="$3" bad_entry=""
902
+ if [ -L "$repo" ] || [ ! -d "$repo" ]; then
903
+ emit_err "$code" \
904
+ "Moondeck refused the $label because its repository root is missing or is a symbolic link: $repo" \
905
+ "validate $label"
906
+ fi
907
+ # Git should only ever create regular files and directories in these control
908
+ # trees. Reject links, FIFOs, sockets, and devices before any Git process can
909
+ # follow or block on a container-created entry.
910
+ if ! bad_entry=$(find "$repo" ! -type f ! -type d -print -quit 2>/dev/null); then
911
+ emit_err "$code" \
912
+ "Moondeck could not validate the $label before host Git access" \
913
+ "validate $label"
914
+ fi
915
+ if [ -n "$bad_entry" ]; then
916
+ emit_err "$code" \
917
+ "Moondeck refused the $label because it contains an unsafe non-file entry at $bad_entry. The task container has been removed; inspect or recreate the task before retrying." \
918
+ "validate $label"
919
+ fi
920
+ }
921
+
922
+ # Make a host-created task tree writable by the container's uid-1000 node user
923
+ # without making it world-writable or changing ownership away from the host.
924
+ # `find` deliberately does not follow repository symlinks. Setgid directories
925
+ # keep Git-created descendants in the shared group; core.sharedRepository
926
+ # below makes Git's own files group-writable as well.
927
+ set_task_entry_container_access() {
928
+ local entry="$1"
929
+ chgrp "$task_container_gid" "$entry" || return 1
930
+ if [ -d "$entry" ]; then
931
+ chmod ug+rwx,g+s "$entry" || return 1
932
+ else
933
+ chmod ug+rw "$entry" || return 1
934
+ fi
935
+ }
936
+
937
+ share_task_entry_with_container() {
938
+ local entry="$1" label="$2"
939
+ if ! set_task_entry_container_access "$entry"; then
940
+ emit_err "WORKTREE_FAILED" \
941
+ "Moondeck could not share the $label with the task container" \
942
+ "set task filesystem permissions"
943
+ fi
944
+ }
945
+
946
+ share_task_tree_with_container() {
947
+ local root="$1" label="$2" entry entry_list failed_entry=""
948
+ [ -d "$root" ] || return 0
949
+
950
+ entry_list=$(mktemp "/tmp/uai-task-tree.XXXXXX")
951
+ if ! find "$root" \( -type d -o -type f \) -print0 > "$entry_list"; then
952
+ rm -f "$entry_list"
953
+ emit_err "WORKTREE_FAILED" \
954
+ "Moondeck could not enumerate the complete $label before sharing it with the task container" \
955
+ "enumerate task filesystem"
956
+ fi
957
+ while IFS= read -r -d '' entry; do
958
+ if ! set_task_entry_container_access "$entry"; then
959
+ failed_entry="$entry"
960
+ break
961
+ fi
962
+ done < "$entry_list"
963
+ rm -f "$entry_list"
964
+ if [ -n "$failed_entry" ]; then
965
+ emit_err "WORKTREE_FAILED" \
966
+ "Moondeck could not share the complete $label with the task container" \
967
+ "set task filesystem permissions"
968
+ fi
969
+ }
970
+
971
+ sanitize_mirror_control_plane() {
972
+ local mirror="$1" origin="$2" config_tmp
973
+ assert_bare_repo_tree_safe "$mirror" "FETCH_FAILED" "host repository cache"
974
+ # Stage outside the bare repo so a predictable in-repo temporary path cannot
975
+ # be redirected even if a previous host operation crashed midway.
976
+ config_tmp=$(mktemp "$(dirname "$mirror")/.uai-config.XXXXXX")
977
+ git_host_control config --file "$config_tmp" core.repositoryformatversion 0
978
+ git_host_control config --file "$config_tmp" core.filemode true
979
+ git_host_control config --file "$config_tmp" core.bare true
980
+ git_host_control config --file "$config_tmp" remote.origin.url "$origin"
981
+ git_host_control config --file "$config_tmp" remote.origin.fetch \
982
+ '+refs/heads/*:refs/remotes/origin/*'
983
+ git_host_control config --file "$config_tmp" remote.pushDefault origin
984
+ git_host_control config --file "$config_tmp" push.autoSetupRemote true
985
+ mv -f "$config_tmp" "$mirror/config"
986
+ rm -rf "$mirror/hooks"
987
+ mkdir -p "$mirror/hooks"
988
+ }
989
+
990
+ sanitize_task_repo_control_plane() {
991
+ local repo="$1" origin="$2" branch="$3" bare="${4:-true}" config_tmp
992
+ local branch_remote="origin" branch_merge="refs/heads/$branch" candidate
993
+ assert_bare_repo_tree_safe "$repo" "WORKTREE_FAILED" \
994
+ "task-private repository"
995
+
996
+ # Preserve only the current branch's safe upstream from the container-
997
+ # writable config. Includes are disabled, the remote must remain `origin`,
998
+ # and the merge target must be a valid heads ref. Everything executable or
999
+ # host-sensitive (hooks, includes, helpers, fsmonitor) is rebuilt below.
1000
+ if [ -f "$repo/config" ]; then
1001
+ candidate=$(git_host_control config --no-includes --file "$repo/config" \
1002
+ --get "branch.$branch.remote" 2>/dev/null || true)
1003
+ [ "$candidate" = "origin" ] && branch_remote="$candidate"
1004
+ candidate=$(git_host_control config --no-includes --file "$repo/config" \
1005
+ --get "branch.$branch.merge" 2>/dev/null || true)
1006
+ case "$candidate" in
1007
+ refs/heads/*)
1008
+ if git_host_control check-ref-format "$candidate" >/dev/null 2>&1; then
1009
+ branch_merge="$candidate"
1010
+ fi
1011
+ ;;
1012
+ esac
1013
+ fi
1014
+
1015
+ config_tmp=$(mktemp "$(dirname "$repo")/.uai-task-config.XXXXXX")
1016
+ git_host_control config --file "$config_tmp" core.repositoryformatversion 0
1017
+ git_host_control config --file "$config_tmp" core.filemode true
1018
+ git_host_control config --file "$config_tmp" core.bare "$bare"
1019
+ git_host_control config --file "$config_tmp" core.sharedRepository group
1020
+ git_host_control config --file "$config_tmp" remote.origin.url "$origin"
1021
+ git_host_control config --file "$config_tmp" remote.origin.fetch \
1022
+ '+refs/heads/*:refs/remotes/origin/*'
1023
+ git_host_control config --file "$config_tmp" remote.pushDefault origin
1024
+ git_host_control config --file "$config_tmp" push.autoSetupRemote true
1025
+ git_host_control config --file "$config_tmp" \
1026
+ "branch.$branch.remote" "$branch_remote"
1027
+ git_host_control config --file "$config_tmp" \
1028
+ "branch.$branch.merge" "$branch_merge"
1029
+ mv -f "$config_tmp" "$repo/config"
1030
+ rm -rf "$repo/hooks"
1031
+ mkdir -p "$repo/hooks"
1032
+ # mktemp is intentionally 0600. Once its trusted contents are atomically in
1033
+ # place, grant only the selected task group access; this must run on every
1034
+ # resume even after the one-time tree migration marker exists.
1035
+ share_task_entry_with_container "$repo/config" \
1036
+ "task-private Git config"
1037
+ share_task_entry_with_container "$repo/hooks" \
1038
+ "task-private Git hooks directory"
1039
+ }
1040
+
1041
+ # ADR-062 shared files: two host-resident roots mounted into the container
1042
+ # when the task's mode isn't "off". Created here so Docker doesn't create
1043
+ # them root-owned at mount time. Mode defaults to ro; org mount only when
1044
+ # the cloud sent an org id (older clouds won't).
1045
+ shared_files_mode=$(jq -r '.[0].shared_files // "ro"' <<<"$task_json")
1046
+ shared_owner_org=$(jq -r '.[0].owner_org_id // ""' <<<"$task_json")
1047
+ shared_owner_user=$(jq -r '.[0].owner_user_id // ""' <<<"$task_json")
1048
+ # Colons break docker's src:dst:mode bind syntax, and personal org ids are
1049
+ # "personal:<userId>" — map ":" to "_". MUST match sharedDirName() in
1050
+ # host-agent/lib/shared-files.ts.
1051
+ shared_owner_org=$(printf '%s' "$shared_owner_org" | tr ':' '_')
1052
+ shared_owner_user=$(printf '%s' "$shared_owner_user" | tr ':' '_')
1053
+ shared_root="$UAI_WORKSPACE_ROOT/shared"
1054
+ # The workspace's own .moondeck is the only one the container can read (/workspace/.moondeck):
1055
+ # the CLI, the chat log and the attachments agents are told to write screenshots
1056
+ # into all live here. Create it unconditionally — it used to be made only when
1057
+ # shared files were on, and `attachments` was made in the unmounted control tree,
1058
+ # so every first browser_take_screenshot hit ENOENT (first seen 2026-08-24).
1059
+ reject_unsafe_host_directory "$task_workspace/.moondeck" \
1060
+ "the task workspace marker directory"
1061
+ mkdir -p "$task_workspace/.moondeck/attachments"
1062
+ if [ "$shared_files_mode" != "off" ]; then
1063
+ [ -n "$shared_owner_org" ] && mkdir -p "$shared_root/orgs/$shared_owner_org"
1064
+ [ -n "$shared_owner_user" ] && mkdir -p "$shared_root/users/$shared_owner_user"
1065
+ # Marker for the preamble: only containers that actually carry the mounts
1066
+ # get the "## Shared files" briefing (pre-feature containers don't).
1067
+ # Never written through a link or special file the task left in its place
1068
+ # (#cb3j0z): that is removed first (rm never follows a link), and nothing
1069
+ # is written where anything remains. The task's container is gone, so
1070
+ # nothing races this.
1071
+ files_mounted="$task_workspace/.moondeck/files-mounted"
1072
+ if [ -L "$files_mounted" ] || { [ -e "$files_mounted" ] && [ ! -f "$files_mounted" ]; }; then
1073
+ rm -f "$files_mounted" 2>/dev/null || true
1074
+ fi
1075
+ [ -L "$files_mounted" ] || [ -e "$files_mounted" ] || : > "$files_mounted"
1076
+ fi
1077
+
1078
+ # Iterate using jq -c so each project is a single JSON object per line.
1079
+ # Bash 3.2 friendly: a while-read loop rather than mapfile/readarray.
1080
+ while IFS= read -r project_obj; do
1081
+ [ -n "$project_obj" ] || continue
1082
+
1083
+ project_id=$(jq -r '.id' <<<"$project_obj")
1084
+ project_slug=$(jq -r '.slug' <<<"$project_obj")
1085
+ case "$project_slug" in
1086
+ ''|.|..|*/*|*\\*)
1087
+ emit_err "WORKTREE_FAILED" \
1088
+ "Moondeck refused an invalid project workspace component: $project_slug" \
1089
+ "validate task worktree ($project_id)"
1090
+ ;;
1091
+ esac
1092
+ acquire_project_lock "$projects_root" "$project_id"
1093
+ repo_url_input=$(jq -r '.repo_url' <<<"$project_obj")
1094
+ repo_is_github=0
1095
+ if is_github_hosted_url "$repo_url_input"; then
1096
+ repo_is_github=1
1097
+ fi
1098
+ # The mirror is fetched HERE, before the task container exists. GitHub URLs
1099
+ # stay canonical HTTPS: a connected user's private credential socket
1100
+ # authenticates them; users without a connection get the SSH fallback. The
1101
+ # token never becomes part of this shared mirror or its remote URL.
1102
+ repo_url=$(normalize_github_repo_url "$repo_url_input")
1103
+ github_https_url=$(github_https_repo_url "$repo_url")
1104
+ if [ "$repo_is_github" = "1" ] && [ -z "$github_https_url" ]; then
1105
+ emit_err "CLONE_FAILED" \
1106
+ "Moondeck refused the malformed GitHub repository URL '$repo_url_input'. Use https://github.com/OWNER/REPOSITORY.git and retry." \
1107
+ "validate GitHub repository URL ($project_id)"
1108
+ fi
1109
+ tool_versions=$(jq -r '.tool_versions // ""' <<<"$project_obj")
1110
+
1111
+ # `repo.git` was historically bind-mounted RW into every task container.
1112
+ # Never reuse it as trusted host input: running legacy tasks can still hold
1113
+ # that old bind mount. The new cache path has never entered a container.
1114
+ mirror_dir="$projects_root/$project_id/host-cache.git"
1115
+ task_repo_dir="$task_uai_dir/repos/$project_id.git"
1116
+ task_repo_dir_physical="$task_uai_dir_physical/repos/$project_id.git"
1117
+ worktree_target="$task_workspace/$project_slug"
1118
+ reject_unsafe_host_directory "$worktree_target" \
1119
+ "the project worktree for $project_slug"
1120
+ # Check before remote/cache Git: a dangling symlink makes `-e` false and the
1121
+ # later seed write would otherwise follow it outside the workspace.
1122
+ reject_unsafe_runtime_manifest "$worktree_target/.tool-versions" \
1123
+ "$project_slug" "$project_id"
1124
+ worktree_exists=0
1125
+ standalone_worktree=0
1126
+ if [ -e "$worktree_target/.git" ] || [ -L "$worktree_target/.git" ]; then
1127
+ worktree_exists=1
1128
+ if [ -L "$worktree_target/.git" ]; then
1129
+ emit_err "WORKTREE_FAILED" \
1130
+ "Moondeck refused to resume $project_slug because its .git path is a symbolic link. The previous container was removed; inspect or recreate the task before retrying." \
1131
+ "validate task worktree ($project_id)"
1132
+ elif [ -d "$worktree_target/.git" ]; then
1133
+ # Empty remotes use a standalone repository inside the workspace.
1134
+ standalone_worktree=1
1135
+ assert_bare_repo_tree_safe "$worktree_target/.git" "WORKTREE_FAILED" \
1136
+ "standalone task repository"
1137
+ elif [ -f "$worktree_target/.git" ]; then
1138
+ gitdir_lines=$(awk 'END { print NR }' "$worktree_target/.git")
1139
+ gitdir_value=$(sed -n 's/^gitdir: //p' "$worktree_target/.git")
1140
+ case "$gitdir_value" in
1141
+ "$task_repo_dir"/worktrees/*)
1142
+ gitdir_leaf=${gitdir_value#"$task_repo_dir"/worktrees/}
1143
+ ;;
1144
+ "$task_repo_dir_physical"/worktrees/*)
1145
+ gitdir_leaf=${gitdir_value#"$task_repo_dir_physical"/worktrees/}
1146
+ ;;
1147
+ *)
1148
+ emit_err "WORKTREE_FAILED" \
1149
+ "This task uses Moondeck's legacy shared Git layout. Its container has been removed so it can no longer write the shared mirror. Preserve any uncommitted changes from $worktree_target, then recreate the task to migrate safely." \
1150
+ "legacy task repository requires recreation ($project_id)"
1151
+ ;;
1152
+ esac
1153
+ case "$gitdir_leaf" in
1154
+ ''|.|..|*/*)
1155
+ emit_err "WORKTREE_FAILED" \
1156
+ "Moondeck refused an invalid task Git metadata path for $project_slug" \
1157
+ "validate task worktree ($project_id)"
1158
+ ;;
1159
+ esac
1160
+ if [ "$gitdir_lines" != "1" ] || [ ! -d "$gitdir_value" ] \
1161
+ || [ -L "$gitdir_value" ]; then
1162
+ emit_err "WORKTREE_FAILED" \
1163
+ "Moondeck refused invalid or missing task Git metadata for $project_slug. The previous container was removed; inspect or recreate the task before retrying." \
1164
+ "validate task worktree ($project_id)"
1165
+ fi
1166
+ assert_bare_repo_tree_safe "$task_repo_dir" "WORKTREE_FAILED" \
1167
+ "task-private repository"
1168
+ else
1169
+ emit_err "WORKTREE_FAILED" \
1170
+ "Moondeck refused an unsupported .git entry for $project_slug" \
1171
+ "validate task worktree ($project_id)"
1172
+ fi
1173
+ fi
1174
+
1175
+ # Ensure the bare mirror exists with **remote-tracking refs**. Plain
1176
+ # `git clone --bare` copies upstream heads straight into refs/heads/*
1177
+ # and never populates refs/remotes/origin/*, so `worktree add ...
1178
+ # origin/<defaultBranch>` would fail with "invalid reference". Init +
1179
+ # remote + standard refspec + fetch gives a proper bare repo whose
1180
+ # origin/* refs match the remote tip.
1181
+ needs_initial_fetch=0
1182
+ if [ ! -d "$mirror_dir" ]; then
1183
+ step "CLONE_FAILED" "bare-clone ($project_id)"
1184
+ mkdir -p "$(dirname "$mirror_dir")"
1185
+ git_host_control init --bare "$mirror_dir" >/dev/null
1186
+ needs_initial_fetch=1
1187
+ fi
1188
+
1189
+ # Replace all container-writable/legacy config before host Git reads the
1190
+ # mirror. This also heals SSH origins to the canonical credential-free URL.
1191
+ sanitize_mirror_control_plane "$mirror_dir" "$repo_url"
1192
+
1193
+ github_fetch_mode=""
1194
+
1195
+ if [ "$needs_initial_fetch" = "1" ]; then
1196
+ # First fetch on a brand-new mirror — required. Bail and clean up so
1197
+ # the next attempt starts fresh rather than re-using a half-init.
1198
+ fetch_err=""
1199
+ fetch_failed=0
1200
+ if [ -n "$github_https_url" ] && [ -n "$github_git_helper" ]; then
1201
+ github_fetch_mode="token"
1202
+ if ! fetch_err=$(git_with_github_credential \
1203
+ -C "$mirror_dir" fetch origin 2>&1); then
1204
+ fetch_failed=1
1205
+ fi
1206
+ elif [ -n "$github_https_url" ]; then
1207
+ if [ "$uai_has_ssh_identity" = "1" ]; then
1208
+ github_fetch_mode="ssh"
1209
+ if ! fetch_err=$(git_with_github_ssh \
1210
+ -C "$mirror_dir" fetch origin 2>&1); then
1211
+ fetch_failed=1
1212
+ # github.com requires an authenticated SSH handshake even for public
1213
+ # repositories. Preserve the no-credential public-repo path, except
1214
+ # for a key a task's lease names: its route fails closed.
1215
+ if [ "$uai_leased_ssh" != "1" ] && git_anonymous_https -C "$mirror_dir" fetch origin \
1216
+ >/dev/null 2>&1; then
1217
+ github_fetch_mode="anonymous"
1218
+ fetch_failed=0
1219
+ fetch_err=""
1220
+ fi
1221
+ fi
1222
+ else
1223
+ github_fetch_mode="none"
1224
+ if ! fetch_err=$(git_anonymous_https \
1225
+ -C "$mirror_dir" fetch origin 2>&1); then
1226
+ fetch_failed=1
1227
+ else
1228
+ github_fetch_mode="anonymous"
1229
+ fi
1230
+ fi
1231
+ elif ! fetch_err=$(git_host_control -C "$mirror_dir" fetch origin 2>&1); then
1232
+ fetch_failed=1
1233
+ fi
1234
+
1235
+ if [ "$fetch_failed" = "1" ]; then
1236
+ [ -n "$fetch_err" ] || fetch_err="git fetch failed without details"
1237
+ rm -rf "$mirror_dir"
1238
+ # GitHub deliberately uses the same 404 for a missing private repository
1239
+ # and a credential that cannot see it. Name the credential path Uai
1240
+ # actually chose so the recovery action is truthful.
1241
+ case "$fetch_err" in
1242
+ *"Repository not found"*|*"repository not found"*|\
1243
+ *"Permission denied"*|*"access rights"*|*"could not read Username"*|\
1244
+ *"Authentication failed"*|*"Invalid username or password"*|*"HTTP 401"*|\
1245
+ *"HTTP 403"*|*"HTTP 404"*|*"requested URL returned error: 401"*|\
1246
+ *"requested URL returned error: 403"*|*"requested URL returned error: 404"*)
1247
+ if [ "$uai_leased_git" = "1" ]; then
1248
+ emit_leased_github_denial "$github_fetch_mode" "$repo_url" "bare-clone ($project_id)"
1249
+ fi
1250
+ if [ "$github_fetch_mode" = "token" ]; then
1251
+ emit_err "GITHUB_TOKEN_ACCESS_DENIED" \
1252
+ "GitHub denied the connected user's access to $repo_url. Confirm that the Moondeck GitHub App is installed on the owning account or organization, this repository is included in its grant, and the connected user can write to it. Then retry the task." \
1253
+ "bare-clone ($project_id)"
1254
+ elif [ "$github_fetch_mode" = "ssh" ]; then
1255
+ emit_err "GITHUB_SSH_ACCESS_DENIED" \
1256
+ "GitHub is not connected on this host, so Moondeck tried its SSH fallback for $repo_url, but GitHub denied that key. Connect GitHub on this host, or register its public SSH key with an account that can read the repository (and authorize it for SAML SSO when required)." \
1257
+ "bare-clone ($project_id)"
1258
+ elif [ "$github_fetch_mode" = "none" ]; then
1259
+ emit_err "GITHUB_CONNECTION_REQUIRED" \
1260
+ "This host has neither a GitHub connection nor an SSH fallback key that can read $repo_url. Connect GitHub on this host, or configure its optional SSH key, then retry the task." \
1261
+ "bare-clone ($project_id)"
1262
+ else
1263
+ case "$repo_url" in
1264
+ git@*|ssh://*)
1265
+ emit_err "CLONE_FAILED" \
1266
+ "The git server denied this host's SSH key access to $repo_url. Confirm the repository exists and add that host's public key to an account that can read it." \
1267
+ "bare-clone ($project_id)"
1268
+ ;;
1269
+ *)
1270
+ emit_err "CLONE_FAILED" \
1271
+ "Access to $repo_url was denied. Configure the matching credential on this host, then retry the task." \
1272
+ "bare-clone ($project_id)"
1273
+ ;;
1274
+ esac
1275
+ fi
1276
+ ;;
1277
+ *)
1278
+ emit_err "CLONE_FAILED" \
1279
+ "initial fetch from $repo_url failed: $fetch_err" \
1280
+ "bare-clone ($project_id)"
1281
+ ;;
1282
+ esac
1283
+ fi
1284
+ else
1285
+ # Existing mirror — refresh best-effort. Offline / network blips should not
1286
+ # destroy a task that already has cached refs, but never switch principals.
1287
+ step "FETCH_FAILED" "git fetch origin ($project_id)"
1288
+ fetch_ok=0
1289
+ fetch_err=""
1290
+ if [ -n "$github_https_url" ] && [ -n "$github_git_helper" ]; then
1291
+ github_fetch_mode="token"
1292
+ if fetch_err=$(git_with_github_credential \
1293
+ -C "$mirror_dir" fetch origin 2>&1); then
1294
+ fetch_ok=1
1295
+ fi
1296
+ elif [ -n "$github_https_url" ]; then
1297
+ if [ "$uai_has_ssh_identity" = "1" ]; then
1298
+ github_fetch_mode="ssh"
1299
+ if fetch_err=$(git_with_github_ssh \
1300
+ -C "$mirror_dir" fetch origin 2>&1); then
1301
+ fetch_ok=1
1302
+ fi
1303
+ else
1304
+ github_fetch_mode="none"
1305
+ fi
1306
+ # A key a task's lease names never falls back to another principal.
1307
+ if [ "$fetch_ok" != "1" ] && [ "$uai_leased_ssh" != "1" ]; then
1308
+ anonymous_err=""
1309
+ if anonymous_err=$(git_anonymous_https \
1310
+ -C "$mirror_dir" fetch origin 2>&1); then
1311
+ github_fetch_mode="anonymous"
1312
+ fetch_ok=1
1313
+ fetch_err=""
1314
+ elif [ "$github_fetch_mode" = "none" ]; then
1315
+ fetch_err="$anonymous_err"
1316
+ fi
1317
+ fi
1318
+ elif fetch_err=$(git_host_control -C "$mirror_dir" fetch origin 2>&1); then
1319
+ fetch_ok=1
1320
+ fi
1321
+ if [ "$fetch_ok" != "1" ]; then
1322
+ case "$fetch_err" in
1323
+ *"Repository not found"*|*"repository not found"*|\
1324
+ *"Permission denied"*|*"access rights"*|*"could not read Username"*|\
1325
+ *"Authentication failed"*|*"Invalid username or password"*|*"HTTP 401"*|\
1326
+ *"HTTP 403"*|*"HTTP 404"*|*"requested URL returned error: 401"*|\
1327
+ *"requested URL returned error: 403"*|*"requested URL returned error: 404"*)
1328
+ if [ "$uai_leased_git" = "1" ]; then
1329
+ emit_leased_github_denial "$github_fetch_mode" "$repo_url" "git fetch origin ($project_id)"
1330
+ fi
1331
+ case "$github_fetch_mode" in
1332
+ token)
1333
+ emit_err "GITHUB_TOKEN_ACCESS_DENIED" \
1334
+ "GitHub denied the connected user's access to $repo_url. Confirm that the Moondeck GitHub App grant still includes this repository and the connected user can write to it, then retry the task." \
1335
+ "git fetch origin ($project_id)"
1336
+ ;;
1337
+ ssh)
1338
+ emit_err "GITHUB_SSH_ACCESS_DENIED" \
1339
+ "GitHub is not connected on this host, and it denied the SSH fallback for $repo_url. Connect GitHub or fix this host's SSH key access, then retry the task." \
1340
+ "git fetch origin ($project_id)"
1341
+ ;;
1342
+ none)
1343
+ emit_err "GITHUB_CONNECTION_REQUIRED" \
1344
+ "This host has neither a GitHub connection nor an SSH fallback key that can read $repo_url. Connect GitHub on this host, or configure its optional SSH key, then retry the task." \
1345
+ "git fetch origin ($project_id)"
1346
+ ;;
1347
+ *)
1348
+ emit_err "FETCH_FAILED" \
1349
+ "The configured credential no longer has access to $repo_url. Restore access, then retry the task." \
1350
+ "git fetch origin ($project_id)"
1351
+ ;;
1352
+ esac
1353
+ ;;
1354
+ *)
1355
+ if [ "$worktree_exists" = "1" ]; then
1356
+ # Resume is the only safe stale-cache case: this same task already
1357
+ # owns the checkout. A new task/user must prove current access
1358
+ # before any shared cached code is materialized into its workspace.
1359
+ log "warning: git fetch failed for $project_id ($repo_url); resuming this task's existing worktree with cached refs"
1360
+ else
1361
+ emit_err "FETCH_FAILED" \
1362
+ "could not verify the task creator's current access to $repo_url: ${fetch_err:-git fetch failed}. Retry when the remote is reachable." \
1363
+ "git fetch origin ($project_id)"
1364
+ fi
1365
+ ;;
1366
+ esac
1367
+ fi
1368
+ fi
1369
+
1370
+ if [ "$github_fetch_mode" = "ssh" ]; then
1371
+ if [ "$needs_initial_fetch" = "1" ] && [ "$fetch_failed" = "0" ]; then
1372
+ uai_used_ssh_transport=1
1373
+ elif [ "$needs_initial_fetch" != "1" ] && [ "$fetch_ok" = "1" ]; then
1374
+ uai_used_ssh_transport=1
1375
+ fi
1376
+ fi
1377
+
1378
+ # Resolve the remote default branch with the same task credential
1379
+ # (best-effort) — fall back to main.
1380
+ case "$github_fetch_mode" in
1381
+ token)
1382
+ git_with_github_credential -C "$mirror_dir" remote set-head origin -a \
1383
+ >/dev/null 2>&1 || true
1384
+ ;;
1385
+ ssh)
1386
+ git_with_github_ssh -C "$mirror_dir" remote set-head origin -a \
1387
+ >/dev/null 2>&1 || true
1388
+ ;;
1389
+ anonymous)
1390
+ git_anonymous_https -C "$mirror_dir" remote set-head origin -a \
1391
+ >/dev/null 2>&1 || true
1392
+ ;;
1393
+ none)
1394
+ git_anonymous_https -C "$mirror_dir" remote set-head origin -a \
1395
+ >/dev/null 2>&1 || true
1396
+ ;;
1397
+ *)
1398
+ git_host_control -C "$mirror_dir" remote set-head origin -a >/dev/null 2>&1 || true
1399
+ ;;
1400
+ esac
1401
+ project_default=$(git_host_control -C "$mirror_dir" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null \
1402
+ | sed 's#^origin/##' || true)
1403
+ [ -n "$project_default" ] || project_default="main"
1404
+
1405
+ # Each linked worktree gets its own writable Git control plane (config,
1406
+ # hooks, refs, metadata, and objects). It borrows the host-only cache only
1407
+ # while being prepared, then repacks every reachable object locally and
1408
+ # removes the alternate before the container starts.
1409
+ worktree_repo_dir="$task_repo_dir"
1410
+ if [ "$standalone_worktree" = "1" ]; then
1411
+ worktree_repo_dir="$worktree_target/.git"
1412
+ sanitize_task_repo_control_plane "$worktree_repo_dir" "$repo_url" \
1413
+ "$task_branch" false
1414
+ else
1415
+ if [ ! -d "$task_repo_dir" ]; then
1416
+ mkdir -p "$(dirname "$task_repo_dir")"
1417
+ git_host_control init --bare "$task_repo_dir" >/dev/null
1418
+ fi
1419
+ sanitize_task_repo_control_plane "$task_repo_dir" "$repo_url" \
1420
+ "$task_branch" true
1421
+ rm -rf "$task_repo_dir/objects/info"
1422
+ mkdir -p "$task_repo_dir/objects/info"
1423
+ printf '%s\n' "$mirror_dir/objects" \
1424
+ > "$task_repo_dir/objects/info/alternates"
1425
+ # Copy only refs/objects from the trusted host mirror. The task repo's
1426
+ # remote remains canonical GitHub HTTPS from the sanitizer above.
1427
+ if ! git_host_control -C "$task_repo_dir" fetch "$mirror_dir" \
1428
+ '+refs/remotes/origin/*:refs/remotes/origin/*' >/dev/null 2>&1; then
1429
+ emit_err "WORKTREE_FAILED" \
1430
+ "could not copy the fetched refs into the task repository for $repo_url" \
1431
+ "copy task repository refs ($project_id)"
1432
+ fi
1433
+ if ! git_host_control -C "$task_repo_dir" repack -a -d >/dev/null; then
1434
+ emit_err "WORKTREE_FAILED" \
1435
+ "could not make the task repository self-contained for $repo_url" \
1436
+ "copy task repository objects ($project_id)"
1437
+ fi
1438
+ rm -f "$task_repo_dir/objects/info/alternates"
1439
+ fi
1440
+
1441
+ mkdir -p "$(dirname "$worktree_target")"
1442
+ if [ -e "$worktree_target/.git" ]; then
1443
+ # Resume (ADR-028): the task worktree survived the stop — reuse it as-is
1444
+ # (a linked worktree's .git is a FILE, hence -e). Re-running `git
1445
+ # worktree add` would refuse ("already exists" / branch exists) and
1446
+ # errored EVERY resume of a worktree-preserving stop within seconds
1447
+ # (found live 2026-07-21). The task branch and any uncommitted work are
1448
+ # exactly what the user expects back.
1449
+ log "worktree for $project_slug already present — resuming with it"
1450
+ if [ "$standalone_worktree" != "1" ]; then
1451
+ # Repair only after the task-private repository and its gitdir pointer
1452
+ # have passed the host boundary checks above.
1453
+ git_host_control -C "$worktree_repo_dir" worktree repair \
1454
+ "$worktree_target" 2>/dev/null || true
1455
+ fi
1456
+ elif [ -n "$task_branch" ] && [ "$task_branch" != "null" ] \
1457
+ && [ "$task_branch" != "$project_default" ] \
1458
+ && ! git_host_control -C "$worktree_repo_dir" rev-parse --verify --quiet \
1459
+ "refs/heads/$task_branch" >/dev/null 2>&1 \
1460
+ && git_host_control -C "$worktree_repo_dir" rev-parse --verify --quiet \
1461
+ "refs/remotes/origin/$task_branch^{commit}" >/dev/null 2>&1; then
1462
+ # The worktree is gone but the task's branch is on the remote (a lost
1463
+ # task directory, 2026-10-20: Resume rebuilt from the base, putting the
1464
+ # agents on the wrong code with the pushed commits out of sight). The
1465
+ # fetch above is fresh — a missing worktree makes a failed fetch fatal —
1466
+ # so continue from the pushed branch, never the base.
1467
+ step "WORKTREE_FAILED" "git worktree add from origin/$task_branch ($project_id)"
1468
+ log "worktree for $project_slug missing — restoring it from origin/$task_branch"
1469
+ # The base this task branched from, as nearly as the remote can tell:
1470
+ # where the task branch leaves the default branch.
1471
+ env_schema_oid=$(git_host_control -C "$mirror_dir" merge-base \
1472
+ "refs/remotes/origin/$task_branch" "refs/remotes/origin/$project_default" \
1473
+ 2>/dev/null || true)
1474
+ if [ -n "$env_schema_oid" ]; then
1475
+ record_env_schema_base "$project_id" "oid $env_schema_oid" \
1476
+ || log "warning: could not record the .env.schema base for $project_id; its keys without a binding are withheld"
1477
+ fi
1478
+ # --no-track: the sanitizer above already wrote this branch's upstream.
1479
+ if ! git_host_control -C "$worktree_repo_dir" worktree add --no-track \
1480
+ "$worktree_target" -b "$task_branch" "refs/remotes/origin/$task_branch"; then
1481
+ emit_err "WORKTREE_FAILED" \
1482
+ "could not restore the task worktree from origin/$task_branch for $repo_url" \
1483
+ "git worktree add from origin/$task_branch ($project_id)"
1484
+ fi
1485
+ elif git_host_control -C "$worktree_repo_dir" rev-parse --verify --quiet \
1486
+ "refs/remotes/origin/$project_default" >/dev/null 2>&1; then
1487
+ # Normal case: branch the task worktree off origin/<defaultBranch>.
1488
+ # Only when the remote has no branch of this task's yet.
1489
+ step "WORKTREE_FAILED" "git worktree add ($project_id)"
1490
+ # The same commit the task repository just copied, read where the task
1491
+ # never writes: the host cache, under this project's lock.
1492
+ env_schema_oid=$(git_host_control -C "$mirror_dir" rev-parse --verify --quiet \
1493
+ "refs/remotes/origin/$project_default^{commit}" 2>/dev/null || true)
1494
+ if [ -n "$env_schema_oid" ]; then
1495
+ record_env_schema_base "$project_id" "oid $env_schema_oid" \
1496
+ || log "warning: could not record the .env.schema base for $project_id; its keys without a binding are withheld"
1497
+ fi
1498
+ if ! git_host_control -C "$worktree_repo_dir" worktree add "$worktree_target" \
1499
+ -b "$task_branch" "origin/$project_default"; then
1500
+ emit_err "WORKTREE_FAILED" \
1501
+ "could not create the task worktree for $repo_url" \
1502
+ "git worktree add ($project_id)"
1503
+ fi
1504
+ else
1505
+ # Empty / branchless remote — there's no origin/<default> to branch from.
1506
+ # Don't fail the whole task over it (the old behaviour: "invalid reference"
1507
+ # -> WORKTREE_FAILED -> respawn loop). Start an empty, git-initialised
1508
+ # workspace so the task still comes up and a first commit can populate the
1509
+ # repo. Leave a note (git-excluded so it isn't accidentally committed).
1510
+ step "WORKTREE_FAILED" "init empty workspace ($project_id)"
1511
+ log "warning: $repo_url has no '$project_default' branch (empty repo?); starting an empty workspace for $project_slug"
1512
+ # Only a cache with no ref at all proves the repository empty. A missing
1513
+ # default branch alone (say, a default that could not be resolved) is
1514
+ # recorded as nothing, so its keys without a binding are withheld rather
1515
+ # than read as declaring nothing.
1516
+ if env_schema_refs=$(git_host_control -C "$mirror_dir" for-each-ref --count=1 2>/dev/null) \
1517
+ && [ -z "$env_schema_refs" ]; then
1518
+ record_env_schema_base "$project_id" "empty" \
1519
+ || log "warning: could not record the .env.schema base for $project_id; its keys without a binding are withheld"
1520
+ fi
1521
+ rm -rf "$worktree_target"
1522
+ mkdir -p "$worktree_target"
1523
+ git_host_control -C "$worktree_target" init -q -b "$project_default" 2>/dev/null \
1524
+ || { git_host_control -C "$worktree_target" init -q \
1525
+ && git_host_control -C "$worktree_target" symbolic-ref HEAD "refs/heads/$project_default"; }
1526
+ git_host_control -C "$worktree_target" remote add origin "$repo_url" 2>/dev/null || true
1527
+ sanitize_task_repo_control_plane "$worktree_target/.git" "$repo_url" \
1528
+ "$project_default" false
1529
+ printf '%s\n' \
1530
+ "# Empty repository" \
1531
+ "" \
1532
+ "\`$repo_url\` has no commits yet, so Moondeck started an empty workspace on" \
1533
+ "branch \`$project_default\`. Make your first commit and run:" \
1534
+ "" \
1535
+ " git push -u origin $project_default" \
1536
+ "" \
1537
+ "to initialise the repository." \
1538
+ > "$worktree_target/UAI_EMPTY_REPO.md"
1539
+ mkdir -p "$worktree_target/.git/info" 2>/dev/null || true
1540
+ printf '%s\n' "/UAI_EMPTY_REPO.md" \
1541
+ >> "$worktree_target/.git/info/exclude" 2>/dev/null || true
1542
+ fi
1543
+
1544
+ # Tool-version seeding: only when the worktree has no checked-in
1545
+ # .tool-versions AND the project declares one. Never overwrite a file
1546
+ # the repo already ships.
1547
+ reject_unsafe_runtime_manifest "$worktree_target/.tool-versions" \
1548
+ "$project_slug" "$project_id"
1549
+ if [ ! -e "$worktree_target/.tool-versions" ] \
1550
+ && [ -n "$tool_versions" ] && [ "$tool_versions" != "null" ]; then
1551
+ printf '%s\n' "$tool_versions" > "$worktree_target/.tool-versions"
1552
+ fi
1553
+ if [ -d "$worktree_target/.git" ] && [ ! -L "$worktree_target/.git" ]; then
1554
+ share_task_tree_with_container "$worktree_target/.git" \
1555
+ "standalone task Git repository"
1556
+ fi
1557
+
1558
+ release_project_lock
1559
+ done < <(jq -c '.[]' <<<"$projects_json")
1560
+
1561
+ # Every host Git operation is over: a task on cloud credentials stops being
1562
+ # served its owner's GitHub token and SSH key now.
1563
+ if [ -n "$uai_handshake_dir" ]; then
1564
+ : > "$uai_handshake_dir/event-clone-done"
1565
+ fi
1566
+
1567
+ # Git and the checked-out files were created by the host. Reconcile their
1568
+ # group/modes only after every host Git operation and before Compose exposes
1569
+ # them to uid 1000. The host-only marker avoids recursively walking a large
1570
+ # workspace (for example node_modules) on every resume, while its gid payload
1571
+ # forces a fresh pass if the task moves to a differently-grouped host. This
1572
+ # first pass self-heals 0.9.2 task repos whose mktemp config was mode 0600 on a
1573
+ # uid-501 macOS host. The sanitizer above repairs its rewritten control files
1574
+ # on every later resume.
1575
+ step "WORKTREE_FAILED" "share task files with container"
1576
+ mkdir -p "$task_uai_dir"
1577
+ # The shared group may be a broad host group (for example macOS `staff`). Keep
1578
+ # its writable leaves behind host-only parents; Docker bind-mounts the exact
1579
+ # workspace/repository roots, so node does not need host-path traversal here.
1580
+ chmod 700 "$task_dir" "$task_uai_dir"
1581
+ filesystem_group_marker="$task_uai_dir/container-filesystem-group-v1"
1582
+ marked_container_gid=""
1583
+ if [ -L "$filesystem_group_marker" ]; then
1584
+ emit_err "WORKTREE_FAILED" \
1585
+ "Moondeck refused a symbolic-link task filesystem marker" \
1586
+ "share task files with container"
1587
+ elif [ -f "$filesystem_group_marker" ]; then
1588
+ marked_container_gid=$(cat "$filesystem_group_marker" 2>/dev/null || true)
1589
+ elif [ -e "$filesystem_group_marker" ]; then
1590
+ emit_err "WORKTREE_FAILED" \
1591
+ "Moondeck refused a non-file task filesystem marker" \
1592
+ "share task files with container"
1593
+ fi
1594
+ if [ "$marked_container_gid" != "$task_container_gid" ]; then
1595
+ share_task_tree_with_container "$task_workspace" "task workspace"
1596
+ filesystem_group_marker_tmp=$(mktemp \
1597
+ "$task_uai_dir/.container-filesystem-group.XXXXXX")
1598
+ printf '%s\n' "$task_container_gid" > "$filesystem_group_marker_tmp"
1599
+ chmod 600 "$filesystem_group_marker_tmp"
1600
+ mv -f "$filesystem_group_marker_tmp" "$filesystem_group_marker"
1601
+ else
1602
+ # A container may have changed a mount root's mode. These two O(1) repairs
1603
+ # keep entry into the already-normalized trees reliable without walking them.
1604
+ share_task_entry_with_container "$task_workspace" "task workspace root"
1605
+ fi
1606
+ # Task repos are repacked, so this tree stays small; normalize it every time.
1607
+ # Unlike the workspace pass, this covers Git metadata created during the host
1608
+ # refresh and any loose objects/locks written by the prior container.
1609
+ share_task_tree_with_container "$task_uai_dir/repos" \
1610
+ "task-private Git repositories"
1611
+
1612
+ if [ -z "$github_git_helper" ] && [ "$uai_used_ssh_transport" = "1" ]; then
1613
+ uai_git_transport="ssh"
1614
+ fi
1615
+
1616
+ # -----------------------------------------------------------------------------
1617
+ # 4. Generate .uai/docker-compose.yml (bash). Union preview ports + env keys
1618
+ # across the selected projects; one app service. Derived Dockerfile only
1619
+ # when a project declares `extra`.
1620
+ # -----------------------------------------------------------------------------
1621
+
1622
+ step "RENDER_FAILED" "generate compose"
1623
+
1624
+ mkdir -p "$task_uai_dir"
1625
+
1626
+ STANDARD_CONTAINER_DIR="${UAI_STANDARD_CONTAINER_ASSETS_DIR:-$SCRIPT_DIR/../../images/standard/container}"
1627
+ if ! STANDARD_CONTAINER_DIR=$(cd "$STANDARD_CONTAINER_DIR" && pwd -P); then
1628
+ emit_err "CONTAINER_INIT_FAILED" \
1629
+ "The station's packaged container asset directory is missing. Repair or update the station installation, then retry." \
1630
+ "resolve packaged container assets"
1631
+ fi
1632
+ UAI_INIT_SOURCE="$STANDARD_CONTAINER_DIR/uai-init"
1633
+ RUNTIME_MATERIALIZER_SOURCE="$STANDARD_CONTAINER_DIR/uai-materialize-runtimes"
1634
+ COREPACK_VERSION_SOURCE="$STANDARD_CONTAINER_DIR/corepack-version"
1635
+ ENV_HOOK_SOURCE="$SCRIPT_DIR/env-hook.sh"
1636
+ UAI_ENV_EXEC_SOURCE="$SCRIPT_DIR/uai-env-exec.sh"
1637
+ # The `moondeck` command (Moondeck plan §5 row 7): this task's agent CLI on
1638
+ # PATH, mounted read-only at /usr/local/bin/moondeck. There is no `uai` one.
1639
+ MOONDECK_CLI_SOURCE="$SCRIPT_DIR/moondeck-cli.sh"
1640
+ if [ -L "$MOONDECK_CLI_SOURCE" ] || [ ! -f "$MOONDECK_CLI_SOURCE" ] \
1641
+ || [ ! -r "$MOONDECK_CLI_SOURCE" ]; then
1642
+ emit_err "CONTAINER_INIT_FAILED" \
1643
+ "Moondeck's packaged agent command is missing or unsafe. Repair or update the station installation, then retry." \
1644
+ "resolve packaged container assets"
1645
+ fi
1646
+ TASK_FORWARDER_SOURCE="$STANDARD_CONTAINER_DIR/uai-task-forwarder.mjs"
1647
+ if [ -L "$UAI_INIT_SOURCE" ] || [ ! -f "$UAI_INIT_SOURCE" ] \
1648
+ || [ ! -x "$UAI_INIT_SOURCE" ]; then
1649
+ emit_err "CONTAINER_INIT_FAILED" \
1650
+ "The station's packaged task initializer is missing or unsafe. Repair or update the station installation, then retry." \
1651
+ "resolve packaged container assets"
1652
+ fi
1653
+ if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
1654
+ for loader_asset in \
1655
+ "$ENV_HOOK_SOURCE" \
1656
+ "$UAI_ENV_EXEC_SOURCE" \
1657
+ "$TASK_FORWARDER_SOURCE"
1658
+ do
1659
+ if [ -L "$loader_asset" ] || [ ! -f "$loader_asset" ] \
1660
+ || [ ! -r "$loader_asset" ]; then
1661
+ emit_err "CONTAINER_INIT_FAILED" \
1662
+ "The station's packaged task env loader is missing or unsafe. Repair or update the station installation, then retry." \
1663
+ "resolve packaged env loader assets"
1664
+ fi
1665
+ done
1666
+ fi
1667
+ if [ "$project_count" -gt 0 ]; then
1668
+ if [ -L "$RUNTIME_MATERIALIZER_SOURCE" ] \
1669
+ || [ ! -f "$RUNTIME_MATERIALIZER_SOURCE" ] \
1670
+ || [ ! -x "$RUNTIME_MATERIALIZER_SOURCE" ]; then
1671
+ emit_err "CONTAINER_INIT_FAILED" \
1672
+ "The station's packaged runtime materializer is missing or unsafe. Repair or update the station installation, then retry." \
1673
+ "resolve packaged container assets"
1674
+ fi
1675
+ if [ -L "$COREPACK_VERSION_SOURCE" ] \
1676
+ || [ ! -f "$COREPACK_VERSION_SOURCE" ] \
1677
+ || [ ! -r "$COREPACK_VERSION_SOURCE" ]; then
1678
+ emit_err "CONTAINER_INIT_FAILED" \
1679
+ "The station's packaged Corepack version pin is missing or unsafe. Repair or update the station installation, then retry." \
1680
+ "resolve packaged container assets"
1681
+ fi
1682
+ fi
1683
+
1684
+ # Containers bind these assets for their whole life, but the packaged
1685
+ # sources live in the VERSIONED runtime tree and the updater keeps only
1686
+ # current+previous — a container surviving two updates found its mounts
1687
+ # pointing at deleted files (uai-init exec 127, recovery wedged the host in
1688
+ # "checking"; live 2026-08-26). Copy the assets into the task's own control
1689
+ # tree and mount THOSE: asset lifetime becomes task lifetime, and a Resume
1690
+ # refreshes the copies to the current version. Atomic rename so a resumed
1691
+ # container can never observe a half-written initializer.
1692
+ task_assets_dir="$task_uai_dir/assets"
1693
+ mkdir -p "$task_assets_dir"
1694
+ copy_task_asset() {
1695
+ copy_src="$1"
1696
+ copy_name="$2"
1697
+ copy_mode="$3"
1698
+ copy_tmp=$(mktemp "$task_assets_dir/.$copy_name.XXXXXX") || return 1
1699
+ cp "$copy_src" "$copy_tmp" || { rm -f "$copy_tmp"; return 1; }
1700
+ chmod "$copy_mode" "$copy_tmp" || { rm -f "$copy_tmp"; return 1; }
1701
+ mv -f "$copy_tmp" "$task_assets_dir/$copy_name"
1702
+ }
1703
+ if ! copy_task_asset "$UAI_INIT_SOURCE" uai-init 0755; then
1704
+ emit_err "CONTAINER_INIT_FAILED" \
1705
+ "Moondeck could not stage the task initializer into this task's control tree. Check disk space and permissions under the station's data directory, then retry." \
1706
+ "stage task container assets"
1707
+ fi
1708
+ UAI_INIT_SOURCE="$task_assets_dir/uai-init"
1709
+ if ! copy_task_asset "$MOONDECK_CLI_SOURCE" moondeck 0555; then
1710
+ emit_err "CONTAINER_INIT_FAILED" \
1711
+ "Moondeck could not stage the agent command into this task's control tree. Check disk space and permissions under the Moondeck data directory, then retry." \
1712
+ "stage task container assets"
1713
+ fi
1714
+ MOONDECK_CLI_SOURCE="$task_assets_dir/moondeck"
1715
+ if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
1716
+ if ! copy_task_asset "$ENV_HOOK_SOURCE" env-hook.sh 0555 \
1717
+ || ! copy_task_asset "$UAI_ENV_EXEC_SOURCE" uai-env-exec 0555 \
1718
+ || ! copy_task_asset "$TASK_FORWARDER_SOURCE" uai-task-forwarder.mjs 0444; then
1719
+ emit_err "CONTAINER_INIT_FAILED" \
1720
+ "Moondeck could not stage the task env loader into this task's control tree. Check disk space and permissions under the station's data directory, then retry." \
1721
+ "stage task env loader assets"
1722
+ fi
1723
+ ENV_HOOK_SOURCE="$task_assets_dir/env-hook.sh"
1724
+ UAI_ENV_EXEC_SOURCE="$task_assets_dir/uai-env-exec"
1725
+ TASK_FORWARDER_SOURCE="$task_assets_dir/uai-task-forwarder.mjs"
1726
+ fi
1727
+ if [ -f "$RUNTIME_MATERIALIZER_SOURCE" ]; then
1728
+ if ! copy_task_asset "$RUNTIME_MATERIALIZER_SOURCE" uai-materialize-runtimes 0755; then
1729
+ emit_err "CONTAINER_INIT_FAILED" \
1730
+ "Moondeck could not stage the runtime materializer into this task's control tree. Check disk space and permissions under the station's data directory, then retry." \
1731
+ "stage task container assets"
1732
+ fi
1733
+ RUNTIME_MATERIALIZER_SOURCE="$task_assets_dir/uai-materialize-runtimes"
1734
+ fi
1735
+ if [ -f "$COREPACK_VERSION_SOURCE" ]; then
1736
+ if ! copy_task_asset "$COREPACK_VERSION_SOURCE" corepack-version 0644; then
1737
+ emit_err "CONTAINER_INIT_FAILED" \
1738
+ "Moondeck could not stage the Corepack version pin into this task's control tree. Check disk space and permissions under the station's data directory, then retry." \
1739
+ "stage task container assets"
1740
+ fi
1741
+ COREPACK_VERSION_SOURCE="$task_assets_dir/corepack-version"
1742
+ fi
1743
+
1744
+ # The host-authored allowlist is the single project-set authority shared by
1745
+ # pre-start runtime proof and task-local dependency installation. It lives in
1746
+ # the container-unmounted .uai control tree and is exposed only as this one
1747
+ # read-only file. Atomic replacement keeps resumptions from observing a
1748
+ # partially written list.
1749
+ runtime_projects_file="$task_uai_dir/runtime-projects"
1750
+ runtime_projects_tmp=$(mktemp "$task_uai_dir/.runtime-projects.XXXXXX")
1751
+ if [ -n "$runtime_project_slugs" ]; then
1752
+ printf '%s\n' "$runtime_project_slugs" > "$runtime_projects_tmp"
1753
+ else
1754
+ : > "$runtime_projects_tmp"
1755
+ fi
1756
+ chmod 0644 "$runtime_projects_tmp"
1757
+ mv -f "$runtime_projects_tmp" "$runtime_projects_file"
1758
+
1759
+ # Union of preview ports across projects, de-duped by containerPort
1760
+ # (first-in-position wins the name). Validated: name non-empty, 1..65535.
1761
+ union_preview_ports_json=$(jq -c '
1762
+ [
1763
+ .[]
1764
+ | (.preview_ports // "[]") as $raw
1765
+ | (if ($raw | type) == "string" then (try ($raw | fromjson) catch [])
1766
+ elif ($raw | type) == "array" then $raw
1767
+ else [] end)
1768
+ | .[]?
1769
+ | {
1770
+ name: (.name // ""),
1771
+ containerPort: (((.containerPort // 0) | tonumber?) // 0)
1772
+ }
1773
+ | select(.name != "" and .containerPort > 0 and .containerPort <= 65535)
1774
+ ]
1775
+ | reduce .[] as $p ({seen: [], out: []};
1776
+ if (.seen | index($p.containerPort)) then .
1777
+ else {seen: (.seen + [$p.containerPort]), out: (.out + [$p])} end)
1778
+ | .out
1779
+ ' <<<"$projects_json")
1780
+
1781
+ # Union of declared env keys across projects (env JSON is an array of
1782
+ # {key,...} objects per ADR-015). De-duped, first occurrence wins.
1783
+ union_env_keys_json=$(jq -c '
1784
+ [
1785
+ .[]
1786
+ | (.env // "[]") as $raw
1787
+ | (if ($raw | type) == "string" then (try ($raw | fromjson) catch [])
1788
+ elif ($raw | type) == "array" then $raw
1789
+ else [] end)
1790
+ | .[]?
1791
+ | (.key // "")
1792
+ | select(. != "")
1793
+ ]
1794
+ | reduce .[] as $k ([]; if (index($k)) then . else . + [$k] end)
1795
+ ' <<<"$projects_json")
1796
+
1797
+ # Concatenated `extra` RUN snippets across projects (in position order).
1798
+ extra_block=$(jq -r '
1799
+ [ .[] | (.extra // "") | select(. != "" and . != "null") ]
1800
+ | join("\n")
1801
+ ' <<<"$projects_json")
1802
+
1803
+ has_derived=0
1804
+ if [ -n "$extra_block" ]; then
1805
+ has_derived=1
1806
+ fi
1807
+
1808
+ # One fail-closed authority for names projects must never control, whether the
1809
+ # value arrived as an ordinary declared env key or as a preview urlEnv literal.
1810
+ # UAI_EDITOR_THEME is the sole preview exception below: it is computed by the
1811
+ # cloud from the task palette, not sourced from a project.
1812
+ is_reserved_task_env_name() {
1813
+ case "$1" in
1814
+ GH_TOKEN | GITHUB_TOKEN | GH_CONFIG_DIR | GH_HOST | \
1815
+ GIT_CONFIG* | GIT_DIR | GIT_WORK_TREE | GIT_COMMON_DIR | \
1816
+ GIT_OBJECT_DIRECTORY | GIT_ALTERNATE_OBJECT_DIRECTORIES | \
1817
+ GIT_INDEX_FILE | GIT_EXEC_PATH | GIT_ASKPASS | SSH_ASKPASS | \
1818
+ GIT_SSH | GIT_SSH_COMMAND | HOME | XDG_CONFIG_HOME | XDG_DATA_HOME | \
1819
+ XDG_CACHE_HOME | \
1820
+ PATH | SHELL | LD_PRELOAD | LD_AUDIT | LD_LIBRARY_PATH | NODE_OPTIONS | \
1821
+ NODE_PATH | SHELLOPTS | BASHOPTS | PS4 | BASH_ENV | ENV | \
1822
+ __UAI_ENV_* | __CURSOR_SANDBOX_ENV_RESTORE | GROK_LOGIN_ENV | \
1823
+ NODE_EXTRA_CA_CERTS | SSL_CERT_FILE | REQUESTS_CA_BUNDLE | \
1824
+ [Aa][Ss][Dd][Ff]_* | [Cc][Oo][Rr][Ee][Pp][Aa][Cc][Kk]_* | \
1825
+ [Nn][Pp][Mm]_[Cc][Oo][Nn][Ff][Ii][Gg]_[Pp][Rr][Ee][Ff][Ii][Xx] | \
1826
+ [Nn][Pp][Mm]_[Cc][Oo][Nn][Ff][Ii][Gg]_[Cc][Aa][Cc][Hh][Ee] | \
1827
+ [Nn][Pp][Mm]_[Cc][Oo][Nn][Ff][Ii][Gg]_[Ll][Oo][Gg][Ss][_-][Dd][Ii][Rr] | \
1828
+ [Nn][Pp][Mm]_[Cc][Oo][Nn][Ff][Ii][Gg]_[Uu][Mm][Aa][Ss][Kk] | UAI_* | \
1829
+ CLAUDE_CODE_OAUTH_TOKEN | \
1830
+ ANTHROPIC_API_KEY | ANTHROPIC_AUTH_TOKEN | XAI_API_KEY | CURSOR_API_KEY | \
1831
+ PLAYWRIGHT_BROWSERS_PATH)
1832
+ return 0
1833
+ ;;
1834
+ esac
1835
+ return 1
1836
+ }
1837
+
1838
+ # A baked task's project env, as both runtimes receive it (Docker's Compose
1839
+ # override, Apple's env file): values JSON on stdin; $1 the declared keys
1840
+ # (JSON array), $2 the preview URL env (JSON object). Declared env names
1841
+ # only, minus every name is_reserved_task_env_name reserves (the one
1842
+ # authority, never a second list) and the preview URL names.
1843
+ filter_baked_project_env() {
1844
+ local declared="$1" preview="$2" values reserved='[]' key
1845
+ values=$(cat)
1846
+ while IFS= read -r key; do
1847
+ if is_reserved_task_env_name "$key"; then
1848
+ reserved=$(jq -c --arg key "$key" '. + [$key]' <<<"$reserved")
1849
+ fi
1850
+ done < <(printf '%s' "$values" | jq -r 'keys[] | select(test("^[A-Za-z_][A-Za-z0-9_]*$"))')
1851
+ printf '%s' "$values" | jq -c \
1852
+ --argjson declared "$declared" \
1853
+ --argjson preview "$preview" \
1854
+ --argjson reserved "$reserved" '
1855
+ with_entries(.key as $key | select($declared | index($key)))
1856
+ | with_entries(select(.key | test("^[A-Za-z_][A-Za-z0-9_]*$")))
1857
+ | delpaths($reserved | map([.]))
1858
+ | delpaths($preview | keys | map([.]))
1859
+ '
1860
+ }
1861
+
1862
+ # Names that the loader owns are non-secret metadata. Mount the exact launch
1863
+ # set read-only so uai-init can remove even the empty clears inherited from a
1864
+ # derived image before it starts code-server. The editor daemon itself never
1865
+ # loads project env; each terminal shell fetches independently.
1866
+ managed_env_keys_file="$task_uai_dir/managed-env-keys"
1867
+ managed_env_keys_tmp=$(mktemp "$task_uai_dir/.managed-env-keys.XXXXXX")
1868
+ # macOS still ships Bash 3.2, which has indexed arrays but no associative
1869
+ # arrays. Names are validated before this delimiter set is consulted, so `|`
1870
+ # cannot collide with an env name.
1871
+ managed_env_seen='|'
1872
+ {
1873
+ while IFS= read -r managed_name; do
1874
+ [[ "$managed_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
1875
+ is_reserved_task_env_name "$managed_name" && continue
1876
+ case "$managed_env_seen" in *"|$managed_name|"*) continue ;; esac
1877
+ managed_env_seen="${managed_env_seen}${managed_name}|"
1878
+ printf '%s\n' "$managed_name"
1879
+ done < <(jq -r '.[]' <<<"$union_env_keys_json")
1880
+ preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
1881
+ while IFS= read -r managed_name; do
1882
+ [[ "$managed_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
1883
+ if [ "$managed_name" != "UAI_EDITOR_THEME" ] \
1884
+ && is_reserved_task_env_name "$managed_name"; then
1885
+ continue
1886
+ fi
1887
+ case "$managed_env_seen" in *"|$managed_name|"*) continue ;; esac
1888
+ managed_env_seen="${managed_env_seen}${managed_name}|"
1889
+ printf '%s\n' "$managed_name"
1890
+ done < <(jq -r 'keys[]?' <<<"$preview_env_raw")
1891
+ } > "$managed_env_keys_tmp"
1892
+ chmod 0444 "$managed_env_keys_tmp"
1893
+ mv -f "$managed_env_keys_tmp" "$managed_env_keys_file"
1894
+
1895
+ # Write the compose file. No top-level name: (the -p flag sets the project),
1896
+ # no container_name:. Env values live host-side (ADR-015): we emit only
1897
+ # pass-through references `${KEY:-}`.
1898
+ {
1899
+ printf 'services:\n'
1900
+ printf ' app:\n'
1901
+ # The station's mark on everything of this task's stack (Moondeck plan §4):
1902
+ # the frozen @runuai/host labels nothing of a Compose stack, so a `task-*`
1903
+ # project without it is the old host's (uninstall-legacy removes those).
1904
+ printf ' labels:\n'
1905
+ printf ' io.moondeck.task: "%s"\n' "$task_id"
1906
+ if [ "$has_derived" = "1" ]; then
1907
+ printf ' build:\n'
1908
+ printf ' context: .\n'
1909
+ printf ' dockerfile: Dockerfile\n'
1910
+ printf ' image: %s\n' "$DERIVED_IMAGE"
1911
+ else
1912
+ printf ' image: %s\n' "$STANDARD_IMAGE"
1913
+ # Never pull: the standard image is built LOCALLY by the host, not hosted
1914
+ # on any registry. Without this, an absent image makes compose attempt a
1915
+ # doomed registry pull ("pull access denied for uai-standard") instead of
1916
+ # failing clearly (live 2026-07-22, a fresh host mid standard-image build).
1917
+ printf ' pull_policy: never\n'
1918
+ fi
1919
+ printf ' init: true\n'
1920
+ # Keep the host MCP gateway reachable through Docker's ordinary
1921
+ # host-gateway address. (The Apple runtime has no extra_hosts equivalent
1922
+ # and none is rendered for it; its gateway route is the vmnet gateway IP.)
1923
+ printf ' extra_hosts:\n'
1924
+ printf ' - "host.docker.internal:host-gateway"\n'
1925
+ # Give node the one host group that owns this task's bind-mounted files.
1926
+ # Numeric group_add works even when the group has a different name (or no
1927
+ # name) in the image. Root hosts use node's existing gid 1000 above.
1928
+ printf ' group_add:\n'
1929
+ printf ' - "%s"\n' "$task_container_gid"
1930
+ printf ' working_dir: /workspace\n'
1931
+ printf ' volumes:\n'
1932
+ printf ' - "%s:/workspace"\n' "$task_workspace"
1933
+ # Identical-host-path binds keep each linked worktree's absolute gitdir
1934
+ # pointer valid in-container (ADR-014). Only the self-contained task-private
1935
+ # repository is mounted; the host cache never enters a container. Legacy
1936
+ # worktrees fail closed before Compose rendering and must be recreated.
1937
+ printf ' - "%s:%s"\n' "$task_workspace" "$task_workspace"
1938
+ if [ "$task_workspace_physical" != "$task_workspace" ]; then
1939
+ # Git canonicalizes a symlinked workspace root in both directions: the
1940
+ # worktree's `.git` points at the physical repository, and the repository's
1941
+ # worktree metadata points back at the physical workspace. Mount the same
1942
+ # already-approved workspace at that alias without exposing its `.uai`
1943
+ # sibling or any broader host directory.
1944
+ printf ' - "%s:%s"\n' \
1945
+ "$task_workspace" "$task_workspace_physical"
1946
+ fi
1947
+ while IFS= read -r vol_obj; do
1948
+ [ -n "$vol_obj" ] || continue
1949
+ vol_pid=$(jq -r '.id' <<<"$vol_obj")
1950
+ printf ' - "%s/repos/%s.git:%s/repos/%s.git"\n' \
1951
+ "$task_uai_dir" "$vol_pid" "$task_uai_dir" "$vol_pid"
1952
+ if [ "$task_uai_dir_physical" != "$task_uai_dir" ]; then
1953
+ printf ' - "%s/repos/%s.git:%s/repos/%s.git"\n' \
1954
+ "$task_uai_dir" "$vol_pid" "$task_uai_dir_physical" "$vol_pid"
1955
+ fi
1956
+ done < <(jq -c '.[]' <<<"$projects_json")
1957
+ printf ' - "%s:/usr/local/bin/uai-init:ro"\n' "$UAI_INIT_SOURCE"
1958
+ printf ' - "%s:/usr/local/bin/moondeck:ro"\n' "$MOONDECK_CLI_SOURCE"
1959
+ if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
1960
+ printf ' - "%s:/usr/local/share/uai/env-hook.sh:ro"\n' \
1961
+ "$ENV_HOOK_SOURCE"
1962
+ printf ' - "%s:/usr/local/bin/uai-env-exec:ro"\n' \
1963
+ "$UAI_ENV_EXEC_SOURCE"
1964
+ printf ' - "%s:/usr/local/share/uai/uai-task-forwarder.mjs:ro"\n' \
1965
+ "$TASK_FORWARDER_SOURCE"
1966
+ fi
1967
+ printf ' - "%s:/run/uai/runtime-projects:ro"\n' \
1968
+ "$runtime_projects_file"
1969
+ printf ' - "%s:/run/uai/managed-env-keys:ro"\n' \
1970
+ "$managed_env_keys_file"
1971
+ # Task processes consume only the exact runtimes/package managers proved by
1972
+ # pre-start maintenance. `:ro` is explicit because Docker's omitted default
1973
+ # is read-write and a legacy Compose file must never look equivalent.
1974
+ printf ' - "%s:/opt/asdf-data:ro"\n' "$ASDF_VOLUME"
1975
+ # ADR-053: host-wide Playwright browser cache. Always mounted (harmless
1976
+ # when browser testing is off); Chromium downloads once per HOST.
1977
+ printf ' - "%s:/opt/pw-browsers"\n' "$PW_VOLUME"
1978
+ # ADR-062 shared files (org + personal) at /workspace/files/{org,me};
1979
+ # ro/rw fixed here for the container's lifetime.
1980
+ if [ "$shared_files_mode" != "off" ]; then
1981
+ shared_suffix=""
1982
+ [ "$shared_files_mode" = "ro" ] && shared_suffix=":ro"
1983
+ if [ -n "$shared_owner_org" ]; then
1984
+ printf ' - "%s/orgs/%s:/workspace/files/org%s"\n' \
1985
+ "$shared_root" "$shared_owner_org" "$shared_suffix"
1986
+ fi
1987
+ if [ -n "$shared_owner_user" ]; then
1988
+ printf ' - "%s/users/%s:/workspace/files/me%s"\n' \
1989
+ "$shared_root" "$shared_owner_user" "$shared_suffix"
1990
+ fi
1991
+ fi
1992
+ printf ' ports:\n'
1993
+ # code-server (the Editor tunnel) is the only auto-published port. Preview
1994
+ # ports are NOT published at task-up (ADR-043 Stage 2: opt-in previews) —
1995
+ # each preview the user enables is reached lazily through a per-(task,preview)
1996
+ # sidecar attached to the compose network, so nothing is exposed by default.
1997
+ printf ' - "127.0.0.1::8080"\n'
1998
+ printf ' environment:\n'
1999
+ # Non-secret transport marker for uai-init. The actual GitHub token is
2000
+ # injected later into gh's private config and never enters Compose.
2001
+ printf ' UAI_GIT_TRANSPORT: "%s"\n' "$uai_git_transport"
2002
+ # Uai owns GitHub auth inside the task container. Keep gh's config outside
2003
+ # the bind-mounted workspace and independent of project-defined XDG/HOME
2004
+ # values so its credential helper always resolves the connected user.
2005
+ printf ' GH_CONFIG_DIR: "/home/node/.config/gh"\n'
2006
+ # Runtime selection/cache authority is host-owned. These values override
2007
+ # both project-declared keys and a derived image's ENV so uai-init cannot be
2008
+ # redirected into an unproved runtime or a different shared-cache writer.
2009
+ printf ' PATH: "%s"\n' "$TASK_RUNTIME_PATH"
2010
+ printf ' HOME: "/home/node"\n'
2011
+ printf ' XDG_CONFIG_HOME: "/home/node/.config"\n'
2012
+ printf ' XDG_DATA_HOME: "/home/node/.local/share"\n'
2013
+ printf ' XDG_CACHE_HOME: "/home/node/.cache"\n'
2014
+ printf ' ASDF_DIR: "/opt/asdf"\n'
2015
+ printf ' ASDF_DATA_DIR: "/opt/asdf-data"\n'
2016
+ printf ' ASDF_CONFIG_FILE: "/dev/null"\n'
2017
+ printf ' ASDF_DEFAULT_TOOL_VERSIONS_FILENAME: ".tool-versions"\n'
2018
+ printf ' ASDF_NODEJS_VERSION: ""\n'
2019
+ printf ' ASDF_PYTHON_VERSION: ""\n'
2020
+ printf ' ASDF_GOLANG_VERSION: ""\n'
2021
+ printf ' ASDF_RUBY_VERSION: ""\n'
2022
+ printf ' ASDF_RUST_VERSION: ""\n'
2023
+ printf ' ASDF_SKIP_RESHIM: "1"\n'
2024
+ printf ' COREPACK_HOME: "/opt/asdf-data/corepack"\n'
2025
+ printf ' COREPACK_DEFAULT_TO_LATEST: "0"\n'
2026
+ printf ' COREPACK_ENV_FILE: "0"\n'
2027
+ printf ' COREPACK_NPM_REGISTRY: "https://registry.npmjs.org"\n'
2028
+ printf ' COREPACK_ENABLE_NETWORK: "0"\n'
2029
+ printf ' COREPACK_ENABLE_AUTO_PIN: "0"\n'
2030
+ printf ' COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"\n'
2031
+ printf ' COREPACK_ENABLE_PROJECT_SPEC: "1"\n'
2032
+ printf ' COREPACK_ENABLE_STRICT: "1"\n'
2033
+ printf ' COREPACK_ENABLE_UNSAFE_CUSTOM_URLS: "0"\n'
2034
+ printf ' COREPACK_INTEGRITY_CHECK: "1"\n'
2035
+ # npm injects lowercase config names into lifecycle scripts, so pin both
2036
+ # cases. These task-private paths are never part of the shared asdf volume.
2037
+ printf ' NPM_CONFIG_PREFIX: "%s"\n' "$TASK_NPM_PREFIX"
2038
+ printf ' npm_config_prefix: "%s"\n' "$TASK_NPM_PREFIX"
2039
+ printf ' NPM_CONFIG_CACHE: "%s"\n' "$TASK_NPM_CACHE"
2040
+ printf ' npm_config_cache: "%s"\n' "$TASK_NPM_CACHE"
2041
+ printf ' NPM_CONFIG_LOGS_DIR: "%s/_logs"\n' "$TASK_NPM_CACHE"
2042
+ printf ' npm_config_logs_dir: "%s/_logs"\n' "$TASK_NPM_CACHE"
2043
+ printf ' NPM_CONFIG_UMASK: "0002"\n'
2044
+ printf ' npm_config_umask: "0002"\n'
2045
+ printf ' UAI_WORKSPACE: "/workspace"\n'
2046
+ printf ' UAI_RUNTIME_PROJECTS: "/run/uai/runtime-projects"\n'
2047
+ printf ' UAI_ENV_MODE: "%s"\n' "$UAI_PROJECT_ENV_MODE"
2048
+ printf ' UAI_ENV_HOOK: "0"\n'
2049
+ printf ' SHELL: "/usr/bin/zsh"\n'
2050
+ printf ' LD_PRELOAD: ""\n'
2051
+ printf ' LD_AUDIT: ""\n'
2052
+ printf ' LD_LIBRARY_PATH: ""\n'
2053
+ printf ' NODE_OPTIONS: ""\n'
2054
+ printf ' NODE_PATH: ""\n'
2055
+ # SHELLOPTS/BASHOPTS are deliberately ABSENT, never empty: bash exports its
2056
+ # live options into any child when these names were present in its startup
2057
+ # environment, so an "empty clear" let set -euo scripts stamp nounset into
2058
+ # code-server and break every asdf shim in the Editor terminal (found live
2059
+ # 2026-08-16, on BOTH engines; the reserved-name filter still strips any
2060
+ # project-declared value).
2061
+ printf ' PS4: ""\n'
2062
+ printf ' BASH_ENV: ""\n'
2063
+ printf ' ENV: ""\n'
2064
+ # Host-resident Claude auth (ADR-021). Headless `claude --print` no longer
2065
+ # uses the interactive subscription/keychain path, so it needs a token from
2066
+ # `claude setup-token`. Interpolated from the host-agent env at up-time, so
2067
+ # the literal token is never written to the compose file on disk. Putting it
2068
+ # in the container env (rather than the host's ~/.claude mount, which broke
2069
+ # with EROFS on session-env writes) means both the orchestrator's headless
2070
+ # claude and the code-server terminal's interactive claude authenticate.
2071
+ if [ "$uai_on_cloud_credentials" != "1" ]; then
2072
+ printf ' %s: "${%s:-}"\n' \
2073
+ "CLAUDE_CODE_OAUTH_TOKEN" "CLAUDE_CODE_OAUTH_TOKEN"
2074
+ # Pasted engine API keys (ADR-070) ride the same at-up interpolation, so the
2075
+ # terminal CLIs authenticate like the adapters' exec-time env forwarding.
2076
+ printf ' %s: "${%s:-}"\n' "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEY"
2077
+ printf ' %s: "${%s:-}"\n' "ANTHROPIC_AUTH_TOKEN" "ANTHROPIC_AUTH_TOKEN"
2078
+ printf ' %s: "${%s:-}"\n' "XAI_API_KEY" "XAI_API_KEY"
2079
+ fi
2080
+ # ADR-053: point Playwright at the shared browser cache volume.
2081
+ printf ' PLAYWRIGHT_BROWSERS_PATH: "/opt/pw-browsers"\n'
2082
+ # No GH_TOKEN/GITHUB_TOKEN in the container env (ADR-027). `gh` prefers such
2083
+ # an env var over its stored credentials, which blocks the per-user
2084
+ # `gh auth login --with-token` the host runs (and would re-attribute every PR
2085
+ # to the env token — breaking multi-user). The host's GitHub token reaches
2086
+ # `gh` through its config file instead (lib/github-tokens.ts), and gh serves
2087
+ # as Git's HTTPS credential helper. SSH remains a disconnected-user fallback.
2088
+ # Projects may declare managed names, so skip them here rather than trust
2089
+ # callers. They are also removed from the in-memory Compose override below.
2090
+ #
2091
+ # UAI_EDITOR_THEME is reserved in BOTH places for the case that is easy to
2092
+ # miss: a task whose stored palette is null, on a project that happens to
2093
+ # declare that name. The cloud sends no theme, so the `preview_env` entry
2094
+ # that normally shadows a declared key is absent too — and without these two
2095
+ # lines a project would get to pick what the Editor pane looks like. Reserved,
2096
+ # the name is not emitted at all and uai-init seeds the image's own default.
2097
+ #
2098
+ # Project values never enter the host environment. Emit an explicit empty
2099
+ # default for every declared key; the stdin-only Compose override supplies
2100
+ # decrypted values at `compose up` time.
2101
+ while IFS= read -r ekey; do
2102
+ [ -n "$ekey" ] || continue
2103
+ is_reserved_task_env_name "$ekey" && continue
2104
+ printf ' %s: ""\n' "$ekey"
2105
+ done < <(jq -r '.[]' <<<"$union_env_keys_json")
2106
+ # Cloud-computed env (ADR-025): a JSON object {VAR: value} on the task row.
2107
+ # A legacy baked task writes the literals as before. A loader task emits only
2108
+ # empty clears (to defeat a derived image's ENV); the loopback endpoint is
2109
+ # the sole source of the current values. Two producers, both cloud-side:
2110
+ # - PUBLIC preview URLs under operator-chosen names (EXPO_PACKAGER_PROXY_URL
2111
+ # and friends), one per preview port that set `urlEnv`;
2112
+ # - UAI_EDITOR_THEME (ADR-091), the Editor pane's colour theme as a VS Code
2113
+ # theme LABEL ("Quiet Light"), already resolved from the palette the
2114
+ # creating browser snapshotted. The host never maps a Uai palette id.
2115
+ # UAI_EDITOR_THEME is absent for every task created before it shipped and for
2116
+ # any created outside a browser; uai-init then seeds the image's own default,
2117
+ # so the column staying null is a supported state, not a value to fill in.
2118
+ preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
2119
+ while IFS= read -r pe_entry; do
2120
+ [ -n "$pe_entry" ] || continue
2121
+ pe_key=$(jq -r '.key' <<<"$pe_entry")
2122
+ pe_val=$(jq -r '.value' <<<"$pe_entry")
2123
+ [ -n "$pe_key" ] || continue
2124
+ if [ "$pe_key" != "UAI_EDITOR_THEME" ] \
2125
+ && is_reserved_task_env_name "$pe_key"; then
2126
+ continue
2127
+ fi
2128
+ if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
2129
+ printf ' %s: ""\n' "$pe_key"
2130
+ else
2131
+ printf ' %s: "%s"\n' "$pe_key" "$pe_val"
2132
+ fi
2133
+ done < <(jq -c 'to_entries[]?' <<<"$preview_env_raw")
2134
+ printf 'networks:\n'
2135
+ printf ' default:\n'
2136
+ printf ' labels:\n'
2137
+ printf ' io.moondeck.task: "%s"\n' "$task_id"
2138
+ printf 'volumes:\n'
2139
+ printf ' %s:\n' "$ASDF_VOLUME"
2140
+ printf ' external: true\n'
2141
+ printf ' %s:\n' "$PW_VOLUME"
2142
+ printf ' external: true\n'
2143
+ } > "$task_uai_dir/docker-compose.yml"
2144
+
2145
+ if [ "$has_derived" = "1" ]; then
2146
+ # Run `extra` as root so apt/system installs work (the standard image ends
2147
+ # as USER node), then drop back to node for the running container.
2148
+ {
2149
+ printf 'FROM %s\n' "$STANDARD_IMAGE"
2150
+ printf 'USER root\n'
2151
+ printf '%s\n' "$extra_block"
2152
+ printf 'USER node\n'
2153
+ } > "$task_uai_dir/Dockerfile"
2154
+ fi
2155
+
2156
+ # -----------------------------------------------------------------------------
2157
+ # 5. Build (when derived) + pre-materialize project runtimes.
2158
+ # -----------------------------------------------------------------------------
2159
+
2160
+ # The shared asdf cache volume is host-wide and declared external — make
2161
+ # sure it exists before compose references it. Idempotent under Docker; the
2162
+ # Apple CLI errors on an existing name, so tolerate creation and then demand
2163
+ # the volume actually exists.
2164
+ crt volume create "$ASDF_VOLUME" >/dev/null 2>&1 || true
2165
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2166
+ # Browsers ride a host directory here, never a block volume — see
2167
+ # PW_BROWSERS_DIR above for why.
2168
+ if ! /bin/mkdir -p "$PW_BROWSERS_DIR"; then
2169
+ emit_err "CONTAINER_INIT_FAILED" \
2170
+ "Moondeck could not create the shared Playwright browsers directory $PW_BROWSERS_DIR." \
2171
+ "create shared volumes"
2172
+ fi
2173
+ if ! crt volume inspect "$ASDF_VOLUME" >/dev/null 2>&1; then
2174
+ emit_err "CONTAINER_INIT_FAILED" \
2175
+ "Moondeck could not create the shared $ASDF_VOLUME volume on the Apple container runtime. Retry after the runtime is healthy." \
2176
+ "create shared volumes"
2177
+ fi
2178
+ # ADR-118: create or adopt this task's private store volume. Adoption
2179
+ # requires the same exact-identity + ownership-label proof as container
2180
+ # destruction: exactly one record, whose id is the requested name,
2181
+ # carrying io.moondeck.task=<this task>. A volume merely OCCUPYING the
2182
+ # reserved name is not this task's store and must never be mounted into
2183
+ # it — foreign bytes at the package store are an integrity boundary.
2184
+ step "CONTAINER_INIT_FAILED" "create task store volume"
2185
+ if store_volume_json=$(crt volume inspect "$TASK_STORE_VOLUME" 2>/dev/null); then
2186
+ store_volume_ok=$(printf '%s' "$store_volume_json" \
2187
+ | jq -r --arg name "$TASK_STORE_VOLUME" --arg tid "$task_id" '
2188
+ if (type == "array" and length == 1
2189
+ and (.[0] | type == "object")
2190
+ and .[0].id == $name
2191
+ and ((.[0].configuration.labels["io.moondeck.task"] // "") == $tid))
2192
+ then "ok" else "mismatch" end' 2>/dev/null) \
2193
+ || store_volume_ok="mismatch"
2194
+ if [ "$store_volume_ok" != "ok" ]; then
2195
+ emit_err "CONTAINER_INIT_FAILED" \
2196
+ "A volume named $TASK_STORE_VOLUME exists but could not be proven as this task's own (exact identity + ownership label); Moondeck will not attach a store volume it cannot prove it owns. Remove or rename it, then retry." \
2197
+ "create task store volume"
2198
+ fi
2199
+ else
2200
+ if ! crt volume create --label "io.moondeck.task=$task_id" \
2201
+ "$TASK_STORE_VOLUME" >/dev/null 2>&1; then
2202
+ emit_err "CONTAINER_INIT_FAILED" \
2203
+ "Moondeck could not create this task's store volume $TASK_STORE_VOLUME on the Apple container runtime. Retry after the runtime is healthy." \
2204
+ "create task store volume"
2205
+ fi
2206
+ fi
2207
+ else
2208
+ crt volume create "$PW_VOLUME" >/dev/null 2>&1 || true
2209
+ fi
2210
+
2211
+ # The standard base image is built by the host (ensureStandardImage), never
2212
+ # pulled from a registry — and derived tasks FROM it too. If it's absent (the
2213
+ # host started before Docker was ready, or the build failed) compose would try
2214
+ # a doomed registry pull; fail with an actionable message instead (the host
2215
+ # also (re)builds it on demand before task-up — this is the belt-and-suspenders
2216
+ # check for a still-in-progress or failed build). Live 2026-07-22.
2217
+ if ! crt image inspect "$STANDARD_IMAGE" >/dev/null 2>&1; then
2218
+ emit_err "STANDARD_IMAGE_MISSING" \
2219
+ "the host base image $STANDARD_IMAGE is not built yet — the host builds it at startup (a few minutes on first run, and after Docker Desktop finishes starting). Wait for the host log line 'built standard image', or restart the host, then retry. If it keeps failing the host log shows the build error (often Docker Desktop low on memory/disk)." \
2220
+ "check standard image"
2221
+ fi
2222
+
2223
+ # A derived task must materialize through the exact filesystem it will run:
2224
+ # project `extra` may add runtime build dependencies. Split build from up so a
2225
+ # failed materialization can never start the task container, and so a later up
2226
+ # cannot silently rebuild a different image after the proof succeeds.
2227
+ runtime_image="$STANDARD_IMAGE"
2228
+ if [ "$has_derived" = "1" ]; then
2229
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2230
+ step "COMPOSE_UP_FAILED" "build derived task image"
2231
+ if ! "$CONTAINER_CLI" build -t "$DERIVED_IMAGE" \
2232
+ -f "$task_uai_dir/Dockerfile" "$task_uai_dir" >&2; then
2233
+ require_docker_after_failed_step "build derived task image"
2234
+ emit_err "COMPOSE_UP_FAILED" \
2235
+ "Moondeck could not build this task's derived image. Check the project's extra setup commands, then retry." \
2236
+ "build derived task image"
2237
+ fi
2238
+ else
2239
+ step "COMPOSE_UP_FAILED" "docker compose build app"
2240
+ if ! docker compose -p "$compose_project" \
2241
+ -f "$task_uai_dir/docker-compose.yml" build app >&2; then
2242
+ require_docker_after_failed_step "docker compose build app"
2243
+ emit_err "COMPOSE_UP_FAILED" \
2244
+ "Moondeck could not build this task's derived image. Check the project's extra setup commands, then retry." \
2245
+ "docker compose build app"
2246
+ fi
2247
+ fi
2248
+ runtime_image="$DERIVED_IMAGE"
2249
+ fi
2250
+
2251
+ # Resolve the mutable service tag once and use only its immutable local image
2252
+ # ID for both maintenance and app creation. The on-disk Compose file keeps its
2253
+ # tag so task-down can still identify/remove a derived image, but no concurrent
2254
+ # rebuild can swap code between runtime proof and execution.
2255
+ step "CONTAINER_INIT_FAILED" "resolve task image identity"
2256
+ runtime_image_id=""
2257
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2258
+ # One snapshot supplies identity, environment, and entrypoint together, so
2259
+ # every image-authority decision below describes the same bytes. The Apple
2260
+ # CLI cannot run an image by its 64-hex id, so the id is re-checked against
2261
+ # the tag after the container is created instead of run directly.
2262
+ apple_image_snapshot=""
2263
+ if ! apple_image_snapshot=$("$CONTAINER_CLI" image inspect \
2264
+ "$runtime_image" 2>/dev/null); then
2265
+ require_docker_after_failed_step "resolve task image identity"
2266
+ emit_err "CONTAINER_INIT_FAILED" \
2267
+ "Moondeck could not resolve the exact local image for this task. Retry after the host image is available." \
2268
+ "resolve task image identity"
2269
+ fi
2270
+ runtime_image_id=$(printf '%s\n' "$apple_image_snapshot" \
2271
+ | jq -r '.[0].id // empty' 2>/dev/null) || runtime_image_id=""
2272
+ if ! printf '%s\n' "$runtime_image_id" \
2273
+ | grep -Eq '^[0-9a-f]{64}$'; then
2274
+ emit_err "CONTAINER_INIT_FAILED" \
2275
+ "The Apple container runtime returned an invalid image identity for $runtime_image; Moondeck refused to start an unpinned task container." \
2276
+ "resolve task image identity"
2277
+ fi
2278
+ # Without a --entrypoint override flag, an image-declared ENTRYPOINT would
2279
+ # prefix every argv this script passes (including the materializer's
2280
+ # `env -i`). Refuse such an image before anything executes from it.
2281
+ if ! printf '%s\n' "$apple_image_snapshot" | jq -e '
2282
+ (.[0].variants | type == "array" and length == 1)
2283
+ and ((.[0].variants[0].config.config.Entrypoint // []) | length == 0)
2284
+ ' >/dev/null 2>&1; then
2285
+ emit_err "CONTAINER_INIT_FAILED" \
2286
+ "Moondeck refused this task image because it declares an ENTRYPOINT or multiple platform variants, which the Apple container runtime cannot safely override. Remove the ENTRYPOINT from the project's extra setup, then retry." \
2287
+ "resolve task image identity"
2288
+ fi
2289
+ else
2290
+ if ! runtime_image_id=$(docker image inspect --format '{{.Id}}' \
2291
+ "$runtime_image" 2>/dev/null); then
2292
+ require_docker_after_failed_step "resolve task image identity"
2293
+ emit_err "CONTAINER_INIT_FAILED" \
2294
+ "Moondeck could not resolve the exact local image for this task. Retry after the host image is available." \
2295
+ "resolve task image identity"
2296
+ fi
2297
+ if ! printf '%s\n' "$runtime_image_id" \
2298
+ | grep -Eq '^sha256:[0-9a-f]{64}$'; then
2299
+ emit_err "CONTAINER_INIT_FAILED" \
2300
+ "Docker returned an invalid image identity for $runtime_image; Moondeck refused to start an unpinned task container." \
2301
+ "resolve task image identity"
2302
+ fi
2303
+ fi
2304
+
2305
+ # Docker keeps image Config.Env entries whose names differ only by case from
2306
+ # later `docker exec -e` overrides. npm treats those spellings
2307
+ # case-insensitively and, depending on environment order, a mixed-case value
2308
+ # can beat both canonical values. Reject such an image before maintenance or
2309
+ # app creation; ordinary registry/auth npm config remains allowed, while the
2310
+ # four path/permission authorities must use one of the two spellings Uai can
2311
+ # deterministically replace.
2312
+ step "CONTAINER_INIT_FAILED" "verify task image npm authority"
2313
+ runtime_image_env_json=""
2314
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2315
+ # From the same snapshot the identity pin came from — the single variant
2316
+ # was already proven above.
2317
+ runtime_image_env_json=$(printf '%s\n' "$apple_image_snapshot" \
2318
+ | jq -c '.[0].variants[0].config.config.Env // []' 2>/dev/null) \
2319
+ || runtime_image_env_json=""
2320
+ if [ -z "$runtime_image_env_json" ]; then
2321
+ emit_err "CONTAINER_INIT_FAILED" \
2322
+ "Moondeck could not inspect this task image's npm environment authority. Retry after the Apple container runtime is stable." \
2323
+ "verify task image npm authority"
2324
+ fi
2325
+ elif ! runtime_image_env_json=$(docker image inspect \
2326
+ --format '{{json .Config.Env}}' "$runtime_image_id" 2>/dev/null); then
2327
+ require_docker_after_failed_step "verify task image npm authority"
2328
+ emit_err "CONTAINER_INIT_FAILED" \
2329
+ "Moondeck could not inspect this task image's npm environment authority. Retry after Docker is stable." \
2330
+ "verify task image npm authority"
2331
+ fi
2332
+ if ! printf '%s\n' "$runtime_image_env_json" | jq -e '
2333
+ def is_owned_npm_config($owned):
2334
+ ascii_downcase as $folded
2335
+ | ($folded | startswith("npm_config_"))
2336
+ and (($owned | index($folded[11:] | gsub("_"; "-"))) != null);
2337
+ (. // []) as $env
2338
+ | [
2339
+ "NPM_CONFIG_PREFIX", "npm_config_prefix",
2340
+ "NPM_CONFIG_CACHE", "npm_config_cache",
2341
+ "NPM_CONFIG_LOGS_DIR", "npm_config_logs_dir",
2342
+ "NPM_CONFIG_UMASK", "npm_config_umask"
2343
+ ] as $canonical
2344
+ | [
2345
+ "prefix", "cache", "logs-dir", "umask"
2346
+ ] as $owned
2347
+ | ($env | type == "array")
2348
+ and all($env[]; type == "string")
2349
+ and all($env[];
2350
+ (split("=")[0]) as $key
2351
+ | (($key | is_owned_npm_config($owned)) | not)
2352
+ or (($canonical | index($key)) != null)
2353
+ )
2354
+ ' >/dev/null 2>&1; then
2355
+ emit_err "CONTAINER_INIT_FAILED" \
2356
+ "Moondeck refused this task image because a mixed-case npm prefix/cache/log setting could redirect global package writes. Remove that image ENV setting (unrelated registry/auth settings remain supported) and retry." \
2357
+ "verify task image npm authority"
2358
+ fi
2359
+
2360
+ # Runtime installation is a host-maintenance phase, not task-container init.
2361
+ # Give the one-shot container only the immutable workspace view and the
2362
+ # explicitly writable host-wide asdf volume. Run it for every selected-project
2363
+ # task: projects without .tool-versions still use the image's proven root
2364
+ # defaults and may request a Corepack-managed package manager. A true
2365
+ # zero-project scratchpad has nothing to prepare and remains the only skip.
2366
+ # GNU timeout lives in the Debian standard image (and therefore every derived
2367
+ # image); it bounds the shared lock wait plus all installs and positive
2368
+ # verification without leaving an orphaned volume writer when the cloud's
2369
+ # 15-minute RPC deadline expires.
2370
+ if [ "$project_count" -gt 0 ]; then
2371
+ step "CONTAINER_INIT_FAILED" "materialize task runtimes"
2372
+ materialization_rc=0
2373
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2374
+ # ADR-108: the shared volume is never written on the task path. Apple
2375
+ # materialization runs INSIDE the app container after the in-guest
2376
+ # overlay is established (section 6a below) — a missing toolchain
2377
+ # installs into the task's own upper layer with zero shared-volume
2378
+ # coordination, at any task count.
2379
+ log "apple runtime materialization runs in-container after the overlay is established"
2380
+ else
2381
+ run_runtime_materializer "$runtime_image_id" || materialization_rc=$?
2382
+ fi
2383
+ if [ "$materialization_rc" -ne 0 ]; then
2384
+ require_docker_after_failed_step "materialize task runtimes"
2385
+ case "$materialization_rc" in
2386
+ 124|137)
2387
+ emit_err "CONTAINER_INIT_FAILED" \
2388
+ "Runtime materialization did not finish inside the station's 10-minute startup budget. Retry the task: Moondeck will re-check every required runtime and package manager before it starts any task container, and will not trust a partial cache." \
2389
+ "materialize task runtimes"
2390
+ ;;
2391
+ *)
2392
+ emit_err "CONTAINER_INIT_FAILED" \
2393
+ "Moondeck could not install and verify every runtime and package manager needed by this task. Check the project's .tool-versions and packageManager entries and the host's network and disk availability, then retry." \
2394
+ "materialize task runtimes"
2395
+ ;;
2396
+ esac
2397
+ fi
2398
+ else
2399
+ log "zero-project scratchpad — skipping runtime materialization"
2400
+ fi
2401
+
2402
+ # -----------------------------------------------------------------------------
2403
+ # 6. Compose up.
2404
+ # -----------------------------------------------------------------------------
2405
+
2406
+ step "COMPOSE_UP_FAILED" "start task container"
2407
+ # Limit the supplied values to keys the selected projects actually declare,
2408
+ # then remove Uai-managed names and preview URL names. Both runtimes consume
2409
+ # the same filtered object: Docker merges it as a Compose stdin override, and
2410
+ # the Apple path serializes it into a transient env-file below. JSON is valid
2411
+ # YAML, so Compose can merge this secret-bearing override directly from stdin
2412
+ # without a temporary env/YAML file and without exporting any value to host
2413
+ # commands.
2414
+ preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
2415
+ filtered_project_env='{}'
2416
+ if [ "$UAI_PROJECT_ENV_MODE" = "baked" ]; then
2417
+ filtered_project_env=$(printf '%s' "$project_env_json" \
2418
+ | filter_baked_project_env "$union_env_keys_json" "$preview_env_raw")
2419
+ fi
2420
+ compose_env_override=""
2421
+ if [ "$TASK_RUNTIME_MODE" = "docker" ]; then
2422
+ compose_env_override=$(printf '%s\n' "$filtered_project_env" | jq -c \
2423
+ --arg image "$runtime_image_id" \
2424
+ '{services:{app:{environment:.,image:$image,pull_policy:"never"}}}')
2425
+ fi
2426
+
2427
+ # Detect a tag replacement that completed during materialization and fail with
2428
+ # a clear retry instead of starting a task under a stale generation. The
2429
+ # immutable stdin override below also closes the remaining recheck→up window.
2430
+ step "CONTAINER_INIT_FAILED" "verify task image identity"
2431
+ current_runtime_image_id=""
2432
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2433
+ if ! current_runtime_image_id=$("$CONTAINER_CLI" image inspect \
2434
+ "$runtime_image" 2>/dev/null | jq -r '.[0].id // empty' 2>/dev/null); then
2435
+ require_docker_after_failed_step "verify task image identity"
2436
+ emit_err "CONTAINER_INIT_FAILED" \
2437
+ "Moondeck could not re-check this task's image after runtime materialization. Retry after the Apple container runtime is stable." \
2438
+ "verify task image identity"
2439
+ fi
2440
+ elif ! current_runtime_image_id=$(docker image inspect --format '{{.Id}}' \
2441
+ "$runtime_image" 2>/dev/null); then
2442
+ require_docker_after_failed_step "verify task image identity"
2443
+ emit_err "CONTAINER_INIT_FAILED" \
2444
+ "Moondeck could not re-check this task's image after runtime materialization. Retry after Docker is stable." \
2445
+ "verify task image identity"
2446
+ fi
2447
+ if [ "$current_runtime_image_id" != "$runtime_image_id" ]; then
2448
+ emit_err "CONTAINER_INIT_FAILED" \
2449
+ "This task's image changed while Moondeck was materializing runtimes. Retry so the new image receives a complete runtime proof before any task container starts." \
2450
+ "verify task image identity"
2451
+ fi
2452
+ reject_workspace_runtime_shadow
2453
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2454
+ step "COMPOSE_UP_FAILED" "start task container"
2455
+ # The Apple CLI takes environment through an env-file whose values are read
2456
+ # verbatim from the first `=` to the newline. A value containing a newline
2457
+ # cannot survive that framing — refuse it rather than truncate a secret.
2458
+ if ! printf '%s\n' "$filtered_project_env" | jq -e \
2459
+ 'to_entries | all(.value | type == "string" and (test("[\r\n]") | not))' \
2460
+ >/dev/null 2>&1; then
2461
+ emit_err "COMPOSE_UP_FAILED" \
2462
+ "A project environment value contains a newline, which the Apple container runtime cannot pass through its env-file. Store the value single-line (for example base64) or run this task on a Docker host." \
2463
+ "start task container"
2464
+ fi
2465
+ task_env_file=$(mktemp "$task_uai_dir/.task-env.XXXXXX")
2466
+ chmod 0600 "$task_env_file"
2467
+ {
2468
+ # Mirrors the Compose service environment exactly (see the rendered
2469
+ # docker-compose.yml for the rationale on each block). Later lines win in
2470
+ # both dialects, so ordering here preserves the Compose precedence:
2471
+ # static authority, engine tokens, declared project values, preview env.
2472
+ printf 'UAI_GIT_TRANSPORT=%s\n' "$uai_git_transport"
2473
+ printf 'GH_CONFIG_DIR=/home/node/.config/gh\n'
2474
+ printf 'PATH=%s\n' "$TASK_RUNTIME_PATH"
2475
+ printf 'HOME=/home/node\n'
2476
+ printf 'XDG_CONFIG_HOME=/home/node/.config\n'
2477
+ printf 'XDG_DATA_HOME=/home/node/.local/share\n'
2478
+ printf 'XDG_CACHE_HOME=/home/node/.cache\n'
2479
+ printf 'ASDF_DIR=/opt/asdf\n'
2480
+ printf 'ASDF_DATA_DIR=/opt/asdf-data\n'
2481
+ printf 'ASDF_CONFIG_FILE=/dev/null\n'
2482
+ printf 'ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions\n'
2483
+ printf 'ASDF_NODEJS_VERSION=\n'
2484
+ printf 'ASDF_PYTHON_VERSION=\n'
2485
+ printf 'ASDF_GOLANG_VERSION=\n'
2486
+ printf 'ASDF_RUBY_VERSION=\n'
2487
+ printf 'ASDF_RUST_VERSION=\n'
2488
+ printf 'ASDF_SKIP_RESHIM=1\n'
2489
+ printf 'COREPACK_HOME=/opt/asdf-data/corepack\n'
2490
+ printf 'COREPACK_DEFAULT_TO_LATEST=0\n'
2491
+ printf 'COREPACK_ENV_FILE=0\n'
2492
+ printf 'COREPACK_NPM_REGISTRY=https://registry.npmjs.org\n'
2493
+ printf 'COREPACK_ENABLE_NETWORK=0\n'
2494
+ printf 'COREPACK_ENABLE_AUTO_PIN=0\n'
2495
+ printf 'COREPACK_ENABLE_DOWNLOAD_PROMPT=0\n'
2496
+ printf 'COREPACK_ENABLE_PROJECT_SPEC=1\n'
2497
+ printf 'COREPACK_ENABLE_STRICT=1\n'
2498
+ printf 'COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=0\n'
2499
+ printf 'COREPACK_INTEGRITY_CHECK=1\n'
2500
+ printf 'NPM_CONFIG_PREFIX=%s\n' "$TASK_NPM_PREFIX"
2501
+ printf 'npm_config_prefix=%s\n' "$TASK_NPM_PREFIX"
2502
+ printf 'NPM_CONFIG_CACHE=%s\n' "$TASK_NPM_CACHE"
2503
+ printf 'npm_config_cache=%s\n' "$TASK_NPM_CACHE"
2504
+ printf 'NPM_CONFIG_LOGS_DIR=%s/_logs\n' "$TASK_NPM_CACHE"
2505
+ printf 'npm_config_logs_dir=%s/_logs\n' "$TASK_NPM_CACHE"
2506
+ printf 'NPM_CONFIG_UMASK=0002\n'
2507
+ printf 'npm_config_umask=0002\n'
2508
+ # ADR-118: pnpm's store must live on the task's block volume, never on
2509
+ # the virtiofs workspace (pnpm's same-filesystem fallback would drop it
2510
+ # at the workspace root). Env beats a project .npmrc, so repos need no
2511
+ # config. Cross-filesystem hard links are impossible; pnpm's
2512
+ # package-import-method=auto falls back to copying, which is correct.
2513
+ printf 'NPM_CONFIG_STORE_DIR=%s\n' "$TASK_STORE_DIR"
2514
+ printf 'npm_config_store_dir=%s\n' "$TASK_STORE_DIR"
2515
+ printf 'UAI_WORKSPACE=/workspace\n'
2516
+ printf 'UAI_RUNTIME_PROJECTS=/run/uai/runtime-projects\n'
2517
+ printf 'UAI_ENV_MODE=%s\n' "$UAI_PROJECT_ENV_MODE"
2518
+ printf 'UAI_ENV_HOOK=0\n'
2519
+ printf 'SHELL=/usr/bin/zsh\n'
2520
+ printf 'LD_PRELOAD=\n'
2521
+ printf 'LD_AUDIT=\n'
2522
+ printf 'LD_LIBRARY_PATH=\n'
2523
+ printf 'NODE_OPTIONS=\n'
2524
+ printf 'NODE_PATH=\n'
2525
+ printf 'PS4=\n'
2526
+ printf 'BASH_ENV=\n'
2527
+ printf 'ENV=\n'
2528
+ if [ "$uai_on_cloud_credentials" != "1" ]; then
2529
+ printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\n' "${CLAUDE_CODE_OAUTH_TOKEN:-}"
2530
+ printf 'ANTHROPIC_API_KEY=%s\n' "${ANTHROPIC_API_KEY:-}"
2531
+ printf 'ANTHROPIC_AUTH_TOKEN=%s\n' "${ANTHROPIC_AUTH_TOKEN:-}"
2532
+ printf 'XAI_API_KEY=%s\n' "${XAI_API_KEY:-}"
2533
+ fi
2534
+ printf 'PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers\n'
2535
+ if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
2536
+ while IFS= read -r ekey; do
2537
+ [ -n "$ekey" ] || continue
2538
+ is_reserved_task_env_name "$ekey" && continue
2539
+ printf '%s=\n' "$ekey"
2540
+ done < <(jq -r '.[]' <<<"$union_env_keys_json")
2541
+ else
2542
+ printf '%s\n' "$filtered_project_env" \
2543
+ | jq -r 'to_entries[] | "\(.key)=\(.value)"'
2544
+ fi
2545
+ while IFS= read -r pe_entry; do
2546
+ [ -n "$pe_entry" ] || continue
2547
+ pe_key=$(jq -r '.key' <<<"$pe_entry")
2548
+ pe_val=$(jq -r '.value' <<<"$pe_entry")
2549
+ [ -n "$pe_key" ] || continue
2550
+ if [ "$pe_key" != "UAI_EDITOR_THEME" ] \
2551
+ && is_reserved_task_env_name "$pe_key"; then
2552
+ continue
2553
+ fi
2554
+ if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
2555
+ printf '%s=\n' "$pe_key"
2556
+ else
2557
+ printf '%s=%s\n' "$pe_key" "$pe_val"
2558
+ fi
2559
+ done < <(jq -c 'to_entries[]?' <<<"$preview_env_raw")
2560
+ } > "$task_env_file"
2561
+ # The mount set matches the rendered Compose file's app service one-for-one
2562
+ # (minus host-gateway and group_add, which have no Apple equivalent and are
2563
+ # not needed — ownership maps transparently across the VM boundary).
2564
+ apple_run_args=(
2565
+ -v "$task_workspace:/workspace"
2566
+ -v "$task_workspace:$task_workspace"
2567
+ )
2568
+ if [ "$task_workspace_physical" != "$task_workspace" ]; then
2569
+ apple_run_args+=( -v "$task_workspace:$task_workspace_physical" )
2570
+ fi
2571
+ while IFS= read -r vol_obj; do
2572
+ [ -n "$vol_obj" ] || continue
2573
+ vol_pid=$(jq -r '.id' <<<"$vol_obj")
2574
+ apple_run_args+=(
2575
+ -v "$task_uai_dir/repos/$vol_pid.git:$task_uai_dir/repos/$vol_pid.git"
2576
+ )
2577
+ if [ "$task_uai_dir_physical" != "$task_uai_dir" ]; then
2578
+ apple_run_args+=(
2579
+ -v "$task_uai_dir/repos/$vol_pid.git:$task_uai_dir_physical/repos/$vol_pid.git"
2580
+ )
2581
+ fi
2582
+ done < <(jq -c '.[]' <<<"$projects_json")
2583
+ apple_run_args+=(
2584
+ -v "$UAI_INIT_SOURCE:/usr/local/bin/uai-init:ro"
2585
+ -v "$MOONDECK_CLI_SOURCE:/usr/local/bin/moondeck:ro"
2586
+ -v "$runtime_projects_file:/run/uai/runtime-projects:ro"
2587
+ -v "$managed_env_keys_file:/run/uai/managed-env-keys:ro"
2588
+ # ADR-108: the shared volume attaches READ-ONLY at a staging path and an
2589
+ # in-guest overlay presents it writable at /opt/asdf-data (upper = the
2590
+ # task's own layer). The shared volume is never written on the task path,
2591
+ # so any number of tasks coexist and first-time installs need no window.
2592
+ -v "$ASDF_VOLUME:/run/uai/asdf-lower:ro"
2593
+ # In-container materialization (post-overlay) uses the packaged current
2594
+ # materializer + Corepack pin, exactly like the retired maintenance
2595
+ # container did.
2596
+ -v "$RUNTIME_MATERIALIZER_SOURCE:/usr/local/bin/uai-materialize-runtimes:ro"
2597
+ -v "$COREPACK_VERSION_SOURCE:/usr/local/share/uai/corepack-version:ro"
2598
+ -v "$PW_BROWSERS_DIR:/opt/pw-browsers"
2599
+ # ADR-118: task-private store volume, writable — created and
2600
+ # ownership-proven above; single-attach by runtime construction.
2601
+ -v "$TASK_STORE_VOLUME:$TASK_STORE_MOUNT"
2602
+ )
2603
+ if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
2604
+ apple_run_args+=(
2605
+ -v "$ENV_HOOK_SOURCE:/usr/local/share/uai/env-hook.sh:ro"
2606
+ -v "$UAI_ENV_EXEC_SOURCE:/usr/local/bin/uai-env-exec:ro"
2607
+ -v "$TASK_FORWARDER_SOURCE:/usr/local/share/uai/uai-task-forwarder.mjs:ro"
2608
+ )
2609
+ fi
2610
+ if [ "$shared_files_mode" != "off" ]; then
2611
+ shared_suffix=""
2612
+ [ "$shared_files_mode" = "ro" ] && shared_suffix=":ro"
2613
+ if [ -n "$shared_owner_org" ]; then
2614
+ apple_run_args+=(
2615
+ -v "$shared_root/orgs/$shared_owner_org:/workspace/files/org$shared_suffix"
2616
+ )
2617
+ fi
2618
+ if [ -n "$shared_owner_user" ]; then
2619
+ apple_run_args+=(
2620
+ -v "$shared_root/users/$shared_owner_user:/workspace/files/me$shared_suffix"
2621
+ )
2622
+ fi
2623
+ fi
2624
+ apple_up_rc=0
2625
+ # CAP_SYS_ADMIN: required for the ADR-108 in-guest overlay mount.
2626
+ # CAP_NET_ADMIN: the ADR-153 secrets proxy's NAT chain, set by the runner's
2627
+ # root execs (node has no sudo). Both are confined by the VM boundary: the
2628
+ # guest kernel is the task's own, so they grant nothing on the host.
2629
+ "$CONTAINER_CLI" run -d --init \
2630
+ --name "$app_container" \
2631
+ -l "io.moondeck.task=$task_id" \
2632
+ --cap-add CAP_SYS_ADMIN \
2633
+ --cap-add CAP_NET_ADMIN \
2634
+ --workdir /workspace \
2635
+ --memory "$APPLE_TASK_MEMORY" \
2636
+ --cpus "$APPLE_TASK_CPUS" \
2637
+ --env-file "$task_env_file" \
2638
+ "${apple_run_args[@]}" \
2639
+ "$runtime_image" >/dev/null || apple_up_rc=$?
2640
+ rm -f "$task_env_file"
2641
+ if [ "$apple_up_rc" -ne 0 ]; then
2642
+ require_docker_after_failed_step "start task container"
2643
+ emit_err "COMPOSE_UP_FAILED" \
2644
+ "Moondeck could not start this task's container on the Apple container runtime. Retry the task." \
2645
+ "start task container"
2646
+ fi
2647
+ # The runtime cannot start a container by immutable image id, so prove the
2648
+ # tag still resolves to the pinned bytes now that the container exists. A
2649
+ # concurrent retag loses either way: before create, the recheck above; here,
2650
+ # the container is quarantined before credentials are installed.
2651
+ apple_post_up_image_id=$("$CONTAINER_CLI" image inspect "$runtime_image" \
2652
+ 2>/dev/null | jq -r '.[0].id // empty' 2>/dev/null) \
2653
+ || apple_post_up_image_id=""
2654
+ if [ "$apple_post_up_image_id" != "$runtime_image_id" ]; then
2655
+ if ! quarantine_untrusted_app_container; then
2656
+ emit_err "CONTAINER_INIT_FAILED" \
2657
+ "This task's image changed while its container was being created, and Moondeck could not prove the container stopped. Stop it manually before retrying." \
2658
+ "verify task image identity"
2659
+ fi
2660
+ emit_err "CONTAINER_INIT_FAILED" \
2661
+ "This task's image changed while its container was being created. Retry so the new image receives a complete runtime proof first." \
2662
+ "verify task image identity"
2663
+ fi
2664
+ else
2665
+ step "COMPOSE_UP_FAILED" "docker compose up -d"
2666
+ compose_up_error=""
2667
+ # Keep the diagnostic in memory only, but bound it: a broken plugin or
2668
+ # daemon must not make task-up retain an arbitrarily large stderr stream.
2669
+ # Docker's allocation error is emitted at the end of Compose's failure.
2670
+ if ! compose_up_error=$(printf '%s\n' "$compose_env_override" \
2671
+ | docker compose -p "$compose_project" \
2672
+ -f "$task_uai_dir/docker-compose.yml" -f - up -d --no-build \
2673
+ 2>&1 >/dev/null | tail -c 65536); then
2674
+ [ -z "$compose_up_error" ] || printf '%s\n' "$compose_up_error" >&2
2675
+ require_docker_after_failed_step "docker compose up -d"
2676
+ if printf '%s\n' "$compose_up_error" \
2677
+ | grep -Fq 'all predefined address pools have been fully subnetted'; then
2678
+ emit_err "COMPOSE_UP_FAILED" \
2679
+ "Docker has no subnet left for this task. Moondeck already reclaimed every empty task network it could prove safe. Close an unused stopped/error task, or enlarge Docker's non-overlapping default address pools and restart Docker, then press Resume." \
2680
+ "docker compose up -d"
2681
+ fi
2682
+ emit_err "COMPOSE_UP_FAILED" \
2683
+ "Moondeck could not start this task's Docker Compose stack. Fix the Docker error above, then press Resume." \
2684
+ "docker compose up -d"
2685
+ fi
2686
+ unset compose_up_error
2687
+ fi
2688
+
2689
+ # Rendered YAML is not runtime evidence: Compose could reuse unexpected state,
2690
+ # or a compatibility bug could silently drop `:ro`. Inspect the exact created
2691
+ # app before it receives credentials. There must be one named volume at this
2692
+ # destination, with Docker itself reporting the mount read-only, and the same
2693
+ # shared volume must not be exposed again at any other destination.
2694
+ step "CONTAINER_INIT_FAILED" "verify task runtime isolation"
2695
+ app_mounts_json=""
2696
+ apple_app_snapshot=""
2697
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2698
+ # One container snapshot backs both the mount and environment proofs, so
2699
+ # they describe the same observed state.
2700
+ apple_app_snapshot=$(crt inspect "$app_container" 2>/dev/null) \
2701
+ || apple_app_snapshot=""
2702
+ app_mounts_json=$(printf '%s\n' "$apple_app_snapshot" \
2703
+ | jq -c '.[0].configuration.mounts // empty' 2>/dev/null) \
2704
+ || app_mounts_json=""
2705
+ fi
2706
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ] \
2707
+ && [ -z "$app_mounts_json" ]; then
2708
+ require_docker_after_failed_step "inspect task runtime isolation"
2709
+ if ! quarantine_untrusted_app_container; then
2710
+ emit_err "CONTAINER_INIT_FAILED" \
2711
+ "Moondeck could not inspect or stop the task container before credentials were installed. Stop it manually before retrying." \
2712
+ "verify task runtime isolation"
2713
+ fi
2714
+ emit_err "CONTAINER_INIT_FAILED" \
2715
+ "Moondeck could not inspect the task container's shared runtime mount. Retry to recreate it safely." \
2716
+ "verify task runtime isolation"
2717
+ elif [ "$TASK_RUNTIME_MODE" = "docker" ] \
2718
+ && ! app_mounts_json=$(docker inspect --format '{{json .Mounts}}' \
2719
+ "$app_container" 2>/dev/null); then
2720
+ require_docker_after_failed_step "inspect task runtime isolation"
2721
+ if ! quarantine_untrusted_app_container; then
2722
+ emit_err "CONTAINER_INIT_FAILED" \
2723
+ "Moondeck could not inspect or stop the task container before credentials were installed. Stop it manually before retrying." \
2724
+ "verify task runtime isolation"
2725
+ fi
2726
+ emit_err "CONTAINER_INIT_FAILED" \
2727
+ "Moondeck could not inspect the task container's shared runtime mount. Retry to recreate it safely." \
2728
+ "verify task runtime isolation"
2729
+ fi
2730
+ app_config_env_json=""
2731
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2732
+ app_config_env_json=$(printf '%s\n' "$apple_app_snapshot" \
2733
+ | jq -c '.[0].configuration.initProcess.environment // empty' \
2734
+ 2>/dev/null) || app_config_env_json=""
2735
+ fi
2736
+ if { [ "$TASK_RUNTIME_MODE" = "apple-container" ] \
2737
+ && [ -z "$app_config_env_json" ]; } \
2738
+ || { [ "$TASK_RUNTIME_MODE" = "docker" ] \
2739
+ && ! app_config_env_json=$(docker inspect \
2740
+ --format '{{json .Config.Env}}' "$app_container" 2>/dev/null); }; then
2741
+ require_docker_after_failed_step "inspect task runtime environment"
2742
+ if ! quarantine_untrusted_app_container; then
2743
+ emit_err "CONTAINER_INIT_FAILED" \
2744
+ "Moondeck could not inspect or stop the task container before credentials were installed. Stop it manually before retrying." \
2745
+ "verify task runtime isolation"
2746
+ fi
2747
+ emit_err "CONTAINER_INIT_FAILED" \
2748
+ "Moondeck could not inspect the task container's runtime environment. Retry to recreate it safely." \
2749
+ "verify task runtime isolation"
2750
+ fi
2751
+ # The two runtimes report mounts in different shapes; each filter asserts the
2752
+ # same contract — exactly one /opt/asdf-data mount, it is the shared
2753
+ # moondeck-asdf-data volume, the runtime itself reports it read-only, and that
2754
+ # volume appears at no other destination.
2755
+ mounts_proof_filter='
2756
+ [ .[] | select(.Destination == "/opt/asdf-data") ] as $runtime
2757
+ | [ .[] | select(.Name? == "moondeck-asdf-data") ] as $shared
2758
+ | ($runtime | length) == 1
2759
+ and ($shared | length) == 1
2760
+ and $runtime[0].Type == "volume"
2761
+ and $runtime[0].Name == "moondeck-asdf-data"
2762
+ and $runtime[0].RW == false
2763
+ '
2764
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2765
+ # ADR-108: the shared volume attaches READ-ONLY at the overlay staging
2766
+ # path, appears nowhere else, and /opt/asdf-data is NOT a host mount at
2767
+ # all — the writable view there is the in-guest overlay, proven from
2768
+ # inside the guest right after this (host inspect cannot see guest
2769
+ # mounts).
2770
+ mounts_proof_filter='
2771
+ [ .[] | select(.destination == "/run/uai/asdf-lower") ] as $runtime
2772
+ | [ .[] | select((.type.volume.name? // "") == "moondeck-asdf-data") ] as $shared
2773
+ | [ .[] | select(.destination == $store_mount) ] as $store
2774
+ | [ .[] | select((.type.volume.name? // "") == $store_volume) ] as $store_vol
2775
+ | ($runtime | length) == 1
2776
+ and ($shared | length) == 1
2777
+ and (($runtime[0].type.volume.name? // "") == "moondeck-asdf-data")
2778
+ and ((($runtime[0].options // []) | index("ro")) != null)
2779
+ and ((($runtime[0].options // []) | index("rw")) == null)
2780
+ and (([ .[] | select(.destination == "/opt/asdf-data") ] | length) == 0)
2781
+ # ADR-118: the task-private store volume attaches WRITABLE at exactly
2782
+ # its one mount point, and no other volume occupies that path.
2783
+ and ($store | length) == 1
2784
+ and ($store_vol | length) == 1
2785
+ and (($store[0].type.volume.name? // "") == $store_volume)
2786
+ and ((($store[0].options // []) | index("ro")) == null)
2787
+ '
2788
+ fi
2789
+ if ! printf '%s\n' "$app_mounts_json" \
2790
+ | jq -e \
2791
+ --arg store_mount "$TASK_STORE_MOUNT" \
2792
+ --arg store_volume "$TASK_STORE_VOLUME" \
2793
+ "$mounts_proof_filter" >/dev/null 2>&1; then
2794
+ if ! quarantine_untrusted_app_container; then
2795
+ emit_err "CONTAINER_INIT_FAILED" \
2796
+ "Moondeck found an unsafe shared runtime mount and could not prove the task container stopped. Stop it manually before retrying." \
2797
+ "verify task runtime isolation"
2798
+ fi
2799
+ emit_err "CONTAINER_INIT_FAILED" \
2800
+ "Moondeck stopped this task because its shared runtime cache was not the exact read-only moondeck-asdf-data volume. Retry to recreate the container safely." \
2801
+ "verify task runtime isolation"
2802
+ fi
2803
+ # ADR-118: the store authority exists only on the apple path (Docker tasks
2804
+ # keep pnpm's default resolution); an empty $store keeps the historical
2805
+ # docker proof byte-for-byte.
2806
+ env_proof_store_dir=""
2807
+ [ "$TASK_RUNTIME_MODE" != "apple-container" ] \
2808
+ || env_proof_store_dir="$TASK_STORE_DIR"
2809
+ if ! printf '%s\n' "$app_config_env_json" | jq -e \
2810
+ --arg path "$TASK_RUNTIME_PATH" \
2811
+ --arg prefix "$TASK_NPM_PREFIX" \
2812
+ --arg cache "$TASK_NPM_CACHE" \
2813
+ --arg store "$env_proof_store_dir" '
2814
+ def is_owned_npm_config($owned):
2815
+ ascii_downcase as $folded
2816
+ | ($folded | startswith("npm_config_"))
2817
+ and (($owned | index($folded[11:] | gsub("_"; "-"))) != null);
2818
+ (. // []) as $env
2819
+ | ([
2820
+ "NPM_CONFIG_PREFIX=\($prefix)",
2821
+ "npm_config_prefix=\($prefix)",
2822
+ "NPM_CONFIG_CACHE=\($cache)",
2823
+ "npm_config_cache=\($cache)",
2824
+ "NPM_CONFIG_LOGS_DIR=\($cache)/_logs",
2825
+ "npm_config_logs_dir=\($cache)/_logs",
2826
+ "NPM_CONFIG_UMASK=0002",
2827
+ "npm_config_umask=0002"
2828
+ ]
2829
+ + (if $store != "" then [
2830
+ "NPM_CONFIG_STORE_DIR=\($store)",
2831
+ "npm_config_store_dir=\($store)"
2832
+ ] else [] end)) as $required
2833
+ | ([
2834
+ "prefix", "cache", "logs-dir", "umask"
2835
+ ]
2836
+ + (if $store != "" then ["store-dir"] else [] end)) as $owned
2837
+ | ($env | type == "array")
2838
+ and all($env[]; type == "string")
2839
+ and all($required[];
2840
+ . as $required_value
2841
+ | ([ $env[] | select(. == $required_value) ] | length) == 1
2842
+ )
2843
+ and ([
2844
+ $env[]
2845
+ | split("=")[0]
2846
+ | select(is_owned_npm_config($owned))
2847
+ ] | length) == ($required | length)
2848
+ and ([ $env[] | select(. == "PATH=\($path)") ] | length) == 1
2849
+ and ([ $env[] | select(. == "ASDF_DATA_DIR=/opt/asdf-data") ] | length) == 1
2850
+ and ([ $env[] | select(. == "ASDF_SKIP_RESHIM=1") ] | length) == 1
2851
+ and ([ $env[] | select(. == "COREPACK_HOME=/opt/asdf-data/corepack") ] | length) == 1
2852
+ and ([ $env[] | select(. == "COREPACK_ENABLE_NETWORK=0") ] | length) == 1
2853
+ ' >/dev/null 2>&1; then
2854
+ if ! quarantine_untrusted_app_container; then
2855
+ emit_err "CONTAINER_INIT_FAILED" \
2856
+ "Moondeck found an unsafe task runtime environment and could not prove the container stopped. Stop it manually before retrying." \
2857
+ "verify task runtime isolation"
2858
+ fi
2859
+ emit_err "CONTAINER_INIT_FAILED" \
2860
+ "Moondeck stopped this task because its runtime/npm environment did not match the fixed private-prefix and read-only-cache authority. Retry to recreate the container safely." \
2861
+ "verify task runtime isolation"
2862
+ fi
2863
+
2864
+ # -----------------------------------------------------------------------------
2865
+ # 6a. ADR-108 (apple only): establish the in-guest runtime overlay, then
2866
+ # materialize runtimes INSIDE the container. The shared volume is never
2867
+ # written on the task path: the read-only lower proved above merges under
2868
+ # a task-private upper layer, so a missing toolchain installs into this
2869
+ # task's own layer with zero shared-volume coordination. Both steps run
2870
+ # before any credential, editor, or agent work; a failure quarantines the
2871
+ # container exactly like the isolation proofs above.
2872
+ # -----------------------------------------------------------------------------
2873
+
2874
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2875
+ step "CONTAINER_INIT_FAILED" "establish task runtime overlay"
2876
+ overlay_rc=0
2877
+ "$CONTAINER_CLI" exec -u root "$app_container" /bin/sh -c '
2878
+ set -e
2879
+ /bin/mkdir -p /var/lib/uai/asdf-upper /var/lib/uai/asdf-work /opt/asdf-data
2880
+ # The merged root inherits the UPPER directory'"'"'s metadata; without this
2881
+ # the node user cannot write the merged view at all (live 2026-08-18).
2882
+ /bin/chown node:node /var/lib/uai/asdf-upper
2883
+ if ! /bin/grep -q "^overlay /opt/asdf-data overlay " /proc/mounts; then
2884
+ /bin/mount -t overlay overlay \
2885
+ -o lowerdir=/run/uai/asdf-lower,upperdir=/var/lib/uai/asdf-upper,workdir=/var/lib/uai/asdf-work \
2886
+ /opt/asdf-data
2887
+ fi
2888
+ # Prove the exact shape from the guest mount table, not from trust —
2889
+ # token-exact (comma-delimited; substring matching accepted
2890
+ # lowerdir=/run/uai/asdf-lower-evil), exactly ONE effective mount at the
2891
+ # merged path and at the lower, a read-only lower, and a real node-owned
2892
+ # upper directory.
2893
+ overlay_lines=$(/bin/grep -c "^overlay /opt/asdf-data overlay " /proc/mounts || true)
2894
+ [ "$overlay_lines" = "1" ] || { echo "expected exactly one /opt/asdf-data overlay, found $overlay_lines" >&2; exit 1; }
2895
+ any_lines=$(/bin/grep -c " /opt/asdf-data " /proc/mounts || true)
2896
+ [ "$any_lines" = "1" ] || { echo "unexpected extra mounts at /opt/asdf-data" >&2; exit 1; }
2897
+ opts=$(/bin/grep "^overlay /opt/asdf-data overlay " /proc/mounts | /usr/bin/cut -d" " -f4)
2898
+ case ",$opts," in *",lowerdir=/run/uai/asdf-lower,"*) ;; *) echo "overlay lower is foreign" >&2; exit 1 ;; esac
2899
+ case ",$opts," in *",upperdir=/var/lib/uai/asdf-upper,"*) ;; *) echo "overlay upper is foreign" >&2; exit 1 ;; esac
2900
+ case ",$opts," in *",workdir=/var/lib/uai/asdf-work,"*) ;; *) echo "overlay workdir is foreign" >&2; exit 1 ;; esac
2901
+ case ",$opts," in *",ro,"*) echo "overlay is unexpectedly read-only" >&2; exit 1 ;; esac
2902
+ lower_lines=$(/bin/grep -c " /run/uai/asdf-lower " /proc/mounts || true)
2903
+ [ "$lower_lines" = "1" ] || { echo "expected exactly one lower attachment, found $lower_lines" >&2; exit 1; }
2904
+ lopts=$(/bin/grep " /run/uai/asdf-lower " /proc/mounts | /usr/bin/cut -d" " -f4)
2905
+ case ",$lopts," in *",ro,"*) ;; *) echo "lower attachment is not read-only" >&2; exit 1 ;; esac
2906
+ [ ! -L /var/lib/uai/asdf-upper ] || { echo "upper is a symlink" >&2; exit 1; }
2907
+ [ -d /var/lib/uai/asdf-upper ] || { echo "upper is not a directory" >&2; exit 1; }
2908
+ [ "$(/usr/bin/stat -c %U /var/lib/uai/asdf-upper)" = "node" ] || { echo "upper is not node-owned" >&2; exit 1; }
2909
+ /usr/bin/test -d /opt/asdf-data/plugins
2910
+ # ADR-118: the store volume is a fresh ext4 with a root-owned root; hand
2911
+ # the pnpm store dir to node and prove the attachment from the guest
2912
+ # mount table — exactly one writable mount at the store path.
2913
+ store_lines=$(/bin/grep -c " /opt/uai/store " /proc/mounts || true)
2914
+ [ "$store_lines" = "1" ] || { echo "expected exactly one /opt/uai/store mount, found $store_lines" >&2; exit 1; }
2915
+ sopts=$(/bin/grep " /opt/uai/store " /proc/mounts | /usr/bin/cut -d" " -f4)
2916
+ case ",$sopts," in *",ro,"*) echo "store volume is unexpectedly read-only" >&2; exit 1 ;; esac
2917
+ /bin/mkdir -p /opt/uai/store/pnpm
2918
+ /bin/chown node:node /opt/uai/store /opt/uai/store/pnpm
2919
+ [ "$(/usr/bin/stat -c %U /opt/uai/store/pnpm)" = "node" ] || { echo "store dir is not node-owned" >&2; exit 1; }
2920
+ ' >/dev/null || overlay_rc=$?
2921
+ if [ "$overlay_rc" -ne 0 ]; then
2922
+ require_docker_after_failed_step "establish task runtime overlay"
2923
+ if ! quarantine_untrusted_app_container; then
2924
+ emit_err "CONTAINER_INIT_FAILED" \
2925
+ "Moondeck could not establish this task's runtime overlay, and could not prove the container stopped. Stop it manually before retrying." \
2926
+ "establish task runtime overlay"
2927
+ fi
2928
+ emit_err "CONTAINER_INIT_FAILED" \
2929
+ "Moondeck could not establish this task's runtime overlay on the Apple container runtime. Retry the task." \
2930
+ "establish task runtime overlay"
2931
+ fi
2932
+
2933
+ if [ "$project_count" -gt 0 ]; then
2934
+ step "CONTAINER_INIT_FAILED" "materialize task runtimes in container"
2935
+ materialization_rc=0
2936
+ "$CONTAINER_CLI" exec -u node -w /workspace "$app_container" \
2937
+ /usr/bin/env -i \
2938
+ HOME=/home/node \
2939
+ ASDF_DIR=/opt/asdf \
2940
+ ASDF_DATA_DIR=/opt/asdf-data \
2941
+ PATH=/opt/asdf-data/shims:/opt/asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
2942
+ /usr/bin/timeout --signal=TERM --kill-after=10s 600s \
2943
+ /usr/local/bin/uai-materialize-runtimes >&2 || materialization_rc=$?
2944
+ if [ "$materialization_rc" -ne 0 ]; then
2945
+ require_docker_after_failed_step "materialize task runtimes in container"
2946
+ if ! quarantine_untrusted_app_container; then
2947
+ emit_err "CONTAINER_INIT_FAILED" \
2948
+ "Runtime materialization failed and Moondeck could not prove the task container stopped. Stop it manually before retrying." \
2949
+ "materialize task runtimes in container"
2950
+ fi
2951
+ case "$materialization_rc" in
2952
+ 124|137)
2953
+ emit_err "CONTAINER_INIT_FAILED" \
2954
+ "Runtime materialization did not finish inside the station's 10-minute startup budget. Retry the task: Moondeck re-checks every required runtime and package manager before agents start." \
2955
+ "materialize task runtimes in container"
2956
+ ;;
2957
+ *)
2958
+ emit_err "CONTAINER_INIT_FAILED" \
2959
+ "Moondeck could not install and verify every runtime and package manager needed by this task. Check the project's .tool-versions and packageManager entries and the host's network availability, then retry." \
2960
+ "materialize task runtimes in container"
2961
+ ;;
2962
+ esac
2963
+ fi
2964
+ fi
2965
+ fi
2966
+
2967
+ # -----------------------------------------------------------------------------
2968
+ # 7. Container init — copy AI creds in, install deps, launch code-server.
2969
+ # -----------------------------------------------------------------------------
2970
+
2971
+ step "CONTAINER_INIT_FAILED" "uai-init (deps + code-server)"
2972
+
2973
+ # `docker exec` inherits the project environment from Compose. Override loader
2974
+ # and config variables before the first executable starts, then use `env -i`
2975
+ # plus absolute command paths so a project value cannot redirect this managed
2976
+ # credential operation to workspace code or another config directory.
2977
+ managed_gh_config_dir="/home/node/.config/gh"
2978
+ managed_exec_env=(
2979
+ -e "HOME=/home/node"
2980
+ -e "GH_CONFIG_DIR=$managed_gh_config_dir"
2981
+ -e "PATH=/usr/bin:/bin"
2982
+ -e "XDG_CONFIG_HOME="
2983
+ -e "XDG_DATA_HOME="
2984
+ -e "XDG_CACHE_HOME="
2985
+ -e "ASDF_DIR=/opt/asdf"
2986
+ -e "ASDF_DATA_DIR=/opt/asdf-data"
2987
+ -e "ASDF_CONFIG_FILE=/dev/null"
2988
+ -e "ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions"
2989
+ -e "ASDF_NODEJS_VERSION="
2990
+ -e "ASDF_PYTHON_VERSION="
2991
+ -e "ASDF_GOLANG_VERSION="
2992
+ -e "ASDF_RUBY_VERSION="
2993
+ -e "ASDF_RUST_VERSION="
2994
+ -e "ASDF_SKIP_RESHIM=1"
2995
+ -e "COREPACK_HOME=/opt/asdf-data/corepack"
2996
+ -e "COREPACK_DEFAULT_TO_LATEST=0"
2997
+ -e "COREPACK_ENV_FILE=0"
2998
+ -e "COREPACK_NPM_REGISTRY=https://registry.npmjs.org"
2999
+ -e "COREPACK_ENABLE_NETWORK=0"
3000
+ -e "COREPACK_ENABLE_AUTO_PIN=0"
3001
+ -e "COREPACK_ENABLE_DOWNLOAD_PROMPT=0"
3002
+ -e "COREPACK_ENABLE_PROJECT_SPEC=1"
3003
+ -e "COREPACK_ENABLE_STRICT=1"
3004
+ -e "COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=0"
3005
+ -e "COREPACK_INTEGRITY_CHECK=1"
3006
+ -e "NPM_CONFIG_PREFIX=$TASK_NPM_PREFIX"
3007
+ -e "npm_config_prefix=$TASK_NPM_PREFIX"
3008
+ -e "NPM_CONFIG_CACHE=$TASK_NPM_CACHE"
3009
+ -e "npm_config_cache=$TASK_NPM_CACHE"
3010
+ -e "NPM_CONFIG_LOGS_DIR=$TASK_NPM_CACHE/_logs"
3011
+ -e "npm_config_logs_dir=$TASK_NPM_CACHE/_logs"
3012
+ -e "NPM_CONFIG_UMASK=0002"
3013
+ -e "npm_config_umask=0002"
3014
+ -e "LD_PRELOAD="
3015
+ -e "LD_AUDIT="
3016
+ -e "LD_LIBRARY_PATH="
3017
+ -e "DYLD_INSERT_LIBRARIES="
3018
+ -e "DYLD_LIBRARY_PATH="
3019
+ -e "UAI_ENV_HOOK=0"
3020
+ -e "BASH_ENV="
3021
+ -e "ENV="
3022
+ -e "GIT_CONFIG="
3023
+ -e "GIT_CONFIG_GLOBAL="
3024
+ -e "GIT_CONFIG_SYSTEM="
3025
+ -e "GIT_CONFIG_NOSYSTEM="
3026
+ -e "GIT_CONFIG_COUNT=0"
3027
+ -e "GIT_EXEC_PATH="
3028
+ -e "GIT_SSH="
3029
+ -e "GIT_SSH_COMMAND="
3030
+ -e "GIT_ASKPASS="
3031
+ -e "SSH_ASKPASS="
3032
+ -e "GH_TOKEN="
3033
+ -e "GITHUB_TOKEN="
3034
+ )
3035
+ managed_clean_env=(
3036
+ /usr/bin/env -i
3037
+ "HOME=/home/node"
3038
+ "GH_CONFIG_DIR=$managed_gh_config_dir"
3039
+ "PATH=/usr/bin:/bin"
3040
+ )
3041
+
3042
+ # Runtime-bearing task initialization gets the same fixed environment as
3043
+ # Compose and agent launches. Unlike the root/control array above, this path
3044
+ # includes task-private global packages last so unique tools are usable while
3045
+ # they remain unable to shadow shared shims or system controls.
3046
+ task_runtime_exec_env=(
3047
+ -e "HOME=/home/node"
3048
+ -e "PATH=$TASK_RUNTIME_PATH"
3049
+ -e "XDG_CONFIG_HOME=/home/node/.config"
3050
+ -e "XDG_DATA_HOME=/home/node/.local/share"
3051
+ -e "XDG_CACHE_HOME=/home/node/.cache"
3052
+ -e "ASDF_DIR=/opt/asdf"
3053
+ -e "ASDF_DATA_DIR=/opt/asdf-data"
3054
+ -e "ASDF_CONFIG_FILE=/dev/null"
3055
+ -e "ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions"
3056
+ -e "ASDF_NODEJS_VERSION="
3057
+ -e "ASDF_PYTHON_VERSION="
3058
+ -e "ASDF_GOLANG_VERSION="
3059
+ -e "ASDF_RUBY_VERSION="
3060
+ -e "ASDF_RUST_VERSION="
3061
+ -e "ASDF_SKIP_RESHIM=1"
3062
+ -e "COREPACK_HOME=/opt/asdf-data/corepack"
3063
+ -e "COREPACK_DEFAULT_TO_LATEST=0"
3064
+ -e "COREPACK_ENV_FILE=0"
3065
+ -e "COREPACK_NPM_REGISTRY=https://registry.npmjs.org"
3066
+ -e "COREPACK_ENABLE_NETWORK=0"
3067
+ -e "COREPACK_ENABLE_AUTO_PIN=0"
3068
+ -e "COREPACK_ENABLE_DOWNLOAD_PROMPT=0"
3069
+ -e "COREPACK_ENABLE_PROJECT_SPEC=1"
3070
+ -e "COREPACK_ENABLE_STRICT=1"
3071
+ -e "COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=0"
3072
+ -e "COREPACK_INTEGRITY_CHECK=1"
3073
+ -e "NPM_CONFIG_PREFIX=$TASK_NPM_PREFIX"
3074
+ -e "npm_config_prefix=$TASK_NPM_PREFIX"
3075
+ -e "NPM_CONFIG_CACHE=$TASK_NPM_CACHE"
3076
+ -e "npm_config_cache=$TASK_NPM_CACHE"
3077
+ -e "NPM_CONFIG_LOGS_DIR=$TASK_NPM_CACHE/_logs"
3078
+ -e "npm_config_logs_dir=$TASK_NPM_CACHE/_logs"
3079
+ -e "NPM_CONFIG_UMASK=0002"
3080
+ -e "npm_config_umask=0002"
3081
+ -e "LD_PRELOAD="
3082
+ -e "LD_AUDIT="
3083
+ -e "LD_LIBRARY_PATH="
3084
+ -e "NODE_OPTIONS="
3085
+ -e "NODE_PATH="
3086
+ -e "PS4="
3087
+ -e "UAI_ENV_MODE=$UAI_PROJECT_ENV_MODE"
3088
+ -e "UAI_ENV_HOOK=0"
3089
+ -e "SHELL=/usr/bin/zsh"
3090
+ -e "BASH_ENV="
3091
+ -e "ENV="
3092
+ )
3093
+ editor_theme=$(jq -r '.UAI_EDITOR_THEME // empty' <<<"$preview_env_raw")
3094
+ if [ -n "$editor_theme" ]; then
3095
+ task_runtime_exec_env+=( -e "UAI_EDITOR_THEME=$editor_theme" )
3096
+ fi
3097
+ # ADR-118 (apple only): every runtime-bearing exec — uai-init's installs
3098
+ # above all — must aim pnpm at the task's block volume, exactly like the
3099
+ # container env-file. Docker execs keep the historical environment.
3100
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
3101
+ task_runtime_exec_env+=(
3102
+ -e "NPM_CONFIG_STORE_DIR=$TASK_STORE_DIR"
3103
+ -e "npm_config_store_dir=$TASK_STORE_DIR"
3104
+ )
3105
+ fi
3106
+
3107
+ # ADR-153 secrets proxy, first step: the CA bundle goes into the task's trust
3108
+ # store before anything else runs in it (the forwarder, uai-init, agents). The
3109
+ # runner wrote the bundle and its fixed install script to
3110
+ # UAI_SECRETS_PROXY_DIR (public material only); unset, the proxy is off and
3111
+ # nothing is installed. A failure leaves the task up without the proxy's
3112
+ # trust, and the runner withholds whatever would have needed it.
3113
+ secrets_proxy_trust=skipped
3114
+ secrets_proxy_pins=skipped
3115
+ secrets_proxy_dir=${UAI_SECRETS_PROXY_DIR:-}
3116
+ # Each outcome is also written back to the runner's directory the moment it
3117
+ # is known: uai-init's shells read the env before task-up returns, and the
3118
+ # runner gives the trust env only once the CA is really in place.
3119
+ secrets_proxy_report() {
3120
+ [ -z "$secrets_proxy_dir" ] || printf '%s\n' "$2" > "$secrets_proxy_dir/$1" || true
3121
+ }
3122
+ if [ "$UAI_PROJECT_ENV_MODE" = "loader" ] && [ -n "$secrets_proxy_dir" ]; then
3123
+ if [ -f "$secrets_proxy_dir/ca-install.sh" ] && [ -f "$secrets_proxy_dir/ca-bundle.pem" ] \
3124
+ && crt exec -i -u root "${managed_exec_env[@]}" "$app_container" \
3125
+ /bin/sh -c "$(cat "$secrets_proxy_dir/ca-install.sh")" \
3126
+ < "$secrets_proxy_dir/ca-bundle.pem" >/dev/null; then
3127
+ secrets_proxy_trust=installed
3128
+ else
3129
+ secrets_proxy_trust=failed
3130
+ log "warning: could not install the secrets proxy CA in ${app_container}; its proxied keys stay withheld"
3131
+ fi
3132
+ secrets_proxy_report trust "$secrets_proxy_trust"
3133
+ fi
3134
+
3135
+ # Loader tasks have two pieces of root-owned bootstrap inside the task:
3136
+ # immutable shell entry-point stubs, and a generation-authenticated loopback
3137
+ # forwarder. Maintenance commands above and below keep UAI_ENV_HOOK=0, so the
3138
+ # stubs cannot make runner-owned execs consume project configuration.
3139
+ if [ "$UAI_PROJECT_ENV_MODE" = "loader" ]; then
3140
+ task_gateway_generation=${UAI_TASK_GATEWAY_GENERATION:-}
3141
+ task_gateway_host=${UAI_TASK_GATEWAY_HOST:-}
3142
+ task_gateway_port=${UAI_MCP_GATEWAY_PORT:-5877}
3143
+ if [[ ! "$task_gateway_generation" =~ ^[A-Za-z0-9_-]{16,64}$ ]] \
3144
+ || [[ ! "$task_gateway_host" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,254}$ ]] \
3145
+ || [[ ! "$task_gateway_port" =~ ^[0-9]{1,5}$ ]] \
3146
+ || [ "$task_gateway_port" -lt 1 ] \
3147
+ || [ "$task_gateway_port" -gt 65535 ]; then
3148
+ emit_err "CONTAINER_INIT_FAILED" \
3149
+ "Moondeck could not establish this task's authenticated env channel. Retry the task after updating the host." \
3150
+ "validate task env gateway identity"
3151
+ fi
3152
+
3153
+ # A heredoc nested inside `$(cat ...)` containing `case` arms is parsed as
3154
+ # shell syntax by Bash 3.2 (the system Bash on macOS) and makes every task-up
3155
+ # fail before it runs. `read -d ''` assigns the same literal without a
3156
+ # command-substitution parser context.
3157
+ env_stub_installer=''
3158
+ IFS= read -r -d '' env_stub_installer <<'UAI_ENV_STUB_INSTALLER' || true
3159
+ set -eu
3160
+ ensure_block() {
3161
+ file=$1
3162
+ line=$2
3163
+ case "$file" in
3164
+ /etc/zsh/zshenv | /etc/zsh/zshrc | /etc/bash.bashrc | /etc/profile.d/uai-env.sh) ;;
3165
+ *) exit 1 ;;
3166
+ esac
3167
+ [ ! -L "$file" ] || exit 1
3168
+ [ ! -e "$file" ] || [ -f "$file" ] || exit 1
3169
+ directory=${file%/*}
3170
+ [ -d "$directory" ] && [ ! -L "$directory" ] || exit 1
3171
+ temporary=$(/usr/bin/mktemp "$directory/.uai-env-stub.XXXXXX")
3172
+ trap '/bin/rm -f -- "$temporary"' EXIT
3173
+ if [ -f "$file" ]; then
3174
+ /usr/bin/awk '
3175
+ $0 == "# >>> uai env loader >>>" { skipping = 1; next }
3176
+ $0 == "# <<< uai env loader <<<" { skipping = 0; next }
3177
+ !skipping { print }
3178
+ ' "$file" > "$temporary"
3179
+ fi
3180
+ {
3181
+ printf '%s\n' '# >>> uai env loader >>>'
3182
+ printf '%s\n' "$line"
3183
+ printf '%s\n' '# <<< uai env loader <<<'
3184
+ } >> "$temporary"
3185
+ /bin/chown root:root "$temporary"
3186
+ /bin/chmod 0644 "$temporary"
3187
+ /bin/mv -f -- "$temporary" "$file"
3188
+ trap - EXIT
3189
+ }
3190
+
3191
+ ensure_block /etc/zsh/zshenv '[ "${UAI_ENV_HOOK-0}" != 1 ] || { . /usr/local/share/uai/env-hook.sh; __uai_env_sync; }'
3192
+ ensure_block /etc/zsh/zshrc '[ "${UAI_ENV_HOOK-0}" != 1 ] || { type __uai_env_install_zsh_preexec >/dev/null 2>&1 || . /usr/local/share/uai/env-hook.sh; __uai_env_install_zsh_preexec; }'
3193
+ ensure_block /etc/bash.bashrc '[ "${UAI_ENV_HOOK-0}" != 1 ] || { . /usr/local/share/uai/env-hook.sh; __uai_env_sync; __uai_env_install_bash_preexec; }'
3194
+ ensure_block /etc/profile.d/uai-env.sh '[ "${UAI_ENV_HOOK-0}" != 1 ] || { . /usr/local/share/uai/env-hook.sh; if [ -z "${BASH_EXECUTION_STRING-}" ] || [ -z "${BASH_ENV-}" ]; then __uai_env_sync; fi; }'
3195
+ UAI_ENV_STUB_INSTALLER
3196
+ if ! crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3197
+ /bin/bash -c "$env_stub_installer" >/dev/null; then
3198
+ emit_err "CONTAINER_INIT_FAILED" \
3199
+ "Moondeck could not install the root-owned task env shell hooks. Retry the task; project env remains withheld until this succeeds." \
3200
+ "install task env shell hooks"
3201
+ fi
3202
+
3203
+ task_forwarder_node=/usr/local/bin/node
3204
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
3205
+ task_forwarder_node="/run/uai/asdf-lower/installs/nodejs/$STANDARD_DEFAULT_NODE_VERSION/bin/node"
3206
+ fi
3207
+
3208
+ task_forwarder_launcher=''
3209
+ IFS= read -r -d '' task_forwarder_launcher <<'UAI_TASK_FORWARDER_LAUNCHER' || true
3210
+ set -eu
3211
+ task_id=$1
3212
+ generation=$2
3213
+ gateway_host=$3
3214
+ gateway_port=$4
3215
+ node_path=$5
3216
+ case "$gateway_port" in ''|*[!0-9]*) exit 1 ;; esac
3217
+ [ "$gateway_port" -ge 1 ] && [ "$gateway_port" -le 65535 ] || exit 1
3218
+ case "$node_path" in
3219
+ /usr/local/bin/node) ;;
3220
+ /run/uai/asdf-lower/installs/nodejs/22.11.0/bin/node)
3221
+ [ ! -L "$node_path" ] && [ -f "$node_path" ] && [ -x "$node_path" ] \
3222
+ || exit 1
3223
+ ;;
3224
+ *) exit 1 ;;
3225
+ esac
3226
+ state_dir=/run/uai/task-forwarder
3227
+ pid_file=$state_dir/pid
3228
+ ready_file=$state_dir/ready
3229
+ error_file=$state_dir/error.log
3230
+ umask 077
3231
+ [ ! -L "$state_dir" ] || exit 1
3232
+ [ ! -e "$state_dir" ] || [ -d "$state_dir" ] || exit 1
3233
+ /usr/bin/install -d -o root -g root -m 0700 "$state_dir"
3234
+
3235
+ if [ -f "$pid_file" ] && [ ! -L "$pid_file" ]; then
3236
+ old_pid=$(/bin/cat "$pid_file" 2>/dev/null || true)
3237
+ case "$old_pid" in ''|*[!0-9]*) old_pid= ;; esac
3238
+ if [ -n "$old_pid" ] && /bin/kill -0 "$old_pid" 2>/dev/null; then
3239
+ old_uid=$(/usr/bin/awk '/^Uid:/ { print $2; exit }' "/proc/$old_pid/status" 2>/dev/null || true)
3240
+ old_args=$(/usr/bin/tr '\000' '\n' < "/proc/$old_pid/cmdline" 2>/dev/null || true)
3241
+ # /run survives a Docker stop/start. Its recorded pid may have been
3242
+ # reused by uai-init or another innocent process; treat that record as
3243
+ # stale and never signal a process unless both uid and argv prove it is
3244
+ # this task's prior forwarder.
3245
+ if [ "$old_uid" = 65532 ] \
3246
+ && printf '%s\n' "$old_args" | /bin/grep -Fxq '/usr/local/share/uai/uai-task-forwarder.mjs' \
3247
+ && printf '%s\n' "$old_args" | /bin/grep -Fxq "$task_id"; then
3248
+ /bin/kill -TERM "$old_pid"
3249
+ attempts=0
3250
+ while /bin/kill -0 "$old_pid" 2>/dev/null && [ "$attempts" -lt 50 ]; do
3251
+ /bin/sleep 0.1
3252
+ attempts=$((attempts + 1))
3253
+ done
3254
+ if /bin/kill -0 "$old_pid" 2>/dev/null; then
3255
+ /bin/kill -KILL "$old_pid"
3256
+ fi
3257
+ fi
3258
+ fi
3259
+ fi
3260
+ /bin/rm -f -- "$pid_file" "$ready_file" "$error_file"
3261
+
3262
+ # Non-interactive Bash otherwise gives an asynchronous command /dev/null as
3263
+ # stdin. Preserve the docker/container exec pipe explicitly, then close the
3264
+ # extra descriptor in both parent and child after fd 0 has been duplicated.
3265
+ exec 3<&0
3266
+ /usr/bin/env -i PATH=/usr/bin:/bin \
3267
+ /usr/bin/setsid \
3268
+ /usr/bin/setpriv \
3269
+ --reuid=65532 \
3270
+ --regid=65532 \
3271
+ --clear-groups \
3272
+ --bounding-set=-all \
3273
+ --inh-caps=-all \
3274
+ --ambient-caps=-all \
3275
+ --no-new-privs \
3276
+ "$node_path" \
3277
+ /usr/local/share/uai/uai-task-forwarder.mjs \
3278
+ --task-id "$task_id" \
3279
+ --generation "$generation" \
3280
+ --gateway-host "$gateway_host" \
3281
+ --gateway-port "$gateway_port" \
3282
+ --listen-port 5877 \
3283
+ <&3 3<&- >"$ready_file" 2>"$error_file" &
3284
+ forwarder_pid=$!
3285
+ exec 3<&-
3286
+ printf '%s\n' "$forwarder_pid" > "$pid_file"
3287
+
3288
+ attempts=0
3289
+ while [ "$attempts" -lt 50 ]; do
3290
+ /bin/kill -0 "$forwarder_pid" 2>/dev/null || exit 1
3291
+ if [ -f "$ready_file" ] \
3292
+ && [ "$(/bin/cat "$ready_file" 2>/dev/null || true)" = READY ]; then
3293
+ exit 0
3294
+ fi
3295
+ /bin/sleep 0.1
3296
+ attempts=$((attempts + 1))
3297
+ done
3298
+ exit 1
3299
+ UAI_TASK_FORWARDER_LAUNCHER
3300
+
3301
+ base64_decode_args=()
3302
+ if printf '' | base64 --decode >/dev/null 2>&1; then
3303
+ base64_decode_args=(--decode)
3304
+ elif printf '' | base64 -D >/dev/null 2>&1; then
3305
+ base64_decode_args=(-D)
3306
+ else
3307
+ emit_err "CONTAINER_INIT_FAILED" \
3308
+ "Moondeck could not decode the task env gateway credential on this host." \
3309
+ "start task env forwarder"
3310
+ fi
3311
+ if ! printf '%s' "$task_gateway_secret_b64" \
3312
+ | base64 "${base64_decode_args[@]}" \
3313
+ | crt exec -i -u root "${managed_exec_env[@]}" "$app_container" \
3314
+ /bin/bash -c "$task_forwarder_launcher" uai-task-forwarder \
3315
+ "$task_id" "$task_gateway_generation" "$task_gateway_host" \
3316
+ "$task_gateway_port" "$task_forwarder_node" \
3317
+ >/dev/null; then
3318
+ unset task_gateway_secret_b64
3319
+ emit_err "CONTAINER_INIT_FAILED" \
3320
+ "Moondeck could not start the authenticated task env forwarder. Retry the task; project env remains withheld until this succeeds." \
3321
+ "start task env forwarder"
3322
+ fi
3323
+ unset task_gateway_secret_b64
3324
+ fi
3325
+
3326
+ # ADR-153 secrets proxy, second step, now that the forwarder listens: the NAT
3327
+ # chain that sends the pin address's :443 to the forwarder, and the names
3328
+ # pinned to it. In the runner's order (routing-order): the chain before the
3329
+ # names when pinning, the names first when there is nothing to pin, so a
3330
+ # failure part-way never leaves a name pointing at a pin with nothing behind
3331
+ # it. The NAT step needs NET_ADMIN. A Docker container does not have it: the
3332
+ # step is the one privileged exec Uai makes, and only that process gets it.
3333
+ # An Apple task's VM was started with it, so there a root exec has it (and
3334
+ # the CLI has no --privileged).
3335
+ secrets_proxy_chain() {
3336
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
3337
+ set -- crt exec -u root
3338
+ else
3339
+ set -- docker exec --privileged -u root
3340
+ fi
3341
+ "$@" "${managed_exec_env[@]}" "$app_container" \
3342
+ /bin/sh -c "$(cat "$secrets_proxy_dir/chain.sh")" >/dev/null \
3343
+ || { log "warning: could not redirect the secrets proxy's names in ${app_container}"; return 1; }
3344
+ }
3345
+ secrets_proxy_names() {
3346
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3347
+ /bin/sh -c "$(cat "$secrets_proxy_dir/pins.sh")" >/dev/null \
3348
+ || { log "warning: could not pin the secrets proxy's names in ${app_container}"; return 1; }
3349
+ }
3350
+ if [ "$secrets_proxy_trust" = "installed" ]; then
3351
+ secrets_proxy_order=$(cat "$secrets_proxy_dir/routing-order" 2>/dev/null || true)
3352
+ # A missing script would run as an empty, successful program.
3353
+ if [ ! -f "$secrets_proxy_dir/pins.sh" ] || [ ! -f "$secrets_proxy_dir/chain.sh" ]; then
3354
+ secrets_proxy_pins=failed
3355
+ log "warning: the secrets proxy's pin scripts are missing for ${app_container}"
3356
+ elif [ "$secrets_proxy_order" = "pin" ]; then
3357
+ if secrets_proxy_chain && secrets_proxy_names; then secrets_proxy_pins=installed; else secrets_proxy_pins=failed; fi
3358
+ elif [ "$secrets_proxy_order" = "unpin" ]; then
3359
+ if secrets_proxy_names && secrets_proxy_chain; then secrets_proxy_pins=installed; else secrets_proxy_pins=failed; fi
3360
+ else
3361
+ secrets_proxy_pins=failed
3362
+ log "warning: the secrets proxy's routing order is missing for ${app_container}"
3363
+ fi
3364
+ secrets_proxy_report pins "$secrets_proxy_pins"
3365
+ fi
3366
+
3367
+ # OpenCode and code-server share ~/.local/share. OpenCode credentials are
3368
+ # copied as root below, so creating only its leaf with `mkdir -p` can leave the
3369
+ # shared parent root-owned (live 2026-08-04). Then node cannot create
3370
+ # code-server's User profile and the editor silently falls back to its light,
3371
+ # full-chrome defaults. Repair both explicitly on every start/resume; GNU
3372
+ # `install -d` also corrects an existing directory with the wrong owner.
3373
+ if ! crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3374
+ /usr/bin/install -d -o node -g node -m 0755 \
3375
+ /home/node/.local/share \
3376
+ /home/node/.local/share/opencode >/dev/null; then
3377
+ emit_err "CONTAINER_INIT_FAILED" \
3378
+ "Moondeck could not prepare the task's node-owned application data directory. Retry the task; the editor cannot load its settings until this succeeds." \
3379
+ "prepare container application data"
3380
+ fi
3381
+
3382
+ # Git rejects a repository owned by another uid even when its shared group is
3383
+ # writable. Trust only the selected worktrees in this task container—never `*`
3384
+ # and never the host cache. The task-private ~/.gitconfig makes these entries
3385
+ # local to this container, and the read-before-add keeps resumes idempotent.
3386
+ step "CONTAINER_INIT_FAILED" "trust task Git worktrees"
3387
+ container_safe_dirs=$(crt exec -u node "${managed_exec_env[@]}" \
3388
+ "$app_container" "${managed_clean_env[@]}" /usr/bin/git config --global \
3389
+ --get-all safe.directory 2>/dev/null || true)
3390
+ while IFS= read -r safe_project; do
3391
+ [ -n "$safe_project" ] || continue
3392
+ safe_project_id=$(jq -r '.id' <<<"$safe_project")
3393
+ safe_project_slug=$(jq -r '.slug' <<<"$safe_project")
3394
+ for safe_worktree in \
3395
+ "/workspace/$safe_project_slug" \
3396
+ "$task_workspace/$safe_project_slug" \
3397
+ "$task_uai_dir/repos/$safe_project_id.git" \
3398
+ "$task_workspace_physical/$safe_project_slug" \
3399
+ "$task_uai_dir_physical/repos/$safe_project_id.git"; do
3400
+ if ! printf '%s\n' "$container_safe_dirs" \
3401
+ | grep -Fqx -- "$safe_worktree"; then
3402
+ if ! crt exec -u node "${managed_exec_env[@]}" "$app_container" \
3403
+ "${managed_clean_env[@]}" /usr/bin/git config --global --add \
3404
+ safe.directory "$safe_worktree" >/dev/null; then
3405
+ emit_err "CONTAINER_INIT_FAILED" \
3406
+ "Moondeck could not mark $safe_worktree as this task's trusted Git path" \
3407
+ "trust task Git worktrees"
3408
+ fi
3409
+ container_safe_dirs="${container_safe_dirs}"$'\n'"${safe_worktree}"
3410
+ fi
3411
+ done
3412
+ done < <(jq -c '.[]' <<<"$projects_json")
3413
+
3414
+ # A running container is not sufficient proof that its linked worktrees are
3415
+ # usable. In particular, Git may have canonicalized a symlinked host workspace
3416
+ # root while Compose mounted only its lexical spelling. Verify the actual path
3417
+ # agents use before credentials, dependency installation, or status=running.
3418
+ step "CONTAINER_INIT_FAILED" "verify task Git worktrees"
3419
+ while IFS= read -r verify_project; do
3420
+ [ -n "$verify_project" ] || continue
3421
+ verify_project_slug=$(jq -r '.slug' <<<"$verify_project")
3422
+ verify_is_worktree=""
3423
+ if ! verify_is_worktree=$(crt exec -u node "${managed_exec_env[@]}" \
3424
+ "$app_container" "${managed_clean_env[@]}" /usr/bin/git \
3425
+ -C "/workspace/$verify_project_slug" rev-parse \
3426
+ --is-inside-work-tree 2>/dev/null) \
3427
+ || [ "$verify_is_worktree" != "true" ]; then
3428
+ emit_err "CONTAINER_INIT_FAILED" \
3429
+ "Moondeck could not open $verify_project_slug as a Git worktree inside the task container. Retry the task after checking the host workspace path." \
3430
+ "verify task Git worktree ($verify_project_slug)"
3431
+ fi
3432
+ done < <(jq -c '.[]' <<<"$projects_json")
3433
+
3434
+ # A failed/retried start can preserve a container that still holds a previous
3435
+ # account. Erase that Uai-managed state first for both connected and
3436
+ # disconnected starts; otherwise a no-credential resume could briefly reuse a
3437
+ # stale token before the asynchronous reconciler catches up.
3438
+ if ! crt exec -u node "${managed_exec_env[@]}" "$app_container" \
3439
+ "${managed_clean_env[@]}" /bin/sh -c '
3440
+ set -eu
3441
+ /bin/rm -f "$GH_CONFIG_DIR/hosts.yml"
3442
+ for key in \
3443
+ credential.https://github.com.helper \
3444
+ credential.https://gist.github.com.helper
3445
+ do
3446
+ /usr/bin/git config --file "$HOME/.gitconfig" --unset-all "$key" \
3447
+ >/dev/null 2>&1 || true
3448
+ if /usr/bin/git config --file "$HOME/.gitconfig" --get-all "$key" \
3449
+ >/dev/null 2>&1; then
3450
+ exit 1
3451
+ fi
3452
+ done
3453
+ test ! -e "$GH_CONFIG_DIR/hosts.yml"
3454
+ ' >/dev/null; then
3455
+ require_docker_after_failed_step "clear container GitHub credential"
3456
+ emit_err "GITHUB_AUTH_UNAVAILABLE" \
3457
+ "Moondeck could not clear the previous managed GitHub credential from the task container. Retry the task before doing GitHub work." \
3458
+ "clear container GitHub credential"
3459
+ fi
3460
+
3461
+ # The task container must have the same connected GitHub principal BEFORE
3462
+ # uai-init runs package managers: private Git submodules/dependencies may be
3463
+ # fetched during install. Read the token back from the private cache and stream
3464
+ # only its password field directly into `gh auth login`; it never enters argv,
3465
+ # environment, a shell variable, Compose, or disk.
3466
+ if [ -n "$github_credential_socket" ]; then
3467
+ if ! printf 'protocol=https\nhost=github.com\n\n' \
3468
+ | git_with_github_credential credential fill \
3469
+ | sed -n 's/^password=//p' \
3470
+ | crt exec -i -u node "${managed_exec_env[@]}" "$app_container" \
3471
+ "${managed_clean_env[@]}" /usr/bin/gh auth login --with-token \
3472
+ --hostname github.com --git-protocol https >/dev/null; then
3473
+ require_docker_after_failed_step "configure container GitHub credential"
3474
+ emit_err "GITHUB_AUTH_UNAVAILABLE" \
3475
+ "GitHub is connected on this host, but Moondeck could not install that credential in the task container. Retry the task; if it persists, reconnect GitHub on this host." \
3476
+ "configure container GitHub credential"
3477
+ fi
3478
+ if ! crt exec -u node "${managed_exec_env[@]}" "$app_container" \
3479
+ "${managed_clean_env[@]}" /usr/bin/gh auth setup-git \
3480
+ --hostname github.com >/dev/null; then
3481
+ require_docker_after_failed_step "configure container Git credential helper"
3482
+ emit_err "GITHUB_AUTH_UNAVAILABLE" \
3483
+ "GitHub is connected on this host, but Moondeck could not configure Git HTTPS in the task container. Retry the task; if it persists, reconnect GitHub on this host." \
3484
+ "configure container Git credential helper"
3485
+ fi
3486
+ fi
3487
+
3488
+ # Copy `.claude.json` into the container instead of bind-mounting it.
3489
+ # Single-file bind mounts on Docker Desktop macOS are fragile — claude
3490
+ # CLI atomically rewrites the file on every start, which can invalidate
3491
+ # the bind mount and leave a stale dentry, after which claude refuses to
3492
+ # start. docker cp gives the container its own stable copy. Best-effort.
3493
+ if [ "$uai_on_cloud_credentials" != "1" ] && [ -f "$UAI_OWNER_HOME/.claude.json" ]; then
3494
+ crt cp "$UAI_OWNER_HOME/.claude.json" \
3495
+ "$app_container":/home/node/.claude.json >/dev/null 2>&1 \
3496
+ || log "warning: crt cp of .claude.json failed; claude may need re-login"
3497
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3498
+ /usr/bin/chown node:node /home/node/.claude.json >/dev/null 2>&1 || true
3499
+ fi
3500
+
3501
+ # Copy Codex credentials/config into a task-private /home/node/.codex.
3502
+ # Do not mount the host ~/.codex writable: Codex stores live SQLite state
3503
+ # there, and concurrent host/container access can corrupt it.
3504
+ # Failures here are REAL errors (the container is running at task-up):
3505
+ # docker cp preserves the host uid/gid, so a skipped chown leaves 0600
3506
+ # files owned by e.g. macOS 501:20 that the container's node (1000:1000)
3507
+ # cannot read — Codex then dies at startup.
3508
+ if crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3509
+ /usr/bin/mkdir -p /home/node/.codex >/dev/null; then
3510
+ for codex_item in auth.json config.toml AGENTS.md version.json installation_id rules; do
3511
+ [ "$uai_on_cloud_credentials" = "1" ] && continue
3512
+ if [ -e "$UAI_OWNER_HOME/.codex/$codex_item" ]; then
3513
+ # ADR-135: the task's copy must not carry the rotating refresh token —
3514
+ # N copies racing to refresh it revoke the whole family. Replace it with
3515
+ # the broker placeholder (the field must exist; codex refuses a missing
3516
+ # one). The host is the only refresher and re-pushes on refresh.
3517
+ if [ "$codex_item" = auth.json ] && command -v jq >/dev/null 2>&1; then
3518
+ brokered_auth="$(mktemp "${TMPDIR:-/tmp}/uai-codex-auth.XXXXXX")"
3519
+ if jq 'if (.tokens|type) == "object" and (.tokens.refresh_token|type) == "string" then .tokens.refresh_token = "uai-broker-managed-no-refresh" else . end' \
3520
+ "$UAI_OWNER_HOME/.codex/auth.json" > "$brokered_auth" 2>/dev/null && [ -s "$brokered_auth" ]; then
3521
+ crt cp "$brokered_auth" "$app_container":/home/node/.codex/auth.json >/dev/null \
3522
+ || log "ERROR: crt cp of brokered .codex/auth.json failed; Codex in this task may not authenticate"
3523
+ rm -f "$brokered_auth"
3524
+ continue
3525
+ fi
3526
+ rm -f "$brokered_auth"
3527
+ log "warning: could not broker .codex/auth.json; copying it verbatim"
3528
+ fi
3529
+ crt cp "$UAI_OWNER_HOME/.codex/$codex_item" \
3530
+ "$app_container":/home/node/.codex/ >/dev/null \
3531
+ || log "ERROR: crt cp of .codex/$codex_item failed; Codex in this task may not authenticate"
3532
+ fi
3533
+ done
3534
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3535
+ /usr/bin/chown -R node:node /home/node/.codex >/dev/null \
3536
+ || log "ERROR: chown of /home/node/.codex failed — 0600 credentials remain unreadable by node. Fix (docker mode; use the bundled container CLI on Apple hosts): docker exec -u root -e PATH=/usr/bin:/bin $app_container /usr/bin/chown -R node:node /home/node/.codex"
3537
+ else
3538
+ log "ERROR: mkdir /home/node/.codex failed in $app_container; Codex credentials were not injected"
3539
+ fi
3540
+
3541
+ # Copy Kimi Code subscription/config into a task-private /home/node/.kimi-code.
3542
+ # The Linux `kimi` binary is baked into the image; here we copy ONLY the
3543
+ # arch-independent config + credential (never the host's macOS bin/, nor live
3544
+ # session state) so the container's kimi runs on the operator's subscription.
3545
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3546
+ /usr/bin/mkdir -p /home/node/.kimi-code/credentials >/dev/null 2>&1 || true
3547
+ if [ "$uai_on_cloud_credentials" != "1" ] && [ -e "$UAI_OWNER_HOME/.kimi-code/config.toml" ]; then
3548
+ crt cp "$UAI_OWNER_HOME/.kimi-code/config.toml" \
3549
+ "$app_container":/home/node/.kimi-code/config.toml >/dev/null 2>&1 \
3550
+ || log "warning: crt cp of .kimi-code/config.toml failed; kimi may need re-login"
3551
+ fi
3552
+ if [ "$uai_on_cloud_credentials" != "1" ] && [ -e "$UAI_OWNER_HOME/.kimi-code/device_id" ]; then
3553
+ crt cp "$UAI_OWNER_HOME/.kimi-code/device_id" \
3554
+ "$app_container":/home/node/.kimi-code/device_id >/dev/null 2>&1 || true
3555
+ fi
3556
+ if [ "$uai_on_cloud_credentials" != "1" ] && [ -e "$UAI_OWNER_HOME/.kimi-code/credentials/kimi-code.json" ]; then
3557
+ crt cp "$UAI_OWNER_HOME/.kimi-code/credentials/kimi-code.json" \
3558
+ "$app_container":/home/node/.kimi-code/credentials/kimi-code.json >/dev/null 2>&1 \
3559
+ || log "warning: crt cp of .kimi-code credential failed; kimi may need re-login"
3560
+ fi
3561
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3562
+ /usr/bin/chown -R node:node /home/node/.kimi-code >/dev/null 2>&1 || true
3563
+
3564
+ # Copy Grok subscription/config into a task-private /home/node/.grok. The Linux
3565
+ # `grok` binary is baked into the image; here we copy the arch-independent
3566
+ # auth + config. auth.json is an OIDC token: the task's copy gets the broker
3567
+ # placeholder in every entry's refresh_token (ADR-135) so it can never rotate
3568
+ # the family; the host is the only refresher.
3569
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3570
+ /usr/bin/mkdir -p /home/node/.grok >/dev/null 2>&1 || true
3571
+ for grok_item in auth.json config.toml models_cache.json agent_id; do
3572
+ [ "$uai_on_cloud_credentials" = "1" ] && continue
3573
+ if [ -e "$UAI_OWNER_HOME/.grok/$grok_item" ]; then
3574
+ if [ "$grok_item" = auth.json ] && command -v jq >/dev/null 2>&1; then
3575
+ brokered_auth="$(mktemp "${TMPDIR:-/tmp}/uai-grok-auth.XXXXXX")"
3576
+ if jq 'with_entries(if (.value|type) == "object" and (.value.refresh_token|type) == "string" then .value.refresh_token = "uai-broker-managed-no-refresh" else . end)' \
3577
+ "$UAI_OWNER_HOME/.grok/auth.json" > "$brokered_auth" 2>/dev/null && [ -s "$brokered_auth" ]; then
3578
+ crt cp "$brokered_auth" "$app_container":/home/node/.grok/auth.json >/dev/null 2>&1 \
3579
+ || log "warning: crt cp of brokered .grok/auth.json failed; grok may need re-login"
3580
+ rm -f "$brokered_auth"
3581
+ continue
3582
+ fi
3583
+ rm -f "$brokered_auth"
3584
+ log "warning: could not broker .grok/auth.json; copying it verbatim"
3585
+ fi
3586
+ crt cp "$UAI_OWNER_HOME/.grok/$grok_item" \
3587
+ "$app_container":/home/node/.grok/ >/dev/null 2>&1 \
3588
+ || log "warning: crt cp of .grok/$grok_item failed; grok may need re-login"
3589
+ fi
3590
+ done
3591
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3592
+ /usr/bin/chown -R node:node /home/node/.grok >/dev/null 2>&1 || true
3593
+
3594
+ # Copy OpenCode provider auth into a task-private /home/node/.local/share/opencode
3595
+ # (ADR-077), same per-task-writable-copy pattern as Codex. The Linux `opencode`
3596
+ # binary is baked into the image; here we copy the arch-independent auth+config.
3597
+ # ADR-076 EXTRA accounts (isolated dirs) are copied later from the host-agent
3598
+ # (provisionEngineAccounts) since they live in the sealed DB, not on disk here.
3599
+ # The shared parent and this leaf were created with node ownership above.
3600
+ for oc_item in auth.json config.json; do
3601
+ [ "$uai_on_cloud_credentials" = "1" ] && continue
3602
+ if [ -e "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" ]; then
3603
+ crt cp "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" \
3604
+ "$app_container":/home/node/.local/share/opencode/ >/dev/null 2>&1 \
3605
+ || log "warning: crt cp of opencode/$oc_item failed; opencode may need re-login"
3606
+ fi
3607
+ done
3608
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3609
+ /usr/bin/chown -R node:node /home/node/.local/share/opencode >/dev/null 2>&1 || true
3610
+
3611
+ # Copy the resolved SSH identity into the container for commit/tag signing and
3612
+ # as the no-GitHub-connection transport fallback. Connected users clone, fetch,
3613
+ # and push over HTTPS through their gh credential instead.
3614
+ # Best-effort: signing + the SSH fallback stay off if the identity is absent —
3615
+ # but every failure is LOGGED (a silent miss here reads as a GitHub
3616
+ # permissions problem hours later; live 2026-07-21). The channel-ensure path
3617
+ # (ensureTaskSshIdentity) re-asserts all of this on reconnects.
3618
+ if [ -f "$uai_identity_key" ]; then
3619
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3620
+ /usr/bin/mkdir -p /home/node/.ssh >/dev/null 2>&1 \
3621
+ || log "warning: ssh setup: mkdir ~/.ssh failed in $app_container"
3622
+ crt cp "$uai_identity_key" \
3623
+ "$app_container":/home/node/.ssh/id_ed25519 >/dev/null 2>&1 \
3624
+ || log "warning: ssh setup: key copy failed — signing and SSH fallback unavailable"
3625
+ crt cp "${uai_identity_key}.pub" \
3626
+ "$app_container":/home/node/.ssh/id_ed25519.pub >/dev/null 2>&1 \
3627
+ || log "warning: ssh setup: pubkey copy failed (signing will be off)"
3628
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" /bin/sh -c \
3629
+ '/usr/bin/chown -R node:node /home/node/.ssh && /usr/bin/chmod 700 /home/node/.ssh && /usr/bin/chmod 600 /home/node/.ssh/id_ed25519' \
3630
+ >/dev/null 2>&1 \
3631
+ || log "warning: ssh setup: chown/chmod failed — key may be unreadable by node"
3632
+ fi
3633
+
3634
+ # uai-init (workspace deps + code-server) is re-runnable, and its death must
3635
+ # not error a task whose container is already up (graceful degradation): a
3636
+ # SIGKILLed exec session (observed live 2026-07-21, exit 137 with no timeout
3637
+ # anywhere on this path) used to fail the whole task over a step anyone can
3638
+ # re-run. Retry once, then come up DEGRADED with a warning the host surfaces
3639
+ # as a system note in the task feed.
3640
+ quarantine_runtime_authority_violation() {
3641
+ quarantine_untrusted_app_container
3642
+ }
3643
+
3644
+ # ADR-154 §12: the runner writes a task on cloud credentials its owner's
3645
+ # GitHub configuration and SSH key, recorded for cleanup, now: after the
3646
+ # managed-auth scrub, before uai-init installs anything that may need them.
3647
+ # task-up waits for its answer, bounded, and stops on anything but continue.
3648
+ if [ -n "$uai_handshake_dir" ]; then
3649
+ : > "$uai_handshake_dir/event-runtime-ready"
3650
+ uai_waited=0
3651
+ while [ ! -f "$uai_handshake_dir/runtime-ready" ] && [ "$uai_waited" -lt 3000 ]; do
3652
+ sleep 0.2
3653
+ uai_waited=$((uai_waited + 1))
3654
+ done
3655
+ uai_runtime_ready=$(cat "$uai_handshake_dir/runtime-ready" 2>/dev/null || true)
3656
+ if [ "$uai_runtime_ready" != "continue" ]; then
3657
+ emit_err "CREDENTIALS_UNAVAILABLE" \
3658
+ "Moondeck could not write the task's GitHub sign-in and SSH key into it. Start the task again." \
3659
+ "write the task's credentials"
3660
+ fi
3661
+ fi
3662
+
3663
+ init_warning=""
3664
+ init_rc=0
3665
+ crt exec "${task_runtime_exec_env[@]}" "$app_container" \
3666
+ /usr/local/bin/uai-init >/dev/null || init_rc=$?
3667
+ if [ "$init_rc" -ne 0 ]; then
3668
+ require_docker_after_failed_step "uai-init (deps + code-server)"
3669
+ if [ "$init_rc" -eq 78 ]; then
3670
+ # Exit 78 is reserved for a host-authority violation, not an ordinary
3671
+ # best-effort dependency failure. Keep the already-created app unavailable
3672
+ # and prove it stopped before returning an actionable task-up failure.
3673
+ if ! quarantine_runtime_authority_violation; then
3674
+ emit_err "CONTAINER_INIT_FAILED" \
3675
+ "A workspace runtime-authority violation was detected, and Moondeck could not prove the task container stopped. Stop it manually before retrying." \
3676
+ "quarantine runtime-authority violation"
3677
+ fi
3678
+ emit_err "CONTAINER_INIT_FAILED" \
3679
+ "Moondeck stopped this task because its runtime isolation changed after startup (for example, a writable shared cache, an unsafe private npm path, or a workspace-root .tool-versions). Retry after removing the reported redirect; project runtime manifests belong only inside selected project directories." \
3680
+ "uai-init runtime authority"
3681
+ fi
3682
+ log "warning: uai-init failed — retrying once"
3683
+ sleep 2
3684
+ init_rc=0
3685
+ crt exec "${task_runtime_exec_env[@]}" "$app_container" \
3686
+ /usr/local/bin/uai-init >/dev/null || init_rc=$?
3687
+ if [ "$init_rc" -ne 0 ]; then
3688
+ require_docker_after_failed_step "uai-init (deps + code-server)"
3689
+ if [ "$init_rc" -eq 78 ]; then
3690
+ if ! quarantine_runtime_authority_violation; then
3691
+ emit_err "CONTAINER_INIT_FAILED" \
3692
+ "A workspace runtime-authority violation was detected, and Moondeck could not prove the task container stopped. Stop it manually before retrying." \
3693
+ "quarantine runtime-authority violation"
3694
+ fi
3695
+ emit_err "CONTAINER_INIT_FAILED" \
3696
+ "Moondeck stopped this task because its runtime isolation changed after startup (for example, a writable shared cache, an unsafe private npm path, or a workspace-root .tool-versions). Retry after removing the reported redirect." \
3697
+ "uai-init runtime authority"
3698
+ fi
3699
+ init_warning="Workspace init (deps + editor) failed twice — the task is up, but the editor and installed dependencies may be missing. Retry from a task terminal with: /usr/local/bin/uai-init"
3700
+ log "warning: $init_warning"
3701
+ fi
3702
+ fi
3703
+
3704
+ # -----------------------------------------------------------------------------
3705
+ # 8. Discover code-server + preview ports, mark running, unlock.
3706
+ # -----------------------------------------------------------------------------
3707
+
3708
+ # code-server: container exposes 8080; Docker auto-maps to a random host
3709
+ # port. Discover via `docker port`. Best-effort — if it fails the Editor
3710
+ # pane is just unavailable.
3711
+ code_server_port=""
3712
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
3713
+ # No ports are published on the Apple runtime: the container's vmnet IP is
3714
+ # host-routable, so the Editor tunnel dials <container-ip>:8080 directly
3715
+ # (resolved per-request from the provider's route inspection).
3716
+ :
3717
+ else
3718
+ code_server_port=$(mapped_host_port "$app_container" 8080)
3719
+ if [ -z "$code_server_port" ]; then
3720
+ require_docker_after_failed_step "discover code-server port"
3721
+ log "warning: could not discover code-server port for ${app_container} (editor pane unavailable)"
3722
+ fi
3723
+ fi
3724
+
3725
+ # ADR-043 Stage 2: preview ports are no longer published at task-up, so there is
3726
+ # nothing to discover here. `preview_ports` stays empty and every enabled preview
3727
+ # is reached through its sidecar (started lazily on first access via the cloud
3728
+ # tunnel). `union_preview_ports_json` is still surfaced to the cloud (GET task
3729
+ # lists the declared/ad-hoc names the user can turn on).
3730
+ preview_ports_runtime_json="[]"
3731
+
3732
+ step "DB_UPDATE_FAILED" "mark task running"
3733
+ cs_sql="code_server_port=NULL"
3734
+ if [ -n "$code_server_port" ]; then
3735
+ cs_sql="code_server_port=${code_server_port}"
3736
+ fi
3737
+ db_update_task "$task_id" \
3738
+ "status='running'" \
3739
+ "$cs_sql" \
3740
+ "preview_ports='$(sql_escape "$preview_ports_runtime_json")'"
3741
+
3742
+ unlock_task "$task_id"
3743
+
3744
+ emit_event "$task_id" "status_changed" "$(jq -nc '{from:"starting",to:"running"}')"
3745
+
3746
+ # NOTE: `codeServerPort` is added only when discovered. Do NOT inline a
3747
+ # `select(.!="")` into the object literal — when it filters to empty, jq
3748
+ # collapses the *entire* object to empty and emit_ok then defaults to
3749
+ # `{}`, producing AGENT_BAD_OUTPUT. Add the key with object-merge instead.
3750
+ emit_ok "$(jq -nc \
3751
+ --arg cp "$compose_project" \
3752
+ --arg wp "$task_dir" \
3753
+ --arg csport "${code_server_port:-}" \
3754
+ --arg iw "${init_warning}" \
3755
+ --arg spt "$secrets_proxy_trust" \
3756
+ --arg spp "$secrets_proxy_pins" \
3757
+ --argjson previewPorts "$preview_ports_runtime_json" \
3758
+ '{composeProject:$cp,worktreePath:$wp}
3759
+ + (if $csport == "" then {} else {codeServerPort:($csport|tonumber)} end)
3760
+ + (if $iw == "" then {} else {initWarning:$iw} end)
3761
+ + {previewPorts:$previewPorts, secretsProxyTrust:$spt, secretsProxyPins:$spp}')"