@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.
- package/images/standard/container/uai-init +38 -19
- package/lib/agent.ts +42 -16
- package/lib/agents/claude.ts +86 -14
- package/lib/agents/factory.ts +9 -1
- package/lib/agents/registry.ts +27 -0
- package/lib/agents/types.ts +3 -0
- 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 +303 -91
- package/lib/repo-clone.ts +12 -101
- package/lib/ssh.ts +11 -8
- package/lib/transcript.ts +17 -2
- 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 +746 -72
- package/src/index.ts +81 -7
- package/src/main.ts +112 -31
- package/src/protocol.ts +51 -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 \
|
|
@@ -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
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
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:
|
|
103
|
-
# $UAI_TASK_IDENTITY_DIR by the orchestrator) so
|
|
104
|
-
#
|
|
105
|
-
#
|
|
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="
|
|
163
|
+
uai_identity_key=""
|
|
111
164
|
fi
|
|
112
|
-
|
|
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
|
|
174
|
+
log "no SSH identity for the task owner — SSH transport fallback unavailable"
|
|
120
175
|
fi
|
|
121
176
|
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
175
|
-
#
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
187
|
-
#
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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 \
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|| {
|
|
236
|
-
&&
|
|
237
|
-
|
|
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
|
|
345
|
-
# (
|
|
346
|
-
#
|
|
347
|
-
#
|
|
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
|
|
354
|
-
"$
|
|
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)
|
|
402
|
-
#
|
|
403
|
-
#
|
|
404
|
-
#
|
|
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
|
-
#
|
|
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).
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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"
|
|
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
|
|
575
|
-
#
|
|
576
|
-
#
|
|
577
|
-
# the
|
|
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 —
|
|
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)"
|