@runuai/host 0.9.2 → 0.9.4

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.
@@ -207,14 +207,26 @@ fi
207
207
  editor_root="$WORKSPACE"
208
208
  [ -d "$editor_root" ] || editor_root="/home/node"
209
209
 
210
- # Seed the curated code-server defaults (slim chrome, telemetry off, trust
211
- # off) into the User settings dir when the task hasn't written its own yet.
212
- cs_user_dir="$HOME/.local/share/code-server/User"
210
+ # Keep code-server on Uai's managed data path even when a project declares
211
+ # XDG_DATA_HOME. Otherwise code-server starts with a fresh profile elsewhere,
212
+ # which restores the light theme and Workspace Trust prompt.
213
+ cs_data_dir="$HOME/.local/share/code-server"
214
+ cs_user_dir="$cs_data_dir/User"
213
215
  cs_seed="/usr/local/share/uai/code-server-settings.json"
214
- if [ -f "$cs_seed" ] && [ ! -f "$cs_user_dir/settings.json" ]; then
215
- mkdir -p "$cs_user_dir"
216
- cp "$cs_seed" "$cs_user_dir/settings.json"
217
- log "seeded code-server settings"
216
+ # The source-tree sibling keeps direct smoke tests and development runs on the
217
+ # same seed as the packaged image; the image always uses the root-owned path.
218
+ cs_source_seed="$(dirname "${BASH_SOURCE[0]}")/code-server-settings.json"
219
+ if [ ! -f "$cs_seed" ] && [ -f "$cs_source_seed" ]; then
220
+ cs_seed="$cs_source_seed"
221
+ fi
222
+ if [ ! -f "$cs_user_dir/settings.json" ]; then
223
+ if [ ! -f "$cs_seed" ]; then
224
+ log "warning: code-server settings seed missing at $cs_seed"
225
+ elif mkdir -p "$cs_user_dir" && cp "$cs_seed" "$cs_user_dir/settings.json"; then
226
+ log "seeded code-server settings"
227
+ else
228
+ log "warning: could not seed code-server settings at $cs_user_dir/settings.json"
229
+ fi
218
230
  fi
219
231
 
220
232
  if pgrep -f 'code-server.*--bind-addr' >/dev/null 2>&1; then
@@ -222,6 +234,8 @@ if pgrep -f 'code-server.*--bind-addr' >/dev/null 2>&1; then
222
234
  else
223
235
  log "launching code-server on 0.0.0.0:8080"
224
236
  nohup code-server \
237
+ --user-data-dir "$cs_data_dir" \
238
+ --disable-workspace-trust \
225
239
  --auth none \
226
240
  --disable-telemetry \
227
241
  --bind-addr 0.0.0.0:8080 \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -89,6 +89,31 @@ task_json=$(db_get_task "$task_id")
89
89
 
90
90
  task_branch=$(jq -r '.[0].branch' <<<"$task_json")
91
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
+
92
117
  step "PROJECT_NOT_FOUND" "read task projects"
93
118
  projects_json=$(db_get_projects_for_task "$task_id")
94
119
  project_count=$(jq 'length' <<<"$projects_json")
@@ -280,6 +305,55 @@ assert_bare_repo_tree_safe() {
280
305
  fi
281
306
  }
282
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
+
283
357
  sanitize_mirror_control_plane() {
284
358
  local mirror="$1" origin="$2" config_tmp
285
359
  assert_bare_repo_tree_safe "$mirror" "FETCH_FAILED" "host repository cache"
@@ -328,6 +402,7 @@ sanitize_task_repo_control_plane() {
328
402
  git_host_control config --file "$config_tmp" core.repositoryformatversion 0
329
403
  git_host_control config --file "$config_tmp" core.filemode true
330
404
  git_host_control config --file "$config_tmp" core.bare "$bare"
405
+ git_host_control config --file "$config_tmp" core.sharedRepository group
331
406
  git_host_control config --file "$config_tmp" remote.origin.url "$origin"
332
407
  git_host_control config --file "$config_tmp" remote.origin.fetch \
333
408
  '+refs/heads/*:refs/remotes/origin/*'
@@ -340,6 +415,13 @@ sanitize_task_repo_control_plane() {
340
415
  mv -f "$config_tmp" "$repo/config"
341
416
  rm -rf "$repo/hooks"
342
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"
343
425
  }
344
426
 
345
427
  # ADR-062 shared files: two host-resident roots mounted into the container
@@ -758,6 +840,8 @@ while IFS= read -r project_obj; do
758
840
  || { git_host_control -C "$worktree_target" init -q \
759
841
  && git_host_control -C "$worktree_target" symbolic-ref HEAD "refs/heads/$project_default"; }
760
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
761
845
  printf '%s\n' \
762
846
  "# Empty repository" \
763
847
  "" \
@@ -785,10 +869,59 @@ while IFS= read -r project_obj; do
785
869
  && [ -n "$tool_versions" ] && [ "$tool_versions" != "null" ]; then
786
870
  printf '%s\n' "$tool_versions" > "$worktree_target/.tool-versions"
787
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
788
876
 
789
877
  release_project_lock
790
878
  done < <(jq -c '.[]' <<<"$projects_json")
791
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
+
792
925
  if [ -z "$github_credential_socket" ] && [ "$uai_used_ssh_transport" = "1" ]; then
793
926
  uai_git_transport="ssh"
794
927
  fi
@@ -872,6 +1005,11 @@ fi
872
1005
  printf ' pull_policy: never\n'
873
1006
  fi
874
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"
875
1013
  printf ' working_dir: /workspace\n'
876
1014
  printf ' volumes:\n'
877
1015
  printf ' - "%s:/workspace"\n' "$task_workspace"
@@ -953,7 +1091,7 @@ fi
953
1091
  GIT_CONFIG* | GIT_DIR | GIT_WORK_TREE | GIT_COMMON_DIR | \
954
1092
  GIT_OBJECT_DIRECTORY | GIT_ALTERNATE_OBJECT_DIRECTORIES | \
955
1093
  GIT_INDEX_FILE | GIT_EXEC_PATH | GIT_ASKPASS | SSH_ASKPASS | \
956
- GIT_SSH | GIT_SSH_COMMAND | HOME | XDG_CONFIG_HOME | \
1094
+ GIT_SSH | GIT_SSH_COMMAND | HOME | XDG_CONFIG_HOME | XDG_DATA_HOME | \
957
1095
  BASH_ENV | ENV | UAI_GIT_TRANSPORT | \
958
1096
  CLAUDE_CODE_OAUTH_TOKEN | ANTHROPIC_API_KEY | \
959
1097
  ANTHROPIC_AUTH_TOKEN | XAI_API_KEY | PLAYWRIGHT_BROWSERS_PATH) continue ;;
@@ -1042,6 +1180,7 @@ compose_env_override=$(printf '%s' "$project_env_json" | jq -c \
1042
1180
  .GIT_SSH_COMMAND,
1043
1181
  .HOME,
1044
1182
  .XDG_CONFIG_HOME,
1183
+ .XDG_DATA_HOME,
1045
1184
  .BASH_ENV,
1046
1185
  .ENV,
1047
1186
  .UAI_GIT_TRANSPORT,
@@ -1078,6 +1217,7 @@ managed_exec_env=(
1078
1217
  -e "GH_CONFIG_DIR=$managed_gh_config_dir"
1079
1218
  -e "PATH=/usr/bin:/bin"
1080
1219
  -e "XDG_CONFIG_HOME="
1220
+ -e "XDG_DATA_HOME="
1081
1221
  -e "LD_PRELOAD="
1082
1222
  -e "LD_LIBRARY_PATH="
1083
1223
  -e "DYLD_INSERT_LIBRARIES="
@@ -1104,6 +1244,36 @@ managed_clean_env=(
1104
1244
  "PATH=/usr/bin:/bin"
1105
1245
  )
1106
1246
 
1247
+ # Git rejects a repository owned by another uid even when its shared group is
1248
+ # writable. Trust only the selected worktrees in this task container—never `*`
1249
+ # and never the host cache. The task-private ~/.gitconfig makes these entries
1250
+ # local to this container, and the read-before-add keeps resumes idempotent.
1251
+ step "CONTAINER_INIT_FAILED" "trust task Git worktrees"
1252
+ container_safe_dirs=$(docker exec -u node "${managed_exec_env[@]}" \
1253
+ "$app_container" "${managed_clean_env[@]}" /usr/bin/git config --global \
1254
+ --get-all safe.directory 2>/dev/null || true)
1255
+ while IFS= read -r safe_project; do
1256
+ [ -n "$safe_project" ] || continue
1257
+ safe_project_id=$(jq -r '.id' <<<"$safe_project")
1258
+ safe_project_slug=$(jq -r '.slug' <<<"$safe_project")
1259
+ for safe_worktree in \
1260
+ "/workspace/$safe_project_slug" \
1261
+ "$task_workspace/$safe_project_slug" \
1262
+ "$task_uai_dir/repos/$safe_project_id.git"; do
1263
+ if ! printf '%s\n' "$container_safe_dirs" \
1264
+ | grep -Fqx -- "$safe_worktree"; then
1265
+ if ! docker exec -u node "${managed_exec_env[@]}" "$app_container" \
1266
+ "${managed_clean_env[@]}" /usr/bin/git config --global --add \
1267
+ safe.directory "$safe_worktree" >/dev/null; then
1268
+ emit_err "CONTAINER_INIT_FAILED" \
1269
+ "Uai could not mark $safe_worktree as this task's trusted Git path" \
1270
+ "trust task Git worktrees"
1271
+ fi
1272
+ container_safe_dirs="${container_safe_dirs}"$'\n'"${safe_worktree}"
1273
+ fi
1274
+ done
1275
+ done < <(jq -c '.[]' <<<"$projects_json")
1276
+
1107
1277
  # A failed/retried start can preserve a container that still holds a previous
1108
1278
  # account. Erase that Uai-managed state first for both connected and
1109
1279
  # disconnected starts; otherwise a no-credential resume could briefly reuse a