@runuai/host 0.9.69 → 0.9.71
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.
- package/lib/agents/transport.ts +9 -0
- package/lib/env-schema.ts +101 -0
- package/lib/machine-exec.ts +89 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-provider-aws.ts +437 -0
- package/lib/machine-provider-local.ts +58 -7
- package/lib/machine-provider.ts +10 -0
- package/lib/task-environment/apple-container.ts +21 -0
- package/lib/task-environment/docker.ts +21 -0
- package/lib/task-environment/index.ts +56 -2
- package/lib/task-environment/machine-task-up.ts +333 -0
- package/lib/task-environment/machine.ts +759 -0
- package/lib/task-environment/types.ts +32 -0
- package/lib/task-environment/workspace-files.ts +51 -0
- package/lib/transcript.ts +83 -5
- package/package.json +1 -1
- package/src/index.ts +194 -13
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121: the `aws` MachineProvider — task machines as EC2 instances.
|
|
3
|
+
*
|
|
4
|
+
* Shell-out to the `aws` CLI with strict JSON parsing, matching every other
|
|
5
|
+
* backend integration (docker, container, ssh): no SDK dependency rides the
|
|
6
|
+
* OTA channel, and tests inject the runner.
|
|
7
|
+
*
|
|
8
|
+
* Identity model: EC2 invents instance ids, but the environment layer's
|
|
9
|
+
* locator-first invariant needs a DERIVED id. The machine id is therefore
|
|
10
|
+
* the logical `uai-machine-<taskId>`; the provider resolves it internally
|
|
11
|
+
* through the `com.uai.machine` tag on every call, and `--client-token`
|
|
12
|
+
* makes launch idempotent so a crashed provision retried cannot mint a
|
|
13
|
+
* second instance. Two live instances carrying one machine's tag is a
|
|
14
|
+
* confusing answer and maps to `unknown` — never to either instance.
|
|
15
|
+
*
|
|
16
|
+
* Absence: a SUCCESSFUL tag query with zero non-terminated matches proves
|
|
17
|
+
* absence (the filter asked AWS directly and AWS answered "none");
|
|
18
|
+
* `terminated` counts as absent — the id is unreachable and its disk is
|
|
19
|
+
* gone. A failed query proves nothing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
|
|
24
|
+
import type { DockerResult } from "./docker-exec";
|
|
25
|
+
import type {
|
|
26
|
+
MachineInfo,
|
|
27
|
+
MachineProvider,
|
|
28
|
+
MachineSpec,
|
|
29
|
+
MachineState,
|
|
30
|
+
} from "./machine-provider";
|
|
31
|
+
|
|
32
|
+
export const AWS_MACHINE_TAG = "com.uai.machine";
|
|
33
|
+
|
|
34
|
+
export interface AwsMachineConfig {
|
|
35
|
+
region: string;
|
|
36
|
+
/** AMI id used when the spec's image is not already an ami-*. */
|
|
37
|
+
subnetId?: string;
|
|
38
|
+
securityGroupId?: string;
|
|
39
|
+
iamInstanceProfileArn?: string;
|
|
40
|
+
/** Override the size ladder; the default picks the smallest Graviton
|
|
41
|
+
* type satisfying both cpu and memory. */
|
|
42
|
+
instanceType?: (spec: MachineSpec) => string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Runner = (args: string[]) => Promise<DockerResult>;
|
|
46
|
+
|
|
47
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
48
|
+
|
|
49
|
+
function defaultRunner(region: string): Runner {
|
|
50
|
+
return (args) =>
|
|
51
|
+
new Promise<DockerResult>((resolve) => {
|
|
52
|
+
let stdout = "";
|
|
53
|
+
let stderr = "";
|
|
54
|
+
let settled = false;
|
|
55
|
+
const child = spawn(
|
|
56
|
+
"aws",
|
|
57
|
+
["--region", region, "--output", "json", ...args],
|
|
58
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
59
|
+
);
|
|
60
|
+
const settle = (status: number | null): void => {
|
|
61
|
+
if (settled) return;
|
|
62
|
+
settled = true;
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
resolve({ status, stdout, stderr });
|
|
65
|
+
};
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
child.kill("SIGKILL");
|
|
68
|
+
const grace = setTimeout(() => settle(null), 2_000);
|
|
69
|
+
grace.unref?.();
|
|
70
|
+
}, DEFAULT_TIMEOUT_MS);
|
|
71
|
+
timer.unref?.();
|
|
72
|
+
child.stdout.setEncoding("utf8");
|
|
73
|
+
child.stderr.setEncoding("utf8");
|
|
74
|
+
child.stdout.on("data", (chunk: string) => (stdout += chunk));
|
|
75
|
+
child.stderr.on("data", (chunk: string) => (stderr += chunk));
|
|
76
|
+
child.once("error", () => settle(null));
|
|
77
|
+
child.once("close", (code) => settle(code));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function awsMachineName(taskId: string): string {
|
|
82
|
+
return `uai-machine-${taskId.toLowerCase()}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Smallest Graviton type satisfying both dimensions. */
|
|
86
|
+
export function defaultInstanceType(spec: MachineSpec): string {
|
|
87
|
+
const ladder: Array<[string, number, number]> = [
|
|
88
|
+
["m7g.medium", 1, 4096],
|
|
89
|
+
["m7g.large", 2, 8192],
|
|
90
|
+
["m7g.xlarge", 4, 16384],
|
|
91
|
+
["m7g.2xlarge", 8, 32768],
|
|
92
|
+
["m7g.4xlarge", 16, 65536],
|
|
93
|
+
];
|
|
94
|
+
for (const [type, cpus, memoryMiB] of ladder) {
|
|
95
|
+
if (spec.cpus <= cpus && spec.memoryMiB <= memoryMiB) return type;
|
|
96
|
+
}
|
|
97
|
+
return "m7g.4xlarge";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** cloud-init: install the orchestrator key for node (and root for the
|
|
101
|
+
* provisioning path), creating the node user when the AMI lacks it. */
|
|
102
|
+
export function awsUserData(authorizedPublicKey: string): string {
|
|
103
|
+
const script = [
|
|
104
|
+
"#!/bin/sh",
|
|
105
|
+
"set -e",
|
|
106
|
+
"id node >/dev/null 2>&1 || useradd -m -u 1000 -s /bin/bash node",
|
|
107
|
+
"for account in node root; do",
|
|
108
|
+
' home=$(getent passwd "$account" | cut -d: -f6)',
|
|
109
|
+
' mkdir -p "$home/.ssh"',
|
|
110
|
+
` printf '%s\\n' '${authorizedPublicKey.replace(/'/g, "")}' > "$home/.ssh/authorized_keys"`,
|
|
111
|
+
' chown -R "$account" "$home/.ssh"',
|
|
112
|
+
' chmod 700 "$home/.ssh"',
|
|
113
|
+
' chmod 600 "$home/.ssh/authorized_keys"',
|
|
114
|
+
"done",
|
|
115
|
+
"",
|
|
116
|
+
].join("\n");
|
|
117
|
+
return Buffer.from(script, "utf8").toString("base64");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type LiveInstance = {
|
|
121
|
+
instanceId: string;
|
|
122
|
+
state: string;
|
|
123
|
+
privateIp: string | null;
|
|
124
|
+
taskLabel: string | null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Strict reservations parse. Any malformed instance voids the answer. */
|
|
128
|
+
function parseReservations(stdout: string): LiveInstance[] | null {
|
|
129
|
+
let parsed: unknown;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(stdout);
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const reservations = (parsed as { Reservations?: unknown }).Reservations;
|
|
136
|
+
if (!Array.isArray(reservations)) return null;
|
|
137
|
+
const instances: LiveInstance[] = [];
|
|
138
|
+
for (const reservation of reservations) {
|
|
139
|
+
const list = (reservation as { Instances?: unknown }).Instances;
|
|
140
|
+
if (!Array.isArray(list)) return null;
|
|
141
|
+
for (const instance of list) {
|
|
142
|
+
if (typeof instance !== "object" || instance === null) return null;
|
|
143
|
+
const record = instance as {
|
|
144
|
+
InstanceId?: unknown;
|
|
145
|
+
State?: { Name?: unknown };
|
|
146
|
+
PrivateIpAddress?: unknown;
|
|
147
|
+
Tags?: Array<{ Key?: unknown; Value?: unknown }>;
|
|
148
|
+
};
|
|
149
|
+
if (typeof record.InstanceId !== "string") return null;
|
|
150
|
+
const state = record.State?.Name;
|
|
151
|
+
if (typeof state !== "string") return null;
|
|
152
|
+
let taskLabel: string | null = null;
|
|
153
|
+
if (record.Tags !== undefined) {
|
|
154
|
+
if (!Array.isArray(record.Tags)) return null;
|
|
155
|
+
for (const tag of record.Tags) {
|
|
156
|
+
if (tag?.Key === AWS_MACHINE_TAG) {
|
|
157
|
+
if (typeof tag.Value !== "string") return null;
|
|
158
|
+
taskLabel = tag.Value;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
instances.push({
|
|
163
|
+
instanceId: record.InstanceId,
|
|
164
|
+
state,
|
|
165
|
+
privateIp:
|
|
166
|
+
typeof record.PrivateIpAddress === "string"
|
|
167
|
+
? record.PrivateIpAddress
|
|
168
|
+
: null,
|
|
169
|
+
taskLabel,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return instances;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function stateFromAws(state: string): MachineState {
|
|
177
|
+
switch (state) {
|
|
178
|
+
case "running":
|
|
179
|
+
return "running";
|
|
180
|
+
case "pending":
|
|
181
|
+
return "pending";
|
|
182
|
+
case "stopping":
|
|
183
|
+
case "stopped":
|
|
184
|
+
return "stopped";
|
|
185
|
+
case "shutting-down":
|
|
186
|
+
case "terminated":
|
|
187
|
+
return "absent";
|
|
188
|
+
default:
|
|
189
|
+
return "unknown";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function unknown(id: string, detail: string): MachineInfo {
|
|
194
|
+
return { id, taskLabel: null, state: "unknown", address: null, detail };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function createAwsMachineProvider(
|
|
198
|
+
config: AwsMachineConfig,
|
|
199
|
+
runner: Runner = defaultRunner(config.region),
|
|
200
|
+
): MachineProvider {
|
|
201
|
+
const taskIdOf = (machineId: string): string => {
|
|
202
|
+
if (!machineId.startsWith("uai-machine-")) {
|
|
203
|
+
throw new Error(`not a uai machine id: ${machineId}`);
|
|
204
|
+
}
|
|
205
|
+
return machineId.slice("uai-machine-".length);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** Resolve the logical machine id to its live instances via the tag. */
|
|
209
|
+
async function resolveLive(
|
|
210
|
+
machineId: string,
|
|
211
|
+
): Promise<{ instances: LiveInstance[] } | { failure: string }> {
|
|
212
|
+
const res = await runner([
|
|
213
|
+
"ec2",
|
|
214
|
+
"describe-instances",
|
|
215
|
+
"--filters",
|
|
216
|
+
`Name=tag:${AWS_MACHINE_TAG},Values=${taskIdOf(machineId)}`,
|
|
217
|
+
"Name=instance-state-name,Values=pending,running,stopping,stopped",
|
|
218
|
+
]);
|
|
219
|
+
if (res.status !== 0) {
|
|
220
|
+
return {
|
|
221
|
+
failure: res.stderr.trim() || `describe exited ${res.status ?? "killed"}`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const instances = parseReservations(res.stdout);
|
|
225
|
+
if (instances === null) {
|
|
226
|
+
return { failure: "describe answered with a confusing shape" };
|
|
227
|
+
}
|
|
228
|
+
return { instances };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function describe(machineId: string): Promise<MachineInfo> {
|
|
232
|
+
let resolved: Awaited<ReturnType<typeof resolveLive>>;
|
|
233
|
+
try {
|
|
234
|
+
resolved = await resolveLive(machineId);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return unknown(
|
|
237
|
+
machineId,
|
|
238
|
+
error instanceof Error ? error.message : String(error),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if ("failure" in resolved) return unknown(machineId, resolved.failure);
|
|
242
|
+
if (resolved.instances.length === 0) {
|
|
243
|
+
// A successful tag query answering "none live" IS the absence proof.
|
|
244
|
+
return { id: machineId, taskLabel: null, state: "absent", address: null };
|
|
245
|
+
}
|
|
246
|
+
if (resolved.instances.length > 1) {
|
|
247
|
+
return unknown(
|
|
248
|
+
machineId,
|
|
249
|
+
`multiple live instances carry this machine's tag`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const instance = resolved.instances[0]!;
|
|
253
|
+
const state = stateFromAws(instance.state);
|
|
254
|
+
return {
|
|
255
|
+
id: machineId,
|
|
256
|
+
taskLabel: instance.taskLabel,
|
|
257
|
+
state,
|
|
258
|
+
address: state === "running" ? instance.privateIp : null,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
kind: "aws",
|
|
264
|
+
|
|
265
|
+
async launch(spec: MachineSpec): Promise<MachineInfo> {
|
|
266
|
+
const machineId = awsMachineName(spec.taskId);
|
|
267
|
+
const type = (config.instanceType ?? defaultInstanceType)(spec);
|
|
268
|
+
const args = [
|
|
269
|
+
"ec2",
|
|
270
|
+
"run-instances",
|
|
271
|
+
"--image-id",
|
|
272
|
+
spec.image,
|
|
273
|
+
"--instance-type",
|
|
274
|
+
type,
|
|
275
|
+
"--count",
|
|
276
|
+
"1",
|
|
277
|
+
// Idempotency across crashed/retried provisions: the same token can
|
|
278
|
+
// never mint a second instance.
|
|
279
|
+
"--client-token",
|
|
280
|
+
machineId,
|
|
281
|
+
"--tag-specifications",
|
|
282
|
+
`ResourceType=instance,Tags=[{Key=${AWS_MACHINE_TAG},Value=${spec.taskId}},{Key=Name,Value=${machineId}}]`,
|
|
283
|
+
...(spec.authorizedPublicKey
|
|
284
|
+
? ["--user-data", awsUserData(spec.authorizedPublicKey)]
|
|
285
|
+
: []),
|
|
286
|
+
...(config.subnetId ? ["--subnet-id", config.subnetId] : []),
|
|
287
|
+
...(config.securityGroupId
|
|
288
|
+
? ["--security-group-ids", config.securityGroupId]
|
|
289
|
+
: []),
|
|
290
|
+
...(config.iamInstanceProfileArn
|
|
291
|
+
? ["--iam-instance-profile", `Arn=${config.iamInstanceProfileArn}`]
|
|
292
|
+
: []),
|
|
293
|
+
];
|
|
294
|
+
const res = await runner(args);
|
|
295
|
+
if (res.status !== 0) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`machine launch failed for ${machineId}: ${
|
|
298
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
299
|
+
}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const info = await describe(machineId);
|
|
303
|
+
if (info.state === "absent" || info.state === "unknown") {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`machine ${machineId} launched but could not be described (${info.state}${
|
|
306
|
+
info.detail ? `: ${info.detail}` : ""
|
|
307
|
+
})`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return info;
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
async stop(machineId: string): Promise<void> {
|
|
314
|
+
const instance = await requireSingleLive(machineId);
|
|
315
|
+
const res = await runner([
|
|
316
|
+
"ec2",
|
|
317
|
+
"stop-instances",
|
|
318
|
+
"--instance-ids",
|
|
319
|
+
instance.instanceId,
|
|
320
|
+
]);
|
|
321
|
+
if (res.status !== 0) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`machine stop failed for ${machineId}: ${
|
|
324
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
325
|
+
}`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
async start(machineId: string): Promise<MachineInfo> {
|
|
331
|
+
const instance = await requireSingleLive(machineId);
|
|
332
|
+
const res = await runner([
|
|
333
|
+
"ec2",
|
|
334
|
+
"start-instances",
|
|
335
|
+
"--instance-ids",
|
|
336
|
+
instance.instanceId,
|
|
337
|
+
]);
|
|
338
|
+
if (res.status !== 0) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`machine start failed for ${machineId}: ${
|
|
341
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
342
|
+
}`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return describe(machineId);
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
async terminate(machineId: string): Promise<void> {
|
|
349
|
+
const resolved = await resolveLive(machineId);
|
|
350
|
+
if ("failure" in resolved) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`machine ${machineId} could not be resolved for terminate: ${resolved.failure}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
// Terminate EVERY live instance carrying the tag: a duplicate from a
|
|
356
|
+
// pathological double-launch must not survive its sibling's teardown.
|
|
357
|
+
for (const instance of resolved.instances) {
|
|
358
|
+
const res = await runner([
|
|
359
|
+
"ec2",
|
|
360
|
+
"terminate-instances",
|
|
361
|
+
"--instance-ids",
|
|
362
|
+
instance.instanceId,
|
|
363
|
+
]);
|
|
364
|
+
if (res.status !== 0) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`machine terminate failed for ${machineId} (${instance.instanceId}): ${
|
|
367
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
368
|
+
}`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// Absence is not best-effort: poll until the tag query answers "none
|
|
373
|
+
// live" (shutting-down/terminated fall out of the live filter).
|
|
374
|
+
const deadline = Date.now() + 120_000;
|
|
375
|
+
for (;;) {
|
|
376
|
+
const after = await describe(machineId);
|
|
377
|
+
if (after.state === "absent") return;
|
|
378
|
+
if (Date.now() >= deadline) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`machine ${machineId} could not be proven absent after terminate (${after.state}${
|
|
381
|
+
after.detail ? `: ${after.detail}` : ""
|
|
382
|
+
})`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 5_000));
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
describe,
|
|
390
|
+
|
|
391
|
+
async list(): Promise<MachineInfo[]> {
|
|
392
|
+
const res = await runner([
|
|
393
|
+
"ec2",
|
|
394
|
+
"describe-instances",
|
|
395
|
+
"--filters",
|
|
396
|
+
`Name=tag-key,Values=${AWS_MACHINE_TAG}`,
|
|
397
|
+
"Name=instance-state-name,Values=pending,running,stopping,stopped",
|
|
398
|
+
]);
|
|
399
|
+
if (res.status !== 0) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`machine listing failed: ${
|
|
402
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
403
|
+
}`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const instances = parseReservations(res.stdout);
|
|
407
|
+
if (instances === null) {
|
|
408
|
+
throw new Error("machine inventory was unparseable");
|
|
409
|
+
}
|
|
410
|
+
return instances.map((instance) => {
|
|
411
|
+
const state = stateFromAws(instance.state);
|
|
412
|
+
return {
|
|
413
|
+
id:
|
|
414
|
+
instance.taskLabel !== null
|
|
415
|
+
? awsMachineName(instance.taskLabel)
|
|
416
|
+
: instance.instanceId,
|
|
417
|
+
taskLabel: instance.taskLabel,
|
|
418
|
+
state,
|
|
419
|
+
address: state === "running" ? instance.privateIp : null,
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
async function requireSingleLive(machineId: string): Promise<LiveInstance> {
|
|
426
|
+
const resolved = await resolveLive(machineId);
|
|
427
|
+
if ("failure" in resolved) {
|
|
428
|
+
throw new Error(`machine ${machineId} could not be resolved: ${resolved.failure}`);
|
|
429
|
+
}
|
|
430
|
+
if (resolved.instances.length !== 1) {
|
|
431
|
+
throw new Error(
|
|
432
|
+
`machine ${machineId} resolution expected one live instance, found ${resolved.instances.length}`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
return resolved.instances[0]!;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
@@ -35,13 +35,23 @@ type Runner = (args: string[]) => Promise<DockerResult>;
|
|
|
35
35
|
const defaultRunner: Runner = (args) => dockerCli(args, { timeoutMs: 60_000 });
|
|
36
36
|
|
|
37
37
|
function machineAbsent(id: string, res: DockerResult): boolean {
|
|
38
|
+
// Daemons vary the casing of the not-found line — Docker Desktop answers
|
|
39
|
+
// "Error: No such object:", OrbStack "error: no such object:" (caught
|
|
40
|
+
// live in the first smoke run: the machine WAS gone and the proof
|
|
41
|
+
// refused it). The prefix is matched case-insensitively; the id itself
|
|
42
|
+
// stays exact — a not-found for "<id>-shadow" still proves nothing.
|
|
43
|
+
// OrbStack also answers with "[]" on stdout beside the error (Docker
|
|
44
|
+
// Desktop prints nothing) — an empty JSON array carries no confusable
|
|
45
|
+
// record, so both are admissible; anything else on stdout is not.
|
|
46
|
+
const stdout = res.stdout.trim();
|
|
38
47
|
return (
|
|
39
48
|
typeof res.status === "number" &&
|
|
40
49
|
res.status !== 0 &&
|
|
41
|
-
|
|
42
|
-
res.stderr
|
|
43
|
-
.
|
|
44
|
-
|
|
50
|
+
(stdout === "" || stdout === "[]") &&
|
|
51
|
+
res.stderr.split("\n").some((line) => {
|
|
52
|
+
const match = /^error: no such object: (.+)$/i.exec(line.trim());
|
|
53
|
+
return match !== null && match[1] === id;
|
|
54
|
+
})
|
|
45
55
|
);
|
|
46
56
|
}
|
|
47
57
|
|
|
@@ -66,6 +76,7 @@ interface InspectShape {
|
|
|
66
76
|
taskLabel: string | null;
|
|
67
77
|
state: MachineState;
|
|
68
78
|
address: string | null;
|
|
79
|
+
sshPort: number | null;
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
/** Strict single-record parse. Exactly one record whose Name is the
|
|
@@ -94,12 +105,19 @@ function parseInspect(id: string, stdout: string): InspectShape | null {
|
|
|
94
105
|
if (value !== undefined && typeof value !== "string") return null;
|
|
95
106
|
taskLabel = typeof value === "string" ? value : null;
|
|
96
107
|
}
|
|
97
|
-
const
|
|
108
|
+
const settings = (
|
|
98
109
|
record as {
|
|
99
|
-
NetworkSettings?: {
|
|
110
|
+
NetworkSettings?: {
|
|
111
|
+
Networks?: Record<string, { IPAddress?: unknown }>;
|
|
112
|
+
Ports?: Record<
|
|
113
|
+
string,
|
|
114
|
+
Array<{ HostIp?: unknown; HostPort?: unknown }> | null
|
|
115
|
+
>;
|
|
116
|
+
};
|
|
100
117
|
}
|
|
101
|
-
).NetworkSettings
|
|
118
|
+
).NetworkSettings;
|
|
102
119
|
let address: string | null = null;
|
|
120
|
+
const networks = settings?.Networks;
|
|
103
121
|
if (networks && typeof networks === "object") {
|
|
104
122
|
for (const net of Object.values(networks)) {
|
|
105
123
|
const ip = net?.IPAddress;
|
|
@@ -109,11 +127,29 @@ function parseInspect(id: string, stdout: string): InspectShape | null {
|
|
|
109
127
|
}
|
|
110
128
|
}
|
|
111
129
|
}
|
|
130
|
+
// A published SSH port (macOS mode) supersedes the bridge IP: the bridge
|
|
131
|
+
// address exists but is unreachable from the Mac, and half-truthful
|
|
132
|
+
// reachability info is worse than none.
|
|
133
|
+
let sshPort: number | null = null;
|
|
134
|
+
const published = settings?.Ports?.["22/tcp"];
|
|
135
|
+
if (Array.isArray(published)) {
|
|
136
|
+
for (const binding of published) {
|
|
137
|
+
const port = Number(binding?.HostPort);
|
|
138
|
+
if (Number.isInteger(port) && port > 0) {
|
|
139
|
+
address = typeof binding?.HostIp === "string" && binding.HostIp.length > 0
|
|
140
|
+
? binding.HostIp
|
|
141
|
+
: "127.0.0.1";
|
|
142
|
+
sshPort = port;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
112
147
|
return {
|
|
113
148
|
name,
|
|
114
149
|
taskLabel,
|
|
115
150
|
state: stateFromDocker(stateObj?.Status),
|
|
116
151
|
address,
|
|
152
|
+
sshPort,
|
|
117
153
|
};
|
|
118
154
|
}
|
|
119
155
|
|
|
@@ -136,6 +172,9 @@ export function createLocalMachineProvider(
|
|
|
136
172
|
taskLabel: record.taskLabel,
|
|
137
173
|
state: record.state,
|
|
138
174
|
address: record.state === "running" ? record.address : null,
|
|
175
|
+
...(record.state === "running" && record.sshPort !== null
|
|
176
|
+
? { sshPort: record.sshPort }
|
|
177
|
+
: {}),
|
|
139
178
|
};
|
|
140
179
|
}
|
|
141
180
|
if (machineAbsent(id, res)) {
|
|
@@ -163,6 +202,15 @@ export function createLocalMachineProvider(
|
|
|
163
202
|
String(spec.cpus),
|
|
164
203
|
"--memory",
|
|
165
204
|
`${spec.memoryMiB}m`,
|
|
205
|
+
// The image's entrypoint installs this for the ssh user before sshd
|
|
206
|
+
// starts — the local analog of cloud user-data key delivery.
|
|
207
|
+
...(spec.authorizedPublicKey
|
|
208
|
+
? ["-e", `UAI_AUTHORIZED_KEY=${spec.authorizedPublicKey}`]
|
|
209
|
+
: []),
|
|
210
|
+
// macOS: bridge IPs are not host-routable; SSH rides a
|
|
211
|
+
// loopback-published ephemeral port instead (never exposed beyond
|
|
212
|
+
// 127.0.0.1).
|
|
213
|
+
...(spec.publishSsh ? ["-p", "127.0.0.1:0:22"] : []),
|
|
166
214
|
spec.image,
|
|
167
215
|
...(spec.command ?? []),
|
|
168
216
|
];
|
|
@@ -275,6 +323,9 @@ export function createLocalMachineProvider(
|
|
|
275
323
|
taskLabel: record.taskLabel,
|
|
276
324
|
state: record.state,
|
|
277
325
|
address: record.state === "running" ? record.address : null,
|
|
326
|
+
...(record.state === "running" && record.sshPort !== null
|
|
327
|
+
? { sshPort: record.sshPort }
|
|
328
|
+
: {}),
|
|
278
329
|
});
|
|
279
330
|
}
|
|
280
331
|
return machines;
|
package/lib/machine-provider.ts
CHANGED
|
@@ -35,6 +35,14 @@ export interface MachineSpec {
|
|
|
35
35
|
/** Optional long-running entrypoint override (the local backend's images
|
|
36
36
|
* must keep the machine alive themselves — typically sshd). */
|
|
37
37
|
command?: string[];
|
|
38
|
+
/** Public key the machine authorizes for the orchestrator's SSH access.
|
|
39
|
+
* Cloud backends deliver it via instance user-data; the local backend via
|
|
40
|
+
* an env var its image's entrypoint installs. */
|
|
41
|
+
authorizedPublicKey?: string;
|
|
42
|
+
/** Local backend on macOS only: container bridge IPs are not
|
|
43
|
+
* host-routable there, so SSH must ride a loopback-published port. The
|
|
44
|
+
* Linux guinea pig and cloud backends dial the address directly. */
|
|
45
|
+
publishSsh?: boolean;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
export interface MachineInfo {
|
|
@@ -50,6 +58,8 @@ export interface MachineInfo {
|
|
|
50
58
|
* reachable; container bridge IPs are NOT host-routable from macOS —
|
|
51
59
|
* the local backend is for the Linux guinea pig, not Mac hosts. */
|
|
52
60
|
address: string | null;
|
|
61
|
+
/** SSH port when it differs from 22 (local backend with `publishSsh`). */
|
|
62
|
+
sshPort?: number;
|
|
53
63
|
/** Present only alongside `unknown` — what the backend actually said. */
|
|
54
64
|
detail?: string;
|
|
55
65
|
}
|
|
@@ -22,6 +22,10 @@ import { lstatSync } from "node:fs";
|
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
|
|
24
24
|
import type { TaskDownResult } from "../agent";
|
|
25
|
+
import {
|
|
26
|
+
hostWorkspaceRead,
|
|
27
|
+
hostWorkspaceWrite,
|
|
28
|
+
} from "./workspace-files";
|
|
25
29
|
import { assertSafeHostTaskId } from "../task-identity";
|
|
26
30
|
import {
|
|
27
31
|
capturedCliExec,
|
|
@@ -272,6 +276,23 @@ class AppleContainerTaskEnvironmentHandle
|
|
|
272
276
|
};
|
|
273
277
|
}
|
|
274
278
|
|
|
279
|
+
async readWorkspaceFile(filePath: string): Promise<Uint8Array | null> {
|
|
280
|
+
return hostWorkspaceRead(
|
|
281
|
+
this.descriptor,
|
|
282
|
+
path.join(this.#value.hostWorktreePath, "workspace"),
|
|
283
|
+
filePath,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async writeWorkspaceFile(filePath: string, bytes: Uint8Array): Promise<void> {
|
|
288
|
+
hostWorkspaceWrite(
|
|
289
|
+
this.descriptor,
|
|
290
|
+
path.join(this.#value.hostWorktreePath, "workspace"),
|
|
291
|
+
filePath,
|
|
292
|
+
bytes,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
275
296
|
exec(request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult> {
|
|
276
297
|
assertTaskEnvironmentProcessRequest(request);
|
|
277
298
|
return capturedCliExec(
|
|
@@ -15,6 +15,10 @@ import {
|
|
|
15
15
|
type TaskUpResult,
|
|
16
16
|
} from "../agent";
|
|
17
17
|
import { dockerCli, type DockerResult } from "../docker-exec";
|
|
18
|
+
import {
|
|
19
|
+
hostWorkspaceRead,
|
|
20
|
+
hostWorkspaceWrite,
|
|
21
|
+
} from "./workspace-files";
|
|
18
22
|
import { taskDir } from "../env";
|
|
19
23
|
import { PREVIEW_SIDECAR_TASK_LABEL } from "../preview-sidecar";
|
|
20
24
|
import { assertSafeHostTaskId } from "../task-identity";
|
|
@@ -271,6 +275,23 @@ class DockerTaskEnvironmentHandle
|
|
|
271
275
|
};
|
|
272
276
|
}
|
|
273
277
|
|
|
278
|
+
async readWorkspaceFile(filePath: string): Promise<Uint8Array | null> {
|
|
279
|
+
return hostWorkspaceRead(
|
|
280
|
+
this.descriptor,
|
|
281
|
+
path.join(this.#value.hostWorktreePath, "workspace"),
|
|
282
|
+
filePath,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async writeWorkspaceFile(filePath: string, bytes: Uint8Array): Promise<void> {
|
|
287
|
+
hostWorkspaceWrite(
|
|
288
|
+
this.descriptor,
|
|
289
|
+
path.join(this.#value.hostWorktreePath, "workspace"),
|
|
290
|
+
filePath,
|
|
291
|
+
bytes,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
274
295
|
exec(request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult> {
|
|
275
296
|
assertTaskEnvironmentProcessRequest(request);
|
|
276
297
|
return capturedCliExec(
|