@runuai/host 0.9.14 → 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 +33 -2
  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 +3060 -218
  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 +1405 -107
  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 +766 -42
  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
@@ -0,0 +1,1338 @@
1
+ /**
2
+ * Container-runtime detection and process-wide selection (ADR-101/ADR-106).
3
+ *
4
+ * The Docker paths keep the selected endpoint in `DOCKER_HOST` so every
5
+ * docker-invoking module follows the same daemon. ADR-106 adds the bundled
6
+ * Apple container backend, preferred wherever it can run: `auto` resolves the
7
+ * durable stateful-provider claim first, then the eligible bundled runtime,
8
+ * then ambient Docker — and the process pin plus the minted installation
9
+ * identity keep a wiped or replaced toolchain state fail-closed, exactly like
10
+ * a replaced Docker daemon at the same socket.
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+ import { randomUUID } from "node:crypto";
15
+ import { readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
16
+ import { release as osRelease } from "node:os";
17
+ import path from "node:path";
18
+
19
+ import { dockerCli, type DockerResult } from "./docker-exec";
20
+ import { env } from "./env";
21
+ import { claimManagedRuntimeCapabilityFloor } from "./managed-runtime";
22
+ import {
23
+ fileRuntimeProviderStateStore,
24
+ runtimeProviderStatePath,
25
+ type RuntimeProviderStateStore,
26
+ } from "./runtime-provider-state";
27
+
28
+ export type RuntimePreference = "auto" | "docker" | "apple-container";
29
+
30
+ export type ContainerRuntimeState =
31
+ | {
32
+ status: "checking";
33
+ preference: RuntimePreference;
34
+ /** Human-readable activation phase (boot visibility; ADR-106 smoke). */
35
+ detail?: string;
36
+ }
37
+ | {
38
+ status: "ready";
39
+ preference: "auto" | "docker";
40
+ provider: "docker";
41
+ }
42
+ | {
43
+ status: "ready";
44
+ preference: "auto" | "apple-container";
45
+ provider: "apple-container";
46
+ }
47
+ | {
48
+ status: "no-runtime";
49
+ preference: RuntimePreference;
50
+ message: string;
51
+ action: string;
52
+ };
53
+
54
+ export const RUNTIME_RECHECK_COMMAND = "uai-host runtime recheck";
55
+ const RUNTIME_RESTART_COMMAND = "uai-host restart";
56
+
57
+ const NO_RUNTIME_MESSAGE =
58
+ "No working container runtime was found. Install or start Docker, then run " +
59
+ `\`${RUNTIME_RECHECK_COMMAND}\`.`;
60
+ const APPLE_RUNTIME_UNAVAILABLE_MESSAGE =
61
+ "The bundled Apple container runtime could not be started. " +
62
+ `Run \`${RUNTIME_RECHECK_COMMAND}\` to retry, or set UAI_RUNTIME=docker ` +
63
+ `and run \`${RUNTIME_RESTART_COMMAND}\` to use Docker instead.`;
64
+ const PINNED_RUNTIME_UNAVAILABLE_MESSAGE =
65
+ "The Docker runtime selected for this Uai Host process is not responding. " +
66
+ `Start it and run \`${RUNTIME_RECHECK_COMMAND}\`. ` +
67
+ `To use a different Docker context or endpoint, run \`${RUNTIME_RESTART_COMMAND}\` ` +
68
+ "after selecting it.";
69
+
70
+ let state: ContainerRuntimeState = {
71
+ status: "checking",
72
+ preference: runtimePreference(process.env.UAI_RUNTIME),
73
+ };
74
+ let operational = false;
75
+ let activationRequired = false;
76
+ let readinessEpoch = 0;
77
+
78
+ type RuntimeChangeListener = (
79
+ next: ContainerRuntimeState,
80
+ previous: ContainerRuntimeState,
81
+ ) => void;
82
+
83
+ const listeners = new Set<RuntimeChangeListener>();
84
+ let detectionInFlight: Promise<ContainerRuntimeState> | null = null;
85
+ // Once a local endpoint answers, every Docker/Compose child in this process
86
+ // must stay on it. Switching a live host to another daemon strands the first
87
+ // daemon's containers, images, and volumes and makes taskDown target the wrong
88
+ // machine. A service restart is the deliberate endpoint-switch boundary.
89
+ let pinnedDockerHost: string | null = null;
90
+ let pinnedRuntimeProvider: "docker" | "apple-container" | null = null;
91
+ let pinnedDockerEngineId: string | null = null;
92
+ function defaultRuntimeProviderStateStore(): RuntimeProviderStateStore {
93
+ // Resolve env lazily. Several consumers import standard-image/runtime code
94
+ // under narrow env mocks, and module import must remain side-effect free.
95
+ const store = fileRuntimeProviderStateStore(
96
+ runtimeProviderStatePath(env.dataDir),
97
+ );
98
+ return {
99
+ read: () => store.read(),
100
+ claim: async (provider) => {
101
+ // The version-independent rollback guard must commit under the managed
102
+ // operation lock before the provider marker. A crash between them is
103
+ // conservative (capability-1 rollback stays disabled) and owns no
104
+ // provider resources; the inverse order could strand state on an old
105
+ // runtime.
106
+ // Apple provider state is only usable by a capability-3 generation
107
+ // (bundled CLI + kernel). Floor 2 would let an interrupted rollback
108
+ // resurrect a Lima-era generation over preserved Apple state.
109
+ await claimManagedRuntimeCapabilityFloor(3);
110
+ return store.claim(provider);
111
+ },
112
+ };
113
+ }
114
+
115
+ interface OperationalWaiter {
116
+ epoch: number;
117
+ finish: (operational: boolean) => void;
118
+ timer: NodeJS.Timeout;
119
+ }
120
+
121
+ const operationalWaiters = new Set<OperationalWaiter>();
122
+
123
+ interface DockerSelection {
124
+ host: string;
125
+ }
126
+
127
+ export interface DockerDaemonProof {
128
+ /** Docker Engine's stable daemon identity (`docker info .ID`). */
129
+ engineId: string;
130
+ serverVersion: string;
131
+ }
132
+
133
+ export interface RuntimeDetectionDeps {
134
+ preference?: RuntimePreference;
135
+ dockerInfo?: (selection?: DockerSelection) => Promise<DockerResult>;
136
+ /** Test seam for the context/endpoint resolver. */
137
+ dockerSelection?: () => Promise<DockerSelection | null>;
138
+ /** Bundled Apple container seam; `undefined` uses the registered binding. */
139
+ appleRuntime?: AppleRuntimeBinding | null;
140
+ /** Test seam for bounded bundled-CLI invocations. */
141
+ appleCli?: (
142
+ args: readonly string[],
143
+ options: { timeoutMs: number; maxOutputBytes: number },
144
+ ) => Promise<{ status: number | null; stdout: string; stderr: string }>;
145
+ /** Test seam for the minted installation identity. */
146
+ appleInstallationId?: {
147
+ read: (endpoint: string) => Promise<string | null>;
148
+ mint: (endpoint: string) => Promise<string>;
149
+ };
150
+ /** Test seam for the durable-row selection guard. */
151
+ appleSelectionGuard?:
152
+ | ((endpoint: string) => boolean | Promise<boolean>)
153
+ | null;
154
+ /** Test seam; production always uses os.release() (Darwin 25 = macOS 26). */
155
+ darwinRelease?: string;
156
+ /** Durable task-state ownership seam. */
157
+ providerStateStore?: RuntimeProviderStateStore;
158
+ /** Test seam; production always uses process.platform. */
159
+ platform?: NodeJS.Platform;
160
+ }
161
+
162
+ interface RuntimeAutoRecheckOptions extends RuntimeDetectionDeps {
163
+ /** Fast first retry covers Docker Desktop / OrbStack boot ordering. */
164
+ firstDelayMs?: number;
165
+ /** Later retries stay cheap while a daemon or desktop app starts. */
166
+ intervalMs?: number;
167
+ /** Bound background probing; an explicit recheck remains available later. */
168
+ maxAttempts?: number;
169
+ }
170
+
171
+ /** Parse an operator override without letting a typo silently pick a backend. */
172
+ export function runtimePreference(value: string | undefined): RuntimePreference {
173
+ return value === "docker" || value === "apple-container" ? value : "auto";
174
+ }
175
+
176
+ /** Authenticated bundled Apple container toolchain (ADR-106). */
177
+ export interface AppleRuntimeBinding {
178
+ /** Absolute path of the signed bundled `container` CLI. */
179
+ readonly containerCliPath: string;
180
+ /** Absolute path of the pinned guest kernel staged in the bundle. */
181
+ readonly kernelPath: string;
182
+ }
183
+
184
+ let appleRuntime: AppleRuntimeBinding | null = null;
185
+
186
+ /** Register the bundled Apple container toolchain from the active signed
187
+ * runtime. A source checkout, Intel Mac, or Linux archive registers null and
188
+ * keeps apple-container selection fail-closed. */
189
+ export function setAppleContainerRuntime(
190
+ binding: AppleRuntimeBinding | null,
191
+ ): void {
192
+ if (
193
+ pinnedRuntimeProvider !== null &&
194
+ (binding === null) !== (appleRuntime === null)
195
+ ) {
196
+ throw new Error(
197
+ "the Apple container runtime cannot change after container runtime selection",
198
+ );
199
+ }
200
+ appleRuntime = binding;
201
+ }
202
+
203
+ /** The active apple-container selection's toolchain, for provider binding. */
204
+ export function appleContainerRuntimeBinding(): AppleRuntimeBinding | null {
205
+ return pinnedRuntimeProvider === "apple-container" ? appleRuntime : null;
206
+ }
207
+
208
+ let appleSelectionGuard:
209
+ | ((endpoint: string) => boolean | Promise<boolean>)
210
+ | null = null;
211
+
212
+ /** Register the durable task/environment preflight for apple-container
213
+ * selection. Docker rows never wrote a stateful claim (they won `auto` by
214
+ * default before ADR-106), so this guard is how an existing Docker host
215
+ * keeps its rows: rows pinned elsewhere make `auto` fall through to Docker
216
+ * instead of activating the bundled runtime over foreign state. */
217
+ export function setAppleContainerSelectionGuard(
218
+ guard: ((endpoint: string) => boolean | Promise<boolean>) | null,
219
+ ): void {
220
+ appleSelectionGuard = guard;
221
+ }
222
+
223
+ /** A copy suitable for capability advertisements and command guards. */
224
+ export function containerRuntimeState(): ContainerRuntimeState {
225
+ if (state.status === "checking" && activationPhase !== null) {
226
+ return { ...state, detail: activationPhase };
227
+ }
228
+ return { ...state };
229
+ }
230
+
231
+ let activationPhase: string | null = null;
232
+
233
+ /** Publish the current activation phase for status surfaces and the log. A
234
+ * silent multi-minute boot (image build, volume seed, CLI reconcile) is
235
+ * indistinguishable from a wedge without this. */
236
+ export function publishActivationPhase(phase: string | null): void {
237
+ activationPhase = phase;
238
+ if (phase !== null) console.log(`[host-agent] activation: ${phase}`);
239
+ }
240
+
241
+ /** Exact machine identity persisted by TaskEnvironment records. An injected
242
+ * test probe that never resolved a local endpoint intentionally has no durable
243
+ * identity; production selection always pins one before reporting ready. */
244
+ /** The pinned backend, independent of the public readiness state. Boot-time
245
+ * maintenance (standard image, volume seeding, writable-container quarantine)
246
+ * runs while the public state is deliberately `checking`; gating its engine
247
+ * selection on readiness deadlocks a Docker-free Mac (found live 2026-08-15:
248
+ * activation could never complete because its own maintenance fell back to a
249
+ * docker binary that does not exist). */
250
+ export function pinnedContainerRuntimeProvider():
251
+ | "docker"
252
+ | "apple-container"
253
+ | null {
254
+ return pinnedRuntimeProvider;
255
+ }
256
+
257
+ /** Pin-based identity, independent of readiness/operational state. Teardown
258
+ * and reconstruction must work while the runtime is still `checking`:
259
+ * removing load is how a degraded host heals, and the live 2026-08-16 ring —
260
+ * task holds the shared volume → maintenance cannot attach → never
261
+ * operational → teardown refused → task never dies — was closed by exactly
262
+ * this. The locator comparison downstream still requires an exact match. */
263
+ export function pinnedContainerRuntimeMachineIdentity(): {
264
+ backend: "docker" | "apple-container";
265
+ endpoint: string;
266
+ engineId: string;
267
+ } | null {
268
+ if (
269
+ pinnedRuntimeProvider === null ||
270
+ pinnedDockerHost === null ||
271
+ pinnedDockerEngineId === null
272
+ ) {
273
+ return null;
274
+ }
275
+ return {
276
+ backend: pinnedRuntimeProvider,
277
+ endpoint: pinnedDockerHost,
278
+ engineId: pinnedDockerEngineId,
279
+ };
280
+ }
281
+
282
+ /** Failure message for TEARDOWN admission: only a truly absent/unpinned
283
+ * runtime refuses teardown. `checking` with a pin admits it. */
284
+ export function containerRuntimeTeardownProblem(): string | null {
285
+ if (state.status === "no-runtime") return state.message;
286
+ if (pinnedContainerRuntimeMachineIdentity() === null) {
287
+ return (
288
+ "The container runtime has not selected a backend yet. " +
289
+ "Retry after runtime detection completes."
290
+ );
291
+ }
292
+ return null;
293
+ }
294
+
295
+ export function containerRuntimeMachineIdentity(): {
296
+ backend: "docker" | "apple-container";
297
+ endpoint: string;
298
+ engineId: string;
299
+ } | null {
300
+ if (
301
+ state.status !== "ready" ||
302
+ pinnedRuntimeProvider === null ||
303
+ pinnedDockerHost === null ||
304
+ pinnedDockerEngineId === null ||
305
+ state.provider !== pinnedRuntimeProvider
306
+ ) {
307
+ return null;
308
+ }
309
+ return {
310
+ backend: pinnedRuntimeProvider,
311
+ endpoint: pinnedDockerHost,
312
+ engineId: pinnedDockerEngineId,
313
+ };
314
+ }
315
+
316
+ /** ADR-106: provider-specific asset roots belonged to the retired bundled-VM
317
+ * fallback; ambient Docker and apple-container both use the package context. */
318
+ export function containerRuntimeStandardAssetsRoot(): string | null {
319
+ return null;
320
+ }
321
+
322
+ export function containerRuntimeStandardImageRoot(): string | null {
323
+ return null;
324
+ }
325
+
326
+ export function containerRuntimeBindRoot(): string | null {
327
+ return null;
328
+ }
329
+
330
+ /** Public/control-plane view: detected is not usable until maintenance and
331
+ * persisted-task recovery have crossed their readiness fence. */
332
+ export function containerRuntimeCapabilityState(): ContainerRuntimeState {
333
+ if (state.status === "ready" && !operational) {
334
+ return { status: "checking", preference: state.preference };
335
+ }
336
+ return containerRuntimeState();
337
+ }
338
+
339
+ /** Complete one readiness transition after image maintenance + recovery. */
340
+ export function markContainerRuntimeOperational(
341
+ expectedEpoch = readinessEpoch,
342
+ ): boolean {
343
+ if (state.status !== "ready" || expectedEpoch !== readinessEpoch) return false;
344
+ operational = true;
345
+ activationRequired = false;
346
+ settleOperationalWaiters();
347
+ return true;
348
+ }
349
+
350
+ /** Fail closed while a known-ready daemon is being revalidated. Explicit
351
+ * operator recovery can also invalidate the generation so ready-to-ready
352
+ * probes still run maintenance and persisted-task recovery. */
353
+ export function suspendContainerRuntime(
354
+ invalidateGeneration = false,
355
+ ): number | null {
356
+ if (state.status !== "ready") return null;
357
+ if (!operational && !invalidateGeneration) return null;
358
+ operational = false;
359
+ if (invalidateGeneration) {
360
+ readinessEpoch += 1;
361
+ activationRequired = true;
362
+ settleOperationalWaiters();
363
+ }
364
+ return readinessEpoch;
365
+ }
366
+
367
+ /** Token for work that must not survive a daemon readiness transition. */
368
+ export function containerRuntimeReadinessEpoch(): number {
369
+ return readinessEpoch;
370
+ }
371
+
372
+ export function containerRuntimeEpochIsCurrent(epoch: number): boolean {
373
+ return epoch === readinessEpoch && state.status === "ready";
374
+ }
375
+
376
+ /** Command-scoped fence: the exact generation is still current AND the
377
+ * runtime is operational right now. Plain suspension (connection
378
+ * revalidation) keeps the epoch and ready status while operational drops —
379
+ * a pass fenced only on the epoch would keep mutating against an
380
+ * unverified daemon. */
381
+ export function containerRuntimeGenerationOperational(epoch: number): boolean {
382
+ return epoch === readinessEpoch && state.status === "ready" && operational;
383
+ }
384
+
385
+ /** Whether a ready verdict must cross maintenance/recovery before use. */
386
+ export function containerRuntimeActivationRequired(): boolean {
387
+ return state.status === "ready" && activationRequired;
388
+ }
389
+
390
+ /**
391
+ * Wait for image maintenance and persisted-task recovery to release one exact
392
+ * runtime generation. A later daemon/provider generation resolves false; it
393
+ * can never accidentally release a task admitted for the older backend.
394
+ */
395
+ export function waitForContainerRuntimeOperational(
396
+ expectedEpoch = readinessEpoch,
397
+ timeoutMs = 15 * 60_000,
398
+ ): Promise<boolean> {
399
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
400
+ return Promise.reject(new Error("runtime operational timeout is invalid"));
401
+ }
402
+ if (expectedEpoch !== readinessEpoch || state.status !== "ready") {
403
+ return Promise.resolve(false);
404
+ }
405
+ if (operational) return Promise.resolve(true);
406
+ if (timeoutMs === 0) return Promise.resolve(false);
407
+
408
+ return new Promise<boolean>((resolve) => {
409
+ const waiter: OperationalWaiter = {
410
+ epoch: expectedEpoch,
411
+ finish: resolve,
412
+ timer: setTimeout(() => {
413
+ if (!operationalWaiters.delete(waiter)) return;
414
+ resolve(false);
415
+ }, timeoutMs),
416
+ };
417
+ operationalWaiters.add(waiter);
418
+ });
419
+ }
420
+
421
+ /**
422
+ * Claim apple-container as stateful before task-up performs its first
423
+ * container mutation. Docker needs no marker because it wins `auto` by
424
+ * default. A stale generation after the fsync is conservative (future `auto`
425
+ * stays on the bundled runtime), never unsafe.
426
+ */
427
+ /**
428
+ * Claim apple-container as stateful before ACTIVATION's first mutation
429
+ * (shared-volume create, image build) — which happens before any task
430
+ * exists. Uninstall reads an absent marker as "nothing to tear down", so a
431
+ * zero-task install→activate→uninstall previously skipped Apple cleanup
432
+ * entirely (review 2026-08-18 round 4). No readiness gate: activation
433
+ * maintenance runs while the public state is still `checking` by design.
434
+ */
435
+ export async function claimContainerRuntimeForMaintenance(
436
+ store: RuntimeProviderStateStore = defaultRuntimeProviderStateStore(),
437
+ ): Promise<void> {
438
+ // The full claim commits a rollback guard under the managed operation
439
+ // lock — once per host lifetime is its job. Every later activation pass
440
+ // takes the cheap read-skip.
441
+ if ((await store.read())?.statefulProvider === "apple-container") return;
442
+ await store.claim("apple-container");
443
+ }
444
+
445
+ export async function claimContainerRuntimeForTask(
446
+ expectedEpoch = readinessEpoch,
447
+ store: RuntimeProviderStateStore = defaultRuntimeProviderStateStore(),
448
+ ): Promise<boolean> {
449
+ if (
450
+ expectedEpoch !== readinessEpoch ||
451
+ state.status !== "ready" ||
452
+ !operational
453
+ ) {
454
+ return false;
455
+ }
456
+ if (state.provider === "docker") return true;
457
+ await store.claim("apple-container");
458
+ return (
459
+ expectedEpoch === readinessEpoch &&
460
+ state.status === "ready" &&
461
+ state.provider === "apple-container" &&
462
+ operational
463
+ );
464
+ }
465
+
466
+ /** Explicit operator recovery: invalidate even a stale ready verdict before
467
+ * probing so a healthy ready-to-ready result still requires a full activation
468
+ * and persisted-task recovery pass. */
469
+ export async function forceContainerRuntimeRecheck(
470
+ deps: RuntimeDetectionDeps = {},
471
+ ): Promise<{ state: ContainerRuntimeState; epoch: number | null }> {
472
+ // Gate new work synchronously, but do not invalidate the generation until
473
+ // an older ordinary probe has settled. Invalidating first would let that
474
+ // probe's ready-to-ready publication start maintenance for an intermediate
475
+ // epoch while this forced refresh was still waiting for the shared detector.
476
+ if (state.status === "ready") suspendContainerRuntime();
477
+ // A forced request must not get absorbed by an older ordinary probe. Wait
478
+ // for it, invalidate its final verdict exactly once, then run the refresh.
479
+ if (detectionInFlight) await detectionInFlight;
480
+ const forcedEpoch =
481
+ state.status === "ready" ? suspendContainerRuntime(true) : null;
482
+ // Re-probe the process pin. Never clear DOCKER_HOST or consult a newly
483
+ // selected ambient context here: even a short async gap would let unrelated
484
+ // Docker work escape to another daemon. Switching endpoints requires a
485
+ // service restart and is explained by the degraded-state message.
486
+ const next = await initializeContainerRuntime(deps);
487
+ return {
488
+ state: next,
489
+ epoch:
490
+ next.status === "ready"
491
+ ? (forcedEpoch ?? containerRuntimeReadinessEpoch())
492
+ : null,
493
+ };
494
+ }
495
+
496
+ /** Subscribe to successful rechecks; used to refresh the capability frame. */
497
+ export function onContainerRuntimeChange(
498
+ listener: RuntimeChangeListener,
499
+ ): () => void {
500
+ listeners.add(listener);
501
+ return () => listeners.delete(listener);
502
+ }
503
+
504
+ /**
505
+ * Quarantine a selected backend after durable environment reconstruction
506
+ * proves it is not the machine those tasks belong to. The process pin is
507
+ * retained so no later recheck can escape to a different daemon; only a host
508
+ * restart after explicit operator repair can change it.
509
+ */
510
+ export function rejectContainerRuntimeSelection(
511
+ message: string,
512
+ action = RUNTIME_RESTART_COMMAND,
513
+ ): void {
514
+ const safeMessage = message.trim();
515
+ const safeAction = action.trim();
516
+ publishContainerRuntimeState(
517
+ {
518
+ status: "no-runtime",
519
+ preference: state.preference,
520
+ message:
521
+ safeMessage.length > 0 && safeMessage.length <= 1_000
522
+ ? safeMessage
523
+ : "The selected container runtime does not match persisted task state.",
524
+ action:
525
+ safeAction.length > 0 && safeAction.length <= 200
526
+ ? safeAction
527
+ : RUNTIME_RESTART_COMMAND,
528
+ },
529
+ true,
530
+ );
531
+ }
532
+
533
+ /**
534
+ * Detect and apply the process-wide runtime selection.
535
+ *
536
+ * Never throws: a missing CLI, stopped daemon, or probe timeout is a normal
537
+ * degraded-host state, not a reason to crash-loop the service.
538
+ */
539
+ export function initializeContainerRuntime(
540
+ deps: RuntimeDetectionDeps = {},
541
+ ): Promise<ContainerRuntimeState> {
542
+ // The bounded background retry and an operator-triggered recheck can land
543
+ // together. Join them so a slower stale failure cannot overwrite a newer
544
+ // ready verdict (and so only one `docker info` process runs at a time).
545
+ if (detectionInFlight) return detectionInFlight;
546
+ const running = detectContainerRuntime(deps)
547
+ .then(publishContainerRuntimeState)
548
+ .finally(() => {
549
+ if (detectionInFlight === running) detectionInFlight = null;
550
+ });
551
+ detectionInFlight = running;
552
+ return running;
553
+ }
554
+
555
+ /**
556
+ * Task-admission entrypoint: repeat ordinary detection so a task that raced
557
+ * host boot still gets a runtime. Runtime activation is intentionally owned
558
+ * by the existing state-change listener; callers can wait on its exact epoch
559
+ * with waitForContainerRuntimeOperational.
560
+ */
561
+ export async function ensureContainerRuntimeForTask(
562
+ deps: RuntimeDetectionDeps = {},
563
+ ): Promise<ContainerRuntimeState> {
564
+ // ADR-106: apple-container activation (service start, kernel install,
565
+ // identity mint) happens inside ordinary detection, so a task needs no
566
+ // separate lazy-provisioning path.
567
+ return initializeContainerRuntime(deps);
568
+ }
569
+
570
+ export function startContainerRuntimeAutoRecheck(
571
+ options: RuntimeAutoRecheckOptions = {},
572
+ ): () => void {
573
+ if (
574
+ state.status !== "no-runtime" ||
575
+ state.action === RUNTIME_RESTART_COMMAND ||
576
+ (options.maxAttempts ?? 45) <= 0
577
+ ) {
578
+ return () => {};
579
+ }
580
+
581
+ const firstDelayMs = options.firstDelayMs ?? 5_000;
582
+ const intervalMs = options.intervalMs ?? 20_000;
583
+ const maxAttempts = options.maxAttempts ?? 45;
584
+ let attempts = 0;
585
+ let stopped = false;
586
+ let timer: NodeJS.Timeout | null = null;
587
+
588
+ const schedule = (delayMs: number): void => {
589
+ timer = setTimeout(() => {
590
+ timer = null;
591
+ if (stopped || state.status === "ready") return;
592
+ attempts += 1;
593
+ void initializeContainerRuntime(options).then((next) => {
594
+ if (stopped || next.status === "ready" || attempts >= maxAttempts) {
595
+ return;
596
+ }
597
+ schedule(intervalMs);
598
+ });
599
+ }, delayMs);
600
+ timer.unref?.();
601
+ };
602
+
603
+ schedule(firstDelayMs);
604
+ return () => {
605
+ stopped = true;
606
+ if (timer) clearTimeout(timer);
607
+ timer = null;
608
+ };
609
+ }
610
+
611
+ /** Pure detection seam kept injectable for deterministic startup tests. */
612
+ interface AppleSystemProbe {
613
+ readonly endpoint: string;
614
+ readonly installationId: string;
615
+ }
616
+
617
+ const APPLE_INSTALLATION_ID_FILE = "uai-installation-id";
618
+ const APPLE_INSTALLATION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{7,63}$/;
619
+
620
+ function defaultAppleCli(
621
+ binding: AppleRuntimeBinding,
622
+ ): NonNullable<RuntimeDetectionDeps["appleCli"]> {
623
+ return (args, options) =>
624
+ new Promise((resolve) => {
625
+ const child = spawn(binding.containerCliPath, [...args], {
626
+ stdio: ["ignore", "pipe", "pipe"],
627
+ });
628
+ const stdout: Buffer[] = [];
629
+ const stderr: Buffer[] = [];
630
+ let bytes = 0;
631
+ // A SIGKILLed CLI normally closes at once, but an unreapable child
632
+ // must never strand boot/recheck/activation on a promise that only
633
+ // settles on close.
634
+ const killAndSettle = (): void => {
635
+ child.kill("SIGKILL");
636
+ const settle = setTimeout(() => finish(null), 5_000);
637
+ settle.unref?.();
638
+ };
639
+ const collect = (target: Buffer[]) => (chunk: Buffer) => {
640
+ bytes += chunk.length;
641
+ if (bytes > options.maxOutputBytes) killAndSettle();
642
+ else target.push(chunk);
643
+ };
644
+ child.stdout.on("data", collect(stdout));
645
+ child.stderr.on("data", collect(stderr));
646
+ const timer = setTimeout(killAndSettle, options.timeoutMs);
647
+ timer.unref?.();
648
+ let settled = false;
649
+ const finish = (status: number | null): void => {
650
+ if (settled) return;
651
+ settled = true;
652
+ clearTimeout(timer);
653
+ resolve({
654
+ status,
655
+ stdout: Buffer.concat(stdout).toString("utf8"),
656
+ stderr: Buffer.concat(stderr).toString("utf8"),
657
+ });
658
+ };
659
+ child.once("error", () => finish(null));
660
+ child.once("close", (code) => finish(code));
661
+ });
662
+ }
663
+
664
+ function defaultAppleInstallationId(): NonNullable<
665
+ RuntimeDetectionDeps["appleInstallationId"]
666
+ > {
667
+ return {
668
+ async read(endpoint) {
669
+ try {
670
+ const raw = (
671
+ await fsReadFile(path.join(endpoint, APPLE_INSTALLATION_ID_FILE), "utf8")
672
+ ).trim();
673
+ return APPLE_INSTALLATION_ID.test(raw) ? raw : null;
674
+ } catch {
675
+ return null;
676
+ }
677
+ },
678
+ async mint(endpoint) {
679
+ const id = randomUUID();
680
+ await fsWriteFile(
681
+ path.join(endpoint, APPLE_INSTALLATION_ID_FILE),
682
+ `${id}\n`,
683
+ { mode: 0o600, flag: "wx" },
684
+ );
685
+ return id;
686
+ },
687
+ };
688
+ }
689
+
690
+ function parseAppleSystemStatus(
691
+ stdout: string,
692
+ ): { running: boolean; appRoot: string } | null {
693
+ let running = false;
694
+ let appRoot: string | null = null;
695
+ for (const line of stdout.split("\n")) {
696
+ if (/^status\s+/.test(line)) {
697
+ running = line.replace(/^status\s+/, "").trim() === "running";
698
+ } else if (/^appRoot\s+/.test(line)) {
699
+ appRoot = line.replace(/^appRoot\s+/, "").trim().replace(/\/$/, "");
700
+ }
701
+ }
702
+ if (appRoot === null || !appRoot.startsWith("/")) return null;
703
+ return { running, appRoot };
704
+ }
705
+
706
+ function selectedAppleRuntime(
707
+ deps: RuntimeDetectionDeps,
708
+ ): AppleRuntimeBinding | null {
709
+ return deps.appleRuntime === undefined ? appleRuntime : deps.appleRuntime;
710
+ }
711
+
712
+ function appleRuntimeEligible(deps: RuntimeDetectionDeps): boolean {
713
+ if ((deps.platform ?? process.platform) !== "darwin") return false;
714
+ // Darwin kernel 25 shipped with macOS 26 — the toolchain's floor. The
715
+ // artifact itself is arm64-only, so architecture is settled upstream.
716
+ const release = deps.darwinRelease ?? osRelease();
717
+ return Number.parseInt(release.split(".")[0] ?? "0", 10) >= 25;
718
+ }
719
+
720
+ /** Prove the per-user toolchain state and its minted installation identity
721
+ * WITHOUT starting anything — the pinned-runtime recheck path. */
722
+ async function probeAppleSystem(
723
+ deps: RuntimeDetectionDeps,
724
+ ): Promise<AppleSystemProbe | null> {
725
+ const binding = selectedAppleRuntime(deps);
726
+ if (binding === null || !appleRuntimeEligible(deps)) return null;
727
+ const cli = deps.appleCli ?? defaultAppleCli(binding);
728
+ const status = await cli(["system", "status"], {
729
+ timeoutMs: 15_000,
730
+ maxOutputBytes: 64 * 1024,
731
+ });
732
+ if (status.status !== 0) return null;
733
+ const parsed = parseAppleSystemStatus(status.stdout);
734
+ if (parsed === null || !parsed.running) return null;
735
+ const ids = deps.appleInstallationId ?? defaultAppleInstallationId();
736
+ const installationId = await ids.read(parsed.appRoot);
737
+ if (installationId === null) return null;
738
+ return { endpoint: parsed.appRoot, installationId };
739
+ }
740
+
741
+ /**
742
+ * Activate and select the bundled Apple container runtime: start the user
743
+ * services when they are down, install the pinned guest kernel and mint the
744
+ * durable installation identity on first activation, then pin the process to
745
+ * (apple-container, state root, installation id). Returns null when the
746
+ * backend is ineligible or activation fails — `auto` falls through to Docker.
747
+ */
748
+ async function probeAndSelectApple(
749
+ preference: "auto" | "apple-container",
750
+ deps: RuntimeDetectionDeps,
751
+ ): Promise<ContainerRuntimeState | null> {
752
+ const binding = selectedAppleRuntime(deps);
753
+ if (binding === null || !appleRuntimeEligible(deps)) return null;
754
+ const cli = deps.appleCli ?? defaultAppleCli(binding);
755
+ const ids = deps.appleInstallationId ?? defaultAppleInstallationId();
756
+
757
+ let status = await cli(["system", "status"], {
758
+ timeoutMs: 15_000,
759
+ maxOutputBytes: 64 * 1024,
760
+ });
761
+ let parsed = status.status === 0 ? parseAppleSystemStatus(status.stdout) : null;
762
+ if (parsed === null || !parsed.running) {
763
+ const started = await cli(["system", "start"], {
764
+ timeoutMs: 120_000,
765
+ maxOutputBytes: 256 * 1024,
766
+ });
767
+ if (started.status !== 0) return null;
768
+ status = await cli(["system", "status"], {
769
+ timeoutMs: 15_000,
770
+ maxOutputBytes: 64 * 1024,
771
+ });
772
+ parsed = status.status === 0 ? parseAppleSystemStatus(status.stdout) : null;
773
+ if (parsed === null || !parsed.running) return null;
774
+ }
775
+
776
+ // Durable rows pinned to another backend keep their machine: fall through
777
+ // (auto reaches Docker) rather than activating over foreign state. The
778
+ // exact engine-identity fence still runs before maintenance either way.
779
+ const guard =
780
+ deps.appleSelectionGuard === undefined
781
+ ? appleSelectionGuard
782
+ : deps.appleSelectionGuard;
783
+ if (guard) {
784
+ try {
785
+ if ((await guard(parsed.appRoot)) !== true) return null;
786
+ } catch {
787
+ return null;
788
+ }
789
+ }
790
+
791
+ let installationId = await ids.read(parsed.appRoot);
792
+ if (installationId === null) {
793
+ // First activation of this state root: the guest kernel must be in place
794
+ // before any container can boot, and the identity is minted only after
795
+ // the kernel install succeeded so a crash re-runs both.
796
+ const kernel = await cli(
797
+ ["system", "kernel", "set", "--binary", binding.kernelPath],
798
+ { timeoutMs: 60_000, maxOutputBytes: 256 * 1024 },
799
+ );
800
+ if (kernel.status !== 0) return null;
801
+ try {
802
+ installationId = await ids.mint(parsed.appRoot);
803
+ } catch {
804
+ installationId = await ids.read(parsed.appRoot);
805
+ if (installationId === null) return null;
806
+ }
807
+ }
808
+
809
+ if (
810
+ !pinRuntime(
811
+ "apple-container",
812
+ parsed.appRoot,
813
+ installationId,
814
+ binding.containerCliPath,
815
+ )
816
+ ) {
817
+ return detectPinnedRuntime(preference, deps);
818
+ }
819
+ return { status: "ready", preference, provider: "apple-container" };
820
+ }
821
+
822
+ export async function detectContainerRuntime(
823
+ deps: RuntimeDetectionDeps = {},
824
+ ): Promise<ContainerRuntimeState> {
825
+ const configured = process.env.UAI_RUNTIME;
826
+ if (
827
+ deps.preference === undefined &&
828
+ configured !== undefined &&
829
+ configured !== "auto" &&
830
+ configured !== "docker" &&
831
+ configured !== "apple-container"
832
+ ) {
833
+ return {
834
+ status: "no-runtime",
835
+ preference: "auto",
836
+ message:
837
+ "UAI_RUNTIME has an invalid value. " +
838
+ "Use `auto`, `docker`, or `apple-container`, then restart Uai Host.",
839
+ action: RUNTIME_RESTART_COMMAND,
840
+ };
841
+ }
842
+ const preference =
843
+ deps.preference ?? runtimePreference(process.env.UAI_RUNTIME);
844
+
845
+ // The selected provider/endpoint is immutable for this process. Rechecks
846
+ // may restart the same toolchain services or prove the same ambient
847
+ // daemon, but cannot cross the boundary while task/credential work may
848
+ // still be in flight.
849
+ if (pinnedDockerHost && pinnedRuntimeProvider) {
850
+ return detectPinnedRuntime(preference, deps);
851
+ }
852
+
853
+ const store =
854
+ deps.providerStateStore ?? defaultRuntimeProviderStateStore();
855
+
856
+ if (preference === "apple-container") {
857
+ return (
858
+ (await probeAndSelectApple(preference, deps)) ??
859
+ appleUnavailable(preference, selectedAppleRuntime(deps) === null, deps)
860
+ );
861
+ }
862
+
863
+ if (preference === "auto") {
864
+ let stickyApple = false;
865
+ try {
866
+ stickyApple =
867
+ (await store.read())?.statefulProvider === "apple-container";
868
+ } catch {
869
+ return {
870
+ status: "no-runtime",
871
+ preference,
872
+ message:
873
+ "Uai Host could not validate its durable container-runtime selection. " +
874
+ "Repair the local runtime state, then restart Uai Host.",
875
+ action: RUNTIME_RESTART_COMMAND,
876
+ };
877
+ }
878
+ if (stickyApple) {
879
+ // The bundled runtime owns durable task state: never silently move
880
+ // that state to an ambient daemon, even when activation fails.
881
+ return (
882
+ (await probeAndSelectApple(preference, deps)) ??
883
+ appleUnavailable(preference, selectedAppleRuntime(deps) === null, deps)
884
+ );
885
+ }
886
+ // ADR-106: prefer the bundled runtime wherever it can run — ambient
887
+ // Docker existing is not a reason to skip it. A failed activation falls
888
+ // through to Docker rather than degrading a machine that has one.
889
+ const selected = await probeAndSelectApple(preference, deps);
890
+ if (selected !== null) return selected;
891
+ }
892
+
893
+ return detectAmbientDocker(preference, deps);
894
+ }
895
+
896
+ function appleUnavailable(
897
+ preference: RuntimePreference,
898
+ unbundled: boolean,
899
+ deps: RuntimeDetectionDeps,
900
+ ): ContainerRuntimeState {
901
+ const eligible = appleRuntimeEligible(deps);
902
+ return {
903
+ status: "no-runtime",
904
+ preference,
905
+ message: unbundled
906
+ ? "The bundled Apple container runtime is not part of this installation. " +
907
+ "Install the standalone runtime on Apple Silicon with macOS 26 or newer, " +
908
+ `or set UAI_RUNTIME=docker and run \`${RUNTIME_RESTART_COMMAND}\`.`
909
+ : eligible
910
+ ? APPLE_RUNTIME_UNAVAILABLE_MESSAGE
911
+ : "The bundled Apple container runtime needs Apple Silicon and " +
912
+ "macOS 26 or newer. Use Docker on this machine: set " +
913
+ `UAI_RUNTIME=docker and run \`${RUNTIME_RESTART_COMMAND}\`.`,
914
+ action: RUNTIME_RECHECK_COMMAND,
915
+ };
916
+ }
917
+
918
+ async function detectAmbientDocker(
919
+ preference: "auto" | "docker",
920
+ deps: RuntimeDetectionDeps,
921
+ ): Promise<ContainerRuntimeState> {
922
+ // A successful ambient `docker info` is not a durable selection: collapse a
923
+ // named context to its exact local endpoint before publishing DOCKER_HOST.
924
+ const selection =
925
+ deps.dockerSelection !== undefined
926
+ ? await deps.dockerSelection()
927
+ : deps.dockerInfo === undefined
928
+ ? await resolveDockerSelection()
929
+ : null;
930
+ if (selection && !isLocalDockerEndpoint(selection.host)) {
931
+ return {
932
+ status: "no-runtime",
933
+ preference,
934
+ message:
935
+ "The selected Docker endpoint is remote. " +
936
+ "Uai Host requires a local Unix-socket daemon for workspace mounts and loopback tunnels. " +
937
+ `Select a local Docker context, then run \`${RUNTIME_RESTART_COMMAND}\`.`,
938
+ action: RUNTIME_RESTART_COMMAND,
939
+ };
940
+ }
941
+
942
+ if (deps.dockerInfo === undefined && selection === null) {
943
+ return {
944
+ status: "no-runtime",
945
+ preference,
946
+ message:
947
+ "Uai Host could not resolve the selected Docker context to a local endpoint. " +
948
+ `Select a local Docker context and run \`${RUNTIME_RECHECK_COMMAND}\`.`,
949
+ action: RUNTIME_RECHECK_COMMAND,
950
+ };
951
+ }
952
+
953
+ const proof = await probeDocker(selection, deps);
954
+ if (proof !== null) {
955
+ if (selection) {
956
+ if (!pinRuntime("docker", selection.host, proof.engineId)) {
957
+ return pinnedRuntimeUnavailable(preference);
958
+ }
959
+ }
960
+ return { status: "ready", preference, provider: "docker" };
961
+ }
962
+ return {
963
+ status: "no-runtime",
964
+ preference,
965
+ message:
966
+ preference === "docker"
967
+ ? "No working Docker runtime was found. Install or start Docker, then run " +
968
+ `\`${RUNTIME_RECHECK_COMMAND}\`.`
969
+ : NO_RUNTIME_MESSAGE,
970
+ action: RUNTIME_RECHECK_COMMAND,
971
+ };
972
+ }
973
+
974
+ async function detectPinnedRuntime(
975
+ preference: RuntimePreference,
976
+ deps: RuntimeDetectionDeps,
977
+ ): Promise<ContainerRuntimeState> {
978
+ const provider = pinnedRuntimeProvider!;
979
+ const host = pinnedDockerHost!;
980
+ if (
981
+ (preference === "docker" && provider !== "docker") ||
982
+ (preference === "apple-container" && provider !== "apple-container")
983
+ ) {
984
+ return pinnedRuntimeUnavailable(preference);
985
+ }
986
+
987
+ if (provider === "apple-container") {
988
+ // Re-prove the same per-user state root and the same minted installation
989
+ // id; a wiped or replaced toolchain state must fail closed exactly like a
990
+ // replaced Docker daemon at the same socket.
991
+ const probe = await probeAppleSystem(deps);
992
+ if (
993
+ probe === null ||
994
+ probe.endpoint !== host ||
995
+ pinnedDockerEngineId === null ||
996
+ probe.installationId !== pinnedDockerEngineId
997
+ ) {
998
+ return pinnedRuntimeUnavailable(preference);
999
+ }
1000
+ applyPinnedAppleRuntimeEnvironment(pinnedAppleCliPath);
1001
+ return {
1002
+ status: "ready",
1003
+ preference: preference === "auto" ? "auto" : "apple-container",
1004
+ provider,
1005
+ };
1006
+ }
1007
+
1008
+ applyPinnedDockerHost(host);
1009
+ const proof = await probeDocker({ host }, deps);
1010
+ if (
1011
+ proof === null ||
1012
+ pinnedDockerEngineId === null ||
1013
+ proof.engineId !== pinnedDockerEngineId
1014
+ ) {
1015
+ return pinnedRuntimeUnavailable(preference);
1016
+ }
1017
+ return {
1018
+ status: "ready",
1019
+ preference: preference === "auto" ? "auto" : "docker",
1020
+ provider: "docker",
1021
+ };
1022
+ }
1023
+
1024
+ /** Read-only TOCTOU fence for work that inspected daemon-owned resources.
1025
+ * Unlike a normal recheck this does not publish state or run a controller
1026
+ * transition; its caller must quarantine the selection when null is returned. */
1027
+ export async function reprobeContainerRuntimeMachineIdentity(
1028
+ deps: Pick<
1029
+ RuntimeDetectionDeps,
1030
+ | "dockerInfo"
1031
+ | "appleRuntime"
1032
+ | "appleCli"
1033
+ | "appleInstallationId"
1034
+ | "darwinRelease"
1035
+ > = {},
1036
+ ): Promise<ReturnType<typeof containerRuntimeMachineIdentity>> {
1037
+ if (
1038
+ state.status !== "ready" ||
1039
+ pinnedRuntimeProvider === null ||
1040
+ pinnedDockerHost === null ||
1041
+ pinnedDockerEngineId === null
1042
+ ) {
1043
+ return null;
1044
+ }
1045
+ if (pinnedRuntimeProvider === "apple-container") {
1046
+ // The pinned endpoint is the per-user state root, not a Docker socket:
1047
+ // publishing it as DOCKER_HOST (and clearing the script selection
1048
+ // variables) would both fail this fence and corrupt the process
1049
+ // environment for every agent script. Re-prove the same state root and
1050
+ // minted installation id through the Apple probe instead.
1051
+ const probe = await probeAppleSystem(deps);
1052
+ if (
1053
+ probe === null ||
1054
+ probe.endpoint !== pinnedDockerHost ||
1055
+ probe.installationId !== pinnedDockerEngineId
1056
+ ) {
1057
+ return null;
1058
+ }
1059
+ applyPinnedAppleRuntimeEnvironment(pinnedAppleCliPath);
1060
+ return {
1061
+ backend: pinnedRuntimeProvider,
1062
+ endpoint: pinnedDockerHost,
1063
+ engineId: pinnedDockerEngineId,
1064
+ };
1065
+ }
1066
+ applyPinnedDockerHost(pinnedDockerHost);
1067
+ const proof = await probeDocker({ host: pinnedDockerHost }, deps);
1068
+ if (proof?.engineId !== pinnedDockerEngineId) return null;
1069
+ return {
1070
+ backend: pinnedRuntimeProvider,
1071
+ endpoint: pinnedDockerHost,
1072
+ engineId: pinnedDockerEngineId,
1073
+ };
1074
+ }
1075
+
1076
+ async function probeDocker(
1077
+ selection: DockerSelection | null,
1078
+ deps: RuntimeDetectionDeps,
1079
+ ): Promise<DockerDaemonProof | null> {
1080
+ const dockerInfo =
1081
+ deps.dockerInfo ??
1082
+ ((selected?: DockerSelection) =>
1083
+ dockerCli(
1084
+ [
1085
+ "--host",
1086
+ selected!.host,
1087
+ "info",
1088
+ "--format",
1089
+ "{{.ID}}\t{{.ServerVersion}}",
1090
+ ],
1091
+ { timeoutMs: 10_000, maxOutputBytes: 4_096 },
1092
+ ));
1093
+ try {
1094
+ return parseDockerDaemonProof(await dockerInfo(selection ?? undefined));
1095
+ } catch {
1096
+ return null;
1097
+ }
1098
+ }
1099
+
1100
+ /** Strict parser for the one atomic identity/liveness probe. Trimming is
1101
+ * deliberately forbidden: a second record, CRLF ambiguity, or whitespace in
1102
+ * either identity component is not a daemon proof. */
1103
+ export function parseDockerDaemonProof(
1104
+ result: DockerResult | null,
1105
+ ): DockerDaemonProof | null {
1106
+ if (
1107
+ result?.status !== 0 ||
1108
+ result.outputTruncated === true ||
1109
+ Buffer.byteLength(result.stdout) > 4_096
1110
+ ) {
1111
+ return null;
1112
+ }
1113
+ const match = /^([A-Za-z0-9][A-Za-z0-9:._-]{0,511})\t([!-~]{1,128})\n$/.exec(
1114
+ result.stdout,
1115
+ );
1116
+ if (!match) return null;
1117
+ return { engineId: match[1]!, serverVersion: match[2]! };
1118
+ }
1119
+
1120
+ async function resolveDockerSelection(): Promise<DockerSelection | null> {
1121
+ const configuredContext = process.env.DOCKER_CONTEXT?.trim();
1122
+ const configuredHost = process.env.DOCKER_HOST?.trim();
1123
+ // Docker gives an explicit context precedence over DOCKER_HOST, so resolve
1124
+ // that same selection before collapsing it to the immutable local endpoint.
1125
+ if (!configuredContext && configuredHost) return { host: configuredHost };
1126
+
1127
+ let context = configuredContext;
1128
+ if (!context) {
1129
+ const shown = await dockerCli(["context", "show"], { timeoutMs: 10_000 });
1130
+ if (shown.status !== 0) return null;
1131
+ context = shown.stdout.trim();
1132
+ }
1133
+ if (!context) return null;
1134
+ const inspected = await dockerCli(
1135
+ [
1136
+ "context",
1137
+ "inspect",
1138
+ context,
1139
+ "--format",
1140
+ "{{.Endpoints.docker.Host}}",
1141
+ ],
1142
+ { timeoutMs: 10_000 },
1143
+ );
1144
+ const host = inspected.stdout.trim();
1145
+ return inspected.status === 0 && host ? { host } : null;
1146
+ }
1147
+
1148
+ function applyPinnedDockerHost(host: string): DockerSelection {
1149
+ process.env.DOCKER_HOST = host;
1150
+ // DOCKER_CONTEXT takes precedence over DOCKER_HOST. Clear it after resolving
1151
+ // the named context so `docker context use` cannot retarget later children.
1152
+ delete process.env.DOCKER_CONTEXT;
1153
+ // A Docker selection must never leave a stale Apple pin visible to the
1154
+ // agent scripts, which consume the backend through these variables.
1155
+ delete process.env.UAI_TASK_RUNTIME;
1156
+ delete process.env.UAI_CONTAINER_CLI;
1157
+ return { host };
1158
+ }
1159
+
1160
+ /** Publish the Apple selection to the agent scripts (task-up/down/status
1161
+ * branch on UAI_TASK_RUNTIME and exec the bundled CLI at UAI_CONTAINER_CLI).
1162
+ * The scripts fail closed on a missing/unexecutable CLI path, so an absent
1163
+ * binding deletes the variable rather than inventing a fallback. */
1164
+ function applyPinnedAppleRuntimeEnvironment(cliPath: string | null): void {
1165
+ process.env.UAI_TASK_RUNTIME = "apple-container";
1166
+ if (cliPath !== null && cliPath.length > 0) {
1167
+ process.env.UAI_CONTAINER_CLI = cliPath;
1168
+ } else {
1169
+ delete process.env.UAI_CONTAINER_CLI;
1170
+ }
1171
+ }
1172
+
1173
+ let pinnedAppleCliPath: string | null = null;
1174
+
1175
+ function pinRuntime(
1176
+ provider: "docker" | "apple-container",
1177
+ host: string,
1178
+ engineId: string,
1179
+ appleCliPath?: string,
1180
+ ): boolean {
1181
+ if (
1182
+ (pinnedDockerHost !== null && pinnedDockerHost !== host) ||
1183
+ (pinnedRuntimeProvider !== null && pinnedRuntimeProvider !== provider) ||
1184
+ (pinnedDockerEngineId !== null && pinnedDockerEngineId !== engineId)
1185
+ ) {
1186
+ if (pinnedDockerHost && pinnedRuntimeProvider === "docker") {
1187
+ applyPinnedDockerHost(pinnedDockerHost);
1188
+ } else if (pinnedDockerHost && pinnedRuntimeProvider === "apple-container") {
1189
+ applyPinnedAppleRuntimeEnvironment(pinnedAppleCliPath);
1190
+ }
1191
+ return false;
1192
+ }
1193
+ pinnedDockerHost = host;
1194
+ pinnedRuntimeProvider = provider;
1195
+ pinnedDockerEngineId = engineId;
1196
+ // apple-container has no Docker socket: the endpoint pin is the per-user
1197
+ // state root; DOCKER_HOST is deliberately left untouched while the script
1198
+ // selection variables are published instead.
1199
+ if (provider === "docker") {
1200
+ applyPinnedDockerHost(host);
1201
+ } else {
1202
+ pinnedAppleCliPath = appleCliPath ?? pinnedAppleCliPath;
1203
+ applyPinnedAppleRuntimeEnvironment(pinnedAppleCliPath);
1204
+ }
1205
+ return true;
1206
+ }
1207
+
1208
+ function isLocalDockerEndpoint(host: string): boolean {
1209
+ if (
1210
+ host.length > 4_096 ||
1211
+ !host.startsWith("unix:///") ||
1212
+ /[\0\r\n]/.test(host) ||
1213
+ host.includes("?") ||
1214
+ host.includes("#")
1215
+ ) {
1216
+ return false;
1217
+ }
1218
+ return host.slice("unix://".length).startsWith("/");
1219
+ }
1220
+
1221
+ function pinnedRuntimeUnavailable(
1222
+ preference: RuntimePreference,
1223
+ ): ContainerRuntimeState {
1224
+ return {
1225
+ status: "no-runtime",
1226
+ preference,
1227
+ message:
1228
+ pinnedRuntimeProvider === "apple-container"
1229
+ ? "The Apple container runtime selected for this Uai Host process is " +
1230
+ `not responding. Run \`${RUNTIME_RECHECK_COMMAND}\` to repair it. ` +
1231
+ "To switch providers, set UAI_RUNTIME explicitly and run " +
1232
+ `\`${RUNTIME_RESTART_COMMAND}\`.`
1233
+ : PINNED_RUNTIME_UNAVAILABLE_MESSAGE,
1234
+ action: RUNTIME_RECHECK_COMMAND,
1235
+ };
1236
+ }
1237
+
1238
+
1239
+ function publishContainerRuntimeState(
1240
+ next: ContainerRuntimeState,
1241
+ forceInvalidateGeneration = false,
1242
+ ): ContainerRuntimeState {
1243
+ const previous = containerRuntimeState();
1244
+ const previousProvider =
1245
+ previous.status === "ready" ? previous.provider : null;
1246
+ const nextProvider = next.status === "ready" ? next.provider : null;
1247
+ if (
1248
+ forceInvalidateGeneration ||
1249
+ (previous.status === "ready") !== (next.status === "ready") ||
1250
+ (previousProvider !== null &&
1251
+ nextProvider !== null &&
1252
+ previousProvider !== nextProvider)
1253
+ ) {
1254
+ readinessEpoch += 1;
1255
+ }
1256
+ if (
1257
+ next.status === "ready" &&
1258
+ (previous.status !== "ready" || previousProvider !== nextProvider)
1259
+ ) {
1260
+ activationRequired = true;
1261
+ }
1262
+ if (
1263
+ next.status !== "ready" ||
1264
+ previous.status !== "ready" ||
1265
+ previousProvider !== nextProvider
1266
+ ) {
1267
+ operational = false;
1268
+ }
1269
+ state = next;
1270
+ settleOperationalWaiters();
1271
+ for (const listener of listeners) {
1272
+ try {
1273
+ listener(containerRuntimeState(), previous);
1274
+ } catch (error) {
1275
+ console.warn(
1276
+ `[host-agent] container runtime listener failed: ${error instanceof Error ? error.message : String(error)}`,
1277
+ );
1278
+ }
1279
+ }
1280
+ return containerRuntimeState();
1281
+ }
1282
+
1283
+ function settleOperationalWaiters(): void {
1284
+ for (const waiter of operationalWaiters) {
1285
+ if (waiter.epoch === readinessEpoch && state.status === "ready") {
1286
+ if (!operational) continue;
1287
+ operationalWaiters.delete(waiter);
1288
+ clearTimeout(waiter.timer);
1289
+ waiter.finish(true);
1290
+ continue;
1291
+ }
1292
+ operationalWaiters.delete(waiter);
1293
+ clearTimeout(waiter.timer);
1294
+ waiter.finish(false);
1295
+ }
1296
+ }
1297
+
1298
+ /** @internal Test isolation for the process-lifetime selection invariant. */
1299
+ export function resetContainerRuntimeSelectionForTests(): void {
1300
+ if (process.env.NODE_ENV !== "test") {
1301
+ throw new Error("runtime selection reset is test-only");
1302
+ }
1303
+ pinnedDockerHost = null;
1304
+ pinnedRuntimeProvider = null;
1305
+ pinnedDockerEngineId = null;
1306
+ appleRuntime = null;
1307
+ operational = false;
1308
+ activationRequired = false;
1309
+ state = {
1310
+ status: "checking",
1311
+ preference: runtimePreference(process.env.UAI_RUNTIME),
1312
+ };
1313
+ for (const waiter of operationalWaiters) {
1314
+ operationalWaiters.delete(waiter);
1315
+ clearTimeout(waiter.timer);
1316
+ waiter.finish(false);
1317
+ }
1318
+ }
1319
+
1320
+ /** Human-facing task failure, or null while the host can attempt Docker work. */
1321
+ export function containerRuntimeProblem(): string | null {
1322
+ if (state.status === "no-runtime") return state.message;
1323
+ if (state.status !== "ready" || !operational) {
1324
+ return (
1325
+ "The container runtime is still preparing this host. " +
1326
+ "Retry after runtime recovery completes."
1327
+ );
1328
+ }
1329
+ return null;
1330
+ }
1331
+
1332
+ /** Expensive repair/recovery work belongs only to a genuine readiness edge. */
1333
+ export function containerRuntimeBecameReady(
1334
+ next: ContainerRuntimeState,
1335
+ previous: ContainerRuntimeState,
1336
+ ): boolean {
1337
+ return next.status === "ready" && previous.status !== "ready";
1338
+ }