@runuai/host 0.9.0 → 0.9.2

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 \
@@ -88,38 +109,238 @@ db_update_task "$task_id" \
88
109
  "worktree_path='$(sql_escape "$task_dir")'" \
89
110
  "compose_project='$(sql_escape "$compose_project")'"
90
111
 
112
+ # `.uai` is not container-mounted, but validate these host-owned parents before
113
+ # using the previous compose file. The workspace itself is checked after the
114
+ # old container is removed so it cannot race the check.
115
+ reject_unsafe_host_directory "$task_dir" "the task storage root"
116
+ reject_unsafe_host_directory "$task_uai_dir" "the task runtime directory"
117
+
118
+ # A previous task container can write both its workspace and its task-private
119
+ # Git repository. Remove it before the host parses either repository on resume.
120
+ # This also cuts off legacy containers that still have the old shared
121
+ # `repo.git` bind mount before we inspect their worktree layout below.
122
+ step "COMPOSE_DOWN_FAILED" "remove prior task containers"
123
+ if [ -f "$task_uai_dir/docker-compose.yml" ]; then
124
+ docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" \
125
+ down --remove-orphans >/dev/null 2>&1 || true
126
+ else
127
+ docker compose -p "$compose_project" down --remove-orphans \
128
+ >/dev/null 2>&1 || true
129
+ fi
130
+ prior_containers=""
131
+ if ! prior_containers=$(docker ps -aq \
132
+ --filter "label=com.docker.compose.project=$compose_project"); then
133
+ emit_err "COMPOSE_DOWN_FAILED" \
134
+ "could not verify removal of the previous task container" \
135
+ "remove prior task containers"
136
+ fi
137
+ if [ -n "$prior_containers" ]; then
138
+ emit_err "COMPOSE_DOWN_FAILED" \
139
+ "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" \
140
+ "remove prior task containers"
141
+ fi
142
+ reject_unsafe_host_directory "$task_workspace" "the task workspace"
143
+ mkdir -p "$task_workspace"
144
+
91
145
  # -----------------------------------------------------------------------------
92
146
  # 3. Bare mirrors + worktrees, one per selected project.
93
147
  # -----------------------------------------------------------------------------
94
148
 
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`).
149
+ # Prepare the optional SSH identity. Connected users fetch over HTTPS with
150
+ # their per-host GitHub credential; SSH is used for Git transport only when the
151
+ # task owner has not connected GitHub. `IdentitiesOnly=yes` +
152
+ # `IdentityAgent=none` keep that fallback from silently selecting a different
153
+ # personal key or blocking on an agent approval prompt.
101
154
  #
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).
155
+ # ADR-029: use only the TASK CREATOR's per-user key (written to
156
+ # $UAI_TASK_IDENTITY_DIR by the orchestrator) so clone + push run as them. A
157
+ # task must never borrow the operator's shared identity: that would authorize
158
+ # one user as a different principal.
106
159
  if [ -n "${UAI_TASK_IDENTITY_DIR:-}" ] && [ -f "${UAI_TASK_IDENTITY_DIR}/id_ed25519" ]; then
107
160
  uai_identity_key="$UAI_TASK_IDENTITY_DIR/id_ed25519"
108
161
  log "git over SSH using the task creator's per-user key"
109
162
  else
110
- uai_identity_key="$UAI_DATA_DIR/identity/id_ed25519"
163
+ uai_identity_key=""
111
164
  fi
112
- if [ -f "$uai_identity_key" ]; then
165
+ uai_has_ssh_identity=0
166
+ if [ -n "$uai_identity_key" ] && [ -f "$uai_identity_key" ]; then
167
+ uai_has_ssh_identity=1
113
168
  uai_known_hosts="$UAI_DATA_DIR/identity/known_hosts"
114
169
  GIT_SSH_COMMAND="ssh -i $uai_identity_key -o IdentitiesOnly=yes -o IdentityAgent=none -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
115
170
  [ -f "$uai_known_hosts" ] && GIT_SSH_COMMAND="$GIT_SSH_COMMAND -o UserKnownHostsFile=$uai_known_hosts"
116
171
  export GIT_SSH_COMMAND
117
172
  log "git over SSH using $uai_identity_key (1Password agent bypassed)"
118
173
  else
119
- log "no uai identity key at $uai_identity_keygit uses host default SSH (run: pnpm setup-identity)"
174
+ log "no SSH identity for the task owner — SSH transport fallback unavailable"
120
175
  fi
121
176
 
122
- mkdir -p "$task_workspace"
177
+ # Only the non-secret path reaches this script. The task owner's token lives in
178
+ # a private per-task `git credential-cache` daemon and is never placed in the
179
+ # environment, argv, remote URL, mirror config, Compose, or logs.
180
+ github_credential_socket="${UAI_GITHUB_CREDENTIAL_SOCKET:-}"
181
+ unset UAI_GITHUB_CREDENTIAL_SOCKET
182
+ if [ -n "$github_credential_socket" ]; then
183
+ uai_git_transport="https"
184
+ else
185
+ # Refined after each GitHub fetch below: select SSH only when it was the
186
+ # mode that actually worked. A present-but-unregistered key must not turn a
187
+ # successfully anonymous public clone into broken SSH inside the container.
188
+ uai_git_transport="anonymous"
189
+ fi
190
+ uai_used_ssh_transport=0
191
+
192
+ git_with_github_credential() (
193
+ unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
194
+ GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
195
+ GIT_TRACE_PACKET GIT_TRACE_REDACT
196
+ export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
197
+ # If a container ever poisoned a shared mirror's URL config, fail closed
198
+ # instead of silently switching this operation to an SSH principal.
199
+ GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 git \
200
+ -c core.hooksPath=/dev/null \
201
+ -c core.fsmonitor=false \
202
+ -c http.extraHeader= \
203
+ -c http.sslVerify=true \
204
+ -c credential.helper= \
205
+ -c "credential.helper=cache --socket=$github_credential_socket" \
206
+ "$@"
207
+ )
208
+
209
+ # Command-scoped rewrite: the shared origin remains canonical HTTPS while this
210
+ # one task uses its SSH fallback identity.
211
+ git_with_github_ssh() (
212
+ unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
213
+ GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
214
+ GIT_TRACE_PACKET GIT_TRACE_REDACT
215
+ export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
216
+ GIT_TERMINAL_PROMPT=0 git \
217
+ -c core.hooksPath=/dev/null \
218
+ -c core.fsmonitor=false \
219
+ -c 'url.git@github.com:.insteadOf=https://github.com/' \
220
+ "$@"
221
+ )
222
+
223
+ # Clear every ambient credential helper so a public fallback cannot borrow the
224
+ # host operator's GitHub identity.
225
+ git_anonymous_https() (
226
+ unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
227
+ GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
228
+ GIT_TRACE_PACKET GIT_TRACE_REDACT
229
+ export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
230
+ GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 git \
231
+ -c core.hooksPath=/dev/null \
232
+ -c core.fsmonitor=false \
233
+ -c http.extraHeader= \
234
+ -c http.sslVerify=true \
235
+ -c credential.helper= \
236
+ "$@"
237
+ )
238
+
239
+ # Host-only Git control plane. New task containers see only their self-contained
240
+ # private repo; the shared mirror is a setup-time fetch cache and is never
241
+ # mounted. Before every host operation we still rebuild its config from trusted
242
+ # project data and discard hooks, both to heal legacy tasks and to fail closed
243
+ # after a crash.
244
+ git_host_control() (
245
+ unset GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_CONFIG \
246
+ GIT_CONFIG_PARAMETERS GIT_TRACE GIT_TRACE2 GIT_TRACE_CURL \
247
+ GIT_TRACE_PACKET GIT_TRACE_REDACT GIT_CONFIG_COUNT \
248
+ GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1
249
+ export LC_ALL=C GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
250
+ # This helper is for local repository bookkeeping and public/local remotes.
251
+ # It must never inherit the operator's SSH agent or credential helper. Every
252
+ # authenticated GitHub fetch goes through one of the explicit helpers above.
253
+ GIT_CONFIG_COUNT=2 \
254
+ GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null \
255
+ GIT_CONFIG_KEY_1=core.fsmonitor GIT_CONFIG_VALUE_1=false \
256
+ GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 git \
257
+ -c credential.helper= \
258
+ "$@"
259
+ )
260
+
261
+ assert_bare_repo_tree_safe() {
262
+ local repo="$1" code="$2" label="$3" bad_entry=""
263
+ if [ -L "$repo" ] || [ ! -d "$repo" ]; then
264
+ emit_err "$code" \
265
+ "Uai refused the $label because its repository root is missing or is a symbolic link: $repo" \
266
+ "validate $label"
267
+ fi
268
+ # Git should only ever create regular files and directories in these control
269
+ # trees. Reject links, FIFOs, sockets, and devices before any Git process can
270
+ # follow or block on a container-created entry.
271
+ if ! bad_entry=$(find "$repo" ! -type f ! -type d -print -quit 2>/dev/null); then
272
+ emit_err "$code" \
273
+ "Uai could not validate the $label before host Git access" \
274
+ "validate $label"
275
+ fi
276
+ if [ -n "$bad_entry" ]; then
277
+ emit_err "$code" \
278
+ "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." \
279
+ "validate $label"
280
+ fi
281
+ }
282
+
283
+ sanitize_mirror_control_plane() {
284
+ local mirror="$1" origin="$2" config_tmp
285
+ assert_bare_repo_tree_safe "$mirror" "FETCH_FAILED" "host repository cache"
286
+ # Stage outside the bare repo so a predictable in-repo temporary path cannot
287
+ # be redirected even if a previous host operation crashed midway.
288
+ config_tmp=$(mktemp "$(dirname "$mirror")/.uai-config.XXXXXX")
289
+ git_host_control config --file "$config_tmp" core.repositoryformatversion 0
290
+ git_host_control config --file "$config_tmp" core.filemode true
291
+ git_host_control config --file "$config_tmp" core.bare true
292
+ git_host_control config --file "$config_tmp" remote.origin.url "$origin"
293
+ git_host_control config --file "$config_tmp" remote.origin.fetch \
294
+ '+refs/heads/*:refs/remotes/origin/*'
295
+ git_host_control config --file "$config_tmp" remote.pushDefault origin
296
+ git_host_control config --file "$config_tmp" push.autoSetupRemote true
297
+ mv -f "$config_tmp" "$mirror/config"
298
+ rm -rf "$mirror/hooks"
299
+ mkdir -p "$mirror/hooks"
300
+ }
301
+
302
+ sanitize_task_repo_control_plane() {
303
+ local repo="$1" origin="$2" branch="$3" bare="${4:-true}" config_tmp
304
+ local branch_remote="origin" branch_merge="refs/heads/$branch" candidate
305
+ assert_bare_repo_tree_safe "$repo" "WORKTREE_FAILED" \
306
+ "task-private repository"
307
+
308
+ # Preserve only the current branch's safe upstream from the container-
309
+ # writable config. Includes are disabled, the remote must remain `origin`,
310
+ # and the merge target must be a valid heads ref. Everything executable or
311
+ # host-sensitive (hooks, includes, helpers, fsmonitor) is rebuilt below.
312
+ if [ -f "$repo/config" ]; then
313
+ candidate=$(git_host_control config --no-includes --file "$repo/config" \
314
+ --get "branch.$branch.remote" 2>/dev/null || true)
315
+ [ "$candidate" = "origin" ] && branch_remote="$candidate"
316
+ candidate=$(git_host_control config --no-includes --file "$repo/config" \
317
+ --get "branch.$branch.merge" 2>/dev/null || true)
318
+ case "$candidate" in
319
+ refs/heads/*)
320
+ if git_host_control check-ref-format "$candidate" >/dev/null 2>&1; then
321
+ branch_merge="$candidate"
322
+ fi
323
+ ;;
324
+ esac
325
+ fi
326
+
327
+ config_tmp=$(mktemp "$(dirname "$repo")/.uai-task-config.XXXXXX")
328
+ git_host_control config --file "$config_tmp" core.repositoryformatversion 0
329
+ git_host_control config --file "$config_tmp" core.filemode true
330
+ git_host_control config --file "$config_tmp" core.bare "$bare"
331
+ git_host_control config --file "$config_tmp" remote.origin.url "$origin"
332
+ git_host_control config --file "$config_tmp" remote.origin.fetch \
333
+ '+refs/heads/*:refs/remotes/origin/*'
334
+ git_host_control config --file "$config_tmp" remote.pushDefault origin
335
+ git_host_control config --file "$config_tmp" push.autoSetupRemote true
336
+ git_host_control config --file "$config_tmp" \
337
+ "branch.$branch.remote" "$branch_remote"
338
+ git_host_control config --file "$config_tmp" \
339
+ "branch.$branch.merge" "$branch_merge"
340
+ mv -f "$config_tmp" "$repo/config"
341
+ rm -rf "$repo/hooks"
342
+ mkdir -p "$repo/hooks"
343
+ }
123
344
 
124
345
  # ADR-062 shared files: two host-resident roots mounted into the container
125
346
  # when the task's mode isn't "off". Created here so Docker doesn't create
@@ -139,6 +360,8 @@ if [ "$shared_files_mode" != "off" ]; then
139
360
  [ -n "$shared_owner_user" ] && mkdir -p "$shared_root/users/$shared_owner_user"
140
361
  # Marker for the preamble: only containers that actually carry the mounts
141
362
  # get the "## Shared files" briefing (pre-feature containers don't).
363
+ reject_unsafe_host_directory "$task_workspace/.uai" \
364
+ "the task workspace marker directory"
142
365
  mkdir -p "$task_workspace/.uai"
143
366
  : > "$task_workspace/.uai/files-mounted"
144
367
  fi
@@ -150,11 +373,93 @@ while IFS= read -r project_obj; do
150
373
 
151
374
  project_id=$(jq -r '.id' <<<"$project_obj")
152
375
  project_slug=$(jq -r '.slug' <<<"$project_obj")
153
- repo_url=$(jq -r '.repo_url' <<<"$project_obj")
376
+ case "$project_slug" in
377
+ ''|.|..|*/*|*\\*)
378
+ emit_err "WORKTREE_FAILED" \
379
+ "Uai refused an invalid project workspace component: $project_slug" \
380
+ "validate task worktree ($project_id)"
381
+ ;;
382
+ esac
383
+ acquire_project_lock "$projects_root" "$project_id"
384
+ repo_url_input=$(jq -r '.repo_url' <<<"$project_obj")
385
+ repo_is_github=0
386
+ if is_github_hosted_url "$repo_url_input"; then
387
+ repo_is_github=1
388
+ fi
389
+ # The mirror is fetched HERE, before the task container exists. GitHub URLs
390
+ # stay canonical HTTPS: a connected user's private credential socket
391
+ # authenticates them; users without a connection get the SSH fallback. The
392
+ # token never becomes part of this shared mirror or its remote URL.
393
+ repo_url=$(normalize_github_repo_url "$repo_url_input")
394
+ github_https_url=$(github_https_repo_url "$repo_url")
395
+ if [ "$repo_is_github" = "1" ] && [ -z "$github_https_url" ]; then
396
+ emit_err "CLONE_FAILED" \
397
+ "Uai refused the malformed GitHub repository URL '$repo_url_input'. Use https://github.com/OWNER/REPOSITORY.git and retry." \
398
+ "validate GitHub repository URL ($project_id)"
399
+ fi
154
400
  tool_versions=$(jq -r '.tool_versions // ""' <<<"$project_obj")
155
401
 
156
- mirror_dir="$projects_root/$project_id/repo.git"
402
+ # `repo.git` was historically bind-mounted RW into every task container.
403
+ # Never reuse it as trusted host input: running legacy tasks can still hold
404
+ # that old bind mount. The new cache path has never entered a container.
405
+ mirror_dir="$projects_root/$project_id/host-cache.git"
406
+ task_repo_dir="$task_uai_dir/repos/$project_id.git"
157
407
  worktree_target="$task_workspace/$project_slug"
408
+ reject_unsafe_host_directory "$worktree_target" \
409
+ "the project worktree for $project_slug"
410
+ # Check before remote/cache Git: a dangling symlink makes `-e` false and the
411
+ # later seed write would otherwise follow it outside the workspace.
412
+ if [ -L "$worktree_target/.tool-versions" ]; then
413
+ emit_err "WORKTREE_FAILED" \
414
+ "Uai refused a symbolic-link .tool-versions for $project_slug" \
415
+ "validate task worktree ($project_id)"
416
+ fi
417
+ worktree_exists=0
418
+ standalone_worktree=0
419
+ if [ -e "$worktree_target/.git" ] || [ -L "$worktree_target/.git" ]; then
420
+ worktree_exists=1
421
+ if [ -L "$worktree_target/.git" ]; then
422
+ emit_err "WORKTREE_FAILED" \
423
+ "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." \
424
+ "validate task worktree ($project_id)"
425
+ elif [ -d "$worktree_target/.git" ]; then
426
+ # Empty remotes use a standalone repository inside the workspace.
427
+ standalone_worktree=1
428
+ assert_bare_repo_tree_safe "$worktree_target/.git" "WORKTREE_FAILED" \
429
+ "standalone task repository"
430
+ elif [ -f "$worktree_target/.git" ]; then
431
+ gitdir_lines=$(awk 'END { print NR }' "$worktree_target/.git")
432
+ gitdir_value=$(sed -n 's/^gitdir: //p' "$worktree_target/.git")
433
+ case "$gitdir_value" in
434
+ "$task_repo_dir"/worktrees/*) ;;
435
+ *)
436
+ emit_err "WORKTREE_FAILED" \
437
+ "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." \
438
+ "legacy task repository requires recreation ($project_id)"
439
+ ;;
440
+ esac
441
+ gitdir_leaf=${gitdir_value#"$task_repo_dir"/worktrees/}
442
+ case "$gitdir_leaf" in
443
+ ''|.|..|*/*)
444
+ emit_err "WORKTREE_FAILED" \
445
+ "Uai refused an invalid task Git metadata path for $project_slug" \
446
+ "validate task worktree ($project_id)"
447
+ ;;
448
+ esac
449
+ if [ "$gitdir_lines" != "1" ] || [ ! -d "$gitdir_value" ] \
450
+ || [ -L "$gitdir_value" ]; then
451
+ emit_err "WORKTREE_FAILED" \
452
+ "Uai refused invalid or missing task Git metadata for $project_slug. The previous container was removed; inspect or recreate the task before retrying." \
453
+ "validate task worktree ($project_id)"
454
+ fi
455
+ assert_bare_repo_tree_safe "$task_repo_dir" "WORKTREE_FAILED" \
456
+ "task-private repository"
457
+ else
458
+ emit_err "WORKTREE_FAILED" \
459
+ "Uai refused an unsupported .git entry for $project_slug" \
460
+ "validate task worktree ($project_id)"
461
+ fi
462
+ fi
158
463
 
159
464
  # Ensure the bare mirror exists with **remote-tracking refs**. Plain
160
465
  # `git clone --bare` copies upstream heads straight into refs/heads/*
@@ -166,37 +471,254 @@ while IFS= read -r project_obj; do
166
471
  if [ ! -d "$mirror_dir" ]; then
167
472
  step "CLONE_FAILED" "bare-clone ($project_id)"
168
473
  mkdir -p "$(dirname "$mirror_dir")"
169
- git init --bare "$mirror_dir" >/dev/null
170
- git -C "$mirror_dir" remote add origin "$repo_url"
474
+ git_host_control init --bare "$mirror_dir" >/dev/null
171
475
  needs_initial_fetch=1
172
476
  fi
173
477
 
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/*'
478
+ # Replace all container-writable/legacy config before host Git reads the
479
+ # mirror. This also heals SSH origins to the canonical credential-free URL.
480
+ sanitize_mirror_control_plane "$mirror_dir" "$repo_url"
481
+
482
+ github_fetch_mode=""
177
483
 
178
484
  if [ "$needs_initial_fetch" = "1" ]; then
179
485
  # First fetch on a brand-new mirror — required. Bail and clean up so
180
486
  # the next attempt starts fresh rather than re-using a half-init.
181
- if ! git -C "$mirror_dir" fetch origin; then
487
+ fetch_err=""
488
+ fetch_failed=0
489
+ if [ -n "$github_https_url" ] && [ -n "$github_credential_socket" ]; then
490
+ github_fetch_mode="token"
491
+ if ! fetch_err=$(git_with_github_credential \
492
+ -C "$mirror_dir" fetch origin 2>&1); then
493
+ fetch_failed=1
494
+ fi
495
+ elif [ -n "$github_https_url" ]; then
496
+ if [ "$uai_has_ssh_identity" = "1" ]; then
497
+ github_fetch_mode="ssh"
498
+ if ! fetch_err=$(git_with_github_ssh \
499
+ -C "$mirror_dir" fetch origin 2>&1); then
500
+ fetch_failed=1
501
+ # github.com requires an authenticated SSH handshake even for public
502
+ # repositories. Preserve the no-credential public-repo path.
503
+ if git_anonymous_https -C "$mirror_dir" fetch origin \
504
+ >/dev/null 2>&1; then
505
+ github_fetch_mode="anonymous"
506
+ fetch_failed=0
507
+ fetch_err=""
508
+ fi
509
+ fi
510
+ else
511
+ github_fetch_mode="none"
512
+ if ! fetch_err=$(git_anonymous_https \
513
+ -C "$mirror_dir" fetch origin 2>&1); then
514
+ fetch_failed=1
515
+ else
516
+ github_fetch_mode="anonymous"
517
+ fi
518
+ fi
519
+ elif ! fetch_err=$(git_host_control -C "$mirror_dir" fetch origin 2>&1); then
520
+ fetch_failed=1
521
+ fi
522
+
523
+ if [ "$fetch_failed" = "1" ]; then
524
+ [ -n "$fetch_err" ] || fetch_err="git fetch failed without details"
182
525
  rm -rf "$mirror_dir"
183
- emit_err "CLONE_FAILED" "initial fetch from $repo_url failed" "bare-clone ($project_id)"
526
+ # GitHub deliberately uses the same 404 for a missing private repository
527
+ # and a credential that cannot see it. Name the credential path Uai
528
+ # actually chose so the recovery action is truthful.
529
+ case "$fetch_err" in
530
+ *"Repository not found"*|*"repository not found"*|\
531
+ *"Permission denied"*|*"access rights"*|*"could not read Username"*|\
532
+ *"Authentication failed"*|*"Invalid username or password"*|*"HTTP 401"*|\
533
+ *"HTTP 403"*|*"HTTP 404"*|*"requested URL returned error: 401"*|\
534
+ *"requested URL returned error: 403"*|*"requested URL returned error: 404"*)
535
+ if [ "$github_fetch_mode" = "token" ]; then
536
+ emit_err "GITHUB_TOKEN_ACCESS_DENIED" \
537
+ "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." \
538
+ "bare-clone ($project_id)"
539
+ elif [ "$github_fetch_mode" = "ssh" ]; then
540
+ emit_err "GITHUB_SSH_ACCESS_DENIED" \
541
+ "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)." \
542
+ "bare-clone ($project_id)"
543
+ elif [ "$github_fetch_mode" = "none" ]; then
544
+ emit_err "GITHUB_CONNECTION_REQUIRED" \
545
+ "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." \
546
+ "bare-clone ($project_id)"
547
+ else
548
+ case "$repo_url" in
549
+ git@*|ssh://*)
550
+ emit_err "CLONE_FAILED" \
551
+ "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." \
552
+ "bare-clone ($project_id)"
553
+ ;;
554
+ *)
555
+ emit_err "CLONE_FAILED" \
556
+ "Access to $repo_url was denied. Configure the matching credential on this host, then retry the task." \
557
+ "bare-clone ($project_id)"
558
+ ;;
559
+ esac
560
+ fi
561
+ ;;
562
+ *)
563
+ emit_err "CLONE_FAILED" \
564
+ "initial fetch from $repo_url failed: $fetch_err" \
565
+ "bare-clone ($project_id)"
566
+ ;;
567
+ esac
184
568
  fi
185
569
  else
186
- # Existing mirror — refresh best-effort. Offline / network blip
187
- # shouldn't break task-up.
570
+ # Existing mirror — refresh best-effort. Offline / network blips should not
571
+ # destroy a task that already has cached refs, but never switch principals.
188
572
  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"
573
+ fetch_ok=0
574
+ fetch_err=""
575
+ if [ -n "$github_https_url" ] && [ -n "$github_credential_socket" ]; then
576
+ github_fetch_mode="token"
577
+ if fetch_err=$(git_with_github_credential \
578
+ -C "$mirror_dir" fetch origin 2>&1); then
579
+ fetch_ok=1
580
+ fi
581
+ elif [ -n "$github_https_url" ]; then
582
+ if [ "$uai_has_ssh_identity" = "1" ]; then
583
+ github_fetch_mode="ssh"
584
+ if fetch_err=$(git_with_github_ssh \
585
+ -C "$mirror_dir" fetch origin 2>&1); then
586
+ fetch_ok=1
587
+ fi
588
+ else
589
+ github_fetch_mode="none"
590
+ fi
591
+ if [ "$fetch_ok" != "1" ]; then
592
+ anonymous_err=""
593
+ if anonymous_err=$(git_anonymous_https \
594
+ -C "$mirror_dir" fetch origin 2>&1); then
595
+ github_fetch_mode="anonymous"
596
+ fetch_ok=1
597
+ fetch_err=""
598
+ elif [ "$github_fetch_mode" = "none" ]; then
599
+ fetch_err="$anonymous_err"
600
+ fi
601
+ fi
602
+ elif fetch_err=$(git_host_control -C "$mirror_dir" fetch origin 2>&1); then
603
+ fetch_ok=1
604
+ fi
605
+ if [ "$fetch_ok" != "1" ]; then
606
+ case "$fetch_err" in
607
+ *"Repository not found"*|*"repository not found"*|\
608
+ *"Permission denied"*|*"access rights"*|*"could not read Username"*|\
609
+ *"Authentication failed"*|*"Invalid username or password"*|*"HTTP 401"*|\
610
+ *"HTTP 403"*|*"HTTP 404"*|*"requested URL returned error: 401"*|\
611
+ *"requested URL returned error: 403"*|*"requested URL returned error: 404"*)
612
+ case "$github_fetch_mode" in
613
+ token)
614
+ emit_err "GITHUB_TOKEN_ACCESS_DENIED" \
615
+ "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." \
616
+ "git fetch origin ($project_id)"
617
+ ;;
618
+ ssh)
619
+ emit_err "GITHUB_SSH_ACCESS_DENIED" \
620
+ "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." \
621
+ "git fetch origin ($project_id)"
622
+ ;;
623
+ none)
624
+ emit_err "GITHUB_CONNECTION_REQUIRED" \
625
+ "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." \
626
+ "git fetch origin ($project_id)"
627
+ ;;
628
+ *)
629
+ emit_err "FETCH_FAILED" \
630
+ "The configured credential no longer has access to $repo_url. Restore access, then retry the task." \
631
+ "git fetch origin ($project_id)"
632
+ ;;
633
+ esac
634
+ ;;
635
+ *)
636
+ if [ "$worktree_exists" = "1" ]; then
637
+ # Resume is the only safe stale-cache case: this same task already
638
+ # owns the checkout. A new task/user must prove current access
639
+ # before any shared cached code is materialized into its workspace.
640
+ log "warning: git fetch failed for $project_id ($repo_url); resuming this task's existing worktree with cached refs"
641
+ else
642
+ emit_err "FETCH_FAILED" \
643
+ "could not verify the task creator's current access to $repo_url: ${fetch_err:-git fetch failed}. Retry when the remote is reachable." \
644
+ "git fetch origin ($project_id)"
645
+ fi
646
+ ;;
647
+ esac
648
+ fi
649
+ fi
650
+
651
+ if [ "$github_fetch_mode" = "ssh" ]; then
652
+ if [ "$needs_initial_fetch" = "1" ] && [ "$fetch_failed" = "0" ]; then
653
+ uai_used_ssh_transport=1
654
+ elif [ "$needs_initial_fetch" != "1" ] && [ "$fetch_ok" = "1" ]; then
655
+ uai_used_ssh_transport=1
191
656
  fi
192
657
  fi
193
658
 
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 \
659
+ # Resolve the remote default branch with the same task credential
660
+ # (best-effort) fall back to main.
661
+ case "$github_fetch_mode" in
662
+ token)
663
+ git_with_github_credential -C "$mirror_dir" remote set-head origin -a \
664
+ >/dev/null 2>&1 || true
665
+ ;;
666
+ ssh)
667
+ git_with_github_ssh -C "$mirror_dir" remote set-head origin -a \
668
+ >/dev/null 2>&1 || true
669
+ ;;
670
+ anonymous)
671
+ git_anonymous_https -C "$mirror_dir" remote set-head origin -a \
672
+ >/dev/null 2>&1 || true
673
+ ;;
674
+ none)
675
+ git_anonymous_https -C "$mirror_dir" remote set-head origin -a \
676
+ >/dev/null 2>&1 || true
677
+ ;;
678
+ *)
679
+ git_host_control -C "$mirror_dir" remote set-head origin -a >/dev/null 2>&1 || true
680
+ ;;
681
+ esac
682
+ project_default=$(git_host_control -C "$mirror_dir" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null \
197
683
  | sed 's#^origin/##' || true)
198
684
  [ -n "$project_default" ] || project_default="main"
199
685
 
686
+ # Each linked worktree gets its own writable Git control plane (config,
687
+ # hooks, refs, metadata, and objects). It borrows the host-only cache only
688
+ # while being prepared, then repacks every reachable object locally and
689
+ # removes the alternate before the container starts.
690
+ worktree_repo_dir="$task_repo_dir"
691
+ if [ "$standalone_worktree" = "1" ]; then
692
+ worktree_repo_dir="$worktree_target/.git"
693
+ sanitize_task_repo_control_plane "$worktree_repo_dir" "$repo_url" \
694
+ "$task_branch" false
695
+ else
696
+ if [ ! -d "$task_repo_dir" ]; then
697
+ mkdir -p "$(dirname "$task_repo_dir")"
698
+ git_host_control init --bare "$task_repo_dir" >/dev/null
699
+ fi
700
+ sanitize_task_repo_control_plane "$task_repo_dir" "$repo_url" \
701
+ "$task_branch" true
702
+ rm -rf "$task_repo_dir/objects/info"
703
+ mkdir -p "$task_repo_dir/objects/info"
704
+ printf '%s\n' "$mirror_dir/objects" \
705
+ > "$task_repo_dir/objects/info/alternates"
706
+ # Copy only refs/objects from the trusted host mirror. The task repo's
707
+ # remote remains canonical GitHub HTTPS from the sanitizer above.
708
+ if ! git_host_control -C "$task_repo_dir" fetch "$mirror_dir" \
709
+ '+refs/remotes/origin/*:refs/remotes/origin/*' >/dev/null 2>&1; then
710
+ emit_err "WORKTREE_FAILED" \
711
+ "could not copy the fetched refs into the task repository for $repo_url" \
712
+ "copy task repository refs ($project_id)"
713
+ fi
714
+ if ! git_host_control -C "$task_repo_dir" repack -a -d >/dev/null; then
715
+ emit_err "WORKTREE_FAILED" \
716
+ "could not make the task repository self-contained for $repo_url" \
717
+ "copy task repository objects ($project_id)"
718
+ fi
719
+ rm -f "$task_repo_dir/objects/info/alternates"
720
+ fi
721
+
200
722
  mkdir -p "$(dirname "$worktree_target")"
201
723
  if [ -e "$worktree_target/.git" ]; then
202
724
  # Resume (ADR-028): the task worktree survived the stop — reuse it as-is
@@ -206,21 +728,22 @@ while IFS= read -r project_obj; do
206
728
  # (found live 2026-07-21). The task branch and any uncommitted work are
207
729
  # exactly what the user expects back.
208
730
  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 \
731
+ if [ "$standalone_worktree" != "1" ]; then
732
+ # Repair only after the task-private repository and its gitdir pointer
733
+ # have passed the host boundary checks above.
734
+ git_host_control -C "$worktree_repo_dir" worktree repair \
735
+ "$worktree_target" 2>/dev/null || true
736
+ fi
737
+ elif git_host_control -C "$worktree_repo_dir" rev-parse --verify --quiet \
219
738
  "refs/remotes/origin/$project_default" >/dev/null 2>&1; then
220
739
  # Normal case: branch the task worktree off origin/<defaultBranch>.
221
740
  step "WORKTREE_FAILED" "git worktree add ($project_id)"
222
- git -C "$mirror_dir" worktree add "$worktree_target" \
223
- -b "$task_branch" "origin/$project_default"
741
+ if ! git_host_control -C "$worktree_repo_dir" worktree add "$worktree_target" \
742
+ -b "$task_branch" "origin/$project_default"; then
743
+ emit_err "WORKTREE_FAILED" \
744
+ "could not create the task worktree for $repo_url" \
745
+ "git worktree add ($project_id)"
746
+ fi
224
747
  else
225
748
  # Empty / branchless remote — there's no origin/<default> to branch from.
226
749
  # Don't fail the whole task over it (the old behaviour: "invalid reference"
@@ -231,10 +754,10 @@ while IFS= read -r project_obj; do
231
754
  log "warning: $repo_url has no '$project_default' branch (empty repo?); starting an empty workspace for $project_slug"
232
755
  rm -rf "$worktree_target"
233
756
  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
757
+ git_host_control -C "$worktree_target" init -q -b "$project_default" 2>/dev/null \
758
+ || { git_host_control -C "$worktree_target" init -q \
759
+ && git_host_control -C "$worktree_target" symbolic-ref HEAD "refs/heads/$project_default"; }
760
+ git_host_control -C "$worktree_target" remote add origin "$repo_url" 2>/dev/null || true
238
761
  printf '%s\n' \
239
762
  "# Empty repository" \
240
763
  "" \
@@ -253,12 +776,23 @@ while IFS= read -r project_obj; do
253
776
  # Tool-version seeding: only when the worktree has no checked-in
254
777
  # .tool-versions AND the project declares one. Never overwrite a file
255
778
  # the repo already ships.
779
+ if [ -L "$worktree_target/.tool-versions" ]; then
780
+ emit_err "WORKTREE_FAILED" \
781
+ "Uai refused a symbolic-link .tool-versions for $project_slug" \
782
+ "validate task worktree ($project_id)"
783
+ fi
256
784
  if [ ! -e "$worktree_target/.tool-versions" ] \
257
785
  && [ -n "$tool_versions" ] && [ "$tool_versions" != "null" ]; then
258
786
  printf '%s\n' "$tool_versions" > "$worktree_target/.tool-versions"
259
787
  fi
788
+
789
+ release_project_lock
260
790
  done < <(jq -c '.[]' <<<"$projects_json")
261
791
 
792
+ if [ -z "$github_credential_socket" ] && [ "$uai_used_ssh_transport" = "1" ]; then
793
+ uai_git_transport="ssh"
794
+ fi
795
+
262
796
  # -----------------------------------------------------------------------------
263
797
  # 4. Generate .uai/docker-compose.yml (bash). Union preview ports + env keys
264
798
  # across the selected projects; one app service. Derived Dockerfile only
@@ -341,17 +875,16 @@ fi
341
875
  printf ' working_dir: /workspace\n'
342
876
  printf ' volumes:\n'
343
877
  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.
878
+ # Identical-host-path binds keep each linked worktree's absolute gitdir
879
+ # pointer valid in-container (ADR-014). Only the self-contained task-private
880
+ # repository is mounted; the host cache never enters a container. Legacy
881
+ # worktrees fail closed before Compose rendering and must be recreated.
349
882
  printf ' - "%s:%s"\n' "$task_workspace" "$task_workspace"
350
883
  while IFS= read -r vol_obj; do
351
884
  [ -n "$vol_obj" ] || continue
352
885
  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"
886
+ printf ' - "%s/repos/%s.git:%s/repos/%s.git"\n' \
887
+ "$task_uai_dir" "$vol_pid" "$task_uai_dir" "$vol_pid"
355
888
  done < <(jq -c '.[]' <<<"$projects_json")
356
889
  printf ' - "%s:/opt/asdf-data"\n' "$ASDF_VOLUME"
357
890
  # ADR-053: host-wide Playwright browser cache. Always mounted (harmless
@@ -378,6 +911,13 @@ fi
378
911
  # sidecar attached to the compose network, so nothing is exposed by default.
379
912
  printf ' - "127.0.0.1::8080"\n'
380
913
  printf ' environment:\n'
914
+ # Non-secret transport marker for uai-init. The actual GitHub token is
915
+ # injected later into gh's private config and never enters Compose.
916
+ printf ' UAI_GIT_TRANSPORT: "%s"\n' "$uai_git_transport"
917
+ # Uai owns GitHub auth inside the task container. Keep gh's config outside
918
+ # the bind-mounted workspace and independent of project-defined XDG/HOME
919
+ # values so its credential helper always resolves the connected user.
920
+ printf ' GH_CONFIG_DIR: "/home/node/.config/gh"\n'
381
921
  # Host-resident Claude auth (ADR-021). Headless `claude --print` no longer
382
922
  # uses the interactive subscription/keychain path, so it needs a token from
383
923
  # `claude setup-token`. Interpolated from the host-agent env at up-time, so
@@ -398,22 +938,27 @@ fi
398
938
  # an env var over its stored credentials, which blocks the per-user
399
939
  # `gh auth login --with-token` the host runs (and would re-attribute every PR
400
940
  # 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.
941
+ # `gh` through its config file instead (lib/github-tokens.ts), and gh serves
942
+ # as Git's HTTPS credential helper. SSH remains a disconnected-user fallback.
943
+ # Projects may declare managed names, so skip them here rather than trust
944
+ # callers. They are also removed from the in-memory Compose override below.
405
945
  #
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).
946
+ # Project values never enter the host environment. Emit an explicit empty
947
+ # default for every declared key; the stdin-only Compose override supplies
948
+ # decrypted values at `compose up` time.
411
949
  while IFS= read -r ekey; do
412
950
  [ -n "$ekey" ] || continue
413
951
  case "$ekey" in
414
- GH_TOKEN | GITHUB_TOKEN) continue ;;
952
+ GH_TOKEN | GITHUB_TOKEN | GH_CONFIG_DIR | GH_HOST | \
953
+ GIT_CONFIG* | GIT_DIR | GIT_WORK_TREE | GIT_COMMON_DIR | \
954
+ GIT_OBJECT_DIRECTORY | GIT_ALTERNATE_OBJECT_DIRECTORIES | \
955
+ GIT_INDEX_FILE | GIT_EXEC_PATH | GIT_ASKPASS | SSH_ASKPASS | \
956
+ GIT_SSH | GIT_SSH_COMMAND | HOME | XDG_CONFIG_HOME | \
957
+ BASH_ENV | ENV | UAI_GIT_TRANSPORT | \
958
+ CLAUDE_CODE_OAUTH_TOKEN | ANTHROPIC_API_KEY | \
959
+ ANTHROPIC_AUTH_TOKEN | XAI_API_KEY | PLAYWRIGHT_BROWSERS_PATH) continue ;;
415
960
  esac
416
- printf ' %s: "${%s:-}"\n' "$ekey" "$ekey"
961
+ printf ' %s: ""\n' "$ekey"
417
962
  done < <(jq -r '.[]' <<<"$union_env_keys_json")
418
963
  # Preview-URL env vars (ADR-025): cloud-computed PUBLIC preview URLs exposed
419
964
  # under operator-chosen names (e.g. EXPO_PACKAGER_PROXY_URL). Non-secret, so
@@ -468,10 +1013,53 @@ if ! docker image inspect "$STANDARD_IMAGE" >/dev/null 2>&1; then
468
1013
  fi
469
1014
 
470
1015
  step "COMPOSE_UP_FAILED" "docker compose up -d"
1016
+ # Limit the supplied values to keys the selected projects actually declare,
1017
+ # then remove Uai-managed names and preview URL names. JSON is valid YAML, so
1018
+ # Compose can merge this secret-bearing override directly from stdin without a
1019
+ # temporary env/YAML file and without exporting any value to host commands.
1020
+ preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
1021
+ compose_env_override=$(printf '%s' "$project_env_json" | jq -c \
1022
+ --argjson declared "$union_env_keys_json" \
1023
+ --argjson preview "$preview_env_raw" '
1024
+ .
1025
+ | with_entries(.key as $key | select($declared | index($key)))
1026
+ | with_entries(select(.key | test("^GIT_CONFIG($|_)") | not))
1027
+ | del(
1028
+ .GH_TOKEN,
1029
+ .GITHUB_TOKEN,
1030
+ .GH_CONFIG_DIR,
1031
+ .GH_HOST,
1032
+ .GIT_DIR,
1033
+ .GIT_WORK_TREE,
1034
+ .GIT_COMMON_DIR,
1035
+ .GIT_OBJECT_DIRECTORY,
1036
+ .GIT_ALTERNATE_OBJECT_DIRECTORIES,
1037
+ .GIT_INDEX_FILE,
1038
+ .GIT_EXEC_PATH,
1039
+ .GIT_ASKPASS,
1040
+ .SSH_ASKPASS,
1041
+ .GIT_SSH,
1042
+ .GIT_SSH_COMMAND,
1043
+ .HOME,
1044
+ .XDG_CONFIG_HOME,
1045
+ .BASH_ENV,
1046
+ .ENV,
1047
+ .UAI_GIT_TRANSPORT,
1048
+ .CLAUDE_CODE_OAUTH_TOKEN,
1049
+ .ANTHROPIC_API_KEY,
1050
+ .ANTHROPIC_AUTH_TOKEN,
1051
+ .XAI_API_KEY,
1052
+ .PLAYWRIGHT_BROWSERS_PATH
1053
+ )
1054
+ | delpaths($preview | keys | map([.]))
1055
+ | {services:{app:{environment:.}}}
1056
+ ')
471
1057
  if [ "$has_derived" = "1" ]; then
472
- docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" up -d --build >/dev/null
1058
+ printf '%s\n' "$compose_env_override" | docker compose -p "$compose_project" \
1059
+ -f "$task_uai_dir/docker-compose.yml" -f - up -d --build >/dev/null
473
1060
  else
474
- docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" up -d >/dev/null
1061
+ printf '%s\n' "$compose_env_override" | docker compose -p "$compose_project" \
1062
+ -f "$task_uai_dir/docker-compose.yml" -f - up -d >/dev/null
475
1063
  fi
476
1064
 
477
1065
  # -----------------------------------------------------------------------------
@@ -480,6 +1068,93 @@ fi
480
1068
 
481
1069
  step "CONTAINER_INIT_FAILED" "uai-init (deps + code-server)"
482
1070
 
1071
+ # `docker exec` inherits the project environment from Compose. Override loader
1072
+ # and config variables before the first executable starts, then use `env -i`
1073
+ # plus absolute command paths so a project value cannot redirect this managed
1074
+ # credential operation to workspace code or another config directory.
1075
+ managed_gh_config_dir="/home/node/.config/gh"
1076
+ managed_exec_env=(
1077
+ -e "HOME=/home/node"
1078
+ -e "GH_CONFIG_DIR=$managed_gh_config_dir"
1079
+ -e "PATH=/usr/bin:/bin"
1080
+ -e "XDG_CONFIG_HOME="
1081
+ -e "LD_PRELOAD="
1082
+ -e "LD_LIBRARY_PATH="
1083
+ -e "DYLD_INSERT_LIBRARIES="
1084
+ -e "DYLD_LIBRARY_PATH="
1085
+ -e "BASH_ENV="
1086
+ -e "ENV="
1087
+ -e "GIT_CONFIG="
1088
+ -e "GIT_CONFIG_GLOBAL="
1089
+ -e "GIT_CONFIG_SYSTEM="
1090
+ -e "GIT_CONFIG_NOSYSTEM="
1091
+ -e "GIT_CONFIG_COUNT=0"
1092
+ -e "GIT_EXEC_PATH="
1093
+ -e "GIT_SSH="
1094
+ -e "GIT_SSH_COMMAND="
1095
+ -e "GIT_ASKPASS="
1096
+ -e "SSH_ASKPASS="
1097
+ -e "GH_TOKEN="
1098
+ -e "GITHUB_TOKEN="
1099
+ )
1100
+ managed_clean_env=(
1101
+ /usr/bin/env -i
1102
+ "HOME=/home/node"
1103
+ "GH_CONFIG_DIR=$managed_gh_config_dir"
1104
+ "PATH=/usr/bin:/bin"
1105
+ )
1106
+
1107
+ # A failed/retried start can preserve a container that still holds a previous
1108
+ # account. Erase that Uai-managed state first for both connected and
1109
+ # disconnected starts; otherwise a no-credential resume could briefly reuse a
1110
+ # stale token before the asynchronous reconciler catches up.
1111
+ if ! docker exec -u node "${managed_exec_env[@]}" "$app_container" \
1112
+ "${managed_clean_env[@]}" /bin/sh -c '
1113
+ set -eu
1114
+ /bin/rm -f "$GH_CONFIG_DIR/hosts.yml"
1115
+ for key in \
1116
+ credential.https://github.com.helper \
1117
+ credential.https://gist.github.com.helper
1118
+ do
1119
+ /usr/bin/git config --file "$HOME/.gitconfig" --unset-all "$key" \
1120
+ >/dev/null 2>&1 || true
1121
+ if /usr/bin/git config --file "$HOME/.gitconfig" --get-all "$key" \
1122
+ >/dev/null 2>&1; then
1123
+ exit 1
1124
+ fi
1125
+ done
1126
+ test ! -e "$GH_CONFIG_DIR/hosts.yml"
1127
+ ' >/dev/null; then
1128
+ emit_err "GITHUB_AUTH_UNAVAILABLE" \
1129
+ "Uai could not clear the previous managed GitHub credential from the task container. Retry the task before doing GitHub work." \
1130
+ "clear container GitHub credential"
1131
+ fi
1132
+
1133
+ # The task container must have the same connected GitHub principal BEFORE
1134
+ # uai-init runs package managers: private Git submodules/dependencies may be
1135
+ # fetched during install. Read the token back from the private cache and stream
1136
+ # only its password field directly into `gh auth login`; it never enters argv,
1137
+ # environment, a shell variable, Compose, or disk.
1138
+ if [ -n "$github_credential_socket" ]; then
1139
+ if ! printf 'protocol=https\nhost=github.com\n\n' \
1140
+ | git_with_github_credential credential fill \
1141
+ | sed -n 's/^password=//p' \
1142
+ | docker exec -i -u node "${managed_exec_env[@]}" "$app_container" \
1143
+ "${managed_clean_env[@]}" /usr/bin/gh auth login --with-token \
1144
+ --hostname github.com --git-protocol https >/dev/null; then
1145
+ emit_err "GITHUB_AUTH_UNAVAILABLE" \
1146
+ "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." \
1147
+ "configure container GitHub credential"
1148
+ fi
1149
+ if ! docker exec -u node "${managed_exec_env[@]}" "$app_container" \
1150
+ "${managed_clean_env[@]}" /usr/bin/gh auth setup-git \
1151
+ --hostname github.com >/dev/null; then
1152
+ emit_err "GITHUB_AUTH_UNAVAILABLE" \
1153
+ "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." \
1154
+ "configure container Git credential helper"
1155
+ fi
1156
+ fi
1157
+
483
1158
  # Copy `.claude.json` into the container instead of bind-mounting it.
484
1159
  # Single-file bind mounts on Docker Desktop macOS are fragile — claude
485
1160
  # CLI atomically rewrites the file on every start, which can invalidate
@@ -571,11 +1246,10 @@ done
571
1246
  docker exec -u root "$app_container" \
572
1247
  chown -R node:node /home/node/.local/share/opencode >/dev/null 2>&1 || true
573
1248
 
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 —
1249
+ # Copy the resolved SSH identity into the container for commit/tag signing and
1250
+ # as the no-GitHub-connection transport fallback. Connected users clone, fetch,
1251
+ # and push over HTTPS through their gh credential instead.
1252
+ # Best-effort: signing + the SSH fallback stay off if the identity is absent —
579
1253
  # but every failure is LOGGED (a silent miss here reads as a GitHub
580
1254
  # permissions problem hours later; live 2026-07-21). The channel-ensure path
581
1255
  # (ensureTaskSshIdentity) re-asserts all of this on reconnects.
@@ -585,7 +1259,7 @@ if [ -f "$uai_identity_key" ]; then
585
1259
  || log "warning: ssh setup: mkdir ~/.ssh failed in $app_container"
586
1260
  docker cp "$uai_identity_key" \
587
1261
  "$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"
1262
+ || log "warning: ssh setup: key copy failed — signing and SSH fallback unavailable"
589
1263
  docker cp "${uai_identity_key}.pub" \
590
1264
  "$app_container":/home/node/.ssh/id_ed25519.pub >/dev/null 2>&1 \
591
1265
  || log "warning: ssh setup: pubkey copy failed (signing will be off)"