@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,956 @@
1
+ import {
2
+ spawn as nodeSpawn,
3
+ type ChildProcessWithoutNullStreams,
4
+ } from "node:child_process";
5
+ import { existsSync } from "node:fs";
6
+ import path from "node:path";
7
+
8
+ import {
9
+ AgentError,
10
+ agent,
11
+ type TaskDownResult,
12
+ type TaskStatusResult,
13
+ type TaskUpCredentials,
14
+ type TaskLaunchInput,
15
+ type TaskUpResult,
16
+ } from "../agent";
17
+ import { dockerCli, type DockerResult } from "../docker-exec";
18
+ import { taskDir } from "../env";
19
+ import { PREVIEW_SIDECAR_TASK_LABEL } from "../preview-sidecar";
20
+ import { assertSafeHostTaskId } from "../task-identity";
21
+ import {
22
+ assertTaskEnvironmentProcessRequest,
23
+ assertTaskEnvironmentSessionRequest,
24
+ type TaskEnvironmentCopyRequest,
25
+ type TaskEnvironmentDescriptor,
26
+ type TaskEnvironmentDetachedSessionRequest,
27
+ type TaskEnvironmentExecRequest,
28
+ type TaskEnvironmentExecResult,
29
+ type TaskEnvironmentHandle,
30
+ type TaskEnvironmentLocator,
31
+ type TaskEnvironmentPort,
32
+ type TaskEnvironmentProcess,
33
+ type TaskEnvironmentProcessExit,
34
+ type TaskEnvironmentProvider,
35
+ type TaskEnvironmentRecoveryContext,
36
+ type TaskEnvironmentRecoveryResult,
37
+ type TaskEnvironmentSessionRequest,
38
+ type TaskEnvironmentSpawnRequest,
39
+ type TaskEnvironmentStatus,
40
+ } from "./types";
41
+
42
+ export const DOCKER_TASK_ENVIRONMENT_PROVIDER = "docker-compose";
43
+ const DOCKER_LOCATOR_SCHEMA_VERSION = 2;
44
+ const MAX_LOCATOR_COMPONENT = 512;
45
+
46
+ export type DockerMachineBackend = "docker";
47
+
48
+ export interface DockerMachineIdentity {
49
+ backend: DockerMachineBackend;
50
+ /** Stable process/durable endpoint identity, never a credential-bearing URL. */
51
+ endpoint: string;
52
+ /** Stable Docker daemon `.ID`, proved atomically with server liveness. */
53
+ engineId: string;
54
+ }
55
+
56
+ interface DockerTaskEnvironmentLocatorValue {
57
+ schemaVersion: typeof DOCKER_LOCATOR_SCHEMA_VERSION;
58
+ taskId: string;
59
+ composeProject: string;
60
+ containerName: string;
61
+ hostWorktreePath: string;
62
+ machine: DockerMachineIdentity;
63
+ codeServerPort?: number;
64
+ previewPorts?: Array<{ name: string; hostPort: number }>;
65
+ initWarning?: string;
66
+ }
67
+
68
+ export interface DockerTaskEnvironmentDeps {
69
+ taskUp?: (
70
+ input: TaskLaunchInput,
71
+ credentials?: TaskUpCredentials,
72
+ ) => Promise<TaskUpResult>;
73
+ taskEnvironmentDown?: (taskId: string) => Promise<TaskDownResult>;
74
+ taskStatus?: (taskId: string) => Promise<TaskStatusResult>;
75
+ docker?: typeof dockerCli;
76
+ spawn?: typeof nodeSpawn;
77
+ machineIdentity: () => DockerMachineIdentity;
78
+ /** Full L4/L5-aware recovery hook. Absence intentionally does not start. */
79
+ recover?: DockerTaskEnvironmentRecoveryDriver;
80
+ /** Late-bound production seam avoids an orchestrator/provider import cycle
81
+ * while letting tests supply an instance-local recovery implementation. */
82
+ recoveryDriver?: () => DockerTaskEnvironmentRecoveryDriver | null;
83
+ }
84
+
85
+ export type DockerTaskEnvironmentRecoveryDriver = (
86
+ descriptor: TaskEnvironmentDescriptor,
87
+ context: TaskEnvironmentRecoveryContext,
88
+ ) => Promise<TaskEnvironmentRecoveryResult>;
89
+
90
+ export function createDockerTaskEnvironmentProvider(
91
+ deps: DockerTaskEnvironmentDeps,
92
+ ): TaskEnvironmentProvider<
93
+ TaskLaunchInput,
94
+ TaskUpCredentials,
95
+ TaskUpResult,
96
+ TaskDownResult
97
+ > {
98
+ const resolved = {
99
+ taskUp: deps.taskUp ?? agent.taskUp,
100
+ taskEnvironmentDown:
101
+ deps.taskEnvironmentDown ?? agent.taskEnvironmentDown,
102
+ taskStatus: deps.taskStatus,
103
+ docker: deps.docker ?? dockerCli,
104
+ spawn: deps.spawn ?? nodeSpawn,
105
+ machineIdentity: deps.machineIdentity,
106
+ recover: deps.recover,
107
+ recoveryDriver: deps.recoveryDriver,
108
+ };
109
+
110
+ return {
111
+ kind: DOCKER_TASK_ENVIRONMENT_PROVIDER,
112
+ async provision(request) {
113
+ if (request.taskId !== request.input.task.id) {
114
+ throw new Error("task environment provision identity mismatch");
115
+ }
116
+ const machine = validateMachineIdentity(resolved.machineIdentity());
117
+ const preparedLocator = dockerTaskEnvironmentPreparedLocator(
118
+ request.taskId,
119
+ machine,
120
+ );
121
+ await request.onPrepared?.(preparedLocator);
122
+ const result = await resolved.taskUp(request.input, request.credentials);
123
+ const prepared = parseDockerTaskEnvironmentLocator(preparedLocator);
124
+ // task-up composes its worktree path in shell from the operator's
125
+ // UAI_WORKSPACE_ROOT verbatim, so a benign trailing slash would fail a
126
+ // strict string compare AFTER the stack is already up. Resolve before
127
+ // comparing; a path that differs after resolution (wrong directory,
128
+ // unexpanded `~`) still fails closed.
129
+ if (
130
+ result.composeProject !== prepared.composeProject ||
131
+ path.resolve(result.worktreePath) !== prepared.hostWorktreePath
132
+ ) {
133
+ throw new Error(
134
+ "task environment returned an unexpected Compose project or worktree path",
135
+ );
136
+ }
137
+ const locator = dockerTaskEnvironmentLocator(
138
+ request.taskId,
139
+ // Persist the canonical resolved path, not the shell-composed spelling.
140
+ { ...result, worktreePath: prepared.hostWorktreePath },
141
+ machine,
142
+ );
143
+ return {
144
+ handle: new DockerTaskEnvironmentHandle(locator, resolved),
145
+ result,
146
+ };
147
+ },
148
+ async reconstruct(locator) {
149
+ const persisted = parseDockerTaskEnvironmentLocator(locator);
150
+ const current = validateMachineIdentity(resolved.machineIdentity());
151
+ if (
152
+ persisted.machine.backend !== current.backend ||
153
+ persisted.machine.endpoint !== current.endpoint ||
154
+ persisted.machine.engineId !== current.engineId
155
+ ) {
156
+ throw new Error(
157
+ "task environment belongs to a different container backend",
158
+ );
159
+ }
160
+ return new DockerTaskEnvironmentHandle(locator, resolved);
161
+ },
162
+ };
163
+ }
164
+
165
+ export function dockerTaskEnvironmentLocator(
166
+ taskId: string,
167
+ result: TaskUpResult,
168
+ machine: DockerMachineIdentity,
169
+ ): TaskEnvironmentLocator {
170
+ assertSafeHostTaskId(taskId);
171
+ const composeProject = `task-${taskId}`;
172
+ if (result.composeProject !== composeProject) {
173
+ throw new Error("task environment returned an unexpected Compose project");
174
+ }
175
+ const value: DockerTaskEnvironmentLocatorValue = {
176
+ schemaVersion: DOCKER_LOCATOR_SCHEMA_VERSION,
177
+ taskId,
178
+ composeProject,
179
+ containerName: `${composeProject}-app-1`,
180
+ hostWorktreePath: result.worktreePath,
181
+ machine: validateMachineIdentity(machine),
182
+ ...(result.codeServerPort === undefined
183
+ ? {}
184
+ : { codeServerPort: result.codeServerPort }),
185
+ ...(result.previewPorts === undefined
186
+ ? {}
187
+ : { previewPorts: result.previewPorts }),
188
+ ...(result.initWarning === undefined
189
+ ? {}
190
+ : { initWarning: result.initWarning }),
191
+ };
192
+ validateDockerLocatorValue(value);
193
+ return {
194
+ schemaVersion: 1,
195
+ provider: DOCKER_TASK_ENVIRONMENT_PROVIDER,
196
+ value,
197
+ };
198
+ }
199
+
200
+ /** Deterministic crash-recovery identity, derived solely from admitted host
201
+ * configuration and task id. Never accept a worktree path from the caller. */
202
+ export function dockerTaskEnvironmentPreparedLocator(
203
+ taskId: string,
204
+ machine: DockerMachineIdentity,
205
+ ): TaskEnvironmentLocator {
206
+ assertSafeHostTaskId(taskId);
207
+ const composeProject = `task-${taskId}`;
208
+ const value: DockerTaskEnvironmentLocatorValue = {
209
+ schemaVersion: DOCKER_LOCATOR_SCHEMA_VERSION,
210
+ taskId,
211
+ composeProject,
212
+ containerName: `${composeProject}-app-1`,
213
+ hostWorktreePath: taskDir(taskId),
214
+ machine: validateMachineIdentity(machine),
215
+ };
216
+ validateDockerLocatorValue(value);
217
+ return {
218
+ schemaVersion: 1,
219
+ provider: DOCKER_TASK_ENVIRONMENT_PROVIDER,
220
+ value,
221
+ };
222
+ }
223
+
224
+ export function parseDockerTaskEnvironmentLocator(
225
+ locator: TaskEnvironmentLocator,
226
+ ): DockerTaskEnvironmentLocatorValue {
227
+ if (
228
+ locator.schemaVersion !== 1 ||
229
+ locator.provider !== DOCKER_TASK_ENVIRONMENT_PROVIDER
230
+ ) {
231
+ throw new Error("task environment locator does not belong to Docker Compose");
232
+ }
233
+ return validateDockerLocatorValue(locator.value);
234
+ }
235
+
236
+ class DockerTaskEnvironmentHandle
237
+ implements TaskEnvironmentHandle<TaskDownResult>
238
+ {
239
+ readonly descriptor: TaskEnvironmentDescriptor;
240
+ readonly durableIdentity: string;
241
+ readonly #value: DockerTaskEnvironmentLocatorValue;
242
+
243
+ constructor(
244
+ locator: TaskEnvironmentLocator,
245
+ private readonly deps: Required<
246
+ Omit<
247
+ DockerTaskEnvironmentDeps,
248
+ "recover" | "recoveryDriver" | "taskStatus"
249
+ >
250
+ > &
251
+ Pick<
252
+ DockerTaskEnvironmentDeps,
253
+ "recover" | "recoveryDriver" | "taskStatus"
254
+ >,
255
+ ) {
256
+ this.#value = parseDockerTaskEnvironmentLocator(locator);
257
+ this.durableIdentity = this.#value.containerName;
258
+ this.descriptor = {
259
+ taskId: this.#value.taskId,
260
+ locator,
261
+ workspacePath: "/workspace",
262
+ ...(this.#value.codeServerPort === undefined
263
+ ? {}
264
+ : { codeServerPort: this.#value.codeServerPort }),
265
+ ...(this.#value.previewPorts === undefined
266
+ ? {}
267
+ : { previewPorts: this.#value.previewPorts }),
268
+ ...(this.#value.initWarning === undefined
269
+ ? {}
270
+ : { initWarning: this.#value.initWarning }),
271
+ };
272
+ }
273
+
274
+ exec(request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult> {
275
+ assertTaskEnvironmentProcessRequest(request);
276
+ return capturedCliExec(
277
+ this.deps.spawn,
278
+ "docker",
279
+ dockerExecArgs(this.#value.containerName, request),
280
+ request,
281
+ );
282
+ }
283
+
284
+ async spawn(
285
+ request: TaskEnvironmentSpawnRequest,
286
+ ): Promise<TaskEnvironmentProcess> {
287
+ assertTaskEnvironmentProcessRequest(request);
288
+ return streamingCliExec(
289
+ this.deps.spawn,
290
+ "docker",
291
+ dockerExecArgs(this.#value.containerName, request),
292
+ request,
293
+ );
294
+ }
295
+
296
+ async spawnSession(
297
+ request: TaskEnvironmentSessionRequest,
298
+ ): Promise<TaskEnvironmentProcess> {
299
+ assertTaskEnvironmentSessionRequest(request);
300
+ return streamingCliExec(
301
+ this.deps.spawn,
302
+ "docker",
303
+ dockerExecArgs(this.#value.containerName, request),
304
+ request,
305
+ );
306
+ }
307
+
308
+ async launchDetachedSession(
309
+ request: TaskEnvironmentDetachedSessionRequest,
310
+ ): Promise<TaskEnvironmentExecResult> {
311
+ assertTaskEnvironmentSessionRequest(request);
312
+ const result = await this.deps.docker(
313
+ dockerExecArgs(this.#value.containerName, request, "detached"),
314
+ {
315
+ timeoutMs: request.launchTimeoutMs,
316
+ maxOutputBytes: request.maxOutputBytes,
317
+ },
318
+ );
319
+ return {
320
+ exitCode: result.status,
321
+ signal: null,
322
+ stdout: Buffer.from(result.stdout),
323
+ stderr: Buffer.from(result.stderr),
324
+ stdoutTruncated: result.outputTruncated === true,
325
+ stderrTruncated: result.outputTruncated === true,
326
+ };
327
+ }
328
+
329
+ async copy(request: TaskEnvironmentCopyRequest): Promise<void> {
330
+ validateCopyRequest(request);
331
+ const args =
332
+ request.direction === "into"
333
+ ? [
334
+ "cp",
335
+ request.source,
336
+ `${this.#value.containerName}:${request.destination}`,
337
+ ]
338
+ : [
339
+ "cp",
340
+ `${this.#value.containerName}:${request.source}`,
341
+ request.destination,
342
+ ];
343
+ const result = await this.deps.docker(args, {
344
+ timeoutMs: 120_000,
345
+ maxOutputBytes: 1024 * 1024,
346
+ });
347
+ requireDockerSuccess(result, "copy task environment data");
348
+ }
349
+
350
+ async ports(): Promise<TaskEnvironmentPort[]> {
351
+ const result = await this.deps.docker(
352
+ ["port", this.#value.containerName],
353
+ { timeoutMs: 10_000, maxOutputBytes: 256 * 1024 },
354
+ );
355
+ requireDockerSuccess(result, "inspect task environment ports");
356
+ return parseDockerPorts(result.stdout);
357
+ }
358
+
359
+ async status(): Promise<TaskEnvironmentStatus> {
360
+ if (this.deps.taskStatus) {
361
+ return taskStatusToEnvironmentStatus(
362
+ await this.deps.taskStatus(this.#value.taskId),
363
+ );
364
+ }
365
+ return inspectDockerTaskEnvironmentStatus(this.deps.docker, this.#value);
366
+ }
367
+
368
+ async recover(
369
+ context: TaskEnvironmentRecoveryContext,
370
+ ): Promise<TaskEnvironmentRecoveryResult> {
371
+ const recover = this.deps.recover ?? this.deps.recoveryDriver?.();
372
+ if (recover) {
373
+ return recover(this.descriptor, context);
374
+ }
375
+ const status = await this.status();
376
+ return status.state === "unknown"
377
+ ? { outcome: "deferred", detail: status.detail }
378
+ : { outcome: "recovered", status };
379
+ }
380
+
381
+ async stop(): Promise<void> {
382
+ // Stop the whole Compose project (app + sidecars) while keeping every
383
+ // container, volume, and the worktree so the task can be resumed. The
384
+ // label filter is scoped by the locator's project, which provision
385
+ // derived from the admitted task id.
386
+ const listed = await this.deps.docker(
387
+ [
388
+ "ps",
389
+ "-q",
390
+ "--filter",
391
+ `label=com.docker.compose.project=${this.#value.composeProject}`,
392
+ ],
393
+ { timeoutMs: 30_000, maxOutputBytes: 256 * 1024 },
394
+ );
395
+ requireDockerSuccess(listed, "list task environment containers");
396
+ const ids = listed.stdout
397
+ .split("\n")
398
+ .map((id) => id.trim())
399
+ .filter(Boolean);
400
+ if (ids.length === 0) return;
401
+ const stopped = await this.deps.docker(["stop", ...ids], {
402
+ timeoutMs: 60_000,
403
+ maxOutputBytes: 256 * 1024,
404
+ });
405
+ requireDockerSuccess(stopped, "stop task environment containers");
406
+ }
407
+
408
+ async teardown(): Promise<TaskDownResult> {
409
+ const result = await this.deps.taskEnvironmentDown(this.#value.taskId);
410
+ let status: TaskEnvironmentStatus;
411
+ try {
412
+ status = await this.status();
413
+ } catch (error) {
414
+ // Preserve structured agent failures so the host command boundary can
415
+ // still poison a vanished daemon. Other provider errors remain an
416
+ // explicit lack of absence proof.
417
+ if (AgentError.is(error)) throw error;
418
+ throw new Error(
419
+ `task environment teardown is unconfirmed: ${boundedErrorMessage(error)}`,
420
+ );
421
+ }
422
+ if (status.state !== "absent") {
423
+ throw new Error(
424
+ status.state === "unknown"
425
+ ? `task environment teardown is unconfirmed: ${status.detail}`
426
+ : `task environment teardown left the task ${status.state}`,
427
+ );
428
+ }
429
+ return result;
430
+ }
431
+ }
432
+
433
+ function validateDockerLocatorValue(
434
+ value: unknown,
435
+ ): DockerTaskEnvironmentLocatorValue {
436
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
437
+ throw new Error("Docker task environment locator must be an object");
438
+ }
439
+ const record = value as Record<string, unknown>;
440
+ const allowed = new Set([
441
+ "schemaVersion",
442
+ "taskId",
443
+ "composeProject",
444
+ "containerName",
445
+ "hostWorktreePath",
446
+ "machine",
447
+ "codeServerPort",
448
+ "previewPorts",
449
+ "initWarning",
450
+ ]);
451
+ if (Object.keys(record).some((key) => !allowed.has(key))) {
452
+ throw new Error("Docker task environment locator has unexpected fields");
453
+ }
454
+ if (record.schemaVersion !== DOCKER_LOCATOR_SCHEMA_VERSION) {
455
+ throw new Error("unsupported Docker task environment locator schema");
456
+ }
457
+ const taskId = exactComponent(record.taskId, "task id");
458
+ if (!/^[a-z0-9][a-z0-9_-]{0,127}$/.test(taskId)) {
459
+ throw new Error("Docker task environment task id is invalid");
460
+ }
461
+ const composeProject = exactComponent(record.composeProject, "Compose project");
462
+ const containerName = exactComponent(record.containerName, "container name");
463
+ if (
464
+ composeProject !== `task-${taskId}` ||
465
+ containerName !== `${composeProject}-app-1`
466
+ ) {
467
+ throw new Error("Docker task environment identity is inconsistent");
468
+ }
469
+ const hostWorktreePath = exactComponent(
470
+ record.hostWorktreePath,
471
+ "host worktree path",
472
+ );
473
+ if (
474
+ !path.isAbsolute(hostWorktreePath) ||
475
+ path.resolve(hostWorktreePath) !== hostWorktreePath ||
476
+ /[\r\n]/.test(hostWorktreePath)
477
+ ) {
478
+ throw new Error("Docker task environment worktree path must be absolute");
479
+ }
480
+ const machine = validateMachineIdentity(record.machine);
481
+ const codeServerPort = optionalPort(record.codeServerPort, "code server port");
482
+ const previewPorts = validatePreviewPorts(record.previewPorts);
483
+ const initWarning =
484
+ record.initWarning === undefined
485
+ ? undefined
486
+ : exactComponent(record.initWarning, "init warning", 4096);
487
+ return {
488
+ schemaVersion: DOCKER_LOCATOR_SCHEMA_VERSION,
489
+ taskId,
490
+ composeProject,
491
+ containerName,
492
+ hostWorktreePath,
493
+ machine,
494
+ ...(codeServerPort === undefined ? {} : { codeServerPort }),
495
+ ...(previewPorts === undefined ? {} : { previewPorts }),
496
+ ...(initWarning === undefined ? {} : { initWarning }),
497
+ };
498
+ }
499
+
500
+ function validateMachineIdentity(value: unknown): DockerMachineIdentity {
501
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
502
+ throw new Error("Docker machine identity must be an object");
503
+ }
504
+ const record = value as Record<string, unknown>;
505
+ if (
506
+ Object.keys(record).sort().join("\0") !==
507
+ "backend\0endpoint\0engineId"
508
+ ) {
509
+ throw new Error("Docker machine identity has unexpected fields");
510
+ }
511
+ if (record.backend !== "docker") {
512
+ throw new Error("Docker machine backend is invalid");
513
+ }
514
+ const endpoint = exactComponent(record.endpoint, "Docker endpoint", 2048);
515
+ const socketPath = endpoint.slice("unix://".length);
516
+ if (
517
+ !endpoint.startsWith("unix:///") ||
518
+ /[@?#\r\n]/.test(endpoint) ||
519
+ !path.isAbsolute(socketPath) ||
520
+ path.resolve(socketPath) !== socketPath
521
+ ) {
522
+ throw new Error("Docker machine endpoint must be a local Unix socket");
523
+ }
524
+ const engineId = exactComponent(record.engineId, "Docker engine id", 512);
525
+ if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(engineId)) {
526
+ throw new Error("Docker engine id is invalid");
527
+ }
528
+ return { backend: record.backend, endpoint, engineId };
529
+ }
530
+
531
+ function validatePreviewPorts(
532
+ value: unknown,
533
+ ): Array<{ name: string; hostPort: number }> | undefined {
534
+ if (value === undefined) return undefined;
535
+ if (!Array.isArray(value) || value.length > 64) {
536
+ throw new Error("Docker task environment preview ports are invalid");
537
+ }
538
+ return value.map((entry) => {
539
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
540
+ throw new Error("Docker task environment preview port is invalid");
541
+ }
542
+ const record = entry as Record<string, unknown>;
543
+ if (Object.keys(record).sort().join("\0") !== "hostPort\0name") {
544
+ throw new Error("Docker task environment preview port has unexpected fields");
545
+ }
546
+ return {
547
+ name: exactComponent(record.name, "preview port name"),
548
+ hostPort: requiredPort(record.hostPort, "preview host port"),
549
+ };
550
+ });
551
+ }
552
+
553
+ function exactComponent(
554
+ value: unknown,
555
+ name: string,
556
+ max = MAX_LOCATOR_COMPONENT,
557
+ ): string {
558
+ if (
559
+ typeof value !== "string" ||
560
+ value.length === 0 ||
561
+ value.length > max ||
562
+ value.includes("\0")
563
+ ) {
564
+ throw new Error(`${name} is invalid`);
565
+ }
566
+ return value;
567
+ }
568
+
569
+ function optionalPort(value: unknown, name: string): number | undefined {
570
+ return value === undefined ? undefined : requiredPort(value, name);
571
+ }
572
+
573
+ function requiredPort(value: unknown, name: string): number {
574
+ if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65_535) {
575
+ throw new Error(`${name} is invalid`);
576
+ }
577
+ return Number(value);
578
+ }
579
+
580
+ function dockerExecArgs(
581
+ containerName: string,
582
+ request:
583
+ | TaskEnvironmentExecRequest
584
+ | TaskEnvironmentSpawnRequest
585
+ | TaskEnvironmentSessionRequest
586
+ | TaskEnvironmentDetachedSessionRequest,
587
+ mode: "interactive" | "detached" = "interactive",
588
+ ): string[] {
589
+ const args = ["exec", mode === "detached" ? "-d" : "-i"];
590
+ if (request.cwd) args.push("--workdir", request.cwd);
591
+ if (request.user) args.push("--user", request.user);
592
+ for (const name of request.inheritEnv ?? []) {
593
+ if (process.env[name] !== undefined) args.push("--env", name);
594
+ }
595
+ for (const [name, value] of Object.entries(request.env ?? {}).sort(([a], [b]) =>
596
+ a.localeCompare(b),
597
+ )) {
598
+ args.push("--env", `${name}=${value}`);
599
+ }
600
+ args.push(containerName, ...request.argv);
601
+ return args;
602
+ }
603
+
604
+ /** Bounded one-shot exec, generalized over the CLI so the Apple container
605
+ * provider reuses the exact truncation/timeout/exit-mapping semantics. */
606
+ export function capturedCliExec(
607
+ spawn: typeof nodeSpawn,
608
+ command: string,
609
+ args: readonly string[],
610
+ request: TaskEnvironmentExecRequest,
611
+ ): Promise<TaskEnvironmentExecResult> {
612
+ return new Promise((resolve) => {
613
+ const child = spawn(command, [...args], {
614
+ stdio: ["pipe", "pipe", "pipe"],
615
+ }) as ChildProcessWithoutNullStreams;
616
+ let stdout: Buffer[] = [];
617
+ let stderr: Buffer[] = [];
618
+ let retained = 0;
619
+ let stdoutTruncated = false;
620
+ let stderrTruncated = false;
621
+ let settled = false;
622
+ const append = (kind: "stdout" | "stderr", chunk: Buffer): void => {
623
+ if (settled) return;
624
+ const remaining = Math.max(0, request.maxOutputBytes - retained);
625
+ const kept = remaining === 0 ? Buffer.alloc(0) : chunk.subarray(0, remaining);
626
+ if (kept.length > 0) {
627
+ if (kind === "stdout") stdout.push(kept);
628
+ else stderr.push(kept);
629
+ retained += kept.length;
630
+ }
631
+ if (kept.length !== chunk.length) {
632
+ if (kind === "stdout") stdoutTruncated = true;
633
+ else stderrTruncated = true;
634
+ child.kill("SIGKILL");
635
+ // Same bounded settlement as the deadline kill: never wait forever
636
+ // on close after an overflow kill.
637
+ const settle = setTimeout(() => finish(null, "SIGKILL"), 5_000);
638
+ settle.unref?.();
639
+ }
640
+ };
641
+ child.stdout.on("data", (chunk: Buffer) => append("stdout", Buffer.from(chunk)));
642
+ child.stderr.on("data", (chunk: Buffer) => append("stderr", Buffer.from(chunk)));
643
+ const timer = setTimeout(() => {
644
+ child.kill("SIGKILL");
645
+ // Settle even if the killed child never emits close (unreapable state);
646
+ // the caller's own deadline already expired.
647
+ const settle = setTimeout(() => finish(null, "SIGKILL"), 5_000);
648
+ settle.unref?.();
649
+ }, request.timeoutMs);
650
+ timer.unref?.();
651
+ const finish = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
652
+ if (settled) return;
653
+ settled = true;
654
+ clearTimeout(timer);
655
+ resolve({
656
+ exitCode,
657
+ signal,
658
+ stdout: Buffer.concat(stdout),
659
+ stderr: Buffer.concat(stderr),
660
+ stdoutTruncated,
661
+ stderrTruncated,
662
+ });
663
+ stdout = [];
664
+ stderr = [];
665
+ };
666
+ child.once("error", (error) => {
667
+ stderr.push(Buffer.from(error.message));
668
+ finish(null, null);
669
+ });
670
+ child.once("close", finish);
671
+ child.stdin.on("error", () => {});
672
+ child.stdin.end(request.stdin);
673
+ });
674
+ }
675
+
676
+ /** Streaming exec, generalized over the CLI for the same reason. */
677
+ export function streamingCliExec(
678
+ spawn: typeof nodeSpawn,
679
+ command: string,
680
+ args: readonly string[],
681
+ request: TaskEnvironmentSpawnRequest | TaskEnvironmentSessionRequest,
682
+ ): TaskEnvironmentProcess {
683
+ const child = spawn(command, [...args], {
684
+ stdio: ["pipe", "pipe", "pipe"],
685
+ }) as ChildProcessWithoutNullStreams;
686
+ // Every kill forces settlement within a bounded grace, like the captured
687
+ // runner: a wedged client must not leave an agent session's completion
688
+ // pending indefinitely.
689
+ let forceSettle: (() => void) | null = null;
690
+ const killAndSettle = (): void => {
691
+ child.kill("SIGKILL");
692
+ const settle = setTimeout(() => forceSettle?.(), 5_000);
693
+ settle.unref?.();
694
+ };
695
+ const budget = new SharedOutputBudget(request.maxOutputBytes, killAndSettle);
696
+ const stdout = new BoundedByteQueue(budget);
697
+ const stderr = new BoundedByteQueue(budget);
698
+ child.stdout.on("data", (chunk: Buffer) => stdout.push(Buffer.from(chunk)));
699
+ child.stderr.on("data", (chunk: Buffer) => stderr.push(Buffer.from(chunk)));
700
+ const timer =
701
+ "timeoutMs" in request
702
+ ? setTimeout(killAndSettle, request.timeoutMs)
703
+ : null;
704
+ timer?.unref?.();
705
+ const completion = new Promise<TaskEnvironmentProcessExit>((resolve) => {
706
+ let settled = false;
707
+ const finish = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
708
+ if (settled) return;
709
+ settled = true;
710
+ if (timer) clearTimeout(timer);
711
+ stdout.end();
712
+ stderr.end();
713
+ resolve({ exitCode, signal });
714
+ };
715
+ forceSettle = () => finish(null, "SIGKILL");
716
+ child.once("error", () => finish(null, null));
717
+ child.once("close", finish);
718
+ });
719
+ child.stdin.on("error", () => {});
720
+ return {
721
+ stdout,
722
+ stderr,
723
+ completion,
724
+ write: (chunk) => writeChildInput(child, chunk),
725
+ closeInput: async () => {
726
+ child.stdin.end();
727
+ },
728
+ terminate: async (signal = "SIGTERM") => {
729
+ child.kill(signal);
730
+ },
731
+ };
732
+ }
733
+
734
+ function writeChildInput(
735
+ child: ChildProcessWithoutNullStreams,
736
+ chunk: Uint8Array,
737
+ ): Promise<void> {
738
+ if (child.stdin.destroyed || !child.stdin.writable) {
739
+ return Promise.reject(new Error("task environment process input is closed"));
740
+ }
741
+ return new Promise((resolve, reject) => {
742
+ const onError = (error: Error): void => {
743
+ child.stdin.off("drain", onDrain);
744
+ reject(error);
745
+ };
746
+ const onDrain = (): void => {
747
+ child.stdin.off("error", onError);
748
+ resolve();
749
+ };
750
+ child.stdin.once("error", onError);
751
+ if (child.stdin.write(chunk)) {
752
+ child.stdin.off("error", onError);
753
+ resolve();
754
+ } else {
755
+ child.stdin.once("drain", onDrain);
756
+ }
757
+ });
758
+ }
759
+
760
+ class SharedOutputBudget {
761
+ #bytes = 0;
762
+ #overflowed = false;
763
+
764
+ constructor(
765
+ private readonly maxBytes: number,
766
+ private readonly onOverflow: () => void,
767
+ ) {}
768
+
769
+ retain(bytes: number): boolean {
770
+ if (this.#overflowed || this.#bytes + bytes > this.maxBytes) {
771
+ if (!this.#overflowed) {
772
+ this.#overflowed = true;
773
+ this.onOverflow();
774
+ }
775
+ return false;
776
+ }
777
+ this.#bytes += bytes;
778
+ return true;
779
+ }
780
+
781
+ release(bytes: number): void {
782
+ this.#bytes = Math.max(0, this.#bytes - bytes);
783
+ }
784
+ }
785
+
786
+ class BoundedByteQueue implements AsyncIterable<Uint8Array> {
787
+ readonly #chunks: Buffer[] = [];
788
+ readonly #waiters: Array<
789
+ (result: IteratorResult<Uint8Array>) => void
790
+ > = [];
791
+ #ended = false;
792
+
793
+ constructor(private readonly budget: SharedOutputBudget) {}
794
+
795
+ push(chunk: Buffer): void {
796
+ if (this.#ended || chunk.length === 0 || !this.budget.retain(chunk.length)) return;
797
+ const waiter = this.#waiters.shift();
798
+ if (waiter) {
799
+ this.budget.release(chunk.length);
800
+ waiter({ done: false, value: chunk });
801
+ } else {
802
+ this.#chunks.push(chunk);
803
+ }
804
+ }
805
+
806
+ end(): void {
807
+ if (this.#ended) return;
808
+ this.#ended = true;
809
+ for (const waiter of this.#waiters.splice(0)) {
810
+ waiter({ done: true, value: undefined });
811
+ }
812
+ }
813
+
814
+ [Symbol.asyncIterator](): AsyncIterator<Uint8Array> {
815
+ return {
816
+ next: () => {
817
+ const chunk = this.#chunks.shift();
818
+ if (chunk) {
819
+ this.budget.release(chunk.length);
820
+ return Promise.resolve({ done: false as const, value: chunk });
821
+ }
822
+ if (this.#ended) {
823
+ return Promise.resolve({ done: true as const, value: undefined });
824
+ }
825
+ return new Promise((resolve) => this.#waiters.push(resolve));
826
+ },
827
+ };
828
+ }
829
+ }
830
+
831
+ function validateCopyRequest(request: TaskEnvironmentCopyRequest): void {
832
+ for (const path of [request.source, request.destination]) {
833
+ if (!path.startsWith("/") || path.includes("\0") || path.includes(":")) {
834
+ throw new Error("task environment copy paths must be absolute and unambiguous");
835
+ }
836
+ }
837
+ }
838
+
839
+ function parseDockerPorts(stdout: string): TaskEnvironmentPort[] {
840
+ const ports: TaskEnvironmentPort[] = [];
841
+ for (const line of stdout.split(/\r?\n/)) {
842
+ if (!line.trim()) continue;
843
+ const match = /^(\d+)(?:\/(?:tcp|udp))? -> 127\.0\.0\.1:(\d+)$/.exec(
844
+ line.trim(),
845
+ );
846
+ if (!match) {
847
+ throw new Error("task environment published a non-loopback or malformed port");
848
+ }
849
+ ports.push({
850
+ containerPort: requiredPort(Number(match[1]), "container port"),
851
+ host: "127.0.0.1",
852
+ hostPort: requiredPort(Number(match[2]), "host port"),
853
+ });
854
+ }
855
+ return ports;
856
+ }
857
+
858
+ function taskStatusToEnvironmentStatus(
859
+ status: TaskStatusResult,
860
+ ): TaskEnvironmentStatus {
861
+ return {
862
+ state: status.composeRunning
863
+ ? "running"
864
+ : status.containers.length > 0 || status.worktreePresent
865
+ ? "stopped"
866
+ : "absent",
867
+ instances: [...status.containers],
868
+ workspacePresent: status.worktreePresent,
869
+ };
870
+ }
871
+
872
+ // The fourth column is the preview-sidecar marker: a sidecar wearing the
873
+ // task's compose labels must read as "not the app", not as malformed
874
+ // identity data — the latter turned a healthy running task's status into
875
+ // "unknown" (live 2026-08-18).
876
+ const DOCKER_STATUS_FORMAT =
877
+ `{{.Names}}\t{{.Label "com.docker.compose.project"}}\t{{.State}}\t{{.Label "${PREVIEW_SIDECAR_TASK_LABEL}"}}`;
878
+
879
+ async function inspectDockerTaskEnvironmentStatus(
880
+ docker: typeof dockerCli,
881
+ environment: DockerTaskEnvironmentLocatorValue,
882
+ ): Promise<TaskEnvironmentStatus> {
883
+ const result = await docker(
884
+ [
885
+ "ps",
886
+ "--all",
887
+ "--filter",
888
+ `label=com.docker.compose.project=${environment.composeProject}`,
889
+ "--filter",
890
+ "label=com.docker.compose.service=app",
891
+ "--format",
892
+ DOCKER_STATUS_FORMAT,
893
+ ],
894
+ { timeoutMs: 30_000, maxOutputBytes: 256 * 1024 },
895
+ );
896
+ if (result.status !== 0) {
897
+ return {
898
+ state: "unknown",
899
+ detail: boundedDockerFailure(result, "inspect task environment"),
900
+ };
901
+ }
902
+
903
+ let state: string | undefined;
904
+ for (const raw of result.stdout.split(/\r?\n/)) {
905
+ // No trim before splitting: the app row's LAST column (the sidecar
906
+ // marker) is empty, and trimming would eat its tab and shift the shape.
907
+ if (!raw.trim()) continue;
908
+ const [name, project, candidateState, sidecar, ...extra] = raw.split("\t");
909
+ // A preview sidecar wearing the task's compose labels is not the app
910
+ // container; skipping it keeps a healthy task from reading as unknown.
911
+ if (sidecar !== undefined && sidecar !== "") continue;
912
+ if (
913
+ state !== undefined ||
914
+ name !== environment.containerName ||
915
+ project !== environment.composeProject ||
916
+ !candidateState ||
917
+ !candidateState.trim() ||
918
+ sidecar === undefined ||
919
+ extra.length > 0
920
+ ) {
921
+ return {
922
+ state: "unknown",
923
+ detail: "task environment inventory returned malformed identity data",
924
+ };
925
+ }
926
+ state = candidateState.trim();
927
+ }
928
+ const workspacePresent = existsSync(environment.hostWorktreePath);
929
+ if (state === undefined) {
930
+ return {
931
+ state: workspacePresent ? "stopped" : "absent",
932
+ instances: [],
933
+ workspacePresent,
934
+ };
935
+ }
936
+ return {
937
+ state: state === "running" ? "running" : "stopped",
938
+ instances: [environment.containerName],
939
+ workspacePresent,
940
+ };
941
+ }
942
+
943
+ function requireDockerSuccess(result: DockerResult, operation: string): void {
944
+ if (result.status === 0) return;
945
+ const detail = (result.stderr || result.stdout).trim().slice(0, 512);
946
+ throw new Error(`${operation} failed${detail ? `: ${detail}` : ""}`);
947
+ }
948
+
949
+ function boundedDockerFailure(result: DockerResult, operation: string): string {
950
+ const detail = (result.stderr || result.stdout).trim().slice(0, 512);
951
+ return `${operation} failed${detail ? `: ${detail}` : ""}`;
952
+ }
953
+
954
+ function boundedErrorMessage(error: unknown): string {
955
+ return (error instanceof Error ? error.message : String(error)).slice(0, 512);
956
+ }