@runuai/host 0.9.14 → 0.9.43

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 +3051 -200
  53. package/lib/preview-sidecar.ts +68 -14
  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 +956 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +459 -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,757 @@
1
+ /**
2
+ * ADR-106 Apple container TaskEnvironment provider.
3
+ *
4
+ * One lightweight VM per task container via Apple's `container` toolchain,
5
+ * invoked ONLY through the authenticated bundled CLI path resolved from the
6
+ * capability-3 descriptor — never from PATH. Container IPs are host-routable
7
+ * over vmnet, so editor/preview routing dials the container address directly
8
+ * and this provider publishes no host ports at all.
9
+ *
10
+ * CLI semantics encoded here were probed against container 1.2.2:
11
+ * - `inspect <name>` exits 1 with "container not found" for absence and
12
+ * otherwise prints a JSON array with {id, status:{state, networks[]},
13
+ * configuration:{labels, image, mounts}}; `id` IS the container name.
14
+ * - `stop` is idempotent (exit 0 for an already-stopped container).
15
+ * - `cp` uses docker-style `name:path` addressing.
16
+ * - `exec` supports -i/-d, --env K=V and bare K inheritance, --user,
17
+ * --workdir.
18
+ */
19
+
20
+ import { spawn as nodeSpawn } from "node:child_process";
21
+ import { lstatSync } from "node:fs";
22
+ import path from "node:path";
23
+
24
+ import type { TaskDownResult } from "../agent";
25
+ import { assertSafeHostTaskId } from "../task-identity";
26
+ import {
27
+ capturedCliExec,
28
+ streamingCliExec,
29
+ } from "./docker";
30
+ import {
31
+ assertTaskEnvironmentProcessRequest,
32
+ assertTaskEnvironmentSessionRequest,
33
+ type TaskEnvironmentCopyRequest,
34
+ type TaskEnvironmentDescriptor,
35
+ type TaskEnvironmentDetachedSessionRequest,
36
+ type TaskEnvironmentExecRequest,
37
+ type TaskEnvironmentExecResult,
38
+ type TaskEnvironmentHandle,
39
+ type TaskEnvironmentLocator,
40
+ type TaskEnvironmentPort,
41
+ type TaskEnvironmentProcess,
42
+ type TaskEnvironmentProvider,
43
+ type TaskEnvironmentRecoveryContext,
44
+ type TaskEnvironmentRecoveryResult,
45
+ type TaskEnvironmentSessionRequest,
46
+ type TaskEnvironmentSpawnRequest,
47
+ type TaskEnvironmentStatus,
48
+ } from "./types";
49
+
50
+ export const APPLE_TASK_ENVIRONMENT_PROVIDER = "apple-container";
51
+ const APPLE_LOCATOR_SCHEMA_VERSION = 1;
52
+ const MAX_LOCATOR_COMPONENT = 512;
53
+ const INSPECT_OUTPUT_LIMIT = 256 * 1024;
54
+ export const APPLE_TASK_LABEL = "com.uai.task";
55
+
56
+ export interface AppleMachineIdentity {
57
+ backend: "apple-container";
58
+ /** The per-user state root that owns every container (application
59
+ * support directory of the bundled toolchain). */
60
+ endpoint: string;
61
+ /** Host-minted durable installation id — the daemon-ID equivalent for a
62
+ * runtime with no daemon identity of its own. */
63
+ engineId: string;
64
+ }
65
+
66
+ interface AppleTaskEnvironmentLocatorValue {
67
+ schemaVersion: typeof APPLE_LOCATOR_SCHEMA_VERSION;
68
+ taskId: string;
69
+ containerName: string;
70
+ hostWorktreePath: string;
71
+ machine: AppleMachineIdentity;
72
+ }
73
+
74
+ export interface AppleCliResult {
75
+ status: number | null;
76
+ stdout: string;
77
+ stderr: string;
78
+ truncated?: boolean;
79
+ }
80
+
81
+ export type AppleCliRunner = (
82
+ args: readonly string[],
83
+ options: { timeoutMs: number; maxOutputBytes: number },
84
+ ) => Promise<AppleCliResult>;
85
+
86
+ export interface AppleContainerTaskEnvironmentDeps<
87
+ TInput = never,
88
+ TCredentials = never,
89
+ TResult = never,
90
+ > {
91
+ /** Absolute path of the authenticated bundled `container` CLI. Late-bound
92
+ * so an HMR reload or runtime re-resolution cannot strand a stale path. */
93
+ containerCliPath: () => string;
94
+ machineIdentity: () => AppleMachineIdentity;
95
+ /** Captured (non-streaming) CLI operations. Tests inject a fake. */
96
+ run?: AppleCliRunner;
97
+ spawn?: typeof nodeSpawn;
98
+ /** Provisioning is wired only where the concrete task-up contract exists
99
+ * (lib/task-environment/index.ts); this module stays agnostic of it. An
100
+ * absent group keeps provision() fail-closed. */
101
+ provisioning?: AppleContainerProvisioningDeps<TInput, TCredentials, TResult>;
102
+ /** Complete teardown through task-down.sh (container + derived image +
103
+ * workspace + durable row). Production wiring always supplies it. */
104
+ taskEnvironmentDown?: (taskId: string) => Promise<TaskDownResult>;
105
+ /** Late-bound recovery implementation (orchestrator-owned, like Docker's). */
106
+ recoveryDriver?: () => AppleTaskEnvironmentRecoveryDriver | null;
107
+ }
108
+
109
+ /** The operational surface every handle needs; provisioning stays separate so
110
+ * a reconstruct-only registration keeps its narrower obligations. */
111
+ interface ResolvedAppleContainerDeps {
112
+ containerCliPath: () => string;
113
+ machineIdentity: () => AppleMachineIdentity;
114
+ run: AppleCliRunner;
115
+ spawn: typeof nodeSpawn;
116
+ taskEnvironmentDown?: (taskId: string) => Promise<TaskDownResult>;
117
+ recoveryDriver?: () => AppleTaskEnvironmentRecoveryDriver | null;
118
+ }
119
+
120
+ export type AppleTaskEnvironmentRecoveryDriver = (
121
+ descriptor: TaskEnvironmentDescriptor,
122
+ context: TaskEnvironmentRecoveryContext,
123
+ ) => Promise<TaskEnvironmentRecoveryResult>;
124
+
125
+ export interface AppleContainerProvisioningDeps<TInput, TCredentials, TResult> {
126
+ /** Runs task-up with the selection environment already published by the
127
+ * runtime pin (UAI_TASK_RUNTIME / UAI_CONTAINER_CLI). */
128
+ taskUp: (input: TInput, credentials?: TCredentials) => Promise<TResult>;
129
+ inputTaskId: (input: TInput) => string;
130
+ /** The host worktree path this task will occupy, derivable before task-up
131
+ * runs so a crash mid-provision still leaves a recoverable locator. */
132
+ hostWorktreePath: (taskId: string) => string;
133
+ resultWorktreePath: (result: TResult) => string;
134
+ }
135
+
136
+ export function appleTaskContainerName(taskId: string): string {
137
+ assertSafeHostTaskId(taskId);
138
+ return `task-${taskId}-app`;
139
+ }
140
+
141
+ export function createAppleContainerTaskEnvironmentProvider<
142
+ TInput = never,
143
+ TCredentials = never,
144
+ TResult = never,
145
+ >(
146
+ deps: AppleContainerTaskEnvironmentDeps<TInput, TCredentials, TResult>,
147
+ ): TaskEnvironmentProvider<TInput, TCredentials, TResult, TaskDownResult> {
148
+ const resolved = {
149
+ containerCliPath: deps.containerCliPath,
150
+ machineIdentity: deps.machineIdentity,
151
+ run: deps.run ?? defaultRunner(deps.containerCliPath),
152
+ spawn: deps.spawn ?? nodeSpawn,
153
+ taskEnvironmentDown: deps.taskEnvironmentDown,
154
+ recoveryDriver: deps.recoveryDriver,
155
+ };
156
+ const provisioning = deps.provisioning;
157
+ return {
158
+ kind: APPLE_TASK_ENVIRONMENT_PROVIDER,
159
+ async provision(request) {
160
+ if (provisioning === undefined) {
161
+ throw new Error("apple-container provisioning is not available yet");
162
+ }
163
+ if (request.taskId !== provisioning.inputTaskId(request.input)) {
164
+ throw new Error("task environment provision identity mismatch");
165
+ }
166
+ const machine = validateAppleMachineIdentity(resolved.machineIdentity());
167
+ const preparedLocator = appleTaskEnvironmentLocator(
168
+ request.taskId,
169
+ provisioning.hostWorktreePath(request.taskId),
170
+ machine,
171
+ );
172
+ await request.onPrepared?.(preparedLocator);
173
+ const result = await provisioning.taskUp(
174
+ request.input,
175
+ request.credentials,
176
+ );
177
+ const prepared = parseAppleTaskEnvironmentLocator(preparedLocator);
178
+ // task-up composes its worktree path in shell from the operator's
179
+ // UAI_WORKSPACE_ROOT verbatim; resolve before comparing so a benign
180
+ // trailing slash cannot fail the proof after the container is up, while
181
+ // a genuinely different directory still fails closed.
182
+ if (
183
+ path.resolve(provisioning.resultWorktreePath(result)) !==
184
+ prepared.hostWorktreePath
185
+ ) {
186
+ throw new Error(
187
+ "task environment returned an unexpected worktree path",
188
+ );
189
+ }
190
+ return {
191
+ handle: new AppleContainerTaskEnvironmentHandle(
192
+ preparedLocator,
193
+ resolved,
194
+ ),
195
+ result,
196
+ };
197
+ },
198
+ async reconstruct(locator) {
199
+ const persisted = parseAppleTaskEnvironmentLocator(locator);
200
+ const current = validateAppleMachineIdentity(resolved.machineIdentity());
201
+ if (
202
+ persisted.machine.endpoint !== current.endpoint ||
203
+ persisted.machine.engineId !== current.engineId
204
+ ) {
205
+ throw new Error(
206
+ "task environment belongs to a different container backend",
207
+ );
208
+ }
209
+ return new AppleContainerTaskEnvironmentHandle(locator, resolved);
210
+ },
211
+ };
212
+ }
213
+
214
+ export function appleTaskEnvironmentLocator(
215
+ taskId: string,
216
+ hostWorktreePath: string,
217
+ machine: AppleMachineIdentity,
218
+ ): TaskEnvironmentLocator {
219
+ const value: AppleTaskEnvironmentLocatorValue = {
220
+ schemaVersion: APPLE_LOCATOR_SCHEMA_VERSION,
221
+ taskId,
222
+ containerName: appleTaskContainerName(taskId),
223
+ hostWorktreePath,
224
+ machine: validateAppleMachineIdentity(machine),
225
+ };
226
+ validateAppleLocatorValue(value);
227
+ return {
228
+ schemaVersion: 1,
229
+ provider: APPLE_TASK_ENVIRONMENT_PROVIDER,
230
+ value,
231
+ };
232
+ }
233
+
234
+ export function parseAppleTaskEnvironmentLocator(
235
+ locator: TaskEnvironmentLocator,
236
+ ): AppleTaskEnvironmentLocatorValue {
237
+ if (
238
+ locator.schemaVersion !== 1 ||
239
+ locator.provider !== APPLE_TASK_ENVIRONMENT_PROVIDER
240
+ ) {
241
+ throw new Error(
242
+ "task environment locator does not belong to Apple container",
243
+ );
244
+ }
245
+ return validateAppleLocatorValue(locator.value);
246
+ }
247
+
248
+ class AppleContainerTaskEnvironmentHandle
249
+ implements TaskEnvironmentHandle<TaskDownResult>
250
+ {
251
+ readonly descriptor: TaskEnvironmentDescriptor;
252
+ readonly durableIdentity: string;
253
+ readonly #value: AppleTaskEnvironmentLocatorValue;
254
+
255
+ constructor(
256
+ locator: TaskEnvironmentLocator,
257
+ private readonly deps: ResolvedAppleContainerDeps,
258
+ ) {
259
+ this.#value = parseAppleTaskEnvironmentLocator(locator);
260
+ this.durableIdentity = this.#value.containerName;
261
+ this.descriptor = {
262
+ taskId: this.#value.taskId,
263
+ locator,
264
+ workspacePath: "/workspace",
265
+ };
266
+ }
267
+
268
+ exec(request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult> {
269
+ assertTaskEnvironmentProcessRequest(request);
270
+ return capturedCliExec(
271
+ this.deps.spawn,
272
+ this.deps.containerCliPath(),
273
+ appleExecArgs(this.#value.containerName, request, "interactive"),
274
+ request,
275
+ );
276
+ }
277
+
278
+ async spawn(
279
+ request: TaskEnvironmentSpawnRequest,
280
+ ): Promise<TaskEnvironmentProcess> {
281
+ assertTaskEnvironmentProcessRequest(request);
282
+ return streamingCliExec(
283
+ this.deps.spawn,
284
+ this.deps.containerCliPath(),
285
+ appleExecArgs(this.#value.containerName, request, "interactive"),
286
+ request,
287
+ );
288
+ }
289
+
290
+ async spawnSession(
291
+ request: TaskEnvironmentSessionRequest,
292
+ ): Promise<TaskEnvironmentProcess> {
293
+ assertTaskEnvironmentSessionRequest(request);
294
+ return streamingCliExec(
295
+ this.deps.spawn,
296
+ this.deps.containerCliPath(),
297
+ appleExecArgs(this.#value.containerName, request, "interactive"),
298
+ request,
299
+ );
300
+ }
301
+
302
+ async launchDetachedSession(
303
+ request: TaskEnvironmentDetachedSessionRequest,
304
+ ): Promise<TaskEnvironmentExecResult> {
305
+ assertTaskEnvironmentSessionRequest(request);
306
+ const result = await this.deps.run(
307
+ appleExecArgs(this.#value.containerName, request, "detached"),
308
+ {
309
+ timeoutMs: request.launchTimeoutMs,
310
+ maxOutputBytes: request.maxOutputBytes,
311
+ },
312
+ );
313
+ return {
314
+ exitCode: result.status,
315
+ signal: null,
316
+ stdout: Buffer.from(result.stdout),
317
+ stderr: Buffer.from(result.stderr),
318
+ stdoutTruncated: result.truncated === true,
319
+ stderrTruncated: result.truncated === true,
320
+ };
321
+ }
322
+
323
+ async copy(request: TaskEnvironmentCopyRequest): Promise<void> {
324
+ validateCopyRequest(request);
325
+ const args =
326
+ request.direction === "into"
327
+ ? [
328
+ "cp",
329
+ request.source,
330
+ `${this.#value.containerName}:${request.destination}`,
331
+ ]
332
+ : [
333
+ "cp",
334
+ `${this.#value.containerName}:${request.source}`,
335
+ request.destination,
336
+ ];
337
+ const result = await this.deps.run(args, {
338
+ timeoutMs: 120_000,
339
+ maxOutputBytes: 1024 * 1024,
340
+ });
341
+ requireCliSuccess(result, "copy task environment data");
342
+ }
343
+
344
+ async ports(): Promise<TaskEnvironmentPort[]> {
345
+ // Direct-IP model: nothing is published on the host. Editor/preview
346
+ // routing resolves the container's vmnet address per connection through
347
+ // the provider-private inspection below.
348
+ return [];
349
+ }
350
+
351
+ async status(): Promise<TaskEnvironmentStatus> {
352
+ const inspected = await this.inspect();
353
+ // Workspace presence is a DISK fact, independent of container state: an
354
+ // absent container preserves its worktree (the cloud's stopped-vs-error
355
+ // decision hangs on exactly this), and a present container can have lost
356
+ // it. Only a real non-symlink directory counts.
357
+ const workspacePresent = ((): boolean => {
358
+ try {
359
+ const stat = lstatSync(
360
+ path.join(this.#value.hostWorktreePath, "workspace"),
361
+ );
362
+ return stat.isDirectory() && !stat.isSymbolicLink();
363
+ } catch {
364
+ return false;
365
+ }
366
+ })();
367
+ if (inspected.kind === "absent") {
368
+ return { state: "absent", instances: [], workspacePresent };
369
+ }
370
+ if (inspected.kind === "unknown") {
371
+ return { state: "unknown", detail: inspected.detail };
372
+ }
373
+ if (inspected.taskLabel !== this.#value.taskId) {
374
+ // A same-name container that does not carry this task's label is a
375
+ // foreign object, not this environment. It must never be started,
376
+ // executed into, or treated as evidence of any state.
377
+ return {
378
+ state: "unknown",
379
+ detail: "the task container name is held by a foreign container",
380
+ };
381
+ }
382
+ // Only exact terminal states map. A transitional answer ("starting",
383
+ // "stopping", ...) reported as stopped would let recovery issue a
384
+ // redundant start and persist stopped while the container goes live.
385
+ if (inspected.state !== "running" && inspected.state !== "stopped") {
386
+ return {
387
+ state: "unknown",
388
+ detail: `the task container is in a transitional state (${inspected.state})`,
389
+ };
390
+ }
391
+ return {
392
+ state: inspected.state,
393
+ instances: [this.#value.containerName],
394
+ workspacePresent,
395
+ };
396
+ }
397
+
398
+ async recover(
399
+ context: TaskEnvironmentRecoveryContext,
400
+ ): Promise<TaskEnvironmentRecoveryResult> {
401
+ const driver = this.deps.recoveryDriver?.();
402
+ if (driver) return driver(this.descriptor, context);
403
+ // Without a driver (provider tests), only a live environment may claim
404
+ // recovery; stopped/absent is deferred so it can never read as healthy.
405
+ const status = await this.status();
406
+ if (status.state === "unknown") {
407
+ return { outcome: "deferred", detail: status.detail };
408
+ }
409
+ if (status.state === "running") {
410
+ return { outcome: "recovered", status };
411
+ }
412
+ return {
413
+ outcome: "deferred",
414
+ detail: `task environment is ${status.state} and no recovery driver is bound`,
415
+ };
416
+ }
417
+
418
+ async stop(): Promise<void> {
419
+ // Idempotent by CLI contract (verified: exit 0 on already-stopped).
420
+ const result = await this.deps.run(
421
+ ["stop", this.#value.containerName],
422
+ { timeoutMs: 60_000, maxOutputBytes: 256 * 1024 },
423
+ );
424
+ if (
425
+ result.status !== 0 &&
426
+ !isNotFound(result, this.#value.containerName)
427
+ ) {
428
+ requireCliSuccess(result, "stop task environment");
429
+ }
430
+ }
431
+
432
+ async teardown(): Promise<TaskDownResult> {
433
+ // task-down.sh owns complete destruction — container, derived image, the
434
+ // task directory with its writable Git data, and the durable row — with
435
+ // its own name-anchored absence proof. The container-only path below
436
+ // exists for provider tests; production wiring always injects the script.
437
+ if (this.deps.taskEnvironmentDown !== undefined) {
438
+ return this.deps.taskEnvironmentDown(this.#value.taskId);
439
+ }
440
+ await this.stop();
441
+ const removed = await this.deps.run(
442
+ ["rm", this.#value.containerName],
443
+ { timeoutMs: 60_000, maxOutputBytes: 256 * 1024 },
444
+ );
445
+ const alreadyGone = isNotFound(removed, this.#value.containerName);
446
+ if (removed.status !== 0 && !alreadyGone) {
447
+ requireCliSuccess(removed, "remove task environment");
448
+ }
449
+ const after = await this.inspect();
450
+ if (after.kind !== "absent") {
451
+ throw new Error(
452
+ after.kind === "unknown"
453
+ ? `task environment teardown is unconfirmed: ${after.detail}`
454
+ : "task environment teardown left the container present",
455
+ );
456
+ }
457
+ return alreadyGone ? { alreadyGone: true } : { status: "removed" };
458
+ }
459
+
460
+ /** Provider-private identity-bound inspection: the routing layer uses the
461
+ * returned vmnet address only together with the task-label proof. */
462
+ async inspectRoute(): Promise<
463
+ | { kind: "running"; ipv4Address: string }
464
+ | { kind: "unavailable" }
465
+ | { kind: "unknown"; detail: string }
466
+ > {
467
+ const inspected = await this.inspect();
468
+ if (inspected.kind === "absent") return { kind: "unavailable" };
469
+ if (inspected.kind === "unknown") return inspected;
470
+ if (
471
+ inspected.state !== "running" ||
472
+ inspected.taskLabel !== this.#value.taskId ||
473
+ inspected.ipv4Address === null
474
+ ) {
475
+ return { kind: "unavailable" };
476
+ }
477
+ return { kind: "running", ipv4Address: inspected.ipv4Address };
478
+ }
479
+
480
+ async inspect(): Promise<
481
+ | { kind: "present"; state: string; taskLabel: string | null; ipv4Address: string | null }
482
+ | { kind: "absent" }
483
+ | { kind: "unknown"; detail: string }
484
+ > {
485
+ const result = await this.deps.run(
486
+ ["inspect", this.#value.containerName],
487
+ { timeoutMs: 15_000, maxOutputBytes: INSPECT_OUTPUT_LIMIT },
488
+ );
489
+ if (result.status !== 0) {
490
+ if (isNotFound(result, this.#value.containerName)) {
491
+ return { kind: "absent" };
492
+ }
493
+ return {
494
+ kind: "unknown",
495
+ detail: boundedDetail(result.stderr) || "container inspect failed",
496
+ };
497
+ }
498
+ if (result.truncated === true) {
499
+ return { kind: "unknown", detail: "container inspect output overflowed" };
500
+ }
501
+ let parsed: unknown;
502
+ try {
503
+ parsed = JSON.parse(result.stdout);
504
+ } catch {
505
+ return { kind: "unknown", detail: "container inspect output is not JSON" };
506
+ }
507
+ if (!Array.isArray(parsed) || parsed.length !== 1) {
508
+ return { kind: "unknown", detail: "container inspect shape is invalid" };
509
+ }
510
+ const entry = parsed[0] as {
511
+ id?: unknown;
512
+ status?: { state?: unknown; networks?: unknown };
513
+ configuration?: { labels?: unknown };
514
+ };
515
+ if (entry.id !== this.#value.containerName) {
516
+ return { kind: "unknown", detail: "container inspect identity mismatch" };
517
+ }
518
+ const state =
519
+ typeof entry.status?.state === "string" ? entry.status.state : null;
520
+ if (state === null) {
521
+ return { kind: "unknown", detail: "container inspect state is missing" };
522
+ }
523
+ const labels = entry.configuration?.labels;
524
+ const taskLabel =
525
+ labels && typeof labels === "object" && !Array.isArray(labels)
526
+ ? typeof (labels as Record<string, unknown>)[APPLE_TASK_LABEL] === "string"
527
+ ? ((labels as Record<string, unknown>)[APPLE_TASK_LABEL] as string)
528
+ : null
529
+ : null;
530
+ return {
531
+ kind: "present",
532
+ state,
533
+ taskLabel,
534
+ ipv4Address: firstIpv4(entry.status?.networks),
535
+ };
536
+ }
537
+ }
538
+
539
+ function appleExecArgs(
540
+ containerName: string,
541
+ request:
542
+ | TaskEnvironmentExecRequest
543
+ | TaskEnvironmentSpawnRequest
544
+ | TaskEnvironmentSessionRequest
545
+ | TaskEnvironmentDetachedSessionRequest,
546
+ mode: "interactive" | "detached",
547
+ ): string[] {
548
+ const args = ["exec", mode === "detached" ? "-d" : "-i"];
549
+ if (request.cwd) args.push("--workdir", request.cwd);
550
+ if (request.user) args.push("--user", request.user);
551
+ for (const name of request.inheritEnv ?? []) {
552
+ if (process.env[name] !== undefined) args.push("--env", name);
553
+ }
554
+ for (const [name, value] of Object.entries(request.env ?? {}).sort(
555
+ ([left], [right]) => left.localeCompare(right),
556
+ )) {
557
+ args.push("--env", `${name}=${value}`);
558
+ }
559
+ args.push(containerName, ...request.argv);
560
+ return args;
561
+ }
562
+
563
+ export function appleCliRunner(
564
+ containerCliPath: () => string,
565
+ ): AppleCliRunner {
566
+ return defaultRunner(containerCliPath);
567
+ }
568
+
569
+ function defaultRunner(containerCliPath: () => string): AppleCliRunner {
570
+ return (args, options) =>
571
+ new Promise((resolve) => {
572
+ const child = nodeSpawn(containerCliPath(), [...args], {
573
+ stdio: ["ignore", "pipe", "pipe"],
574
+ });
575
+ const stdout: Buffer[] = [];
576
+ const stderr: Buffer[] = [];
577
+ let bytes = 0;
578
+ let truncated = false;
579
+ const collect = (target: Buffer[]) => (chunk: Buffer) => {
580
+ bytes += chunk.length;
581
+ if (bytes > options.maxOutputBytes) {
582
+ truncated = true;
583
+ child.kill("SIGKILL");
584
+ // The overflow kill needs the same bounded settlement as the
585
+ // deadline kill: an unreapable child must not strand the caller.
586
+ const settle = setTimeout(() => finish(null), 5_000);
587
+ settle.unref?.();
588
+ } else {
589
+ target.push(chunk);
590
+ }
591
+ };
592
+ child.stdout.on("data", collect(stdout));
593
+ child.stderr.on("data", collect(stderr));
594
+ let settled = false;
595
+ const timer = setTimeout(() => {
596
+ child.kill("SIGKILL");
597
+ // A SIGKILLed CLI normally closes at once, but an unreapable child
598
+ // must not strand activation/recovery on a promise that never
599
+ // settles — the caller's deadline has already expired.
600
+ const settle = setTimeout(() => finish(null), 5_000);
601
+ settle.unref?.();
602
+ }, options.timeoutMs);
603
+ timer.unref?.();
604
+ const finish = (status: number | null): void => {
605
+ if (settled) return;
606
+ settled = true;
607
+ clearTimeout(timer);
608
+ resolve({
609
+ status,
610
+ stdout: Buffer.concat(stdout).toString("utf8"),
611
+ stderr: Buffer.concat(stderr).toString("utf8"),
612
+ truncated,
613
+ });
614
+ };
615
+ child.once("error", (error) => {
616
+ stderr.push(Buffer.from(error.message));
617
+ finish(null);
618
+ });
619
+ child.once("close", (code) => finish(code));
620
+ });
621
+ }
622
+
623
+ function validateAppleMachineIdentity(
624
+ value: AppleMachineIdentity,
625
+ ): AppleMachineIdentity {
626
+ if (value.backend !== "apple-container") {
627
+ throw new Error("Apple container machine backend is invalid");
628
+ }
629
+ const endpoint = exactComponent(value.endpoint, "machine endpoint");
630
+ if (!endpoint.startsWith("/")) {
631
+ throw new Error("Apple container machine endpoint must be absolute");
632
+ }
633
+ const engineId = exactComponent(value.engineId, "installation id");
634
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(engineId)) {
635
+ throw new Error("Apple container installation id is invalid");
636
+ }
637
+ return { backend: "apple-container", endpoint, engineId };
638
+ }
639
+
640
+ function validateAppleLocatorValue(
641
+ value: unknown,
642
+ ): AppleTaskEnvironmentLocatorValue {
643
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
644
+ throw new Error("Apple container locator must be an object");
645
+ }
646
+ const record = value as Record<string, unknown>;
647
+ const allowed = new Set([
648
+ "schemaVersion",
649
+ "taskId",
650
+ "containerName",
651
+ "hostWorktreePath",
652
+ "machine",
653
+ ]);
654
+ if (Object.keys(record).some((key) => !allowed.has(key))) {
655
+ throw new Error("Apple container locator has unexpected fields");
656
+ }
657
+ if (record.schemaVersion !== APPLE_LOCATOR_SCHEMA_VERSION) {
658
+ throw new Error("unsupported Apple container locator schema");
659
+ }
660
+ const taskId = exactComponent(record.taskId, "task id");
661
+ assertSafeHostTaskId(taskId);
662
+ if (record.containerName !== appleTaskContainerName(taskId)) {
663
+ throw new Error("Apple container locator name does not match its task");
664
+ }
665
+ const hostWorktreePath = exactComponent(
666
+ record.hostWorktreePath,
667
+ "worktree path",
668
+ 4096,
669
+ );
670
+ if (!hostWorktreePath.startsWith("/")) {
671
+ throw new Error("Apple container locator worktree path must be absolute");
672
+ }
673
+ const machine = validateAppleMachineIdentity(
674
+ record.machine as AppleMachineIdentity,
675
+ );
676
+ return {
677
+ schemaVersion: APPLE_LOCATOR_SCHEMA_VERSION,
678
+ taskId,
679
+ containerName: record.containerName,
680
+ hostWorktreePath,
681
+ machine,
682
+ };
683
+ }
684
+
685
+ function validateCopyRequest(request: TaskEnvironmentCopyRequest): void {
686
+ if (request.direction !== "into" && request.direction !== "out") {
687
+ throw new Error("task environment copy direction is invalid");
688
+ }
689
+ for (const [label, value] of [
690
+ ["source", request.source],
691
+ ["destination", request.destination],
692
+ ] as const) {
693
+ if (
694
+ typeof value !== "string" ||
695
+ value.length === 0 ||
696
+ value.length > 4096 ||
697
+ /[\0\r\n]/.test(value) ||
698
+ value.includes(":")
699
+ ) {
700
+ throw new Error(`task environment copy ${label} is invalid`);
701
+ }
702
+ }
703
+ }
704
+
705
+ function requireCliSuccess(result: AppleCliResult, operation: string): void {
706
+ if (result.status === 0) return;
707
+ const detail = boundedDetail(result.stderr || result.stdout);
708
+ throw new Error(`${operation} failed${detail ? `: ${detail}` : ""}`);
709
+ }
710
+
711
+ /** The CLI exits nonzero for both a missing container and a broken
712
+ * apiserver; only the not-found error that names this exact container proves
713
+ * absence. Anything else is unknown and must never authorize destruction. */
714
+ function isNotFound(result: AppleCliResult, containerName: string): boolean {
715
+ // Substring matching would let `container not found: <name>-shadow` prove
716
+ // the absence of <name>. Require the complete bounded error line and no
717
+ // stdout at all.
718
+ return (
719
+ result.status !== 0 &&
720
+ result.stdout.trim() === "" &&
721
+ result.stderr
722
+ .split("\n")
723
+ .some((line) => line.trim() === `Error: container not found: ${containerName}`)
724
+ );
725
+ }
726
+
727
+ function boundedDetail(text: string): string {
728
+ return text.trim().slice(0, 512);
729
+ }
730
+
731
+ function firstIpv4(networks: unknown): string | null {
732
+ if (!Array.isArray(networks)) return null;
733
+ for (const network of networks) {
734
+ const address = (network as { ipv4Address?: unknown })?.ipv4Address;
735
+ if (typeof address === "string") {
736
+ const bare = address.split("/")[0]!;
737
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bare)) return bare;
738
+ }
739
+ }
740
+ return null;
741
+ }
742
+
743
+ function exactComponent(
744
+ value: unknown,
745
+ label: string,
746
+ maxLength = MAX_LOCATOR_COMPONENT,
747
+ ): string {
748
+ if (
749
+ typeof value !== "string" ||
750
+ value.length === 0 ||
751
+ value.length > maxLength ||
752
+ /[\0\r\n]/.test(value)
753
+ ) {
754
+ throw new Error(`Apple container ${label} is invalid`);
755
+ }
756
+ return value;
757
+ }