@runuai/host 0.9.160 → 0.9.162

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/lib/agent-cli.ts CHANGED
@@ -183,11 +183,15 @@ export async function writeAgentCliViaEnvironment(
183
183
 
184
184
  /**
185
185
  * Per-agent env for the `docker exec` that spawns one agent (ADR-048): its OWN
186
- * task token (carrying only ITS permissions) + the API url. Empty when the agent
187
- * has no permissions (or no owner/url) — unless `allowPermissionless` is set
188
- * for the designated Secretary, whose dispatch route is role-gated rather than
189
- * permission-gated. That token still carries an empty permission set and
190
- * cannot borrow anyone else's powers.
186
+ * task token (carrying only ITS permissions) + the API url. Every agent gets
187
+ * one, permissions or not: it is the agent's identity — the Uai Managed
188
+ * Engine's gateway authenticates and bills by it (ADR-128), and the
189
+ * Secretary's dispatch is role-gated on it. Until 2026-10-20 an agent with no
190
+ * CLI permissions got none, so such a persona could not run on the managed
191
+ * engine at all. An empty-permission token cannot borrow anyone else's powers:
192
+ * every permissioned route refuses it, and the self-scoped ones
193
+ * (`requireAnyPersonaPermission`) do too. Empty only when the task has no
194
+ * owner, API url or CLI secret.
191
195
  */
192
196
  export function agentCliEnv(
193
197
  taskId: string,
@@ -195,18 +199,10 @@ export function agentCliEnv(
195
199
  ownerUserId: string | null,
196
200
  apiUrl: string | null,
197
201
  cliSecret: string | null,
198
- allowPermissionless = false,
199
202
  ): Record<string, string> {
200
203
  const runtimeAuthority = { ...AGENT_RUNTIME_AUTHORITY_ENV };
201
204
  const permissions = agent.permissions ?? [];
202
- if (
203
- !ownerUserId ||
204
- !apiUrl ||
205
- !cliSecret ||
206
- (permissions.length === 0 && !allowPermissionless)
207
- ) {
208
- return runtimeAuthority;
209
- }
205
+ if (!ownerUserId || !apiUrl || !cliSecret) return runtimeAuthority;
210
206
  return {
211
207
  ...runtimeAuthority,
212
208
  UAI_TASK_TOKEN: signTaskToken(
@@ -498,7 +494,22 @@ async function main() {
498
494
  const port = Number(pos[1] || flags.port);
499
495
  if (!name || !Number.isInteger(port)) { console.error("uai: preview add <name> <containerPort>"); process.exit(1); }
500
496
  const r = await api("POST", "/api/agent/previews", { name, containerPort: port });
501
- out("preview '" + r.name + "' on port " + r.containerPort + " — enable it in the task's preview menu to get a URL");
497
+ out("preview '" + r.name + "' on port " + r.containerPort + " — turn it on with: uai preview enable " + r.name);
498
+ break;
499
+ }
500
+ case "preview enable":
501
+ case "preview disable":
502
+ case "preview public":
503
+ case "preview private": {
504
+ const name = pos[0] || flags.name;
505
+ if (!name) { console.error("uai: preview " + action + " <name>"); process.exit(1); }
506
+ const body = action === "enable" || action === "disable"
507
+ ? { enabled: action === "enable" }
508
+ : { public: action === "public" };
509
+ const r = await api("PATCH", "/api/agent/previews/" + encodeURIComponent(name), body);
510
+ const state = (r.enabled ? "on" : "off") + ", " + (r.public ? "public (anyone with the link)" : "private (sign-in)");
511
+ out("preview '" + r.name + "' is " + state + (r.url ? " — " + r.url : ""));
512
+ if (!r.enabled && r.public) console.error("uai: it's public but off — it serves nothing until: uai preview enable " + r.name);
502
513
  break;
503
514
  }
504
515
  case "project create": {
@@ -551,6 +562,8 @@ async function main() {
551
562
  " uai todo remove <#id>",
552
563
  " uai todo claim|done|reopen|unclaim <#id>",
553
564
  " uai preview add <name> <containerPort>",
565
+ " uai preview enable|disable <name> (turn it on; prints its URL)",
566
+ " uai preview public|private <name> (public: a link anyone can open)",
554
567
  " uai project create --name <n> --repo <git-url> [--prompt <p>]",
555
568
  " uai task search <query> (your own past tasks)",
556
569
  " uai task history (list your past tasks)",
package/lib/agent.ts CHANGED
@@ -157,6 +157,13 @@ const TaskUpData = z.object({
157
157
  export type TaskUpResult = z.infer<typeof TaskUpData>;
158
158
 
159
159
  // task-down
160
+ /** task-down.sh destroys the task's runtime AND its directory (worktree +
161
+ * task-private Git data), so it refuses without this explicit terminal
162
+ * intent. Only the cloud taskDown reaches these two calls: the handler in
163
+ * src/index.ts, and a credential cleanup under that same command
164
+ * (`reason: "teardown"`, task-credential-cleanup.ts). */
165
+ const TASK_DOWN_TERMINAL_ENV = Object.freeze({ UAI_TASK_DOWN_TERMINAL: "1" });
166
+
160
167
  const TaskDownData = z
161
168
  .object({
162
169
  status: z.string().optional(),
@@ -598,6 +605,7 @@ export const agent = {
598
605
  [input.taskId],
599
606
  TaskDownData,
600
607
  commandDbPath,
608
+ TASK_DOWN_TERMINAL_ENV,
601
609
  );
602
610
  } finally {
603
611
  removeCommandDb(commandDbPath);
@@ -617,6 +625,7 @@ export const agent = {
617
625
  [taskId],
618
626
  TaskDownData,
619
627
  commandDbPath,
628
+ TASK_DOWN_TERMINAL_ENV,
620
629
  );
621
630
  } finally {
622
631
  removeCommandDb(commandDbPath);
package/lib/agents/uai.ts CHANGED
@@ -93,11 +93,10 @@ const create: AgentSessionFactory["create"] = async (args): Promise<AgentSession
93
93
  }
94
94
  const taskToken = args.agentEnv?.UAI_TASK_TOKEN;
95
95
  if (!taskToken) {
96
- // agentCliEnv mints no token for a permissionless non-secretary agent;
97
- // the gateway authenticates with exactly that token, so the engine
98
- // needs it. The default crew grants permissions, so this is rare.
96
+ // agentCliEnv mints one for every managed-engine agent, permissions or
97
+ // not; none here means the task has no owner, API url or CLI secret.
99
98
  throw new Error(
100
- "uai engine: this agent has no task token (no CLI permissions) — grant it at least one permission or use a direct engine",
99
+ "uai engine: this agent has no task token (the task has no CLI identity yet) — restart the task",
101
100
  );
102
101
  }
103
102
  return delegate.create({
@@ -1004,6 +1004,11 @@ async function managedInstallState(paths: ManagedRuntimePaths): Promise<ManagedI
1004
1004
  ] as const) {
1005
1005
  try {
1006
1006
  await lstat(state);
1007
+ // ensureManagedDirectories (a capability-floor claim) creates the bare
1008
+ // runtime directory on an npm install too. Empty, it holds no
1009
+ // generation: still no managed install. Every npm-installed task
1010
+ // machine sat pending on this (2026-10-20).
1011
+ if (state === paths.runtime && (await readdir(state)).length === 0) continue;
1007
1012
  return { kind: "damaged", detail: `the managed runtime has a ${what} but no current runtime; repair it first` };
1008
1013
  } catch (error) {
1009
1014
  if (!isMissing(error)) throw error;
@@ -2595,7 +2595,6 @@ export class Orchestrator {
2595
2595
  task.ownerUserId,
2596
2596
  apiUrl,
2597
2597
  cliSecret,
2598
- this.isPromptLevelSecretary(channel, agent.id),
2599
2598
  ),
2600
2599
  task,
2601
2600
  ),
@@ -4371,7 +4370,6 @@ export class Orchestrator {
4371
4370
  task.ownerUserId,
4372
4371
  apiUrl,
4373
4372
  cliSecret,
4374
- this.isPromptLevelSecretary(channel, agent.id),
4375
4373
  ),
4376
4374
  task,
4377
4375
  ),
@@ -5488,7 +5486,6 @@ export class Orchestrator {
5488
5486
  task.ownerUserId,
5489
5487
  apiUrl,
5490
5488
  cliSecret,
5491
- this.isPromptLevelSecretary(channel, agent.id),
5492
5489
  );
5493
5490
  const boundAccountId = channel.accountByAgent.get(agentId);
5494
5491
  const boundAccount = boundAccountId
@@ -5698,7 +5695,6 @@ export class Orchestrator {
5698
5695
  task.ownerUserId,
5699
5696
  apiUrl,
5700
5697
  cliSecret,
5701
- this.isPromptLevelSecretary(channel, agent.id),
5702
5698
  );
5703
5699
  channel.accountByAgent.set(agentId, next.id);
5704
5700
  noteEngineAccountUsed(next.id);
@@ -5908,7 +5904,6 @@ export class Orchestrator {
5908
5904
  task.ownerUserId,
5909
5905
  apiUrl,
5910
5906
  cliSecret,
5911
- this.isPromptLevelSecretary(channel, agent.id),
5912
5907
  );
5913
5908
  channel.accountByAgent.set(agentId, account.id);
5914
5909
  noteEngineAccountUsed(account.id);
@@ -7280,7 +7275,7 @@ export function buildSystemPreamble(
7280
7275
  ? ", `todo list|add|edit|remove|claim|done`"
7281
7276
  : "") +
7282
7277
  (agent.permissions?.includes("previews.write")
7283
- ? ", `preview add <name> <port>`"
7278
+ ? ", `preview add|enable|public <name>`"
7284
7279
  : "") +
7285
7280
  (agent.permissions?.includes("projects.write")
7286
7281
  ? ", `project create --name --repo`"
@@ -7355,8 +7350,10 @@ export function buildSystemPreamble(
7355
7350
  ...(agent.permissions?.includes("previews.write")
7356
7351
  ? [
7357
7352
  "**Previews:** started a dev server? `preview add <name>",
7358
- "<containerPort>` exposes it so the human can open it (they",
7359
- "enable it from the task's preview menu).",
7353
+ "<containerPort>` registers it, then `preview enable <name>` turns",
7354
+ "it on and prints its URL: give that URL to the humans. It needs",
7355
+ "a sign-in; `preview public <name>` makes the task's previews",
7356
+ "a link anyone can open — only when a human asks for that.",
7360
7357
  ]
7361
7358
  : []),
7362
7359
  "",
@@ -22,8 +22,11 @@ import { join } from "node:path";
22
22
  /** Image label carrying the build-context content hash (rebuild trigger). */
23
23
  export const CONTEXT_HASH_LABEL = "com.runuai.context-hash";
24
24
 
25
- /** The public registry repository CI publishes the prebuilt image to. */
26
- export const STANDARD_IMAGE_RELEASE_REF = "ghcr.io/runuai/uai-standard";
25
+ /** The public registry repository CI publishes the prebuilt image to. The
26
+ * GitHub org was renamed runuai → moondeckio on 2026-10-20; GHCR, unlike
27
+ * github.com, does not redirect the old owner (pushes fail "owner not
28
+ * found", pulls 404). */
29
+ export const STANDARD_IMAGE_RELEASE_REF = "ghcr.io/moondeckio/uai-standard";
27
30
 
28
31
  /** Pin file basename; packaged at `agent/images/<basename>` in the payload,
29
32
  * one directory above the build context so it never perturbs the hash. */
@@ -64,7 +64,8 @@ export interface TaskCredentialCleanupDeps {
64
64
  db?: Db;
65
65
  hooks: TaskCredentialCleanupHooks;
66
66
  removePath?: (row: HostCredentialCleanupPath) => Promise<void>;
67
- /** Full cleanup only: destroy one exact runtime and prove it absent. */
67
+ /** Full cleanup under the cloud's taskDown (`reason: "teardown"`) only:
68
+ * destroy one exact runtime and prove it absent. */
68
69
  destroyRuntime?: (request: {
69
70
  taskId: string;
70
71
  runEpoch: number;
@@ -464,9 +465,18 @@ export class TaskCredentialCleanupCoordinator {
464
465
  }
465
466
 
466
467
  // A whole-task cleanup may prove absence by destroying the exact writable
467
- // runtime instead. Selective removal must never take this broader action.
468
+ // runtime instead, but ONLY inside the cloud's taskDown of this run (the
469
+ // durable reason `teardown` has no other source). Every other cleanup —
470
+ // boot recovery, a fence escalation, stop, park, assignment — defers: the
471
+ // gate stays closed, the pending witness and inventory rows stay, and the
472
+ // registry's retry tries the exact paths again later. A failed exec is
473
+ // not proof of absence; Docker briefly gone (an OrbStack update,
474
+ // 2026-10-20) once turned this fallback into task-down.sh on every live
475
+ // task, deleting their worktrees. Selective removal must never take this
476
+ // broader action either.
468
477
  if (
469
478
  !consumersFailed &&
479
+ request.reason === "teardown" &&
470
480
  request.credentialIds === undefined &&
471
481
  failed.length > 0
472
482
  ) {
@@ -1015,7 +1015,8 @@ export function createMachineHostTaskEnvironmentProvider(
1015
1015
  // timeouts bound the wait). A teardown left for later could take a
1016
1016
  // replacement, which reuses the task's machine name.
1017
1017
  const late = await provisioning.catch(() => null);
1018
- if (late) await late.handle.teardown().catch(() => {});
1018
+ // A reused machine holds the task's work: stopped, never destroyed.
1019
+ if (late) await (late.reused ? late.handle.stop() : late.handle.teardown()).catch(() => {});
1019
1020
  }
1020
1021
  throw error;
1021
1022
  }
@@ -1043,7 +1044,10 @@ export function createMachineHostTaskEnvironmentProvider(
1043
1044
  const abandon = (): Promise<void> => {
1044
1045
  over = true;
1045
1046
  abandoning ??= (async () => {
1046
- await handle.teardown().catch(() => {});
1047
+ // Only a machine this start launched is destroyed. One it reused (a
1048
+ // Resume of a stopped machine) holds the task's work on its disk, so
1049
+ // a failed start stops it again and the next Resume tries afresh.
1050
+ await (provisioned.reused ? handle.stop() : handle.teardown()).catch(() => {});
1047
1051
  await joinBounded(begun, deps.abandonJoinMs ?? ABANDON_JOIN_MS);
1048
1052
  })();
1049
1053
  return abandoning;
@@ -862,6 +862,7 @@ export function createMachineTaskEnvironmentProvider(
862
862
  `machine ${machineId} state is unknown: ${existing.detail ?? "no detail"}`,
863
863
  );
864
864
  }
865
+ const reused = existing.state !== "absent";
865
866
  if (existing.state === "absent") {
866
867
  const launched = await deps.machines.launch({
867
868
  taskId,
@@ -935,7 +936,7 @@ export function createMachineTaskEnvironmentProvider(
935
936
  );
936
937
  }
937
938
  }
938
- return { handle, result: handle.descriptor };
939
+ return { handle, result: handle.descriptor, reused };
939
940
  },
940
941
 
941
942
  async reconstruct(
@@ -349,6 +349,10 @@ export interface TaskEnvironmentProvisioned<TResult, TTeardownResult = void> {
349
349
  handle: TaskEnvironmentHandle<TTeardownResult>;
350
350
  /** Provider launch result retained for rolling wire compatibility. */
351
351
  result: TResult;
352
+ /** The provider reused an environment that already existed — a Resume's
353
+ * stopped machine, whose disk holds the task's work — rather than creating
354
+ * it. A failed start stops such an environment; it never destroys it. */
355
+ reused?: boolean;
352
356
  }
353
357
 
354
358
  export interface TaskEnvironmentProvider<
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.160",
3
+ "version": "0.9.162",
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>",
@@ -5,6 +5,13 @@
5
5
  # 2. rm -rf the whole task dir — workspace + task-private Git repositories.
6
6
  # Host caches under projects/<id>/host-cache.git persist.
7
7
  # 3. Persist final state: status=killed (unless already error/shipped).
8
+ #
9
+ # Destruction needs explicit terminal intent: the runner sets
10
+ # UAI_TASK_DOWN_TERMINAL=1 only on the cloud taskDown path (a task close or
11
+ # orphan GC). Without it the script refuses before touching Docker or disk —
12
+ # a stray caller (recovery, a credential-cleanup fallback, an operator
13
+ # running the script by hand) must never delete a live task's worktree
14
+ # (2026-10-20: a cleanup fallback did, while Docker was briefly gone).
8
15
 
9
16
  set -euo pipefail
10
17
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -20,6 +27,12 @@ if [ "${#task_id}" -gt 128 ] ||
20
27
  [[ ! "$task_id" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
21
28
  emit_err "BAD_ARGS" "invalid task id" "task identity validation"
22
29
  fi
30
+ # Before UAI_TASK_ID is set: a refusal must not mark the live task errored.
31
+ if [ "${UAI_TASK_DOWN_TERMINAL:-}" != "1" ]; then
32
+ emit_err "BAD_ARGS" \
33
+ "task-down requires terminal intent (UAI_TASK_DOWN_TERMINAL=1, set only by the cloud taskDown); refusing to destroy task $task_id" \
34
+ "terminal intent"
35
+ fi
23
36
  UAI_TASK_ID="$task_id"
24
37
  setup_err_trap
25
38
  validate_task_runtime_selection
@@ -1453,9 +1453,39 @@ while IFS= read -r project_obj; do
1453
1453
  git_host_control -C "$worktree_repo_dir" worktree repair \
1454
1454
  "$worktree_target" 2>/dev/null || true
1455
1455
  fi
1456
+ elif [ -n "$task_branch" ] && [ "$task_branch" != "null" ] \
1457
+ && [ "$task_branch" != "$project_default" ] \
1458
+ && ! git_host_control -C "$worktree_repo_dir" rev-parse --verify --quiet \
1459
+ "refs/heads/$task_branch" >/dev/null 2>&1 \
1460
+ && git_host_control -C "$worktree_repo_dir" rev-parse --verify --quiet \
1461
+ "refs/remotes/origin/$task_branch^{commit}" >/dev/null 2>&1; then
1462
+ # The worktree is gone but the task's branch is on the remote (a lost
1463
+ # task directory, 2026-10-20: Resume rebuilt from the base, putting the
1464
+ # agents on the wrong code with the pushed commits out of sight). The
1465
+ # fetch above is fresh — a missing worktree makes a failed fetch fatal —
1466
+ # so continue from the pushed branch, never the base.
1467
+ step "WORKTREE_FAILED" "git worktree add from origin/$task_branch ($project_id)"
1468
+ log "worktree for $project_slug missing — restoring it from origin/$task_branch"
1469
+ # The base this task branched from, as nearly as the remote can tell:
1470
+ # where the task branch leaves the default branch.
1471
+ env_schema_oid=$(git_host_control -C "$mirror_dir" merge-base \
1472
+ "refs/remotes/origin/$task_branch" "refs/remotes/origin/$project_default" \
1473
+ 2>/dev/null || true)
1474
+ if [ -n "$env_schema_oid" ]; then
1475
+ record_env_schema_base "$project_id" "oid $env_schema_oid" \
1476
+ || log "warning: could not record the .env.schema base for $project_id; its keys without a binding are withheld"
1477
+ fi
1478
+ # --no-track: the sanitizer above already wrote this branch's upstream.
1479
+ if ! git_host_control -C "$worktree_repo_dir" worktree add --no-track \
1480
+ "$worktree_target" -b "$task_branch" "refs/remotes/origin/$task_branch"; then
1481
+ emit_err "WORKTREE_FAILED" \
1482
+ "could not restore the task worktree from origin/$task_branch for $repo_url" \
1483
+ "git worktree add from origin/$task_branch ($project_id)"
1484
+ fi
1456
1485
  elif git_host_control -C "$worktree_repo_dir" rev-parse --verify --quiet \
1457
1486
  "refs/remotes/origin/$project_default" >/dev/null 2>&1; then
1458
1487
  # Normal case: branch the task worktree off origin/<defaultBranch>.
1488
+ # Only when the remote has no branch of this task's yet.
1459
1489
  step "WORKTREE_FAILED" "git worktree add ($project_id)"
1460
1490
  # The same commit the task repository just copied, read where the task
1461
1491
  # never writes: the host cache, under this project's lock.