@runuai/host 0.9.140 → 0.9.142

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.140",
3
+ "version": "0.9.142",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
@@ -141,6 +141,42 @@ reject_workspace_runtime_shadow() {
141
141
  fi
142
142
  }
143
143
 
144
+ # The first check of a task-up used to be the refusal above. A fresh task
145
+ # never trips it (the workspace is empty), but a RESUMED one can: the
146
+ # workspace persists across an idle park, agents' shells start in /workspace,
147
+ # and `asdf local nodejs 22` from there writes exactly this file — after
148
+ # which the task could never wake again without someone deleting the file on
149
+ # the host (client runner, 2026-09-14). The entry is the task's own
150
+ # container-writable content, so move it aside instead: a rename is
151
+ # metadata-only (never opens a FIFO/device/socket), the runtime proof is
152
+ # unchanged because nothing is left at the root, and the content survives
153
+ # under .uai/refused/ where the agent can re-pin it in a project. The
154
+ # post-materialization recheck below keeps refusing — by then the root must
155
+ # be empty by construction, so an entry there is a bug, not a user file.
156
+ quarantine_workspace_runtime_shadow() {
157
+ local path="$task_workspace/.tool-versions" aside_dir aside
158
+ if [ -L "$path" ] || [ -e "$path" ]; then
159
+ aside_dir="$task_workspace/.uai/refused"
160
+ reject_unsafe_host_directory "$task_workspace/.uai" "the task control directory"
161
+ reject_unsafe_host_directory "$aside_dir" "the refused-files directory"
162
+ mkdir -p "$aside_dir"
163
+ aside="$aside_dir/tool-versions.$(date +%s)"
164
+ if ! mv -f "$path" "$aside" 2>/dev/null || [ -L "$path" ] || [ -e "$path" ]; then
165
+ reject_workspace_runtime_shadow
166
+ fi
167
+ cat > "$aside_dir/README.txt" <<'NOTE'
168
+ Uai moved a /workspace/.tool-versions aside before starting this task.
169
+
170
+ A .tool-versions at the workspace root is outside the runtime proof and would
171
+ override the runtimes proved for every project in the task, so the host refuses
172
+ to start a container while one exists. The file is kept here, untouched, under
173
+ its original content. Pin runtimes in a project's own .tool-versions instead
174
+ (/workspace/<project>/.tool-versions), which the materializer proves.
175
+ NOTE
176
+ log "moved a workspace-root .tool-versions aside to ${aside#"$task_workspace"/} (it would override the runtimes proved for this task; pin per project instead)"
177
+ fi
178
+ }
179
+
144
180
  mapped_host_port() {
145
181
  local container="$1" container_port="$2"
146
182
  docker port "$container" "$container_port" 2>/dev/null \
@@ -555,7 +591,7 @@ else
555
591
  fi
556
592
  reject_unsafe_host_directory "$task_workspace" "the task workspace"
557
593
  mkdir -p "$task_workspace"
558
- reject_workspace_runtime_shadow
594
+ quarantine_workspace_runtime_shadow
559
595
 
560
596
  # Git resolves symlinked ancestors when it writes a linked worktree's absolute
561
597
  # `.git` pointer. Keep the configured (lexical) task paths as Uai's stable host
package/src/main.ts CHANGED
@@ -813,8 +813,13 @@ void connect();
813
813
  // Local browser UI (ADR-028) — same single process, alongside the WSS client.
814
814
  // Best-effort: a UI bind failure must not take the host service down.
815
815
  // A task machine has no operator sitting at it (ADR-133): no local UI, no
816
- // Mac vnode watch.
817
- if (!sidecarMode() && !signInBoxMode()) void startLocalUi();
816
+ // Mac vnode watch. The sign-in box (ADR-136) has no operator either, but it
817
+ // keeps a monitor-only status listener: `uai-host status` reads it, and so
818
+ // does the managed installer's post-start check — without it the box read
819
+ // as "service not running" and the first managed install rolled itself back
820
+ // (live 2026-09-14), leaving the box on an npm install that no auto-update
821
+ // reaches.
822
+ if (!sidecarMode()) void startLocalUi();
818
823
  // ADR-118 follow-up: vnode-pressure observability (macOS/apple-container
819
824
  // hosts). Logs only on band transitions; the sensor caches its sysctl read.
820
825
  if (!sidecarMode() && !signInBoxMode()) {
@@ -834,6 +839,7 @@ async function startLocalUi(): Promise<void> {
834
839
  cloudUrl: bridgeUrl,
835
840
  hostId,
836
841
  logPath: serviceLogPath(),
842
+ monitorOnly: signInBoxMode(),
837
843
  taskMemory: async (composeProject) =>
838
844
  containerRuntimeProblem()
839
845
  ? null
@@ -865,7 +871,11 @@ async function startLocalUi(): Promise<void> {
865
871
  }
866
872
  },
867
873
  });
868
- console.log(`[host-agent] local UI on http://127.0.0.1:${handle.port}`);
874
+ console.log(
875
+ signInBoxMode()
876
+ ? `[host-agent] local status endpoint on http://127.0.0.1:${handle.port} (monitor only)`
877
+ : `[host-agent] local UI on http://127.0.0.1:${handle.port}`,
878
+ );
869
879
  } catch (err) {
870
880
  console.warn(
871
881
  `[host-agent] local UI failed to start: ${err instanceof Error ? err.message : String(err)}`,
package/src/ui/server.ts CHANGED
@@ -69,6 +69,15 @@ export interface UiServerOptions {
69
69
  uiDir: string;
70
70
  /** File to persist the chosen port to (`$UAI_DATA_DIR/host.port`). */
71
71
  portFilePath: string;
72
+ /**
73
+ * ADR-136 sign-in box: answer only the read-only status trio
74
+ * (`/api/status`, `/api/cloud`, `/api/tasks`) so `uai-host status` — and
75
+ * the managed installer's post-start check, which parses it — can see the
76
+ * box come up and connect. Every write and every other read (engines,
77
+ * users, events, the static UI) is 404: the box holds no credential at
78
+ * rest and runs no tasks, and nobody sits at it.
79
+ */
80
+ monitorOnly?: boolean;
72
81
  /** First port to try (5876 or UAI_HOST_UI_PORT). 0 lets the OS pick (tests). */
73
82
  startPort: number;
74
83
  // Facts for /api/status:
@@ -110,6 +119,13 @@ export interface UiServerHandle {
110
119
  close: () => Promise<void>;
111
120
  }
112
121
 
122
+ /** The GET routes a `monitorOnly` server still answers (see UiServerOptions). */
123
+ const MONITOR_ONLY_ROUTES: ReadonlySet<string> = new Set([
124
+ "/api/status",
125
+ "/api/cloud",
126
+ "/api/tasks",
127
+ ]);
128
+
113
129
  /** Start the local UI server, binding to 127.0.0.1 with port fallback. */
114
130
  export async function startUiServer(
115
131
  opts: UiServerOptions,
@@ -170,6 +186,9 @@ async function handle(
170
186
  const path = (req.url ?? "/").split("?")[0] ?? "/";
171
187
  const method = req.method ?? "GET";
172
188
  try {
189
+ if (opts.monitorOnly && !MONITOR_ONLY_ROUTES.has(path)) {
190
+ return sendError(res, 404, `no such endpoint: ${path}`);
191
+ }
173
192
  // POST — engine connect/disconnect (the only writes; localhost-bound, so
174
193
  // the loopback binding is the perimeter, ADR-028).
175
194
  if (method === "POST") {