@runuai/host 0.9.13 → 0.9.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +69 -4
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3070 -223
  53. package/lib/preview-sidecar.ts +57 -13
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +945 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +443 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1463 -109
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +871 -50
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
@@ -0,0 +1,1527 @@
1
+ #!/bin/bash
2
+ # Materialize project-declared runtimes into /opt/asdf-data.
3
+ #
4
+ # Host contract: run as `node` with /opt/asdf-data writable. On Docker that
5
+ # is the shared host-wide volume, mounted read-write in a dedicated
6
+ # maintenance container with /workspace read-only. On the Apple runtime
7
+ # (ADR-108) this runs INSIDE the app container, where /opt/asdf-data is the
8
+ # task's in-guest overlay (shared read-only lower + task-private upper) —
9
+ # installs land in the task's own layer and the shared volume is never
10
+ # written on the task path. The host supplies the total operation timeout;
11
+ # this executable supplies the bounded volume lock (cross-process on Docker,
12
+ # per-task on the overlay, which is exactly the exclusion each case needs).
13
+ #
14
+ # UAI_MATERIALIZE_VERIFY_ONLY=1 (ADR-106): the same validation and positive
15
+ # execution proofs with ZERO volume mutations — no install, no repair, no
16
+ # reshim, no Corepack preparation — against /opt/asdf-data mounted READ-ONLY.
17
+ # Apple block volumes refuse a writable attach while any task container holds
18
+ # them, so a task starting next to running tasks can only verify; the host
19
+ # escalates to the writable pass when this one reports something missing.
20
+ # Every mutation below is individually gated so a verify pass that would need
21
+ # one fails loudly instead of writing.
22
+
23
+ set -euo pipefail
24
+
25
+ readonly WORKSPACE=/workspace
26
+ readonly PROJECT_ALLOWLIST=/run/uai/runtime-projects
27
+ readonly COREPACK_VERSION_FILE=/usr/local/share/uai/corepack-version
28
+ readonly ASDF_SH=/opt/asdf/asdf.sh
29
+ readonly ASDF_DATA=/opt/asdf-data
30
+ readonly LOCK_PATH="$ASDF_DATA/.uai-maintenance.lock"
31
+ readonly CONTROL_PATH=/opt/asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
32
+ readonly APP_PATH="$ASDF_DATA/shims:$CONTROL_PATH"
33
+ readonly FLOCK_BIN=/usr/bin/flock
34
+ readonly REALPATH_BIN=/usr/bin/realpath
35
+ readonly RM_BIN=/usr/bin/rm
36
+ readonly LOCK_WAIT_SECONDS=300
37
+ readonly PNPM_FALLBACK_VERSION=10.15.0
38
+ readonly YARN_FALLBACK_VERSION=4.9.2
39
+ readonly NPM_REGISTRY=https://registry.npmjs.org/
40
+ readonly NPM_USERCONFIG=/dev/null
41
+ readonly NPM_GLOBALCONFIG=/dev/uai-empty-npmrc
42
+ readonly MAX_ALLOWLIST_BYTES=1048576
43
+ readonly MAX_MANIFEST_BYTES=65536
44
+
45
+ log() {
46
+ printf 'uai-materialize-runtimes: %s\n' "$*" >&2
47
+ }
48
+
49
+ fail() {
50
+ log "error: $*"
51
+ exit 1
52
+ }
53
+
54
+ if [ "$#" -ne 0 ]; then
55
+ fail "this host-controlled executable accepts no arguments"
56
+ fi
57
+ readonly VERIFY_ONLY="${UAI_MATERIALIZE_VERIFY_ONLY:-0}"
58
+ case "$VERIFY_ONLY" in
59
+ 0|1) ;;
60
+ *) fail "UAI_MATERIALIZE_VERIFY_ONLY must be 0 or 1" ;;
61
+ esac
62
+ export HOME=/home/node
63
+ export ASDF_DIR=/opt/asdf
64
+ export ASDF_DATA_DIR="$ASDF_DATA"
65
+ export ASDF_CONFIG_FILE=/dev/null
66
+ export ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions
67
+ export PATH="$CONTROL_PATH"
68
+ export COREPACK_DEFAULT_TO_LATEST=0
69
+ export COREPACK_ENABLE_AUTO_PIN=0
70
+ export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
71
+ export COREPACK_ENV_FILE=0
72
+ export COREPACK_ENABLE_PROJECT_SPEC=1
73
+ export COREPACK_ENABLE_STRICT=1
74
+ export COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=0
75
+ export COREPACK_INTEGRITY_CHECK=1
76
+ export COREPACK_NPM_REGISTRY=https://registry.npmjs.org
77
+ unset NODE_OPTIONS NPM_CONFIG_PREFIX NPM_CONFIG_REGISTRY \
78
+ NPM_CONFIG_USERCONFIG NPM_CONFIG_GLOBALCONFIG \
79
+ npm_config_prefix npm_config_registry npm_config_userconfig \
80
+ npm_config_globalconfig
81
+ unset COREPACK_ENABLE_NETWORK
82
+ if [ -L "$COREPACK_VERSION_FILE" ] || [ ! -f "$COREPACK_VERSION_FILE" ]; then
83
+ fail "$COREPACK_VERSION_FILE must be a regular, non-symbolic-link host asset"
84
+ fi
85
+ corepack_version=$(sed -n '1p' "$COREPACK_VERSION_FILE")
86
+ [[ "$corepack_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
87
+ || fail "$COREPACK_VERSION_FILE does not contain a trusted exact version"
88
+ if ! printf '%s\n' "$corepack_version" \
89
+ | cmp -s - "$COREPACK_VERSION_FILE"; then
90
+ fail "$COREPACK_VERSION_FILE must contain exactly one canonical version line"
91
+ fi
92
+ readonly COREPACK_VERSION=$corepack_version
93
+ unset corepack_version
94
+ if [ "$(id -un)" != node ]; then
95
+ fail "must run as the node user"
96
+ fi
97
+ if [ ! -d "$WORKSPACE" ]; then
98
+ fail "$WORKSPACE is missing (the host must mount the task workspace read-only)"
99
+ fi
100
+ if [ -e "$WORKSPACE/.tool-versions" ] || [ -L "$WORKSPACE/.tool-versions" ]; then
101
+ fail "$WORKSPACE/.tool-versions is forbidden because it is outside the selected-project authority"
102
+ fi
103
+ if [ ! -r "$ASDF_SH" ]; then
104
+ fail "asdf is unavailable at $ASDF_SH"
105
+ fi
106
+ if [ "$VERIFY_ONLY" = 1 ]; then
107
+ if [ ! -d "$ASDF_DATA" ] || [ ! -r "$ASDF_DATA" ]; then
108
+ fail "$ASDF_DATA is not a readable shared asdf volume"
109
+ fi
110
+ elif [ ! -d "$ASDF_DATA" ] || [ ! -w "$ASDF_DATA" ]; then
111
+ fail "$ASDF_DATA is not a writable shared asdf volume"
112
+ fi
113
+ [ ! -e "$NPM_GLOBALCONFIG" ] \
114
+ || fail "trusted empty npm config path $NPM_GLOBALCONFIG unexpectedly exists"
115
+ [ -x "$FLOCK_BIN" ] || fail "flock is unavailable at $FLOCK_BIN"
116
+ [ -x "$REALPATH_BIN" ] || fail "realpath is unavailable at $REALPATH_BIN"
117
+ [ -x "$RM_BIN" ] || fail "rm is unavailable at $RM_BIN"
118
+
119
+ declare -a projects=()
120
+ declare -a manifests=()
121
+ # `/workspace` also carries control/shared directories such as `.uai` and
122
+ # `files`, so filesystem discovery cannot prove the complete selected-project
123
+ # set. The host binds its persisted selection as a fixed, read-only allowlist.
124
+ # Snapshot a bounded prefix before parsing so a read error cannot look like a
125
+ # clean EOF or silently produce a partial proof set.
126
+ if [ -L "$PROJECT_ALLOWLIST" ] || [ ! -f "$PROJECT_ALLOWLIST" ]; then
127
+ fail "$PROJECT_ALLOWLIST must be a regular, non-symbolic-link host allowlist"
128
+ fi
129
+ allowlist_snapshot=$(mktemp)
130
+ cleanup() {
131
+ rm -f -- "$allowlist_snapshot"
132
+ }
133
+ trap cleanup EXIT
134
+ if ! dd if="$PROJECT_ALLOWLIST" of="$allowlist_snapshot" \
135
+ bs=$((MAX_ALLOWLIST_BYTES + 1)) count=1 status=none; then
136
+ fail "could not read $PROJECT_ALLOWLIST"
137
+ fi
138
+ allowlist_bytes=$(wc -c < "$allowlist_snapshot")
139
+ [ "$allowlist_bytes" -gt 0 ] \
140
+ || fail "$PROJECT_ALLOWLIST must name at least one selected project"
141
+ [ "$allowlist_bytes" -le "$MAX_ALLOWLIST_BYTES" ] \
142
+ || fail "$PROJECT_ALLOWLIST exceeds the ${MAX_ALLOWLIST_BYTES}-byte limit"
143
+ if LC_ALL=C grep -q '[[:cntrl:]]' "$allowlist_snapshot"; then
144
+ fail "$PROJECT_ALLOWLIST contains a control character"
145
+ fi
146
+
147
+ declare -A seen_projects=()
148
+ while IFS= read -r slug || [ -n "$slug" ]; do
149
+ [[ "$slug" =~ ^[a-z0-9-]{1,64}$ ]] \
150
+ || fail "$PROJECT_ALLOWLIST contains an invalid project slug"
151
+ [ -z "${seen_projects[$slug]:-}" ] \
152
+ || fail "$PROJECT_ALLOWLIST contains duplicate project slug '$slug'"
153
+ seen_projects[$slug]=1
154
+ entry="$WORKSPACE/$slug"
155
+ if [ -L "$entry" ] || [ ! -d "$entry" ]; then
156
+ fail "selected project '$slug' is not a real one-level workspace directory"
157
+ fi
158
+ if [ -L "$entry/.git" ] \
159
+ || { [ ! -f "$entry/.git" ] && [ ! -d "$entry/.git" ]; }; then
160
+ fail "selected project '$slug' lacks a regular, non-symbolic-link Git marker"
161
+ fi
162
+ projects+=("$entry")
163
+ done < "$allowlist_snapshot"
164
+ [ "${#projects[@]}" -gt 0 ] \
165
+ || fail "$PROJECT_ALLOWLIST did not contain a selected project"
166
+
167
+ for project in "${projects[@]}"; do
168
+ manifest="$project/.tool-versions"
169
+ [ -e "$manifest" ] || [ -L "$manifest" ] || continue
170
+ if [ -L "$manifest" ] || [ ! -f "$manifest" ]; then
171
+ fail "$manifest must be a regular, non-symbolic-link .tool-versions file"
172
+ fi
173
+ manifests+=("$manifest")
174
+ done
175
+
176
+ trim_spaces() {
177
+ local value=$1
178
+ value="${value#"${value%%[! ]*}"}"
179
+ value="${value%"${value##*[! ]}"}"
180
+ printf '%s' "$value"
181
+ }
182
+
183
+ reject_dot_segments() {
184
+ local value=$1 manifest=$2 line_number=$3
185
+ case "/$value/" in
186
+ *'/../'*|*'/./'*|*'//'*)
187
+ fail "$manifest:$line_number contains an unsafe version path segment"
188
+ ;;
189
+ esac
190
+ }
191
+
192
+ validate_version() {
193
+ local version=$1 project=$2 manifest=$3 line_number=$4
194
+ local suffix resolved project_resolved
195
+
196
+ case "$version" in
197
+ system)
198
+ return
199
+ ;;
200
+ path:*)
201
+ suffix=${version#path:}
202
+ [ -n "$suffix" ] || fail "$manifest:$line_number has an empty path: version"
203
+ case "$suffix" in
204
+ /*|'~'*)
205
+ fail "$manifest:$line_number path: versions must stay inside their project"
206
+ ;;
207
+ esac
208
+ resolved=$(cd "$project" && "$REALPATH_BIN" -e -- "$suffix" 2>/dev/null) \
209
+ || fail "$manifest:$line_number path:$suffix does not resolve"
210
+ project_resolved=$("$REALPATH_BIN" -e -- "$project") \
211
+ || fail "$manifest:$line_number project directory does not resolve"
212
+ [ -d "$resolved" ] \
213
+ || fail "$manifest:$line_number path:$suffix is not a directory"
214
+ case "$resolved" in
215
+ "$project_resolved"|"$project_resolved"/*) ;;
216
+ *) fail "$manifest:$line_number path:$suffix escapes its project" ;;
217
+ esac
218
+ return
219
+ ;;
220
+ ref:*)
221
+ suffix=${version#ref:}
222
+ [ -n "$suffix" ] || fail "$manifest:$line_number has an empty ref: version"
223
+ case "$suffix" in
224
+ */*)
225
+ fail "$manifest:$line_number ref: versions must use one cache path segment"
226
+ ;;
227
+ esac
228
+ [[ "$suffix" =~ ^[[:alnum:]][[:alnum:].+_@/-]*$ ]] \
229
+ || fail "$manifest:$line_number has a malformed ref: version"
230
+ reject_dot_segments "$suffix" "$manifest" "$line_number"
231
+ return
232
+ ;;
233
+ esac
234
+
235
+ case "$version" in
236
+ ref-*)
237
+ # asdf 0.14.1 stores `ref:foo` at `ref-foo`; accepting a normal
238
+ # `ref-foo` selector would alias the same destructive repair target.
239
+ fail "$manifest:$line_number normal versions may not use the reserved ref- prefix"
240
+ ;;
241
+ */*)
242
+ # `foo` and `foo/bar` would be ancestor/descendant cache entries; a
243
+ # repair of the former could delete the latter.
244
+ fail "$manifest:$line_number managed versions must use one cache path segment"
245
+ ;;
246
+ esac
247
+ [[ "$version" =~ ^[[:alnum:]][[:alnum:].+_@/-]*$ ]] \
248
+ || fail "$manifest:$line_number has a malformed version '$version'"
249
+ reject_dot_segments "$version" "$manifest" "$line_number"
250
+ }
251
+
252
+ # Parse and validate the complete manifest before passing any field to asdf.
253
+ # ASDF remains the resolution authority; this parser only rejects shapes that
254
+ # are ambiguous or could be interpreted as command/path injection.
255
+ validate_manifest() {
256
+ local manifest=$1 project line line_number=0 tool version bytes
257
+ local -a fields=()
258
+ local -A seen_tools=()
259
+
260
+ project=$(dirname "$manifest")
261
+ bytes=$(wc -c < "$manifest")
262
+ [ "$bytes" -le "$MAX_MANIFEST_BYTES" ] \
263
+ || fail "$manifest exceeds the ${MAX_MANIFEST_BYTES}-byte limit"
264
+ if LC_ALL=C grep -q '[[:cntrl:]]' "$manifest"; then
265
+ fail "$manifest contains a control character (tabs and CRLF are not accepted)"
266
+ fi
267
+
268
+ while IFS= read -r line || [ -n "$line" ]; do
269
+ line_number=$((line_number + 1))
270
+ line=${line%%#*}
271
+ line=$(trim_spaces "$line")
272
+ [ -n "$line" ] || continue
273
+ read -r -a fields <<< "$line"
274
+ [ "${#fields[@]}" -ge 2 ] \
275
+ || fail "$manifest:$line_number must declare a tool and at least one version"
276
+ tool=${fields[0]}
277
+ [[ "$tool" =~ ^[a-z0-9][a-z0-9_-]*$ ]] \
278
+ || fail "$manifest:$line_number has a malformed tool name '$tool'"
279
+ case "$tool" in
280
+ nodejs|python|golang|ruby|rust) ;;
281
+ pnpm|yarn)
282
+ # Not asdf plugins here: package managers are Corepack's domain
283
+ # (live 2026-08-17 — the asdf-pnpm convention is common enough that
284
+ # refusing it broke a real monorepo). One plain pinned version;
285
+ # project_package_manager consumes it below.
286
+ [ "${#fields[@]}" -eq 2 ] \
287
+ || fail "$manifest:$line_number must pin exactly one $tool version"
288
+ [[ "${fields[1]}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] \
289
+ || fail "$manifest:$line_number must pin an exact $tool version (Corepack manages package managers)"
290
+ ;;
291
+ *) fail "$manifest:$line_number requests unsupported asdf plugin '$tool'" ;;
292
+ esac
293
+ [ -z "${seen_tools[$tool]:-}" ] \
294
+ || fail "$manifest:$line_number declares '$tool' more than once"
295
+ seen_tools[$tool]=1
296
+ case "$tool" in
297
+ pnpm|yarn) continue ;;
298
+ esac
299
+ for version in "${fields[@]:1}"; do
300
+ validate_version "$version" "$project" "$manifest" "$line_number"
301
+ done
302
+ done < "$manifest"
303
+ }
304
+
305
+ # The pnpm/yarn pin from a project's .tool-versions, if any — the asdf-world
306
+ # spelling of what package.json's packageManager states. Corepack authority
307
+ # order: packageManager field, then this, then lockfile fallback.
308
+ tool_versions_package_manager() {
309
+ local project=$1 line line_number=0
310
+ local -a fields=()
311
+ TOOL_VERSIONS_PM_MANAGER=""
312
+ TOOL_VERSIONS_PM_VERSION=""
313
+ [ -f "$project/.tool-versions" ] || return 0
314
+ while IFS= read -r line || [ -n "$line" ]; do
315
+ line_number=$((line_number + 1))
316
+ line=${line%%#*}
317
+ line=$(trim_spaces "$line")
318
+ [ -n "$line" ] || continue
319
+ read -r -a fields <<< "$line"
320
+ case "${fields[0]}" in
321
+ pnpm|yarn)
322
+ TOOL_VERSIONS_PM_MANAGER=${fields[0]}
323
+ TOOL_VERSIONS_PM_VERSION=${fields[1]:-}
324
+ return 0
325
+ ;;
326
+ esac
327
+ done < "$project/.tool-versions"
328
+ }
329
+
330
+ for manifest in "${manifests[@]}"; do
331
+ validate_manifest "$manifest"
332
+ done
333
+
334
+ # Do not inherit a derived image's ASDF_<TOOL>_VERSION overrides: the checked
335
+ # manifest mounted by the host is the authority for this operation.
336
+ unset_tool_override() {
337
+ local tool=$1 variable
338
+ variable="ASDF_$(printf '%s' "$tool" | tr '[:lower:]-' '[:upper:]_')_VERSION"
339
+ unset "$variable"
340
+ }
341
+
342
+ for baked_tool in nodejs python golang ruby rust; do
343
+ unset_tool_override "$baked_tool"
344
+ done
345
+
346
+ project_package_manager() {
347
+ local project=$1 spec="" manager="" is_fallback=0
348
+ local has_dev_engines_manager=0 has_supported_package_manager=0
349
+ PROJECT_PACKAGE_MANAGER=""
350
+ PROJECT_PACKAGE_MANAGER_SPEC=""
351
+ PROJECT_PACKAGE_MANAGER_IS_FALLBACK=0
352
+
353
+ if [ -f "$project/package.json" ]; then
354
+ jq -e 'type == "object"' "$project/package.json" >/dev/null 2>&1 \
355
+ || fail "$(basename "$project") has an invalid package.json"
356
+ spec=$(jq -r \
357
+ 'if (.packageManager | type) == "string" then .packageManager else "" end' \
358
+ "$project/package.json")
359
+ manager=${spec%%@*}
360
+ case "$manager" in
361
+ pnpm|yarn) has_supported_package_manager=1 ;;
362
+ esac
363
+ if jq -e \
364
+ '(.devEngines? | type) == "object" and (.devEngines | has("packageManager"))' \
365
+ "$project/package.json" >/dev/null; then
366
+ has_dev_engines_manager=1
367
+ fi
368
+ if [ "$has_dev_engines_manager" -eq 1 ] \
369
+ && [ "$has_supported_package_manager" -ne 1 ]; then
370
+ fail "$(basename "$project") devEngines.packageManager requires an exact safe pnpm/yarn packageManager field"
371
+ fi
372
+ fi
373
+ if [ -z "$manager" ]; then
374
+ # The asdf-world pin (`pnpm 9.12.0` in .tool-versions) beats the generic
375
+ # lockfile fallback: it names an exact version the project chose.
376
+ tool_versions_package_manager "$project"
377
+ if [ -n "$TOOL_VERSIONS_PM_MANAGER" ]; then
378
+ manager=$TOOL_VERSIONS_PM_MANAGER
379
+ spec="$manager@$TOOL_VERSIONS_PM_VERSION"
380
+ is_fallback=1
381
+ fi
382
+ else
383
+ tool_versions_package_manager "$project"
384
+ if [ -n "$TOOL_VERSIONS_PM_MANAGER" ] \
385
+ && [ "${spec%%+*}" != "$TOOL_VERSIONS_PM_MANAGER@$TOOL_VERSIONS_PM_VERSION" ]; then
386
+ log "$(basename "$project"): package.json packageManager ($spec) wins over .tool-versions ($TOOL_VERSIONS_PM_MANAGER $TOOL_VERSIONS_PM_VERSION)"
387
+ fi
388
+ fi
389
+ if [ -z "$manager" ]; then
390
+ if [ -f "$project/pnpm-lock.yaml" ]; then
391
+ manager=pnpm
392
+ spec="pnpm@$PNPM_FALLBACK_VERSION"
393
+ is_fallback=1
394
+ elif [ -f "$project/yarn.lock" ]; then
395
+ manager=yarn
396
+ spec="yarn@$YARN_FALLBACK_VERSION"
397
+ is_fallback=1
398
+ fi
399
+ fi
400
+ case "$manager" in
401
+ pnpm|yarn)
402
+ if ! [[ "$spec" =~ ^(pnpm|yarn)@[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+sha(224|256|384|512)\.[0-9A-Za-z_+/=-]+)?$ ]]; then
403
+ fail "$(basename "$project") packageManager must pin an exact safe $manager version"
404
+ fi
405
+ PROJECT_PACKAGE_MANAGER=$manager
406
+ PROJECT_PACKAGE_MANAGER_SPEC=$spec
407
+ PROJECT_PACKAGE_MANAGER_IS_FALLBACK=$is_fallback
408
+ ;;
409
+ esac
410
+ }
411
+
412
+ tool_executable() {
413
+ case "$1" in
414
+ nodejs) printf node ;;
415
+ python) printf python ;;
416
+ golang) printf go ;;
417
+ ruby) printf ruby ;;
418
+ rust) printf rustc ;;
419
+ *) return 1 ;;
420
+ esac
421
+ }
422
+
423
+ selected_executable() {
424
+ local tool=$1 version=$2 project=$3 executable=$4 variable
425
+ SELECTED_EXECUTABLE=""
426
+ case "$version" in
427
+ system)
428
+ SELECTED_EXECUTABLE=$(PATH="$CONTROL_PATH" command -v "$executable" 2>/dev/null) \
429
+ || return 1
430
+ ;;
431
+ *)
432
+ # The plugin, not Uai, owns executable layout (golang includes both
433
+ # `bin` and `go/bin`). Path selections are explicitly reshimmed under the
434
+ # shared lock, so ASDF can resolve that plugin-aware layout without
435
+ # writing to the external tree itself.
436
+ variable="ASDF_$(printf '%s' "$tool" | tr '[:lower:]-' '[:upper:]_')_VERSION"
437
+ SELECTED_EXECUTABLE=$(cd "$project" && env "$variable=$version" \
438
+ asdf which "$executable" 2>/dev/null) \
439
+ || return 1
440
+ ;;
441
+ esac
442
+ }
443
+
444
+ run_tool_version_probe() {
445
+ local tool=$1 executable=$2 project=$3 output
446
+ TOOL_VERSION_OUTPUT=""
447
+ case "$tool" in
448
+ golang)
449
+ output=$(cd "$project" && PATH="$CONTROL_PATH" \
450
+ "$executable" version 2>&1) || return 1
451
+ ;;
452
+ *)
453
+ output=$(cd "$project" && PATH="$CONTROL_PATH" \
454
+ "$executable" --version 2>&1) || return 1
455
+ ;;
456
+ esac
457
+ TOOL_VERSION_OUTPUT=${output%%$'\n'*}
458
+ [ -n "$TOOL_VERSION_OUTPUT" ]
459
+ }
460
+
461
+ resolve_app_shim() {
462
+ local executable=$1 resolved
463
+ APP_SHIM=""
464
+ resolved=$(PATH="$APP_PATH" command -v "$executable" 2>/dev/null) \
465
+ || return 1
466
+ [ "$resolved" = "$ASDF_DATA/shims/$executable" ] \
467
+ || return 1
468
+ [ ! -L "$resolved" ] && [ -f "$resolved" ] && [ -x "$resolved" ] \
469
+ || return 1
470
+ APP_SHIM=$resolved
471
+ }
472
+
473
+ run_tool_shim_version_probe() {
474
+ local tool=$1 executable=$2 project=$3 output
475
+ TOOL_VERSION_OUTPUT=""
476
+ resolve_app_shim "$executable" || return 1
477
+ # ASDF shims use `#!/usr/bin/env bash`; execute the already-resolved shim
478
+ # with the control PATH so another shared `bash` shim cannot intercept it.
479
+ case "$tool" in
480
+ golang)
481
+ output=$(cd "$project" && PATH="$CONTROL_PATH" \
482
+ "$APP_SHIM" version 2>&1) || return 1
483
+ ;;
484
+ *)
485
+ output=$(cd "$project" && PATH="$CONTROL_PATH" \
486
+ "$APP_SHIM" --version 2>&1) || return 1
487
+ ;;
488
+ esac
489
+ TOOL_VERSION_OUTPUT=${output%%$'\n'*}
490
+ [ -n "$TOOL_VERSION_OUTPUT" ]
491
+ }
492
+
493
+ run_node_shim_probe() {
494
+ local project=$1 node_version=$2 executable=$3
495
+ shift 3
496
+ resolve_app_shim "$executable" || return 1
497
+ (cd "$project" && PATH="$CONTROL_PATH" \
498
+ ASDF_NODEJS_VERSION="$node_version" "$APP_SHIM" "$@")
499
+ }
500
+
501
+ # BEGIN UAI_MANAGED_VERSION_PROOF
502
+ managed_executable_is_contained() {
503
+ local version=$1 executable=$2 real_executable
504
+ case "$version" in
505
+ system|path:*) return 0 ;;
506
+ esac
507
+ [ -n "${MANAGED_INSTALL_PATH:-}" ] || return 1
508
+ [ -f "$executable" ] && [ -x "$executable" ] || return 1
509
+ real_executable=$("$REALPATH_BIN" -e -- "$executable" 2>/dev/null) || return 1
510
+ case "$real_executable" in
511
+ "$MANAGED_INSTALL_PATH"/*) return 0 ;;
512
+ *) return 1 ;;
513
+ esac
514
+ }
515
+
516
+ normalize_tool_version_output() {
517
+ local tool=$1 output=$2
518
+ NORMALIZED_TOOL_VERSION=""
519
+ case "$tool" in
520
+ nodejs)
521
+ [[ "$output" =~ ^v([^[:space:]]+) ]] || return 1
522
+ NORMALIZED_TOOL_VERSION=${BASH_REMATCH[1]}
523
+ ;;
524
+ python)
525
+ [[ "$output" =~ ^Python[[:space:]]+([^[:space:]]+) ]] || return 1
526
+ NORMALIZED_TOOL_VERSION=${BASH_REMATCH[1]}
527
+ ;;
528
+ golang)
529
+ [[ "$output" =~ ^go[[:space:]]+version[[:space:]]+go([^[:space:]]+) ]] \
530
+ || return 1
531
+ NORMALIZED_TOOL_VERSION=${BASH_REMATCH[1]}
532
+ ;;
533
+ ruby)
534
+ [[ "$output" =~ ^ruby[[:space:]]+([^[:space:]]+) ]] || return 1
535
+ NORMALIZED_TOOL_VERSION=${BASH_REMATCH[1]}
536
+ if [[ "$NORMALIZED_TOOL_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)p[0-9]+$ ]]; then
537
+ NORMALIZED_TOOL_VERSION=${BASH_REMATCH[1]}
538
+ fi
539
+ ;;
540
+ rust)
541
+ [[ "$output" =~ ^rustc[[:space:]]+([^[:space:]]+) ]] || return 1
542
+ NORMALIZED_TOOL_VERSION=${BASH_REMATCH[1]}
543
+ ;;
544
+ *) return 1 ;;
545
+ esac
546
+ }
547
+
548
+ tool_selection_requires_exact_output() {
549
+ local tool=$1 version=$2 numeric='[0-9]+\.[0-9]+\.[0-9]+'
550
+ case "$version" in
551
+ system|path:*|ref:*) return 1 ;;
552
+ esac
553
+ case "$tool" in
554
+ nodejs)
555
+ [[ "$version" =~ ^${numeric}(-rc\.[0-9]+)?$ ]]
556
+ ;;
557
+ python)
558
+ [[ "$version" =~ ^${numeric}((a|b|rc)[0-9]+)?$ ]]
559
+ ;;
560
+ ruby)
561
+ [[ "$version" =~ ^${numeric}(-(preview|rc)[0-9]+)?$ ]]
562
+ ;;
563
+ golang|rust)
564
+ [[ "$version" =~ ^${numeric}$ ]]
565
+ ;;
566
+ *) return 1 ;;
567
+ esac
568
+ }
569
+
570
+ tool_output_matches_selection() {
571
+ local tool=$1 version=$2 output=$3 expected
572
+ # Moving/plugin-specific aliases (for example Python's free-threaded `t`
573
+ # suffix or Ruby `-dev`) remain execution+containment proved. Plain releases
574
+ # and the deliberately enumerated prerelease forms must report exactly.
575
+ tool_selection_requires_exact_output "$tool" "$version" || return 0
576
+ normalize_tool_version_output "$tool" "$output" || return 1
577
+ expected=$version
578
+ if [ "$tool" = ruby ]; then
579
+ expected=${expected/-preview/preview}
580
+ expected=${expected/-rc/rc}
581
+ fi
582
+ [ "$NORMALIZED_TOOL_VERSION" = "$expected" ]
583
+ }
584
+ # END UAI_MANAGED_VERSION_PROOF
585
+
586
+ selection_is_proven() {
587
+ local tool=$1 version=$2 project=$3 executable variable reported npm_candidate npm_report
588
+ SELECTION_PROOF_ERROR=""
589
+ executable=$(tool_executable "$tool") \
590
+ || {
591
+ SELECTION_PROOF_ERROR="no executable proof is defined for supported plugin $tool"
592
+ return 1
593
+ }
594
+ variable="ASDF_$(printf '%s' "$tool" | tr '[:lower:]-' '[:upper:]_')_VERSION"
595
+ export "$variable=$version"
596
+ if ! (cd "$project" && asdf current "$tool" >/dev/null); then
597
+ SELECTION_PROOF_ERROR="ASDF could not resolve $tool $version in $(basename "$project")"
598
+ unset "$variable"
599
+ return 1
600
+ fi
601
+ if ! selected_executable "$tool" "$version" "$project" "$executable"; then
602
+ SELECTION_PROOF_ERROR="$tool $version did not resolve executable '$executable'"
603
+ unset "$variable"
604
+ return 1
605
+ fi
606
+ if ! managed_executable_is_contained "$version" "$SELECTED_EXECUTABLE"; then
607
+ SELECTION_PROOF_ERROR="$tool $version executable escapes its exact managed install directory"
608
+ unset "$variable"
609
+ return 1
610
+ fi
611
+ if ! run_tool_version_probe "$tool" "$SELECTED_EXECUTABLE" "$project"; then
612
+ SELECTION_PROOF_ERROR="$tool $version executable '$executable' failed its version probe"
613
+ unset "$variable"
614
+ return 1
615
+ fi
616
+ reported=$TOOL_VERSION_OUTPUT
617
+ if ! tool_output_matches_selection "$tool" "$version" "$reported"; then
618
+ SELECTION_PROOF_ERROR="$tool $version executable reported '$reported'"
619
+ unset "$variable"
620
+ return 1
621
+ fi
622
+ if [ "$tool" = nodejs ]; then
623
+ case "$version" in
624
+ system|path:*) ;;
625
+ *)
626
+ npm_candidate="$MANAGED_INSTALL_PATH/bin/npm"
627
+ if ! managed_executable_is_contained "$version" "$npm_candidate" \
628
+ || ! npm_report=$(cd "$project" \
629
+ && PATH="$MANAGED_INSTALL_PATH/bin:$CONTROL_PATH" \
630
+ "$npm_candidate" --version 2>&1) \
631
+ || [ -z "$npm_report" ]; then
632
+ SELECTION_PROOF_ERROR="nodejs $version has no contained runnable npm"
633
+ unset "$variable"
634
+ return 1
635
+ fi
636
+ ;;
637
+ esac
638
+ fi
639
+ # Execute through the same shim-first PATH the app receives. This catches a
640
+ # direct `asdf which` success whose shim was lost by a later global reshim.
641
+ if ! run_tool_shim_version_probe "$tool" "$executable" "$project"; then
642
+ SELECTION_PROOF_ERROR="$tool $version shim '$executable' failed its version probe"
643
+ unset "$variable"
644
+ return 1
645
+ fi
646
+ reported=$TOOL_VERSION_OUTPUT
647
+ if ! tool_output_matches_selection "$tool" "$version" "$reported"; then
648
+ SELECTION_PROOF_ERROR="$tool $version shim reported '$reported'"
649
+ unset "$variable"
650
+ return 1
651
+ fi
652
+ unset "$variable"
653
+ return 0
654
+ }
655
+
656
+ prove_selection() {
657
+ local tool=$1 version=$2 project=$3 source=$4
658
+ case "$version" in
659
+ system|path:*) ;;
660
+ *) managed_install_path "$tool" "$version" "$project" "$source" ;;
661
+ esac
662
+ selection_is_proven "$tool" "$version" "$project" \
663
+ || fail "$SELECTION_PROOF_ERROR"
664
+ }
665
+
666
+ # BEGIN UAI_MANAGED_INSTALL_REPAIR
667
+ # asdf 0.14.1 considers any existing install directory complete, even when a
668
+ # daemon/container death left it empty. A managed selection that fails the
669
+ # positive executable proof gets one exact-directory repair under the global
670
+ # maintenance lock. system/path selections never enter this code.
671
+ managed_install_location() {
672
+ local tool=$1 version=$2 project=$3 source=$4 suffix component
673
+ local installs_root lexical_installs_root real_installs_root
674
+ local root lexical_root real_root candidate lexical_candidate real_candidate
675
+
676
+ case "$tool" in
677
+ nodejs|python|golang|ruby|rust) ;;
678
+ *) fail "refusing to repair unsupported asdf plugin '$tool'" ;;
679
+ esac
680
+ case "$version" in
681
+ system|path:*) fail "refusing to repair external $tool $version" ;;
682
+ esac
683
+ # Revalidate at the destructive boundary instead of relying only on the
684
+ # earlier parser pass.
685
+ validate_version "$version" "$project" "$source" 0
686
+ case "$version" in
687
+ ref:*)
688
+ suffix=${version#ref:}
689
+ component="ref-$suffix"
690
+ ;;
691
+ *) component=$version ;;
692
+ esac
693
+
694
+ installs_root="$ASDF_DATA/installs"
695
+ if [ -L "$installs_root" ] || [ ! -d "$installs_root" ]; then
696
+ fail "managed installs root $installs_root is not a real directory"
697
+ fi
698
+ lexical_installs_root=$("$REALPATH_BIN" -m -s -- "$installs_root") \
699
+ || fail "could not normalize managed installs root $installs_root"
700
+ real_installs_root=$("$REALPATH_BIN" -e -- "$installs_root") \
701
+ || fail "could not resolve managed installs root $installs_root"
702
+ [ "$real_installs_root" = "$lexical_installs_root" ] \
703
+ || fail "managed installs root $installs_root traverses a symbolic link"
704
+
705
+ root="$installs_root/$tool"
706
+ lexical_root=$("$REALPATH_BIN" -m -s -- "$root") \
707
+ || fail "could not normalize managed install root $root"
708
+ case "$lexical_root" in
709
+ "$lexical_installs_root"/*) ;;
710
+ *) fail "managed install root $root escapes $lexical_installs_root" ;;
711
+ esac
712
+ if [ -e "$root" ] || [ -L "$root" ]; then
713
+ if [ -L "$root" ] || [ ! -d "$root" ]; then
714
+ fail "managed install root $root is not a real directory"
715
+ fi
716
+ real_root=$("$REALPATH_BIN" -e -- "$root") \
717
+ || fail "could not resolve managed install root $root"
718
+ [ "$real_root" = "$lexical_root" ] \
719
+ || fail "managed install root $root traverses a symbolic link"
720
+ fi
721
+
722
+ candidate="$root/$component"
723
+ lexical_candidate=$("$REALPATH_BIN" -m -s -- "$candidate") \
724
+ || fail "could not normalize managed install path for $tool $version"
725
+ case "$lexical_candidate" in
726
+ "$lexical_root"/*) ;;
727
+ *) fail "managed install path for $tool $version escapes $lexical_root" ;;
728
+ esac
729
+ MANAGED_INSTALL_EXISTS=0
730
+ if [ -e "$candidate" ] || [ -L "$candidate" ]; then
731
+ if [ -L "$candidate" ] || [ ! -d "$candidate" ]; then
732
+ fail "managed install path for $tool $version is not a real directory"
733
+ fi
734
+ real_candidate=$("$REALPATH_BIN" -e -- "$candidate") \
735
+ || fail "could not resolve managed install path for $tool $version"
736
+ [ "$real_candidate" = "$lexical_candidate" ] \
737
+ || fail "managed install path for $tool $version traverses a symbolic link"
738
+ MANAGED_INSTALL_EXISTS=1
739
+ fi
740
+
741
+ MANAGED_INSTALL_PATH=$lexical_candidate
742
+ }
743
+
744
+ managed_install_path() {
745
+ managed_install_location "$@"
746
+ [ "$MANAGED_INSTALL_EXISTS" -eq 1 ] \
747
+ || fail "failed $1 $2 proof but its exact managed install directory is missing"
748
+ }
749
+
750
+ is_default_node_version() {
751
+ local requested=$1 line version
752
+ local -a fields=()
753
+ while IFS= read -r line || [ -n "$line" ]; do
754
+ line=${line%%#*}
755
+ line=$(trim_spaces "$line")
756
+ case "$line" in
757
+ nodejs' '*)
758
+ read -r -a fields <<< "$line"
759
+ for version in "${fields[@]:1}"; do
760
+ [ "$version" != "$requested" ] || return 0
761
+ done
762
+ ;;
763
+ esac
764
+ done < /home/node/.tool-versions
765
+ return 1
766
+ }
767
+
768
+ prove_or_repair_managed_selection() {
769
+ local tool=$1 version=$2 project=$3 source=$4 initial_error failed_path
770
+ case "$version" in
771
+ system|path:*)
772
+ selection_is_proven "$tool" "$version" "$project" \
773
+ || fail "$SELECTION_PROOF_ERROR"
774
+ return 0
775
+ ;;
776
+ esac
777
+
778
+ # Validate even a healthy-looking cache entry before ASDF resolution: asdf
779
+ # follows an install-directory symlink and can otherwise prove the wrong
780
+ # runtime version under the requested selector.
781
+ managed_install_path "$tool" "$version" "$project" "$source"
782
+ if selection_is_proven "$tool" "$version" "$project"; then
783
+ return 0
784
+ fi
785
+ initial_error=$SELECTION_PROOF_ERROR
786
+ # ${VERIFY_ONLY:-0}: this block is extracted into a standalone test harness
787
+ # that runs it without the script preamble.
788
+ if [ "${VERIFY_ONLY:-0}" = 1 ]; then
789
+ fail "$initial_error (verify-only pass makes no repairs)"
790
+ fi
791
+ failed_path=$MANAGED_INSTALL_PATH
792
+ if [ "$tool" = nodejs ] && is_default_node_version "$version"; then
793
+ fail "$initial_error; refusing to remove the host-default Node candidate because boot maintenance owns its CLI/Corepack invariant"
794
+ fi
795
+ # Candidate-controlled probes ran after the initial path check. Re-resolve
796
+ # every parent immediately at the destructive boundary and require the same
797
+ # exact target before removing anything.
798
+ managed_install_path "$tool" "$version" "$project" "$source"
799
+ [ "$MANAGED_INSTALL_PATH" = "$failed_path" ] \
800
+ || fail "managed install path for $tool $version changed during proof"
801
+ log "$initial_error; removing incomplete exact cache entry $MANAGED_INSTALL_PATH and retrying once"
802
+ rm -rf --one-file-system -- "${MANAGED_INSTALL_PATH:?}" \
803
+ || fail "could not remove incomplete $tool $version install"
804
+ if [ -e "$MANAGED_INSTALL_PATH" ] || [ -L "$MANAGED_INSTALL_PATH" ]; then
805
+ fail "incomplete $tool $version install still exists after repair removal"
806
+ fi
807
+ (cd "$project" && run_asdf_install "$tool" "$version") \
808
+ || fail "asdf reinstall failed for $tool $version in $(basename "$project")"
809
+ managed_install_path "$tool" "$version" "$project" "$source"
810
+ (cd "$project" && run_asdf_reshim "$tool" "$version") \
811
+ || fail "asdf reshim failed after repairing $tool $version"
812
+ managed_install_path "$tool" "$version" "$project" "$source"
813
+ selection_is_proven "$tool" "$version" "$project" \
814
+ || fail "$SELECTION_PROOF_ERROR after one exact-directory repair"
815
+ }
816
+ # END UAI_MANAGED_INSTALL_REPAIR
817
+
818
+ resolve_node_context_executable() {
819
+ local project=$1 node_version=$2 source=$3 executable=$4 candidate
820
+ NODE_CONTEXT_EXECUTABLE=""
821
+ case "$node_version" in
822
+ system|path:*)
823
+ selected_executable nodejs "$node_version" "$project" "$executable" \
824
+ || return 1
825
+ NODE_CONTEXT_EXECUTABLE=$SELECTED_EXECUTABLE
826
+ return 0
827
+ ;;
828
+ esac
829
+
830
+ managed_install_path nodejs "$node_version" "$project" "$source"
831
+ if [ "$executable" = npm ]; then
832
+ # asdf-nodejs intentionally reports a plugin-owned wrapper for `asdf which
833
+ # npm`; maintenance must execute the selected runtime's own npm instead.
834
+ candidate="$MANAGED_INSTALL_PATH/bin/npm"
835
+ managed_executable_is_contained "$node_version" "$candidate" || return 1
836
+ NODE_CONTEXT_EXECUTABLE=$candidate
837
+ return 0
838
+ fi
839
+ selected_executable nodejs "$node_version" "$project" "$executable" \
840
+ || return 1
841
+ managed_executable_is_contained "$node_version" "$SELECTED_EXECUTABLE" \
842
+ || return 1
843
+ NODE_CONTEXT_EXECUTABLE=$SELECTED_EXECUTABLE
844
+ }
845
+
846
+ # BEGIN UAI_MANAGED_NODE_PROCESS_CONTEXT
847
+ managed_node_process_context_is_selected() {
848
+ local project=$1 node_version=$2 report reported_version reported_executable
849
+ report=$(cd "$project" \
850
+ && PATH="$MANAGED_INSTALL_PATH/bin:$CONTROL_PATH" \
851
+ /usr/bin/env node -p 'process.version + "\t" + process.execPath' 2>/dev/null) \
852
+ || return 1
853
+ case "$report" in
854
+ *$'\t'*) ;;
855
+ *) return 1 ;;
856
+ esac
857
+ reported_version=${report%%$'\t'*}
858
+ reported_executable=${report#*$'\t'}
859
+ managed_executable_is_contained "$node_version" "$reported_executable" \
860
+ || return 1
861
+ tool_output_matches_selection nodejs "$node_version" "$reported_version"
862
+ }
863
+ # END UAI_MANAGED_NODE_PROCESS_CONTEXT
864
+
865
+ execute_in_node_context() {
866
+ local executable=$1 project=$2 node_version=$3 suffix resolved
867
+ shift 3
868
+ case "$node_version" in
869
+ system)
870
+ (cd "$project" && PATH="$CONTROL_PATH" "$executable" "$@")
871
+ ;;
872
+ path:*)
873
+ suffix=${node_version#path:}
874
+ resolved=$(cd "$project" && "$REALPATH_BIN" -e -- "$suffix") || return 1
875
+ (cd "$project" && PATH="$resolved/bin:$CONTROL_PATH" "$executable" "$@")
876
+ ;;
877
+ *)
878
+ managed_node_process_context_is_selected "$project" "$node_version" \
879
+ || return 1
880
+ (cd "$project" && PATH="$MANAGED_INSTALL_PATH/bin:$CONTROL_PATH" \
881
+ ASDF_NODEJS_VERSION="$node_version" \
882
+ "$executable" "$@")
883
+ ;;
884
+ esac
885
+ }
886
+
887
+ corepack_version_is_usable() {
888
+ local corepack_bin=$1 project=$2 node_version=$3 version
889
+ version=$(execute_in_node_context \
890
+ "$corepack_bin" "$project" "$node_version" --version 2>/dev/null) \
891
+ || return 1
892
+ [ "$version" = "$COREPACK_VERSION" ]
893
+ }
894
+
895
+ # BEGIN UAI_MANAGED_COREPACK_REPAIR
896
+ reinstall_managed_corepack() {
897
+ local project=$1 node_version=$2 source_manifest=$3 manager=$4
898
+ local npm_bin corepack_bin
899
+ resolve_node_context_executable \
900
+ "$project" "$node_version" "$source_manifest" npm \
901
+ || fail "nodejs $node_version has no contained npm for Corepack repair"
902
+ npm_bin=$NODE_CONTEXT_EXECUTABLE
903
+ log "reinstalling corepack@$COREPACK_VERSION inside nodejs $node_version"
904
+ (cd / && PATH="$MANAGED_INSTALL_PATH/bin:$CONTROL_PATH" \
905
+ ASDF_NODEJS_VERSION="$node_version" \
906
+ "$npm_bin" install -g --force "corepack@$COREPACK_VERSION" \
907
+ --no-audit --no-fund \
908
+ --registry="$NPM_REGISTRY" \
909
+ --userconfig="$NPM_USERCONFIG" \
910
+ --globalconfig="$NPM_GLOBALCONFIG") \
911
+ || fail "could not reinstall corepack@$COREPACK_VERSION for nodejs $node_version"
912
+ managed_install_path nodejs "$node_version" "$project" "$source_manifest"
913
+ run_asdf_reshim nodejs "$node_version" \
914
+ || fail "could not reshim nodejs $node_version after Corepack reinstall"
915
+ managed_install_path nodejs "$node_version" "$project" "$source_manifest"
916
+ resolve_node_context_executable \
917
+ "$project" "$node_version" "$source_manifest" corepack \
918
+ || fail "reinstalled Corepack escaped nodejs $node_version"
919
+ corepack_bin=$NODE_CONTEXT_EXECUTABLE
920
+ corepack_version_is_usable "$corepack_bin" "$project" "$node_version" \
921
+ || fail "reinstalled Corepack failed proof for nodejs $node_version"
922
+ execute_in_node_context \
923
+ "$corepack_bin" "$project" "$node_version" disable "$manager" \
924
+ || fail "reinstalled Corepack could not clear the stale $manager launcher"
925
+ managed_install_path nodejs "$node_version" "$project" "$source_manifest"
926
+ }
927
+
928
+ prepare_managed_package_manager_once() {
929
+ local project=$1 node_version=$2 manager=$3 spec=$4 is_fallback=$5
930
+ local source_manifest=$6 corepack_bin manager_bin actual expected
931
+ COREPACK_PREPARATION_ERROR=""
932
+
933
+ if ! resolve_node_context_executable \
934
+ "$project" "$node_version" "$source_manifest" corepack; then
935
+ COREPACK_PREPARATION_ERROR="Corepack is not contained in nodejs $node_version"
936
+ return 1
937
+ fi
938
+ corepack_bin=$NODE_CONTEXT_EXECUTABLE
939
+ if ! corepack_version_is_usable "$corepack_bin" "$project" "$node_version"; then
940
+ COREPACK_PREPARATION_ERROR="Corepack version proof failed in nodejs $node_version"
941
+ return 1
942
+ fi
943
+ if [ "$is_fallback" -eq 1 ]; then
944
+ if ! execute_in_node_context \
945
+ "$corepack_bin" "$project" "$node_version" install --global "$spec"; then
946
+ COREPACK_PREPARATION_ERROR="Corepack could not install fallback $spec"
947
+ return 1
948
+ fi
949
+ elif ! execute_in_node_context \
950
+ "$corepack_bin" "$project" "$node_version" \
951
+ install --global --cache-only "$spec"; then
952
+ COREPACK_PREPARATION_ERROR="Corepack could not cache exact $spec"
953
+ return 1
954
+ fi
955
+
956
+ if ! resolve_node_context_executable \
957
+ "$project" "$node_version" "$source_manifest" corepack; then
958
+ COREPACK_PREPARATION_ERROR="Corepack escaped nodejs $node_version after cache preparation"
959
+ return 1
960
+ fi
961
+ corepack_bin=$NODE_CONTEXT_EXECUTABLE
962
+ if ! execute_in_node_context \
963
+ "$corepack_bin" "$project" "$node_version" enable "$manager"; then
964
+ COREPACK_PREPARATION_ERROR="Corepack could not enable $manager"
965
+ return 1
966
+ fi
967
+ managed_install_path nodejs "$node_version" "$project" "$source_manifest"
968
+ if ! run_asdf_reshim nodejs "$node_version"; then
969
+ COREPACK_PREPARATION_ERROR="asdf could not reshim $manager for nodejs $node_version"
970
+ return 1
971
+ fi
972
+ managed_install_path nodejs "$node_version" "$project" "$source_manifest"
973
+ if ! resolve_node_context_executable \
974
+ "$project" "$node_version" "$source_manifest" corepack; then
975
+ COREPACK_PREPARATION_ERROR="Corepack escaped nodejs $node_version after final reshim"
976
+ return 1
977
+ fi
978
+ corepack_bin=$NODE_CONTEXT_EXECUTABLE
979
+ if ! corepack_version_is_usable "$corepack_bin" "$project" "$node_version"; then
980
+ COREPACK_PREPARATION_ERROR="Corepack failed final version proof in nodejs $node_version"
981
+ return 1
982
+ fi
983
+ if ! resolve_node_context_executable \
984
+ "$project" "$node_version" "$source_manifest" "$manager"; then
985
+ COREPACK_PREPARATION_ERROR="$manager is not contained in nodejs $node_version"
986
+ return 1
987
+ fi
988
+ manager_bin=$NODE_CONTEXT_EXECUTABLE
989
+ if ! actual=$(execute_in_node_context \
990
+ "$manager_bin" "$project" "$node_version" --version); then
991
+ COREPACK_PREPARATION_ERROR="$manager is not executable in nodejs $node_version"
992
+ return 1
993
+ fi
994
+ expected=${spec#"$manager"@}
995
+ expected=${expected%%+*}
996
+ if [ "$actual" != "$expected" ]; then
997
+ COREPACK_PREPARATION_ERROR="$manager resolved $actual instead of $expected"
998
+ return 1
999
+ fi
1000
+ return 0
1001
+ }
1002
+
1003
+ prepare_package_manager() {
1004
+ local project=$1 node_version=$2 manager=$3 spec=$4 is_fallback=$5
1005
+ local source_manifest=$6
1006
+ local corepack_bin manager_bin actual expected initial_error
1007
+ local corepack_repaired=0
1008
+
1009
+ case "$node_version" in
1010
+ system|path:*)
1011
+ resolve_node_context_executable \
1012
+ "$project" "$node_version" "$source_manifest" corepack \
1013
+ || fail "external nodejs $node_version must already provide Corepack for $manager"
1014
+ corepack_bin=$NODE_CONTEXT_EXECUTABLE
1015
+ corepack_version_is_usable "$corepack_bin" "$project" "$node_version" || fail \
1016
+ "external nodejs $node_version has obsolete Corepack; Uai will not mutate external runtimes"
1017
+ resolve_node_context_executable \
1018
+ "$project" "$node_version" "$source_manifest" "$manager" \
1019
+ || fail "external nodejs $node_version must already provide $manager; Uai will not mutate it"
1020
+ manager_bin=$NODE_CONTEXT_EXECUTABLE
1021
+ actual=$(execute_in_node_context \
1022
+ "$manager_bin" "$project" "$node_version" --version) \
1023
+ || fail "$manager is not executable for external nodejs $node_version"
1024
+ expected=${spec#"$manager"@}
1025
+ expected=${expected%%+*}
1026
+ [ "$actual" = "$expected" ] || fail \
1027
+ "$manager resolved $actual instead of packageManager version $expected for $(basename "$project")"
1028
+ return
1029
+ ;;
1030
+ esac
1031
+
1032
+ resolve_node_context_executable \
1033
+ "$project" "$node_version" "$source_manifest" npm \
1034
+ || fail "nodejs $node_version installed without a contained runnable npm"
1035
+ if resolve_node_context_executable \
1036
+ "$project" "$node_version" "$source_manifest" corepack; then
1037
+ corepack_bin=$NODE_CONTEXT_EXECUTABLE
1038
+ else
1039
+ corepack_bin=""
1040
+ fi
1041
+ if [ -z "$corepack_bin" ] \
1042
+ || ! corepack_version_is_usable "$corepack_bin" "$project" "$node_version"; then
1043
+ log "Corepack is missing or failed its exact proof in nodejs $node_version; performing one exact Corepack repair"
1044
+ reinstall_managed_corepack \
1045
+ "$project" "$node_version" "$source_manifest" "$manager"
1046
+ corepack_repaired=1
1047
+ fi
1048
+
1049
+ log "enabling Corepack $manager for nodejs $node_version"
1050
+ if prepare_managed_package_manager_once \
1051
+ "$project" "$node_version" "$manager" "$spec" "$is_fallback" \
1052
+ "$source_manifest"; then
1053
+ return 0
1054
+ fi
1055
+ initial_error=$COREPACK_PREPARATION_ERROR
1056
+ if [ "$corepack_repaired" -eq 1 ]; then
1057
+ fail "$initial_error after one exact Corepack repair"
1058
+ fi
1059
+ log "$initial_error; performing one exact Corepack repair and retry"
1060
+ reinstall_managed_corepack \
1061
+ "$project" "$node_version" "$source_manifest" "$manager"
1062
+ prepare_managed_package_manager_once \
1063
+ "$project" "$node_version" "$manager" "$spec" "$is_fallback" \
1064
+ "$source_manifest" \
1065
+ || fail "$COREPACK_PREPARATION_ERROR after one exact Corepack repair (initial: $initial_error)"
1066
+ }
1067
+ # END UAI_MANAGED_COREPACK_REPAIR
1068
+
1069
+ materialize_manifest() {
1070
+ local manifest=$1 project line tool version line_number=0
1071
+ local -a fields=()
1072
+
1073
+ project=$(dirname "$manifest")
1074
+ project_package_manager "$project"
1075
+ while IFS= read -r line || [ -n "$line" ]; do
1076
+ line_number=$((line_number + 1))
1077
+ line=${line%%#*}
1078
+ line=$(trim_spaces "$line")
1079
+ [ -n "$line" ] || continue
1080
+ read -r -a fields <<< "$line"
1081
+ tool=${fields[0]}
1082
+ case "$tool" in
1083
+ pnpm|yarn) continue ;; # Corepack-managed; see project_package_manager.
1084
+ esac
1085
+ unset_tool_override "$tool"
1086
+ asdf plugin list | grep -Fqx -- "$tool" \
1087
+ || fail "$manifest:$line_number requests unsupported asdf plugin '$tool'"
1088
+
1089
+ for version in "${fields[@]:1}"; do
1090
+ case "$version" in
1091
+ system) ;;
1092
+ path:*)
1093
+ if [ "$VERIFY_ONLY" = 1 ]; then
1094
+ # Shims either already exist (the later shim probe proves them) or
1095
+ # the verify pass fails and the host escalates to the writable one.
1096
+ log "verify-only: skipping shim creation for external $tool $version"
1097
+ else
1098
+ (cd "$project" && run_asdf_reshim "$tool" "$version") \
1099
+ || fail "could not create ASDF shims for external $tool $version"
1100
+ fi
1101
+ ;;
1102
+ *)
1103
+ if [ "$tool" = nodejs ] && is_default_node_version "$version"; then
1104
+ # Boot maintenance exclusively owns the default Node candidate and
1105
+ # its globally installed agent CLIs/Corepack. Do not create or
1106
+ # alter it after a failed boot transaction.
1107
+ managed_install_location \
1108
+ "$tool" "$version" "$project" "$manifest"
1109
+ [ "$MANAGED_INSTALL_EXISTS" -eq 1 ] \
1110
+ || fail "host-default nodejs $version is missing; standard-image boot maintenance must repair it before task runtime materialization"
1111
+ log "proving host-default $tool $version for $(basename "$project")"
1112
+ elif [ "$VERIFY_ONLY" = 1 ]; then
1113
+ managed_install_location \
1114
+ "$tool" "$version" "$project" "$manifest"
1115
+ [ "$MANAGED_INSTALL_EXISTS" -eq 1 ] \
1116
+ || fail "$tool $version is not installed on the shared runtime volume (verify-only pass makes no installs)"
1117
+ log "verifying $tool $version for $(basename "$project")"
1118
+ else
1119
+ managed_install_location \
1120
+ "$tool" "$version" "$project" "$manifest"
1121
+ log "installing $tool $version for $(basename "$project")"
1122
+ (cd "$project" && run_asdf_install "$tool" "$version") \
1123
+ || fail "asdf install failed for $tool $version in $(basename "$project")"
1124
+ fi
1125
+ ;;
1126
+ esac
1127
+ done
1128
+ for version in "${fields[@]:1}"; do
1129
+ prove_or_repair_managed_selection \
1130
+ "$tool" "$version" "$project" "$manifest"
1131
+ done
1132
+
1133
+ done < "$manifest"
1134
+ }
1135
+
1136
+ manifest_declares_tool() {
1137
+ local manifest=$1 tool=$2 line
1138
+ [ -f "$manifest" ] || return 1
1139
+ while IFS= read -r line || [ -n "$line" ]; do
1140
+ line=${line%%#*}
1141
+ line=$(trim_spaces "$line")
1142
+ case "$line" in
1143
+ "$tool"' '*) return 0 ;;
1144
+ esac
1145
+ done < "$manifest"
1146
+ return 1
1147
+ }
1148
+
1149
+ materialize_fallback_runtimes() {
1150
+ local project=$1 defaults=/home/node/.tool-versions manifest line tool version
1151
+ local -a fields=()
1152
+ manifest="$project/.tool-versions"
1153
+ while IFS= read -r line || [ -n "$line" ]; do
1154
+ line=${line%%#*}
1155
+ line=$(trim_spaces "$line")
1156
+ [ -n "$line" ] || continue
1157
+ read -r -a fields <<< "$line"
1158
+ tool=${fields[0]}
1159
+ if manifest_declares_tool "$manifest" "$tool"; then
1160
+ continue
1161
+ fi
1162
+ asdf plugin list | grep -Fqx -- "$tool" \
1163
+ || fail "$defaults requests unavailable fallback plugin '$tool'"
1164
+ for version in "${fields[@]:1}"; do
1165
+ case "$version" in
1166
+ system) ;;
1167
+ path:*)
1168
+ if [ "$VERIFY_ONLY" = 1 ]; then
1169
+ log "verify-only: skipping shim creation for fallback external $tool $version"
1170
+ else
1171
+ (cd "$project" && run_asdf_reshim "$tool" "$version") \
1172
+ || fail "could not create ASDF shims for fallback external $tool $version"
1173
+ fi
1174
+ ;;
1175
+ *)
1176
+ if [ "$tool" = nodejs ] && is_default_node_version "$version"; then
1177
+ # The standard-image transaction must restore Node/npm plus its
1178
+ # CLI/Corepack invariant as one crash-retryable unit.
1179
+ managed_install_location \
1180
+ "$tool" "$version" "$project" "$defaults"
1181
+ [ "$MANAGED_INSTALL_EXISTS" -eq 1 ] \
1182
+ || fail "fallback host-default nodejs $version is missing; standard-image boot maintenance must repair it before task runtime materialization"
1183
+ log "proving fallback host-default $tool $version for $(basename "$project")"
1184
+ elif [ "$VERIFY_ONLY" = 1 ]; then
1185
+ managed_install_location \
1186
+ "$tool" "$version" "$project" "$defaults"
1187
+ [ "$MANAGED_INSTALL_EXISTS" -eq 1 ] \
1188
+ || fail "fallback $tool $version is not installed on the shared runtime volume (verify-only pass makes no installs)"
1189
+ log "verifying fallback $tool $version for $(basename "$project")"
1190
+ else
1191
+ managed_install_location \
1192
+ "$tool" "$version" "$project" "$defaults"
1193
+ log "installing fallback $tool $version for $(basename "$project")"
1194
+ (cd "$project" && run_asdf_install "$tool" "$version") \
1195
+ || fail "asdf install failed for fallback $tool $version in $(basename "$project")"
1196
+ fi
1197
+ ;;
1198
+ esac
1199
+ prove_or_repair_managed_selection \
1200
+ "$tool" "$version" "$project" "$defaults"
1201
+ done
1202
+ done < "$defaults"
1203
+ }
1204
+
1205
+ default_node_versions() {
1206
+ local defaults=/home/node/.tool-versions line
1207
+ local -a fields=()
1208
+ DEFAULT_NODE_VERSIONS=()
1209
+ if [ -L "$defaults" ] || [ ! -f "$defaults" ]; then
1210
+ fail "effective default Node manifest $defaults is not a regular file"
1211
+ fi
1212
+ while IFS= read -r line || [ -n "$line" ]; do
1213
+ line=${line%%#*}
1214
+ line=$(trim_spaces "$line")
1215
+ case "$line" in
1216
+ nodejs' '*)
1217
+ read -r -a fields <<< "$line"
1218
+ for version in "${fields[@]:1}"; do
1219
+ validate_version "$version" /home/node "$defaults" 1
1220
+ DEFAULT_NODE_VERSIONS+=("$version")
1221
+ done
1222
+ ;;
1223
+ esac
1224
+ done < "$defaults"
1225
+ [ "${#DEFAULT_NODE_VERSIONS[@]}" -gt 0 ] \
1226
+ || fail "$defaults does not declare an effective default Node runtime"
1227
+ }
1228
+
1229
+ effective_node_versions() {
1230
+ local project=$1 manifest="$project/.tool-versions" line
1231
+ local -a fields=()
1232
+ EFFECTIVE_NODE_VERSIONS=()
1233
+ EFFECTIVE_NODE_SOURCE=""
1234
+ if manifest_declares_tool "$manifest" nodejs; then
1235
+ EFFECTIVE_NODE_SOURCE=$manifest
1236
+ while IFS= read -r line || [ -n "$line" ]; do
1237
+ line=${line%%#*}
1238
+ line=$(trim_spaces "$line")
1239
+ case "$line" in
1240
+ nodejs' '*)
1241
+ read -r -a fields <<< "$line"
1242
+ EFFECTIVE_NODE_VERSIONS=("${fields[@]:1}")
1243
+ break
1244
+ ;;
1245
+ esac
1246
+ done < "$manifest"
1247
+ else
1248
+ default_node_versions
1249
+ EFFECTIVE_NODE_VERSIONS=("${DEFAULT_NODE_VERSIONS[@]}")
1250
+ EFFECTIVE_NODE_SOURCE=/home/node/.tool-versions
1251
+ fi
1252
+ [ "${#EFFECTIVE_NODE_VERSIONS[@]}" -gt 0 ] \
1253
+ || fail "could not determine effective Node versions for $(basename "$project")"
1254
+ }
1255
+
1256
+ project_uses_npm() {
1257
+ local project=$1 manager=""
1258
+ [ -f "$project/package.json" ] || return 1
1259
+ manager=$(jq -r '.packageManager // ""' "$project/package.json" \
1260
+ | sed 's/@.*//')
1261
+ if [ -z "$manager" ]; then
1262
+ if [ -f "$project/pnpm-lock.yaml" ]; then
1263
+ manager=pnpm
1264
+ elif [ -f "$project/yarn.lock" ]; then
1265
+ manager=yarn
1266
+ elif [ -f "$project/bun.lockb" ] || [ -f "$project/bun.lock" ]; then
1267
+ manager=bun
1268
+ else
1269
+ manager=npm
1270
+ fi
1271
+ fi
1272
+ case "$manager" in
1273
+ pnpm|yarn|bun) return 1 ;;
1274
+ *) return 0 ;;
1275
+ esac
1276
+ }
1277
+
1278
+ prove_project_npm() {
1279
+ local project=$1 version npm_bin actual shim_actual
1280
+ project_uses_npm "$project" || return 0
1281
+ effective_node_versions "$project"
1282
+ for version in "${EFFECTIVE_NODE_VERSIONS[@]}"; do
1283
+ resolve_node_context_executable \
1284
+ "$project" "$version" "$EFFECTIVE_NODE_SOURCE" npm \
1285
+ || fail "npm did not resolve inside effective nodejs $version in $(basename "$project")"
1286
+ npm_bin=$NODE_CONTEXT_EXECUTABLE
1287
+ actual=$(execute_in_node_context \
1288
+ "$npm_bin" "$project" "$version" --version) \
1289
+ || fail "npm execution failed for effective nodejs $version in $(basename "$project")"
1290
+ [ -n "$actual" ] \
1291
+ || fail "npm returned no version for effective nodejs $version in $(basename "$project")"
1292
+ shim_actual=$(run_node_shim_probe \
1293
+ "$project" "$version" npm --version) \
1294
+ || fail "npm shim execution failed for effective nodejs $version in $(basename "$project")"
1295
+ [ -n "$shim_actual" ] \
1296
+ || fail "npm shim returned no version for effective nodejs $version in $(basename "$project")"
1297
+ done
1298
+ }
1299
+
1300
+ prepare_project_package_manager() {
1301
+ local project=$1 version
1302
+ project_package_manager "$project"
1303
+ [ -n "$PROJECT_PACKAGE_MANAGER" ] || return 0
1304
+ effective_node_versions "$project"
1305
+ for version in "${EFFECTIVE_NODE_VERSIONS[@]}"; do
1306
+ prepare_package_manager "$project" "$version" \
1307
+ "$PROJECT_PACKAGE_MANAGER" "$PROJECT_PACKAGE_MANAGER_SPEC" \
1308
+ "$PROJECT_PACKAGE_MANAGER_IS_FALLBACK" "$EFFECTIVE_NODE_SOURCE"
1309
+ done
1310
+ }
1311
+
1312
+ prove_project_package_manager() {
1313
+ local project=$1 version manager_bin actual shim_actual expected
1314
+ project_package_manager "$project"
1315
+ [ -n "$PROJECT_PACKAGE_MANAGER" ] || return 0
1316
+ effective_node_versions "$project"
1317
+ expected=${PROJECT_PACKAGE_MANAGER_SPEC#"$PROJECT_PACKAGE_MANAGER"@}
1318
+ expected=${expected%%+*}
1319
+ for version in "${EFFECTIVE_NODE_VERSIONS[@]}"; do
1320
+ resolve_node_context_executable \
1321
+ "$project" "$version" "$EFFECTIVE_NODE_SOURCE" \
1322
+ "$PROJECT_PACKAGE_MANAGER" \
1323
+ || fail "final $PROJECT_PACKAGE_MANAGER proof could not resolve inside nodejs $version"
1324
+ manager_bin=$NODE_CONTEXT_EXECUTABLE
1325
+ actual=$(execute_in_node_context \
1326
+ "$manager_bin" "$project" "$version" --version) \
1327
+ || fail "final $PROJECT_PACKAGE_MANAGER execution proof failed for nodejs $version"
1328
+ [ "$actual" = "$expected" ] || fail \
1329
+ "$PROJECT_PACKAGE_MANAGER resolved $actual instead of $expected after final reshim"
1330
+ shim_actual=$(run_node_shim_probe \
1331
+ "$project" "$version" "$PROJECT_PACKAGE_MANAGER" --version) \
1332
+ || fail "final $PROJECT_PACKAGE_MANAGER shim execution failed for nodejs $version"
1333
+ [ "$shim_actual" = "$expected" ] || fail \
1334
+ "$PROJECT_PACKAGE_MANAGER shim resolved $shim_actual instead of $expected"
1335
+ done
1336
+ }
1337
+
1338
+ prove_fallback_runtimes() {
1339
+ local project=$1 defaults=/home/node/.tool-versions manifest line tool version
1340
+ local -a fields=()
1341
+ manifest="$project/.tool-versions"
1342
+ while IFS= read -r line || [ -n "$line" ]; do
1343
+ line=${line%%#*}
1344
+ line=$(trim_spaces "$line")
1345
+ [ -n "$line" ] || continue
1346
+ read -r -a fields <<< "$line"
1347
+ tool=${fields[0]}
1348
+ if manifest_declares_tool "$manifest" "$tool"; then
1349
+ continue
1350
+ fi
1351
+ for version in "${fields[@]:1}"; do
1352
+ prove_selection "$tool" "$version" "$project" "$defaults"
1353
+ done
1354
+ done < "$defaults"
1355
+ }
1356
+
1357
+ # Validate fallback and package-manager declarations before any shared-volume
1358
+ # mutation. Every real project has effective home defaults even with no file.
1359
+ validate_manifest /home/node/.tool-versions
1360
+ for project in "${projects[@]}"; do
1361
+ project_package_manager "$project"
1362
+ done
1363
+
1364
+ # Fresh image-seeded volumes already carry this root-owned, node-writable
1365
+ # regular file. Legacy volumes are repaired by standard-image boot maintenance.
1366
+ if [ -L "$LOCK_PATH" ]; then
1367
+ fail "$LOCK_PATH is a symbolic link"
1368
+ fi
1369
+ if [ "$VERIFY_ONLY" = 1 ]; then
1370
+ # A verify pass cannot create the lock on a read-only volume. Take a SHARED
1371
+ # flock when the file exists (readers must not serialize behind each other,
1372
+ # and must still wait out a writer on runtimes where one is possible); a
1373
+ # missing lock file only occurs on a legacy volume, where the read-only
1374
+ # attachment itself excludes every Apple writer.
1375
+ if [ -e "$LOCK_PATH" ]; then
1376
+ [ -f "$LOCK_PATH" ] || fail "$LOCK_PATH is not a regular lock file"
1377
+ exec 9< "$LOCK_PATH" || fail "could not open $LOCK_PATH"
1378
+ if ! "$FLOCK_BIN" --shared --timeout "$LOCK_WAIT_SECONDS" 9; then
1379
+ fail "timed out after ${LOCK_WAIT_SECONDS}s waiting for shared asdf maintenance"
1380
+ fi
1381
+ else
1382
+ log "verify-only: $LOCK_PATH is absent on the read-only volume; proceeding without it"
1383
+ fi
1384
+ else
1385
+ if [ ! -e "$LOCK_PATH" ]; then
1386
+ old_umask=$(umask)
1387
+ umask 002
1388
+ : > "$LOCK_PATH" || fail "could not create $LOCK_PATH"
1389
+ umask "$old_umask"
1390
+ fi
1391
+ if [ ! -f "$LOCK_PATH" ]; then
1392
+ fail "$LOCK_PATH is not a regular lock file"
1393
+ fi
1394
+ exec 9< "$LOCK_PATH" || fail "could not open $LOCK_PATH"
1395
+ if ! "$FLOCK_BIN" --exclusive --timeout "$LOCK_WAIT_SECONDS" 9; then
1396
+ fail "timed out after ${LOCK_WAIT_SECONDS}s waiting for shared asdf maintenance"
1397
+ fi
1398
+ fi
1399
+
1400
+ # BEGIN UAI_FORBIDDEN_INTERPRETER_SHIM
1401
+ validate_shims_root() {
1402
+ local shims_root="$ASDF_DATA/shims" lexical resolved
1403
+ if [ -L "$shims_root" ] || [ ! -d "$shims_root" ]; then
1404
+ fail "shared asdf shims root is not a real directory"
1405
+ fi
1406
+ lexical=$("$REALPATH_BIN" -m -s -- "$shims_root") \
1407
+ || fail "could not normalize shared asdf shims root"
1408
+ resolved=$("$REALPATH_BIN" -e -- "$shims_root") \
1409
+ || fail "could not resolve shared asdf shims root"
1410
+ [ "$lexical" = "$resolved" ] \
1411
+ || fail "shared asdf shims root traverses a symbolic link"
1412
+ SHIMS_ROOT=$lexical
1413
+ }
1414
+
1415
+ clear_forbidden_interpreter_shim() {
1416
+ local fail_if_found=$1 candidate
1417
+ validate_shims_root
1418
+ candidate="$SHIMS_ROOT/bash"
1419
+ # Every asdf shim uses `#!/usr/bin/env bash`. A shared shim named `bash`
1420
+ # would recursively intercept every app command and previously could also
1421
+ # intercept maintenance itself. It is not an advertised runtime executable.
1422
+ if [ ! -e "$candidate" ] && [ ! -L "$candidate" ]; then
1423
+ return 0
1424
+ fi
1425
+ [ ! -d "$candidate" ] \
1426
+ || fail "forbidden shared asdf bash shim is a directory; refusing recursive cleanup"
1427
+ "$RM_BIN" -f -- "$candidate" \
1428
+ || fail "could not remove the exact forbidden shared asdf bash shim"
1429
+ [ ! -e "$candidate" ] && [ ! -L "$candidate" ] \
1430
+ || fail "forbidden shared asdf bash shim survived exact cleanup"
1431
+ log "removed forbidden shared asdf bash shim"
1432
+ [ "$fail_if_found" -eq 0 ] || return 1
1433
+ }
1434
+
1435
+ run_asdf_reshim() {
1436
+ local reshim_status=0 shim_status=0
1437
+ asdf reshim "$@" || reshim_status=$?
1438
+ clear_forbidden_interpreter_shim 1 || shim_status=$?
1439
+ [ "$shim_status" -eq 0 ] || return 1
1440
+ return "$reshim_status"
1441
+ }
1442
+
1443
+ run_asdf_install() {
1444
+ local install_status=0 shim_status=0
1445
+ asdf install "$@" || install_status=$?
1446
+ # asdf 0.14.1 reshims internally after a successful install. Always inspect
1447
+ # and clean that side effect, including when the install later reports an
1448
+ # error, before returning control to an actionable caller failure.
1449
+ clear_forbidden_interpreter_shim 1 || shim_status=$?
1450
+ [ "$shim_status" -eq 0 ] || return 1
1451
+ return "$install_status"
1452
+ }
1453
+ # END UAI_FORBIDDEN_INTERPRETER_SHIM
1454
+
1455
+ # A prior killed run may have created this exact stale shim. Clean it while
1456
+ # holding the real volume lock, before sourcing or executing any ASDF shim.
1457
+ clear_forbidden_interpreter_shim 0
1458
+
1459
+ # Sourcing asdf prepends the shared shims directory. Do it only after the
1460
+ # volume lock is held, retain its shell function, then restore the trusted
1461
+ # control-plane PATH. App/shim parity is opted into only at explicit probes.
1462
+ # shellcheck disable=SC1091
1463
+ . "$ASDF_SH"
1464
+ export PATH="$CONTROL_PATH"
1465
+ command -v asdf >/dev/null 2>&1 || fail "asdf did not initialize"
1466
+
1467
+ export COREPACK_HOME="$ASDF_DATA/corepack"
1468
+ # A verify pass only reads the Corepack cache; a missing directory then fails
1469
+ # the package-manager proofs below, which is the correct escalation signal.
1470
+ [ "$VERIFY_ONLY" = 1 ] || mkdir -p "$COREPACK_HOME"
1471
+ for manifest in "${manifests[@]}"; do
1472
+ # Revalidate after acquiring the lock, then perform this caller's own full
1473
+ # install and proof. A prior concurrent materializer is never success for us.
1474
+ validate_manifest "$manifest"
1475
+ materialize_manifest "$manifest"
1476
+ done
1477
+ for project in "${projects[@]}"; do
1478
+ materialize_fallback_runtimes "$project"
1479
+ done
1480
+
1481
+ # Rebuild shared shims after runtime installs, then prepare package managers.
1482
+ # Corepack preparation may reshim selected Node versions itself; every runtime
1483
+ # and manager is therefore re-executed below only after the last mutation.
1484
+ if [ "$VERIFY_ONLY" = 1 ]; then
1485
+ # Nothing was mutated, so there is nothing to reshim or prepare; the proofs
1486
+ # below still execute every runtime and manager through the existing shims.
1487
+ log "verify-only: skipping reshim and package-manager preparation"
1488
+ else
1489
+ run_asdf_reshim || fail "final asdf reshim failed"
1490
+ for project in "${projects[@]}"; do
1491
+ prepare_project_package_manager "$project"
1492
+ done
1493
+ clear_forbidden_interpreter_shim 1 \
1494
+ || fail "a runtime recreated the forbidden shared asdf bash shim"
1495
+ fi
1496
+
1497
+ # From here on, package-manager execution is a cache-only postcondition. The
1498
+ # app receives the same setting, so a missing exact/fallback artifact fails now
1499
+ # instead of downloading later after the shared cache becomes read-only.
1500
+ export COREPACK_ENABLE_NETWORK=0
1501
+ for manifest in "${manifests[@]}"; do
1502
+ project=$(dirname "$manifest")
1503
+ while IFS= read -r line || [ -n "$line" ]; do
1504
+ line=${line%%#*}
1505
+ line=$(trim_spaces "$line")
1506
+ [ -n "$line" ] || continue
1507
+ read -r -a fields <<< "$line"
1508
+ case "${fields[0]}" in
1509
+ pnpm|yarn) continue ;; # proven via prove_project_package_manager below
1510
+ esac
1511
+ unset_tool_override "${fields[0]}"
1512
+ for version in "${fields[@]:1}"; do
1513
+ prove_selection "${fields[0]}" "$version" "$project" "$manifest"
1514
+ done
1515
+ done < "$manifest"
1516
+ done
1517
+ for project in "${projects[@]}"; do
1518
+ prove_fallback_runtimes "$project"
1519
+ prove_project_npm "$project"
1520
+ prove_project_package_manager "$project"
1521
+ done
1522
+
1523
+ if [ "$VERIFY_ONLY" = 1 ]; then
1524
+ log "runtime verification complete for ${#projects[@]} project(s) (no volume changes)"
1525
+ else
1526
+ log "runtime materialization complete for ${#projects[@]} project(s)"
1527
+ fi