@botiverse/testbed-cli 0.5.1 → 0.7.0
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/main.mjs +319 -0
- package/package.json +1 -1
- package/skills/testbed/SKILL.md +30 -0
package/lib/main.mjs
CHANGED
|
@@ -34,6 +34,57 @@ export const USAGE = `testbed — cloud phones + the acceptance testbed in the R
|
|
|
34
34
|
testbed verify login <session-id> --account <account-id>
|
|
35
35
|
testbed verify reinstall <session-id> --apk-key fast/<id> --apk-sha256 HEX
|
|
36
36
|
|
|
37
|
+
testbed bed provision [--ttl SEC] [--template ID] [--spec JSON] [--request-id ID]
|
|
38
|
+
create a bed (a Linux VM on exe.dev via the Broker); spec JSON e.g. '{"platform":"linux","labels":{"purpose":"probe"}}'
|
|
39
|
+
testbed bed list your beds
|
|
40
|
+
testbed bed get <bed-id>
|
|
41
|
+
testbed bed cleanup <bed-id> [--expected-revision N]
|
|
42
|
+
destroy the bed and prove zero provider residue
|
|
43
|
+
testbed bed residue <bed-id> read-only cleanup residue evidence (after tombstone)
|
|
44
|
+
testbed bed recover <bed-id> [--expected-revision N] [--request-id ID]
|
|
45
|
+
claim cleanup recovery of a bed stuck in cleanup
|
|
46
|
+
testbed bed reconcile <bed-id> reconcile a bed stuck in provisioning
|
|
47
|
+
testbed bed round <bed-id> --argv JSON [--timeout-ms N] [--expected-revision N] [--request-id ID]
|
|
48
|
+
run one command round on the bed (argv as a JSON array)
|
|
49
|
+
testbed bed report create <bed-id> --name NAME [--description TEXT] [--spec JSON] [--expected-revision N]
|
|
50
|
+
testbed bed report list <bed-id>
|
|
51
|
+
testbed bed retention status retention status across beds
|
|
52
|
+
testbed bed retention get <bed-id>
|
|
53
|
+
testbed bed retention retain <bed-id> --finding FINDING_ID --reason TEXT [--ttl SEC] [--identity JSON] [--notify JSON] [--expected-revision N]
|
|
54
|
+
testbed bed retention renew <bed-id> --reason TEXT [--ttl SEC] [--expected-hold-revision N] [--expected-revision N]
|
|
55
|
+
testbed bed retention release <bed-id> --disposition TEXT [--export-sha HEX] [--expected-hold-revision N] [--expected-revision N]
|
|
56
|
+
testbed bed template list | get <id> | save --name NAME [--description TEXT] --spec JSON | delete <id> [--expected-revision N]
|
|
57
|
+
testbed bed collab list <bed-id>
|
|
58
|
+
testbed bed collab grant <bed-id> --label LABEL --public-key-file FILE [--ttl SEC] [--expected-revision N] [--request-id ID]
|
|
59
|
+
testbed bed collab revoke <bed-id> <collaborator-id> [--expected-revision N] [--request-id ID]
|
|
60
|
+
testbed bed exec start <bed-id> --argv JSON [--timeout-ms N] [--expected-revision N] [--request-id ID]
|
|
61
|
+
long-running command on the bed; returns a job
|
|
62
|
+
testbed bed exec get <bed-id> <job-id>
|
|
63
|
+
testbed bed exec cancel <bed-id> <job-id> [--expected-job-revision N] [--request-id ID]
|
|
64
|
+
testbed bed diagnose <bed-id> <diagnostic-id>
|
|
65
|
+
cleanup diagnostic record
|
|
66
|
+
testbed bed llm preflight --integration REF
|
|
67
|
+
testbed bed llm diagnostic --correlation ID
|
|
68
|
+
provider LLM authority preflight / diagnostic (exe.dev integrations)
|
|
69
|
+
|
|
70
|
+
testbed pve facts --purpose P macOS VM (PVE VM130) bridge facts: guest agent, os, baseline revision
|
|
71
|
+
testbed pve acquire --purpose P [--ttl SEC] [--request-id ID]
|
|
72
|
+
lease the macOS VM (one active lease; ttl 60..3600, default 3600)
|
|
73
|
+
testbed pve status <lease-id> --epoch N --purpose P
|
|
74
|
+
the adapter's own lease state (independent readback)
|
|
75
|
+
testbed pve renew <lease-id> --epoch N --purpose P [--ttl SEC] [--request-id ID]
|
|
76
|
+
testbed pve exec <lease-id> --epoch N --purpose P --argv JSON [--timeout SEC]
|
|
77
|
+
testbed pve put <lease-id> --epoch N --purpose P --path /remote/file --file LOCAL [--timeout SEC]
|
|
78
|
+
file of at most 15,722,496 bytes (just under 15 MiB; the adapter caps the whole request at 20 MiB)
|
|
79
|
+
testbed pve get <lease-id> --epoch N --purpose P --path /remote/file [--out LOCAL] [--timeout SEC]
|
|
80
|
+
testbed pve reboot <lease-id> --epoch N --purpose P [--request-id ID]
|
|
81
|
+
testbed pve rollback <lease-id> --epoch N --purpose P [--request-id ID]
|
|
82
|
+
restore the baseline snapshot (everything after it is discarded)
|
|
83
|
+
testbed pve release <lease-id> --epoch N --purpose P [--request-id ID]
|
|
84
|
+
end the lease; the adapter ALSO rolls back to baseline
|
|
85
|
+
testbed pve reap --purpose P [--request-id ID]
|
|
86
|
+
reclaim expired leases
|
|
87
|
+
|
|
37
88
|
testbed skill print the agent skill (SKILL.md)
|
|
38
89
|
testbed skill --install [-a AGENT...] [--global]
|
|
39
90
|
install for any runtime via "npx skills add" (Claude Code, Codex, Cursor, Gemini, OpenCode, ...)
|
|
@@ -221,6 +272,274 @@ export async function main(argv, opts) {
|
|
|
221
272
|
emit(d, () => out(JSON.stringify(d, null, 2)));
|
|
222
273
|
return exit(0);
|
|
223
274
|
}
|
|
275
|
+
case "bed": {
|
|
276
|
+
// Beds: Linux VMs on exe.dev brokered by the testbed (artin 2026-09-07:
|
|
277
|
+
// everything an agent does goes through this CLI; the manifest only
|
|
278
|
+
// advertises agent-login). One subcommand per route; bodies mirror
|
|
279
|
+
// the route contracts exactly; --help never sends a request.
|
|
280
|
+
const sub = args.shift();
|
|
281
|
+
const bedPath = (id) => `/beds/${encodeURIComponent(id)}`;
|
|
282
|
+
const parseJson = (flag, text) => {
|
|
283
|
+
if (text === undefined) return undefined;
|
|
284
|
+
try { return JSON.parse(text); } catch { throw new Error(`--${flag} must be valid JSON`); }
|
|
285
|
+
};
|
|
286
|
+
const num = (flag, text) => (text === undefined ? undefined : Number(text));
|
|
287
|
+
const reqId = () => takeFlag(args, "request-id") ?? `testbed-cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
288
|
+
const bedLine = (b) => `bed ${b.id} — ${b.state}${b.provider_bed_id ? ` (${b.provider} ${b.provider_bed_id})` : ""}${b.expires_at ? `, expires ${new Date(b.expires_at).toISOString().slice(0, 16)}` : ""}`;
|
|
289
|
+
const opLine = (d, verb) => out(`${verb} ${d.bed?.id ?? d.bed_id ?? d.outcome?.bed ?? ""}: ${d.outcome?.outcome ?? d.operation?.state ?? (d.ok ? "ok" : "failed")}${d.error?.code ? ` (${d.error.code})` : ""}${d.idempotent ? " [idempotent]" : ""}`);
|
|
290
|
+
switch (sub) {
|
|
291
|
+
case "provision": {
|
|
292
|
+
const ttl = takeFlag(args, "ttl"); const template = takeFlag(args, "template"); const spec = parseJson("spec", takeFlag(args, "spec"));
|
|
293
|
+
const body = { idempotency_key: reqId(), ttl_seconds: num("ttl", ttl), template_id: template, spec: spec ?? (template ? undefined : { platform: "linux" }) };
|
|
294
|
+
const d = await api("POST", "/beds", body);
|
|
295
|
+
emit(d, () => { if (d.bed) out(bedLine(d.bed)); opLine(d, "provision"); });
|
|
296
|
+
return exit(d.ok === false ? 1 : 0);
|
|
297
|
+
}
|
|
298
|
+
case "list": {
|
|
299
|
+
const d = await api("GET", "/beds");
|
|
300
|
+
emit(d, () => { for (const b of d.beds ?? []) out(bedLine(b)); out(`${d.total ?? (d.beds ?? []).length} bed(s)`); });
|
|
301
|
+
return exit(0);
|
|
302
|
+
}
|
|
303
|
+
case "get": {
|
|
304
|
+
const id = args[0]; if (!id) throw new Error("usage: testbed bed get <bed-id>");
|
|
305
|
+
const d = await api("GET", bedPath(id));
|
|
306
|
+
emit(d, () => out(bedLine(d)));
|
|
307
|
+
return exit(0);
|
|
308
|
+
}
|
|
309
|
+
case "cleanup": {
|
|
310
|
+
const id = args[0]; if (!id) throw new Error("usage: testbed bed cleanup <bed-id> [--expected-revision N]");
|
|
311
|
+
const rev = takeFlag(args, "expected-revision");
|
|
312
|
+
const d = await api("DELETE", bedPath(id), { idempotency_key: reqId(), expected_revision: num("expected-revision", rev) });
|
|
313
|
+
emit(d, () => opLine(d, "cleanup"));
|
|
314
|
+
return exit(d.ok === false ? 1 : 0);
|
|
315
|
+
}
|
|
316
|
+
case "residue": {
|
|
317
|
+
const id = args[0]; if (!id) throw new Error("usage: testbed bed residue <bed-id>");
|
|
318
|
+
const d = await api("GET", `${bedPath(id)}/residue`);
|
|
319
|
+
emit(d, () => out(`bed ${id}: zero_residue=${d.zero_residue} vm_present=${d.evidence?.provider?.vm_present} serving=${String(d.serving_revision ?? "").slice(0, 12)}`));
|
|
320
|
+
return exit(d.zero_residue === true ? 0 : 1);
|
|
321
|
+
}
|
|
322
|
+
case "recover": {
|
|
323
|
+
const id = args[0]; if (!id) throw new Error("usage: testbed bed recover <bed-id> [--expected-revision N]");
|
|
324
|
+
const rev = takeFlag(args, "expected-revision");
|
|
325
|
+
const d = await api("POST", `${bedPath(id)}/cleanup-recovery-claim`, { idempotency_key: reqId(), expected_revision: num("expected-revision", rev) });
|
|
326
|
+
emit(d, () => opLine(d, "recover"));
|
|
327
|
+
return exit(d.ok === false ? 1 : 0);
|
|
328
|
+
}
|
|
329
|
+
case "reconcile": {
|
|
330
|
+
const id = args[0]; if (!id) throw new Error("usage: testbed bed reconcile <bed-id>");
|
|
331
|
+
const d = await api("POST", `${bedPath(id)}/reconcile`, {});
|
|
332
|
+
emit(d, () => opLine(d, "reconcile"));
|
|
333
|
+
return exit(d.ok === false ? 1 : 0);
|
|
334
|
+
}
|
|
335
|
+
case "round": {
|
|
336
|
+
const id = args[0]; if (!id) throw new Error("usage: testbed bed round <bed-id> --argv JSON [--timeout-ms N]");
|
|
337
|
+
const argv = parseJson("argv", takeFlag(args, "argv")); if (!Array.isArray(argv)) throw new Error("--argv must be a JSON array");
|
|
338
|
+
const d = await api("POST", `${bedPath(id)}/rounds`, { idempotency_key: reqId(), argv, timeout_ms: num("timeout-ms", takeFlag(args, "timeout-ms")), expected_revision: num("expected-revision", takeFlag(args, "expected-revision")) });
|
|
339
|
+
emit(d, () => opLine(d, "round"));
|
|
340
|
+
return exit(d.ok === false ? 1 : 0);
|
|
341
|
+
}
|
|
342
|
+
case "report": {
|
|
343
|
+
const action = args.shift(); const id = args[0];
|
|
344
|
+
if (action === "create") {
|
|
345
|
+
if (!id) throw new Error("usage: testbed bed report create <bed-id> --name NAME [--description TEXT] [--spec JSON]");
|
|
346
|
+
const name = takeFlag(args, "name"); if (!name) throw new Error("usage: --name is required");
|
|
347
|
+
const d = await api("POST", `${bedPath(id)}/reports`, { name, description: takeFlag(args, "description"), spec: parseJson("spec", takeFlag(args, "spec")), expected_revision: num("expected-revision", takeFlag(args, "expected-revision")) });
|
|
348
|
+
emit(d, () => out(`report ${d.report?.id ?? ""} created (${d.report?.body_sha256 ?? "no digest"})`));
|
|
349
|
+
return exit(d.ok === false ? 1 : 0);
|
|
350
|
+
}
|
|
351
|
+
if (action === "list") {
|
|
352
|
+
if (!id) throw new Error("usage: testbed bed report list <bed-id>");
|
|
353
|
+
const d = await api("GET", `${bedPath(id)}/reports`);
|
|
354
|
+
emit(d, () => { for (const r of d.reports ?? []) out(`report ${r.id} ${r.name ?? ""}`); out(`${d.total ?? 0} report(s)`); });
|
|
355
|
+
return exit(0);
|
|
356
|
+
}
|
|
357
|
+
throw new Error("usage: testbed bed report create|list …");
|
|
358
|
+
}
|
|
359
|
+
case "retention": {
|
|
360
|
+
const action = args.shift();
|
|
361
|
+
if (action === "status") { const d = await api("GET", "/retention"); emit(d, () => out(JSON.stringify(d))); return exit(0); }
|
|
362
|
+
const id = args[0]; if (!id) throw new Error(`usage: testbed bed retention ${action ?? "<get|retain|renew|release>"} <bed-id> …`);
|
|
363
|
+
const rp = `${bedPath(id)}/retention`;
|
|
364
|
+
if (action === "get") { const d = await api("GET", rp); emit(d, () => out(JSON.stringify(d.retention ?? d))); return exit(0); }
|
|
365
|
+
if (action === "retain") {
|
|
366
|
+
const finding = takeFlag(args, "finding"); const reason = takeFlag(args, "reason");
|
|
367
|
+
if (!finding || !reason) throw new Error("usage: --finding and --reason are required");
|
|
368
|
+
const d = await api("POST", rp, { finding_id: finding, reason, ttl_seconds: num("ttl", takeFlag(args, "ttl")), identity: parseJson("identity", takeFlag(args, "identity")), notify_targets: parseJson("notify", takeFlag(args, "notify")), expected_revision: num("expected-revision", takeFlag(args, "expected-revision")) });
|
|
369
|
+
emit(d, () => out(`retained ${id}: ${JSON.stringify(d.retention ?? d.next_action ?? d.ok)}`));
|
|
370
|
+
return exit(d.ok === false ? 1 : 0);
|
|
371
|
+
}
|
|
372
|
+
if (action === "renew") {
|
|
373
|
+
const reason = takeFlag(args, "reason"); if (!reason) throw new Error("usage: --reason is required");
|
|
374
|
+
const d = await api("PATCH", rp, { renewal_reason: reason, ttl_seconds: num("ttl", takeFlag(args, "ttl")), expected_hold_revision: num("expected-hold-revision", takeFlag(args, "expected-hold-revision")), expected_revision: num("expected-revision", takeFlag(args, "expected-revision")) });
|
|
375
|
+
emit(d, () => out(`renewed ${id}: ${JSON.stringify(d.retention ?? d.ok)}`));
|
|
376
|
+
return exit(d.ok === false ? 1 : 0);
|
|
377
|
+
}
|
|
378
|
+
if (action === "release") {
|
|
379
|
+
const disposition = takeFlag(args, "disposition"); if (!disposition) throw new Error("usage: --disposition is required");
|
|
380
|
+
const d = await api("DELETE", rp, { disposition, evidence_export_sha: takeFlag(args, "export-sha"), expected_hold_revision: num("expected-hold-revision", takeFlag(args, "expected-hold-revision")), expected_revision: num("expected-revision", takeFlag(args, "expected-revision")) });
|
|
381
|
+
emit(d, () => out(`released ${id}: ${JSON.stringify(d.retention ?? d.ok)}`));
|
|
382
|
+
return exit(d.ok === false ? 1 : 0);
|
|
383
|
+
}
|
|
384
|
+
throw new Error("usage: testbed bed retention status|get|retain|renew|release …");
|
|
385
|
+
}
|
|
386
|
+
case "template": {
|
|
387
|
+
const action = args.shift();
|
|
388
|
+
if (action === "list") { const d = await api("GET", "/templates"); emit(d, () => { for (const t of d.templates ?? []) out(`template ${t.id} ${t.name ?? ""}`); out(`${d.total ?? 0} template(s)`); }); return exit(0); }
|
|
389
|
+
if (action === "save") {
|
|
390
|
+
const name = takeFlag(args, "name"); const spec = parseJson("spec", takeFlag(args, "spec"));
|
|
391
|
+
if (!name || spec === undefined) throw new Error("usage: testbed bed template save --name NAME --spec JSON [--description TEXT]");
|
|
392
|
+
const d = await api("POST", "/templates", { name, description: takeFlag(args, "description"), spec, expected_revision: num("expected-revision", takeFlag(args, "expected-revision")) });
|
|
393
|
+
emit(d, () => out(`template ${d.template?.id ?? ""} saved`));
|
|
394
|
+
return exit(d.ok === false ? 1 : 0);
|
|
395
|
+
}
|
|
396
|
+
const id = args[0]; if (!id) throw new Error("usage: testbed bed template get|delete <template-id>");
|
|
397
|
+
if (action === "get") { const d = await api("GET", `/templates/${encodeURIComponent(id)}`); emit(d, () => out(d.template ? `template ${d.template.id} ${d.template.name ?? ""}` : `template ${id}: deleted`)); return exit(0); }
|
|
398
|
+
if (action === "delete") { const d = await api("DELETE", `/templates/${encodeURIComponent(id)}`, { expected_revision: num("expected-revision", takeFlag(args, "expected-revision")) }); emit(d, () => out(`template ${id} deleted${d.idempotent ? " [idempotent]" : ""}`)); return exit(d.ok === false ? 1 : 0); }
|
|
399
|
+
throw new Error("usage: testbed bed template list|get|save|delete …");
|
|
400
|
+
}
|
|
401
|
+
case "collab": {
|
|
402
|
+
const action = args.shift(); const id = args[0]; if (!id) throw new Error("usage: testbed bed collab list|grant|revoke <bed-id> …");
|
|
403
|
+
const cp = `${bedPath(id)}/collaborators`;
|
|
404
|
+
if (action === "list") { const d = await api("GET", cp); emit(d, () => out(`${d.total ?? 0} collaborator(s) on ${id}`)); return exit(0); }
|
|
405
|
+
if (action === "grant") {
|
|
406
|
+
const label = takeFlag(args, "label"); const keyFile = takeFlag(args, "public-key-file");
|
|
407
|
+
if (!label || !keyFile) throw new Error("usage: testbed bed collab grant <bed-id> --label LABEL --public-key-file FILE");
|
|
408
|
+
const publicKey = readFileSync(keyFile, "utf8").trim();
|
|
409
|
+
const d = await api("POST", cp, { idempotency_key: reqId(), label, public_key: publicKey, ttl_seconds: num("ttl", takeFlag(args, "ttl")), expected_revision: num("expected-revision", takeFlag(args, "expected-revision")) });
|
|
410
|
+
emit(d, () => out(`collaborator ${d.collaborator?.id ?? ""} ${d.collaborator?.state ?? ""}${d.idempotent ? " [idempotent]" : ""}`));
|
|
411
|
+
return exit(d.ok === false ? 1 : 0);
|
|
412
|
+
}
|
|
413
|
+
if (action === "revoke") {
|
|
414
|
+
const cid = args[1]; if (!cid) throw new Error("usage: testbed bed collab revoke <bed-id> <collaborator-id>");
|
|
415
|
+
const d = await api("DELETE", `${cp}/${encodeURIComponent(cid)}`, { idempotency_key: reqId(), expected_revision: num("expected-revision", takeFlag(args, "expected-revision")) });
|
|
416
|
+
emit(d, () => out(`collaborator ${cid} ${d.collaborator?.state ?? "revoked"}`));
|
|
417
|
+
return exit(d.ok === false ? 1 : 0);
|
|
418
|
+
}
|
|
419
|
+
throw new Error("usage: testbed bed collab list|grant|revoke …");
|
|
420
|
+
}
|
|
421
|
+
case "exec": {
|
|
422
|
+
const action = args.shift(); const id = args[0]; if (!id) throw new Error("usage: testbed bed exec start|get|cancel <bed-id> …");
|
|
423
|
+
const lp = `${bedPath(id)}/long-executions`;
|
|
424
|
+
if (action === "start") {
|
|
425
|
+
const argv = parseJson("argv", takeFlag(args, "argv")); if (!Array.isArray(argv)) throw new Error("--argv must be a JSON array");
|
|
426
|
+
const d = await api("POST", lp, { idempotency_key: reqId(), argv, timeout_ms: num("timeout-ms", takeFlag(args, "timeout-ms")), expected_revision: num("expected-revision", takeFlag(args, "expected-revision")) });
|
|
427
|
+
emit(d, () => out(`job ${d.execution?.id ?? ""} ${d.execution?.state ?? ""}`));
|
|
428
|
+
return exit(0);
|
|
429
|
+
}
|
|
430
|
+
const jobId = args[1]; if (!jobId) throw new Error("usage: testbed bed exec get|cancel <bed-id> <job-id>");
|
|
431
|
+
if (action === "get") { const d = await api("GET", `${lp}/${encodeURIComponent(jobId)}`); emit(d, () => out(`job ${jobId} ${d.execution?.state ?? ""}`)); return exit(d.ok === false ? 1 : 0); }
|
|
432
|
+
if (action === "cancel") { const d = await api("DELETE", `${lp}/${encodeURIComponent(jobId)}`, { idempotency_key: reqId(), expected_job_revision: num("expected-job-revision", takeFlag(args, "expected-job-revision")) }); emit(d, () => out(`job ${jobId} ${d.execution?.state ?? "cancelled"}`)); return exit(0); }
|
|
433
|
+
throw new Error("usage: testbed bed exec start|get|cancel …");
|
|
434
|
+
}
|
|
435
|
+
case "diagnose": {
|
|
436
|
+
const id = args[0]; const diag = args[1]; if (!id || !diag) throw new Error("usage: testbed bed diagnose <bed-id> <diagnostic-id>");
|
|
437
|
+
const d = await api("GET", `${bedPath(id)}/cleanup-diagnostics/${encodeURIComponent(diag)}`);
|
|
438
|
+
emit(d, () => out(JSON.stringify(d.diagnostic ?? d)));
|
|
439
|
+
return exit(0);
|
|
440
|
+
}
|
|
441
|
+
case "llm": {
|
|
442
|
+
const action = args.shift();
|
|
443
|
+
if (action === "preflight") { const ref = takeFlag(args, "integration"); if (!ref) throw new Error("usage: testbed bed llm preflight --integration REF"); const d = await api("POST", "/provider-llm/authority", { integration_ref: ref }); emit(d, () => out(JSON.stringify(d.integration ?? d))); return exit(d.ok === false ? 1 : 0); }
|
|
444
|
+
if (action === "diagnostic") { const cid = takeFlag(args, "correlation"); if (!cid) throw new Error("usage: testbed bed llm diagnostic --correlation ID"); const d = await api("POST", "/provider-llm/authority-diagnostic", { correlation_id: cid }); emit(d, () => out(JSON.stringify(d.diagnostic ?? d))); return exit(0); }
|
|
445
|
+
throw new Error("usage: testbed bed llm preflight|diagnostic …");
|
|
446
|
+
}
|
|
447
|
+
default:
|
|
448
|
+
throw new Error("usage: testbed bed provision|list|get|cleanup|residue|recover|reconcile|round|report|retention|template|collab|exec|diagnose|llm …");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
case "pve": {
|
|
452
|
+
// macOS VM lease on Proxmox (VM130) through the Worker's pve-macos
|
|
453
|
+
// routes (task #42, replaces PR #114). Same rule as beds: CLI only,
|
|
454
|
+
// nothing in the manifest. The Worker signs every adapter call with
|
|
455
|
+
// its own credential; the agent never sees the bridge, its token or
|
|
456
|
+
// a host command. `--purpose` is explicit on every call because the
|
|
457
|
+
// provider gate is purpose-based (quarantine accepts exactly one).
|
|
458
|
+
const sub = args.shift();
|
|
459
|
+
const base = "/pve-macos";
|
|
460
|
+
const parseJson = (flag, text) => {
|
|
461
|
+
if (text === undefined) return undefined;
|
|
462
|
+
try { return JSON.parse(text); } catch { throw new Error(`--${flag} must be valid JSON`); }
|
|
463
|
+
};
|
|
464
|
+
const num = (flag, text) => (text === undefined ? undefined : Number(text));
|
|
465
|
+
const reqId = () => takeFlag(args, "request-id") ?? `testbed-cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
466
|
+
const purpose = () => { const p = takeFlag(args, "purpose"); if (!p) throw new Error(`usage: testbed pve ${sub} … --purpose P (required; the provider gate is purpose-based)`); return p; };
|
|
467
|
+
const leaseRef = (usage) => {
|
|
468
|
+
const id = args[0]; const epoch = takeFlag(args, "epoch");
|
|
469
|
+
if (!id || epoch === undefined) throw new Error(usage);
|
|
470
|
+
args.shift();
|
|
471
|
+
return { lease_id: id, epoch: Number(epoch) };
|
|
472
|
+
};
|
|
473
|
+
const leaseLine = (l) => (l ? `lease ${l.lease_id} epoch ${l.epoch} — ${l.state}${l.resource_id ? ` on ${l.resource_id}` : ""}${l.expires_at ? `, expires ${l.expires_at}` : ""}` : "");
|
|
474
|
+
const q = (obj) => "?" + Object.entries(obj).filter(([, v]) => v !== undefined).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
|
|
475
|
+
switch (sub) {
|
|
476
|
+
case "facts": {
|
|
477
|
+
const d = await api("GET", `${base}/facts${q({ purpose: purpose() })}`);
|
|
478
|
+
emit(d, () => out(`${d.resource_id ?? "vm"}: ${d.os ?? "?"} ${d.arch ?? ""} guest_agent=${d.guest_agent ?? "?"} baseline=${d.baseline_revision ?? "?"}${d.boot_id ? ` boot=${d.boot_id}` : ""}`));
|
|
479
|
+
return exit(0);
|
|
480
|
+
}
|
|
481
|
+
case "acquire": {
|
|
482
|
+
const ttl = takeFlag(args, "ttl");
|
|
483
|
+
const body = { purpose: purpose(), ttl_seconds: num("ttl", ttl), request_id: reqId() };
|
|
484
|
+
const d = await api("POST", `${base}/acquire`, body);
|
|
485
|
+
emit(d, () => out(leaseLine(d.lease ?? d)));
|
|
486
|
+
return exit(0);
|
|
487
|
+
}
|
|
488
|
+
case "status": {
|
|
489
|
+
const ref = leaseRef("usage: testbed pve status <lease-id> --epoch N --purpose P");
|
|
490
|
+
const d = await api("GET", `${base}/lease-status${q({ lease_id: ref.lease_id, epoch: ref.epoch, purpose: purpose() })}`);
|
|
491
|
+
emit(d, () => out(leaseLine(d.lease ?? d)));
|
|
492
|
+
return exit(0);
|
|
493
|
+
}
|
|
494
|
+
case "renew": {
|
|
495
|
+
const ref = leaseRef("usage: testbed pve renew <lease-id> --epoch N --purpose P [--ttl SEC]");
|
|
496
|
+
const ttl = takeFlag(args, "ttl");
|
|
497
|
+
const d = await api("POST", `${base}/renew`, { purpose: purpose(), ...ref, ttl_seconds: num("ttl", ttl), request_id: reqId() });
|
|
498
|
+
emit(d, () => out(leaseLine(d.lease ?? d)));
|
|
499
|
+
return exit(0);
|
|
500
|
+
}
|
|
501
|
+
case "exec": {
|
|
502
|
+
const ref = leaseRef("usage: testbed pve exec <lease-id> --epoch N --purpose P --argv JSON [--timeout SEC]");
|
|
503
|
+
const argv = parseJson("argv", takeFlag(args, "argv")); const timeout = takeFlag(args, "timeout");
|
|
504
|
+
if (!Array.isArray(argv) || argv.length === 0) throw new Error("--argv must be a non-empty JSON array of strings");
|
|
505
|
+
const d = await api("POST", `${base}/exec`, { purpose: purpose(), ...ref, argv, timeout_seconds: num("timeout", timeout), request_id: reqId() });
|
|
506
|
+
emit(d, () => { if (d.stdout) out(d.stdout.replace(/\n$/, "")); if (d.stderr) err(d.stderr.replace(/\n$/, "")); out(`exit ${d.exit_code}${d.timed_out ? " (timed out)" : ""}`); });
|
|
507
|
+
return exit(typeof d.exit_code === "number" && d.exit_code !== 0 ? 1 : 0);
|
|
508
|
+
}
|
|
509
|
+
case "put": {
|
|
510
|
+
const ref = leaseRef("usage: testbed pve put <lease-id> --epoch N --purpose P --path /remote/file --file LOCAL [--timeout SEC]");
|
|
511
|
+
const remote = takeFlag(args, "path"); const file = takeFlag(args, "file"); const timeout = takeFlag(args, "timeout");
|
|
512
|
+
if (!remote || !file) throw new Error("usage: testbed pve put <lease-id> --epoch N --purpose P --path /remote/file --file LOCAL");
|
|
513
|
+
const data = readFileSync(file);
|
|
514
|
+
const d = await api("POST", `${base}/put`, { purpose: purpose(), ...ref, path: remote, data_base64: data.toString("base64"), timeout_seconds: num("timeout", timeout), request_id: reqId() });
|
|
515
|
+
emit(d, () => out(`put ${remote}: ${data.length} bytes${d.sha256 ? ` sha256=${d.sha256}` : ""}`));
|
|
516
|
+
return exit(0);
|
|
517
|
+
}
|
|
518
|
+
case "get": {
|
|
519
|
+
const ref = leaseRef("usage: testbed pve get <lease-id> --epoch N --purpose P --path /remote/file [--out LOCAL] [--timeout SEC]");
|
|
520
|
+
const remote = takeFlag(args, "path"); const outFile = takeFlag(args, "out"); const timeout = takeFlag(args, "timeout");
|
|
521
|
+
if (!remote) throw new Error("usage: testbed pve get <lease-id> --epoch N --purpose P --path /remote/file [--out LOCAL]");
|
|
522
|
+
const d = await api("POST", `${base}/get`, { purpose: purpose(), ...ref, path: remote, timeout_seconds: num("timeout", timeout), request_id: reqId() });
|
|
523
|
+
const bytes = Buffer.from(d.data ?? "", "base64");
|
|
524
|
+
if (outFile) { writeFileSync(outFile, bytes); emit(d, () => out(`get ${remote}: ${bytes.length} bytes → ${outFile}${d.sha256 ? ` sha256=${d.sha256}` : ""}`)); }
|
|
525
|
+
else emit(d, () => out(bytes.toString("utf8")));
|
|
526
|
+
return exit(0);
|
|
527
|
+
}
|
|
528
|
+
case "reboot": case "rollback": case "release": {
|
|
529
|
+
const ref = leaseRef(`usage: testbed pve ${sub} <lease-id> --epoch N --purpose P`);
|
|
530
|
+
const d = await api("POST", `${base}/${sub}`, { purpose: purpose(), ...ref, request_id: reqId() });
|
|
531
|
+
emit(d, () => out(`${sub} ${ref.lease_id} epoch ${ref.epoch}: ${d.ok === false ? "failed" : "ok"}${d.lease?.state ? ` (${d.lease.state})` : ""}`));
|
|
532
|
+
return exit(d.ok === false ? 1 : 0);
|
|
533
|
+
}
|
|
534
|
+
case "reap": {
|
|
535
|
+
const d = await api("POST", `${base}/reap-expired`, { purpose: purpose(), request_id: reqId() });
|
|
536
|
+
emit(d, () => out(`reap-expired: ${d.reaped ?? d.count ?? "ok"}`));
|
|
537
|
+
return exit(0);
|
|
538
|
+
}
|
|
539
|
+
default:
|
|
540
|
+
throw new Error("usage: testbed pve facts|acquire|status|renew|exec|put|get|reboot|rollback|release|reap …");
|
|
541
|
+
}
|
|
542
|
+
}
|
|
224
543
|
case "verify": {
|
|
225
544
|
const sub = args.shift();
|
|
226
545
|
const vs = (id) => `/verify-sessions/${encodeURIComponent(id)}`;
|
package/package.json
CHANGED
package/skills/testbed/SKILL.md
CHANGED
|
@@ -72,6 +72,36 @@ Device safety (non-negotiable):
|
|
|
72
72
|
- Cloud-phone screenshots can return a stale frame: to prove "nothing changed", the
|
|
73
73
|
capture must include something that would have changed if it were live.
|
|
74
74
|
|
|
75
|
+
## Beds (Linux VMs on exe.dev)
|
|
76
|
+
|
|
77
|
+
A bed is a throwaway Linux VM the testbed brokers on exe.dev. Everything is `testbed bed …`; the Raft manifest advertises nothing for beds.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
testbed bed provision --ttl 900 --spec '{"platform":"linux","labels":{"purpose":"probe"}}' # → bed id, state ready when the VM is up
|
|
81
|
+
testbed bed list | testbed bed get <bed-id>
|
|
82
|
+
testbed bed round <bed-id> --argv '["uname","-a"]' # one command round
|
|
83
|
+
testbed bed exec start <bed-id> --argv '["./long.sh"]' # long job → job id; exec get/cancel
|
|
84
|
+
testbed bed collab grant <bed-id> --label me --public-key-file ~/.ssh/id_ed25519.pub # SSH access; the key never leaves the file
|
|
85
|
+
testbed bed cleanup <bed-id> # destroy; then `bed residue <bed-id>` proves zero provider residue
|
|
86
|
+
```
|
|
87
|
+
Rules: always `cleanup` what you `provision` (TTL is a backstop, not a plan); `residue` is the proof, not the destroy call; `--request-id` makes provision/round/exec/collab idempotent on retry; reports, retention and templates exist for evidence handling and are documented in `testbed bed --help`.
|
|
88
|
+
|
|
89
|
+
## macOS VM (PVE VM130 lease)
|
|
90
|
+
|
|
91
|
+
One macOS VM on Proxmox, leased through the testbed; the Worker signs every adapter call with its own credential, so you never see the bridge, its token or a host command. Everything is `testbed pve …`; the manifest advertises nothing for it. `--purpose` is required on every call: while the provider is in quarantine the only accepted value is `task-9-universal-lease-v0.1-acceptance`.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
P=task-9-universal-lease-v0.1-acceptance
|
|
95
|
+
testbed pve facts --purpose $P # guest agent, os, baseline revision
|
|
96
|
+
testbed pve acquire --purpose $P --ttl 3600 # → lease id + epoch (one active lease)
|
|
97
|
+
testbed pve status <lease-id> --epoch N --purpose $P # the adapter's own view, not the Worker's
|
|
98
|
+
testbed pve exec <lease-id> --epoch N --purpose $P --argv '["uname","-a"]'
|
|
99
|
+
testbed pve put <lease-id> --epoch N --purpose $P --path /tmp/x --file ./x
|
|
100
|
+
testbed pve get <lease-id> --epoch N --purpose $P --path /tmp/x --out ./x.back
|
|
101
|
+
testbed pve release <lease-id> --epoch N --purpose $P # ends the lease AND rolls back to baseline
|
|
102
|
+
```
|
|
103
|
+
Rules: `put` carries at most 15,722,496 bytes per file (just under 15 MiB: the adapter caps the whole request, data base64-encoded plus metadata, at 20 MiB; the Worker refuses larger requests with 413 before sending). TTL is 60..3600 s. `release` and `rollback` restore the baseline snapshot, so anything written after it is gone — copy out what you need first. Wrong owner/epoch/expired are typed refusals from the adapter, relayed verbatim. `--request-id` makes acquire/renew/exec/put/reboot/rollback/release idempotent on retry.
|
|
104
|
+
|
|
75
105
|
## Errors
|
|
76
106
|
|
|
77
107
|
- `401` → `testbed login` (agent) or check `TESTBED_TOKEN` (human/CI).
|