@runuai/host 0.9.144 → 0.9.146

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.
@@ -55,6 +55,7 @@ import type {
55
55
  // Sourced from that picker; UPDATE WHEN MODELS CHANGE. Order = display order
56
56
  // in the cloud picker. Legacy models remain reachable via config.toml.
57
57
  const CODEX_MODELS = [
58
+ "gpt-6-astra",
58
59
  "gpt-5.6-sol",
59
60
  "gpt-5.6-terra",
60
61
  "gpt-5.6-luna",
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The warm-pool claim's host half (docs/machine-warm-pool.md): the cloud's
3
+ * `sidecarAssign` names the task this pool machine now serves. The task id
4
+ * is appended to the sidecar unit's EnvironmentFile and the process exits
5
+ * with ASSIGN_RESTART_EXIT_CODE — systemd (`Restart=on-failure`) brings the
6
+ * unit back up in plain sidecar mode, the tested runner-restart path.
7
+ * Rewriting a root-owned file and restarting is deliberately boring: no
8
+ * runtime re-wiring of boot-time gates, no half-pool half-task states.
9
+ */
10
+ import { appendFileSync, readFileSync } from "node:fs";
11
+
12
+ import { sidecarPoolMode } from "./sidecar";
13
+
14
+ /** Anything but 0 and 3: 0 would not restart the unit, 3 is the lease-denied
15
+ * latch (`RestartPreventExitStatus=3`). */
16
+ export const ASSIGN_RESTART_EXIT_CODE = 64;
17
+ const EXIT_DELAY_MS = 500;
18
+
19
+ export function sidecarEnvFile(): string {
20
+ return process.env.UAI_SIDECAR_ENV_FILE ?? "/etc/uai/sidecar.env";
21
+ }
22
+
23
+ export function applySidecarAssign(
24
+ taskId: string,
25
+ deps: {
26
+ envFile?: string;
27
+ exit?: (code: number) => void;
28
+ append?: (file: string, text: string) => void;
29
+ read?: (file: string) => string;
30
+ } = {},
31
+ ): void {
32
+ if (!sidecarPoolMode()) {
33
+ throw new Error("not a pool machine (already assigned, or not a sidecar)");
34
+ }
35
+ if (!/^[a-z0-9]{1,32}$/i.test(taskId)) throw new Error("invalid task id");
36
+ const file = deps.envFile ?? sidecarEnvFile();
37
+ const read = deps.read ?? ((f: string) => readFileSync(f, "utf8"));
38
+ const current = read(file);
39
+ if (/^UAI_SIDECAR_TASK=/m.test(current)) {
40
+ throw new Error("this machine already carries a task assignment");
41
+ }
42
+ const append = deps.append ?? ((f: string, text: string) => appendFileSync(f, text));
43
+ append(file, `UAI_SIDECAR_TASK=${taskId}\n`);
44
+ // Ack first, then die: the delay lets the command reply flush to the
45
+ // bridge before the socket goes with the process.
46
+ const exit = deps.exit ?? ((code: number) => process.exit(code));
47
+ const timer = setTimeout(() => exit(ASSIGN_RESTART_EXIT_CODE), EXIT_DELAY_MS);
48
+ timer.unref?.();
49
+ }
package/lib/sidecar.ts CHANGED
@@ -13,8 +13,19 @@ export function sidecarTaskId(): string | null {
13
13
  return value && /^[a-z0-9]{1,32}$/i.test(value) ? value : null;
14
14
  }
15
15
 
16
+ /**
17
+ * Warm-pool mode (docs/machine-warm-pool.md): a sidecar with no task yet.
18
+ * Same machine gates as sidecar mode, no lease (nothing to scope one to);
19
+ * the process waits for `sidecarAssign` to write `UAI_SIDECAR_TASK` into
20
+ * the unit env and restart it into plain sidecar mode. Once a task id is
21
+ * present, pool mode is over — even before the restart lands.
22
+ */
23
+ export function sidecarPoolMode(): boolean {
24
+ return process.env.UAI_SIDECAR_POOL === "1" && sidecarTaskId() === null;
25
+ }
26
+
16
27
  export function sidecarMode(): boolean {
17
- return sidecarTaskId() !== null;
28
+ return sidecarTaskId() !== null || sidecarPoolMode();
18
29
  }
19
30
 
20
31
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.144",
3
+ "version": "0.9.146",
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>",
@@ -91,11 +91,9 @@ project_env_json=$(printf '%s' "$project_env_json" | jq -c '.')
91
91
 
92
92
  task_dir="$UAI_WORKSPACE_ROOT/tasks/$task_id"
93
93
  task_workspace="$task_dir/workspace"
94
+ # The HOST control tree. Deliberately not bind-mounted: the container sees only
95
+ # $task_workspace. Anything an agent must reach belongs under $task_workspace/.uai.
94
96
  task_uai_dir="$task_dir/.uai"
95
- # Agents are told to save browser screenshots and other artifacts under
96
- # /workspace/.uai/attachments/, but nothing created the directory — every
97
- # first browser_take_screenshot hit ENOENT (found by an agent in the first
98
- # apple-container task, 2026-08-24). Created with the .uai dir below.
99
97
  projects_root="$UAI_WORKSPACE_ROOT/projects"
100
98
  compose_project=$(compose_project_for_task "$task_id")
101
99
  app_container=$(app_container_for_task "$task_id")
@@ -880,14 +878,19 @@ shared_owner_user=$(jq -r '.[0].owner_user_id // ""' <<<"$task_json")
880
878
  shared_owner_org=$(printf '%s' "$shared_owner_org" | tr ':' '_')
881
879
  shared_owner_user=$(printf '%s' "$shared_owner_user" | tr ':' '_')
882
880
  shared_root="$UAI_WORKSPACE_ROOT/shared"
881
+ # The workspace's own .uai is the only one the container can read (/workspace/.uai):
882
+ # the CLI, the chat log and the attachments agents are told to write screenshots
883
+ # into all live here. Create it unconditionally — it used to be made only when
884
+ # shared files were on, and `attachments` was made in the unmounted control tree,
885
+ # so every first browser_take_screenshot hit ENOENT (first seen 2026-08-24).
886
+ reject_unsafe_host_directory "$task_workspace/.uai" \
887
+ "the task workspace marker directory"
888
+ mkdir -p "$task_workspace/.uai/attachments"
883
889
  if [ "$shared_files_mode" != "off" ]; then
884
890
  [ -n "$shared_owner_org" ] && mkdir -p "$shared_root/orgs/$shared_owner_org"
885
891
  [ -n "$shared_owner_user" ] && mkdir -p "$shared_root/users/$shared_owner_user"
886
892
  # Marker for the preamble: only containers that actually carry the mounts
887
893
  # get the "## Shared files" briefing (pre-feature containers don't).
888
- reject_unsafe_host_directory "$task_workspace/.uai" \
889
- "the task workspace marker directory"
890
- mkdir -p "$task_workspace/.uai"
891
894
  : > "$task_workspace/.uai/files-mounted"
892
895
  fi
893
896
 
@@ -1328,7 +1331,7 @@ done < <(jq -c '.[]' <<<"$projects_json")
1328
1331
  # uid-501 macOS host. The sanitizer above repairs its rewritten control files
1329
1332
  # on every later resume.
1330
1333
  step "WORKTREE_FAILED" "share task files with container"
1331
- mkdir -p "$task_uai_dir" "$task_uai_dir/attachments"
1334
+ mkdir -p "$task_uai_dir"
1332
1335
  # The shared group may be a broad host group (for example macOS `staff`). Keep
1333
1336
  # its writable leaves behind host-only parents; Docker bind-mounts the exact
1334
1337
  # workspace/repository roots, so node does not need host-path traversal here.
@@ -1376,7 +1379,7 @@ fi
1376
1379
 
1377
1380
  step "RENDER_FAILED" "generate compose"
1378
1381
 
1379
- mkdir -p "$task_uai_dir" "$task_uai_dir/attachments"
1382
+ mkdir -p "$task_uai_dir"
1380
1383
 
1381
1384
  STANDARD_CONTAINER_DIR="${UAI_STANDARD_CONTAINER_ASSETS_DIR:-$SCRIPT_DIR/../../images/standard/container}"
1382
1385
  if ! STANDARD_CONTAINER_DIR=$(cd "$STANDARD_CONTAINER_DIR" && pwd -P); then
package/src/index.ts CHANGED
@@ -1459,6 +1459,17 @@ export const hostCommands: HostCommands = {
1459
1459
  }
1460
1460
  },
1461
1461
 
1462
+ async sidecarAssign(ctx, input) {
1463
+ logCommand(ctx, "sidecarAssign", input.taskId);
1464
+ try {
1465
+ const { applySidecarAssign } = await import("../lib/sidecar-assign");
1466
+ applySidecarAssign(input.taskId);
1467
+ return ok(undefined);
1468
+ } catch (err) {
1469
+ return failFromUnknown(err);
1470
+ }
1471
+ },
1472
+
1462
1473
  async channelInterrupt(ctx, taskId, agentId) {
1463
1474
  logCommand(ctx, "channelInterrupt", taskId, agentId);
1464
1475
  try {
package/src/main.ts CHANGED
@@ -257,7 +257,7 @@ import {
257
257
  } from "./protocol";
258
258
  import { isEnvironmentTransportProvider } from "../lib/task-environment/types";
259
259
  import { LeaseManager } from "../lib/lease";
260
- import { markSidecarReady, sidecarMode, sidecarTaskId } from "../lib/sidecar";
260
+ import { markSidecarReady, sidecarMode, sidecarPoolMode, sidecarTaskId } from "../lib/sidecar";
261
261
  import {
262
262
  purgeDefaultEngineSlots,
263
263
  SIGN_IN_BOX_TASK_REFUSAL,
@@ -638,7 +638,9 @@ if (signInBoxMode()) {
638
638
  * reconciles and the S3 file mounts run. A denial means the task is no
639
639
  * longer ours to run — exit 3 and let the cloud's sweep reclaim the machine.
640
640
  */
641
- const leaseManager: LeaseManager | null = sidecarMode()
641
+ // A POOL-mode sidecar requests no lease: it has no task to scope one to,
642
+ // and the cloud would refuse it anyway (docs/machine-warm-pool.md).
643
+ const leaseManager: LeaseManager | null = sidecarTaskId()
642
644
  ? new LeaseManager({
643
645
  taskId: sidecarTaskId()!,
644
646
  send: (frame) => {
@@ -931,7 +933,11 @@ function buildCapabilities(): HostCapabilities {
931
933
  },
932
934
  }
933
935
  : {}),
934
- ...(sidecarTaskId() ? { sidecar: { taskId: sidecarTaskId()! } } : {}),
936
+ ...(sidecarTaskId()
937
+ ? { sidecar: { taskId: sidecarTaskId()! } }
938
+ : sidecarPoolMode()
939
+ ? { sidecar: { pool: true } }
940
+ : {}),
935
941
  maintenanceReady: areAgentClisReady(),
936
942
  mcpGateway: mcpGateway.state(),
937
943
  engineLogins: engineLoginManager.capabilities(),
@@ -2777,6 +2783,8 @@ async function dispatchCommand(
2777
2783
  );
2778
2784
  case "filesOp":
2779
2785
  return hostCommands.filesOp(ctx, expectFilesOpInput(args, 0));
2786
+ case "sidecarAssign":
2787
+ return hostCommands.sidecarAssign(ctx, expectSidecarAssignInput(args, 0));
2780
2788
  case "channelInterrupt":
2781
2789
  return hostCommands.channelInterrupt(
2782
2790
  ctx,
@@ -2910,6 +2918,26 @@ function expectEngineAccountRemoveInput(
2910
2918
  };
2911
2919
  }
2912
2920
 
2921
+ function expectSidecarAssignInput(
2922
+ args: unknown[],
2923
+ index: number,
2924
+ ): { hostId: string; taskId: string } {
2925
+ const value = args[index];
2926
+ const hostId = (value as { hostId?: unknown } | null)?.hostId;
2927
+ const taskId = (value as { taskId?: unknown } | null)?.taskId;
2928
+ if (
2929
+ !value ||
2930
+ typeof value !== "object" ||
2931
+ typeof hostId !== "string" ||
2932
+ hostId.length === 0 ||
2933
+ typeof taskId !== "string" ||
2934
+ !/^[a-z0-9]{1,32}$/i.test(taskId)
2935
+ ) {
2936
+ throw new Error(`invalid command args: expected sidecar assignment at ${index}`);
2937
+ }
2938
+ return { hostId, taskId };
2939
+ }
2940
+
2913
2941
  function expectNumberArg(args: unknown[], index: number): number {
2914
2942
  const value = args[index];
2915
2943
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -3505,6 +3533,7 @@ const HOST_COMMAND_NAMES = {
3505
3533
  attachmentWrite: true,
3506
3534
  attachmentRead: true,
3507
3535
  filesOp: true,
3536
+ sidecarAssign: true,
3508
3537
  channelInterrupt: true,
3509
3538
  appendTranscript: true,
3510
3539
  previewEnsure: true,
package/src/protocol.ts CHANGED
@@ -1075,8 +1075,10 @@ export interface HostCapabilities {
1075
1075
  * provider/region on the host page and, eventually, per-task backing
1076
1076
  * choice keys off it. */
1077
1077
  /** ADR-133 sidecar mode: this host IS the task's machine — exactly one
1078
- * task, no container runtime, tunnels on loopback. */
1079
- sidecar?: { taskId: string };
1078
+ * task, no container runtime, tunnels on loopback. A warm-pool machine
1079
+ * (docs/machine-warm-pool.md) advertises `pool` instead of a task until
1080
+ * `sidecarAssign` restarts it into task mode. */
1081
+ sidecar?: { taskId?: string; pool?: boolean };
1080
1082
  machineTasks?: {
1081
1083
  provider: string;
1082
1084
  region?: string;
@@ -1550,6 +1552,14 @@ export interface HostCommands {
1550
1552
  ctx: CommandContext,
1551
1553
  input: FilesOpInput,
1552
1554
  ): Promise<HostCommandResult<FilesOpValue>>;
1555
+ /** Warm pool claim (docs/machine-warm-pool.md): tell a POOL-mode sidecar
1556
+ * which task it now serves. The sidecar writes `UAI_SIDECAR_TASK` into
1557
+ * its unit env, acks, and restarts into plain sidecar mode; a runner not
1558
+ * in pool mode refuses. */
1559
+ sidecarAssign(
1560
+ ctx: CommandContext,
1561
+ input: { hostId: string; taskId: string },
1562
+ ): Promise<HostCommandResult<void>>;
1553
1563
  channelInterrupt(
1554
1564
  ctx: CommandContext,
1555
1565
  taskId: string,