@runuai/host 0.9.13 → 0.9.42

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 (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +69 -4
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3070 -223
  53. package/lib/preview-sidecar.ts +57 -13
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +945 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +443 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1463 -109
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +871 -50
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
package/src/cli.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  * `uai-host` CLI (ADR-028). Hand-rolled argv switch — no argparser dependency.
4
4
  *
5
5
  * run | install | uninstall | start | stop | restart
6
- * status | logs [--follow] | pair <token> | open
6
+ * status | logs [--follow] | runtime recheck
7
+ * enroll <token> | pair <token> | open
7
8
  *
8
9
  * `run` boots the service in-process; the install/start/... family dispatches
9
10
  * to the per-OS installer (scripts/install/{darwin,linux,win}.ts) by
@@ -21,18 +22,88 @@ import "./load-env";
21
22
 
22
23
  import { spawn, spawnSync } from "node:child_process";
23
24
  import { createHash, randomBytes } from "node:crypto";
24
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
- import { hostname } from "node:os";
26
- import { dirname } from "node:path";
25
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
26
+ import { homedir, hostname } from "node:os";
27
+ import { dirname, join, resolve } from "node:path";
27
28
 
29
+ import { parse as parseDotenv } from "dotenv";
28
30
  import { ulid } from "ulid";
29
31
 
32
+ import {
33
+ commitPendingEnrollment,
34
+ discardPendingEnrollment,
35
+ enrollmentTokenSha256,
36
+ ensurePendingEnrollment,
37
+ pendingEnrollmentMatches,
38
+ pendingEnrollmentPath,
39
+ removeCommittedPendingEnrollment,
40
+ writeEnvValuesAtomic,
41
+ type PendingEnrollment,
42
+ } from "../lib/enrollment-state";
43
+ import { parseHostCloudEndpoint } from "../lib/host-cloud-url";
44
+ import { env } from "../lib/env";
30
45
  import {
31
46
  persistTelemetryDisclosure,
32
47
  telemetryNoticeIfActive,
33
48
  } from "../lib/obs";
34
- import { envLocalPath, serviceLogPath, uaiHome, uiPortFilePath } from "./paths";
35
- import { CloudResponse, StatusResponse, TasksResponse } from "./ui/types";
49
+ import {
50
+ abortManagedRuntimeInstall,
51
+ acknowledgeManagedRuntimeActivation,
52
+ beginManagedRuntimeInstall,
53
+ completeManagedRuntimeInstall,
54
+ installManagedRuntimeArchive,
55
+ managedRuntimeInstallStartupState,
56
+ managedRuntimePaths,
57
+ nativeReleasePlatform,
58
+ reconcileManagedRuntimeActivation,
59
+ reconcileManagedRuntimeInstallLifecycle,
60
+ reconcileManagedRuntimeUninstall,
61
+ removeManagedRuntime,
62
+ resolveBundledRuntimeFromEnvironment,
63
+ rollbackManagedRuntime,
64
+ runManagedRuntimeInstallIntentMutation,
65
+ runManagedRuntimeInstallLifecycleMutation,
66
+ stopManagedRuntimeServiceForInstall,
67
+ updateManagedRuntime,
68
+ validateManagedRuntimeSelection,
69
+ validateProvisionalManagedRuntimeSelection,
70
+ MANAGED_UPDATE_RESTART_EXIT_CODE,
71
+ type ManagedServiceDefinitionState,
72
+ type ManagedServiceManagerState,
73
+ type ReconcileManagedRuntimeInstallLifecycleOptions,
74
+ } from "../lib/managed-runtime";
75
+ import { teardownAppleRuntimeStateForUninstall } from "../lib/apple-uninstall-teardown";
76
+ import {
77
+ readRuntimeProviderState,
78
+ runtimeProviderStatePath,
79
+ } from "../lib/runtime-provider-state";
80
+ import { ManagedActivationWatcher } from "../lib/managed-activation-watcher";
81
+ import { ManagedInstallOwnerWatcher } from "../lib/managed-install-owner-watcher";
82
+ import { ManagedUpdateScheduler } from "../lib/managed-update-scheduler";
83
+ import { PINNED_RELEASE_MANIFEST_KEYS } from "../lib/release-trust";
84
+ import {
85
+ hasActiveHostTasks,
86
+ hasInFlightHostTaskLifecycle,
87
+ hasNonTerminalHostTasks,
88
+ } from "../lib/runtime-state";
89
+ import {
90
+ ENROLLMENT_REPLAY_PROTOCOL_HEADER,
91
+ ENROLLMENT_REPLAY_PROTOCOL_VERSION,
92
+ } from "./protocol";
93
+ import {
94
+ envLocalPath,
95
+ packageVersion,
96
+ readPersistedHostId,
97
+ serviceLogPath,
98
+ uaiHome,
99
+ uiPortFilePath,
100
+ } from "./paths";
101
+ import {
102
+ CloudResponse,
103
+ ContainerRuntimeResponse,
104
+ StatusResponse,
105
+ TasksResponse,
106
+ } from "./ui/types";
36
107
  import type { InstallContext, Installer } from "../scripts/install/types";
37
108
 
38
109
  // --- tiny ANSI (no chalk) ---------------------------------------------------
@@ -64,17 +135,28 @@ async function main(): Promise<void> {
64
135
  return cmdOpen();
65
136
  case "logs":
66
137
  return cmdLogs(follow);
138
+ case "runtime":
139
+ return cmdRuntime(rest);
140
+ case "update":
141
+ return cmdUpdate(rest);
142
+ case "rollback":
143
+ return cmdRollback(rest);
144
+ case "enrollment":
145
+ return cmdEnrollmentStatus(rest);
67
146
  case "setup":
68
147
  return cmdSetup(rest);
148
+ case "enroll":
149
+ return cmdEnroll(rest);
69
150
  case "pair":
70
151
  return cmdPair(rest[0]);
71
152
  case "install":
72
- return cmdInstall(dryRun);
73
- case "uninstall":
153
+ return cmdInstall(rest);
74
154
  case "start":
75
155
  case "stop":
76
156
  case "restart":
77
157
  return cmdInstaller(cmd, dryRun);
158
+ case "uninstall":
159
+ return cmdUninstall(rest);
78
160
  case undefined:
79
161
  case "help":
80
162
  case "-h":
@@ -89,10 +171,202 @@ async function main(): Promise<void> {
89
171
 
90
172
  // --- run --------------------------------------------------------------------
91
173
 
174
+ function timestampServiceLogs(): void {
175
+ const stamp = (): string => new Date().toISOString().slice(0, 19);
176
+ for (const level of ["log", "warn", "error"] as const) {
177
+ const original = console[level].bind(console);
178
+ console[level] = (...args: unknown[]) => original(`[${stamp()}]`, ...args);
179
+ }
180
+ }
181
+
92
182
  async function cmdRun(): Promise<void> {
183
+ // Timestamped service logs: the ADR-106 live smoke burned an hour dating
184
+ // undated scrollback. Applies to the service process only, not one-shot CLI
185
+ // commands.
186
+ timestampServiceLogs();
187
+ let installState = await managedRuntimeInstallStartupState();
188
+ if (installState === "attested") {
189
+ // Attestation is the durable commit decision. A supervisor restart after
190
+ // that point finishes the service/command journals; it must never enter the
191
+ // pre-attestation recovery gate or restore the old installation.
192
+ await reconcileInstallLifecycle(await loadInstaller());
193
+ installState = "none";
194
+ }
195
+ if (installState === "stale") {
196
+ const outcome = await waitForManagedInstaller();
197
+ if (outcome === "stopped") return;
198
+ if (outcome === "restart") {
199
+ process.exitCode = MANAGED_UPDATE_RESTART_EXIT_CODE;
200
+ return;
201
+ }
202
+ installState = "live";
203
+ }
204
+ const runningVersion = packageVersion();
205
+ const managedPaths = managedRuntimePaths();
206
+ const provisionalInstall =
207
+ installState === "live" || installState === "unknown";
208
+ if (
209
+ existsSync(managedPaths.runtime) &&
210
+ !(provisionalInstall
211
+ ? await validateProvisionalManagedRuntimeSelection(runningVersion)
212
+ : await validateManagedRuntimeSelection(runningVersion))
213
+ ) {
214
+ throw new Error(
215
+ `managed runtime activation is still in progress; refusing to start stale runtime ${runningVersion}`,
216
+ );
217
+ }
93
218
  // Boot the service in-process: main.ts's top-level code starts the WSS
94
219
  // client + local UI server and keeps the event loop alive.
95
- await import("./main");
220
+ const host = await import("./main");
221
+ // Close the none→intent race while the comparatively expensive service
222
+ // module imported. This is one lock-free marker read, not a steady-state
223
+ // poller; only a provisional install arms the recurring watcher.
224
+ try {
225
+ installState = await managedRuntimeInstallStartupState();
226
+ } catch (error) {
227
+ host.requestManagedHostRestart();
228
+ throw error;
229
+ }
230
+ if (installState === "stale" || installState === "attested") {
231
+ host.requestManagedHostRestart();
232
+ return;
233
+ }
234
+ if (installState === "live" || installState === "unknown") {
235
+ watchManagedInstaller(host.requestManagedHostRestart);
236
+ }
237
+ if (
238
+ existsSync(managedPaths.runtime) &&
239
+ installState !== "live" &&
240
+ installState !== "unknown"
241
+ ) {
242
+ try {
243
+ if (!(await acknowledgeManagedRuntimeActivation(runningVersion))) {
244
+ // Selection changed while the service module was loading. Its task
245
+ // admission remains closed by the marker; drain and let the supervisor
246
+ // start the newly selected stable command.
247
+ host.requestManagedHostRestart();
248
+ return;
249
+ }
250
+ } catch (error) {
251
+ // A malformed or concurrently changed marker must not leave an imported
252
+ // service running indefinitely. Admission is still closed, so request a
253
+ // graceful stop before surfacing the settlement failure.
254
+ host.requestManagedHostRestart();
255
+ throw error;
256
+ }
257
+ }
258
+ let localManagedUpdateRunning = false;
259
+ if (existsSync(managedPaths.current)) {
260
+ new ManagedActivationWatcher({
261
+ restartRequired: async () => {
262
+ if (localManagedUpdateRunning) return false;
263
+ return reconcileManagedRuntimeActivation(runningVersion);
264
+ },
265
+ isIdle: host.isIdleForManagedHostUpdate,
266
+ onReady: host.requestManagedHostRestart,
267
+ onError: (error) =>
268
+ console.warn(
269
+ `[host-agent] managed runtime activation check failed: ${error instanceof Error ? error.message : String(error)}`,
270
+ ),
271
+ }).start();
272
+ }
273
+ if (managedAutoupdateEnabled()) {
274
+ new ManagedUpdateScheduler({
275
+ isIdle: host.isIdleForManagedHostUpdate,
276
+ check: async () => {
277
+ localManagedUpdateRunning = true;
278
+ try {
279
+ const result = await updateManagedRuntime({
280
+ canActivate: host.isIdleForManagedHostUpdate,
281
+ });
282
+ if (result.status === "deferred") return "busy";
283
+ return result.status;
284
+ } finally {
285
+ localManagedUpdateRunning = false;
286
+ }
287
+ },
288
+ onUpdated: host.requestManagedHostRestart,
289
+ onError: (error) =>
290
+ console.warn(
291
+ `[host-agent] managed runtime update check failed: ${error instanceof Error ? error.message : String(error)}`,
292
+ ),
293
+ }).start();
294
+ }
295
+ }
296
+
297
+ async function waitForManagedInstaller(): Promise<"live" | "restart" | "stopped"> {
298
+ console.error(
299
+ red(
300
+ "managed host installation was interrupted; rerun the installer to resume safely",
301
+ ),
302
+ );
303
+ return new Promise((resolve) => {
304
+ let stopped = false;
305
+ let timer: NodeJS.Timeout | undefined;
306
+ const finish = (state: "live" | "restart" | "stopped") => {
307
+ if (stopped) return;
308
+ stopped = true;
309
+ if (timer) clearTimeout(timer);
310
+ process.off("SIGTERM", onSignal);
311
+ process.off("SIGINT", onSignal);
312
+ resolve(state);
313
+ };
314
+ const onSignal = () => finish("stopped");
315
+ const poll = async () => {
316
+ try {
317
+ const state = await managedRuntimeInstallStartupState();
318
+ if (state === "live") {
319
+ finish("live");
320
+ return;
321
+ }
322
+ if (state === "none" || state === "attested") {
323
+ // Another CLI settled the stale transaction. Exit nonzero so a
324
+ // Restart=on-failure supervisor launches a fresh process that can
325
+ // either boot normally or finish the attested commit before import.
326
+ finish("restart");
327
+ return;
328
+ }
329
+ } catch (error) {
330
+ console.error(
331
+ red(
332
+ `managed host install gate could not be checked: ${
333
+ error instanceof Error ? error.message : String(error)
334
+ }`,
335
+ ),
336
+ );
337
+ }
338
+ if (!stopped) timer = setTimeout(poll, 1_000);
339
+ };
340
+ process.once("SIGTERM", onSignal);
341
+ process.once("SIGINT", onSignal);
342
+ timer = setTimeout(poll, 1_000);
343
+ });
344
+ }
345
+
346
+ function watchManagedInstaller(onStale: () => void): void {
347
+ new ManagedInstallOwnerWatcher({
348
+ check: () => managedRuntimeInstallStartupState(),
349
+ onRestart: (state) => {
350
+ console.error(
351
+ red(
352
+ state === "attested"
353
+ ? "managed host install attested; draining so the supervisor can finalize it"
354
+ : "managed host installer exited before attestation; draining into the recovery gate",
355
+ ),
356
+ );
357
+ onStale();
358
+ },
359
+ onUnsafe: (error) => {
360
+ console.error(
361
+ red(
362
+ `managed host install intent became unreadable; draining safely: ${
363
+ error instanceof Error ? error.message : String(error)
364
+ }`,
365
+ ),
366
+ );
367
+ onStale();
368
+ },
369
+ }).start();
96
370
  }
97
371
 
98
372
  // --- status -----------------------------------------------------------------
@@ -108,9 +382,14 @@ async function cmdStatus(): Promise<void> {
108
382
  }
109
383
  let status, cloud, tasks;
110
384
  try {
111
- status = StatusResponse.parse(await api(port, "/api/status"));
112
- cloud = CloudResponse.parse(await api(port, "/api/cloud"));
113
- tasks = TasksResponse.parse(await api(port, "/api/tasks"));
385
+ const [rawStatus, rawCloud, rawTasks] = await Promise.all([
386
+ api(port, "/api/status"),
387
+ api(port, "/api/cloud"),
388
+ api(port, "/api/tasks"),
389
+ ]);
390
+ status = StatusResponse.parse(rawStatus);
391
+ cloud = CloudResponse.parse(rawCloud);
392
+ tasks = TasksResponse.parse(rawTasks);
114
393
  } catch (err) {
115
394
  console.log(
116
395
  dim(
@@ -141,6 +420,22 @@ async function cmdStatus(): Promise<void> {
141
420
  console.log(` agents ${yellow("mock")} ${dim("— echo only, no real CLI runs")}`);
142
421
  console.log(` ${dim("unset UAI_AGENTS in " + envLocalPath() + ", then: uai-host restart")}`);
143
422
  }
423
+ if (status.containerRuntime) {
424
+ const runtime = status.containerRuntime;
425
+ const label =
426
+ runtime.status === "ready"
427
+ ? green(runtime.provider)
428
+ : runtime.status === "checking"
429
+ ? yellow("checking")
430
+ : red("unavailable");
431
+ console.log(` runtime ${label} ${dim(`(${runtime.preference})`)}`);
432
+ if (runtime.status === "checking" && runtime.detail) {
433
+ console.log(` ${dim(runtime.detail)}`);
434
+ }
435
+ if (runtime.status === "no-runtime") {
436
+ console.log(` ${dim(runtime.message)}`);
437
+ }
438
+ }
144
439
  console.log(` ui ${dim(`http://127.0.0.1:${port}`)}`);
145
440
  console.log(` log ${dim(status.logPath)}`);
146
441
  console.log(` tasks ${tasks.tasks.length === 0 ? dim("none") : tasks.tasks.length}`);
@@ -157,11 +452,395 @@ async function cmdStatus(): Promise<void> {
157
452
  }
158
453
 
159
454
  async function api(port: number, path: string): Promise<unknown> {
160
- const res = await fetch(`http://127.0.0.1:${port}${path}`);
455
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, {
456
+ signal: AbortSignal.timeout(1_000),
457
+ });
161
458
  if (!res.ok) throw new Error(`${path} → ${res.status}`);
162
459
  return (await res.json()) as unknown;
163
460
  }
164
461
 
462
+ async function boundedResponseJson(
463
+ response: Response,
464
+ maxBytes = 16 * 1024,
465
+ ): Promise<unknown> {
466
+ const declared = response.headers.get("content-length");
467
+ if (declared !== null) {
468
+ if (!/^(?:0|[1-9]\d*)$/.test(declared) || Number(declared) > maxBytes) {
469
+ throw new Error("cloud response exceeds its byte limit");
470
+ }
471
+ }
472
+ if (!response.body) throw new Error("cloud response has no body");
473
+ const reader = response.body.getReader();
474
+ const chunks: Uint8Array[] = [];
475
+ let total = 0;
476
+ try {
477
+ for (;;) {
478
+ const { done, value } = await reader.read();
479
+ if (done) break;
480
+ total += value.byteLength;
481
+ if (total > maxBytes) {
482
+ await reader.cancel("cloud response exceeds its byte limit");
483
+ throw new Error("cloud response exceeds its byte limit");
484
+ }
485
+ chunks.push(value);
486
+ }
487
+ } finally {
488
+ reader.releaseLock();
489
+ }
490
+ const bytes = new Uint8Array(total);
491
+ let offset = 0;
492
+ for (const chunk of chunks) {
493
+ bytes.set(chunk, offset);
494
+ offset += chunk.byteLength;
495
+ }
496
+ return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
497
+ }
498
+
499
+ // --- runtime ---------------------------------------------------------------
500
+
501
+ async function cmdRuntime(rest: string[]): Promise<void> {
502
+ if (rest[0] === "begin-install") {
503
+ return cmdBeginRuntimeInstall(rest.slice(1));
504
+ }
505
+ if (rest[0] === "complete-install") {
506
+ return cmdCompleteRuntimeInstall(rest.slice(1));
507
+ }
508
+ if (rest[0] === "abort-install") {
509
+ return cmdAbortRuntimeInstall(rest.slice(1));
510
+ }
511
+ if (rest[0] === "stop-service") {
512
+ return cmdStopRuntimeInstallService(rest.slice(1));
513
+ }
514
+ if (rest.length === 1 && rest[0] === "reconcile-uninstall") {
515
+ await reconcileManagedRuntimeUninstall();
516
+ return;
517
+ }
518
+ if (rest[0] === "install-archive") {
519
+ return cmdInstallRuntimeArchive(rest.slice(1));
520
+ }
521
+ if (rest.length !== 1 || rest[0] !== "recheck") {
522
+ console.error(
523
+ red(
524
+ "usage: uai-host runtime recheck\n uai-host runtime install-archive --archive <file> --version <x.y.z> --size <bytes> --sha256 <hex>",
525
+ ),
526
+ );
527
+ process.exitCode = 1;
528
+ return;
529
+ }
530
+ const port = readPort();
531
+ if (port == null) {
532
+ console.error(
533
+ red("service not running — start it before rechecking"),
534
+ );
535
+ process.exitCode = 1;
536
+ return;
537
+ }
538
+ try {
539
+ const res = await fetch(`http://127.0.0.1:${port}/api/runtime/recheck`, {
540
+ method: "POST",
541
+ signal: AbortSignal.timeout(30_000),
542
+ headers: {
543
+ "x-uai-local-control": "runtime-recheck-v1",
544
+ },
545
+ });
546
+ if (!res.ok) throw new Error(`runtime recheck → ${res.status}`);
547
+ const runtime = ContainerRuntimeResponse.parse(
548
+ await boundedResponseJson(res),
549
+ );
550
+ if (runtime.status === "ready") {
551
+ console.log(
552
+ green("container runtime ready") + dim(` — ${runtime.provider}`),
553
+ );
554
+ return;
555
+ }
556
+ if (runtime.status === "checking") {
557
+ console.log(
558
+ yellow("container runtime detected") +
559
+ dim(" — preparing images and recovering tasks; check status shortly"),
560
+ );
561
+ return;
562
+ }
563
+ console.error(red(runtime.message ?? "container runtime unavailable"));
564
+ process.exitCode = 1;
565
+ } catch (err) {
566
+ console.error(
567
+ red(err instanceof Error ? err.message : "runtime recheck failed"),
568
+ );
569
+ process.exitCode = 1;
570
+ }
571
+ }
572
+
573
+ async function cmdBeginRuntimeInstall(rest: string[]): Promise<void> {
574
+ if (rest.length !== 0) {
575
+ throw new Error("usage: uai-host runtime begin-install");
576
+ }
577
+ const installer = await loadInstaller();
578
+ const result = await beginManagedRuntimeInstall({
579
+ // The installer invokes this command directly with output redirection. Its
580
+ // parent is therefore the long-lived compound shell, not a short-lived
581
+ // command-substitution process. PID start identity protects against reuse.
582
+ ownerPid: process.ppid,
583
+ reconcileStandaloneDefinition: () => reconcileDefinitionInstall(installer),
584
+ definitionState: (managedCommand) =>
585
+ serviceDefinitionState(installer, managedCommand),
586
+ prepareRecovery: (installIntent) =>
587
+ prepareDefinitionInstallRollback(installer, installIntent),
588
+ resumeRecovery: (installIntent) =>
589
+ resumeDefinitionInstallRollback(installer, installIntent),
590
+ resumeStoppedService: (installIntent, priorManager) =>
591
+ resumeServiceForInstall(installer, installIntent, priorManager),
592
+ beforeFinalize: (installIntent) =>
593
+ completeDefinitionInstall(installer, installIntent),
594
+ });
595
+ console.log(
596
+ [
597
+ result.nonce,
598
+ result.legacyShim ? "legacy" : "none",
599
+ result.serviceOwnership,
600
+ result.serviceQuiescence,
601
+ result.serviceManager.loaded ? "loaded" : "unloaded",
602
+ result.serviceManager.active ? "active" : "inactive",
603
+ result.serviceManager.enabled ? "enabled" : "disabled",
604
+ ].join(" "),
605
+ );
606
+ }
607
+
608
+ async function cmdStopRuntimeInstallService(rest: string[]): Promise<void> {
609
+ const installIntent = exactFlagValue(rest, "--install-intent");
610
+ if (!installIntent) {
611
+ throw new Error(
612
+ "usage: uai-host runtime stop-service --install-intent <nonce>",
613
+ );
614
+ }
615
+ const installer = await loadInstaller();
616
+ await stopManagedRuntimeServiceForInstall({
617
+ installIntent,
618
+ stopService: (intent, priorManager) =>
619
+ stopServiceForInstall(installer, intent, priorManager),
620
+ });
621
+ }
622
+
623
+ async function cmdCompleteRuntimeInstall(rest: string[]): Promise<void> {
624
+ const installIntent = exactFlagValue(rest, "--install-intent");
625
+ if (!installIntent) {
626
+ throw new Error(
627
+ "usage: uai-host runtime complete-install --install-intent <nonce>",
628
+ );
629
+ }
630
+ const installer = await loadInstaller();
631
+ await completeManagedRuntimeInstall({
632
+ installIntent,
633
+ beforeFinalize: (intent) => completeDefinitionInstall(installer, intent),
634
+ });
635
+ }
636
+
637
+ async function cmdAbortRuntimeInstall(rest: string[]): Promise<void> {
638
+ const installIntent = exactFlagValue(rest, "--install-intent");
639
+ if (!installIntent) {
640
+ throw new Error(
641
+ "usage: uai-host runtime abort-install --install-intent <nonce>",
642
+ );
643
+ }
644
+ const installer = await loadInstaller();
645
+ await abortManagedRuntimeInstall({
646
+ installIntent,
647
+ prepareRestore: (intent) =>
648
+ prepareDefinitionInstallRollback(installer, intent),
649
+ resumeRestore: (intent) =>
650
+ resumeDefinitionInstallRollback(installer, intent),
651
+ resumeStoppedService: (intent, priorManager) =>
652
+ resumeServiceForInstall(installer, intent, priorManager),
653
+ beforeFinalize: (intent) => completeDefinitionInstall(installer, intent),
654
+ });
655
+ }
656
+
657
+ async function cmdInstallRuntimeArchive(rest: string[]): Promise<void> {
658
+ const parsed = parseRuntimeInstallArgs(rest);
659
+ if (!parsed) {
660
+ console.error(
661
+ red(
662
+ "usage: uai-host runtime install-archive --archive <file> --version <x.y.z> --size <bytes> --sha256 <hex>",
663
+ ),
664
+ );
665
+ process.exitCode = 1;
666
+ return;
667
+ }
668
+ const platform = nativeReleasePlatform();
669
+ const result = await installManagedRuntimeArchive({
670
+ archivePath: parsed.archive,
671
+ artifact: {
672
+ // Bootstrap authenticated the tuple in the cloud-rendered script. The
673
+ // local installer consumes only size/hash; update URLs come from the
674
+ // separately verified signed manifest.
675
+ url: "bootstrap-authenticated-artifact",
676
+ size: parsed.size,
677
+ sha256: parsed.sha256,
678
+ },
679
+ version: parsed.version,
680
+ platform,
681
+ allowDowngrade: parsed.allowDowngrade,
682
+ // Legacy conversion takes over the old service's live tasks, so it needs
683
+ // every task idle. A managed version update restarts only our own
684
+ // service, which running tasks survive (durable sessions) — it drains
685
+ // only in-flight lifecycle mutations.
686
+ canActivate: (context) =>
687
+ context.legacyConversion
688
+ ? !hasActiveHostTasks()
689
+ : !hasInFlightHostTaskLifecycle(),
690
+ releaseRollbackUpdateHold: true,
691
+ installIntent: parsed.installIntent,
692
+ });
693
+ if (result.deferred) {
694
+ console.error(red("runtime activation deferred because host tasks are active"));
695
+ process.exitCode = 1;
696
+ return;
697
+ }
698
+ console.log(
699
+ green(result.changed ? `installed host runtime ${result.version}` : `host runtime ${result.version} is already installed`),
700
+ );
701
+ }
702
+
703
+ interface RuntimeInstallArgs {
704
+ archive: string;
705
+ version: string;
706
+ size: number;
707
+ sha256: string;
708
+ allowDowngrade: boolean;
709
+ installIntent?: string;
710
+ }
711
+
712
+ function parseRuntimeInstallArgs(rest: string[]): RuntimeInstallArgs | null {
713
+ const values = new Map<string, string>();
714
+ let allowDowngrade = false;
715
+ for (let index = 0; index < rest.length; index += 1) {
716
+ const arg = rest[index]!;
717
+ if (arg === "--allow-downgrade") {
718
+ if (allowDowngrade) return null;
719
+ allowDowngrade = true;
720
+ continue;
721
+ }
722
+ if (
723
+ ![
724
+ "--archive",
725
+ "--version",
726
+ "--size",
727
+ "--sha256",
728
+ "--install-intent",
729
+ ].includes(arg)
730
+ ) {
731
+ return null;
732
+ }
733
+ if (values.has(arg)) return null;
734
+ const value = rest[index + 1];
735
+ if (!value || value.startsWith("--")) return null;
736
+ values.set(arg, value);
737
+ index += 1;
738
+ }
739
+ const archive = values.get("--archive");
740
+ const version = values.get("--version");
741
+ const sizeValue = values.get("--size");
742
+ const sha256 = values.get("--sha256");
743
+ const installIntent = values.get("--install-intent");
744
+ if (!archive || !version || !sizeValue || !sha256) return null;
745
+ if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(version)) {
746
+ return null;
747
+ }
748
+ if (!/^[1-9]\d*$/.test(sizeValue) || !/^[a-f0-9]{64}$/.test(sha256)) {
749
+ return null;
750
+ }
751
+ const size = Number(sizeValue);
752
+ if (!Number.isSafeInteger(size)) return null;
753
+ if (installIntent && !/^[a-f0-9]{64}$/.test(installIntent)) return null;
754
+ return { archive, version, size, sha256, allowDowngrade, installIntent };
755
+ }
756
+
757
+ async function cmdUpdate(rest: string[]): Promise<void> {
758
+ if (rest.length !== 0) {
759
+ console.error(red("usage: uai-host update"));
760
+ process.exitCode = 1;
761
+ return;
762
+ }
763
+ // Running tasks survive the service restart (durable sessions); only a
764
+ // task mid-lifecycle blocks, and only until it drains.
765
+ if (hasInFlightHostTaskLifecycle()) {
766
+ console.error(red("refusing to update while a task is still starting; retry in a moment"));
767
+ process.exitCode = 1;
768
+ return;
769
+ }
770
+ const result = await updateManagedRuntime({
771
+ canActivate: () => !hasInFlightHostTaskLifecycle(),
772
+ releaseRollbackUpdateHold: true,
773
+ });
774
+ if (result.status === "not-managed") {
775
+ console.error(red("this host is not installed as a managed runtime"));
776
+ process.exitCode = 1;
777
+ return;
778
+ }
779
+ if (result.status === "current") {
780
+ console.log(green(`host runtime ${result.version} is current`));
781
+ return;
782
+ }
783
+ if (result.status === "deferred") {
784
+ console.error(red("host runtime update was deferred because the host became busy"));
785
+ process.exitCode = 1;
786
+ return;
787
+ }
788
+ console.log(
789
+ green(`updated host runtime ${result.fromVersion} → ${result.version}`) +
790
+ dim(" — activation staged; the service will restart after in-flight work drains"),
791
+ );
792
+ }
793
+
794
+ async function cmdRollback(rest: string[]): Promise<void> {
795
+ if (rest.length !== 0) {
796
+ console.error(red("usage: uai-host rollback"));
797
+ process.exitCode = 1;
798
+ return;
799
+ }
800
+ if (hasActiveHostTasks()) {
801
+ console.error(red("refusing to roll back while host tasks are active"));
802
+ process.exitCode = 1;
803
+ return;
804
+ }
805
+ const idle = () => !hasActiveHostTasks();
806
+ const result = await rollbackManagedRuntime(undefined, undefined, idle);
807
+ console.log(
808
+ green(`rolled back host runtime ${result.fromVersion} → ${result.version}`) +
809
+ dim(" — activation staged; the service will restart after in-flight work drains"),
810
+ );
811
+ console.log(
812
+ yellow("automatic runtime updates are paused; run `uai-host update` to resume them"),
813
+ );
814
+ }
815
+
816
+ function cmdEnrollmentStatus(rest: string[]): void {
817
+ if (rest.length !== 1 || rest[0] !== "status") {
818
+ console.error(red("usage: uai-host enrollment status"));
819
+ process.exitCode = 2;
820
+ return;
821
+ }
822
+ const identity = hostIdentity();
823
+ if (identity.kind === "complete") {
824
+ console.log("enrolled");
825
+ return;
826
+ }
827
+ if (identity.kind === "partial") {
828
+ console.error(red("incomplete host identity"));
829
+ process.exitCode = 2;
830
+ return;
831
+ }
832
+ console.log("unenrolled");
833
+ process.exitCode = 1;
834
+ }
835
+
836
+ function managedAutoupdateEnabled(): boolean {
837
+ return (
838
+ process.env.UAI_HOST_AUTOUPDATE !== "0" &&
839
+ PINNED_RELEASE_MANIFEST_KEYS.size > 0 &&
840
+ existsSync(managedRuntimePaths().current)
841
+ );
842
+ }
843
+
165
844
  // --- open -------------------------------------------------------------------
166
845
 
167
846
  async function cmdOpen(): Promise<void> {
@@ -222,6 +901,90 @@ function flagValue(rest: string[], name: string): string | undefined {
222
901
  return eq ? eq.slice(name.length + 1) : undefined;
223
902
  }
224
903
 
904
+ function exactFlagValue(rest: string[], name: string): string | undefined {
905
+ return rest.length === 2 && rest[0] === name && rest[1]
906
+ ? rest[1]
907
+ : undefined;
908
+ }
909
+
910
+ type EnrollArgs =
911
+ | { ok: true; token: string; cloud?: string }
912
+ | { ok: false };
913
+
914
+ /** `enroll` spends a one-time token, so reject malformed/ambiguous argv
915
+ * instead of silently falling back to persisted configuration. */
916
+ function parseEnrollArgs(rest: string[]): EnrollArgs {
917
+ let token: string | undefined;
918
+ let cloud: string | undefined;
919
+ let sawCloud = false;
920
+
921
+ for (let index = 0; index < rest.length; index += 1) {
922
+ const arg = rest[index]!;
923
+ if (arg === "--cloud") {
924
+ if (sawCloud) return { ok: false };
925
+ sawCloud = true;
926
+ const value = rest[index + 1];
927
+ if (!value || value.startsWith("-")) return { ok: false };
928
+ cloud = value;
929
+ index += 1;
930
+ continue;
931
+ }
932
+ if (arg.startsWith("--cloud=")) {
933
+ if (sawCloud) return { ok: false };
934
+ sawCloud = true;
935
+ cloud = arg.slice("--cloud=".length);
936
+ if (!cloud) return { ok: false };
937
+ continue;
938
+ }
939
+ if (arg.startsWith("-") || token) return { ok: false };
940
+ token = arg;
941
+ }
942
+
943
+ return token ? { ok: true, token, ...(cloud ? { cloud } : {}) } : { ok: false };
944
+ }
945
+
946
+ type HostIdentity =
947
+ | { kind: "none" }
948
+ | { kind: "partial" }
949
+ | { kind: "complete"; hostId: string; hostToken: string };
950
+
951
+ /** Resolve both modern env identities and legacy `pair` identities whose id
952
+ * lives in `$UAI_DATA_DIR/host-id`. Exactly one unresolved half fails closed. */
953
+ function hostIdentity(): HostIdentity {
954
+ const hostToken = process.env.UAI_HOST_TOKEN;
955
+ const hostId = process.env.UAI_HOST_ID ?? (hostToken ? readPersistedHostId() : null);
956
+ if (!hostId && !hostToken) return { kind: "none" };
957
+ if (!hostId || !hostToken) return { kind: "partial" };
958
+ return { kind: "complete", hostId, hostToken };
959
+ }
960
+
961
+ function rejectPartialIdentity(): void {
962
+ console.error(
963
+ red("incomplete host identity — refusing to replace an existing enrollment"),
964
+ );
965
+ console.error(
966
+ dim("restore UAI_HOST_ID/UAI_HOST_TOKEN, or use `uai-host setup --force` for a deliberate reset."),
967
+ );
968
+ process.exitCode = 1;
969
+ }
970
+
971
+ /** The bridge credential is durable, so its destination trust anchor must be
972
+ * durable too. Process env is intentionally NOT consulted here: a pasted
973
+ * command can override it before load-env reads the host's real config. */
974
+ function persistedCloudUrl(): string | undefined {
975
+ for (const path of [envLocalPath(), join(uaiHome(), ".env")]) {
976
+ if (!existsSync(path)) continue;
977
+ try {
978
+ const value = parseDotenv(readFileSync(path)).UAI_CLOUD_URL;
979
+ if (value) return value;
980
+ } catch {
981
+ // The normal env loader already reports malformed/unreadable files. A
982
+ // missing trust anchor is handled by the caller without leaking secrets.
983
+ }
984
+ }
985
+ return undefined;
986
+ }
987
+
225
988
  /**
226
989
  * Claim this machine as a host: redeem a one-time enrollment token (minted in
227
990
  * the web app) for a permanent bridge credential, then write the config. The
@@ -241,48 +1004,102 @@ async function cmdSetup(rest: string[]): Promise<void> {
241
1004
  return;
242
1005
  }
243
1006
 
244
- let cloudUrl: URL;
1007
+ let cloudEndpoint: ReturnType<typeof parseHostCloudEndpoint>;
245
1008
  try {
246
- cloudUrl = new URL(cloud);
247
- } catch {
248
- console.error(red(`invalid --cloud URL: ${cloud}`));
1009
+ cloudEndpoint = parseHostCloudEndpoint(cloud);
1010
+ } catch (error) {
1011
+ console.error(
1012
+ red(
1013
+ `invalid --cloud URL: ${error instanceof Error ? error.message : String(error)}`,
1014
+ ),
1015
+ );
249
1016
  process.exitCode = 1;
250
1017
  return;
251
1018
  }
1019
+ const normalizedCloud = cloudEndpoint.bridgeUrl;
252
1020
 
253
- // Don't silently re-enroll an already-claimed machine (would orphan the old
254
- // host row + steal its slot).
255
- const existing = existsSync(envLocalPath())
256
- ? readFileSync(envLocalPath(), "utf8")
257
- : "";
258
- if (/^UAI_HOST_TOKEN=/m.test(existing) && !rest.includes("--force")) {
259
- console.error(red("this machine is already enrolled (UAI_HOST_TOKEN set)."));
1021
+ // An already-claimed machine ATTACHES the token to its existing identity
1022
+ // (ADR-098) pasting a second org's enroll command Just Works, no new host
1023
+ // row, no --force. --force still re-enrolls from scratch (new identity,
1024
+ // orphans the old row) for the rare deliberate reset.
1025
+ const identity = hostIdentity();
1026
+ if (identity.kind === "partial" && !rest.includes("--force")) {
1027
+ rejectPartialIdentity();
1028
+ return;
1029
+ }
1030
+ if (identity.kind === "complete" && !rest.includes("--force")) {
1031
+ try {
1032
+ removeCommittedPendingEnrollment(pendingEnrollmentPath(uaiHome()), {
1033
+ hostId: identity.hostId,
1034
+ hostSecret: identity.hostToken,
1035
+ });
1036
+ } catch (err) {
1037
+ console.error(
1038
+ red(
1039
+ `cannot verify pending enrollment state: ${err instanceof Error ? err.message : String(err)}`,
1040
+ ),
1041
+ );
1042
+ process.exitCode = 1;
1043
+ return;
1044
+ }
1045
+ console.log(
1046
+ dim("already enrolled — attaching this machine to the token's workspace instead."),
1047
+ );
1048
+ return attachToOrg(enroll, normalizedCloud, identity);
1049
+ }
1050
+
1051
+ const pendingFile = pendingEnrollmentPath(uaiHome());
1052
+ if (rest.includes("--force")) discardPendingEnrollment(pendingFile);
1053
+
1054
+ const redeemUrl = `${cloudEndpoint.apiOrigin}/api/hosts/enroll/redeem`;
1055
+
1056
+ // Persist this host's proposed identity BEFORE spending the one-time token.
1057
+ // A retry after a lost response reuses the exact id/secret, and the cloud's
1058
+ // same-host replay returns the already-completed result without new access.
1059
+ const candidate: PendingEnrollment = {
1060
+ schemaVersion: 1,
1061
+ enrollmentTokenSha256: enrollmentTokenSha256(enroll),
1062
+ hostId: ulid().toLowerCase(),
1063
+ hostSecret: randomBytes(32).toString("hex"),
1064
+ cloudUrl: normalizedCloud,
1065
+ hostName: hostname(),
1066
+ createdAt: Date.now(),
1067
+ };
1068
+ let pending: PendingEnrollment;
1069
+ try {
1070
+ pending = ensurePendingEnrollment(pendingFile, candidate).pending;
1071
+ } catch (err) {
260
1072
  console.error(
261
- dim("re-enrolling creates a new host + orphans the old one — pass --force to proceed."),
1073
+ red(
1074
+ `could not persist pending enrollment: ${err instanceof Error ? err.message : String(err)}`,
1075
+ ),
262
1076
  );
263
1077
  process.exitCode = 1;
264
1078
  return;
265
1079
  }
266
-
267
- const proto =
268
- cloudUrl.protocol === "wss:"
269
- ? "https:"
270
- : cloudUrl.protocol === "ws:"
271
- ? "http:"
272
- : cloudUrl.protocol;
273
- const redeemUrl = `${proto}//${cloudUrl.host}/api/hosts/enroll/redeem`;
274
-
275
- // This host's identity + bridge secret. Secret stays local; cloud gets the hash.
276
- const hostId = ulid().toLowerCase();
277
- const secret = randomBytes(32).toString("hex");
1080
+ if (!pendingEnrollmentMatches(pending, enroll, normalizedCloud)) {
1081
+ console.error(red("another enrollment is already pending on this machine"));
1082
+ console.error(
1083
+ dim(
1084
+ "retry the original enrollment command, or use `uai-host setup --force` for a deliberate reset.",
1085
+ ),
1086
+ );
1087
+ process.exitCode = 1;
1088
+ return;
1089
+ }
1090
+ const hostId = pending.hostId;
1091
+ const secret = pending.hostSecret;
278
1092
  const tokenHash = createHash("sha256").update(secret).digest("hex");
279
- const name = hostname();
1093
+ const name = pending.hostName;
280
1094
 
281
- console.log(dim(`enrolling ${name} (${hostId}) with ${cloudUrl.host}…`));
1095
+ console.log(
1096
+ dim(`enrolling ${name} (${hostId}) with ${cloudEndpoint.displayHost}…`),
1097
+ );
282
1098
  let res: Response;
283
1099
  try {
284
1100
  res = await fetch(redeemUrl, {
285
1101
  method: "POST",
1102
+ redirect: "error",
286
1103
  headers: { "content-type": "application/json" },
287
1104
  body: JSON.stringify({ enrollToken: enroll, hostId, tokenHash, name }),
288
1105
  signal: AbortSignal.timeout(15_000),
@@ -291,32 +1108,70 @@ async function cmdSetup(rest: string[]): Promise<void> {
291
1108
  console.error(
292
1109
  red(`could not reach the cloud: ${err instanceof Error ? err.message : String(err)}`),
293
1110
  );
1111
+ console.error(dim("the proposed identity is saved locally; retry this exact command."));
294
1112
  process.exitCode = 1;
295
1113
  return;
296
1114
  }
297
1115
  if (!res.ok) {
298
1116
  let detail = `HTTP ${res.status}`;
299
1117
  try {
300
- const j = (await res.json()) as { error?: { message?: string } };
1118
+ const j = (await boundedResponseJson(res)) as {
1119
+ error?: { message?: string };
1120
+ };
301
1121
  if (j.error?.message) detail = j.error.message;
302
1122
  } catch {
303
1123
  /* keep the HTTP status */
304
1124
  }
305
1125
  console.error(red(`enrollment failed: ${detail}`));
1126
+ const definitiveUnclaimed =
1127
+ (res.status === 404 || res.status === 410) &&
1128
+ res.headers.get(ENROLLMENT_REPLAY_PROTOCOL_HEADER) ===
1129
+ ENROLLMENT_REPLAY_PROTOCOL_VERSION;
1130
+ if (definitiveUnclaimed) {
1131
+ // A same-host-v1 cloud checks `redeemedAt` before expiry, so these are
1132
+ // proven unclaimed. Old/rolled-back handlers carry no header and may
1133
+ // return 410 after committing a response that was lost in transit.
1134
+ discardPendingEnrollment(pendingFile);
1135
+ console.error(dim("mint a fresh enrollment command in the web app and retry."));
1136
+ } else {
1137
+ // USED is intentionally retained for rolling compatibility: an older
1138
+ // cloud may have completed the first request but lack same-host replay.
1139
+ console.error(dim("the proposed identity is saved locally; retry this exact command."));
1140
+ }
1141
+ process.exitCode = 1;
1142
+ return;
1143
+ }
1144
+
1145
+ let success: unknown;
1146
+ try {
1147
+ success = await boundedResponseJson(res);
1148
+ } catch (error) {
306
1149
  console.error(
307
- dim("the token may be expired or already used — mint a fresh one in the web app."),
1150
+ red(
1151
+ `enrollment outcome could not be verified: ${error instanceof Error ? error.message : String(error)}`,
1152
+ ),
308
1153
  );
1154
+ console.error(dim("the proposed identity is saved locally; retry this exact command."));
1155
+ process.exitCode = 1;
1156
+ return;
1157
+ }
1158
+ if (
1159
+ !success ||
1160
+ typeof success !== "object" ||
1161
+ Array.isArray(success) ||
1162
+ (success as Record<string, unknown>).ok !== true ||
1163
+ (success as Record<string, unknown>).hostId !== pending.hostId
1164
+ ) {
1165
+ console.error(red("enrollment outcome did not match the proposed host identity"));
1166
+ console.error(dim("the proposed identity is saved locally; retry this exact command."));
309
1167
  process.exitCode = 1;
310
1168
  return;
311
1169
  }
312
1170
 
313
- // The host is already created on the cloud and the token is spent. If the
314
- // local write fails, don't lose the secret print it so it can be saved by
315
- // hand rather than forcing the user to mint a fresh token.
1171
+ // Atomically commit all three values. The pending file is removed only after
1172
+ // the replacement is durable; a crash can leave both files, never neither.
316
1173
  try {
317
- upsertEnvLocal("UAI_HOST_ID", hostId);
318
- upsertEnvLocal("UAI_HOST_TOKEN", secret);
319
- upsertEnvLocal("UAI_CLOUD_URL", cloud);
1174
+ commitPendingEnrollment(pendingFile, envLocalPath(), pending);
320
1175
  } catch (err) {
321
1176
  console.error(
322
1177
  red(
@@ -324,11 +1179,8 @@ async function cmdSetup(rest: string[]): Promise<void> {
324
1179
  ),
325
1180
  );
326
1181
  console.error(
327
- dim("the host exists on the cloudsave these into that file manually, then `uai-host start`:"),
1182
+ dim("the identity remains in protected pending state retry this exact enrollment command."),
328
1183
  );
329
- console.error(` UAI_HOST_ID=${hostId}`);
330
- console.error(` UAI_HOST_TOKEN=${secret}`);
331
- console.error(` UAI_CLOUD_URL=${cloud}`);
332
1184
  process.exitCode = 1;
333
1185
  return;
334
1186
  }
@@ -340,6 +1192,171 @@ async function cmdSetup(rest: string[]): Promise<void> {
340
1192
  console.log(cyan(" uai-host restart") + dim(" (already installed)"));
341
1193
  }
342
1194
 
1195
+ // --- enroll (attach an org, ADR-098) -----------------------------------------
1196
+
1197
+ /**
1198
+ * Enroll this machine. Without an identity this delegates to fresh setup; with
1199
+ * one, it spends the token against the existing host rather than minting a new
1200
+ * identity. The bridge secret authenticates attach (same credential the WSS
1201
+ * bridge presents) and may only be sent over TLS or to a loopback dev cloud.
1202
+ *
1203
+ * uai-host enroll <token> [--cloud <wss-url>]
1204
+ */
1205
+ async function cmdEnroll(rest: string[]): Promise<void> {
1206
+ const parsed = parseEnrollArgs(rest);
1207
+ if (!parsed.ok) {
1208
+ console.error(red("usage: uai-host enroll <token> [--cloud <wss-url>]"));
1209
+ process.exitCode = 1;
1210
+ return;
1211
+ }
1212
+ const identity = hostIdentity();
1213
+ if (identity.kind === "partial") {
1214
+ rejectPartialIdentity();
1215
+ return;
1216
+ }
1217
+ const cloud =
1218
+ parsed.cloud ??
1219
+ (identity.kind === "complete"
1220
+ ? persistedCloudUrl()
1221
+ : process.env.UAI_CLOUD_URL);
1222
+ if (!cloud) {
1223
+ console.error(
1224
+ red(
1225
+ identity.kind === "complete"
1226
+ ? "no persisted cloud URL — restore UAI_CLOUD_URL in the host config"
1227
+ : "no cloud URL — pass --cloud <wss-url>",
1228
+ ),
1229
+ );
1230
+ process.exitCode = 1;
1231
+ return;
1232
+ }
1233
+ if (identity.kind === "none") {
1234
+ return cmdSetup(["--cloud", cloud, "--enroll", parsed.token]);
1235
+ }
1236
+ return attachToOrg(parsed.token, cloud, identity);
1237
+ }
1238
+
1239
+ /** Shared by `enroll` and `setup`-on-an-enrolled-machine. */
1240
+ async function attachToOrg(
1241
+ enrollToken: string,
1242
+ cloud: string,
1243
+ identity: Extract<HostIdentity, { kind: "complete" }>,
1244
+ ): Promise<void> {
1245
+ const { hostId, hostToken } = identity;
1246
+
1247
+ let cloudEndpoint: ReturnType<typeof parseHostCloudEndpoint>;
1248
+ try {
1249
+ cloudEndpoint = parseHostCloudEndpoint(cloud);
1250
+ } catch (error) {
1251
+ console.error(
1252
+ red(
1253
+ `invalid cloud URL: ${error instanceof Error ? error.message : String(error)}`,
1254
+ ),
1255
+ );
1256
+ process.exitCode = 1;
1257
+ return;
1258
+ }
1259
+
1260
+ const configuredCloud = persistedCloudUrl();
1261
+ if (!configuredCloud) {
1262
+ console.error(
1263
+ red("cannot verify the enrollment destination — UAI_CLOUD_URL is not persisted"),
1264
+ );
1265
+ console.error(dim("restore the original cloud URL before attaching this host."));
1266
+ process.exitCode = 1;
1267
+ return;
1268
+ }
1269
+ let configuredEndpoint: ReturnType<typeof parseHostCloudEndpoint>;
1270
+ try {
1271
+ configuredEndpoint = parseHostCloudEndpoint(configuredCloud);
1272
+ } catch (error) {
1273
+ console.error(
1274
+ red(
1275
+ `invalid configured UAI_CLOUD_URL: ${error instanceof Error ? error.message : String(error)}`,
1276
+ ),
1277
+ );
1278
+ process.exitCode = 1;
1279
+ return;
1280
+ }
1281
+ if (cloudEndpoint.apiOrigin !== configuredEndpoint.apiOrigin) {
1282
+ console.error(
1283
+ red("refusing to send the host credential to a different cloud origin"),
1284
+ );
1285
+ console.error(dim(`configured cloud: ${configuredEndpoint.displayHost}`));
1286
+ process.exitCode = 1;
1287
+ return;
1288
+ }
1289
+ const attachUrl = `${cloudEndpoint.apiOrigin}/api/hosts/enroll/attach`;
1290
+
1291
+ console.log(
1292
+ dim(`attaching ${hostname()} (${hostId}) via ${cloudEndpoint.displayHost}…`),
1293
+ );
1294
+ let res: Response;
1295
+ try {
1296
+ res = await fetch(attachUrl, {
1297
+ method: "POST",
1298
+ redirect: "error",
1299
+ headers: { "content-type": "application/json" },
1300
+ body: JSON.stringify({ enrollToken, hostId, token: hostToken }),
1301
+ signal: AbortSignal.timeout(15_000),
1302
+ });
1303
+ } catch (err) {
1304
+ console.error(
1305
+ red(`could not reach the cloud: ${err instanceof Error ? err.message : String(err)}`),
1306
+ );
1307
+ process.exitCode = 1;
1308
+ return;
1309
+ }
1310
+ if (!res.ok) {
1311
+ let detail = `HTTP ${res.status}`;
1312
+ try {
1313
+ const j = (await boundedResponseJson(res)) as {
1314
+ error?: { message?: string };
1315
+ };
1316
+ if (j.error?.message) detail = j.error.message;
1317
+ } catch {
1318
+ /* keep the HTTP status */
1319
+ }
1320
+ console.error(red(`attach failed: ${detail}`));
1321
+ console.error(
1322
+ dim("the token may be expired or already used — mint a fresh one in the web app."),
1323
+ );
1324
+ process.exitCode = 1;
1325
+ return;
1326
+ }
1327
+ let body: unknown;
1328
+ try {
1329
+ body = await boundedResponseJson(res);
1330
+ } catch (error) {
1331
+ console.error(
1332
+ red(
1333
+ `attach outcome could not be verified: ${error instanceof Error ? error.message : String(error)}`,
1334
+ ),
1335
+ );
1336
+ process.exitCode = 1;
1337
+ return;
1338
+ }
1339
+ const result = body as Record<string, unknown> | null;
1340
+ if (
1341
+ !result ||
1342
+ Array.isArray(result) ||
1343
+ result.ok !== true ||
1344
+ result.hostId !== hostId ||
1345
+ typeof result.orgId !== "string" ||
1346
+ result.orgId.length === 0 ||
1347
+ result.orgId.length > 128 ||
1348
+ (result.orgName !== null &&
1349
+ result.orgName !== undefined &&
1350
+ typeof result.orgName !== "string")
1351
+ ) {
1352
+ console.error(red("attach outcome did not match this host identity"));
1353
+ process.exitCode = 1;
1354
+ return;
1355
+ }
1356
+ const where = result.orgName ? ` to ${result.orgName}` : "";
1357
+ console.log(green(`✓ enrolled${where}`) + dim(" — no restart needed."));
1358
+ }
1359
+
343
1360
  // --- pair -------------------------------------------------------------------
344
1361
 
345
1362
  async function cmdPair(token: string | undefined): Promise<void> {
@@ -358,30 +1375,85 @@ async function cmdPair(token: string | undefined): Promise<void> {
358
1375
  }
359
1376
 
360
1377
  function upsertEnvLocal(key: string, value: string): void {
361
- const path = envLocalPath();
362
- // UAI_HOME (~/.uai on a fresh install) may not exist yet — create it before
363
- // the first write, or writeFileSync ENOENTs (which on `setup` would orphan a
364
- // just-redeemed host + burn the one-time token).
365
- mkdirSync(dirname(path), { recursive: true });
366
- const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
367
- const kept = existing
368
- .split("\n")
369
- .filter((l) => !l.startsWith(`${key}=`) && l.trim() !== "");
370
- kept.push(`${key}=${value}`);
371
- writeFileSync(path, kept.join("\n") + "\n", { mode: 0o600 });
1378
+ writeEnvValuesAtomic(envLocalPath(), { [key]: value });
372
1379
  }
373
1380
 
374
1381
  // --- install dispatch -------------------------------------------------------
375
1382
 
376
- async function cmdInstall(dryRun: boolean): Promise<void> {
1383
+ async function cmdInstall(rest: string[]): Promise<void> {
1384
+ const parsed = parseInstallArgs(rest);
1385
+ if (!parsed) {
1386
+ console.error(
1387
+ red(
1388
+ "usage: uai-host install [--no-start] [--dry-run] [--install-intent <nonce>]",
1389
+ ),
1390
+ );
1391
+ process.exitCode = 1;
1392
+ return;
1393
+ }
1394
+ const { dryRun, startOnInstall, installIntent } = parsed;
377
1395
  // Disclosure BEFORE activation (review): install starts the service, and
378
1396
  // the notice must precede the first telemetry-armed run.
379
1397
  if (!dryRun) printTelemetryNotice();
1398
+ if (!dryRun) mkdirSync(uaiHome(), { recursive: true, mode: 0o700 });
380
1399
  const installer = await loadInstaller();
381
- await installer.install(installContext(dryRun));
1400
+ if (dryRun) {
1401
+ await installer.install(installContext(true, startOnInstall));
1402
+ } else if (installIntent) {
1403
+ await runManagedRuntimeInstallIntentMutation({
1404
+ installIntent,
1405
+ mutation: (identity) =>
1406
+ installer.install(installContext(false, startOnInstall, identity)),
1407
+ });
1408
+ } else {
1409
+ await runInstallLifecycleMutation(installer, () =>
1410
+ installer.install(installContext(false, startOnInstall)),
1411
+ );
1412
+ }
382
1413
  if (!dryRun) {
383
- console.log(green("installed") + dim(" — start it: uai-host start"));
1414
+ console.log(
1415
+ green("installed") +
1416
+ dim(
1417
+ startOnInstall
1418
+ ? " — service registered and started"
1419
+ : " — service registered but not started; start it: uai-host start",
1420
+ ),
1421
+ );
1422
+ }
1423
+ }
1424
+
1425
+ function parseInstallArgs(rest: string[]): {
1426
+ dryRun: boolean;
1427
+ startOnInstall: boolean;
1428
+ installIntent?: string;
1429
+ } | null {
1430
+ let dryRun = false;
1431
+ let noStart = false;
1432
+ let installIntent: string | undefined;
1433
+ for (let index = 0; index < rest.length; index += 1) {
1434
+ const arg = rest[index]!;
1435
+ if (arg === "--dry-run") {
1436
+ if (dryRun) return null;
1437
+ dryRun = true;
1438
+ continue;
1439
+ }
1440
+ if (arg === "--no-start") {
1441
+ if (noStart) return null;
1442
+ noStart = true;
1443
+ continue;
1444
+ }
1445
+ if (arg === "--install-intent") {
1446
+ if (installIntent) return null;
1447
+ const value = rest[index + 1];
1448
+ if (!value || !/^[a-f0-9]{64}$/.test(value)) return null;
1449
+ installIntent = value;
1450
+ index += 1;
1451
+ continue;
1452
+ }
1453
+ return null;
384
1454
  }
1455
+ if (dryRun && installIntent) return null;
1456
+ return { dryRun, startOnInstall: !noStart, installIntent };
385
1457
  }
386
1458
 
387
1459
  /**
@@ -400,7 +1472,7 @@ function printTelemetryNotice(): void {
400
1472
  }
401
1473
 
402
1474
  async function cmdInstaller(
403
- action: "uninstall" | "start" | "stop" | "restart",
1475
+ action: "start" | "stop" | "restart",
404
1476
  dryRun: boolean,
405
1477
  ): Promise<void> {
406
1478
  // Upgrade path (review finding): operators of EXISTING installs meet
@@ -410,8 +1482,132 @@ async function cmdInstaller(
410
1482
  printTelemetryNotice();
411
1483
  }
412
1484
  const installer = await loadInstaller();
413
- await installer[action](dryRun);
414
- if (!dryRun && action === "uninstall") console.log(green("uninstalled"));
1485
+ if (dryRun) {
1486
+ await installer[action](true);
1487
+ } else {
1488
+ await runInstallLifecycleMutation(installer, () => installer[action](false));
1489
+ }
1490
+ }
1491
+
1492
+ async function cmdUninstall(rest: string[]): Promise<void> {
1493
+ const dryRun = rest.includes("--dry-run");
1494
+ const purge = rest.includes("--purge");
1495
+ if (
1496
+ rest.some((arg) => arg !== "--dry-run" && arg !== "--purge") ||
1497
+ rest.filter((arg) => arg === "--dry-run").length > 1 ||
1498
+ rest.filter((arg) => arg === "--purge").length > 1
1499
+ ) {
1500
+ console.error(red("usage: uai-host uninstall [--purge] [--dry-run]"));
1501
+ process.exitCode = 1;
1502
+ return;
1503
+ }
1504
+ const installer = await loadInstaller();
1505
+ const paths = managedRuntimePaths();
1506
+ if (dryRun) {
1507
+ await installer.uninstall(true);
1508
+ console.log(`$ rm -rf ${paths.root}`);
1509
+ if (purge) console.log(`$ rm -rf ${paths.data}`);
1510
+ return;
1511
+ }
1512
+ await reconcileInstallLifecycle(installer);
1513
+ await removeManagedRuntime({
1514
+ purgeData: purge,
1515
+ canRemove: () => !hasNonTerminalHostTasks(),
1516
+ beforeRemove: () => installer.uninstall(false),
1517
+ afterServiceTeardown: () => teardownBundledRuntimeForUninstall(purge),
1518
+ });
1519
+ console.log(
1520
+ green("uninstalled") +
1521
+ (purge
1522
+ ? dim(" — host runtime and ~/.uai data removed")
1523
+ : dim(" — ~/.uai data preserved (use --purge to remove it)")),
1524
+ );
1525
+ }
1526
+
1527
+ /**
1528
+ * The service is already down, but the active signed generation and purge
1529
+ * target are still attached here — the last moment the bundled CLI exists to
1530
+ * clean up what Uai provisioned through it. Ownership-proven and fail-closed
1531
+ * (see lib/apple-uninstall-teardown.ts): an incomplete teardown THROWS, which
1532
+ * deliberately leaves the managed-removal transaction's teardown marker and
1533
+ * the signed runtime in place for a retry — never remove the escape route
1534
+ * while Uai resources may remain.
1535
+ */
1536
+ async function teardownBundledRuntimeForUninstall(purge: boolean): Promise<void> {
1537
+ if (process.platform !== "darwin") return;
1538
+ // The durable provider claim marker is the authority for whether Apple
1539
+ // state can exist at all: it is written BEFORE any apple task provisioning
1540
+ // (runtime-provider-state.ts). Absent marker = nothing was ever
1541
+ // provisioned = nothing to tear down. A malformed marker throws from the
1542
+ // reader — uncertainty keeps the uninstall retryable. With the marker
1543
+ // present, a corrupt bundle must THROW rather than skip (review
1544
+ // 2026-08-18 round 3: the old `.catch(() => null)` silently removed the
1545
+ // signed runtime while Apple state remained — the escape-route failure).
1546
+ const providerState = readRuntimeProviderState(
1547
+ runtimeProviderStatePath(env.dataDir),
1548
+ );
1549
+ if (providerState?.statefulProvider !== "apple-container") return;
1550
+ let bundle: Awaited<ReturnType<typeof resolveBundledRuntimeFromEnvironment>>;
1551
+ try {
1552
+ bundle = await resolveBundledRuntimeFromEnvironment();
1553
+ } catch (error) {
1554
+ throw new Error(
1555
+ "Apple container state exists but the bundled runtime failed " +
1556
+ `verification (${error instanceof Error ? error.message : String(error)}); ` +
1557
+ "uninstall was left retryable — repair or reinstall the host runtime, then retry",
1558
+ );
1559
+ }
1560
+ const cliPath = bundle?.containerCliPath;
1561
+ if (!cliPath) {
1562
+ throw new Error(
1563
+ "Apple container state exists but no bundled CLI is available to tear it down; uninstall was left retryable",
1564
+ );
1565
+ }
1566
+ const cli = (args: string[], timeoutMs: number) =>
1567
+ new Promise<{ code: number | null; stdout: string; stderr: string }>(
1568
+ (resolveCli) => {
1569
+ let stdout = "";
1570
+ let stderr = "";
1571
+ let settled = false;
1572
+ const child = spawn(cliPath, args, {
1573
+ stdio: ["ignore", "pipe", "pipe"],
1574
+ });
1575
+ const settle = (code: number | null): void => {
1576
+ if (settled) return;
1577
+ settled = true;
1578
+ clearTimeout(timer);
1579
+ resolveCli({ code, stdout, stderr });
1580
+ };
1581
+ const timer = setTimeout(() => {
1582
+ child.kill("SIGKILL");
1583
+ const backstop = setTimeout(() => settle(null), 1_000);
1584
+ backstop.unref();
1585
+ }, timeoutMs);
1586
+ timer.unref();
1587
+ child.stdout?.setEncoding("utf8");
1588
+ child.stderr?.setEncoding("utf8");
1589
+ child.stdout?.on("data", (chunk: string) => {
1590
+ stdout += chunk;
1591
+ });
1592
+ child.stderr?.on("data", (chunk: string) => {
1593
+ stderr += chunk;
1594
+ });
1595
+ child.on("error", () => settle(null));
1596
+ child.on("close", (code) => settle(code));
1597
+ },
1598
+ );
1599
+ const result = await teardownAppleRuntimeStateForUninstall({ cli, purge });
1600
+ for (const removed of result.removedContainers) {
1601
+ console.log(dim(`removed container ${removed}`));
1602
+ }
1603
+ for (const removed of result.removedImages) {
1604
+ console.log(dim(`removed image ${removed}`));
1605
+ }
1606
+ if (result.problems.length > 0) {
1607
+ throw new Error(
1608
+ `Apple container teardown is incomplete — uninstall was left retryable: ${result.problems.join("; ")}`,
1609
+ );
1610
+ }
415
1611
  }
416
1612
 
417
1613
  async function loadInstaller(): Promise<Installer> {
@@ -433,19 +1629,204 @@ async function loadInstaller(): Promise<Installer> {
433
1629
  }
434
1630
  }
435
1631
 
436
- function installContext(dryRun: boolean): InstallContext {
1632
+ async function reconcileDefinitionInstall(
1633
+ installer: Installer,
1634
+ installIntent?: string,
1635
+ ): Promise<void> {
1636
+ const reconcile = (
1637
+ installer as Installer & {
1638
+ reconcileDefinitionInstall?: (installIntent?: string) => Promise<void>;
1639
+ }
1640
+ ).reconcileDefinitionInstall;
1641
+ if (!reconcile) {
1642
+ throw new Error(
1643
+ "service installer cannot reconcile an interrupted definition update",
1644
+ );
1645
+ }
1646
+ await reconcile.call(installer, installIntent);
1647
+ }
1648
+
1649
+ async function completeDefinitionInstall(
1650
+ installer: Installer,
1651
+ installIntent?: string,
1652
+ ): Promise<void> {
1653
+ const complete = (
1654
+ installer as Installer & {
1655
+ completeDefinitionInstall?: (installIntent?: string) => Promise<void>;
1656
+ }
1657
+ ).completeDefinitionInstall;
1658
+ if (!complete) {
1659
+ throw new Error(
1660
+ "service installer cannot complete its durable definition update",
1661
+ );
1662
+ }
1663
+ await complete.call(installer, installIntent);
1664
+ }
1665
+
1666
+ async function prepareDefinitionInstallRollback(
1667
+ installer: Installer,
1668
+ installIntent: string,
1669
+ ): Promise<void> {
1670
+ const prepare = (
1671
+ installer as Installer & {
1672
+ prepareDefinitionInstallRollback?: (installIntent: string) => Promise<void>;
1673
+ }
1674
+ ).prepareDefinitionInstallRollback;
1675
+ if (!prepare) {
1676
+ throw new Error(
1677
+ "service installer cannot prepare durable definition rollback",
1678
+ );
1679
+ }
1680
+ await prepare.call(installer, installIntent);
1681
+ }
1682
+
1683
+ async function resumeDefinitionInstallRollback(
1684
+ installer: Installer,
1685
+ installIntent: string,
1686
+ ): Promise<void> {
1687
+ const resume = (
1688
+ installer as Installer & {
1689
+ resumeDefinitionInstallRollback?: (installIntent: string) => Promise<void>;
1690
+ }
1691
+ ).resumeDefinitionInstallRollback;
1692
+ if (!resume) {
1693
+ throw new Error(
1694
+ "service installer cannot resume durable definition rollback",
1695
+ );
1696
+ }
1697
+ await resume.call(installer, installIntent);
1698
+ }
1699
+
1700
+ async function serviceDefinitionState(
1701
+ installer: Installer,
1702
+ managedCommand: string,
1703
+ ): Promise<ManagedServiceDefinitionState> {
1704
+ const inspect = (
1705
+ installer as Installer & {
1706
+ definitionState?: (
1707
+ managedCommand: string,
1708
+ ) => Promise<ManagedServiceDefinitionState>;
1709
+ }
1710
+ ).definitionState;
1711
+ if (!inspect) {
1712
+ throw new Error("service installer cannot inspect definition lifecycle state");
1713
+ }
1714
+ return inspect.call(installer, managedCommand);
1715
+ }
1716
+
1717
+ async function stopServiceForInstall(
1718
+ installer: Installer,
1719
+ installIntent: string,
1720
+ priorManager: ManagedServiceManagerState,
1721
+ ): Promise<void> {
1722
+ const stop = (
1723
+ installer as Installer & {
1724
+ stopForInstall?: (
1725
+ installIntent: string,
1726
+ priorManager: ManagedServiceManagerState,
1727
+ ) => Promise<void>;
1728
+ }
1729
+ ).stopForInstall;
1730
+ if (!stop) {
1731
+ throw new Error("service installer cannot stop an install-owned service");
1732
+ }
1733
+ await stop.call(installer, installIntent, priorManager);
1734
+ }
1735
+
1736
+ async function resumeServiceForInstall(
1737
+ installer: Installer,
1738
+ installIntent: string,
1739
+ priorManager: ManagedServiceManagerState,
1740
+ ): Promise<void> {
1741
+ const resume = (
1742
+ installer as Installer & {
1743
+ resumeServiceForInstall?: (
1744
+ installIntent: string,
1745
+ priorManager: ManagedServiceManagerState,
1746
+ ) => Promise<void>;
1747
+ }
1748
+ ).resumeServiceForInstall;
1749
+ if (!resume) {
1750
+ throw new Error("service installer cannot resume an install-owned service");
1751
+ }
1752
+ await resume.call(installer, installIntent, priorManager);
1753
+ }
1754
+
1755
+ async function reconcileInstallLifecycle(installer: Installer): Promise<void> {
1756
+ await reconcileManagedRuntimeInstallLifecycle(
1757
+ installLifecycleCallbacks(installer),
1758
+ );
1759
+ }
1760
+
1761
+ async function runInstallLifecycleMutation<T>(
1762
+ installer: Installer,
1763
+ mutation: () => Promise<T>,
1764
+ ): Promise<T> {
1765
+ return runManagedRuntimeInstallLifecycleMutation({
1766
+ ...installLifecycleCallbacks(installer),
1767
+ mutation,
1768
+ });
1769
+ }
1770
+
1771
+ function installLifecycleCallbacks(
1772
+ installer: Installer,
1773
+ ): ReconcileManagedRuntimeInstallLifecycleOptions {
1774
+ return {
1775
+ reconcileDefinition: (installIntent) =>
1776
+ reconcileDefinitionInstall(installer, installIntent),
1777
+ completeDefinition: (installIntent) =>
1778
+ completeDefinitionInstall(installer, installIntent),
1779
+ prepareDefinitionRollback: (installIntent) =>
1780
+ prepareDefinitionInstallRollback(installer, installIntent),
1781
+ resumeDefinitionRollback: (installIntent) =>
1782
+ resumeDefinitionInstallRollback(installer, installIntent),
1783
+ resumeStoppedService: (installIntent, priorManager) =>
1784
+ resumeServiceForInstall(installer, installIntent, priorManager),
1785
+ };
1786
+ }
1787
+
1788
+ function installContext(
1789
+ dryRun: boolean,
1790
+ startOnInstall = true,
1791
+ definitionInstallIntent?: { nonce: string; ownerPid: number },
1792
+ ): InstallContext {
437
1793
  const run = resolveRunCommand();
438
- return { execPath: run.execPath, args: run.args, cwd: uaiHome(), dryRun };
1794
+ const currentPath = process.env.PATH ?? "";
1795
+ const managedBin = pathForManagedCommand(run.execPath);
1796
+ const envPath = managedBin
1797
+ ? [managedBin, ...currentPath.split(":").filter((entry) => entry !== managedBin)].join(":")
1798
+ : currentPath;
1799
+ const context: InstallContext & {
1800
+ definitionInstallIntent?: { nonce: string; ownerPid: number };
1801
+ } = {
1802
+ execPath: run.execPath,
1803
+ args: run.args,
1804
+ cwd: uaiHome(),
1805
+ dryRun,
1806
+ envPath,
1807
+ startOnInstall,
1808
+ definitionInstallIntent,
1809
+ };
1810
+ return context;
439
1811
  }
440
1812
 
441
1813
  /** The command that runs `uai-host run`: the installed binary if on PATH,
442
1814
  * else `pnpm host-agent` from the repo (dev). */
443
1815
  function resolveRunCommand(): { execPath: string; args: string[] } {
1816
+ const managed = managedRuntimePaths();
1817
+ if (existsSync(managed.current) && existsSync(managed.shim)) {
1818
+ return { execPath: managed.shim, args: ["run"] };
1819
+ }
444
1820
  const uaiHost = which("uai-host");
445
1821
  if (uaiHost) return { execPath: uaiHost, args: ["run"] };
446
1822
  return { execPath: which("pnpm") ?? "pnpm", args: ["host-agent"] };
447
1823
  }
448
1824
 
1825
+ function pathForManagedCommand(execPath: string): string | null {
1826
+ const managed = managedRuntimePaths();
1827
+ return resolve(execPath) === managed.shim ? dirname(managed.shim) : null;
1828
+ }
1829
+
449
1830
  function which(cmd: string): string | null {
450
1831
  const finder = process.platform === "win32" ? "where" : "which";
451
1832
  const res = spawnSync(finder, [cmd], { encoding: "utf8" });
@@ -489,14 +1870,25 @@ function printHelp(): void {
489
1870
 
490
1871
  run run the service in the foreground (debug)
491
1872
  install [--dry-run] install as a per-user service for this OS
492
- uninstall remove the service
1873
+ [--no-start] register the service without launching it
1874
+ uninstall [--purge] remove service/runtime; preserve ~/.uai unless --purge
493
1875
  start | stop control the installed service
494
1876
  restart stop + start
1877
+ update accept signed updates (also resumes after rollback)
1878
+ rollback reactivate previous runtime; pause automatic updates
1879
+ uai-host-recover rollback
1880
+ roll back via previous runtime if current cannot start
1881
+ enrollment status report whether this machine has a complete identity
495
1882
  status connection, service info, active tasks (same as the UI)
496
1883
  logs [--follow] tail the service log
1884
+ runtime recheck probe Docker again and refresh cloud capabilities
497
1885
  setup --cloud <wss-url> --enroll <token>
498
1886
  claim this machine via an enrollment token from the web
499
- app (mints + writes the host credential)
1887
+ app (mints + writes the host credential); on an
1888
+ already-enrolled machine, attaches the token's workspace
1889
+ enroll <token> [--cloud <wss-url>]
1890
+ first-time setup, or attach this machine to another
1891
+ workspace (token minted on that workspace's Hosts page)
500
1892
  pair <token> store a host token directly (manual fallback)
501
1893
  open open the local UI in your browser
502
1894
  `);