@botiverse/testbed-cli 0.6.0 → 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 CHANGED
@@ -67,6 +67,24 @@ export const USAGE = `testbed — cloud phones + the acceptance testbed in the R
67
67
  testbed bed llm diagnostic --correlation ID
68
68
  provider LLM authority preflight / diagnostic (exe.dev integrations)
69
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
+
70
88
  testbed skill print the agent skill (SKILL.md)
71
89
  testbed skill --install [-a AGENT...] [--global]
72
90
  install for any runtime via "npx skills add" (Claude Code, Codex, Cursor, Gemini, OpenCode, ...)
@@ -268,7 +286,7 @@ export async function main(argv, opts) {
268
286
  const num = (flag, text) => (text === undefined ? undefined : Number(text));
269
287
  const reqId = () => takeFlag(args, "request-id") ?? `testbed-cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
270
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)}` : ""}`;
271
- const opLine = (d, verb) => out(`${verb} ${d.bed?.id ?? d.bed_id ?? ""}: ${d.outcome?.outcome ?? d.operation?.state ?? (d.ok ? "ok" : "failed")}${d.error?.code ? ` (${d.error.code})` : ""}${d.idempotent ? " [idempotent]" : ""}`);
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]" : ""}`);
272
290
  switch (sub) {
273
291
  case "provision": {
274
292
  const ttl = takeFlag(args, "ttl"); const template = takeFlag(args, "template"); const spec = parseJson("spec", takeFlag(args, "spec"));
@@ -430,6 +448,98 @@ export async function main(argv, opts) {
430
448
  throw new Error("usage: testbed bed provision|list|get|cleanup|residue|recover|reconcile|round|report|retention|template|collab|exec|diagnose|llm …");
431
449
  }
432
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
+ }
433
543
  case "verify": {
434
544
  const sub = args.shift();
435
545
  const vs = (id) => `/verify-sessions/${encodeURIComponent(id)}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/testbed-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "CLI for testbed \u2014 cloud phones and the acceptance testbed in the Raft release loop",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -86,6 +86,22 @@ testbed bed cleanup <bed-id> # destroy; then `b
86
86
  ```
87
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
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
+
89
105
  ## Errors
90
106
 
91
107
  - `401` → `testbed login` (agent) or check `TESTBED_TOKEN` (human/CI).