@runuai/host 0.9.1 → 0.9.3

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.
@@ -38,6 +38,18 @@ ASDF_VOLUME="uai-asdf-data"
38
38
  PW_VOLUME="uai-playwright"
39
39
  DERIVED_IMAGE="uai-task-${task_id}"
40
40
 
41
+ # Project-defined environment values arrive as one JSON object on stdin. They
42
+ # are deliberately NOT part of this host process environment: names such as
43
+ # BASH_ENV, PATH, GIT_CONFIG_*, and UAI_* must not influence task setup or the
44
+ # credentialed host-side fetch. Values stay in shell memory until Compose reads
45
+ # an override document from stdin; they are never written to disk.
46
+ project_env_json=$(cat)
47
+ [ -n "$project_env_json" ] || project_env_json='{}'
48
+ if ! printf '%s' "$project_env_json" | jq -e 'type == "object"' >/dev/null 2>&1; then
49
+ emit_err "BAD_ARGS" "task environment payload must be a JSON object" "read task environment"
50
+ fi
51
+ project_env_json=$(printf '%s' "$project_env_json" | jq -c '.')
52
+
41
53
  # -----------------------------------------------------------------------------
42
54
  # Paths (mirror host-agent/lib/env.ts).
43
55
  # -----------------------------------------------------------------------------
@@ -49,6 +61,15 @@ projects_root="$UAI_WORKSPACE_ROOT/projects"
49
61
  compose_project=$(compose_project_for_task "$task_id")
50
62
  app_container=$(app_container_for_task "$task_id")
51
63
 
64
+ reject_unsafe_host_directory() {
65
+ local path="$1" label="$2"
66
+ if [ -L "$path" ] || { [ -e "$path" ] && [ ! -d "$path" ]; }; then
67
+ emit_err "WORKTREE_FAILED" \
68
+ "Uai refused $label because it is not a trusted directory: $path" \
69
+ "validate task workspace paths"
70
+ fi
71
+ }
72
+
52
73
  mapped_host_port() {
53
74
  local container="$1" container_port="$2"
54
75
  docker port "$container" "$container_port" 2>/dev/null \
@@ -68,6 +89,31 @@ task_json=$(db_get_task "$task_id")
68
89
 
69
90
  task_branch=$(jq -r '.[0].branch' <<<"$task_json")
70
91
 
92
+ # The standard container runs as node:node (1000:1000), while Docker Desktop
93
+ # hosts commonly run as 501:20 and Linux hosts may use another uid/gid. Share
94
+ # only this task's writable trees through the host's effective group. A root
95
+ # host is the exception: never add the unprivileged container user to gid 0;
96
+ # root can chgrp the task trees directly to node's gid instead.
97
+ task_container_gid=$(id -g)
98
+ if [ "$(id -u)" = "0" ]; then
99
+ task_container_gid=1000
100
+ elif [ "$task_container_gid" = "0" ]; then
101
+ task_container_gid=""
102
+ for candidate_gid in $(id -G); do
103
+ if [ "$candidate_gid" != "0" ]; then
104
+ task_container_gid="$candidate_gid"
105
+ break
106
+ fi
107
+ done
108
+ fi
109
+ case "$task_container_gid" in
110
+ ''|*[!0-9]*)
111
+ emit_err "WORKTREE_FAILED" \
112
+ "Uai could not resolve a safe host group for the task container" \
113
+ "resolve task container filesystem group"
114
+ ;;
115
+ esac
116
+
71
117
  step "PROJECT_NOT_FOUND" "read task projects"
72
118
  projects_json=$(db_get_projects_for_task "$task_id")
73
119
  project_count=$(jq 'length' <<<"$projects_json")
@@ -88,38 +134,295 @@ db_update_task "$task_id" \
88
134
  "worktree_path='$(sql_escape "$task_dir")'" \
89
135
  "compose_project='$(sql_escape "$compose_project")'"
90
136
 
137
+ # `.uai` is not container-mounted, but validate these host-owned parents before
138
+ # using the previous compose file. The workspace itself is checked after the
139
+ # old container is removed so it cannot race the check.
140
+ reject_unsafe_host_directory "$task_dir" "the task storage root"
141
+ reject_unsafe_host_directory "$task_uai_dir" "the task runtime directory"
142
+
143
+ # A previous task container can write both its workspace and its task-private
144
+ # Git repository. Remove it before the host parses either repository on resume.
145
+ # This also cuts off legacy containers that still have the old shared
146
+ # `repo.git` bind mount before we inspect their worktree layout below.
147
+ step "COMPOSE_DOWN_FAILED" "remove prior task containers"
148
+ if [ -f "$task_uai_dir/docker-compose.yml" ]; then
149
+ docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" \
150
+ down --remove-orphans >/dev/null 2>&1 || true
151
+ else
152
+ docker compose -p "$compose_project" down --remove-orphans \
153
+ >/dev/null 2>&1 || true
154
+ fi
155
+ prior_containers=""
156
+ if ! prior_containers=$(docker ps -aq \
157
+ --filter "label=com.docker.compose.project=$compose_project"); then
158
+ emit_err "COMPOSE_DOWN_FAILED" \
159
+ "could not verify removal of the previous task container" \
160
+ "remove prior task containers"
161
+ fi
162
+ if [ -n "$prior_containers" ]; then
163
+ emit_err "COMPOSE_DOWN_FAILED" \
164
+ "the previous task container is still running or stopped; Uai will not let the host access its writable Git repository until that container is removed" \
165
+ "remove prior task containers"
166
+ fi
167
+ reject_unsafe_host_directory "$task_workspace" "the task workspace"
168
+ mkdir -p "$task_workspace"
169
+
91
170
  # -----------------------------------------------------------------------------
92
171
  # 3. Bare mirrors + worktrees, one per selected project.
93
172
  # -----------------------------------------------------------------------------
94
173
 
95
- # Use the uai-managed host identity for all git-over-SSH (ADR-015: git auth is
96
- # a host-resident credential). `IdentitiesOnly=yes` + `IdentityAgent=none` keep
97
- # it from falling through to the operator's personal SSH agent (e.g. 1Password),
98
- # which both leaks the wrong key and blocks unattended task-up on its approval
99
- # prompt. `BatchMode=yes` fails fast instead of prompting. Falls back to the
100
- # host default SSH when the identity hasn't been set up (`pnpm setup-identity`).
174
+ # Prepare the optional SSH identity. Connected users fetch over HTTPS with
175
+ # their per-host GitHub credential; SSH is used for Git transport only when the
176
+ # task owner has not connected GitHub. `IdentitiesOnly=yes` +
177
+ # `IdentityAgent=none` keep that fallback from silently selecting a different
178
+ # personal key or blocking on an agent approval prompt.
101
179
  #
102
- # ADR-029: prefer the TASK CREATOR's per-user key (written to
103
- # $UAI_TASK_IDENTITY_DIR by the orchestrator) so the clone + push run as them,
104
- # not the operator. Fall back to the shared operator identity when the creator
105
- # has no key on this host (transitional / pre-ADR-029 users).
180
+ # ADR-029: use only the TASK CREATOR's per-user key (written to
181
+ # $UAI_TASK_IDENTITY_DIR by the orchestrator) so clone + push run as them. A
182
+ # task must never borrow the operator's shared identity: that would authorize
183
+ # one user as a different principal.
106
184
  if [ -n "${UAI_TASK_IDENTITY_DIR:-}" ] && [ -f "${UAI_TASK_IDENTITY_DIR}/id_ed25519" ]; then
107
185
  uai_identity_key="$UAI_TASK_IDENTITY_DIR/id_ed25519"
108
186
  log "git over SSH using the task creator's per-user key"
109
187
  else
110
- uai_identity_key="$UAI_DATA_DIR/identity/id_ed25519"
188
+ uai_identity_key=""
111
189
  fi
112
- if [ -f "$uai_identity_key" ]; then
190
+ uai_has_ssh_identity=0
191
+ if [ -n "$uai_identity_key" ] && [ -f "$uai_identity_key" ]; then
192
+ uai_has_ssh_identity=1
113
193
  uai_known_hosts="$UAI_DATA_DIR/identity/known_hosts"
114
194
  GIT_SSH_COMMAND="ssh -i $uai_identity_key -o IdentitiesOnly=yes -o IdentityAgent=none -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
115
195
  [ -f "$uai_known_hosts" ] && GIT_SSH_COMMAND="$GIT_SSH_COMMAND -o UserKnownHostsFile=$uai_known_hosts"
116
196
  export GIT_SSH_COMMAND
117
197
  log "git over SSH using $uai_identity_key (1Password agent bypassed)"
118
198
  else
119
- log "no uai identity key at $uai_identity_keygit uses host default SSH (run: pnpm setup-identity)"
199
+ log "no SSH identity for the task owner — SSH transport fallback unavailable"
120
200
  fi
121
201
 
122
- mkdir -p "$task_workspace"
202
+ # Only the non-secret path reaches this script. The task owner's token lives in
203
+ # a private per-task `git credential-cache` daemon and is never placed in the
204
+ # environment, argv, remote URL, mirror config, Compose, or logs.
205
+ github_credential_socket="${UAI_GITHUB_CREDENTIAL_SOCKET:-}"
206
+ unset UAI_GITHUB_CREDENTIAL_SOCKET
207
+ if [ -n "$github_credential_socket" ]; then
208
+ uai_git_transport="https"
209
+ else
210
+ # Refined after each GitHub fetch below: select SSH only when it was the
211
+ # mode that actually worked. A present-but-unregistered key must not turn a
212
+ # successfully anonymous public clone into broken SSH inside the container.
213
+ uai_git_transport="anonymous"
214
+ fi
215
+ uai_used_ssh_transport=0
216
+
217
+ git_with_github_credential() (
218
+ unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
219
+ GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
220
+ GIT_TRACE_PACKET GIT_TRACE_REDACT
221
+ export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
222
+ # If a container ever poisoned a shared mirror's URL config, fail closed
223
+ # instead of silently switching this operation to an SSH principal.
224
+ GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 git \
225
+ -c core.hooksPath=/dev/null \
226
+ -c core.fsmonitor=false \
227
+ -c http.extraHeader= \
228
+ -c http.sslVerify=true \
229
+ -c credential.helper= \
230
+ -c "credential.helper=cache --socket=$github_credential_socket" \
231
+ "$@"
232
+ )
233
+
234
+ # Command-scoped rewrite: the shared origin remains canonical HTTPS while this
235
+ # one task uses its SSH fallback identity.
236
+ git_with_github_ssh() (
237
+ unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
238
+ GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
239
+ GIT_TRACE_PACKET GIT_TRACE_REDACT
240
+ export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
241
+ GIT_TERMINAL_PROMPT=0 git \
242
+ -c core.hooksPath=/dev/null \
243
+ -c core.fsmonitor=false \
244
+ -c 'url.git@github.com:.insteadOf=https://github.com/' \
245
+ "$@"
246
+ )
247
+
248
+ # Clear every ambient credential helper so a public fallback cannot borrow the
249
+ # host operator's GitHub identity.
250
+ git_anonymous_https() (
251
+ unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
252
+ GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
253
+ GIT_TRACE_PACKET GIT_TRACE_REDACT
254
+ export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
255
+ GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 git \
256
+ -c core.hooksPath=/dev/null \
257
+ -c core.fsmonitor=false \
258
+ -c http.extraHeader= \
259
+ -c http.sslVerify=true \
260
+ -c credential.helper= \
261
+ "$@"
262
+ )
263
+
264
+ # Host-only Git control plane. New task containers see only their self-contained
265
+ # private repo; the shared mirror is a setup-time fetch cache and is never
266
+ # mounted. Before every host operation we still rebuild its config from trusted
267
+ # project data and discard hooks, both to heal legacy tasks and to fail closed
268
+ # after a crash.
269
+ git_host_control() (
270
+ unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
271
+ GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
272
+ GIT_TRACE_PACKET GIT_TRACE_REDACT GIT_CONFIG_COUNT \
273
+ GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1
274
+ export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
275
+ # This helper is for local repository bookkeeping and public/local remotes.
276
+ # It must never inherit the operator's SSH agent or credential helper. Every
277
+ # authenticated GitHub fetch goes through one of the explicit helpers above.
278
+ GIT_CONFIG_COUNT=2 \
279
+ GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null \
280
+ GIT_CONFIG_KEY_1=core.fsmonitor GIT_CONFIG_VALUE_1=false \
281
+ GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 git \
282
+ -c credential.helper= \
283
+ "$@"
284
+ )
285
+
286
+ assert_bare_repo_tree_safe() {
287
+ local repo="$1" code="$2" label="$3" bad_entry=""
288
+ if [ -L "$repo" ] || [ ! -d "$repo" ]; then
289
+ emit_err "$code" \
290
+ "Uai refused the $label because its repository root is missing or is a symbolic link: $repo" \
291
+ "validate $label"
292
+ fi
293
+ # Git should only ever create regular files and directories in these control
294
+ # trees. Reject links, FIFOs, sockets, and devices before any Git process can
295
+ # follow or block on a container-created entry.
296
+ if ! bad_entry=$(find "$repo" ! -type f ! -type d -print -quit 2>/dev/null); then
297
+ emit_err "$code" \
298
+ "Uai could not validate the $label before host Git access" \
299
+ "validate $label"
300
+ fi
301
+ if [ -n "$bad_entry" ]; then
302
+ emit_err "$code" \
303
+ "Uai refused the $label because it contains an unsafe non-file entry at $bad_entry. The task container has been removed; inspect or recreate the task before retrying." \
304
+ "validate $label"
305
+ fi
306
+ }
307
+
308
+ # Make a host-created task tree writable by the container's uid-1000 node user
309
+ # without making it world-writable or changing ownership away from the host.
310
+ # `find` deliberately does not follow repository symlinks. Setgid directories
311
+ # keep Git-created descendants in the shared group; core.sharedRepository
312
+ # below makes Git's own files group-writable as well.
313
+ set_task_entry_container_access() {
314
+ local entry="$1"
315
+ chgrp "$task_container_gid" "$entry" || return 1
316
+ if [ -d "$entry" ]; then
317
+ chmod ug+rwx,g+s "$entry" || return 1
318
+ else
319
+ chmod ug+rw "$entry" || return 1
320
+ fi
321
+ }
322
+
323
+ share_task_entry_with_container() {
324
+ local entry="$1" label="$2"
325
+ if ! set_task_entry_container_access "$entry"; then
326
+ emit_err "WORKTREE_FAILED" \
327
+ "Uai could not share the $label with the task container" \
328
+ "set task filesystem permissions"
329
+ fi
330
+ }
331
+
332
+ share_task_tree_with_container() {
333
+ local root="$1" label="$2" entry entry_list failed_entry=""
334
+ [ -d "$root" ] || return 0
335
+
336
+ entry_list=$(mktemp "/tmp/uai-task-tree.XXXXXX")
337
+ if ! find "$root" \( -type d -o -type f \) -print0 > "$entry_list"; then
338
+ rm -f "$entry_list"
339
+ emit_err "WORKTREE_FAILED" \
340
+ "Uai could not enumerate the complete $label before sharing it with the task container" \
341
+ "enumerate task filesystem"
342
+ fi
343
+ while IFS= read -r -d '' entry; do
344
+ if ! set_task_entry_container_access "$entry"; then
345
+ failed_entry="$entry"
346
+ break
347
+ fi
348
+ done < "$entry_list"
349
+ rm -f "$entry_list"
350
+ if [ -n "$failed_entry" ]; then
351
+ emit_err "WORKTREE_FAILED" \
352
+ "Uai could not share the complete $label with the task container" \
353
+ "set task filesystem permissions"
354
+ fi
355
+ }
356
+
357
+ sanitize_mirror_control_plane() {
358
+ local mirror="$1" origin="$2" config_tmp
359
+ assert_bare_repo_tree_safe "$mirror" "FETCH_FAILED" "host repository cache"
360
+ # Stage outside the bare repo so a predictable in-repo temporary path cannot
361
+ # be redirected even if a previous host operation crashed midway.
362
+ config_tmp=$(mktemp "$(dirname "$mirror")/.uai-config.XXXXXX")
363
+ git_host_control config --file "$config_tmp" core.repositoryformatversion 0
364
+ git_host_control config --file "$config_tmp" core.filemode true
365
+ git_host_control config --file "$config_tmp" core.bare true
366
+ git_host_control config --file "$config_tmp" remote.origin.url "$origin"
367
+ git_host_control config --file "$config_tmp" remote.origin.fetch \
368
+ '+refs/heads/*:refs/remotes/origin/*'
369
+ git_host_control config --file "$config_tmp" remote.pushDefault origin
370
+ git_host_control config --file "$config_tmp" push.autoSetupRemote true
371
+ mv -f "$config_tmp" "$mirror/config"
372
+ rm -rf "$mirror/hooks"
373
+ mkdir -p "$mirror/hooks"
374
+ }
375
+
376
+ sanitize_task_repo_control_plane() {
377
+ local repo="$1" origin="$2" branch="$3" bare="${4:-true}" config_tmp
378
+ local branch_remote="origin" branch_merge="refs/heads/$branch" candidate
379
+ assert_bare_repo_tree_safe "$repo" "WORKTREE_FAILED" \
380
+ "task-private repository"
381
+
382
+ # Preserve only the current branch's safe upstream from the container-
383
+ # writable config. Includes are disabled, the remote must remain `origin`,
384
+ # and the merge target must be a valid heads ref. Everything executable or
385
+ # host-sensitive (hooks, includes, helpers, fsmonitor) is rebuilt below.
386
+ if [ -f "$repo/config" ]; then
387
+ candidate=$(git_host_control config --no-includes --file "$repo/config" \
388
+ --get "branch.$branch.remote" 2>/dev/null || true)
389
+ [ "$candidate" = "origin" ] && branch_remote="$candidate"
390
+ candidate=$(git_host_control config --no-includes --file "$repo/config" \
391
+ --get "branch.$branch.merge" 2>/dev/null || true)
392
+ case "$candidate" in
393
+ refs/heads/*)
394
+ if git_host_control check-ref-format "$candidate" >/dev/null 2>&1; then
395
+ branch_merge="$candidate"
396
+ fi
397
+ ;;
398
+ esac
399
+ fi
400
+
401
+ config_tmp=$(mktemp "$(dirname "$repo")/.uai-task-config.XXXXXX")
402
+ git_host_control config --file "$config_tmp" core.repositoryformatversion 0
403
+ git_host_control config --file "$config_tmp" core.filemode true
404
+ git_host_control config --file "$config_tmp" core.bare "$bare"
405
+ git_host_control config --file "$config_tmp" core.sharedRepository group
406
+ git_host_control config --file "$config_tmp" remote.origin.url "$origin"
407
+ git_host_control config --file "$config_tmp" remote.origin.fetch \
408
+ '+refs/heads/*:refs/remotes/origin/*'
409
+ git_host_control config --file "$config_tmp" remote.pushDefault origin
410
+ git_host_control config --file "$config_tmp" push.autoSetupRemote true
411
+ git_host_control config --file "$config_tmp" \
412
+ "branch.$branch.remote" "$branch_remote"
413
+ git_host_control config --file "$config_tmp" \
414
+ "branch.$branch.merge" "$branch_merge"
415
+ mv -f "$config_tmp" "$repo/config"
416
+ rm -rf "$repo/hooks"
417
+ mkdir -p "$repo/hooks"
418
+ # mktemp is intentionally 0600. Once its trusted contents are atomically in
419
+ # place, grant only the selected task group access; this must run on every
420
+ # resume even after the one-time tree migration marker exists.
421
+ share_task_entry_with_container "$repo/config" \
422
+ "task-private Git config"
423
+ share_task_entry_with_container "$repo/hooks" \
424
+ "task-private Git hooks directory"
425
+ }
123
426
 
124
427
  # ADR-062 shared files: two host-resident roots mounted into the container
125
428
  # when the task's mode isn't "off". Created here so Docker doesn't create
@@ -139,6 +442,8 @@ if [ "$shared_files_mode" != "off" ]; then
139
442
  [ -n "$shared_owner_user" ] && mkdir -p "$shared_root/users/$shared_owner_user"
140
443
  # Marker for the preamble: only containers that actually carry the mounts
141
444
  # get the "## Shared files" briefing (pre-feature containers don't).
445
+ reject_unsafe_host_directory "$task_workspace/.uai" \
446
+ "the task workspace marker directory"
142
447
  mkdir -p "$task_workspace/.uai"
143
448
  : > "$task_workspace/.uai/files-mounted"
144
449
  fi
@@ -150,11 +455,93 @@ while IFS= read -r project_obj; do
150
455
 
151
456
  project_id=$(jq -r '.id' <<<"$project_obj")
152
457
  project_slug=$(jq -r '.slug' <<<"$project_obj")
153
- repo_url=$(jq -r '.repo_url' <<<"$project_obj")
458
+ case "$project_slug" in
459
+ ''|.|..|*/*|*\\*)
460
+ emit_err "WORKTREE_FAILED" \
461
+ "Uai refused an invalid project workspace component: $project_slug" \
462
+ "validate task worktree ($project_id)"
463
+ ;;
464
+ esac
465
+ acquire_project_lock "$projects_root" "$project_id"
466
+ repo_url_input=$(jq -r '.repo_url' <<<"$project_obj")
467
+ repo_is_github=0
468
+ if is_github_hosted_url "$repo_url_input"; then
469
+ repo_is_github=1
470
+ fi
471
+ # The mirror is fetched HERE, before the task container exists. GitHub URLs
472
+ # stay canonical HTTPS: a connected user's private credential socket
473
+ # authenticates them; users without a connection get the SSH fallback. The
474
+ # token never becomes part of this shared mirror or its remote URL.
475
+ repo_url=$(normalize_github_repo_url "$repo_url_input")
476
+ github_https_url=$(github_https_repo_url "$repo_url")
477
+ if [ "$repo_is_github" = "1" ] && [ -z "$github_https_url" ]; then
478
+ emit_err "CLONE_FAILED" \
479
+ "Uai refused the malformed GitHub repository URL '$repo_url_input'. Use https://github.com/OWNER/REPOSITORY.git and retry." \
480
+ "validate GitHub repository URL ($project_id)"
481
+ fi
154
482
  tool_versions=$(jq -r '.tool_versions // ""' <<<"$project_obj")
155
483
 
156
- mirror_dir="$projects_root/$project_id/repo.git"
484
+ # `repo.git` was historically bind-mounted RW into every task container.
485
+ # Never reuse it as trusted host input: running legacy tasks can still hold
486
+ # that old bind mount. The new cache path has never entered a container.
487
+ mirror_dir="$projects_root/$project_id/host-cache.git"
488
+ task_repo_dir="$task_uai_dir/repos/$project_id.git"
157
489
  worktree_target="$task_workspace/$project_slug"
490
+ reject_unsafe_host_directory "$worktree_target" \
491
+ "the project worktree for $project_slug"
492
+ # Check before remote/cache Git: a dangling symlink makes `-e` false and the
493
+ # later seed write would otherwise follow it outside the workspace.
494
+ if [ -L "$worktree_target/.tool-versions" ]; then
495
+ emit_err "WORKTREE_FAILED" \
496
+ "Uai refused a symbolic-link .tool-versions for $project_slug" \
497
+ "validate task worktree ($project_id)"
498
+ fi
499
+ worktree_exists=0
500
+ standalone_worktree=0
501
+ if [ -e "$worktree_target/.git" ] || [ -L "$worktree_target/.git" ]; then
502
+ worktree_exists=1
503
+ if [ -L "$worktree_target/.git" ]; then
504
+ emit_err "WORKTREE_FAILED" \
505
+ "Uai refused to resume $project_slug because its .git path is a symbolic link. The previous container was removed; inspect or recreate the task before retrying." \
506
+ "validate task worktree ($project_id)"
507
+ elif [ -d "$worktree_target/.git" ]; then
508
+ # Empty remotes use a standalone repository inside the workspace.
509
+ standalone_worktree=1
510
+ assert_bare_repo_tree_safe "$worktree_target/.git" "WORKTREE_FAILED" \
511
+ "standalone task repository"
512
+ elif [ -f "$worktree_target/.git" ]; then
513
+ gitdir_lines=$(awk 'END { print NR }' "$worktree_target/.git")
514
+ gitdir_value=$(sed -n 's/^gitdir: //p' "$worktree_target/.git")
515
+ case "$gitdir_value" in
516
+ "$task_repo_dir"/worktrees/*) ;;
517
+ *)
518
+ emit_err "WORKTREE_FAILED" \
519
+ "This task uses Uai's legacy shared Git layout. Its container has been removed so it can no longer write the shared mirror. Preserve any uncommitted changes from $worktree_target, then recreate the task to migrate safely." \
520
+ "legacy task repository requires recreation ($project_id)"
521
+ ;;
522
+ esac
523
+ gitdir_leaf=${gitdir_value#"$task_repo_dir"/worktrees/}
524
+ case "$gitdir_leaf" in
525
+ ''|.|..|*/*)
526
+ emit_err "WORKTREE_FAILED" \
527
+ "Uai refused an invalid task Git metadata path for $project_slug" \
528
+ "validate task worktree ($project_id)"
529
+ ;;
530
+ esac
531
+ if [ "$gitdir_lines" != "1" ] || [ ! -d "$gitdir_value" ] \
532
+ || [ -L "$gitdir_value" ]; then
533
+ emit_err "WORKTREE_FAILED" \
534
+ "Uai refused invalid or missing task Git metadata for $project_slug. The previous container was removed; inspect or recreate the task before retrying." \
535
+ "validate task worktree ($project_id)"
536
+ fi
537
+ assert_bare_repo_tree_safe "$task_repo_dir" "WORKTREE_FAILED" \
538
+ "task-private repository"
539
+ else
540
+ emit_err "WORKTREE_FAILED" \
541
+ "Uai refused an unsupported .git entry for $project_slug" \
542
+ "validate task worktree ($project_id)"
543
+ fi
544
+ fi
158
545
 
159
546
  # Ensure the bare mirror exists with **remote-tracking refs**. Plain
160
547
  # `git clone --bare` copies upstream heads straight into refs/heads/*
@@ -166,37 +553,254 @@ while IFS= read -r project_obj; do
166
553
  if [ ! -d "$mirror_dir" ]; then
167
554
  step "CLONE_FAILED" "bare-clone ($project_id)"
168
555
  mkdir -p "$(dirname "$mirror_dir")"
169
- git init --bare "$mirror_dir" >/dev/null
170
- git -C "$mirror_dir" remote add origin "$repo_url"
556
+ git_host_control init --bare "$mirror_dir" >/dev/null
171
557
  needs_initial_fetch=1
172
558
  fi
173
559
 
174
- # Always (re-)assert the remote-tracking refspec. Self-heals mirrors
175
- # created before this script learned the right refspec — idempotent.
176
- git -C "$mirror_dir" config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
560
+ # Replace all container-writable/legacy config before host Git reads the
561
+ # mirror. This also heals SSH origins to the canonical credential-free URL.
562
+ sanitize_mirror_control_plane "$mirror_dir" "$repo_url"
563
+
564
+ github_fetch_mode=""
177
565
 
178
566
  if [ "$needs_initial_fetch" = "1" ]; then
179
567
  # First fetch on a brand-new mirror — required. Bail and clean up so
180
568
  # the next attempt starts fresh rather than re-using a half-init.
181
- if ! git -C "$mirror_dir" fetch origin; then
569
+ fetch_err=""
570
+ fetch_failed=0
571
+ if [ -n "$github_https_url" ] && [ -n "$github_credential_socket" ]; then
572
+ github_fetch_mode="token"
573
+ if ! fetch_err=$(git_with_github_credential \
574
+ -C "$mirror_dir" fetch origin 2>&1); then
575
+ fetch_failed=1
576
+ fi
577
+ elif [ -n "$github_https_url" ]; then
578
+ if [ "$uai_has_ssh_identity" = "1" ]; then
579
+ github_fetch_mode="ssh"
580
+ if ! fetch_err=$(git_with_github_ssh \
581
+ -C "$mirror_dir" fetch origin 2>&1); then
582
+ fetch_failed=1
583
+ # github.com requires an authenticated SSH handshake even for public
584
+ # repositories. Preserve the no-credential public-repo path.
585
+ if git_anonymous_https -C "$mirror_dir" fetch origin \
586
+ >/dev/null 2>&1; then
587
+ github_fetch_mode="anonymous"
588
+ fetch_failed=0
589
+ fetch_err=""
590
+ fi
591
+ fi
592
+ else
593
+ github_fetch_mode="none"
594
+ if ! fetch_err=$(git_anonymous_https \
595
+ -C "$mirror_dir" fetch origin 2>&1); then
596
+ fetch_failed=1
597
+ else
598
+ github_fetch_mode="anonymous"
599
+ fi
600
+ fi
601
+ elif ! fetch_err=$(git_host_control -C "$mirror_dir" fetch origin 2>&1); then
602
+ fetch_failed=1
603
+ fi
604
+
605
+ if [ "$fetch_failed" = "1" ]; then
606
+ [ -n "$fetch_err" ] || fetch_err="git fetch failed without details"
182
607
  rm -rf "$mirror_dir"
183
- emit_err "CLONE_FAILED" "initial fetch from $repo_url failed" "bare-clone ($project_id)"
608
+ # GitHub deliberately uses the same 404 for a missing private repository
609
+ # and a credential that cannot see it. Name the credential path Uai
610
+ # actually chose so the recovery action is truthful.
611
+ case "$fetch_err" in
612
+ *"Repository not found"*|*"repository not found"*|\
613
+ *"Permission denied"*|*"access rights"*|*"could not read Username"*|\
614
+ *"Authentication failed"*|*"Invalid username or password"*|*"HTTP 401"*|\
615
+ *"HTTP 403"*|*"HTTP 404"*|*"requested URL returned error: 401"*|\
616
+ *"requested URL returned error: 403"*|*"requested URL returned error: 404"*)
617
+ if [ "$github_fetch_mode" = "token" ]; then
618
+ emit_err "GITHUB_TOKEN_ACCESS_DENIED" \
619
+ "GitHub denied the connected user's access to $repo_url. Confirm that the Uai GitHub App is installed on the owning account or organization, this repository is included in its grant, and the connected user can write to it. Then retry the task." \
620
+ "bare-clone ($project_id)"
621
+ elif [ "$github_fetch_mode" = "ssh" ]; then
622
+ emit_err "GITHUB_SSH_ACCESS_DENIED" \
623
+ "GitHub is not connected on this host, so Uai tried its SSH fallback for $repo_url, but GitHub denied that key. Connect GitHub on this host, or register its public SSH key with an account that can read the repository (and authorize it for SAML SSO when required)." \
624
+ "bare-clone ($project_id)"
625
+ elif [ "$github_fetch_mode" = "none" ]; then
626
+ emit_err "GITHUB_CONNECTION_REQUIRED" \
627
+ "This host has neither a GitHub connection nor an SSH fallback key that can read $repo_url. Connect GitHub on this host, or configure its optional SSH key, then retry the task." \
628
+ "bare-clone ($project_id)"
629
+ else
630
+ case "$repo_url" in
631
+ git@*|ssh://*)
632
+ emit_err "CLONE_FAILED" \
633
+ "The git server denied this host's SSH key access to $repo_url. Confirm the repository exists and add that host's public key to an account that can read it." \
634
+ "bare-clone ($project_id)"
635
+ ;;
636
+ *)
637
+ emit_err "CLONE_FAILED" \
638
+ "Access to $repo_url was denied. Configure the matching credential on this host, then retry the task." \
639
+ "bare-clone ($project_id)"
640
+ ;;
641
+ esac
642
+ fi
643
+ ;;
644
+ *)
645
+ emit_err "CLONE_FAILED" \
646
+ "initial fetch from $repo_url failed: $fetch_err" \
647
+ "bare-clone ($project_id)"
648
+ ;;
649
+ esac
184
650
  fi
185
651
  else
186
- # Existing mirror — refresh best-effort. Offline / network blip
187
- # shouldn't break task-up.
652
+ # Existing mirror — refresh best-effort. Offline / network blips should not
653
+ # destroy a task that already has cached refs, but never switch principals.
188
654
  step "FETCH_FAILED" "git fetch origin ($project_id)"
189
- if ! git -C "$mirror_dir" fetch origin >/dev/null 2>&1; then
190
- log "warning: git fetch failed for $project_id ($repo_url); using cached refs"
655
+ fetch_ok=0
656
+ fetch_err=""
657
+ if [ -n "$github_https_url" ] && [ -n "$github_credential_socket" ]; then
658
+ github_fetch_mode="token"
659
+ if fetch_err=$(git_with_github_credential \
660
+ -C "$mirror_dir" fetch origin 2>&1); then
661
+ fetch_ok=1
662
+ fi
663
+ elif [ -n "$github_https_url" ]; then
664
+ if [ "$uai_has_ssh_identity" = "1" ]; then
665
+ github_fetch_mode="ssh"
666
+ if fetch_err=$(git_with_github_ssh \
667
+ -C "$mirror_dir" fetch origin 2>&1); then
668
+ fetch_ok=1
669
+ fi
670
+ else
671
+ github_fetch_mode="none"
672
+ fi
673
+ if [ "$fetch_ok" != "1" ]; then
674
+ anonymous_err=""
675
+ if anonymous_err=$(git_anonymous_https \
676
+ -C "$mirror_dir" fetch origin 2>&1); then
677
+ github_fetch_mode="anonymous"
678
+ fetch_ok=1
679
+ fetch_err=""
680
+ elif [ "$github_fetch_mode" = "none" ]; then
681
+ fetch_err="$anonymous_err"
682
+ fi
683
+ fi
684
+ elif fetch_err=$(git_host_control -C "$mirror_dir" fetch origin 2>&1); then
685
+ fetch_ok=1
686
+ fi
687
+ if [ "$fetch_ok" != "1" ]; then
688
+ case "$fetch_err" in
689
+ *"Repository not found"*|*"repository not found"*|\
690
+ *"Permission denied"*|*"access rights"*|*"could not read Username"*|\
691
+ *"Authentication failed"*|*"Invalid username or password"*|*"HTTP 401"*|\
692
+ *"HTTP 403"*|*"HTTP 404"*|*"requested URL returned error: 401"*|\
693
+ *"requested URL returned error: 403"*|*"requested URL returned error: 404"*)
694
+ case "$github_fetch_mode" in
695
+ token)
696
+ emit_err "GITHUB_TOKEN_ACCESS_DENIED" \
697
+ "GitHub denied the connected user's access to $repo_url. Confirm that the Uai GitHub App grant still includes this repository and the connected user can write to it, then retry the task." \
698
+ "git fetch origin ($project_id)"
699
+ ;;
700
+ ssh)
701
+ emit_err "GITHUB_SSH_ACCESS_DENIED" \
702
+ "GitHub is not connected on this host, and it denied the SSH fallback for $repo_url. Connect GitHub or fix this host's SSH key access, then retry the task." \
703
+ "git fetch origin ($project_id)"
704
+ ;;
705
+ none)
706
+ emit_err "GITHUB_CONNECTION_REQUIRED" \
707
+ "This host has neither a GitHub connection nor an SSH fallback key that can read $repo_url. Connect GitHub on this host, or configure its optional SSH key, then retry the task." \
708
+ "git fetch origin ($project_id)"
709
+ ;;
710
+ *)
711
+ emit_err "FETCH_FAILED" \
712
+ "The configured credential no longer has access to $repo_url. Restore access, then retry the task." \
713
+ "git fetch origin ($project_id)"
714
+ ;;
715
+ esac
716
+ ;;
717
+ *)
718
+ if [ "$worktree_exists" = "1" ]; then
719
+ # Resume is the only safe stale-cache case: this same task already
720
+ # owns the checkout. A new task/user must prove current access
721
+ # before any shared cached code is materialized into its workspace.
722
+ log "warning: git fetch failed for $project_id ($repo_url); resuming this task's existing worktree with cached refs"
723
+ else
724
+ emit_err "FETCH_FAILED" \
725
+ "could not verify the task creator's current access to $repo_url: ${fetch_err:-git fetch failed}. Retry when the remote is reachable." \
726
+ "git fetch origin ($project_id)"
727
+ fi
728
+ ;;
729
+ esac
730
+ fi
731
+ fi
732
+
733
+ if [ "$github_fetch_mode" = "ssh" ]; then
734
+ if [ "$needs_initial_fetch" = "1" ] && [ "$fetch_failed" = "0" ]; then
735
+ uai_used_ssh_transport=1
736
+ elif [ "$needs_initial_fetch" != "1" ] && [ "$fetch_ok" = "1" ]; then
737
+ uai_used_ssh_transport=1
191
738
  fi
192
739
  fi
193
740
 
194
- # Resolve the remote default branch (best-effort) fall back to main.
195
- git -C "$mirror_dir" remote set-head origin -a >/dev/null 2>&1 || true
196
- project_default=$(git -C "$mirror_dir" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null \
741
+ # Resolve the remote default branch with the same task credential
742
+ # (best-effort) fall back to main.
743
+ case "$github_fetch_mode" in
744
+ token)
745
+ git_with_github_credential -C "$mirror_dir" remote set-head origin -a \
746
+ >/dev/null 2>&1 || true
747
+ ;;
748
+ ssh)
749
+ git_with_github_ssh -C "$mirror_dir" remote set-head origin -a \
750
+ >/dev/null 2>&1 || true
751
+ ;;
752
+ anonymous)
753
+ git_anonymous_https -C "$mirror_dir" remote set-head origin -a \
754
+ >/dev/null 2>&1 || true
755
+ ;;
756
+ none)
757
+ git_anonymous_https -C "$mirror_dir" remote set-head origin -a \
758
+ >/dev/null 2>&1 || true
759
+ ;;
760
+ *)
761
+ git_host_control -C "$mirror_dir" remote set-head origin -a >/dev/null 2>&1 || true
762
+ ;;
763
+ esac
764
+ project_default=$(git_host_control -C "$mirror_dir" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null \
197
765
  | sed 's#^origin/##' || true)
198
766
  [ -n "$project_default" ] || project_default="main"
199
767
 
768
+ # Each linked worktree gets its own writable Git control plane (config,
769
+ # hooks, refs, metadata, and objects). It borrows the host-only cache only
770
+ # while being prepared, then repacks every reachable object locally and
771
+ # removes the alternate before the container starts.
772
+ worktree_repo_dir="$task_repo_dir"
773
+ if [ "$standalone_worktree" = "1" ]; then
774
+ worktree_repo_dir="$worktree_target/.git"
775
+ sanitize_task_repo_control_plane "$worktree_repo_dir" "$repo_url" \
776
+ "$task_branch" false
777
+ else
778
+ if [ ! -d "$task_repo_dir" ]; then
779
+ mkdir -p "$(dirname "$task_repo_dir")"
780
+ git_host_control init --bare "$task_repo_dir" >/dev/null
781
+ fi
782
+ sanitize_task_repo_control_plane "$task_repo_dir" "$repo_url" \
783
+ "$task_branch" true
784
+ rm -rf "$task_repo_dir/objects/info"
785
+ mkdir -p "$task_repo_dir/objects/info"
786
+ printf '%s\n' "$mirror_dir/objects" \
787
+ > "$task_repo_dir/objects/info/alternates"
788
+ # Copy only refs/objects from the trusted host mirror. The task repo's
789
+ # remote remains canonical GitHub HTTPS from the sanitizer above.
790
+ if ! git_host_control -C "$task_repo_dir" fetch "$mirror_dir" \
791
+ '+refs/remotes/origin/*:refs/remotes/origin/*' >/dev/null 2>&1; then
792
+ emit_err "WORKTREE_FAILED" \
793
+ "could not copy the fetched refs into the task repository for $repo_url" \
794
+ "copy task repository refs ($project_id)"
795
+ fi
796
+ if ! git_host_control -C "$task_repo_dir" repack -a -d >/dev/null; then
797
+ emit_err "WORKTREE_FAILED" \
798
+ "could not make the task repository self-contained for $repo_url" \
799
+ "copy task repository objects ($project_id)"
800
+ fi
801
+ rm -f "$task_repo_dir/objects/info/alternates"
802
+ fi
803
+
200
804
  mkdir -p "$(dirname "$worktree_target")"
201
805
  if [ -e "$worktree_target/.git" ]; then
202
806
  # Resume (ADR-028): the task worktree survived the stop — reuse it as-is
@@ -206,21 +810,22 @@ while IFS= read -r project_obj; do
206
810
  # (found live 2026-07-21). The task branch and any uncommitted work are
207
811
  # exactly what the user expects back.
208
812
  log "worktree for $project_slug already present — resuming with it"
209
- # Self-heal an ORPHANED worktree. The bare mirror is shared by every task
210
- # on this project, so a prune of it (auto-gc, or a sibling's task-down)
211
- # can delete THIS worktree's admin dir (repo.git/worktrees/<name>) while
212
- # its working tree is momentarily unreachable — e.g. the re-mount window on
213
- # resume. The .git file is then a dangling pointer and every in-container
214
- # git op dies "fatal: not a git repository" (found live 2026-07-24, a
215
- # blocked agent unable to commit). `git worktree repair` rebuilds the admin
216
- # dir from the intact working tree; it is a no-op when the link is healthy.
217
- git -C "$mirror_dir" worktree repair "$worktree_target" 2>/dev/null || true
218
- elif git -C "$mirror_dir" rev-parse --verify --quiet \
813
+ if [ "$standalone_worktree" != "1" ]; then
814
+ # Repair only after the task-private repository and its gitdir pointer
815
+ # have passed the host boundary checks above.
816
+ git_host_control -C "$worktree_repo_dir" worktree repair \
817
+ "$worktree_target" 2>/dev/null || true
818
+ fi
819
+ elif git_host_control -C "$worktree_repo_dir" rev-parse --verify --quiet \
219
820
  "refs/remotes/origin/$project_default" >/dev/null 2>&1; then
220
821
  # Normal case: branch the task worktree off origin/<defaultBranch>.
221
822
  step "WORKTREE_FAILED" "git worktree add ($project_id)"
222
- git -C "$mirror_dir" worktree add "$worktree_target" \
223
- -b "$task_branch" "origin/$project_default"
823
+ if ! git_host_control -C "$worktree_repo_dir" worktree add "$worktree_target" \
824
+ -b "$task_branch" "origin/$project_default"; then
825
+ emit_err "WORKTREE_FAILED" \
826
+ "could not create the task worktree for $repo_url" \
827
+ "git worktree add ($project_id)"
828
+ fi
224
829
  else
225
830
  # Empty / branchless remote — there's no origin/<default> to branch from.
226
831
  # Don't fail the whole task over it (the old behaviour: "invalid reference"
@@ -231,10 +836,12 @@ while IFS= read -r project_obj; do
231
836
  log "warning: $repo_url has no '$project_default' branch (empty repo?); starting an empty workspace for $project_slug"
232
837
  rm -rf "$worktree_target"
233
838
  mkdir -p "$worktree_target"
234
- git -C "$worktree_target" init -q -b "$project_default" 2>/dev/null \
235
- || { git -C "$worktree_target" init -q \
236
- && git -C "$worktree_target" symbolic-ref HEAD "refs/heads/$project_default"; }
237
- git -C "$worktree_target" remote add origin "$repo_url" 2>/dev/null || true
839
+ git_host_control -C "$worktree_target" init -q -b "$project_default" 2>/dev/null \
840
+ || { git_host_control -C "$worktree_target" init -q \
841
+ && git_host_control -C "$worktree_target" symbolic-ref HEAD "refs/heads/$project_default"; }
842
+ git_host_control -C "$worktree_target" remote add origin "$repo_url" 2>/dev/null || true
843
+ sanitize_task_repo_control_plane "$worktree_target/.git" "$repo_url" \
844
+ "$project_default" false
238
845
  printf '%s\n' \
239
846
  "# Empty repository" \
240
847
  "" \
@@ -253,12 +860,72 @@ while IFS= read -r project_obj; do
253
860
  # Tool-version seeding: only when the worktree has no checked-in
254
861
  # .tool-versions AND the project declares one. Never overwrite a file
255
862
  # the repo already ships.
863
+ if [ -L "$worktree_target/.tool-versions" ]; then
864
+ emit_err "WORKTREE_FAILED" \
865
+ "Uai refused a symbolic-link .tool-versions for $project_slug" \
866
+ "validate task worktree ($project_id)"
867
+ fi
256
868
  if [ ! -e "$worktree_target/.tool-versions" ] \
257
869
  && [ -n "$tool_versions" ] && [ "$tool_versions" != "null" ]; then
258
870
  printf '%s\n' "$tool_versions" > "$worktree_target/.tool-versions"
259
871
  fi
872
+ if [ -d "$worktree_target/.git" ] && [ ! -L "$worktree_target/.git" ]; then
873
+ share_task_tree_with_container "$worktree_target/.git" \
874
+ "standalone task Git repository"
875
+ fi
876
+
877
+ release_project_lock
260
878
  done < <(jq -c '.[]' <<<"$projects_json")
261
879
 
880
+ # Git and the checked-out files were created by the host. Reconcile their
881
+ # group/modes only after every host Git operation and before Compose exposes
882
+ # them to uid 1000. The host-only marker avoids recursively walking a large
883
+ # workspace (for example node_modules) on every resume, while its gid payload
884
+ # forces a fresh pass if the task moves to a differently-grouped host. This
885
+ # first pass self-heals 0.9.2 task repos whose mktemp config was mode 0600 on a
886
+ # uid-501 macOS host. The sanitizer above repairs its rewritten control files
887
+ # on every later resume.
888
+ step "WORKTREE_FAILED" "share task files with container"
889
+ mkdir -p "$task_uai_dir"
890
+ # The shared group may be a broad host group (for example macOS `staff`). Keep
891
+ # its writable leaves behind host-only parents; Docker bind-mounts the exact
892
+ # workspace/repository roots, so node does not need host-path traversal here.
893
+ chmod 700 "$task_dir" "$task_uai_dir"
894
+ filesystem_group_marker="$task_uai_dir/container-filesystem-group-v1"
895
+ marked_container_gid=""
896
+ if [ -L "$filesystem_group_marker" ]; then
897
+ emit_err "WORKTREE_FAILED" \
898
+ "Uai refused a symbolic-link task filesystem marker" \
899
+ "share task files with container"
900
+ elif [ -f "$filesystem_group_marker" ]; then
901
+ marked_container_gid=$(cat "$filesystem_group_marker" 2>/dev/null || true)
902
+ elif [ -e "$filesystem_group_marker" ]; then
903
+ emit_err "WORKTREE_FAILED" \
904
+ "Uai refused a non-file task filesystem marker" \
905
+ "share task files with container"
906
+ fi
907
+ if [ "$marked_container_gid" != "$task_container_gid" ]; then
908
+ share_task_tree_with_container "$task_workspace" "task workspace"
909
+ filesystem_group_marker_tmp=$(mktemp \
910
+ "$task_uai_dir/.container-filesystem-group.XXXXXX")
911
+ printf '%s\n' "$task_container_gid" > "$filesystem_group_marker_tmp"
912
+ chmod 600 "$filesystem_group_marker_tmp"
913
+ mv -f "$filesystem_group_marker_tmp" "$filesystem_group_marker"
914
+ else
915
+ # A container may have changed a mount root's mode. These two O(1) repairs
916
+ # keep entry into the already-normalized trees reliable without walking them.
917
+ share_task_entry_with_container "$task_workspace" "task workspace root"
918
+ fi
919
+ # Task repos are repacked, so this tree stays small; normalize it every time.
920
+ # Unlike the workspace pass, this covers Git metadata created during the host
921
+ # refresh and any loose objects/locks written by the prior container.
922
+ share_task_tree_with_container "$task_uai_dir/repos" \
923
+ "task-private Git repositories"
924
+
925
+ if [ -z "$github_credential_socket" ] && [ "$uai_used_ssh_transport" = "1" ]; then
926
+ uai_git_transport="ssh"
927
+ fi
928
+
262
929
  # -----------------------------------------------------------------------------
263
930
  # 4. Generate .uai/docker-compose.yml (bash). Union preview ports + env keys
264
931
  # across the selected projects; one app service. Derived Dockerfile only
@@ -338,20 +1005,24 @@ fi
338
1005
  printf ' pull_policy: never\n'
339
1006
  fi
340
1007
  printf ' init: true\n'
1008
+ # Give node the one host group that owns this task's bind-mounted files.
1009
+ # Numeric group_add works even when the group has a different name (or no
1010
+ # name) in the image. Root hosts use node's existing gid 1000 above.
1011
+ printf ' group_add:\n'
1012
+ printf ' - "%s"\n' "$task_container_gid"
341
1013
  printf ' working_dir: /workspace\n'
342
1014
  printf ' volumes:\n'
343
1015
  printf ' - "%s:/workspace"\n' "$task_workspace"
344
- # Identical-host-path binds so each git worktree's absolute gitdir pointer
345
- # (-> projects/<id>/repo.git/worktrees/<slug>) resolves INSIDE the container
346
- # (ADR-014). Without them in-container git sees a dangling .git and every
347
- # commit fails with "not a git repository". The mirror is bound rw because
348
- # commits write objects back into the shared object store.
1016
+ # Identical-host-path binds keep each linked worktree's absolute gitdir
1017
+ # pointer valid in-container (ADR-014). Only the self-contained task-private
1018
+ # repository is mounted; the host cache never enters a container. Legacy
1019
+ # worktrees fail closed before Compose rendering and must be recreated.
349
1020
  printf ' - "%s:%s"\n' "$task_workspace" "$task_workspace"
350
1021
  while IFS= read -r vol_obj; do
351
1022
  [ -n "$vol_obj" ] || continue
352
1023
  vol_pid=$(jq -r '.id' <<<"$vol_obj")
353
- printf ' - "%s/%s/repo.git:%s/%s/repo.git"\n' \
354
- "$projects_root" "$vol_pid" "$projects_root" "$vol_pid"
1024
+ printf ' - "%s/repos/%s.git:%s/repos/%s.git"\n' \
1025
+ "$task_uai_dir" "$vol_pid" "$task_uai_dir" "$vol_pid"
355
1026
  done < <(jq -c '.[]' <<<"$projects_json")
356
1027
  printf ' - "%s:/opt/asdf-data"\n' "$ASDF_VOLUME"
357
1028
  # ADR-053: host-wide Playwright browser cache. Always mounted (harmless
@@ -378,6 +1049,13 @@ fi
378
1049
  # sidecar attached to the compose network, so nothing is exposed by default.
379
1050
  printf ' - "127.0.0.1::8080"\n'
380
1051
  printf ' environment:\n'
1052
+ # Non-secret transport marker for uai-init. The actual GitHub token is
1053
+ # injected later into gh's private config and never enters Compose.
1054
+ printf ' UAI_GIT_TRANSPORT: "%s"\n' "$uai_git_transport"
1055
+ # Uai owns GitHub auth inside the task container. Keep gh's config outside
1056
+ # the bind-mounted workspace and independent of project-defined XDG/HOME
1057
+ # values so its credential helper always resolves the connected user.
1058
+ printf ' GH_CONFIG_DIR: "/home/node/.config/gh"\n'
381
1059
  # Host-resident Claude auth (ADR-021). Headless `claude --print` no longer
382
1060
  # uses the interactive subscription/keychain path, so it needs a token from
383
1061
  # `claude setup-token`. Interpolated from the host-agent env at up-time, so
@@ -398,22 +1076,27 @@ fi
398
1076
  # an env var over its stored credentials, which blocks the per-user
399
1077
  # `gh auth login --with-token` the host runs (and would re-attribute every PR
400
1078
  # to the env token — breaking multi-user). The host's GitHub token reaches
401
- # `gh` through its config file instead (lib/github-tokens.ts); git rides the
402
- # per-user SSH identity, not an HTTPS token (uai-init routes remotes over SSH).
403
- # A project may declare GH_TOKEN/GITHUB_TOKEN as an env key, so skip them here
404
- # rather than trust callers this is where the rule is enforced.
1079
+ # `gh` through its config file instead (lib/github-tokens.ts), and gh serves
1080
+ # as Git's HTTPS credential helper. SSH remains a disconnected-user fallback.
1081
+ # Projects may declare managed names, so skip them here rather than trust
1082
+ # callers. They are also removed from the in-memory Compose override below.
405
1083
  #
406
- # The decrypted per-(project, key) env VALUES live in this script's process
407
- # env (injected by lib/agent.ts from the host store). The `${KEY:-}`
408
- # pass-throughs below resolve to them via docker's interpolation at up-time
409
- # the value is never written into the YAML (mirrors CLAUDE_CODE_OAUTH_TOKEN),
410
- # so this stays secret-blind end to end (ADR-015).
1084
+ # Project values never enter the host environment. Emit an explicit empty
1085
+ # default for every declared key; the stdin-only Compose override supplies
1086
+ # decrypted values at `compose up` time.
411
1087
  while IFS= read -r ekey; do
412
1088
  [ -n "$ekey" ] || continue
413
1089
  case "$ekey" in
414
- GH_TOKEN | GITHUB_TOKEN) continue ;;
1090
+ GH_TOKEN | GITHUB_TOKEN | GH_CONFIG_DIR | GH_HOST | \
1091
+ GIT_CONFIG* | GIT_DIR | GIT_WORK_TREE | GIT_COMMON_DIR | \
1092
+ GIT_OBJECT_DIRECTORY | GIT_ALTERNATE_OBJECT_DIRECTORIES | \
1093
+ GIT_INDEX_FILE | GIT_EXEC_PATH | GIT_ASKPASS | SSH_ASKPASS | \
1094
+ GIT_SSH | GIT_SSH_COMMAND | HOME | XDG_CONFIG_HOME | \
1095
+ BASH_ENV | ENV | UAI_GIT_TRANSPORT | \
1096
+ CLAUDE_CODE_OAUTH_TOKEN | ANTHROPIC_API_KEY | \
1097
+ ANTHROPIC_AUTH_TOKEN | XAI_API_KEY | PLAYWRIGHT_BROWSERS_PATH) continue ;;
415
1098
  esac
416
- printf ' %s: "${%s:-}"\n' "$ekey" "$ekey"
1099
+ printf ' %s: ""\n' "$ekey"
417
1100
  done < <(jq -r '.[]' <<<"$union_env_keys_json")
418
1101
  # Preview-URL env vars (ADR-025): cloud-computed PUBLIC preview URLs exposed
419
1102
  # under operator-chosen names (e.g. EXPO_PACKAGER_PROXY_URL). Non-secret, so
@@ -468,10 +1151,53 @@ if ! docker image inspect "$STANDARD_IMAGE" >/dev/null 2>&1; then
468
1151
  fi
469
1152
 
470
1153
  step "COMPOSE_UP_FAILED" "docker compose up -d"
1154
+ # Limit the supplied values to keys the selected projects actually declare,
1155
+ # then remove Uai-managed names and preview URL names. JSON is valid YAML, so
1156
+ # Compose can merge this secret-bearing override directly from stdin without a
1157
+ # temporary env/YAML file and without exporting any value to host commands.
1158
+ preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
1159
+ compose_env_override=$(printf '%s' "$project_env_json" | jq -c \
1160
+ --argjson declared "$union_env_keys_json" \
1161
+ --argjson preview "$preview_env_raw" '
1162
+ .
1163
+ | with_entries(.key as $key | select($declared | index($key)))
1164
+ | with_entries(select(.key | test("^GIT_CONFIG($|_)") | not))
1165
+ | del(
1166
+ .GH_TOKEN,
1167
+ .GITHUB_TOKEN,
1168
+ .GH_CONFIG_DIR,
1169
+ .GH_HOST,
1170
+ .GIT_DIR,
1171
+ .GIT_WORK_TREE,
1172
+ .GIT_COMMON_DIR,
1173
+ .GIT_OBJECT_DIRECTORY,
1174
+ .GIT_ALTERNATE_OBJECT_DIRECTORIES,
1175
+ .GIT_INDEX_FILE,
1176
+ .GIT_EXEC_PATH,
1177
+ .GIT_ASKPASS,
1178
+ .SSH_ASKPASS,
1179
+ .GIT_SSH,
1180
+ .GIT_SSH_COMMAND,
1181
+ .HOME,
1182
+ .XDG_CONFIG_HOME,
1183
+ .BASH_ENV,
1184
+ .ENV,
1185
+ .UAI_GIT_TRANSPORT,
1186
+ .CLAUDE_CODE_OAUTH_TOKEN,
1187
+ .ANTHROPIC_API_KEY,
1188
+ .ANTHROPIC_AUTH_TOKEN,
1189
+ .XAI_API_KEY,
1190
+ .PLAYWRIGHT_BROWSERS_PATH
1191
+ )
1192
+ | delpaths($preview | keys | map([.]))
1193
+ | {services:{app:{environment:.}}}
1194
+ ')
471
1195
  if [ "$has_derived" = "1" ]; then
472
- docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" up -d --build >/dev/null
1196
+ printf '%s\n' "$compose_env_override" | docker compose -p "$compose_project" \
1197
+ -f "$task_uai_dir/docker-compose.yml" -f - up -d --build >/dev/null
473
1198
  else
474
- docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" up -d >/dev/null
1199
+ printf '%s\n' "$compose_env_override" | docker compose -p "$compose_project" \
1200
+ -f "$task_uai_dir/docker-compose.yml" -f - up -d >/dev/null
475
1201
  fi
476
1202
 
477
1203
  # -----------------------------------------------------------------------------
@@ -480,6 +1206,123 @@ fi
480
1206
 
481
1207
  step "CONTAINER_INIT_FAILED" "uai-init (deps + code-server)"
482
1208
 
1209
+ # `docker exec` inherits the project environment from Compose. Override loader
1210
+ # and config variables before the first executable starts, then use `env -i`
1211
+ # plus absolute command paths so a project value cannot redirect this managed
1212
+ # credential operation to workspace code or another config directory.
1213
+ managed_gh_config_dir="/home/node/.config/gh"
1214
+ managed_exec_env=(
1215
+ -e "HOME=/home/node"
1216
+ -e "GH_CONFIG_DIR=$managed_gh_config_dir"
1217
+ -e "PATH=/usr/bin:/bin"
1218
+ -e "XDG_CONFIG_HOME="
1219
+ -e "LD_PRELOAD="
1220
+ -e "LD_LIBRARY_PATH="
1221
+ -e "DYLD_INSERT_LIBRARIES="
1222
+ -e "DYLD_LIBRARY_PATH="
1223
+ -e "BASH_ENV="
1224
+ -e "ENV="
1225
+ -e "GIT_CONFIG="
1226
+ -e "GIT_CONFIG_GLOBAL="
1227
+ -e "GIT_CONFIG_SYSTEM="
1228
+ -e "GIT_CONFIG_NOSYSTEM="
1229
+ -e "GIT_CONFIG_COUNT=0"
1230
+ -e "GIT_EXEC_PATH="
1231
+ -e "GIT_SSH="
1232
+ -e "GIT_SSH_COMMAND="
1233
+ -e "GIT_ASKPASS="
1234
+ -e "SSH_ASKPASS="
1235
+ -e "GH_TOKEN="
1236
+ -e "GITHUB_TOKEN="
1237
+ )
1238
+ managed_clean_env=(
1239
+ /usr/bin/env -i
1240
+ "HOME=/home/node"
1241
+ "GH_CONFIG_DIR=$managed_gh_config_dir"
1242
+ "PATH=/usr/bin:/bin"
1243
+ )
1244
+
1245
+ # Git rejects a repository owned by another uid even when its shared group is
1246
+ # writable. Trust only the selected worktrees in this task container—never `*`
1247
+ # and never the host cache. The task-private ~/.gitconfig makes these entries
1248
+ # local to this container, and the read-before-add keeps resumes idempotent.
1249
+ step "CONTAINER_INIT_FAILED" "trust task Git worktrees"
1250
+ container_safe_dirs=$(docker exec -u node "${managed_exec_env[@]}" \
1251
+ "$app_container" "${managed_clean_env[@]}" /usr/bin/git config --global \
1252
+ --get-all safe.directory 2>/dev/null || true)
1253
+ while IFS= read -r safe_project; do
1254
+ [ -n "$safe_project" ] || continue
1255
+ safe_project_id=$(jq -r '.id' <<<"$safe_project")
1256
+ safe_project_slug=$(jq -r '.slug' <<<"$safe_project")
1257
+ for safe_worktree in \
1258
+ "/workspace/$safe_project_slug" \
1259
+ "$task_workspace/$safe_project_slug" \
1260
+ "$task_uai_dir/repos/$safe_project_id.git"; do
1261
+ if ! printf '%s\n' "$container_safe_dirs" \
1262
+ | grep -Fqx -- "$safe_worktree"; then
1263
+ if ! docker exec -u node "${managed_exec_env[@]}" "$app_container" \
1264
+ "${managed_clean_env[@]}" /usr/bin/git config --global --add \
1265
+ safe.directory "$safe_worktree" >/dev/null; then
1266
+ emit_err "CONTAINER_INIT_FAILED" \
1267
+ "Uai could not mark $safe_worktree as this task's trusted Git path" \
1268
+ "trust task Git worktrees"
1269
+ fi
1270
+ container_safe_dirs="${container_safe_dirs}"$'\n'"${safe_worktree}"
1271
+ fi
1272
+ done
1273
+ done < <(jq -c '.[]' <<<"$projects_json")
1274
+
1275
+ # A failed/retried start can preserve a container that still holds a previous
1276
+ # account. Erase that Uai-managed state first for both connected and
1277
+ # disconnected starts; otherwise a no-credential resume could briefly reuse a
1278
+ # stale token before the asynchronous reconciler catches up.
1279
+ if ! docker exec -u node "${managed_exec_env[@]}" "$app_container" \
1280
+ "${managed_clean_env[@]}" /bin/sh -c '
1281
+ set -eu
1282
+ /bin/rm -f "$GH_CONFIG_DIR/hosts.yml"
1283
+ for key in \
1284
+ credential.https://github.com.helper \
1285
+ credential.https://gist.github.com.helper
1286
+ do
1287
+ /usr/bin/git config --file "$HOME/.gitconfig" --unset-all "$key" \
1288
+ >/dev/null 2>&1 || true
1289
+ if /usr/bin/git config --file "$HOME/.gitconfig" --get-all "$key" \
1290
+ >/dev/null 2>&1; then
1291
+ exit 1
1292
+ fi
1293
+ done
1294
+ test ! -e "$GH_CONFIG_DIR/hosts.yml"
1295
+ ' >/dev/null; then
1296
+ emit_err "GITHUB_AUTH_UNAVAILABLE" \
1297
+ "Uai could not clear the previous managed GitHub credential from the task container. Retry the task before doing GitHub work." \
1298
+ "clear container GitHub credential"
1299
+ fi
1300
+
1301
+ # The task container must have the same connected GitHub principal BEFORE
1302
+ # uai-init runs package managers: private Git submodules/dependencies may be
1303
+ # fetched during install. Read the token back from the private cache and stream
1304
+ # only its password field directly into `gh auth login`; it never enters argv,
1305
+ # environment, a shell variable, Compose, or disk.
1306
+ if [ -n "$github_credential_socket" ]; then
1307
+ if ! printf 'protocol=https\nhost=github.com\n\n' \
1308
+ | git_with_github_credential credential fill \
1309
+ | sed -n 's/^password=//p' \
1310
+ | docker exec -i -u node "${managed_exec_env[@]}" "$app_container" \
1311
+ "${managed_clean_env[@]}" /usr/bin/gh auth login --with-token \
1312
+ --hostname github.com --git-protocol https >/dev/null; then
1313
+ emit_err "GITHUB_AUTH_UNAVAILABLE" \
1314
+ "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." \
1315
+ "configure container GitHub credential"
1316
+ fi
1317
+ if ! docker exec -u node "${managed_exec_env[@]}" "$app_container" \
1318
+ "${managed_clean_env[@]}" /usr/bin/gh auth setup-git \
1319
+ --hostname github.com >/dev/null; then
1320
+ emit_err "GITHUB_AUTH_UNAVAILABLE" \
1321
+ "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." \
1322
+ "configure container Git credential helper"
1323
+ fi
1324
+ fi
1325
+
483
1326
  # Copy `.claude.json` into the container instead of bind-mounting it.
484
1327
  # Single-file bind mounts on Docker Desktop macOS are fragile — claude
485
1328
  # CLI atomically rewrites the file on every start, which can invalidate
@@ -571,11 +1414,10 @@ done
571
1414
  docker exec -u root "$app_container" \
572
1415
  chown -R node:node /home/node/.local/share/opencode >/dev/null 2>&1 || true
573
1416
 
574
- # Copy the same resolved SSH identity (task creator's per-user key when present,
575
- # else the operator identity see above) into the container, so the agent signs
576
- # + pushes with the key whose .pub the user registered on GitHub. ADR-027 drops
577
- # the HTTPS/GH_TOKEN path; uai-init routes every GitHub remote over SSH.
578
- # Best-effort: signing + SSH push just stay off if the identity is absent —
1417
+ # Copy the resolved SSH identity into the container for commit/tag signing and
1418
+ # as the no-GitHub-connection transport fallback. Connected users clone, fetch,
1419
+ # and push over HTTPS through their gh credential instead.
1420
+ # Best-effort: signing + the SSH fallback stay off if the identity is absent —
579
1421
  # but every failure is LOGGED (a silent miss here reads as a GitHub
580
1422
  # permissions problem hours later; live 2026-07-21). The channel-ensure path
581
1423
  # (ensureTaskSshIdentity) re-asserts all of this on reconnects.
@@ -585,7 +1427,7 @@ if [ -f "$uai_identity_key" ]; then
585
1427
  || log "warning: ssh setup: mkdir ~/.ssh failed in $app_container"
586
1428
  docker cp "$uai_identity_key" \
587
1429
  "$app_container":/home/node/.ssh/id_ed25519 >/dev/null 2>&1 \
588
- || log "warning: ssh setup: key copy failed — pushes will fall back to HTTPS"
1430
+ || log "warning: ssh setup: key copy failed — signing and SSH fallback unavailable"
589
1431
  docker cp "${uai_identity_key}.pub" \
590
1432
  "$app_container":/home/node/.ssh/id_ed25519.pub >/dev/null 2>&1 \
591
1433
  || log "warning: ssh setup: pubkey copy failed (signing will be off)"