@runuai/host 0.9.14 → 0.9.43

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 (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +33 -2
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3051 -200
  53. package/lib/preview-sidecar.ts +68 -14
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +956 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +459 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1405 -107
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +766 -42
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
@@ -13,9 +13,12 @@
13
13
  # branch, seed .tool-versions when missing.
14
14
  # 4. Generate .uai/docker-compose.yml in bash (and a derived .uai/Dockerfile
15
15
  # when any project declares `extra`).
16
- # 5. docker compose -p task-<id> up -d (--build only when derived).
17
- # 6. docker cp AI auth into the container, then docker exec uai-init.
18
- # 7. Discover code-server + preview ports; mark status=running; unlock.
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.
19
22
  #
20
23
  # The AIs are NOT started here — the chat orchestrator drives them and
21
24
  # delivers the task's initial prompt once the task is running.
@@ -34,10 +37,33 @@ setup_err_trap
34
37
  # Pinned constants (all clusters must agree — see ADR-022).
35
38
  STANDARD_IMAGE="uai-standard:dev"
36
39
  ASDF_VOLUME="uai-asdf-data"
40
+ TASK_NPM_PREFIX="/home/node/.local/share/uai-npm"
41
+ TASK_NPM_CACHE="/home/node/.cache/uai-npm"
42
+ # Host-proved shims and system controls always win. A task-global package with
43
+ # a new command name remains discoverable at the final path component, while a
44
+ # package named node/claude/git/sh cannot intercept managed or agent startup.
45
+ 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"
37
46
  # ADR-053: host-wide Playwright browser cache (Chromium downloads once).
38
47
  PW_VOLUME="uai-playwright"
48
+ # ADR-106: on the Apple runtime the cache is a host DIRECTORY instead. Apple
49
+ # block volumes allow exactly ONE writable attachment, and session start
50
+ # writes browsers from inside every task — a shared writable volume let the
51
+ # first task exclude every later `container run` (live 2026-08-17). Virtiofs
52
+ # directory shares carry no exclusivity, like /workspace itself.
53
+ PW_BROWSERS_DIR="$UAI_WORKSPACE_ROOT/pw-browsers"
39
54
  DERIVED_IMAGE="uai-task-${task_id}"
40
55
 
56
+ # ADR-106: runtime selection is shared with task-down/task-status via
57
+ # _common.sh; `docker` keeps the historical Compose path byte-for-byte, while
58
+ # `apple-container` drives the bundled Apple CLI directly (no Compose, no
59
+ # published ports — tasks are reached at their vmnet IP).
60
+ validate_task_runtime_selection
61
+ # Per-container VM sizing (Apple runtime only; Docker tasks share the daemon
62
+ # VM). Overridable for constrained hosts; ADR-106 picked these defaults for a
63
+ # dev task with language servers + builds.
64
+ APPLE_TASK_MEMORY="${UAI_APPLE_TASK_MEMORY:-8192M}"
65
+ APPLE_TASK_CPUS="${UAI_APPLE_TASK_CPUS:-4}"
66
+
41
67
  # Project-defined environment values arrive as one JSON object on stdin. They
42
68
  # are deliberately NOT part of this host process environment: names such as
43
69
  # BASH_ENV, PATH, GIT_CONFIG_*, and UAI_* must not influence task setup or the
@@ -60,6 +86,8 @@ task_uai_dir="$task_dir/.uai"
60
86
  projects_root="$UAI_WORKSPACE_ROOT/projects"
61
87
  compose_project=$(compose_project_for_task "$task_id")
62
88
  app_container=$(app_container_for_task "$task_id")
89
+ [ "$TASK_RUNTIME_MODE" != "apple-container" ] \
90
+ || app_container=$(apple_app_container_for_task "$task_id")
63
91
 
64
92
  reject_unsafe_host_directory() {
65
93
  local path="$1" label="$2"
@@ -70,6 +98,36 @@ reject_unsafe_host_directory() {
70
98
  fi
71
99
  }
72
100
 
101
+ reject_unsafe_runtime_manifest() {
102
+ local path="$1" project_slug="$2" project_id="$3"
103
+ if [ -L "$path" ]; then
104
+ emit_err "WORKTREE_FAILED" \
105
+ "Uai refused a symbolic-link .tool-versions for $project_slug" \
106
+ "validate task worktree ($project_id)"
107
+ fi
108
+ # Never open an untrusted special file: `-f` is a metadata-only check, so a
109
+ # FIFO/socket/device cannot block the host while it validates a resumed
110
+ # container-writable workspace.
111
+ if [ -e "$path" ] && [ ! -f "$path" ]; then
112
+ emit_err "WORKTREE_FAILED" \
113
+ "Uai refused a non-regular .tool-versions for $project_slug" \
114
+ "validate task worktree ($project_id)"
115
+ fi
116
+ }
117
+
118
+ reject_workspace_runtime_shadow() {
119
+ local path="$task_workspace/.tool-versions"
120
+ # The workspace root is not a project. Any entry here would shadow the
121
+ # image defaults for a no-manifest project (and even a scratchpad command),
122
+ # while the trusted materializer proves only the selected project paths.
123
+ # Metadata-only predicates keep FIFO/device/socket cases nonblocking.
124
+ if [ -L "$path" ] || [ -e "$path" ]; then
125
+ emit_err "WORKTREE_FAILED" \
126
+ "Uai refused a workspace-root .tool-versions because it could override the runtimes proved for this task" \
127
+ "validate task workspace runtime authority"
128
+ fi
129
+ }
130
+
73
131
  mapped_host_port() {
74
132
  local container="$1" container_port="$2"
75
133
  docker port "$container" "$container_port" 2>/dev/null \
@@ -78,6 +136,292 @@ mapped_host_port() {
78
136
  || true
79
137
  }
80
138
 
139
+ # A failed Docker call cannot be followed by an unbounded health probe: the
140
+ # same stuck socket would defeat the operation's own watchdog. This portable
141
+ # Bash 3.2 wrapper gives `docker info` 15 seconds, writes completion before its
142
+ # exact wrapper PID disappears, and reaps every helper on success or timeout.
143
+ docker_daemon_reachable_bounded() {
144
+ local probe_dir wrapper_pid watchdog_pid probe_rc=0 watchdog_sleep_pid
145
+
146
+ probe_dir=$(mktemp -d "$task_uai_dir/.docker-info-watchdog.XXXXXX")
147
+ (
148
+ runtime_probe_pid=""
149
+ finish_runtime_probe() {
150
+ local wrapper_rc=$?
151
+ trap - EXIT
152
+ : > "$probe_dir/completed"
153
+ exit "$wrapper_rc"
154
+ }
155
+ terminate_runtime_probe() {
156
+ local signal_rc="$1"
157
+ trap - TERM INT
158
+ if [ -n "$runtime_probe_pid" ]; then
159
+ kill -TERM "$runtime_probe_pid" >/dev/null 2>&1 || true
160
+ sleep 2
161
+ kill -KILL "$runtime_probe_pid" >/dev/null 2>&1 || true
162
+ wait "$runtime_probe_pid" 2>/dev/null || true
163
+ fi
164
+ exit "$signal_rc"
165
+ }
166
+ trap finish_runtime_probe EXIT
167
+ trap 'terminate_runtime_probe 143' TERM
168
+ trap 'terminate_runtime_probe 130' INT
169
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
170
+ "$CONTAINER_CLI" system status >/dev/null 2>&1 &
171
+ else
172
+ docker info --format '{{.ServerVersion}}' >/dev/null 2>&1 &
173
+ fi
174
+ runtime_probe_pid=$!
175
+ if wait "$runtime_probe_pid"; then exit 0; else exit $?; fi
176
+ ) &
177
+ wrapper_pid=$!
178
+
179
+ (
180
+ watchdog_sleep_pid=""
181
+ cancel_probe_watchdog() {
182
+ trap - TERM INT
183
+ if [ -n "$watchdog_sleep_pid" ]; then
184
+ kill -TERM "$watchdog_sleep_pid" >/dev/null 2>&1 || true
185
+ wait "$watchdog_sleep_pid" 2>/dev/null || true
186
+ fi
187
+ exit 0
188
+ }
189
+ trap cancel_probe_watchdog TERM INT
190
+ sleep 15 &
191
+ watchdog_sleep_pid=$!
192
+ if ! wait "$watchdog_sleep_pid"; then exit 0; fi
193
+ watchdog_sleep_pid=""
194
+ if [ ! -f "$probe_dir/completed" ] \
195
+ && kill -0 "$wrapper_pid" >/dev/null 2>&1; then
196
+ : > "$probe_dir/timed-out"
197
+ kill -TERM "$wrapper_pid" >/dev/null 2>&1 || true
198
+ sleep 5 &
199
+ watchdog_sleep_pid=$!
200
+ wait "$watchdog_sleep_pid" 2>/dev/null || true
201
+ watchdog_sleep_pid=""
202
+ if [ ! -f "$probe_dir/completed" ]; then
203
+ kill -KILL "$wrapper_pid" >/dev/null 2>&1 || true
204
+ fi
205
+ fi
206
+ ) &
207
+ watchdog_pid=$!
208
+
209
+ if wait "$wrapper_pid"; then probe_rc=0; else probe_rc=$?; fi
210
+ kill -TERM "$watchdog_pid" >/dev/null 2>&1 || true
211
+ wait "$watchdog_pid" 2>/dev/null || true
212
+ [ ! -f "$probe_dir/timed-out" ] || probe_rc=124
213
+ rm -f "$probe_dir/completed" "$probe_dir/timed-out"
214
+ rmdir "$probe_dir" 2>/dev/null || true
215
+ return "$probe_rc"
216
+ }
217
+
218
+ # A Docker-dependent step can fail for its own, degradable reason (for example,
219
+ # uai-init exits nonzero while the container remains healthy) or because the
220
+ # daemon disappeared after TypeScript admitted task-up. Only the latter is
221
+ # retryable host infrastructure state. Re-probe before translating a failed
222
+ # Docker step into an operation-specific error or warning so task-up never
223
+ # publishes status=running against an unknown backend.
224
+ require_docker_after_failed_step() {
225
+ local step_label="$1" runtime_name="Docker"
226
+ [ "$TASK_RUNTIME_MODE" != "apple-container" ] \
227
+ || runtime_name="the Apple container runtime"
228
+ if ! docker_daemon_reachable_bounded; then
229
+ emit_err "CONTAINER_INIT_FAILED" \
230
+ "The container runtime stopped responding during task startup. Retry after $runtime_name is running again." \
231
+ "$step_label"
232
+ fi
233
+ }
234
+
235
+ # Stop an app that failed a post-create authority proof and verify it is no
236
+ # longer running before any credential, dependency, editor, or agent work can
237
+ # begin. Preserve the container/worktree for diagnosis; a retry recreates the
238
+ # container from Uai's current Compose contract.
239
+ quarantine_untrusted_app_container() {
240
+ local authority_running=""
241
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
242
+ # `container stop` is idempotent; proven absence quarantines as well as a
243
+ # stopped state does. An inspect that fails WITHOUT naming this container
244
+ # as not-found proves nothing and must not read as stopped.
245
+ if ! crt stop --time 30 "$app_container" >/dev/null 2>&1; then
246
+ require_docker_after_failed_step "quarantine untrusted task container"
247
+ fi
248
+ apple_quarantine_proven=""
249
+ quarantine_state=$(crt inspect "$app_container" 2>/dev/null \
250
+ | jq -r '.[0].status.state // empty' 2>/dev/null) || quarantine_state=""
251
+ if [ -n "$quarantine_state" ] && [ "$quarantine_state" != "running" ]; then
252
+ apple_quarantine_proven=1
253
+ elif [ -z "$quarantine_state" ] \
254
+ && apple_container_proven_absent "$app_container"; then
255
+ apple_quarantine_proven=1
256
+ fi
257
+ if [ -z "$apple_quarantine_proven" ]; then
258
+ crt stop --signal KILL "$app_container" >/dev/null 2>&1 || true
259
+ quarantine_state=$(crt inspect "$app_container" 2>/dev/null \
260
+ | jq -r '.[0].status.state // empty' 2>/dev/null) || quarantine_state=""
261
+ if [ -n "$quarantine_state" ] && [ "$quarantine_state" != "running" ]; then
262
+ apple_quarantine_proven=1
263
+ elif [ -z "$quarantine_state" ] \
264
+ && apple_container_proven_absent "$app_container"; then
265
+ apple_quarantine_proven=1
266
+ fi
267
+ fi
268
+ [ -n "$apple_quarantine_proven" ]
269
+ return
270
+ fi
271
+ if ! docker stop --time 30 "$app_container" >/dev/null 2>&1; then
272
+ require_docker_after_failed_step "quarantine untrusted task container"
273
+ fi
274
+ authority_running=$(docker ps -q --filter "name=^/${app_container}$") \
275
+ || require_docker_after_failed_step "verify task-container quarantine"
276
+ if [ -n "$authority_running" ]; then
277
+ docker kill "$app_container" >/dev/null 2>&1 || true
278
+ authority_running=$(docker ps -q --filter "name=^/${app_container}$") \
279
+ || require_docker_after_failed_step "verify task-container quarantine"
280
+ fi
281
+ [ -z "$authority_running" ]
282
+ }
283
+
284
+ # Run the maintenance container under two independent deadlines. The inner GNU
285
+ # timeout bounds the actual writer (including the shared-volume lock wait); the
286
+ # host watchdog is slightly looser and covers a Docker CLI that hangs before it
287
+ # creates or starts the container. Bash 3.2 has no `wait -n` or `timeout`, so
288
+ # keep and reap both exact child PIDs explicitly.
289
+ run_runtime_materializer() {
290
+ local runtime_image="$1" wrapper_pid watchdog_pid materialization_rc=0
291
+ local watchdog_dir watchdog_sleep_pid
292
+
293
+ watchdog_dir=$(mktemp -d "$task_uai_dir/.runtime-materializer-watchdog.XXXXXX")
294
+
295
+ # The completion marker is written from the wrapper's EXIT trap before its
296
+ # PID can disappear. The watchdog consults it before every signal, closing
297
+ # the wait/reap/PID-reuse race around a just-finished Docker client.
298
+ (
299
+ runtime_docker_pid=""
300
+ finish_runtime_wrapper() {
301
+ local wrapper_rc=$?
302
+ trap - EXIT
303
+ : > "$watchdog_dir/completed"
304
+ exit "$wrapper_rc"
305
+ }
306
+ terminate_runtime_wrapper() {
307
+ local signal_rc="$1"
308
+ trap - TERM INT
309
+ if [ -n "$runtime_docker_pid" ]; then
310
+ # Do not reap before the grace period: the exact child PID therefore
311
+ # cannot be reused between TERM and KILL.
312
+ kill -TERM "$runtime_docker_pid" >/dev/null 2>&1 || true
313
+ sleep 10
314
+ kill -KILL "$runtime_docker_pid" >/dev/null 2>&1 || true
315
+ wait "$runtime_docker_pid" 2>/dev/null || true
316
+ fi
317
+ exit "$signal_rc"
318
+ }
319
+ trap finish_runtime_wrapper EXIT
320
+ trap 'terminate_runtime_wrapper 143' TERM
321
+ trap 'terminate_runtime_wrapper 130' INT
322
+
323
+ # ADR-108: on the Apple runtime this function is never called —
324
+ # materialization runs inside the app container against its in-guest
325
+ # overlay. This maintenance-container path is Docker-only.
326
+ docker run --rm --user node --group-add "$task_container_gid" \
327
+ --workdir /workspace \
328
+ -v "$task_workspace:/workspace:ro" \
329
+ -v "$ASDF_VOLUME:/opt/asdf-data:rw" \
330
+ -v "$RUNTIME_MATERIALIZER_SOURCE:/usr/local/bin/uai-materialize-runtimes:ro" \
331
+ -v "$COREPACK_VERSION_SOURCE:/usr/local/share/uai/corepack-version:ro" \
332
+ -v "$runtime_projects_file:/run/uai/runtime-projects:ro" \
333
+ --env LD_PRELOAD= \
334
+ --env LD_AUDIT= \
335
+ --env LD_LIBRARY_PATH= \
336
+ --env BASH_ENV= \
337
+ --env ENV= \
338
+ --entrypoint /usr/bin/env \
339
+ "$runtime_image" \
340
+ -i \
341
+ HOME=/home/node \
342
+ ASDF_DIR=/opt/asdf \
343
+ ASDF_DATA_DIR=/opt/asdf-data \
344
+ PATH=/opt/asdf-data/shims:/opt/asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
345
+ /usr/bin/timeout --signal=TERM --kill-after=10s 600s \
346
+ /usr/local/bin/uai-materialize-runtimes >&2 &
347
+ runtime_docker_pid=$!
348
+ if wait "$runtime_docker_pid"; then
349
+ exit 0
350
+ else
351
+ exit $?
352
+ fi
353
+ ) &
354
+ wrapper_pid=$!
355
+
356
+ (
357
+ watchdog_sleep_pid=""
358
+ cancel_runtime_watchdog() {
359
+ trap - TERM INT
360
+ if [ -n "$watchdog_sleep_pid" ]; then
361
+ kill -TERM "$watchdog_sleep_pid" >/dev/null 2>&1 || true
362
+ wait "$watchdog_sleep_pid" 2>/dev/null || true
363
+ fi
364
+ exit 0
365
+ }
366
+ trap cancel_runtime_watchdog TERM INT
367
+
368
+ sleep 620 &
369
+ watchdog_sleep_pid=$!
370
+ if ! wait "$watchdog_sleep_pid"; then
371
+ exit 0
372
+ fi
373
+ watchdog_sleep_pid=""
374
+
375
+ if [ ! -f "$watchdog_dir/completed" ] \
376
+ && kill -0 "$wrapper_pid" >/dev/null 2>&1; then
377
+ : > "$watchdog_dir/timed-out"
378
+ kill -TERM "$wrapper_pid" >/dev/null 2>&1 || true
379
+ # The wrapper gives its exact Docker child 10 seconds before KILL. This
380
+ # slightly looser guard is only a backstop for a wedged signal handler.
381
+ sleep 15 &
382
+ watchdog_sleep_pid=$!
383
+ wait "$watchdog_sleep_pid" 2>/dev/null || true
384
+ watchdog_sleep_pid=""
385
+ if [ ! -f "$watchdog_dir/completed" ]; then
386
+ kill -KILL "$wrapper_pid" >/dev/null 2>&1 || true
387
+ fi
388
+ fi
389
+ ) &
390
+ watchdog_pid=$!
391
+
392
+ cancel_runtime_materializer() {
393
+ local signal_rc="$1"
394
+ trap - TERM INT
395
+ if [ ! -f "$watchdog_dir/completed" ]; then
396
+ kill -TERM "$wrapper_pid" >/dev/null 2>&1 || true
397
+ fi
398
+ kill -TERM "$watchdog_pid" >/dev/null 2>&1 || true
399
+ wait "$watchdog_pid" 2>/dev/null || true
400
+ wait "$wrapper_pid" 2>/dev/null || true
401
+ rm -f "$watchdog_dir/completed" "$watchdog_dir/timed-out"
402
+ rmdir "$watchdog_dir" 2>/dev/null || true
403
+ exit "$signal_rc"
404
+ }
405
+ trap 'cancel_runtime_materializer 143' TERM
406
+ trap 'cancel_runtime_materializer 130' INT
407
+
408
+ if wait "$wrapper_pid"; then
409
+ materialization_rc=0
410
+ else
411
+ materialization_rc=$?
412
+ fi
413
+ kill -TERM "$watchdog_pid" >/dev/null 2>&1 || true
414
+ wait "$watchdog_pid" 2>/dev/null || true
415
+
416
+ trap - TERM INT
417
+ if [ -f "$watchdog_dir/timed-out" ]; then
418
+ materialization_rc=124
419
+ fi
420
+ rm -f "$watchdog_dir/completed" "$watchdog_dir/timed-out"
421
+ rmdir "$watchdog_dir" 2>/dev/null || true
422
+ return "$materialization_rc"
423
+ }
424
+
81
425
  # -----------------------------------------------------------------------------
82
426
  # 1. Read rows.
83
427
  # -----------------------------------------------------------------------------
@@ -88,6 +432,7 @@ task_json=$(db_get_task "$task_id")
88
432
  || emit_err "TASK_NOT_FOUND" "no such task: $task_id" "read task row"
89
433
 
90
434
  task_branch=$(jq -r '.[0].branch' <<<"$task_json")
435
+ UAI_RETRY_STATUS=$(jq -r '.[0].status // ""' <<<"$task_json")
91
436
 
92
437
  # The standard container runs as node:node (1000:1000), while Docker Desktop
93
438
  # hosts commonly run as 501:20 and Linux hosts may use another uid/gid. Share
@@ -117,6 +462,18 @@ esac
117
462
  step "PROJECT_NOT_FOUND" "read task projects"
118
463
  projects_json=$(db_get_projects_for_task "$task_id")
119
464
  project_count=$(jq 'length' <<<"$projects_json")
465
+ if ! jq -e '
466
+ map(.slug) as $slugs
467
+ | all($slugs[];
468
+ type == "string"
469
+ and test("^[a-z0-9-]{1,64}$"))
470
+ and (($slugs | unique | length) == ($slugs | length))
471
+ ' <<<"$projects_json" >/dev/null; then
472
+ emit_err "WORKTREE_FAILED" \
473
+ "Uai refused an unsafe or duplicate project workspace slug" \
474
+ "validate task projects"
475
+ fi
476
+ runtime_project_slugs=$(jq -r '.[].slug' <<<"$projects_json")
120
477
 
121
478
  # -----------------------------------------------------------------------------
122
479
  # 2. Lock + status=starting.
@@ -145,27 +502,46 @@ reject_unsafe_host_directory "$task_uai_dir" "the task runtime directory"
145
502
  # This also cuts off legacy containers that still have the old shared
146
503
  # `repo.git` bind mount before we inspect their worktree layout below.
147
504
  step "COMPOSE_DOWN_FAILED" "remove prior task containers"
148
- if [ -f "$task_uai_dir/docker-compose.yml" ]; then
149
- docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" \
150
- down --remove-orphans >/dev/null 2>&1 || true
505
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
506
+ # Selection fencing guarantees an apple-mode host has no Docker task rows,
507
+ # so the only possible prior container is this task's fixed name. `inspect`
508
+ # exiting nonzero afterwards is the absence proof.
509
+ crt stop --time 10 "$app_container" >/dev/null 2>&1 || true
510
+ crt delete --force "$app_container" >/dev/null 2>&1 || true
511
+ if crt inspect "$app_container" >/dev/null 2>&1; then
512
+ emit_err "COMPOSE_DOWN_FAILED" \
513
+ "the previous task container is still present; Uai will not let the host access its writable Git repository until that container is removed" \
514
+ "remove prior task containers"
515
+ fi
516
+ if ! apple_container_proven_absent "$app_container"; then
517
+ emit_err "COMPOSE_DOWN_FAILED" \
518
+ "could not verify removal of the previous task container" \
519
+ "remove prior task containers"
520
+ fi
151
521
  else
152
- docker compose -p "$compose_project" down --remove-orphans \
153
- >/dev/null 2>&1 || true
154
- fi
155
- prior_containers=""
156
- if ! prior_containers=$(docker ps -aq \
157
- --filter "label=com.docker.compose.project=$compose_project"); then
158
- emit_err "COMPOSE_DOWN_FAILED" \
159
- "could not verify removal of the previous task container" \
160
- "remove prior task containers"
161
- fi
162
- if [ -n "$prior_containers" ]; then
163
- emit_err "COMPOSE_DOWN_FAILED" \
164
- "the previous task container is still running or stopped; Uai will not let the host access its writable Git repository until that container is removed" \
165
- "remove prior task containers"
522
+ if [ -f "$task_uai_dir/docker-compose.yml" ]; then
523
+ docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" \
524
+ down --remove-orphans >/dev/null 2>&1 || true
525
+ else
526
+ docker compose -p "$compose_project" down --remove-orphans \
527
+ >/dev/null 2>&1 || true
528
+ fi
529
+ prior_containers=""
530
+ if ! prior_containers=$(docker ps -aq \
531
+ --filter "label=com.docker.compose.project=$compose_project"); then
532
+ emit_err "COMPOSE_DOWN_FAILED" \
533
+ "could not verify removal of the previous task container" \
534
+ "remove prior task containers"
535
+ fi
536
+ if [ -n "$prior_containers" ]; then
537
+ emit_err "COMPOSE_DOWN_FAILED" \
538
+ "the previous task container is still running or stopped; Uai will not let the host access its writable Git repository until that container is removed" \
539
+ "remove prior task containers"
540
+ fi
166
541
  fi
167
542
  reject_unsafe_host_directory "$task_workspace" "the task workspace"
168
543
  mkdir -p "$task_workspace"
544
+ reject_workspace_runtime_shadow
169
545
 
170
546
  # Git resolves symlinked ancestors when it writes a linked worktree's absolute
171
547
  # `.git` pointer. Keep the configured (lexical) task paths as Uai's stable host
@@ -508,11 +884,8 @@ while IFS= read -r project_obj; do
508
884
  "the project worktree for $project_slug"
509
885
  # Check before remote/cache Git: a dangling symlink makes `-e` false and the
510
886
  # later seed write would otherwise follow it outside the workspace.
511
- if [ -L "$worktree_target/.tool-versions" ]; then
512
- emit_err "WORKTREE_FAILED" \
513
- "Uai refused a symbolic-link .tool-versions for $project_slug" \
514
- "validate task worktree ($project_id)"
515
- fi
887
+ reject_unsafe_runtime_manifest "$worktree_target/.tool-versions" \
888
+ "$project_slug" "$project_id"
516
889
  worktree_exists=0
517
890
  standalone_worktree=0
518
891
  if [ -e "$worktree_target/.git" ] || [ -L "$worktree_target/.git" ]; then
@@ -881,11 +1254,8 @@ while IFS= read -r project_obj; do
881
1254
  # Tool-version seeding: only when the worktree has no checked-in
882
1255
  # .tool-versions AND the project declares one. Never overwrite a file
883
1256
  # the repo already ships.
884
- if [ -L "$worktree_target/.tool-versions" ]; then
885
- emit_err "WORKTREE_FAILED" \
886
- "Uai refused a symbolic-link .tool-versions for $project_slug" \
887
- "validate task worktree ($project_id)"
888
- fi
1257
+ reject_unsafe_runtime_manifest "$worktree_target/.tool-versions" \
1258
+ "$project_slug" "$project_id"
889
1259
  if [ ! -e "$worktree_target/.tool-versions" ] \
890
1260
  && [ -n "$tool_versions" ] && [ "$tool_versions" != "null" ]; then
891
1261
  printf '%s\n' "$tool_versions" > "$worktree_target/.tool-versions"
@@ -957,6 +1327,53 @@ step "RENDER_FAILED" "generate compose"
957
1327
 
958
1328
  mkdir -p "$task_uai_dir"
959
1329
 
1330
+ STANDARD_CONTAINER_DIR="${UAI_STANDARD_CONTAINER_ASSETS_DIR:-$SCRIPT_DIR/../../images/standard/container}"
1331
+ if ! STANDARD_CONTAINER_DIR=$(cd "$STANDARD_CONTAINER_DIR" && pwd -P); then
1332
+ emit_err "CONTAINER_INIT_FAILED" \
1333
+ "Uai's packaged container asset directory is missing. Repair or update the host installation, then retry." \
1334
+ "resolve packaged container assets"
1335
+ fi
1336
+ UAI_INIT_SOURCE="$STANDARD_CONTAINER_DIR/uai-init"
1337
+ RUNTIME_MATERIALIZER_SOURCE="$STANDARD_CONTAINER_DIR/uai-materialize-runtimes"
1338
+ COREPACK_VERSION_SOURCE="$STANDARD_CONTAINER_DIR/corepack-version"
1339
+ if [ -L "$UAI_INIT_SOURCE" ] || [ ! -f "$UAI_INIT_SOURCE" ] \
1340
+ || [ ! -x "$UAI_INIT_SOURCE" ]; then
1341
+ emit_err "CONTAINER_INIT_FAILED" \
1342
+ "Uai's packaged task initializer is missing or unsafe. Repair or update the host installation, then retry." \
1343
+ "resolve packaged container assets"
1344
+ fi
1345
+ if [ "$project_count" -gt 0 ]; then
1346
+ if [ -L "$RUNTIME_MATERIALIZER_SOURCE" ] \
1347
+ || [ ! -f "$RUNTIME_MATERIALIZER_SOURCE" ] \
1348
+ || [ ! -x "$RUNTIME_MATERIALIZER_SOURCE" ]; then
1349
+ emit_err "CONTAINER_INIT_FAILED" \
1350
+ "Uai's packaged runtime materializer is missing or unsafe. Repair or update the host installation, then retry." \
1351
+ "resolve packaged container assets"
1352
+ fi
1353
+ if [ -L "$COREPACK_VERSION_SOURCE" ] \
1354
+ || [ ! -f "$COREPACK_VERSION_SOURCE" ] \
1355
+ || [ ! -r "$COREPACK_VERSION_SOURCE" ]; then
1356
+ emit_err "CONTAINER_INIT_FAILED" \
1357
+ "Uai's packaged Corepack version pin is missing or unsafe. Repair or update the host installation, then retry." \
1358
+ "resolve packaged container assets"
1359
+ fi
1360
+ fi
1361
+
1362
+ # The host-authored allowlist is the single project-set authority shared by
1363
+ # pre-start runtime proof and task-local dependency installation. It lives in
1364
+ # the container-unmounted .uai control tree and is exposed only as this one
1365
+ # read-only file. Atomic replacement keeps resumptions from observing a
1366
+ # partially written list.
1367
+ runtime_projects_file="$task_uai_dir/runtime-projects"
1368
+ runtime_projects_tmp=$(mktemp "$task_uai_dir/.runtime-projects.XXXXXX")
1369
+ if [ -n "$runtime_project_slugs" ]; then
1370
+ printf '%s\n' "$runtime_project_slugs" > "$runtime_projects_tmp"
1371
+ else
1372
+ : > "$runtime_projects_tmp"
1373
+ fi
1374
+ chmod 0644 "$runtime_projects_tmp"
1375
+ mv -f "$runtime_projects_tmp" "$runtime_projects_file"
1376
+
960
1377
  # Union of preview ports across projects, de-duped by containerPort
961
1378
  # (first-in-position wins the name). Validated: name non-empty, 1..65535.
962
1379
  union_preview_ports_json=$(jq -c '
@@ -1006,6 +1423,34 @@ if [ -n "$extra_block" ]; then
1006
1423
  has_derived=1
1007
1424
  fi
1008
1425
 
1426
+ # One fail-closed authority for names projects must never control, whether the
1427
+ # value arrived as an ordinary declared env key or as a preview urlEnv literal.
1428
+ # UAI_EDITOR_THEME is the sole preview exception below: it is computed by the
1429
+ # cloud from the task palette, not sourced from a project.
1430
+ is_reserved_task_env_name() {
1431
+ case "$1" in
1432
+ GH_TOKEN | GITHUB_TOKEN | GH_CONFIG_DIR | GH_HOST | \
1433
+ GIT_CONFIG* | GIT_DIR | GIT_WORK_TREE | GIT_COMMON_DIR | \
1434
+ GIT_OBJECT_DIRECTORY | GIT_ALTERNATE_OBJECT_DIRECTORIES | \
1435
+ GIT_INDEX_FILE | GIT_EXEC_PATH | GIT_ASKPASS | SSH_ASKPASS | \
1436
+ GIT_SSH | GIT_SSH_COMMAND | HOME | XDG_CONFIG_HOME | XDG_DATA_HOME | \
1437
+ XDG_CACHE_HOME | \
1438
+ PATH | LD_PRELOAD | LD_AUDIT | LD_LIBRARY_PATH | NODE_OPTIONS | \
1439
+ NODE_PATH | SHELLOPTS | BASHOPTS | PS4 | BASH_ENV | ENV | \
1440
+ ASDF_* | COREPACK_* | \
1441
+ [Nn][Pp][Mm]_[Cc][Oo][Nn][Ff][Ii][Gg]_[Pp][Rr][Ee][Ff][Ii][Xx] | \
1442
+ [Nn][Pp][Mm]_[Cc][Oo][Nn][Ff][Ii][Gg]_[Cc][Aa][Cc][Hh][Ee] | \
1443
+ [Nn][Pp][Mm]_[Cc][Oo][Nn][Ff][Ii][Gg]_[Ll][Oo][Gg][Ss][_-][Dd][Ii][Rr] | \
1444
+ [Nn][Pp][Mm]_[Cc][Oo][Nn][Ff][Ii][Gg]_[Uu][Mm][Aa][Ss][Kk] | UAI_* | \
1445
+ CLAUDE_CODE_OAUTH_TOKEN | \
1446
+ ANTHROPIC_API_KEY | ANTHROPIC_AUTH_TOKEN | XAI_API_KEY | \
1447
+ PLAYWRIGHT_BROWSERS_PATH)
1448
+ return 0
1449
+ ;;
1450
+ esac
1451
+ return 1
1452
+ }
1453
+
1009
1454
  # Write the compose file. No top-level name: (the -p flag sets the project),
1010
1455
  # no container_name:. Env values live host-side (ADR-015): we emit only
1011
1456
  # pass-through references `${KEY:-}`.
@@ -1026,6 +1471,11 @@ fi
1026
1471
  printf ' pull_policy: never\n'
1027
1472
  fi
1028
1473
  printf ' init: true\n'
1474
+ # Keep the host MCP gateway reachable through Docker's ordinary
1475
+ # host-gateway address. (The Apple runtime has no extra_hosts equivalent
1476
+ # and none is rendered for it; its gateway route is the vmnet gateway IP.)
1477
+ printf ' extra_hosts:\n'
1478
+ printf ' - "host.docker.internal:host-gateway"\n'
1029
1479
  # Give node the one host group that owns this task's bind-mounted files.
1030
1480
  # Numeric group_add works even when the group has a different name (or no
1031
1481
  # name) in the image. Root hosts use node's existing gid 1000 above.
@@ -1058,7 +1508,13 @@ fi
1058
1508
  "$task_uai_dir" "$vol_pid" "$task_uai_dir_physical" "$vol_pid"
1059
1509
  fi
1060
1510
  done < <(jq -c '.[]' <<<"$projects_json")
1061
- printf ' - "%s:/opt/asdf-data"\n' "$ASDF_VOLUME"
1511
+ printf ' - "%s:/usr/local/bin/uai-init:ro"\n' "$UAI_INIT_SOURCE"
1512
+ printf ' - "%s:/run/uai/runtime-projects:ro"\n' \
1513
+ "$runtime_projects_file"
1514
+ # Task processes consume only the exact runtimes/package managers proved by
1515
+ # pre-start maintenance. `:ro` is explicit because Docker's omitted default
1516
+ # is read-write and a legacy Compose file must never look equivalent.
1517
+ printf ' - "%s:/opt/asdf-data:ro"\n' "$ASDF_VOLUME"
1062
1518
  # ADR-053: host-wide Playwright browser cache. Always mounted (harmless
1063
1519
  # when browser testing is off); Chromium downloads once per HOST.
1064
1520
  printf ' - "%s:/opt/pw-browsers"\n' "$PW_VOLUME"
@@ -1090,6 +1546,61 @@ fi
1090
1546
  # the bind-mounted workspace and independent of project-defined XDG/HOME
1091
1547
  # values so its credential helper always resolves the connected user.
1092
1548
  printf ' GH_CONFIG_DIR: "/home/node/.config/gh"\n'
1549
+ # Runtime selection/cache authority is host-owned. These values override
1550
+ # both project-declared keys and a derived image's ENV so uai-init cannot be
1551
+ # redirected into an unproved runtime or a different shared-cache writer.
1552
+ printf ' PATH: "%s"\n' "$TASK_RUNTIME_PATH"
1553
+ printf ' HOME: "/home/node"\n'
1554
+ printf ' XDG_CONFIG_HOME: "/home/node/.config"\n'
1555
+ printf ' XDG_DATA_HOME: "/home/node/.local/share"\n'
1556
+ printf ' XDG_CACHE_HOME: "/home/node/.cache"\n'
1557
+ printf ' ASDF_DIR: "/opt/asdf"\n'
1558
+ printf ' ASDF_DATA_DIR: "/opt/asdf-data"\n'
1559
+ printf ' ASDF_CONFIG_FILE: "/dev/null"\n'
1560
+ printf ' ASDF_DEFAULT_TOOL_VERSIONS_FILENAME: ".tool-versions"\n'
1561
+ printf ' ASDF_NODEJS_VERSION: ""\n'
1562
+ printf ' ASDF_PYTHON_VERSION: ""\n'
1563
+ printf ' ASDF_GOLANG_VERSION: ""\n'
1564
+ printf ' ASDF_RUBY_VERSION: ""\n'
1565
+ printf ' ASDF_RUST_VERSION: ""\n'
1566
+ printf ' ASDF_SKIP_RESHIM: "1"\n'
1567
+ printf ' COREPACK_HOME: "/opt/asdf-data/corepack"\n'
1568
+ printf ' COREPACK_DEFAULT_TO_LATEST: "0"\n'
1569
+ printf ' COREPACK_ENV_FILE: "0"\n'
1570
+ printf ' COREPACK_NPM_REGISTRY: "https://registry.npmjs.org"\n'
1571
+ printf ' COREPACK_ENABLE_NETWORK: "0"\n'
1572
+ printf ' COREPACK_ENABLE_AUTO_PIN: "0"\n'
1573
+ printf ' COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"\n'
1574
+ printf ' COREPACK_ENABLE_PROJECT_SPEC: "1"\n'
1575
+ printf ' COREPACK_ENABLE_STRICT: "1"\n'
1576
+ printf ' COREPACK_ENABLE_UNSAFE_CUSTOM_URLS: "0"\n'
1577
+ printf ' COREPACK_INTEGRITY_CHECK: "1"\n'
1578
+ # npm injects lowercase config names into lifecycle scripts, so pin both
1579
+ # cases. These task-private paths are never part of the shared asdf volume.
1580
+ printf ' NPM_CONFIG_PREFIX: "%s"\n' "$TASK_NPM_PREFIX"
1581
+ printf ' npm_config_prefix: "%s"\n' "$TASK_NPM_PREFIX"
1582
+ printf ' NPM_CONFIG_CACHE: "%s"\n' "$TASK_NPM_CACHE"
1583
+ printf ' npm_config_cache: "%s"\n' "$TASK_NPM_CACHE"
1584
+ printf ' NPM_CONFIG_LOGS_DIR: "%s/_logs"\n' "$TASK_NPM_CACHE"
1585
+ printf ' npm_config_logs_dir: "%s/_logs"\n' "$TASK_NPM_CACHE"
1586
+ printf ' NPM_CONFIG_UMASK: "0002"\n'
1587
+ printf ' npm_config_umask: "0002"\n'
1588
+ printf ' UAI_WORKSPACE: "/workspace"\n'
1589
+ printf ' UAI_RUNTIME_PROJECTS: "/run/uai/runtime-projects"\n'
1590
+ printf ' LD_PRELOAD: ""\n'
1591
+ printf ' LD_AUDIT: ""\n'
1592
+ printf ' LD_LIBRARY_PATH: ""\n'
1593
+ printf ' NODE_OPTIONS: ""\n'
1594
+ printf ' NODE_PATH: ""\n'
1595
+ # SHELLOPTS/BASHOPTS are deliberately ABSENT, never empty: bash exports its
1596
+ # live options into any child when these names were present in its startup
1597
+ # environment, so an "empty clear" let set -euo scripts stamp nounset into
1598
+ # code-server and break every asdf shim in the Editor terminal (found live
1599
+ # 2026-08-16, on BOTH engines; the reserved-name filter still strips any
1600
+ # project-declared value).
1601
+ printf ' PS4: ""\n'
1602
+ printf ' BASH_ENV: ""\n'
1603
+ printf ' ENV: ""\n'
1093
1604
  # Host-resident Claude auth (ADR-021). Headless `claude --print` no longer
1094
1605
  # uses the interactive subscription/keychain path, so it needs a token from
1095
1606
  # `claude setup-token`. Interpolated from the host-agent env at up-time, so
@@ -1127,17 +1638,7 @@ fi
1127
1638
  # decrypted values at `compose up` time.
1128
1639
  while IFS= read -r ekey; do
1129
1640
  [ -n "$ekey" ] || continue
1130
- case "$ekey" in
1131
- GH_TOKEN | GITHUB_TOKEN | GH_CONFIG_DIR | GH_HOST | \
1132
- GIT_CONFIG* | GIT_DIR | GIT_WORK_TREE | GIT_COMMON_DIR | \
1133
- GIT_OBJECT_DIRECTORY | GIT_ALTERNATE_OBJECT_DIRECTORIES | \
1134
- GIT_INDEX_FILE | GIT_EXEC_PATH | GIT_ASKPASS | SSH_ASKPASS | \
1135
- GIT_SSH | GIT_SSH_COMMAND | HOME | XDG_CONFIG_HOME | XDG_DATA_HOME | \
1136
- BASH_ENV | ENV | UAI_GIT_TRANSPORT | \
1137
- CLAUDE_CODE_OAUTH_TOKEN | ANTHROPIC_API_KEY | \
1138
- ANTHROPIC_AUTH_TOKEN | XAI_API_KEY | PLAYWRIGHT_BROWSERS_PATH | \
1139
- UAI_EDITOR_THEME) continue ;;
1140
- esac
1641
+ is_reserved_task_env_name "$ekey" && continue
1141
1642
  printf ' %s: ""\n' "$ekey"
1142
1643
  done < <(jq -r '.[]' <<<"$union_env_keys_json")
1143
1644
  # Cloud-computed literal env (ADR-025): a JSON object {VAR: value} on the
@@ -1158,6 +1659,10 @@ fi
1158
1659
  pe_key=$(jq -r '.key' <<<"$pe_entry")
1159
1660
  pe_val=$(jq -r '.value' <<<"$pe_entry")
1160
1661
  [ -n "$pe_key" ] || continue
1662
+ if [ "$pe_key" != "UAI_EDITOR_THEME" ] \
1663
+ && is_reserved_task_env_name "$pe_key"; then
1664
+ continue
1665
+ fi
1161
1666
  printf ' %s: "%s"\n' "$pe_key" "$pe_val"
1162
1667
  done < <(jq -c 'to_entries[]?' <<<"$preview_env_raw")
1163
1668
  printf 'volumes:\n'
@@ -1179,13 +1684,30 @@ if [ "$has_derived" = "1" ]; then
1179
1684
  fi
1180
1685
 
1181
1686
  # -----------------------------------------------------------------------------
1182
- # 5. Compose up.
1687
+ # 5. Build (when derived) + pre-materialize project runtimes.
1183
1688
  # -----------------------------------------------------------------------------
1184
1689
 
1185
1690
  # The shared asdf cache volume is host-wide and declared external — make
1186
- # sure it exists before compose references it. Idempotent.
1187
- docker volume create "$ASDF_VOLUME" >/dev/null 2>&1 || true
1188
- docker volume create "$PW_VOLUME" >/dev/null 2>&1 || true
1691
+ # sure it exists before compose references it. Idempotent under Docker; the
1692
+ # Apple CLI errors on an existing name, so tolerate creation and then demand
1693
+ # the volume actually exists.
1694
+ crt volume create "$ASDF_VOLUME" >/dev/null 2>&1 || true
1695
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
1696
+ # Browsers ride a host directory here, never a block volume — see
1697
+ # PW_BROWSERS_DIR above for why.
1698
+ if ! /bin/mkdir -p "$PW_BROWSERS_DIR"; then
1699
+ emit_err "CONTAINER_INIT_FAILED" \
1700
+ "Uai could not create the shared Playwright browsers directory $PW_BROWSERS_DIR." \
1701
+ "create shared volumes"
1702
+ fi
1703
+ if ! crt volume inspect "$ASDF_VOLUME" >/dev/null 2>&1; then
1704
+ emit_err "CONTAINER_INIT_FAILED" \
1705
+ "Uai could not create the shared $ASDF_VOLUME volume on the Apple container runtime. Retry after the runtime is healthy." \
1706
+ "create shared volumes"
1707
+ fi
1708
+ else
1709
+ crt volume create "$PW_VOLUME" >/dev/null 2>&1 || true
1710
+ fi
1189
1711
 
1190
1712
  # The standard base image is built by the host (ensureStandardImage), never
1191
1713
  # pulled from a registry — and derived tasks FROM it too. If it's absent (the
@@ -1193,24 +1715,215 @@ docker volume create "$PW_VOLUME" >/dev/null 2>&1 || true
1193
1715
  # a doomed registry pull; fail with an actionable message instead (the host
1194
1716
  # also (re)builds it on demand before task-up — this is the belt-and-suspenders
1195
1717
  # check for a still-in-progress or failed build). Live 2026-07-22.
1196
- if ! docker image inspect "$STANDARD_IMAGE" >/dev/null 2>&1; then
1718
+ if ! crt image inspect "$STANDARD_IMAGE" >/dev/null 2>&1; then
1197
1719
  emit_err "STANDARD_IMAGE_MISSING" \
1198
1720
  "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)." \
1199
1721
  "check standard image"
1200
1722
  fi
1201
1723
 
1202
- step "COMPOSE_UP_FAILED" "docker compose up -d"
1724
+ # A derived task must materialize through the exact filesystem it will run:
1725
+ # project `extra` may add runtime build dependencies. Split build from up so a
1726
+ # failed materialization can never start the task container, and so a later up
1727
+ # cannot silently rebuild a different image after the proof succeeds.
1728
+ runtime_image="$STANDARD_IMAGE"
1729
+ if [ "$has_derived" = "1" ]; then
1730
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
1731
+ step "COMPOSE_UP_FAILED" "build derived task image"
1732
+ if ! "$CONTAINER_CLI" build -t "$DERIVED_IMAGE" \
1733
+ -f "$task_uai_dir/Dockerfile" "$task_uai_dir" >&2; then
1734
+ require_docker_after_failed_step "build derived task image"
1735
+ emit_err "COMPOSE_UP_FAILED" \
1736
+ "Uai could not build this task's derived image. Check the project's extra setup commands, then retry." \
1737
+ "build derived task image"
1738
+ fi
1739
+ else
1740
+ step "COMPOSE_UP_FAILED" "docker compose build app"
1741
+ if ! docker compose -p "$compose_project" \
1742
+ -f "$task_uai_dir/docker-compose.yml" build app >&2; then
1743
+ require_docker_after_failed_step "docker compose build app"
1744
+ emit_err "COMPOSE_UP_FAILED" \
1745
+ "Uai could not build this task's derived image. Check the project's extra setup commands, then retry." \
1746
+ "docker compose build app"
1747
+ fi
1748
+ fi
1749
+ runtime_image="$DERIVED_IMAGE"
1750
+ fi
1751
+
1752
+ # Resolve the mutable service tag once and use only its immutable local image
1753
+ # ID for both maintenance and app creation. The on-disk Compose file keeps its
1754
+ # tag so task-down can still identify/remove a derived image, but no concurrent
1755
+ # rebuild can swap code between runtime proof and execution.
1756
+ step "CONTAINER_INIT_FAILED" "resolve task image identity"
1757
+ runtime_image_id=""
1758
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
1759
+ # One snapshot supplies identity, environment, and entrypoint together, so
1760
+ # every image-authority decision below describes the same bytes. The Apple
1761
+ # CLI cannot run an image by its 64-hex id, so the id is re-checked against
1762
+ # the tag after the container is created instead of run directly.
1763
+ apple_image_snapshot=""
1764
+ if ! apple_image_snapshot=$("$CONTAINER_CLI" image inspect \
1765
+ "$runtime_image" 2>/dev/null); then
1766
+ require_docker_after_failed_step "resolve task image identity"
1767
+ emit_err "CONTAINER_INIT_FAILED" \
1768
+ "Uai could not resolve the exact local image for this task. Retry after the host image is available." \
1769
+ "resolve task image identity"
1770
+ fi
1771
+ runtime_image_id=$(printf '%s\n' "$apple_image_snapshot" \
1772
+ | jq -r '.[0].id // empty' 2>/dev/null) || runtime_image_id=""
1773
+ if ! printf '%s\n' "$runtime_image_id" \
1774
+ | grep -Eq '^[0-9a-f]{64}$'; then
1775
+ emit_err "CONTAINER_INIT_FAILED" \
1776
+ "The Apple container runtime returned an invalid image identity for $runtime_image; Uai refused to start an unpinned task container." \
1777
+ "resolve task image identity"
1778
+ fi
1779
+ # Without a --entrypoint override flag, an image-declared ENTRYPOINT would
1780
+ # prefix every argv this script passes (including the materializer's
1781
+ # `env -i`). Refuse such an image before anything executes from it.
1782
+ if ! printf '%s\n' "$apple_image_snapshot" | jq -e '
1783
+ (.[0].variants | type == "array" and length == 1)
1784
+ and ((.[0].variants[0].config.config.Entrypoint // []) | length == 0)
1785
+ ' >/dev/null 2>&1; then
1786
+ emit_err "CONTAINER_INIT_FAILED" \
1787
+ "Uai 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." \
1788
+ "resolve task image identity"
1789
+ fi
1790
+ else
1791
+ if ! runtime_image_id=$(docker image inspect --format '{{.Id}}' \
1792
+ "$runtime_image" 2>/dev/null); then
1793
+ require_docker_after_failed_step "resolve task image identity"
1794
+ emit_err "CONTAINER_INIT_FAILED" \
1795
+ "Uai could not resolve the exact local image for this task. Retry after the host image is available." \
1796
+ "resolve task image identity"
1797
+ fi
1798
+ if ! printf '%s\n' "$runtime_image_id" \
1799
+ | grep -Eq '^sha256:[0-9a-f]{64}$'; then
1800
+ emit_err "CONTAINER_INIT_FAILED" \
1801
+ "Docker returned an invalid image identity for $runtime_image; Uai refused to start an unpinned task container." \
1802
+ "resolve task image identity"
1803
+ fi
1804
+ fi
1805
+
1806
+ # Docker keeps image Config.Env entries whose names differ only by case from
1807
+ # later `docker exec -e` overrides. npm treats those spellings
1808
+ # case-insensitively and, depending on environment order, a mixed-case value
1809
+ # can beat both canonical values. Reject such an image before maintenance or
1810
+ # app creation; ordinary registry/auth npm config remains allowed, while the
1811
+ # four path/permission authorities must use one of the two spellings Uai can
1812
+ # deterministically replace.
1813
+ step "CONTAINER_INIT_FAILED" "verify task image npm authority"
1814
+ runtime_image_env_json=""
1815
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
1816
+ # From the same snapshot the identity pin came from — the single variant
1817
+ # was already proven above.
1818
+ runtime_image_env_json=$(printf '%s\n' "$apple_image_snapshot" \
1819
+ | jq -c '.[0].variants[0].config.config.Env // []' 2>/dev/null) \
1820
+ || runtime_image_env_json=""
1821
+ if [ -z "$runtime_image_env_json" ]; then
1822
+ emit_err "CONTAINER_INIT_FAILED" \
1823
+ "Uai could not inspect this task image's npm environment authority. Retry after the Apple container runtime is stable." \
1824
+ "verify task image npm authority"
1825
+ fi
1826
+ elif ! runtime_image_env_json=$(docker image inspect \
1827
+ --format '{{json .Config.Env}}' "$runtime_image_id" 2>/dev/null); then
1828
+ require_docker_after_failed_step "verify task image npm authority"
1829
+ emit_err "CONTAINER_INIT_FAILED" \
1830
+ "Uai could not inspect this task image's npm environment authority. Retry after Docker is stable." \
1831
+ "verify task image npm authority"
1832
+ fi
1833
+ if ! printf '%s\n' "$runtime_image_env_json" | jq -e '
1834
+ def is_owned_npm_config($owned):
1835
+ ascii_downcase as $folded
1836
+ | ($folded | startswith("npm_config_"))
1837
+ and (($owned | index($folded[11:] | gsub("_"; "-"))) != null);
1838
+ (. // []) as $env
1839
+ | [
1840
+ "NPM_CONFIG_PREFIX", "npm_config_prefix",
1841
+ "NPM_CONFIG_CACHE", "npm_config_cache",
1842
+ "NPM_CONFIG_LOGS_DIR", "npm_config_logs_dir",
1843
+ "NPM_CONFIG_UMASK", "npm_config_umask"
1844
+ ] as $canonical
1845
+ | [
1846
+ "prefix", "cache", "logs-dir", "umask"
1847
+ ] as $owned
1848
+ | ($env | type == "array")
1849
+ and all($env[]; type == "string")
1850
+ and all($env[];
1851
+ (split("=")[0]) as $key
1852
+ | (($key | is_owned_npm_config($owned)) | not)
1853
+ or (($canonical | index($key)) != null)
1854
+ )
1855
+ ' >/dev/null 2>&1; then
1856
+ emit_err "CONTAINER_INIT_FAILED" \
1857
+ "Uai 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." \
1858
+ "verify task image npm authority"
1859
+ fi
1860
+
1861
+ # Runtime installation is a host-maintenance phase, not task-container init.
1862
+ # Give the one-shot container only the immutable workspace view and the
1863
+ # explicitly writable host-wide asdf volume. Run it for every selected-project
1864
+ # task: projects without .tool-versions still use the image's proven root
1865
+ # defaults and may request a Corepack-managed package manager. A true
1866
+ # zero-project scratchpad has nothing to prepare and remains the only skip.
1867
+ # GNU timeout lives in the Debian standard image (and therefore every derived
1868
+ # image); it bounds the shared lock wait plus all installs and positive
1869
+ # verification without leaving an orphaned volume writer when the cloud's
1870
+ # 15-minute RPC deadline expires.
1871
+ if [ "$project_count" -gt 0 ]; then
1872
+ step "CONTAINER_INIT_FAILED" "materialize task runtimes"
1873
+ materialization_rc=0
1874
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
1875
+ # ADR-108: the shared volume is never written on the task path. Apple
1876
+ # materialization runs INSIDE the app container after the in-guest
1877
+ # overlay is established (section 6a below) — a missing toolchain
1878
+ # installs into the task's own upper layer with zero shared-volume
1879
+ # coordination, at any task count.
1880
+ log "apple runtime materialization runs in-container after the overlay is established"
1881
+ else
1882
+ run_runtime_materializer "$runtime_image_id" || materialization_rc=$?
1883
+ fi
1884
+ if [ "$materialization_rc" -ne 0 ]; then
1885
+ require_docker_after_failed_step "materialize task runtimes"
1886
+ case "$materialization_rc" in
1887
+ 124|137)
1888
+ emit_err "CONTAINER_INIT_FAILED" \
1889
+ "Runtime materialization did not finish inside Uai's 10-minute startup budget. Retry the task: Uai will re-check every required runtime and package manager before it starts any task container, and will not trust a partial cache." \
1890
+ "materialize task runtimes"
1891
+ ;;
1892
+ *)
1893
+ emit_err "CONTAINER_INIT_FAILED" \
1894
+ "Uai 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." \
1895
+ "materialize task runtimes"
1896
+ ;;
1897
+ esac
1898
+ fi
1899
+ else
1900
+ log "zero-project scratchpad — skipping runtime materialization"
1901
+ fi
1902
+
1903
+ # -----------------------------------------------------------------------------
1904
+ # 6. Compose up.
1905
+ # -----------------------------------------------------------------------------
1906
+
1907
+ step "COMPOSE_UP_FAILED" "start task container"
1203
1908
  # Limit the supplied values to keys the selected projects actually declare,
1204
- # then remove Uai-managed names and preview URL names. JSON is valid YAML, so
1205
- # Compose can merge this secret-bearing override directly from stdin without a
1206
- # temporary env/YAML file and without exporting any value to host commands.
1909
+ # then remove Uai-managed names and preview URL names. Both runtimes consume
1910
+ # the same filtered object: Docker merges it as a Compose stdin override, and
1911
+ # the Apple path serializes it into a transient env-file below. JSON is valid
1912
+ # YAML, so Compose can merge this secret-bearing override directly from stdin
1913
+ # without a temporary env/YAML file and without exporting any value to host
1914
+ # commands.
1207
1915
  preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
1208
- compose_env_override=$(printf '%s' "$project_env_json" | jq -c \
1916
+ filtered_project_env=$(printf '%s' "$project_env_json" | jq -c \
1209
1917
  --argjson declared "$union_env_keys_json" \
1210
1918
  --argjson preview "$preview_env_raw" '
1211
1919
  .
1212
1920
  | with_entries(.key as $key | select($declared | index($key)))
1213
1921
  | with_entries(select(.key | test("^GIT_CONFIG($|_)") | not))
1922
+ | with_entries(select(.key | test(
1923
+ "^(ASDF_|COREPACK_)|^npm_config_(prefix|cache|logs[_-]dir|umask)$";
1924
+ "i"
1925
+ ) | not))
1926
+ | with_entries(select(.key | test("^UAI_") | not))
1214
1927
  | del(
1215
1928
  .GH_TOKEN,
1216
1929
  .GITHUB_TOKEN,
@@ -1230,6 +1943,16 @@ compose_env_override=$(printf '%s' "$project_env_json" | jq -c \
1230
1943
  .HOME,
1231
1944
  .XDG_CONFIG_HOME,
1232
1945
  .XDG_DATA_HOME,
1946
+ .XDG_CACHE_HOME,
1947
+ .PATH,
1948
+ .LD_PRELOAD,
1949
+ .LD_AUDIT,
1950
+ .LD_LIBRARY_PATH,
1951
+ .NODE_OPTIONS,
1952
+ .NODE_PATH,
1953
+ .SHELLOPTS,
1954
+ .BASHOPTS,
1955
+ .PS4,
1233
1956
  .BASH_ENV,
1234
1957
  .ENV,
1235
1958
  .UAI_GIT_TRANSPORT,
@@ -1241,18 +1964,466 @@ compose_env_override=$(printf '%s' "$project_env_json" | jq -c \
1241
1964
  .UAI_EDITOR_THEME
1242
1965
  )
1243
1966
  | delpaths($preview | keys | map([.]))
1244
- | {services:{app:{environment:.}}}
1245
1967
  ')
1246
- if [ "$has_derived" = "1" ]; then
1247
- printf '%s\n' "$compose_env_override" | docker compose -p "$compose_project" \
1248
- -f "$task_uai_dir/docker-compose.yml" -f - up -d --build >/dev/null
1968
+ compose_env_override=""
1969
+ if [ "$TASK_RUNTIME_MODE" = "docker" ]; then
1970
+ compose_env_override=$(printf '%s\n' "$filtered_project_env" | jq -c \
1971
+ --arg image "$runtime_image_id" \
1972
+ '{services:{app:{environment:.,image:$image,pull_policy:"never"}}}')
1973
+ fi
1974
+
1975
+ # Detect a tag replacement that completed during materialization and fail with
1976
+ # a clear retry instead of starting a task under a stale generation. The
1977
+ # immutable stdin override below also closes the remaining recheck→up window.
1978
+ step "CONTAINER_INIT_FAILED" "verify task image identity"
1979
+ current_runtime_image_id=""
1980
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
1981
+ if ! current_runtime_image_id=$("$CONTAINER_CLI" image inspect \
1982
+ "$runtime_image" 2>/dev/null | jq -r '.[0].id // empty' 2>/dev/null); then
1983
+ require_docker_after_failed_step "verify task image identity"
1984
+ emit_err "CONTAINER_INIT_FAILED" \
1985
+ "Uai could not re-check this task's image after runtime materialization. Retry after the Apple container runtime is stable." \
1986
+ "verify task image identity"
1987
+ fi
1988
+ elif ! current_runtime_image_id=$(docker image inspect --format '{{.Id}}' \
1989
+ "$runtime_image" 2>/dev/null); then
1990
+ require_docker_after_failed_step "verify task image identity"
1991
+ emit_err "CONTAINER_INIT_FAILED" \
1992
+ "Uai could not re-check this task's image after runtime materialization. Retry after Docker is stable." \
1993
+ "verify task image identity"
1994
+ fi
1995
+ if [ "$current_runtime_image_id" != "$runtime_image_id" ]; then
1996
+ emit_err "CONTAINER_INIT_FAILED" \
1997
+ "This task's image changed while Uai was materializing runtimes. Retry so the new image receives a complete runtime proof before any task container starts." \
1998
+ "verify task image identity"
1999
+ fi
2000
+ reject_workspace_runtime_shadow
2001
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2002
+ step "COMPOSE_UP_FAILED" "start task container"
2003
+ # The Apple CLI takes environment through an env-file whose values are read
2004
+ # verbatim from the first `=` to the newline. A value containing a newline
2005
+ # cannot survive that framing — refuse it rather than truncate a secret.
2006
+ if ! printf '%s\n' "$filtered_project_env" | jq -e \
2007
+ 'to_entries | all(.value | type == "string" and (test("[\r\n]") | not))' \
2008
+ >/dev/null 2>&1; then
2009
+ emit_err "COMPOSE_UP_FAILED" \
2010
+ "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." \
2011
+ "start task container"
2012
+ fi
2013
+ task_env_file=$(mktemp "$task_uai_dir/.task-env.XXXXXX")
2014
+ chmod 0600 "$task_env_file"
2015
+ {
2016
+ # Mirrors the Compose service environment exactly (see the rendered
2017
+ # docker-compose.yml for the rationale on each block). Later lines win in
2018
+ # both dialects, so ordering here preserves the Compose precedence:
2019
+ # static authority, engine tokens, declared project values, preview env.
2020
+ printf 'UAI_GIT_TRANSPORT=%s\n' "$uai_git_transport"
2021
+ printf 'GH_CONFIG_DIR=/home/node/.config/gh\n'
2022
+ printf 'PATH=%s\n' "$TASK_RUNTIME_PATH"
2023
+ printf 'HOME=/home/node\n'
2024
+ printf 'XDG_CONFIG_HOME=/home/node/.config\n'
2025
+ printf 'XDG_DATA_HOME=/home/node/.local/share\n'
2026
+ printf 'XDG_CACHE_HOME=/home/node/.cache\n'
2027
+ printf 'ASDF_DIR=/opt/asdf\n'
2028
+ printf 'ASDF_DATA_DIR=/opt/asdf-data\n'
2029
+ printf 'ASDF_CONFIG_FILE=/dev/null\n'
2030
+ printf 'ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions\n'
2031
+ printf 'ASDF_NODEJS_VERSION=\n'
2032
+ printf 'ASDF_PYTHON_VERSION=\n'
2033
+ printf 'ASDF_GOLANG_VERSION=\n'
2034
+ printf 'ASDF_RUBY_VERSION=\n'
2035
+ printf 'ASDF_RUST_VERSION=\n'
2036
+ printf 'ASDF_SKIP_RESHIM=1\n'
2037
+ printf 'COREPACK_HOME=/opt/asdf-data/corepack\n'
2038
+ printf 'COREPACK_DEFAULT_TO_LATEST=0\n'
2039
+ printf 'COREPACK_ENV_FILE=0\n'
2040
+ printf 'COREPACK_NPM_REGISTRY=https://registry.npmjs.org\n'
2041
+ printf 'COREPACK_ENABLE_NETWORK=0\n'
2042
+ printf 'COREPACK_ENABLE_AUTO_PIN=0\n'
2043
+ printf 'COREPACK_ENABLE_DOWNLOAD_PROMPT=0\n'
2044
+ printf 'COREPACK_ENABLE_PROJECT_SPEC=1\n'
2045
+ printf 'COREPACK_ENABLE_STRICT=1\n'
2046
+ printf 'COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=0\n'
2047
+ printf 'COREPACK_INTEGRITY_CHECK=1\n'
2048
+ printf 'NPM_CONFIG_PREFIX=%s\n' "$TASK_NPM_PREFIX"
2049
+ printf 'npm_config_prefix=%s\n' "$TASK_NPM_PREFIX"
2050
+ printf 'NPM_CONFIG_CACHE=%s\n' "$TASK_NPM_CACHE"
2051
+ printf 'npm_config_cache=%s\n' "$TASK_NPM_CACHE"
2052
+ printf 'NPM_CONFIG_LOGS_DIR=%s/_logs\n' "$TASK_NPM_CACHE"
2053
+ printf 'npm_config_logs_dir=%s/_logs\n' "$TASK_NPM_CACHE"
2054
+ printf 'NPM_CONFIG_UMASK=0002\n'
2055
+ printf 'npm_config_umask=0002\n'
2056
+ printf 'UAI_WORKSPACE=/workspace\n'
2057
+ printf 'UAI_RUNTIME_PROJECTS=/run/uai/runtime-projects\n'
2058
+ printf 'LD_PRELOAD=\n'
2059
+ printf 'LD_AUDIT=\n'
2060
+ printf 'LD_LIBRARY_PATH=\n'
2061
+ printf 'NODE_OPTIONS=\n'
2062
+ printf 'NODE_PATH=\n'
2063
+ printf 'PS4=\n'
2064
+ printf 'BASH_ENV=\n'
2065
+ printf 'ENV=\n'
2066
+ printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\n' "${CLAUDE_CODE_OAUTH_TOKEN:-}"
2067
+ printf 'ANTHROPIC_API_KEY=%s\n' "${ANTHROPIC_API_KEY:-}"
2068
+ printf 'ANTHROPIC_AUTH_TOKEN=%s\n' "${ANTHROPIC_AUTH_TOKEN:-}"
2069
+ printf 'XAI_API_KEY=%s\n' "${XAI_API_KEY:-}"
2070
+ printf 'PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers\n'
2071
+ printf '%s\n' "$filtered_project_env" \
2072
+ | jq -r 'to_entries[] | "\(.key)=\(.value)"'
2073
+ while IFS= read -r pe_entry; do
2074
+ [ -n "$pe_entry" ] || continue
2075
+ pe_key=$(jq -r '.key' <<<"$pe_entry")
2076
+ pe_val=$(jq -r '.value' <<<"$pe_entry")
2077
+ [ -n "$pe_key" ] || continue
2078
+ if [ "$pe_key" != "UAI_EDITOR_THEME" ] \
2079
+ && is_reserved_task_env_name "$pe_key"; then
2080
+ continue
2081
+ fi
2082
+ printf '%s=%s\n' "$pe_key" "$pe_val"
2083
+ done < <(jq -c 'to_entries[]?' <<<"$preview_env_raw")
2084
+ } > "$task_env_file"
2085
+ # The mount set matches the rendered Compose file's app service one-for-one
2086
+ # (minus host-gateway and group_add, which have no Apple equivalent and are
2087
+ # not needed — ownership maps transparently across the VM boundary).
2088
+ apple_run_args=(
2089
+ -v "$task_workspace:/workspace"
2090
+ -v "$task_workspace:$task_workspace"
2091
+ )
2092
+ if [ "$task_workspace_physical" != "$task_workspace" ]; then
2093
+ apple_run_args+=( -v "$task_workspace:$task_workspace_physical" )
2094
+ fi
2095
+ while IFS= read -r vol_obj; do
2096
+ [ -n "$vol_obj" ] || continue
2097
+ vol_pid=$(jq -r '.id' <<<"$vol_obj")
2098
+ apple_run_args+=(
2099
+ -v "$task_uai_dir/repos/$vol_pid.git:$task_uai_dir/repos/$vol_pid.git"
2100
+ )
2101
+ if [ "$task_uai_dir_physical" != "$task_uai_dir" ]; then
2102
+ apple_run_args+=(
2103
+ -v "$task_uai_dir/repos/$vol_pid.git:$task_uai_dir_physical/repos/$vol_pid.git"
2104
+ )
2105
+ fi
2106
+ done < <(jq -c '.[]' <<<"$projects_json")
2107
+ apple_run_args+=(
2108
+ -v "$UAI_INIT_SOURCE:/usr/local/bin/uai-init:ro"
2109
+ -v "$runtime_projects_file:/run/uai/runtime-projects:ro"
2110
+ # ADR-108: the shared volume attaches READ-ONLY at a staging path and an
2111
+ # in-guest overlay presents it writable at /opt/asdf-data (upper = the
2112
+ # task's own layer). The shared volume is never written on the task path,
2113
+ # so any number of tasks coexist and first-time installs need no window.
2114
+ -v "$ASDF_VOLUME:/run/uai/asdf-lower:ro"
2115
+ # In-container materialization (post-overlay) uses the packaged current
2116
+ # materializer + Corepack pin, exactly like the retired maintenance
2117
+ # container did.
2118
+ -v "$RUNTIME_MATERIALIZER_SOURCE:/usr/local/bin/uai-materialize-runtimes:ro"
2119
+ -v "$COREPACK_VERSION_SOURCE:/usr/local/share/uai/corepack-version:ro"
2120
+ -v "$PW_BROWSERS_DIR:/opt/pw-browsers"
2121
+ )
2122
+ if [ "$shared_files_mode" != "off" ]; then
2123
+ shared_suffix=""
2124
+ [ "$shared_files_mode" = "ro" ] && shared_suffix=":ro"
2125
+ if [ -n "$shared_owner_org" ]; then
2126
+ apple_run_args+=(
2127
+ -v "$shared_root/orgs/$shared_owner_org:/workspace/files/org$shared_suffix"
2128
+ )
2129
+ fi
2130
+ if [ -n "$shared_owner_user" ]; then
2131
+ apple_run_args+=(
2132
+ -v "$shared_root/users/$shared_owner_user:/workspace/files/me$shared_suffix"
2133
+ )
2134
+ fi
2135
+ fi
2136
+ apple_up_rc=0
2137
+ # CAP_SYS_ADMIN: required for the ADR-108 in-guest overlay mount. The
2138
+ # capability is confined by the VM boundary — the guest kernel is the
2139
+ # task's own, so this grants nothing on the host.
2140
+ "$CONTAINER_CLI" run -d --init \
2141
+ --name "$app_container" \
2142
+ -l "com.uai.task=$task_id" \
2143
+ --cap-add CAP_SYS_ADMIN \
2144
+ --workdir /workspace \
2145
+ --memory "$APPLE_TASK_MEMORY" \
2146
+ --cpus "$APPLE_TASK_CPUS" \
2147
+ --env-file "$task_env_file" \
2148
+ "${apple_run_args[@]}" \
2149
+ "$runtime_image" >/dev/null || apple_up_rc=$?
2150
+ rm -f "$task_env_file"
2151
+ if [ "$apple_up_rc" -ne 0 ]; then
2152
+ require_docker_after_failed_step "start task container"
2153
+ emit_err "COMPOSE_UP_FAILED" \
2154
+ "Uai could not start this task's container on the Apple container runtime. Retry the task." \
2155
+ "start task container"
2156
+ fi
2157
+ # The runtime cannot start a container by immutable image id, so prove the
2158
+ # tag still resolves to the pinned bytes now that the container exists. A
2159
+ # concurrent retag loses either way: before create, the recheck above; here,
2160
+ # the container is quarantined before credentials are installed.
2161
+ apple_post_up_image_id=$("$CONTAINER_CLI" image inspect "$runtime_image" \
2162
+ 2>/dev/null | jq -r '.[0].id // empty' 2>/dev/null) \
2163
+ || apple_post_up_image_id=""
2164
+ if [ "$apple_post_up_image_id" != "$runtime_image_id" ]; then
2165
+ if ! quarantine_untrusted_app_container; then
2166
+ emit_err "CONTAINER_INIT_FAILED" \
2167
+ "This task's image changed while its container was being created, and Uai could not prove the container stopped. Stop it manually before retrying." \
2168
+ "verify task image identity"
2169
+ fi
2170
+ emit_err "CONTAINER_INIT_FAILED" \
2171
+ "This task's image changed while its container was being created. Retry so the new image receives a complete runtime proof first." \
2172
+ "verify task image identity"
2173
+ fi
1249
2174
  else
2175
+ step "COMPOSE_UP_FAILED" "docker compose up -d"
1250
2176
  printf '%s\n' "$compose_env_override" | docker compose -p "$compose_project" \
1251
- -f "$task_uai_dir/docker-compose.yml" -f - up -d >/dev/null
2177
+ -f "$task_uai_dir/docker-compose.yml" -f - up -d --no-build >/dev/null
2178
+ fi
2179
+
2180
+ # Rendered YAML is not runtime evidence: Compose could reuse unexpected state,
2181
+ # or a compatibility bug could silently drop `:ro`. Inspect the exact created
2182
+ # app before it receives credentials. There must be one named volume at this
2183
+ # destination, with Docker itself reporting the mount read-only, and the same
2184
+ # shared volume must not be exposed again at any other destination.
2185
+ step "CONTAINER_INIT_FAILED" "verify task runtime isolation"
2186
+ app_mounts_json=""
2187
+ apple_app_snapshot=""
2188
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2189
+ # One container snapshot backs both the mount and environment proofs, so
2190
+ # they describe the same observed state.
2191
+ apple_app_snapshot=$(crt inspect "$app_container" 2>/dev/null) \
2192
+ || apple_app_snapshot=""
2193
+ app_mounts_json=$(printf '%s\n' "$apple_app_snapshot" \
2194
+ | jq -c '.[0].configuration.mounts // empty' 2>/dev/null) \
2195
+ || app_mounts_json=""
2196
+ fi
2197
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ] \
2198
+ && [ -z "$app_mounts_json" ]; then
2199
+ require_docker_after_failed_step "inspect task runtime isolation"
2200
+ if ! quarantine_untrusted_app_container; then
2201
+ emit_err "CONTAINER_INIT_FAILED" \
2202
+ "Uai could not inspect or stop the task container before credentials were installed. Stop it manually before retrying." \
2203
+ "verify task runtime isolation"
2204
+ fi
2205
+ emit_err "CONTAINER_INIT_FAILED" \
2206
+ "Uai could not inspect the task container's shared runtime mount. Retry to recreate it safely." \
2207
+ "verify task runtime isolation"
2208
+ elif [ "$TASK_RUNTIME_MODE" = "docker" ] \
2209
+ && ! app_mounts_json=$(docker inspect --format '{{json .Mounts}}' \
2210
+ "$app_container" 2>/dev/null); then
2211
+ require_docker_after_failed_step "inspect task runtime isolation"
2212
+ if ! quarantine_untrusted_app_container; then
2213
+ emit_err "CONTAINER_INIT_FAILED" \
2214
+ "Uai could not inspect or stop the task container before credentials were installed. Stop it manually before retrying." \
2215
+ "verify task runtime isolation"
2216
+ fi
2217
+ emit_err "CONTAINER_INIT_FAILED" \
2218
+ "Uai could not inspect the task container's shared runtime mount. Retry to recreate it safely." \
2219
+ "verify task runtime isolation"
2220
+ fi
2221
+ app_config_env_json=""
2222
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2223
+ app_config_env_json=$(printf '%s\n' "$apple_app_snapshot" \
2224
+ | jq -c '.[0].configuration.initProcess.environment // empty' \
2225
+ 2>/dev/null) || app_config_env_json=""
2226
+ fi
2227
+ if { [ "$TASK_RUNTIME_MODE" = "apple-container" ] \
2228
+ && [ -z "$app_config_env_json" ]; } \
2229
+ || { [ "$TASK_RUNTIME_MODE" = "docker" ] \
2230
+ && ! app_config_env_json=$(docker inspect \
2231
+ --format '{{json .Config.Env}}' "$app_container" 2>/dev/null); }; then
2232
+ require_docker_after_failed_step "inspect task runtime environment"
2233
+ if ! quarantine_untrusted_app_container; then
2234
+ emit_err "CONTAINER_INIT_FAILED" \
2235
+ "Uai could not inspect or stop the task container before credentials were installed. Stop it manually before retrying." \
2236
+ "verify task runtime isolation"
2237
+ fi
2238
+ emit_err "CONTAINER_INIT_FAILED" \
2239
+ "Uai could not inspect the task container's runtime environment. Retry to recreate it safely." \
2240
+ "verify task runtime isolation"
2241
+ fi
2242
+ # The two runtimes report mounts in different shapes; each filter asserts the
2243
+ # same contract — exactly one /opt/asdf-data mount, it is the shared
2244
+ # uai-asdf-data volume, the runtime itself reports it read-only, and that
2245
+ # volume appears at no other destination.
2246
+ mounts_proof_filter='
2247
+ [ .[] | select(.Destination == "/opt/asdf-data") ] as $runtime
2248
+ | [ .[] | select(.Name? == "uai-asdf-data") ] as $shared
2249
+ | ($runtime | length) == 1
2250
+ and ($shared | length) == 1
2251
+ and $runtime[0].Type == "volume"
2252
+ and $runtime[0].Name == "uai-asdf-data"
2253
+ and $runtime[0].RW == false
2254
+ '
2255
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2256
+ # ADR-108: the shared volume attaches READ-ONLY at the overlay staging
2257
+ # path, appears nowhere else, and /opt/asdf-data is NOT a host mount at
2258
+ # all — the writable view there is the in-guest overlay, proven from
2259
+ # inside the guest right after this (host inspect cannot see guest
2260
+ # mounts).
2261
+ mounts_proof_filter='
2262
+ [ .[] | select(.destination == "/run/uai/asdf-lower") ] as $runtime
2263
+ | [ .[] | select((.type.volume.name? // "") == "uai-asdf-data") ] as $shared
2264
+ | ($runtime | length) == 1
2265
+ and ($shared | length) == 1
2266
+ and (($runtime[0].type.volume.name? // "") == "uai-asdf-data")
2267
+ and ((($runtime[0].options // []) | index("ro")) != null)
2268
+ and ((($runtime[0].options // []) | index("rw")) == null)
2269
+ and (([ .[] | select(.destination == "/opt/asdf-data") ] | length) == 0)
2270
+ '
2271
+ fi
2272
+ if ! printf '%s\n' "$app_mounts_json" \
2273
+ | jq -e "$mounts_proof_filter" >/dev/null 2>&1; then
2274
+ if ! quarantine_untrusted_app_container; then
2275
+ emit_err "CONTAINER_INIT_FAILED" \
2276
+ "Uai found an unsafe shared runtime mount and could not prove the task container stopped. Stop it manually before retrying." \
2277
+ "verify task runtime isolation"
2278
+ fi
2279
+ emit_err "CONTAINER_INIT_FAILED" \
2280
+ "Uai stopped this task because its shared runtime cache was not the exact read-only uai-asdf-data volume. Retry to recreate the container safely." \
2281
+ "verify task runtime isolation"
2282
+ fi
2283
+ if ! printf '%s\n' "$app_config_env_json" | jq -e \
2284
+ --arg path "$TASK_RUNTIME_PATH" \
2285
+ --arg prefix "$TASK_NPM_PREFIX" \
2286
+ --arg cache "$TASK_NPM_CACHE" '
2287
+ def is_owned_npm_config($owned):
2288
+ ascii_downcase as $folded
2289
+ | ($folded | startswith("npm_config_"))
2290
+ and (($owned | index($folded[11:] | gsub("_"; "-"))) != null);
2291
+ (. // []) as $env
2292
+ | [
2293
+ "NPM_CONFIG_PREFIX=\($prefix)",
2294
+ "npm_config_prefix=\($prefix)",
2295
+ "NPM_CONFIG_CACHE=\($cache)",
2296
+ "npm_config_cache=\($cache)",
2297
+ "NPM_CONFIG_LOGS_DIR=\($cache)/_logs",
2298
+ "npm_config_logs_dir=\($cache)/_logs",
2299
+ "NPM_CONFIG_UMASK=0002",
2300
+ "npm_config_umask=0002"
2301
+ ] as $required
2302
+ | [
2303
+ "prefix", "cache", "logs-dir", "umask"
2304
+ ] as $owned
2305
+ | ($env | type == "array")
2306
+ and all($env[]; type == "string")
2307
+ and all($required[];
2308
+ . as $required_value
2309
+ | ([ $env[] | select(. == $required_value) ] | length) == 1
2310
+ )
2311
+ and ([
2312
+ $env[]
2313
+ | split("=")[0]
2314
+ | select(is_owned_npm_config($owned))
2315
+ ] | length) == ($required | length)
2316
+ and ([ $env[] | select(. == "PATH=\($path)") ] | length) == 1
2317
+ and ([ $env[] | select(. == "ASDF_DATA_DIR=/opt/asdf-data") ] | length) == 1
2318
+ and ([ $env[] | select(. == "ASDF_SKIP_RESHIM=1") ] | length) == 1
2319
+ and ([ $env[] | select(. == "COREPACK_HOME=/opt/asdf-data/corepack") ] | length) == 1
2320
+ and ([ $env[] | select(. == "COREPACK_ENABLE_NETWORK=0") ] | length) == 1
2321
+ ' >/dev/null 2>&1; then
2322
+ if ! quarantine_untrusted_app_container; then
2323
+ emit_err "CONTAINER_INIT_FAILED" \
2324
+ "Uai found an unsafe task runtime environment and could not prove the container stopped. Stop it manually before retrying." \
2325
+ "verify task runtime isolation"
2326
+ fi
2327
+ emit_err "CONTAINER_INIT_FAILED" \
2328
+ "Uai 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." \
2329
+ "verify task runtime isolation"
2330
+ fi
2331
+
2332
+ # -----------------------------------------------------------------------------
2333
+ # 6a. ADR-108 (apple only): establish the in-guest runtime overlay, then
2334
+ # materialize runtimes INSIDE the container. The shared volume is never
2335
+ # written on the task path: the read-only lower proved above merges under
2336
+ # a task-private upper layer, so a missing toolchain installs into this
2337
+ # task's own layer with zero shared-volume coordination. Both steps run
2338
+ # before any credential, editor, or agent work; a failure quarantines the
2339
+ # container exactly like the isolation proofs above.
2340
+ # -----------------------------------------------------------------------------
2341
+
2342
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2343
+ step "CONTAINER_INIT_FAILED" "establish task runtime overlay"
2344
+ overlay_rc=0
2345
+ "$CONTAINER_CLI" exec -u root "$app_container" /bin/sh -c '
2346
+ set -e
2347
+ /bin/mkdir -p /var/lib/uai/asdf-upper /var/lib/uai/asdf-work /opt/asdf-data
2348
+ # The merged root inherits the UPPER directory'"'"'s metadata; without this
2349
+ # the node user cannot write the merged view at all (live 2026-08-18).
2350
+ /bin/chown node:node /var/lib/uai/asdf-upper
2351
+ if ! /bin/grep -q "^overlay /opt/asdf-data overlay " /proc/mounts; then
2352
+ /bin/mount -t overlay overlay \
2353
+ -o lowerdir=/run/uai/asdf-lower,upperdir=/var/lib/uai/asdf-upper,workdir=/var/lib/uai/asdf-work \
2354
+ /opt/asdf-data
2355
+ fi
2356
+ # Prove the exact shape from the guest mount table, not from trust —
2357
+ # token-exact (comma-delimited; substring matching accepted
2358
+ # lowerdir=/run/uai/asdf-lower-evil), exactly ONE effective mount at the
2359
+ # merged path and at the lower, a read-only lower, and a real node-owned
2360
+ # upper directory.
2361
+ overlay_lines=$(/bin/grep -c "^overlay /opt/asdf-data overlay " /proc/mounts || true)
2362
+ [ "$overlay_lines" = "1" ] || { echo "expected exactly one /opt/asdf-data overlay, found $overlay_lines" >&2; exit 1; }
2363
+ any_lines=$(/bin/grep -c " /opt/asdf-data " /proc/mounts || true)
2364
+ [ "$any_lines" = "1" ] || { echo "unexpected extra mounts at /opt/asdf-data" >&2; exit 1; }
2365
+ opts=$(/bin/grep "^overlay /opt/asdf-data overlay " /proc/mounts | /usr/bin/cut -d" " -f4)
2366
+ case ",$opts," in *",lowerdir=/run/uai/asdf-lower,"*) ;; *) echo "overlay lower is foreign" >&2; exit 1 ;; esac
2367
+ case ",$opts," in *",upperdir=/var/lib/uai/asdf-upper,"*) ;; *) echo "overlay upper is foreign" >&2; exit 1 ;; esac
2368
+ case ",$opts," in *",workdir=/var/lib/uai/asdf-work,"*) ;; *) echo "overlay workdir is foreign" >&2; exit 1 ;; esac
2369
+ case ",$opts," in *",ro,"*) echo "overlay is unexpectedly read-only" >&2; exit 1 ;; esac
2370
+ lower_lines=$(/bin/grep -c " /run/uai/asdf-lower " /proc/mounts || true)
2371
+ [ "$lower_lines" = "1" ] || { echo "expected exactly one lower attachment, found $lower_lines" >&2; exit 1; }
2372
+ lopts=$(/bin/grep " /run/uai/asdf-lower " /proc/mounts | /usr/bin/cut -d" " -f4)
2373
+ case ",$lopts," in *",ro,"*) ;; *) echo "lower attachment is not read-only" >&2; exit 1 ;; esac
2374
+ [ ! -L /var/lib/uai/asdf-upper ] || { echo "upper is a symlink" >&2; exit 1; }
2375
+ [ -d /var/lib/uai/asdf-upper ] || { echo "upper is not a directory" >&2; exit 1; }
2376
+ [ "$(/usr/bin/stat -c %U /var/lib/uai/asdf-upper)" = "node" ] || { echo "upper is not node-owned" >&2; exit 1; }
2377
+ /usr/bin/test -d /opt/asdf-data/plugins
2378
+ ' >/dev/null || overlay_rc=$?
2379
+ if [ "$overlay_rc" -ne 0 ]; then
2380
+ require_docker_after_failed_step "establish task runtime overlay"
2381
+ if ! quarantine_untrusted_app_container; then
2382
+ emit_err "CONTAINER_INIT_FAILED" \
2383
+ "Uai could not establish this task's runtime overlay, and could not prove the container stopped. Stop it manually before retrying." \
2384
+ "establish task runtime overlay"
2385
+ fi
2386
+ emit_err "CONTAINER_INIT_FAILED" \
2387
+ "Uai could not establish this task's runtime overlay on the Apple container runtime. Retry the task." \
2388
+ "establish task runtime overlay"
2389
+ fi
2390
+
2391
+ if [ "$project_count" -gt 0 ]; then
2392
+ step "CONTAINER_INIT_FAILED" "materialize task runtimes in container"
2393
+ materialization_rc=0
2394
+ "$CONTAINER_CLI" exec -u node -w /workspace "$app_container" \
2395
+ /usr/bin/env -i \
2396
+ HOME=/home/node \
2397
+ ASDF_DIR=/opt/asdf \
2398
+ ASDF_DATA_DIR=/opt/asdf-data \
2399
+ PATH=/opt/asdf-data/shims:/opt/asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
2400
+ /usr/bin/timeout --signal=TERM --kill-after=10s 600s \
2401
+ /usr/local/bin/uai-materialize-runtimes >&2 || materialization_rc=$?
2402
+ if [ "$materialization_rc" -ne 0 ]; then
2403
+ require_docker_after_failed_step "materialize task runtimes in container"
2404
+ if ! quarantine_untrusted_app_container; then
2405
+ emit_err "CONTAINER_INIT_FAILED" \
2406
+ "Runtime materialization failed and Uai could not prove the task container stopped. Stop it manually before retrying." \
2407
+ "materialize task runtimes in container"
2408
+ fi
2409
+ case "$materialization_rc" in
2410
+ 124|137)
2411
+ emit_err "CONTAINER_INIT_FAILED" \
2412
+ "Runtime materialization did not finish inside Uai's 10-minute startup budget. Retry the task: Uai re-checks every required runtime and package manager before agents start." \
2413
+ "materialize task runtimes in container"
2414
+ ;;
2415
+ *)
2416
+ emit_err "CONTAINER_INIT_FAILED" \
2417
+ "Uai 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." \
2418
+ "materialize task runtimes in container"
2419
+ ;;
2420
+ esac
2421
+ fi
2422
+ fi
1252
2423
  fi
1253
2424
 
1254
2425
  # -----------------------------------------------------------------------------
1255
- # 6. Container init — copy AI creds in, install deps, launch code-server.
2426
+ # 7. Container init — copy AI creds in, install deps, launch code-server.
1256
2427
  # -----------------------------------------------------------------------------
1257
2428
 
1258
2429
  step "CONTAINER_INIT_FAILED" "uai-init (deps + code-server)"
@@ -1268,7 +2439,38 @@ managed_exec_env=(
1268
2439
  -e "PATH=/usr/bin:/bin"
1269
2440
  -e "XDG_CONFIG_HOME="
1270
2441
  -e "XDG_DATA_HOME="
2442
+ -e "XDG_CACHE_HOME="
2443
+ -e "ASDF_DIR=/opt/asdf"
2444
+ -e "ASDF_DATA_DIR=/opt/asdf-data"
2445
+ -e "ASDF_CONFIG_FILE=/dev/null"
2446
+ -e "ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions"
2447
+ -e "ASDF_NODEJS_VERSION="
2448
+ -e "ASDF_PYTHON_VERSION="
2449
+ -e "ASDF_GOLANG_VERSION="
2450
+ -e "ASDF_RUBY_VERSION="
2451
+ -e "ASDF_RUST_VERSION="
2452
+ -e "ASDF_SKIP_RESHIM=1"
2453
+ -e "COREPACK_HOME=/opt/asdf-data/corepack"
2454
+ -e "COREPACK_DEFAULT_TO_LATEST=0"
2455
+ -e "COREPACK_ENV_FILE=0"
2456
+ -e "COREPACK_NPM_REGISTRY=https://registry.npmjs.org"
2457
+ -e "COREPACK_ENABLE_NETWORK=0"
2458
+ -e "COREPACK_ENABLE_AUTO_PIN=0"
2459
+ -e "COREPACK_ENABLE_DOWNLOAD_PROMPT=0"
2460
+ -e "COREPACK_ENABLE_PROJECT_SPEC=1"
2461
+ -e "COREPACK_ENABLE_STRICT=1"
2462
+ -e "COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=0"
2463
+ -e "COREPACK_INTEGRITY_CHECK=1"
2464
+ -e "NPM_CONFIG_PREFIX=$TASK_NPM_PREFIX"
2465
+ -e "npm_config_prefix=$TASK_NPM_PREFIX"
2466
+ -e "NPM_CONFIG_CACHE=$TASK_NPM_CACHE"
2467
+ -e "npm_config_cache=$TASK_NPM_CACHE"
2468
+ -e "NPM_CONFIG_LOGS_DIR=$TASK_NPM_CACHE/_logs"
2469
+ -e "npm_config_logs_dir=$TASK_NPM_CACHE/_logs"
2470
+ -e "NPM_CONFIG_UMASK=0002"
2471
+ -e "npm_config_umask=0002"
1271
2472
  -e "LD_PRELOAD="
2473
+ -e "LD_AUDIT="
1272
2474
  -e "LD_LIBRARY_PATH="
1273
2475
  -e "DYLD_INSERT_LIBRARIES="
1274
2476
  -e "DYLD_LIBRARY_PATH="
@@ -1294,13 +2496,62 @@ managed_clean_env=(
1294
2496
  "PATH=/usr/bin:/bin"
1295
2497
  )
1296
2498
 
2499
+ # Runtime-bearing task initialization gets the same fixed environment as
2500
+ # Compose and agent launches. Unlike the root/control array above, this path
2501
+ # includes task-private global packages last so unique tools are usable while
2502
+ # they remain unable to shadow shared shims or system controls.
2503
+ task_runtime_exec_env=(
2504
+ -e "HOME=/home/node"
2505
+ -e "PATH=$TASK_RUNTIME_PATH"
2506
+ -e "XDG_CONFIG_HOME=/home/node/.config"
2507
+ -e "XDG_DATA_HOME=/home/node/.local/share"
2508
+ -e "XDG_CACHE_HOME=/home/node/.cache"
2509
+ -e "ASDF_DIR=/opt/asdf"
2510
+ -e "ASDF_DATA_DIR=/opt/asdf-data"
2511
+ -e "ASDF_CONFIG_FILE=/dev/null"
2512
+ -e "ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions"
2513
+ -e "ASDF_NODEJS_VERSION="
2514
+ -e "ASDF_PYTHON_VERSION="
2515
+ -e "ASDF_GOLANG_VERSION="
2516
+ -e "ASDF_RUBY_VERSION="
2517
+ -e "ASDF_RUST_VERSION="
2518
+ -e "ASDF_SKIP_RESHIM=1"
2519
+ -e "COREPACK_HOME=/opt/asdf-data/corepack"
2520
+ -e "COREPACK_DEFAULT_TO_LATEST=0"
2521
+ -e "COREPACK_ENV_FILE=0"
2522
+ -e "COREPACK_NPM_REGISTRY=https://registry.npmjs.org"
2523
+ -e "COREPACK_ENABLE_NETWORK=0"
2524
+ -e "COREPACK_ENABLE_AUTO_PIN=0"
2525
+ -e "COREPACK_ENABLE_DOWNLOAD_PROMPT=0"
2526
+ -e "COREPACK_ENABLE_PROJECT_SPEC=1"
2527
+ -e "COREPACK_ENABLE_STRICT=1"
2528
+ -e "COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=0"
2529
+ -e "COREPACK_INTEGRITY_CHECK=1"
2530
+ -e "NPM_CONFIG_PREFIX=$TASK_NPM_PREFIX"
2531
+ -e "npm_config_prefix=$TASK_NPM_PREFIX"
2532
+ -e "NPM_CONFIG_CACHE=$TASK_NPM_CACHE"
2533
+ -e "npm_config_cache=$TASK_NPM_CACHE"
2534
+ -e "NPM_CONFIG_LOGS_DIR=$TASK_NPM_CACHE/_logs"
2535
+ -e "npm_config_logs_dir=$TASK_NPM_CACHE/_logs"
2536
+ -e "NPM_CONFIG_UMASK=0002"
2537
+ -e "npm_config_umask=0002"
2538
+ -e "LD_PRELOAD="
2539
+ -e "LD_AUDIT="
2540
+ -e "LD_LIBRARY_PATH="
2541
+ -e "NODE_OPTIONS="
2542
+ -e "NODE_PATH="
2543
+ -e "PS4="
2544
+ -e "BASH_ENV="
2545
+ -e "ENV="
2546
+ )
2547
+
1297
2548
  # OpenCode and code-server share ~/.local/share. OpenCode credentials are
1298
2549
  # copied as root below, so creating only its leaf with `mkdir -p` can leave the
1299
2550
  # shared parent root-owned (live 2026-08-04). Then node cannot create
1300
2551
  # code-server's User profile and the editor silently falls back to its light,
1301
2552
  # full-chrome defaults. Repair both explicitly on every start/resume; GNU
1302
2553
  # `install -d` also corrects an existing directory with the wrong owner.
1303
- if ! docker exec -u root "${managed_exec_env[@]}" "$app_container" \
2554
+ if ! crt exec -u root "${managed_exec_env[@]}" "$app_container" \
1304
2555
  /usr/bin/install -d -o node -g node -m 0755 \
1305
2556
  /home/node/.local/share \
1306
2557
  /home/node/.local/share/opencode >/dev/null; then
@@ -1314,7 +2565,7 @@ fi
1314
2565
  # and never the host cache. The task-private ~/.gitconfig makes these entries
1315
2566
  # local to this container, and the read-before-add keeps resumes idempotent.
1316
2567
  step "CONTAINER_INIT_FAILED" "trust task Git worktrees"
1317
- container_safe_dirs=$(docker exec -u node "${managed_exec_env[@]}" \
2568
+ container_safe_dirs=$(crt exec -u node "${managed_exec_env[@]}" \
1318
2569
  "$app_container" "${managed_clean_env[@]}" /usr/bin/git config --global \
1319
2570
  --get-all safe.directory 2>/dev/null || true)
1320
2571
  while IFS= read -r safe_project; do
@@ -1329,7 +2580,7 @@ while IFS= read -r safe_project; do
1329
2580
  "$task_uai_dir_physical/repos/$safe_project_id.git"; do
1330
2581
  if ! printf '%s\n' "$container_safe_dirs" \
1331
2582
  | grep -Fqx -- "$safe_worktree"; then
1332
- if ! docker exec -u node "${managed_exec_env[@]}" "$app_container" \
2583
+ if ! crt exec -u node "${managed_exec_env[@]}" "$app_container" \
1333
2584
  "${managed_clean_env[@]}" /usr/bin/git config --global --add \
1334
2585
  safe.directory "$safe_worktree" >/dev/null; then
1335
2586
  emit_err "CONTAINER_INIT_FAILED" \
@@ -1350,7 +2601,7 @@ while IFS= read -r verify_project; do
1350
2601
  [ -n "$verify_project" ] || continue
1351
2602
  verify_project_slug=$(jq -r '.slug' <<<"$verify_project")
1352
2603
  verify_is_worktree=""
1353
- if ! verify_is_worktree=$(docker exec -u node "${managed_exec_env[@]}" \
2604
+ if ! verify_is_worktree=$(crt exec -u node "${managed_exec_env[@]}" \
1354
2605
  "$app_container" "${managed_clean_env[@]}" /usr/bin/git \
1355
2606
  -C "/workspace/$verify_project_slug" rev-parse \
1356
2607
  --is-inside-work-tree 2>/dev/null) \
@@ -1365,7 +2616,7 @@ done < <(jq -c '.[]' <<<"$projects_json")
1365
2616
  # account. Erase that Uai-managed state first for both connected and
1366
2617
  # disconnected starts; otherwise a no-credential resume could briefly reuse a
1367
2618
  # stale token before the asynchronous reconciler catches up.
1368
- if ! docker exec -u node "${managed_exec_env[@]}" "$app_container" \
2619
+ if ! crt exec -u node "${managed_exec_env[@]}" "$app_container" \
1369
2620
  "${managed_clean_env[@]}" /bin/sh -c '
1370
2621
  set -eu
1371
2622
  /bin/rm -f "$GH_CONFIG_DIR/hosts.yml"
@@ -1382,6 +2633,7 @@ if ! docker exec -u node "${managed_exec_env[@]}" "$app_container" \
1382
2633
  done
1383
2634
  test ! -e "$GH_CONFIG_DIR/hosts.yml"
1384
2635
  ' >/dev/null; then
2636
+ require_docker_after_failed_step "clear container GitHub credential"
1385
2637
  emit_err "GITHUB_AUTH_UNAVAILABLE" \
1386
2638
  "Uai could not clear the previous managed GitHub credential from the task container. Retry the task before doing GitHub work." \
1387
2639
  "clear container GitHub credential"
@@ -1396,16 +2648,18 @@ if [ -n "$github_credential_socket" ]; then
1396
2648
  if ! printf 'protocol=https\nhost=github.com\n\n' \
1397
2649
  | git_with_github_credential credential fill \
1398
2650
  | sed -n 's/^password=//p' \
1399
- | docker exec -i -u node "${managed_exec_env[@]}" "$app_container" \
2651
+ | crt exec -i -u node "${managed_exec_env[@]}" "$app_container" \
1400
2652
  "${managed_clean_env[@]}" /usr/bin/gh auth login --with-token \
1401
2653
  --hostname github.com --git-protocol https >/dev/null; then
2654
+ require_docker_after_failed_step "configure container GitHub credential"
1402
2655
  emit_err "GITHUB_AUTH_UNAVAILABLE" \
1403
2656
  "GitHub is connected on this host, but Uai could not install that credential in the task container. Retry the task; if it persists, reconnect GitHub on this host." \
1404
2657
  "configure container GitHub credential"
1405
2658
  fi
1406
- if ! docker exec -u node "${managed_exec_env[@]}" "$app_container" \
2659
+ if ! crt exec -u node "${managed_exec_env[@]}" "$app_container" \
1407
2660
  "${managed_clean_env[@]}" /usr/bin/gh auth setup-git \
1408
2661
  --hostname github.com >/dev/null; then
2662
+ require_docker_after_failed_step "configure container Git credential helper"
1409
2663
  emit_err "GITHUB_AUTH_UNAVAILABLE" \
1410
2664
  "GitHub is connected on this host, but Uai could not configure Git HTTPS in the task container. Retry the task; if it persists, reconnect GitHub on this host." \
1411
2665
  "configure container Git credential helper"
@@ -1418,11 +2672,11 @@ fi
1418
2672
  # the bind mount and leave a stale dentry, after which claude refuses to
1419
2673
  # start. docker cp gives the container its own stable copy. Best-effort.
1420
2674
  if [ -f "$UAI_OWNER_HOME/.claude.json" ]; then
1421
- docker cp "$UAI_OWNER_HOME/.claude.json" \
2675
+ crt cp "$UAI_OWNER_HOME/.claude.json" \
1422
2676
  "$app_container":/home/node/.claude.json >/dev/null 2>&1 \
1423
- || log "warning: docker cp of .claude.json failed; claude may need re-login"
1424
- docker exec -u root "$app_container" \
1425
- chown node:node /home/node/.claude.json >/dev/null 2>&1 || true
2677
+ || log "warning: crt cp of .claude.json failed; claude may need re-login"
2678
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
2679
+ /usr/bin/chown node:node /home/node/.claude.json >/dev/null 2>&1 || true
1426
2680
  fi
1427
2681
 
1428
2682
  # Copy Codex credentials/config into a task-private /home/node/.codex.
@@ -1432,18 +2686,18 @@ fi
1432
2686
  # docker cp preserves the host uid/gid, so a skipped chown leaves 0600
1433
2687
  # files owned by e.g. macOS 501:20 that the container's node (1000:1000)
1434
2688
  # cannot read — Codex then dies at startup.
1435
- if docker exec -u root "$app_container" \
1436
- mkdir -p /home/node/.codex >/dev/null; then
2689
+ if crt exec -u root "${managed_exec_env[@]}" "$app_container" \
2690
+ /usr/bin/mkdir -p /home/node/.codex >/dev/null; then
1437
2691
  for codex_item in auth.json config.toml AGENTS.md version.json installation_id rules; do
1438
2692
  if [ -e "$UAI_OWNER_HOME/.codex/$codex_item" ]; then
1439
- docker cp "$UAI_OWNER_HOME/.codex/$codex_item" \
2693
+ crt cp "$UAI_OWNER_HOME/.codex/$codex_item" \
1440
2694
  "$app_container":/home/node/.codex/ >/dev/null \
1441
- || log "ERROR: docker cp of .codex/$codex_item failed; Codex in this task may not authenticate"
2695
+ || log "ERROR: crt cp of .codex/$codex_item failed; Codex in this task may not authenticate"
1442
2696
  fi
1443
2697
  done
1444
- docker exec -u root "$app_container" \
1445
- chown -R node:node /home/node/.codex >/dev/null \
1446
- || log "ERROR: chown of /home/node/.codex failed — 0600 credentials remain unreadable by node. Fix: docker exec -u root $app_container chown -R node:node /home/node/.codex"
2698
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
2699
+ /usr/bin/chown -R node:node /home/node/.codex >/dev/null \
2700
+ || 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"
1447
2701
  else
1448
2702
  log "ERROR: mkdir /home/node/.codex failed in $app_container; Codex credentials were not injected"
1449
2703
  fi
@@ -1452,39 +2706,39 @@ fi
1452
2706
  # The Linux `kimi` binary is baked into the image; here we copy ONLY the
1453
2707
  # arch-independent config + credential (never the host's macOS bin/, nor live
1454
2708
  # session state) so the container's kimi runs on the operator's subscription.
1455
- docker exec -u root "$app_container" \
1456
- mkdir -p /home/node/.kimi-code/credentials >/dev/null 2>&1 || true
2709
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
2710
+ /usr/bin/mkdir -p /home/node/.kimi-code/credentials >/dev/null 2>&1 || true
1457
2711
  if [ -e "$UAI_OWNER_HOME/.kimi-code/config.toml" ]; then
1458
- docker cp "$UAI_OWNER_HOME/.kimi-code/config.toml" \
2712
+ crt cp "$UAI_OWNER_HOME/.kimi-code/config.toml" \
1459
2713
  "$app_container":/home/node/.kimi-code/config.toml >/dev/null 2>&1 \
1460
- || log "warning: docker cp of .kimi-code/config.toml failed; kimi may need re-login"
2714
+ || log "warning: crt cp of .kimi-code/config.toml failed; kimi may need re-login"
1461
2715
  fi
1462
2716
  if [ -e "$UAI_OWNER_HOME/.kimi-code/device_id" ]; then
1463
- docker cp "$UAI_OWNER_HOME/.kimi-code/device_id" \
2717
+ crt cp "$UAI_OWNER_HOME/.kimi-code/device_id" \
1464
2718
  "$app_container":/home/node/.kimi-code/device_id >/dev/null 2>&1 || true
1465
2719
  fi
1466
2720
  if [ -e "$UAI_OWNER_HOME/.kimi-code/credentials/kimi-code.json" ]; then
1467
- docker cp "$UAI_OWNER_HOME/.kimi-code/credentials/kimi-code.json" \
2721
+ crt cp "$UAI_OWNER_HOME/.kimi-code/credentials/kimi-code.json" \
1468
2722
  "$app_container":/home/node/.kimi-code/credentials/kimi-code.json >/dev/null 2>&1 \
1469
- || log "warning: docker cp of .kimi-code credential failed; kimi may need re-login"
2723
+ || log "warning: crt cp of .kimi-code credential failed; kimi may need re-login"
1470
2724
  fi
1471
- docker exec -u root "$app_container" \
1472
- chown -R node:node /home/node/.kimi-code >/dev/null 2>&1 || true
2725
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
2726
+ /usr/bin/chown -R node:node /home/node/.kimi-code >/dev/null 2>&1 || true
1473
2727
 
1474
2728
  # Copy Grok subscription/config into a task-private /home/node/.grok. The Linux
1475
2729
  # `grok` binary is baked into the image; here we copy the arch-independent
1476
2730
  # auth + config (auth.json is an OIDC token the CLI refreshes in-container).
1477
- docker exec -u root "$app_container" \
1478
- mkdir -p /home/node/.grok >/dev/null 2>&1 || true
2731
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
2732
+ /usr/bin/mkdir -p /home/node/.grok >/dev/null 2>&1 || true
1479
2733
  for grok_item in auth.json config.toml models_cache.json agent_id; do
1480
2734
  if [ -e "$UAI_OWNER_HOME/.grok/$grok_item" ]; then
1481
- docker cp "$UAI_OWNER_HOME/.grok/$grok_item" \
2735
+ crt cp "$UAI_OWNER_HOME/.grok/$grok_item" \
1482
2736
  "$app_container":/home/node/.grok/ >/dev/null 2>&1 \
1483
- || log "warning: docker cp of .grok/$grok_item failed; grok may need re-login"
2737
+ || log "warning: crt cp of .grok/$grok_item failed; grok may need re-login"
1484
2738
  fi
1485
2739
  done
1486
- docker exec -u root "$app_container" \
1487
- chown -R node:node /home/node/.grok >/dev/null 2>&1 || true
2740
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
2741
+ /usr/bin/chown -R node:node /home/node/.grok >/dev/null 2>&1 || true
1488
2742
 
1489
2743
  # Copy OpenCode provider auth into a task-private /home/node/.local/share/opencode
1490
2744
  # (ADR-077), same per-task-writable-copy pattern as Codex. The Linux `opencode`
@@ -1494,13 +2748,13 @@ docker exec -u root "$app_container" \
1494
2748
  # The shared parent and this leaf were created with node ownership above.
1495
2749
  for oc_item in auth.json config.json; do
1496
2750
  if [ -e "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" ]; then
1497
- docker cp "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" \
2751
+ crt cp "$UAI_OWNER_HOME/.local/share/opencode/$oc_item" \
1498
2752
  "$app_container":/home/node/.local/share/opencode/ >/dev/null 2>&1 \
1499
- || log "warning: docker cp of opencode/$oc_item failed; opencode may need re-login"
2753
+ || log "warning: crt cp of opencode/$oc_item failed; opencode may need re-login"
1500
2754
  fi
1501
2755
  done
1502
- docker exec -u root "$app_container" \
1503
- chown -R node:node /home/node/.local/share/opencode >/dev/null 2>&1 || true
2756
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
2757
+ /usr/bin/chown -R node:node /home/node/.local/share/opencode >/dev/null 2>&1 || true
1504
2758
 
1505
2759
  # Copy the resolved SSH identity into the container for commit/tag signing and
1506
2760
  # as the no-GitHub-connection transport fallback. Connected users clone, fetch,
@@ -1510,17 +2764,17 @@ docker exec -u root "$app_container" \
1510
2764
  # permissions problem hours later; live 2026-07-21). The channel-ensure path
1511
2765
  # (ensureTaskSshIdentity) re-asserts all of this on reconnects.
1512
2766
  if [ -f "$uai_identity_key" ]; then
1513
- docker exec -u root "$app_container" \
1514
- mkdir -p /home/node/.ssh >/dev/null 2>&1 \
2767
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
2768
+ /usr/bin/mkdir -p /home/node/.ssh >/dev/null 2>&1 \
1515
2769
  || log "warning: ssh setup: mkdir ~/.ssh failed in $app_container"
1516
- docker cp "$uai_identity_key" \
2770
+ crt cp "$uai_identity_key" \
1517
2771
  "$app_container":/home/node/.ssh/id_ed25519 >/dev/null 2>&1 \
1518
2772
  || log "warning: ssh setup: key copy failed — signing and SSH fallback unavailable"
1519
- docker cp "${uai_identity_key}.pub" \
2773
+ crt cp "${uai_identity_key}.pub" \
1520
2774
  "$app_container":/home/node/.ssh/id_ed25519.pub >/dev/null 2>&1 \
1521
2775
  || log "warning: ssh setup: pubkey copy failed (signing will be off)"
1522
- docker exec -u root "$app_container" sh -c \
1523
- 'chown -R node:node /home/node/.ssh && chmod 700 /home/node/.ssh && chmod 600 /home/node/.ssh/id_ed25519' \
2776
+ crt exec -u root "${managed_exec_env[@]}" "$app_container" /bin/sh -c \
2777
+ '/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' \
1524
2778
  >/dev/null 2>&1 \
1525
2779
  || log "warning: ssh setup: chown/chmod failed — key may be unreadable by node"
1526
2780
  fi
@@ -1531,26 +2785,70 @@ fi
1531
2785
  # anywhere on this path) used to fail the whole task over a step anyone can
1532
2786
  # re-run. Retry once, then come up DEGRADED with a warning the host surfaces
1533
2787
  # as a system note in the task feed.
2788
+ quarantine_runtime_authority_violation() {
2789
+ quarantine_untrusted_app_container
2790
+ }
2791
+
1534
2792
  init_warning=""
1535
- if ! docker exec "$app_container" /usr/local/bin/uai-init >/dev/null; then
2793
+ init_rc=0
2794
+ crt exec "${task_runtime_exec_env[@]}" "$app_container" \
2795
+ /usr/local/bin/uai-init >/dev/null || init_rc=$?
2796
+ if [ "$init_rc" -ne 0 ]; then
2797
+ require_docker_after_failed_step "uai-init (deps + code-server)"
2798
+ if [ "$init_rc" -eq 78 ]; then
2799
+ # Exit 78 is reserved for a host-authority violation, not an ordinary
2800
+ # best-effort dependency failure. Keep the already-created app unavailable
2801
+ # and prove it stopped before returning an actionable task-up failure.
2802
+ if ! quarantine_runtime_authority_violation; then
2803
+ emit_err "CONTAINER_INIT_FAILED" \
2804
+ "A workspace runtime-authority violation was detected, and Uai could not prove the task container stopped. Stop it manually before retrying." \
2805
+ "quarantine runtime-authority violation"
2806
+ fi
2807
+ emit_err "CONTAINER_INIT_FAILED" \
2808
+ "Uai 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." \
2809
+ "uai-init runtime authority"
2810
+ fi
1536
2811
  log "warning: uai-init failed — retrying once"
1537
2812
  sleep 2
1538
- if ! docker exec "$app_container" /usr/local/bin/uai-init >/dev/null; then
2813
+ init_rc=0
2814
+ crt exec "${task_runtime_exec_env[@]}" "$app_container" \
2815
+ /usr/local/bin/uai-init >/dev/null || init_rc=$?
2816
+ if [ "$init_rc" -ne 0 ]; then
2817
+ require_docker_after_failed_step "uai-init (deps + code-server)"
2818
+ if [ "$init_rc" -eq 78 ]; then
2819
+ if ! quarantine_runtime_authority_violation; then
2820
+ emit_err "CONTAINER_INIT_FAILED" \
2821
+ "A workspace runtime-authority violation was detected, and Uai could not prove the task container stopped. Stop it manually before retrying." \
2822
+ "quarantine runtime-authority violation"
2823
+ fi
2824
+ emit_err "CONTAINER_INIT_FAILED" \
2825
+ "Uai 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." \
2826
+ "uai-init runtime authority"
2827
+ fi
1539
2828
  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"
1540
2829
  log "warning: $init_warning"
1541
2830
  fi
1542
2831
  fi
1543
2832
 
1544
2833
  # -----------------------------------------------------------------------------
1545
- # 7. Discover code-server + preview ports, mark running, unlock.
2834
+ # 8. Discover code-server + preview ports, mark running, unlock.
1546
2835
  # -----------------------------------------------------------------------------
1547
2836
 
1548
2837
  # code-server: container exposes 8080; Docker auto-maps to a random host
1549
2838
  # port. Discover via `docker port`. Best-effort — if it fails the Editor
1550
2839
  # pane is just unavailable.
1551
- code_server_port=$(mapped_host_port "$app_container" 8080)
1552
- if [ -z "$code_server_port" ]; then
1553
- log "warning: could not discover code-server port for ${app_container} (editor pane unavailable)"
2840
+ code_server_port=""
2841
+ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2842
+ # No ports are published on the Apple runtime: the container's vmnet IP is
2843
+ # host-routable, so the Editor tunnel dials <container-ip>:8080 directly
2844
+ # (resolved per-request from the provider's route inspection).
2845
+ :
2846
+ else
2847
+ code_server_port=$(mapped_host_port "$app_container" 8080)
2848
+ if [ -z "$code_server_port" ]; then
2849
+ require_docker_after_failed_step "discover code-server port"
2850
+ log "warning: could not discover code-server port for ${app_container} (editor pane unavailable)"
2851
+ fi
1554
2852
  fi
1555
2853
 
1556
2854
  # ADR-043 Stage 2: preview ports are no longer published at task-up, so there is