@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.
- package/README.md +22 -5
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +80 -30
- package/images/standard/Dockerfile +36 -10
- package/images/standard/README.md +63 -18
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +308 -38
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/lib/agent-cli.ts +33 -2
- package/lib/agent.ts +46 -7
- package/lib/agents/claude.ts +13 -8
- package/lib/agents/codex.ts +11 -6
- package/lib/agents/cursor.ts +39 -29
- package/lib/agents/durable-proc.ts +20 -27
- package/lib/agents/factory.ts +9 -25
- package/lib/agents/grok.ts +43 -30
- package/lib/agents/kimi.ts +44 -29
- package/lib/agents/opencode.ts +43 -31
- package/lib/agents/proc.ts +149 -114
- package/lib/agents/transport.ts +62 -50
- package/lib/agents/types.ts +6 -4
- package/lib/apple-runtime-recycle.ts +236 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/browser-testing.ts +233 -93
- package/lib/codex-auth.ts +40 -6
- package/lib/command-db.ts +20 -0
- package/lib/container-runtime.ts +1338 -0
- package/lib/db.ts +1 -0
- package/lib/docker-exec.ts +87 -5
- package/lib/engine-accounts.ts +68 -5
- package/lib/engine-login.ts +1952 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +155 -0
- package/lib/env.ts +4 -0
- package/lib/git-diff.ts +98 -32
- package/lib/git-identity.ts +199 -87
- package/lib/github-tokens.ts +202 -91
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-logs.ts +962 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +49 -0
- package/lib/managed-runtime.ts +3644 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-gateway.ts +450 -23
- package/lib/orchestrator.ts +3051 -200
- package/lib/preview-sidecar.ts +68 -14
- package/lib/release-manifest.ts +708 -0
- package/lib/release-trust.ts +28 -0
- package/lib/runtime-activation-tail.ts +232 -0
- package/lib/runtime-archive.ts +1086 -0
- package/lib/runtime-authority.ts +79 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-state.ts +232 -12
- package/lib/skills.ts +24 -3
- package/lib/ssh.ts +18 -0
- package/lib/standard-image.ts +1104 -141
- package/lib/stopped-task-status-queue.ts +44 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-diff.ts +66 -46
- package/lib/task-environment/apple-container.ts +757 -0
- package/lib/task-environment/docker.ts +956 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +459 -0
- package/lib/task-environment/registry.ts +58 -0
- package/lib/task-environment/types.ts +408 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +585 -0
- package/lib/tunnel-registry.ts +135 -19
- package/lib/tunnel-runtime.ts +235 -0
- package/package.json +1 -1
- package/scripts/agent/_common.sh +123 -3
- package/scripts/agent/task-down.sh +146 -38
- package/scripts/agent/task-status.sh +19 -3
- package/scripts/agent/task-up.sh +1405 -107
- package/scripts/install/darwin.ts +848 -50
- package/scripts/install/linux.ts +838 -35
- package/scripts/install/types.ts +43 -0
- package/scripts/install/util.ts +215 -8
- package/scripts/install/win.ts +12 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/cli.ts +1464 -72
- package/src/event-outbox.ts +83 -4
- package/src/index.ts +766 -42
- package/src/main.ts +1398 -255
- package/src/paths.ts +17 -1
- package/src/protocol.ts +695 -1
- package/src/runtime-bootstrap.ts +165 -0
- package/src/ui/server.ts +46 -10
- package/src/ui/types.ts +37 -0
package/scripts/agent/task-up.sh
CHANGED
|
@@ -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.
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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 [
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
885
|
-
|
|
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:/
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1188
|
-
|
|
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 !
|
|
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
|
-
|
|
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.
|
|
1205
|
-
#
|
|
1206
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
-
#
|
|
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 !
|
|
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=$(
|
|
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 !
|
|
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=$(
|
|
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 !
|
|
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
|
-
|
|
|
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 !
|
|
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
|
-
|
|
2675
|
+
crt cp "$UAI_OWNER_HOME/.claude.json" \
|
|
1422
2676
|
"$app_container":/home/node/.claude.json >/dev/null 2>&1 \
|
|
1423
|
-
|| log "warning:
|
|
1424
|
-
|
|
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
|
|
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
|
-
|
|
2693
|
+
crt cp "$UAI_OWNER_HOME/.codex/$codex_item" \
|
|
1440
2694
|
"$app_container":/home/node/.codex/ >/dev/null \
|
|
1441
|
-
|| log "ERROR:
|
|
2695
|
+
|| log "ERROR: crt cp of .codex/$codex_item failed; Codex in this task may not authenticate"
|
|
1442
2696
|
fi
|
|
1443
2697
|
done
|
|
1444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
2723
|
+
|| log "warning: crt cp of .kimi-code credential failed; kimi may need re-login"
|
|
1470
2724
|
fi
|
|
1471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2735
|
+
crt cp "$UAI_OWNER_HOME/.grok/$grok_item" \
|
|
1482
2736
|
"$app_container":/home/node/.grok/ >/dev/null 2>&1 \
|
|
1483
|
-
|| log "warning:
|
|
2737
|
+
|| log "warning: crt cp of .grok/$grok_item failed; grok may need re-login"
|
|
1484
2738
|
fi
|
|
1485
2739
|
done
|
|
1486
|
-
|
|
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
|
-
|
|
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:
|
|
2753
|
+
|| log "warning: crt cp of opencode/$oc_item failed; opencode may need re-login"
|
|
1500
2754
|
fi
|
|
1501
2755
|
done
|
|
1502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
1552
|
-
if [
|
|
1553
|
-
|
|
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
|