@runuai/host 0.9.69 → 0.9.70

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.
@@ -0,0 +1,728 @@
1
+ /**
2
+ * ADR-121 walking skeleton: the machine-backed TaskEnvironmentProvider.
3
+ *
4
+ * A task's environment is a whole machine (EC2/GCP instance, or a local
5
+ * SSH-reachable container on the guinea-pig daemon), reached exclusively
6
+ * over SSH via the shared machine-exec transport. The workspace lives ON
7
+ * the machine (ADR-121's core principle) — provisioning clones in-env; the
8
+ * host holds only the locator, the SSH identity, and proofs.
9
+ *
10
+ * NOT yet registered with the production task flow: the provider is
11
+ * exercised by tests and the guinea-pig smoke driver until the strangler
12
+ * ops land (attachments/diff/transcripts still assume a host-side
13
+ * workspace). Registration is one line when that day comes; the contract
14
+ * is complete now so nothing about the shape is provisional.
15
+ */
16
+
17
+ import { spawn as nodeSpawn } from "node:child_process";
18
+ import { readFileSync, writeFileSync } from "node:fs";
19
+
20
+ import type {
21
+ MachineInfo,
22
+ MachineProvider,
23
+ } from "../machine-provider";
24
+ import { machineEnvironmentSshArgs } from "../machine-exec";
25
+ import { capturedCliExec, streamingCliExec } from "./docker";
26
+ import {
27
+ assertTaskEnvironmentProcessRequest,
28
+ assertTaskEnvironmentSessionRequest,
29
+ assertWorkspaceScopedPath,
30
+ type TaskEnvironmentCopyRequest,
31
+ type TaskEnvironmentDescriptor,
32
+ type TaskEnvironmentDetachedSessionRequest,
33
+ type TaskEnvironmentExecRequest,
34
+ type TaskEnvironmentExecResult,
35
+ type TaskEnvironmentHandle,
36
+ type TaskEnvironmentLocator,
37
+ type TaskEnvironmentPort,
38
+ type TaskEnvironmentProcess,
39
+ type TaskEnvironmentProvider,
40
+ type TaskEnvironmentProvisionRequest,
41
+ type TaskEnvironmentProvisioned,
42
+ type TaskEnvironmentRecoveryContext,
43
+ type TaskEnvironmentRecoveryResult,
44
+ type TaskEnvironmentSessionRequest,
45
+ type TaskEnvironmentSpawnRequest,
46
+ type TaskEnvironmentStatus,
47
+ } from "./types";
48
+
49
+ export const MACHINE_TASK_ENVIRONMENT_PROVIDER = "machine";
50
+
51
+ const SAFE_TASK_ID = /^[a-z0-9][a-z0-9_-]{0,127}$/;
52
+
53
+ export interface MachineTaskEnvironmentLocatorValue {
54
+ taskId: string;
55
+ /** MachineProvider-native identifier (instance id / container name). */
56
+ machineId: string;
57
+ /** Which MachineProvider kind owns machineId — a locator must never be
58
+ * replayed against a different backend. */
59
+ machineKind: "local" | "aws" | "gcp";
60
+ /** Host path of the private key the machine authorizes. */
61
+ privateKeyPath: string;
62
+ /** Absolute workspace path on the machine. */
63
+ workspacePath: string;
64
+ sshUser: string;
65
+ }
66
+
67
+ export interface MachineTaskInput {
68
+ image: string;
69
+ cpus: number;
70
+ memoryMiB: number;
71
+ workspacePath?: string;
72
+ /** Optional initial clone performed in-env (ADR-121: clone happens in the
73
+ * task's world; the URL may embed short-lived credentials that never
74
+ * touch machine disk beyond git's own storage). */
75
+ repo?: { url: string; branch?: string };
76
+ /** macOS-local development only; see MachineSpec.publishSsh. */
77
+ publishSsh?: boolean;
78
+ }
79
+
80
+ export interface MachineTaskEnvironmentDeps {
81
+ machines: MachineProvider;
82
+ /** Task control dir (host-side) for the SSH identity. */
83
+ taskControlDir(taskId: string): string;
84
+ mintKeyPair(taskControlDir: string): Promise<{
85
+ privateKeyPath: string;
86
+ publicKey: string;
87
+ }>;
88
+ spawn?: typeof nodeSpawn;
89
+ /** SSH-readiness budget after launch/start. */
90
+ readyTimeoutMs?: number;
91
+ }
92
+
93
+ export function machineTaskEnvironmentLocator(
94
+ value: MachineTaskEnvironmentLocatorValue,
95
+ ): TaskEnvironmentLocator {
96
+ validateMachineLocatorValue(value);
97
+ return {
98
+ schemaVersion: 1,
99
+ provider: MACHINE_TASK_ENVIRONMENT_PROVIDER,
100
+ value: { ...value },
101
+ };
102
+ }
103
+
104
+ export function parseMachineTaskEnvironmentLocator(
105
+ locator: TaskEnvironmentLocator,
106
+ ): MachineTaskEnvironmentLocatorValue {
107
+ if (
108
+ locator.schemaVersion !== 1 ||
109
+ locator.provider !== MACHINE_TASK_ENVIRONMENT_PROVIDER
110
+ ) {
111
+ throw new Error("task environment locator does not belong to machine");
112
+ }
113
+ return validateMachineLocatorValue(locator.value);
114
+ }
115
+
116
+ function validateMachineLocatorValue(
117
+ value: unknown,
118
+ ): MachineTaskEnvironmentLocatorValue {
119
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
120
+ throw new Error("machine locator value must be an object");
121
+ }
122
+ const record = value as Record<string, unknown>;
123
+ const {
124
+ taskId,
125
+ machineId,
126
+ machineKind,
127
+ privateKeyPath,
128
+ workspacePath,
129
+ sshUser,
130
+ } = record;
131
+ if (typeof taskId !== "string" || !SAFE_TASK_ID.test(taskId)) {
132
+ throw new Error("machine locator taskId is invalid");
133
+ }
134
+ if (
135
+ typeof machineId !== "string" ||
136
+ machineId.length === 0 ||
137
+ machineId.includes("\0")
138
+ ) {
139
+ throw new Error("machine locator machineId is invalid");
140
+ }
141
+ if (
142
+ machineKind !== "local" &&
143
+ machineKind !== "aws" &&
144
+ machineKind !== "gcp"
145
+ ) {
146
+ throw new Error("machine locator machineKind is invalid");
147
+ }
148
+ if (
149
+ typeof privateKeyPath !== "string" ||
150
+ !privateKeyPath.startsWith("/") ||
151
+ privateKeyPath.includes("\0")
152
+ ) {
153
+ throw new Error("machine locator privateKeyPath is invalid");
154
+ }
155
+ if (
156
+ typeof workspacePath !== "string" ||
157
+ !workspacePath.startsWith("/") ||
158
+ workspacePath.includes("\0")
159
+ ) {
160
+ throw new Error("machine locator workspacePath is invalid");
161
+ }
162
+ if (typeof sshUser !== "string" || sshUser.length === 0) {
163
+ throw new Error("machine locator sshUser is invalid");
164
+ }
165
+ return {
166
+ taskId,
167
+ machineId,
168
+ machineKind,
169
+ privateKeyPath,
170
+ workspacePath,
171
+ sshUser,
172
+ };
173
+ }
174
+
175
+ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
176
+ readonly descriptor: TaskEnvironmentDescriptor;
177
+ readonly durableIdentity: string;
178
+ readonly #value: MachineTaskEnvironmentLocatorValue;
179
+ readonly #deps: Required<
180
+ Pick<MachineTaskEnvironmentDeps, "machines" | "spawn" | "readyTimeoutMs">
181
+ > &
182
+ MachineTaskEnvironmentDeps;
183
+
184
+ constructor(
185
+ locator: TaskEnvironmentLocator,
186
+ deps: MachineTaskEnvironmentDeps,
187
+ ) {
188
+ this.#value = parseMachineTaskEnvironmentLocator(locator);
189
+ this.#deps = {
190
+ ...deps,
191
+ machines: deps.machines,
192
+ spawn: deps.spawn ?? nodeSpawn,
193
+ readyTimeoutMs: deps.readyTimeoutMs ?? 120_000,
194
+ };
195
+ this.durableIdentity = this.#value.machineId;
196
+ this.descriptor = {
197
+ taskId: this.#value.taskId,
198
+ locator,
199
+ workspacePath: this.#value.workspacePath,
200
+ };
201
+ }
202
+
203
+ /** Resolve the CURRENT dial target. Cloud machines change address across
204
+ * stop/start, so the locator never stores one — describe is the only
205
+ * source of truth, and a non-running machine has no target at all. */
206
+ async #target(): Promise<{
207
+ address: string;
208
+ keyPath: string;
209
+ port?: number;
210
+ }> {
211
+ const info = await this.#describeOwn();
212
+ if (info.state !== "running" || info.address === null) {
213
+ throw new Error(
214
+ `machine ${this.#value.machineId} is not reachable (${info.state})`,
215
+ );
216
+ }
217
+ return {
218
+ address: info.address,
219
+ keyPath: this.#value.privateKeyPath,
220
+ ...(info.sshPort !== undefined ? { port: info.sshPort } : {}),
221
+ };
222
+ }
223
+
224
+ /** Describe with the ownership check every mutation rides on: a machine
225
+ * that does not carry OUR task label is a foreign occupant of the id and
226
+ * is treated as unknown — never dialed, never stopped, never terminated. */
227
+ async #describeOwn(): Promise<MachineInfo> {
228
+ const info = await this.#deps.machines.describe(this.#value.machineId);
229
+ if (
230
+ (info.state === "running" || info.state === "stopped") &&
231
+ info.taskLabel !== this.#value.taskId
232
+ ) {
233
+ return {
234
+ id: info.id,
235
+ taskLabel: info.taskLabel,
236
+ state: "unknown",
237
+ address: null,
238
+ detail: "machine does not carry this task's ownership label",
239
+ };
240
+ }
241
+ return info;
242
+ }
243
+
244
+ async readWorkspaceFile(path: string): Promise<Uint8Array | null> {
245
+ assertWorkspaceScopedPath(this.descriptor, path);
246
+ const target = await this.#target();
247
+ const result = await capturedCliExec(
248
+ this.#deps.spawn,
249
+ "ssh",
250
+ machineEnvironmentSshArgs(
251
+ target,
252
+ {
253
+ argv: [
254
+ "/bin/sh",
255
+ "-c",
256
+ // Absence and error are different facts: 44 marks a MISSING file
257
+ // so a transport/permission failure can never masquerade as one.
258
+ `if [ -e "$1" ]; then exec cat "$1"; else exit 44; fi`,
259
+ "read",
260
+ path,
261
+ ],
262
+ },
263
+ "interactive",
264
+ ),
265
+ {
266
+ argv: ["/bin/cat"],
267
+ timeoutMs: 60_000,
268
+ maxOutputBytes: 64 * 1024 * 1024,
269
+ },
270
+ );
271
+ if (result.exitCode === 44) return null;
272
+ if (result.exitCode !== 0) {
273
+ throw new Error(
274
+ `machine workspace read failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
275
+ );
276
+ }
277
+ return result.stdout;
278
+ }
279
+
280
+ async writeWorkspaceFile(path: string, bytes: Uint8Array): Promise<void> {
281
+ assertWorkspaceScopedPath(this.descriptor, path);
282
+ const target = await this.#target();
283
+ const result = await capturedCliExec(
284
+ this.#deps.spawn,
285
+ "ssh",
286
+ machineEnvironmentSshArgs(
287
+ target,
288
+ {
289
+ argv: [
290
+ "/bin/sh",
291
+ "-c",
292
+ `mkdir -p "$(dirname "$1")" && cat > "$1"`,
293
+ "write",
294
+ path,
295
+ ],
296
+ },
297
+ "interactive",
298
+ ),
299
+ {
300
+ argv: ["/bin/sh"],
301
+ stdin: bytes,
302
+ timeoutMs: 120_000,
303
+ maxOutputBytes: 1024 * 1024,
304
+ },
305
+ );
306
+ if (result.exitCode !== 0) {
307
+ throw new Error(
308
+ `machine workspace write failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
309
+ );
310
+ }
311
+ }
312
+
313
+ async exec(
314
+ request: TaskEnvironmentExecRequest,
315
+ ): Promise<TaskEnvironmentExecResult> {
316
+ assertTaskEnvironmentProcessRequest(request);
317
+ const target = await this.#target();
318
+ return capturedCliExec(
319
+ this.#deps.spawn,
320
+ "ssh",
321
+ machineEnvironmentSshArgs(target, request, "interactive"),
322
+ request,
323
+ );
324
+ }
325
+
326
+ async spawn(
327
+ request: TaskEnvironmentSpawnRequest,
328
+ ): Promise<TaskEnvironmentProcess> {
329
+ assertTaskEnvironmentProcessRequest(request);
330
+ const target = await this.#target();
331
+ return streamingCliExec(
332
+ this.#deps.spawn,
333
+ "ssh",
334
+ machineEnvironmentSshArgs(target, request, "interactive"),
335
+ request,
336
+ );
337
+ }
338
+
339
+ async spawnSession(
340
+ request: TaskEnvironmentSessionRequest,
341
+ ): Promise<TaskEnvironmentProcess> {
342
+ assertTaskEnvironmentSessionRequest(request);
343
+ const target = await this.#target();
344
+ return streamingCliExec(
345
+ this.#deps.spawn,
346
+ "ssh",
347
+ machineEnvironmentSshArgs(target, request, "interactive"),
348
+ request,
349
+ );
350
+ }
351
+
352
+ async launchDetachedSession(
353
+ request: TaskEnvironmentDetachedSessionRequest,
354
+ ): Promise<TaskEnvironmentExecResult> {
355
+ assertTaskEnvironmentSessionRequest(request);
356
+ const target = await this.#target();
357
+ return capturedCliExec(
358
+ this.#deps.spawn,
359
+ "ssh",
360
+ machineEnvironmentSshArgs(target, request, "detached"),
361
+ {
362
+ ...request,
363
+ timeoutMs: request.launchTimeoutMs,
364
+ },
365
+ );
366
+ }
367
+
368
+ async copy(request: TaskEnvironmentCopyRequest): Promise<void> {
369
+ if (
370
+ !request.source.startsWith("/") ||
371
+ !request.destination.startsWith("/") ||
372
+ request.source.includes("\0") ||
373
+ request.destination.includes("\0")
374
+ ) {
375
+ throw new Error("machine copy paths must be absolute and NUL-free");
376
+ }
377
+ const target = await this.#target();
378
+ if (request.direction === "into") {
379
+ const bytes = readFileSync(request.source);
380
+ const result = await capturedCliExec(
381
+ this.#deps.spawn,
382
+ "ssh",
383
+ machineEnvironmentSshArgs(
384
+ target,
385
+ {
386
+ argv: [
387
+ "/bin/sh",
388
+ "-c",
389
+ `mkdir -p "$(dirname "$1")" && cat > "$1"`,
390
+ "copy",
391
+ request.destination,
392
+ ],
393
+ },
394
+ "interactive",
395
+ ),
396
+ {
397
+ argv: ["/bin/sh"],
398
+ stdin: bytes,
399
+ timeoutMs: 120_000,
400
+ maxOutputBytes: 1024 * 1024,
401
+ },
402
+ );
403
+ if (result.exitCode !== 0) {
404
+ throw new Error(
405
+ `machine copy into failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
406
+ );
407
+ }
408
+ return;
409
+ }
410
+ const result = await capturedCliExec(
411
+ this.#deps.spawn,
412
+ "ssh",
413
+ machineEnvironmentSshArgs(
414
+ target,
415
+ { argv: ["/bin/cat", request.source] },
416
+ "interactive",
417
+ ),
418
+ {
419
+ argv: ["/bin/cat"],
420
+ timeoutMs: 120_000,
421
+ maxOutputBytes: 64 * 1024 * 1024,
422
+ },
423
+ );
424
+ if (result.exitCode !== 0) {
425
+ throw new Error(
426
+ `machine copy out failed: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
427
+ );
428
+ }
429
+ writeFileSync(request.destination, Buffer.from(result.stdout));
430
+ }
431
+
432
+ async ports(): Promise<TaskEnvironmentPort[]> {
433
+ // Direct-IP model, like the Apple provider: nothing is published on the
434
+ // host; routing resolves the machine address per connection below.
435
+ return [];
436
+ }
437
+
438
+ async inspectRoute(): Promise<
439
+ | { kind: "running"; ipv4Address: string }
440
+ | { kind: "unavailable" }
441
+ | { kind: "unknown"; detail: string }
442
+ > {
443
+ const info = await this.#describeOwn();
444
+ if (info.state === "running" && info.address !== null) {
445
+ return { kind: "running", ipv4Address: info.address };
446
+ }
447
+ if (info.state === "stopped" || info.state === "absent") {
448
+ return { kind: "unavailable" };
449
+ }
450
+ return {
451
+ kind: "unknown",
452
+ detail: info.detail ?? `machine state ${info.state}`,
453
+ };
454
+ }
455
+
456
+ async status(): Promise<TaskEnvironmentStatus> {
457
+ const info = await this.#describeOwn();
458
+ switch (info.state) {
459
+ case "running":
460
+ return {
461
+ state: "running",
462
+ instances: [this.#value.machineId],
463
+ workspacePresent: await this.#workspacePresent(),
464
+ };
465
+ case "pending":
466
+ // Pending is running-shaped for status purposes: resources exist.
467
+ return {
468
+ state: "running",
469
+ instances: [this.#value.machineId],
470
+ workspacePresent: false,
471
+ };
472
+ case "stopped":
473
+ // The workspace lives on the machine's own disk, which stop keeps
474
+ // by design (EBS / container filesystem). Not observable while
475
+ // stopped; asserted from the architecture, not probed.
476
+ return {
477
+ state: "stopped",
478
+ instances: [this.#value.machineId],
479
+ workspacePresent: true,
480
+ };
481
+ case "absent":
482
+ return { state: "absent", instances: [], workspacePresent: false };
483
+ default:
484
+ return {
485
+ state: "unknown",
486
+ detail: info.detail ?? "machine state unknown",
487
+ };
488
+ }
489
+ }
490
+
491
+ async #workspacePresent(): Promise<boolean> {
492
+ try {
493
+ const target = await this.#target();
494
+ const result = await capturedCliExec(
495
+ this.#deps.spawn,
496
+ "ssh",
497
+ machineEnvironmentSshArgs(
498
+ target,
499
+ { argv: ["/usr/bin/test", "-d", this.#value.workspacePath] },
500
+ "interactive",
501
+ ),
502
+ {
503
+ argv: ["/usr/bin/test"],
504
+ timeoutMs: 15_000,
505
+ maxOutputBytes: 4 * 1024,
506
+ },
507
+ );
508
+ return result.exitCode === 0;
509
+ } catch {
510
+ return false;
511
+ }
512
+ }
513
+
514
+ async recover(
515
+ context: TaskEnvironmentRecoveryContext,
516
+ ): Promise<TaskEnvironmentRecoveryResult> {
517
+ await context.maintenanceReady;
518
+ if (!context.isCurrent()) {
519
+ return { outcome: "deferred", detail: "runtime generation superseded" };
520
+ }
521
+ const info = await this.#describeOwn();
522
+ if (info.state === "unknown") {
523
+ return {
524
+ outcome: "deferred",
525
+ detail: info.detail ?? "machine state unknown",
526
+ };
527
+ }
528
+ if (info.state === "absent" || info.state === "stopped") {
529
+ // Durable truths recovery may settle without touching anything.
530
+ return { outcome: "recovered", status: await this.status() };
531
+ }
532
+ // Running (or pending): the machine must actually answer before
533
+ // consumers are re-admitted — a wedged guest that describes as running
534
+ // must not be exposed (this week's OOM lesson, applied to machines).
535
+ const answered = await this.#sshAnswers();
536
+ if (!answered) {
537
+ return {
538
+ outcome: "deferred",
539
+ detail: "machine describes as running but does not answer ssh",
540
+ };
541
+ }
542
+ if (!context.isCurrent()) {
543
+ return { outcome: "deferred", detail: "runtime generation superseded" };
544
+ }
545
+ context.allowConsumers?.();
546
+ await context.ensureConsumersStarted?.();
547
+ if (!context.isCurrent()) {
548
+ await context.quarantineConsumers?.();
549
+ return { outcome: "deferred", detail: "runtime generation superseded" };
550
+ }
551
+ return { outcome: "recovered", status: await this.status() };
552
+ }
553
+
554
+ async #sshAnswers(): Promise<boolean> {
555
+ try {
556
+ const target = await this.#target();
557
+ const result = await capturedCliExec(
558
+ this.#deps.spawn,
559
+ "ssh",
560
+ machineEnvironmentSshArgs(target, { argv: ["/bin/true"] }, "interactive"),
561
+ {
562
+ argv: ["/bin/true"],
563
+ timeoutMs: 15_000,
564
+ maxOutputBytes: 4 * 1024,
565
+ },
566
+ );
567
+ return result.exitCode === 0;
568
+ } catch {
569
+ return false;
570
+ }
571
+ }
572
+
573
+ async stop(): Promise<void> {
574
+ const info = await this.#describeOwn();
575
+ if (info.state === "stopped" || info.state === "absent") return;
576
+ if (info.state === "unknown") {
577
+ throw new Error(
578
+ `machine ${this.#value.machineId} cannot be stopped: ${
579
+ info.detail ?? "state unknown"
580
+ }`,
581
+ );
582
+ }
583
+ await this.#deps.machines.stop(this.#value.machineId);
584
+ }
585
+
586
+ async teardown(): Promise<void> {
587
+ const info = await this.#describeOwn();
588
+ if (info.state === "absent") return;
589
+ if (info.state === "unknown") {
590
+ // Terminating an id we cannot prove is ours would be destruction
591
+ // without authority — the same refusal task-down makes for containers.
592
+ throw new Error(
593
+ `machine ${this.#value.machineId} cannot be torn down: ${
594
+ info.detail ?? "ownership unprovable"
595
+ }`,
596
+ );
597
+ }
598
+ // terminate() resolves only after proven absence (provider contract).
599
+ await this.#deps.machines.terminate(this.#value.machineId);
600
+ }
601
+ }
602
+
603
+ export function createMachineTaskEnvironmentProvider(
604
+ deps: MachineTaskEnvironmentDeps,
605
+ ): TaskEnvironmentProvider<MachineTaskInput, void, TaskEnvironmentDescriptor> {
606
+ return {
607
+ kind: MACHINE_TASK_ENVIRONMENT_PROVIDER,
608
+
609
+ async provision(
610
+ request: TaskEnvironmentProvisionRequest<MachineTaskInput, void>,
611
+ ): Promise<TaskEnvironmentProvisioned<TaskEnvironmentDescriptor>> {
612
+ const { taskId, input } = request;
613
+ if (!SAFE_TASK_ID.test(taskId)) {
614
+ throw new Error("machine provisioning refused an unsafe task id");
615
+ }
616
+ const workspacePath = input.workspacePath ?? "/workspace";
617
+ const keys = await deps.mintKeyPair(deps.taskControlDir(taskId));
618
+ // The locator is durable BEFORE any external resource exists: a crash
619
+ // after onPrepared is recoverable from the locator alone, and the
620
+ // machine id is derived (never invented post-launch) so the locator
621
+ // written here names exactly what launch will create.
622
+ const machineId = `uai-machine-${taskId.toLowerCase()}`;
623
+ const locator = machineTaskEnvironmentLocator({
624
+ taskId,
625
+ machineId,
626
+ machineKind: deps.machines.kind,
627
+ privateKeyPath: keys.privateKeyPath,
628
+ workspacePath,
629
+ sshUser: "node",
630
+ });
631
+ await request.onPrepared?.(locator);
632
+
633
+ const launched = await deps.machines.launch({
634
+ taskId,
635
+ image: input.image,
636
+ cpus: input.cpus,
637
+ memoryMiB: input.memoryMiB,
638
+ authorizedPublicKey: keys.publicKey,
639
+ ...(input.publishSsh ? { publishSsh: true } : {}),
640
+ });
641
+ if (launched.id !== machineId) {
642
+ // The backend named the machine something other than what the
643
+ // durable locator promises — tear it down rather than strand an
644
+ // untracked machine, then fail the provision loudly.
645
+ await deps.machines.terminate(launched.id);
646
+ throw new Error(
647
+ `machine backend named ${launched.id}, locator promised ${machineId}`,
648
+ );
649
+ }
650
+
651
+ const handle = new MachineTaskEnvironmentHandle(locator, deps);
652
+ await waitForSsh(handle, deps.readyTimeoutMs ?? 120_000);
653
+
654
+ // Workspace + optional in-env clone (ADR-121: the clone happens in the
655
+ // task's world; the host never materializes task code).
656
+ // As root: the workspace root is typically directly under a
657
+ // root-owned / (cloud images and the local image alike), then handed
658
+ // to the task user — the same gesture cloud user-data makes.
659
+ const prepare = await handle.exec({
660
+ argv: [
661
+ "/bin/sh",
662
+ "-c",
663
+ `mkdir -p "$1" && chown node:node "$1"`,
664
+ "prepare",
665
+ workspacePath,
666
+ ],
667
+ user: "root",
668
+ timeoutMs: 30_000,
669
+ maxOutputBytes: 64 * 1024,
670
+ });
671
+ if (prepare.exitCode !== 0) {
672
+ throw new Error(
673
+ `machine workspace preparation failed: ${Buffer.from(prepare.stderr).toString("utf8").trim()}`,
674
+ );
675
+ }
676
+ if (input.repo) {
677
+ const cloneArgv: [string, ...string[]] = [
678
+ "/usr/bin/git",
679
+ "clone",
680
+ ...(input.repo.branch ? ["--branch", input.repo.branch] : []),
681
+ input.repo.url,
682
+ workspacePath,
683
+ ];
684
+ const clone = await handle.exec({
685
+ argv: cloneArgv,
686
+ timeoutMs: 10 * 60_000,
687
+ maxOutputBytes: 4 * 1024 * 1024,
688
+ });
689
+ if (clone.exitCode !== 0) {
690
+ throw new Error(
691
+ `in-env clone failed: ${Buffer.from(clone.stderr).toString("utf8").trim()}`,
692
+ );
693
+ }
694
+ }
695
+ return { handle, result: handle.descriptor };
696
+ },
697
+
698
+ async reconstruct(
699
+ locator: TaskEnvironmentLocator,
700
+ ): Promise<TaskEnvironmentHandle> {
701
+ return new MachineTaskEnvironmentHandle(locator, deps);
702
+ },
703
+ };
704
+ }
705
+
706
+ async function waitForSsh(
707
+ handle: MachineTaskEnvironmentHandle,
708
+ budgetMs: number,
709
+ ): Promise<void> {
710
+ const deadline = Date.now() + budgetMs;
711
+ let lastDetail = "ssh never answered";
712
+ while (Date.now() < deadline) {
713
+ try {
714
+ const probe = await handle.exec({
715
+ argv: ["/bin/true"],
716
+ timeoutMs: 10_000,
717
+ maxOutputBytes: 4 * 1024,
718
+ });
719
+ if (probe.exitCode === 0) return;
720
+ lastDetail = Buffer.from(probe.stderr).toString("utf8").trim() ||
721
+ `probe exited ${probe.exitCode ?? "killed"}`;
722
+ } catch (error) {
723
+ lastDetail = error instanceof Error ? error.message : String(error);
724
+ }
725
+ await new Promise<void>((resolve) => setTimeout(resolve, 2_000));
726
+ }
727
+ throw new Error(`machine never became reachable over ssh: ${lastDetail}`);
728
+ }