@runuai/host 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/bin/uai-host.mjs +14 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/meta/_journal.json +41 -0
  10. package/db/schema.ts +82 -0
  11. package/images/standard/Dockerfile +232 -0
  12. package/images/standard/README.md +122 -0
  13. package/images/standard/container/code-server-settings.json +36 -0
  14. package/images/standard/container/uai-init +215 -0
  15. package/images/standard/tool-versions +2 -0
  16. package/lib/agent.ts +292 -0
  17. package/lib/agents/claude.ts +343 -0
  18. package/lib/agents/codex.ts +522 -0
  19. package/lib/agents/factory.ts +34 -0
  20. package/lib/agents/mock.ts +133 -0
  21. package/lib/agents/proc.ts +172 -0
  22. package/lib/agents/registry.ts +109 -0
  23. package/lib/agents/types.ts +133 -0
  24. package/lib/attachments.ts +46 -0
  25. package/lib/cloud-state.ts +56 -0
  26. package/lib/command-db.ts +278 -0
  27. package/lib/db.ts +68 -0
  28. package/lib/env.ts +140 -0
  29. package/lib/git-diff.ts +370 -0
  30. package/lib/git-identity.ts +65 -0
  31. package/lib/github-tokens.ts +321 -0
  32. package/lib/orchestrator.ts +975 -0
  33. package/lib/preview-ports.ts +85 -0
  34. package/lib/repo-clone.ts +127 -0
  35. package/lib/runtime-state.ts +120 -0
  36. package/lib/secrets.ts +71 -0
  37. package/lib/ssh.ts +186 -0
  38. package/lib/standard-image.ts +152 -0
  39. package/lib/task-diff.ts +113 -0
  40. package/lib/task-status.ts +46 -0
  41. package/lib/transcript.ts +30 -0
  42. package/lib/ulid.ts +7 -0
  43. package/package.json +85 -0
  44. package/scripts/agent/_common.sh +248 -0
  45. package/scripts/agent/task-down.sh +113 -0
  46. package/scripts/agent/task-status.sh +54 -0
  47. package/scripts/agent/task-up.sh +457 -0
  48. package/scripts/install/darwin.ts +167 -0
  49. package/scripts/install/linux.ts +115 -0
  50. package/scripts/install/types.ts +35 -0
  51. package/scripts/install/util.ts +39 -0
  52. package/scripts/install/win.ts +130 -0
  53. package/src/cli.ts +445 -0
  54. package/src/index.ts +375 -0
  55. package/src/load-env.ts +52 -0
  56. package/src/main.ts +1156 -0
  57. package/src/paths.ts +64 -0
  58. package/src/protocol.ts +413 -0
  59. package/src/ui/server.ts +343 -0
  60. package/src/ui/types.ts +78 -0
  61. package/ui/app.js +264 -0
  62. package/ui/index.html +55 -0
  63. package/ui/style.css +359 -0
  64. package/ui/uai-logo-black.svg +9 -0
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bash
2
+ # task-down <taskId> — tear the task stack down. Idempotent (ADR-022).
3
+ #
4
+ # 1. docker compose down -v --rmi local --remove-orphans.
5
+ # 2. For each selected project, git worktree remove --force its worktree
6
+ # (falls back to rm -rf when git refuses).
7
+ # 3. rm -rf the whole task dir — workspace + ephemeral .uai/ render. The
8
+ # per-project bare mirrors under projects/<id>/repo.git persist.
9
+ # 4. Persist final state: status=killed (unless already error/shipped).
10
+
11
+ set -euo pipefail
12
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ # shellcheck source=_common.sh
14
+ . "$SCRIPT_DIR/_common.sh"
15
+
16
+ [ "$#" -eq 1 ] || emit_err "BAD_ARGS" "usage: task-down <taskId>" "args"
17
+
18
+ task_id="$1"
19
+ UAI_TASK_ID="$task_id"
20
+ setup_err_trap
21
+
22
+ step "TASK_NOT_FOUND" "read task row"
23
+ task_json=$(db_get_task "$task_id")
24
+ # Idempotent: it's fine if the task row is gone — emit success and return.
25
+ if [ "$(jq 'length' <<<"$task_json")" -lt 1 ]; then
26
+ emit_ok '{"alreadyGone":true}'
27
+ exit 0
28
+ fi
29
+
30
+ worktree_path=$(jq -r '.[0].worktree_path // ""' <<<"$task_json")
31
+ compose_project=$(jq -r '.[0].compose_project // ""' <<<"$task_json")
32
+
33
+ projects_json=$(db_get_projects_for_task "$task_id")
34
+ projects_root="$UAI_WORKSPACE_ROOT/projects"
35
+
36
+ # task_dir falls back to the derived path when the row's worktree_path is
37
+ # missing (e.g. crashed before persisting).
38
+ task_dir="$worktree_path"
39
+ if [ -z "$task_dir" ]; then
40
+ task_dir="$UAI_WORKSPACE_ROOT/tasks/$task_id"
41
+ fi
42
+
43
+ # -----------------------------------------------------------------------------
44
+ # 1. Compose stack.
45
+ # -----------------------------------------------------------------------------
46
+
47
+ if [ -n "$compose_project" ]; then
48
+ step "COMPOSE_DOWN_FAILED" "docker compose down -v --rmi local"
49
+ if [ -n "$task_dir" ] && [ -f "$task_dir/.uai/docker-compose.yml" ]; then
50
+ docker compose -p "$compose_project" -f "$task_dir/.uai/docker-compose.yml" \
51
+ down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
52
+ else
53
+ # Fallback: tear down by project name only.
54
+ docker compose -p "$compose_project" down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
55
+ fi
56
+ fi
57
+
58
+ # -----------------------------------------------------------------------------
59
+ # 2. Worktrees — one per selected project.
60
+ # -----------------------------------------------------------------------------
61
+
62
+ if [ -n "$task_dir" ] && [ -d "$task_dir/workspace" ]; then
63
+ while IFS= read -r project_obj; do
64
+ [ -n "$project_obj" ] || continue
65
+ project_id=$(jq -r '.id' <<<"$project_obj")
66
+ project_slug=$(jq -r '.slug' <<<"$project_obj")
67
+ mirror_dir="$projects_root/$project_id/repo.git"
68
+ worktree_target="$task_dir/workspace/$project_slug"
69
+ [ -d "$worktree_target" ] || continue
70
+ if [ -d "$mirror_dir" ]; then
71
+ # `--force` because the worktree may carry uncommitted changes the
72
+ # user asked us to discard.
73
+ git -C "$mirror_dir" worktree remove --force "$worktree_target" 2>/dev/null \
74
+ || rm -rf "$worktree_target"
75
+ else
76
+ rm -rf "$worktree_target"
77
+ fi
78
+ done < <(jq -c '.[]' <<<"$projects_json")
79
+ fi
80
+
81
+ # -----------------------------------------------------------------------------
82
+ # 3. Remove the entire task directory (workspace + .uai/).
83
+ # -----------------------------------------------------------------------------
84
+
85
+ if [ -n "$task_dir" ] && [ -d "$task_dir" ]; then
86
+ step "WORKTREE_REMOVE_FAILED" "rm -rf task dir"
87
+ rm -rf "$task_dir"
88
+ fi
89
+
90
+ # -----------------------------------------------------------------------------
91
+ # 4. Persist final state.
92
+ # -----------------------------------------------------------------------------
93
+
94
+ current_status=$(jq -r '.[0].status' <<<"$task_json")
95
+ if [ "$current_status" != "shipped" ] && [ "$current_status" != "error" ]; then
96
+ new_status="killed"
97
+ else
98
+ new_status="$current_status"
99
+ fi
100
+
101
+ step "DB_UPDATE_FAILED" "persist final state"
102
+ db_update_task "$task_id" \
103
+ "status='$new_status'" \
104
+ "code_server_port=NULL" \
105
+ "preview_ports='[]'" \
106
+ "compose_project=NULL" \
107
+ "worktree_path=NULL" \
108
+ "locked_at=NULL" \
109
+ "ended_at=(unixepoch()*1000)"
110
+
111
+ emit_event "$task_id" "status_changed" "$(jq -nc --arg f "$current_status" --arg t "$new_status" '{from:$f,to:$t}')"
112
+
113
+ emit_ok "$(jq -nc --arg s "$new_status" '{status:$s}')"
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bash
2
+ # task-status <taskId> — introspect the actual runtime state.
3
+ #
4
+ # Returns the truth from docker / git / process listings rather than
5
+ # trusting the Task row. Used by POST /api/tasks/:id/reconcile to detect
6
+ # drift after a manual `docker compose down` or similar.
7
+
8
+ set -euo pipefail
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ # shellcheck source=_common.sh
11
+ . "$SCRIPT_DIR/_common.sh"
12
+
13
+ [ "$#" -eq 1 ] || emit_err "BAD_ARGS" "usage: task-status <taskId>" "args"
14
+
15
+ task_id="$1"
16
+ UAI_TASK_ID="$task_id"
17
+ setup_err_trap
18
+
19
+ step "TASK_NOT_FOUND" "read task row"
20
+ task_json=$(db_get_task "$task_id")
21
+ [ "$(jq 'length' <<<"$task_json")" -ge 1 ] \
22
+ || emit_err "TASK_NOT_FOUND" "no such task: $task_id" "read task row"
23
+
24
+ compose_project=$(jq -r '.[0].compose_project // ""' <<<"$task_json")
25
+ worktree_path=$(jq -r '.[0].worktree_path // ""' <<<"$task_json")
26
+
27
+ # Containers running under this compose project, JSON array of names.
28
+ step "DOCKER_PS_FAILED" "docker ps"
29
+ containers_json="[]"
30
+ if [ -n "$compose_project" ]; then
31
+ raw=$(docker ps --filter "label=com.docker.compose.project=$compose_project" --format '{{.Names}}' || true)
32
+ if [ -n "$raw" ]; then
33
+ containers_json=$(printf '%s\n' "$raw" | jq -R . | jq -sc .)
34
+ fi
35
+ fi
36
+
37
+ # Compose running iff at least one container is up.
38
+ if [ "$containers_json" = "[]" ]; then
39
+ compose_running="false"
40
+ else
41
+ compose_running="true"
42
+ fi
43
+
44
+ # Worktree present iff the directory exists.
45
+ worktree_present="false"
46
+ if [ -n "$worktree_path" ] && [ -d "$worktree_path" ]; then
47
+ worktree_present="true"
48
+ fi
49
+
50
+ emit_ok "$(jq -nc \
51
+ --argjson cr "$compose_running" \
52
+ --argjson cs "$containers_json" \
53
+ --argjson wp "$worktree_present" \
54
+ '{composeRunning:$cr,containers:$cs,worktreePresent:$wp}')"
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env bash
2
+ # task-up <taskId> — bring up the task stack end-to-end (ADR-022).
3
+ #
4
+ # The "standard image" model: every host runs one prebuilt image,
5
+ # `uai-standard:dev`. A task selects 0..N projects (0 = empty workspace /
6
+ # scratchpad). Each selected project contributes a git worktree under the
7
+ # task's workspace, plus a union of preview ports and env keys.
8
+ #
9
+ # Steps:
10
+ # 1. Read task (-> branch) + the task's selected projects.
11
+ # 2. Acquire lock; mark status=starting.
12
+ # 3. Per project: ensure a bare mirror, fetch, add a worktree on the task
13
+ # branch, seed .tool-versions when missing.
14
+ # 4. Generate .uai/docker-compose.yml in bash (and a derived .uai/Dockerfile
15
+ # when any project declares `extra`).
16
+ # 5. docker compose -p task-<id> up -d (--build only when derived).
17
+ # 6. docker cp AI auth into the container, then docker exec uai-init.
18
+ # 7. Discover code-server + preview ports; mark status=running; unlock.
19
+ #
20
+ # The AIs are NOT started here — the chat orchestrator drives them and
21
+ # delivers the task's initial prompt once the task is running.
22
+
23
+ set -euo pipefail
24
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+ # shellcheck source=_common.sh
26
+ . "$SCRIPT_DIR/_common.sh"
27
+
28
+ [ "$#" -eq 1 ] || emit_err "BAD_ARGS" "usage: task-up <taskId>" "args"
29
+
30
+ task_id="$1"
31
+ UAI_TASK_ID="$task_id"
32
+ setup_err_trap
33
+
34
+ # Pinned constants (all clusters must agree — see ADR-022).
35
+ STANDARD_IMAGE="uai-standard:dev"
36
+ ASDF_VOLUME="uai-asdf-data"
37
+ DERIVED_IMAGE="uai-task-${task_id}"
38
+
39
+ # -----------------------------------------------------------------------------
40
+ # Paths (mirror host-agent/lib/env.ts).
41
+ # -----------------------------------------------------------------------------
42
+
43
+ task_dir="$UAI_WORKSPACE_ROOT/tasks/$task_id"
44
+ task_workspace="$task_dir/workspace"
45
+ task_uai_dir="$task_dir/.uai"
46
+ projects_root="$UAI_WORKSPACE_ROOT/projects"
47
+ compose_project=$(compose_project_for_task "$task_id")
48
+ app_container=$(app_container_for_task "$task_id")
49
+
50
+ mapped_host_port() {
51
+ local container="$1" container_port="$2"
52
+ docker port "$container" "$container_port" 2>/dev/null \
53
+ | awk -F: 'NR==1 {print $NF}' \
54
+ | tr -d '[:space:]' \
55
+ || true
56
+ }
57
+
58
+ # -----------------------------------------------------------------------------
59
+ # 1. Read rows.
60
+ # -----------------------------------------------------------------------------
61
+
62
+ step "TASK_NOT_FOUND" "read task row"
63
+ task_json=$(db_get_task "$task_id")
64
+ [ "$(jq 'length' <<<"$task_json")" -ge 1 ] \
65
+ || emit_err "TASK_NOT_FOUND" "no such task: $task_id" "read task row"
66
+
67
+ task_branch=$(jq -r '.[0].branch' <<<"$task_json")
68
+
69
+ step "PROJECT_NOT_FOUND" "read task projects"
70
+ projects_json=$(db_get_projects_for_task "$task_id")
71
+ project_count=$(jq 'length' <<<"$projects_json")
72
+
73
+ # -----------------------------------------------------------------------------
74
+ # 2. Lock + status=starting.
75
+ # -----------------------------------------------------------------------------
76
+
77
+ step "LOCKED" "acquire task lock"
78
+ lock_task "$task_id"
79
+
80
+ step "DB_UPDATE_FAILED" "mark task starting"
81
+ db_update_task "$task_id" \
82
+ "status='starting'" \
83
+ "started_at=(unixepoch()*1000)" \
84
+ "code_server_port=NULL" \
85
+ "preview_ports='[]'" \
86
+ "worktree_path='$(sql_escape "$task_dir")'" \
87
+ "compose_project='$(sql_escape "$compose_project")'"
88
+
89
+ # -----------------------------------------------------------------------------
90
+ # 3. Bare mirrors + worktrees, one per selected project.
91
+ # -----------------------------------------------------------------------------
92
+
93
+ # Use the uai-managed host identity for all git-over-SSH (ADR-015: git auth is
94
+ # a host-resident credential). `IdentitiesOnly=yes` + `IdentityAgent=none` keep
95
+ # it from falling through to the operator's personal SSH agent (e.g. 1Password),
96
+ # which both leaks the wrong key and blocks unattended task-up on its approval
97
+ # prompt. `BatchMode=yes` fails fast instead of prompting. Falls back to the
98
+ # host default SSH when the identity hasn't been set up (`pnpm setup-identity`).
99
+ #
100
+ # ADR-029: prefer the TASK CREATOR's per-user key (written to
101
+ # $UAI_TASK_IDENTITY_DIR by the orchestrator) so the clone + push run as them,
102
+ # not the operator. Fall back to the shared operator identity when the creator
103
+ # has no key on this host (transitional / pre-ADR-029 users).
104
+ if [ -n "${UAI_TASK_IDENTITY_DIR:-}" ] && [ -f "${UAI_TASK_IDENTITY_DIR}/id_ed25519" ]; then
105
+ uai_identity_key="$UAI_TASK_IDENTITY_DIR/id_ed25519"
106
+ log "git over SSH using the task creator's per-user key"
107
+ else
108
+ uai_identity_key="$UAI_DATA_DIR/identity/id_ed25519"
109
+ fi
110
+ if [ -f "$uai_identity_key" ]; then
111
+ uai_known_hosts="$UAI_DATA_DIR/identity/known_hosts"
112
+ GIT_SSH_COMMAND="ssh -i $uai_identity_key -o IdentitiesOnly=yes -o IdentityAgent=none -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
113
+ [ -f "$uai_known_hosts" ] && GIT_SSH_COMMAND="$GIT_SSH_COMMAND -o UserKnownHostsFile=$uai_known_hosts"
114
+ export GIT_SSH_COMMAND
115
+ log "git over SSH using $uai_identity_key (1Password agent bypassed)"
116
+ else
117
+ log "no uai identity key at $uai_identity_key — git uses host default SSH (run: pnpm setup-identity)"
118
+ fi
119
+
120
+ mkdir -p "$task_workspace"
121
+
122
+ # Iterate using jq -c so each project is a single JSON object per line.
123
+ # Bash 3.2 friendly: a while-read loop rather than mapfile/readarray.
124
+ while IFS= read -r project_obj; do
125
+ [ -n "$project_obj" ] || continue
126
+
127
+ project_id=$(jq -r '.id' <<<"$project_obj")
128
+ project_slug=$(jq -r '.slug' <<<"$project_obj")
129
+ repo_url=$(jq -r '.repo_url' <<<"$project_obj")
130
+ tool_versions=$(jq -r '.tool_versions // ""' <<<"$project_obj")
131
+
132
+ mirror_dir="$projects_root/$project_id/repo.git"
133
+ worktree_target="$task_workspace/$project_slug"
134
+
135
+ # Ensure the bare mirror exists with **remote-tracking refs**. Plain
136
+ # `git clone --bare` copies upstream heads straight into refs/heads/*
137
+ # and never populates refs/remotes/origin/*, so `worktree add ...
138
+ # origin/<defaultBranch>` would fail with "invalid reference". Init +
139
+ # remote + standard refspec + fetch gives a proper bare repo whose
140
+ # origin/* refs match the remote tip.
141
+ needs_initial_fetch=0
142
+ if [ ! -d "$mirror_dir" ]; then
143
+ step "CLONE_FAILED" "bare-clone ($project_id)"
144
+ mkdir -p "$(dirname "$mirror_dir")"
145
+ git init --bare "$mirror_dir" >/dev/null
146
+ git -C "$mirror_dir" remote add origin "$repo_url"
147
+ needs_initial_fetch=1
148
+ fi
149
+
150
+ # Always (re-)assert the remote-tracking refspec. Self-heals mirrors
151
+ # created before this script learned the right refspec — idempotent.
152
+ git -C "$mirror_dir" config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
153
+
154
+ if [ "$needs_initial_fetch" = "1" ]; then
155
+ # First fetch on a brand-new mirror — required. Bail and clean up so
156
+ # the next attempt starts fresh rather than re-using a half-init.
157
+ if ! git -C "$mirror_dir" fetch origin; then
158
+ rm -rf "$mirror_dir"
159
+ emit_err "CLONE_FAILED" "initial fetch from $repo_url failed" "bare-clone ($project_id)"
160
+ fi
161
+ else
162
+ # Existing mirror — refresh best-effort. Offline / network blip
163
+ # shouldn't break task-up.
164
+ step "FETCH_FAILED" "git fetch origin ($project_id)"
165
+ if ! git -C "$mirror_dir" fetch origin >/dev/null 2>&1; then
166
+ log "warning: git fetch failed for $project_id ($repo_url); using cached refs"
167
+ fi
168
+ fi
169
+
170
+ # Resolve the remote default branch (best-effort) — fall back to main.
171
+ git -C "$mirror_dir" remote set-head origin -a >/dev/null 2>&1 || true
172
+ project_default=$(git -C "$mirror_dir" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null \
173
+ | sed 's#^origin/##' || true)
174
+ [ -n "$project_default" ] || project_default="main"
175
+
176
+ # Create the worktree on a fresh task branch off origin/<defaultBranch>.
177
+ step "WORKTREE_FAILED" "git worktree add ($project_id)"
178
+ mkdir -p "$(dirname "$worktree_target")"
179
+ git -C "$mirror_dir" worktree add "$worktree_target" \
180
+ -b "$task_branch" "origin/$project_default"
181
+
182
+ # Tool-version seeding: only when the worktree has no checked-in
183
+ # .tool-versions AND the project declares one. Never overwrite a file
184
+ # the repo already ships.
185
+ if [ ! -e "$worktree_target/.tool-versions" ] \
186
+ && [ -n "$tool_versions" ] && [ "$tool_versions" != "null" ]; then
187
+ printf '%s\n' "$tool_versions" > "$worktree_target/.tool-versions"
188
+ fi
189
+ done < <(jq -c '.[]' <<<"$projects_json")
190
+
191
+ # -----------------------------------------------------------------------------
192
+ # 4. Generate .uai/docker-compose.yml (bash). Union preview ports + env keys
193
+ # across the selected projects; one app service. Derived Dockerfile only
194
+ # when a project declares `extra`.
195
+ # -----------------------------------------------------------------------------
196
+
197
+ step "RENDER_FAILED" "generate compose"
198
+
199
+ mkdir -p "$task_uai_dir"
200
+
201
+ # Union of preview ports across projects, de-duped by containerPort
202
+ # (first-in-position wins the name). Validated: name non-empty, 1..65535.
203
+ union_preview_ports_json=$(jq -c '
204
+ [
205
+ .[]
206
+ | (.preview_ports // "[]") as $raw
207
+ | (if ($raw | type) == "string" then (try ($raw | fromjson) catch [])
208
+ elif ($raw | type) == "array" then $raw
209
+ else [] end)
210
+ | .[]?
211
+ | {
212
+ name: (.name // ""),
213
+ containerPort: (((.containerPort // 0) | tonumber?) // 0)
214
+ }
215
+ | select(.name != "" and .containerPort > 0 and .containerPort <= 65535)
216
+ ]
217
+ | reduce .[] as $p ({seen: [], out: []};
218
+ if (.seen | index($p.containerPort)) then .
219
+ else {seen: (.seen + [$p.containerPort]), out: (.out + [$p])} end)
220
+ | .out
221
+ ' <<<"$projects_json")
222
+
223
+ # Union of declared env keys across projects (env JSON is an array of
224
+ # {key,...} objects per ADR-015). De-duped, first occurrence wins.
225
+ union_env_keys_json=$(jq -c '
226
+ [
227
+ .[]
228
+ | (.env // "[]") as $raw
229
+ | (if ($raw | type) == "string" then (try ($raw | fromjson) catch [])
230
+ elif ($raw | type) == "array" then $raw
231
+ else [] end)
232
+ | .[]?
233
+ | (.key // "")
234
+ | select(. != "")
235
+ ]
236
+ | reduce .[] as $k ([]; if (index($k)) then . else . + [$k] end)
237
+ ' <<<"$projects_json")
238
+
239
+ # Concatenated `extra` RUN snippets across projects (in position order).
240
+ extra_block=$(jq -r '
241
+ [ .[] | (.extra // "") | select(. != "" and . != "null") ]
242
+ | join("\n")
243
+ ' <<<"$projects_json")
244
+
245
+ has_derived=0
246
+ if [ -n "$extra_block" ]; then
247
+ has_derived=1
248
+ fi
249
+
250
+ # Write the compose file. No top-level name: (the -p flag sets the project),
251
+ # no container_name:. Env values live host-side (ADR-015): we emit only
252
+ # pass-through references `${KEY:-}`.
253
+ {
254
+ printf 'services:\n'
255
+ printf ' app:\n'
256
+ if [ "$has_derived" = "1" ]; then
257
+ printf ' build:\n'
258
+ printf ' context: .\n'
259
+ printf ' dockerfile: Dockerfile\n'
260
+ printf ' image: %s\n' "$DERIVED_IMAGE"
261
+ else
262
+ printf ' image: %s\n' "$STANDARD_IMAGE"
263
+ fi
264
+ printf ' init: true\n'
265
+ printf ' working_dir: /workspace\n'
266
+ printf ' volumes:\n'
267
+ printf ' - "%s:/workspace"\n' "$task_workspace"
268
+ # Identical-host-path binds so each git worktree's absolute gitdir pointer
269
+ # (-> projects/<id>/repo.git/worktrees/<slug>) resolves INSIDE the container
270
+ # (ADR-014). Without them in-container git sees a dangling .git and every
271
+ # commit fails with "not a git repository". The mirror is bound rw because
272
+ # commits write objects back into the shared object store.
273
+ printf ' - "%s:%s"\n' "$task_workspace" "$task_workspace"
274
+ while IFS= read -r vol_obj; do
275
+ [ -n "$vol_obj" ] || continue
276
+ vol_pid=$(jq -r '.id' <<<"$vol_obj")
277
+ printf ' - "%s/%s/repo.git:%s/%s/repo.git"\n' \
278
+ "$projects_root" "$vol_pid" "$projects_root" "$vol_pid"
279
+ done < <(jq -c '.[]' <<<"$projects_json")
280
+ printf ' - "%s:/opt/asdf-data"\n' "$ASDF_VOLUME"
281
+ printf ' ports:\n'
282
+ printf ' - "127.0.0.1::8080"\n'
283
+ while IFS= read -r cport; do
284
+ [ -n "$cport" ] || continue
285
+ printf ' - "127.0.0.1::%s"\n' "$cport"
286
+ done < <(jq -r '.[].containerPort' <<<"$union_preview_ports_json")
287
+ printf ' environment:\n'
288
+ # Host-resident Claude auth (ADR-021). Headless `claude --print` no longer
289
+ # uses the interactive subscription/keychain path, so it needs a token from
290
+ # `claude setup-token`. Interpolated from the host-agent env at up-time, so
291
+ # the literal token is never written to the compose file on disk. Putting it
292
+ # in the container env (rather than the host's ~/.claude mount, which broke
293
+ # with EROFS on session-env writes) means both the orchestrator's headless
294
+ # claude and the code-server terminal's interactive claude authenticate.
295
+ printf ' %s: "${%s:-}"\n' \
296
+ "CLAUDE_CODE_OAUTH_TOKEN" "CLAUDE_CODE_OAUTH_TOKEN"
297
+ # No GH_TOKEN/GITHUB_TOKEN in the container env (ADR-027). `gh` prefers such
298
+ # an env var over its stored credentials, which blocks the per-user
299
+ # `gh auth login --with-token` the host runs (and would re-attribute every PR
300
+ # to the env token — breaking multi-user). The host's GitHub token reaches
301
+ # `gh` through its config file instead (lib/github-tokens.ts); git rides the
302
+ # per-user SSH identity, not an HTTPS token (uai-init routes remotes over SSH).
303
+ # A project may declare GH_TOKEN/GITHUB_TOKEN as an env key, so skip them here
304
+ # rather than trust callers — this is where the rule is enforced.
305
+ while IFS= read -r ekey; do
306
+ [ -n "$ekey" ] || continue
307
+ case "$ekey" in
308
+ GH_TOKEN | GITHUB_TOKEN) continue ;;
309
+ esac
310
+ printf ' %s: "${%s:-}"\n' "$ekey" "$ekey"
311
+ done < <(jq -r '.[]' <<<"$union_env_keys_json")
312
+ printf 'volumes:\n'
313
+ printf ' %s:\n' "$ASDF_VOLUME"
314
+ printf ' external: true\n'
315
+ } > "$task_uai_dir/docker-compose.yml"
316
+
317
+ if [ "$has_derived" = "1" ]; then
318
+ # Run `extra` as root so apt/system installs work (the standard image ends
319
+ # as USER node), then drop back to node for the running container.
320
+ {
321
+ printf 'FROM %s\n' "$STANDARD_IMAGE"
322
+ printf 'USER root\n'
323
+ printf '%s\n' "$extra_block"
324
+ printf 'USER node\n'
325
+ } > "$task_uai_dir/Dockerfile"
326
+ fi
327
+
328
+ # -----------------------------------------------------------------------------
329
+ # 5. Compose up.
330
+ # -----------------------------------------------------------------------------
331
+
332
+ # The shared asdf cache volume is host-wide and declared external — make
333
+ # sure it exists before compose references it. Idempotent.
334
+ docker volume create "$ASDF_VOLUME" >/dev/null 2>&1 || true
335
+
336
+ step "COMPOSE_UP_FAILED" "docker compose up -d"
337
+ if [ "$has_derived" = "1" ]; then
338
+ docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" up -d --build >/dev/null
339
+ else
340
+ docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" up -d >/dev/null
341
+ fi
342
+
343
+ # -----------------------------------------------------------------------------
344
+ # 6. Container init — copy AI creds in, install deps, launch code-server.
345
+ # -----------------------------------------------------------------------------
346
+
347
+ step "CONTAINER_INIT_FAILED" "uai-init (deps + code-server)"
348
+
349
+ # Copy `.claude.json` into the container instead of bind-mounting it.
350
+ # Single-file bind mounts on Docker Desktop macOS are fragile — claude
351
+ # CLI atomically rewrites the file on every start, which can invalidate
352
+ # the bind mount and leave a stale dentry, after which claude refuses to
353
+ # start. docker cp gives the container its own stable copy. Best-effort.
354
+ if [ -f "$UAI_OWNER_HOME/.claude.json" ]; then
355
+ docker cp "$UAI_OWNER_HOME/.claude.json" \
356
+ "$app_container":/home/node/.claude.json >/dev/null 2>&1 \
357
+ || log "warning: docker cp of .claude.json failed; claude may need re-login"
358
+ docker exec -u root "$app_container" \
359
+ chown node:node /home/node/.claude.json >/dev/null 2>&1 || true
360
+ fi
361
+
362
+ # Copy Codex credentials/config into a task-private /home/node/.codex.
363
+ # Do not mount the host ~/.codex writable: Codex stores live SQLite state
364
+ # there, and concurrent host/container access can corrupt it.
365
+ docker exec -u root "$app_container" \
366
+ mkdir -p /home/node/.codex >/dev/null 2>&1 || true
367
+ for codex_item in auth.json config.toml AGENTS.md version.json installation_id rules; do
368
+ if [ -e "$UAI_OWNER_HOME/.codex/$codex_item" ]; then
369
+ docker cp "$UAI_OWNER_HOME/.codex/$codex_item" \
370
+ "$app_container":/home/node/.codex/ >/dev/null 2>&1 \
371
+ || log "warning: docker cp of .codex/$codex_item failed; codex may need re-login"
372
+ fi
373
+ done
374
+ docker exec -u root "$app_container" \
375
+ chown -R node:node /home/node/.codex >/dev/null 2>&1 || true
376
+
377
+ # Copy the same resolved SSH identity (task creator's per-user key when present,
378
+ # else the operator identity — see above) into the container, so the agent signs
379
+ # + pushes with the key whose .pub the user registered on GitHub. ADR-027 drops
380
+ # the HTTPS/GH_TOKEN path; uai-init routes every GitHub remote over SSH.
381
+ # Best-effort: signing + SSH push just stay off if the identity is absent.
382
+ if [ -f "$uai_identity_key" ]; then
383
+ docker exec -u root "$app_container" \
384
+ mkdir -p /home/node/.ssh >/dev/null 2>&1 || true
385
+ docker cp "$uai_identity_key" \
386
+ "$app_container":/home/node/.ssh/id_ed25519 >/dev/null 2>&1 || true
387
+ docker cp "${uai_identity_key}.pub" \
388
+ "$app_container":/home/node/.ssh/id_ed25519.pub >/dev/null 2>&1 || true
389
+ docker exec -u root "$app_container" sh -c \
390
+ 'chown -R node:node /home/node/.ssh && chmod 700 /home/node/.ssh && chmod 600 /home/node/.ssh/id_ed25519' \
391
+ >/dev/null 2>&1 || true
392
+ fi
393
+
394
+ docker exec "$app_container" /usr/local/bin/uai-init >/dev/null
395
+
396
+ # -----------------------------------------------------------------------------
397
+ # 7. Discover code-server + preview ports, mark running, unlock.
398
+ # -----------------------------------------------------------------------------
399
+
400
+ # code-server: container exposes 8080; Docker auto-maps to a random host
401
+ # port. Discover via `docker port`. Best-effort — if it fails the Editor
402
+ # pane is just unavailable.
403
+ code_server_port=$(mapped_host_port "$app_container" 8080)
404
+ if [ -z "$code_server_port" ]; then
405
+ log "warning: could not discover code-server port for ${app_container} (editor pane unavailable)"
406
+ fi
407
+
408
+ preview_ports_runtime_json="[]"
409
+ if [ "$(jq 'length' <<<"$union_preview_ports_json")" -gt 0 ]; then
410
+ preview_port_lines=""
411
+ while IFS= read -r preview_obj; do
412
+ [ -n "$preview_obj" ] || continue
413
+ preview_name=$(jq -r '.name' <<<"$preview_obj")
414
+ preview_container_port=$(jq -r '.containerPort' <<<"$preview_obj")
415
+ preview_host_port=$(mapped_host_port "$app_container" "$preview_container_port")
416
+ if [ -z "$preview_host_port" ]; then
417
+ log "warning: could not discover preview port ${preview_name}:${preview_container_port} for ${app_container}"
418
+ continue
419
+ fi
420
+ preview_port_lines="${preview_port_lines}$(jq -nc \
421
+ --arg name "$preview_name" \
422
+ --arg hp "$preview_host_port" \
423
+ '{name:$name,hostPort:($hp|tonumber)}')
424
+ "
425
+ done < <(jq -c '.[]' <<<"$union_preview_ports_json")
426
+
427
+ if [ -n "$preview_port_lines" ]; then
428
+ preview_ports_runtime_json=$(printf '%s' "$preview_port_lines" | jq -s -c '.')
429
+ fi
430
+ fi
431
+
432
+ step "DB_UPDATE_FAILED" "mark task running"
433
+ cs_sql="code_server_port=NULL"
434
+ if [ -n "$code_server_port" ]; then
435
+ cs_sql="code_server_port=${code_server_port}"
436
+ fi
437
+ db_update_task "$task_id" \
438
+ "status='running'" \
439
+ "$cs_sql" \
440
+ "preview_ports='$(sql_escape "$preview_ports_runtime_json")'"
441
+
442
+ unlock_task "$task_id"
443
+
444
+ emit_event "$task_id" "status_changed" "$(jq -nc '{from:"starting",to:"running"}')"
445
+
446
+ # NOTE: `codeServerPort` is added only when discovered. Do NOT inline a
447
+ # `select(.!="")` into the object literal — when it filters to empty, jq
448
+ # collapses the *entire* object to empty and emit_ok then defaults to
449
+ # `{}`, producing AGENT_BAD_OUTPUT. Add the key with object-merge instead.
450
+ emit_ok "$(jq -nc \
451
+ --arg cp "$compose_project" \
452
+ --arg wp "$task_dir" \
453
+ --arg csport "${code_server_port:-}" \
454
+ --argjson previewPorts "$preview_ports_runtime_json" \
455
+ '{composeProject:$cp,worktreePath:$wp}
456
+ + (if $csport == "" then {} else {codeServerPort:($csport|tonumber)} end)
457
+ + {previewPorts:$previewPorts}')"