@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.
- package/images/standard/container/uai-init +38 -19
- package/lib/agent.ts +42 -16
- package/lib/git-identity.ts +349 -70
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-tokens.ts +756 -110
- package/lib/orchestrator.ts +70 -28
- package/lib/repo-clone.ts +12 -101
- package/lib/ssh.ts +11 -8
- package/package.json +1 -1
- package/scripts/agent/_common.sh +214 -0
- package/scripts/agent/task-down.sh +35 -59
- package/scripts/agent/task-up.sh +914 -72
- package/src/index.ts +78 -5
- package/src/main.ts +100 -31
- package/src/protocol.ts +4 -0
package/scripts/agent/task-up.sh
CHANGED
|
@@ -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
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
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:
|
|
103
|
-
# $UAI_TASK_IDENTITY_DIR by the orchestrator) so
|
|
104
|
-
#
|
|
105
|
-
#
|
|
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="
|
|
188
|
+
uai_identity_key=""
|
|
111
189
|
fi
|
|
112
|
-
|
|
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
|
|
199
|
+
log "no SSH identity for the task owner — SSH transport fallback unavailable"
|
|
120
200
|
fi
|
|
121
201
|
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
175
|
-
#
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
187
|
-
#
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|| {
|
|
236
|
-
&&
|
|
237
|
-
|
|
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
|
|
345
|
-
# (
|
|
346
|
-
#
|
|
347
|
-
#
|
|
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
|
|
354
|
-
"$
|
|
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)
|
|
402
|
-
#
|
|
403
|
-
#
|
|
404
|
-
#
|
|
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
|
-
#
|
|
407
|
-
#
|
|
408
|
-
#
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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"
|
|
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
|
|
575
|
-
#
|
|
576
|
-
#
|
|
577
|
-
# the
|
|
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 —
|
|
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)"
|