@botiverse/testbed-cli 0.5.0 → 0.6.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
@@ -15,8 +15,10 @@ export const USAGE = `testbed — cloud phones + the acceptance testbed in the R
15
15
  testbed whoami who the API thinks you are
16
16
 
17
17
  testbed pool warm-pool devices and free/leased state
18
- testbed acquire [--ttl MIN] [--instance ID] [--request-id ID] [--key-out FILE]
19
- lease a phone; the ADB private key goes to --key-out (0600) or --json
18
+ testbed acquire [--ttl MIN] [--instance ID] [--request-id ID] [--key-out FILE] [--wait-ms N]
19
+ lease a phone; waits through in_flight (same request id) up to --wait-ms (90000);
20
+ the ADB private key goes to --key-out (0600) or --json
21
+ testbed <command> --help usage only — never sends a request
20
22
  testbed devices your leases
21
23
  testbed renew <lease-id> [--ttl MIN]
22
24
  testbed release <lease-id>
@@ -32,6 +34,39 @@ export const USAGE = `testbed — cloud phones + the acceptance testbed in the R
32
34
  testbed verify login <session-id> --account <account-id>
33
35
  testbed verify reinstall <session-id> --apk-key fast/<id> --apk-sha256 HEX
34
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
+
35
70
  testbed skill print the agent skill (SKILL.md)
36
71
  testbed skill --install [-a AGENT...] [--global]
37
72
  install for any runtime via "npx skills add" (Claude Code, Codex, Cursor, Gemini, OpenCode, ...)
@@ -95,6 +130,10 @@ export async function main(argv, opts) {
95
130
  const env = opts.env ?? process.env;
96
131
  const exit = opts.exit ?? ((code) => process.exit(code));
97
132
  const json = takeSwitch(args, "json");
133
+ // `--help` / `-h` anywhere prints usage and NEVER reaches the API. 2026-09-05:
134
+ // `testbed acquire --help` acquired a device (HuaTuo) because flags were
135
+ // only consumed by the command that expected them.
136
+ if (args.includes("--help") || args.includes("-h")) { out(USAGE); return exit(0); }
98
137
  const cmd = args.shift();
99
138
  const emit = (data, human) => { if (json) out(JSON.stringify(data, null, 2)); else human(); };
100
139
 
@@ -148,11 +187,24 @@ export async function main(argv, opts) {
148
187
  const body = {};
149
188
  if (ttl !== undefined) body.ttl_minutes = Number(ttl);
150
189
  if (instance) body.instance_id = instance;
151
- if (requestId) body.request_id = requestId;
152
- const d = await api("POST", "/device-broker/leases", body);
153
- if (d.status === "in_flight") {
154
- emit(d, () => out(`in_flight: request ${d.request_id} on ${d.instance_id ?? "?"} retry acquire with --request-id ${d.request_id}`));
155
- return exit(2);
190
+ // Always send a request id so a retry never takes a second device: the
191
+ // broker answers 202 in_flight (retryable) while it is still preparing
192
+ // the lease, and the SAME request id retried returns the acquired lease
193
+ // with its one-time key. 2026-09-05 (HuaTuo): the old CLI printed the
194
+ // in_flight receipt and exited 2, leaving a lease held with no key.
195
+ body.request_id = requestId ?? `testbed-cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
196
+ const waitMs = Number(takeFlag(args, "wait-ms", "90000"));
197
+ const sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
198
+ const started = Date.now();
199
+ let d = await api("POST", "/device-broker/leases", body);
200
+ while (d.status === "in_flight") {
201
+ if (Date.now() - started > waitMs) {
202
+ err(`testbed: acquire still in_flight after ${Math.round(waitMs / 1000)}s (request ${body.request_id}${d.instance_id ? ` on ${d.instance_id}` : ""}); the broker may still hold a lease for this request — run \`testbed devices\` and release it, or retry with --request-id ${body.request_id}`);
203
+ return exit(2);
204
+ }
205
+ if (!json) err(`testbed: in_flight${d.instance_id ? ` on ${d.instance_id}` : ""}, retrying with the same request id…`);
206
+ await sleep(2000);
207
+ d = await api("POST", "/device-broker/leases", body);
156
208
  }
157
209
  let keyPath = null;
158
210
  if (keyOut && d.adb_private_key) {
@@ -202,6 +254,182 @@ export async function main(argv, opts) {
202
254
  emit(d, () => out(JSON.stringify(d, null, 2)));
203
255
  return exit(0);
204
256
  }
257
+ case "bed": {
258
+ // Beds: Linux VMs on exe.dev brokered by the testbed (artin 2026-09-07:
259
+ // everything an agent does goes through this CLI; the manifest only
260
+ // advertises agent-login). One subcommand per route; bodies mirror
261
+ // the route contracts exactly; --help never sends a request.
262
+ const sub = args.shift();
263
+ const bedPath = (id) => `/beds/${encodeURIComponent(id)}`;
264
+ const parseJson = (flag, text) => {
265
+ if (text === undefined) return undefined;
266
+ try { return JSON.parse(text); } catch { throw new Error(`--${flag} must be valid JSON`); }
267
+ };
268
+ const num = (flag, text) => (text === undefined ? undefined : Number(text));
269
+ const reqId = () => takeFlag(args, "request-id") ?? `testbed-cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
270
+ 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]" : ""}`);
272
+ switch (sub) {
273
+ case "provision": {
274
+ const ttl = takeFlag(args, "ttl"); const template = takeFlag(args, "template"); const spec = parseJson("spec", takeFlag(args, "spec"));
275
+ const body = { idempotency_key: reqId(), ttl_seconds: num("ttl", ttl), template_id: template, spec: spec ?? (template ? undefined : { platform: "linux" }) };
276
+ const d = await api("POST", "/beds", body);
277
+ emit(d, () => { if (d.bed) out(bedLine(d.bed)); opLine(d, "provision"); });
278
+ return exit(d.ok === false ? 1 : 0);
279
+ }
280
+ case "list": {
281
+ const d = await api("GET", "/beds");
282
+ emit(d, () => { for (const b of d.beds ?? []) out(bedLine(b)); out(`${d.total ?? (d.beds ?? []).length} bed(s)`); });
283
+ return exit(0);
284
+ }
285
+ case "get": {
286
+ const id = args[0]; if (!id) throw new Error("usage: testbed bed get <bed-id>");
287
+ const d = await api("GET", bedPath(id));
288
+ emit(d, () => out(bedLine(d)));
289
+ return exit(0);
290
+ }
291
+ case "cleanup": {
292
+ const id = args[0]; if (!id) throw new Error("usage: testbed bed cleanup <bed-id> [--expected-revision N]");
293
+ const rev = takeFlag(args, "expected-revision");
294
+ const d = await api("DELETE", bedPath(id), { idempotency_key: reqId(), expected_revision: num("expected-revision", rev) });
295
+ emit(d, () => opLine(d, "cleanup"));
296
+ return exit(d.ok === false ? 1 : 0);
297
+ }
298
+ case "residue": {
299
+ const id = args[0]; if (!id) throw new Error("usage: testbed bed residue <bed-id>");
300
+ const d = await api("GET", `${bedPath(id)}/residue`);
301
+ 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)}`));
302
+ return exit(d.zero_residue === true ? 0 : 1);
303
+ }
304
+ case "recover": {
305
+ const id = args[0]; if (!id) throw new Error("usage: testbed bed recover <bed-id> [--expected-revision N]");
306
+ const rev = takeFlag(args, "expected-revision");
307
+ const d = await api("POST", `${bedPath(id)}/cleanup-recovery-claim`, { idempotency_key: reqId(), expected_revision: num("expected-revision", rev) });
308
+ emit(d, () => opLine(d, "recover"));
309
+ return exit(d.ok === false ? 1 : 0);
310
+ }
311
+ case "reconcile": {
312
+ const id = args[0]; if (!id) throw new Error("usage: testbed bed reconcile <bed-id>");
313
+ const d = await api("POST", `${bedPath(id)}/reconcile`, {});
314
+ emit(d, () => opLine(d, "reconcile"));
315
+ return exit(d.ok === false ? 1 : 0);
316
+ }
317
+ case "round": {
318
+ const id = args[0]; if (!id) throw new Error("usage: testbed bed round <bed-id> --argv JSON [--timeout-ms N]");
319
+ const argv = parseJson("argv", takeFlag(args, "argv")); if (!Array.isArray(argv)) throw new Error("--argv must be a JSON array");
320
+ 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")) });
321
+ emit(d, () => opLine(d, "round"));
322
+ return exit(d.ok === false ? 1 : 0);
323
+ }
324
+ case "report": {
325
+ const action = args.shift(); const id = args[0];
326
+ if (action === "create") {
327
+ if (!id) throw new Error("usage: testbed bed report create <bed-id> --name NAME [--description TEXT] [--spec JSON]");
328
+ const name = takeFlag(args, "name"); if (!name) throw new Error("usage: --name is required");
329
+ 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")) });
330
+ emit(d, () => out(`report ${d.report?.id ?? ""} created (${d.report?.body_sha256 ?? "no digest"})`));
331
+ return exit(d.ok === false ? 1 : 0);
332
+ }
333
+ if (action === "list") {
334
+ if (!id) throw new Error("usage: testbed bed report list <bed-id>");
335
+ const d = await api("GET", `${bedPath(id)}/reports`);
336
+ emit(d, () => { for (const r of d.reports ?? []) out(`report ${r.id} ${r.name ?? ""}`); out(`${d.total ?? 0} report(s)`); });
337
+ return exit(0);
338
+ }
339
+ throw new Error("usage: testbed bed report create|list …");
340
+ }
341
+ case "retention": {
342
+ const action = args.shift();
343
+ if (action === "status") { const d = await api("GET", "/retention"); emit(d, () => out(JSON.stringify(d))); return exit(0); }
344
+ const id = args[0]; if (!id) throw new Error(`usage: testbed bed retention ${action ?? "<get|retain|renew|release>"} <bed-id> …`);
345
+ const rp = `${bedPath(id)}/retention`;
346
+ if (action === "get") { const d = await api("GET", rp); emit(d, () => out(JSON.stringify(d.retention ?? d))); return exit(0); }
347
+ if (action === "retain") {
348
+ const finding = takeFlag(args, "finding"); const reason = takeFlag(args, "reason");
349
+ if (!finding || !reason) throw new Error("usage: --finding and --reason are required");
350
+ 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")) });
351
+ emit(d, () => out(`retained ${id}: ${JSON.stringify(d.retention ?? d.next_action ?? d.ok)}`));
352
+ return exit(d.ok === false ? 1 : 0);
353
+ }
354
+ if (action === "renew") {
355
+ const reason = takeFlag(args, "reason"); if (!reason) throw new Error("usage: --reason is required");
356
+ 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")) });
357
+ emit(d, () => out(`renewed ${id}: ${JSON.stringify(d.retention ?? d.ok)}`));
358
+ return exit(d.ok === false ? 1 : 0);
359
+ }
360
+ if (action === "release") {
361
+ const disposition = takeFlag(args, "disposition"); if (!disposition) throw new Error("usage: --disposition is required");
362
+ 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")) });
363
+ emit(d, () => out(`released ${id}: ${JSON.stringify(d.retention ?? d.ok)}`));
364
+ return exit(d.ok === false ? 1 : 0);
365
+ }
366
+ throw new Error("usage: testbed bed retention status|get|retain|renew|release …");
367
+ }
368
+ case "template": {
369
+ const action = args.shift();
370
+ 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); }
371
+ if (action === "save") {
372
+ const name = takeFlag(args, "name"); const spec = parseJson("spec", takeFlag(args, "spec"));
373
+ if (!name || spec === undefined) throw new Error("usage: testbed bed template save --name NAME --spec JSON [--description TEXT]");
374
+ const d = await api("POST", "/templates", { name, description: takeFlag(args, "description"), spec, expected_revision: num("expected-revision", takeFlag(args, "expected-revision")) });
375
+ emit(d, () => out(`template ${d.template?.id ?? ""} saved`));
376
+ return exit(d.ok === false ? 1 : 0);
377
+ }
378
+ const id = args[0]; if (!id) throw new Error("usage: testbed bed template get|delete <template-id>");
379
+ 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); }
380
+ 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); }
381
+ throw new Error("usage: testbed bed template list|get|save|delete …");
382
+ }
383
+ case "collab": {
384
+ const action = args.shift(); const id = args[0]; if (!id) throw new Error("usage: testbed bed collab list|grant|revoke <bed-id> …");
385
+ const cp = `${bedPath(id)}/collaborators`;
386
+ if (action === "list") { const d = await api("GET", cp); emit(d, () => out(`${d.total ?? 0} collaborator(s) on ${id}`)); return exit(0); }
387
+ if (action === "grant") {
388
+ const label = takeFlag(args, "label"); const keyFile = takeFlag(args, "public-key-file");
389
+ if (!label || !keyFile) throw new Error("usage: testbed bed collab grant <bed-id> --label LABEL --public-key-file FILE");
390
+ const publicKey = readFileSync(keyFile, "utf8").trim();
391
+ 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")) });
392
+ emit(d, () => out(`collaborator ${d.collaborator?.id ?? ""} ${d.collaborator?.state ?? ""}${d.idempotent ? " [idempotent]" : ""}`));
393
+ return exit(d.ok === false ? 1 : 0);
394
+ }
395
+ if (action === "revoke") {
396
+ const cid = args[1]; if (!cid) throw new Error("usage: testbed bed collab revoke <bed-id> <collaborator-id>");
397
+ const d = await api("DELETE", `${cp}/${encodeURIComponent(cid)}`, { idempotency_key: reqId(), expected_revision: num("expected-revision", takeFlag(args, "expected-revision")) });
398
+ emit(d, () => out(`collaborator ${cid} ${d.collaborator?.state ?? "revoked"}`));
399
+ return exit(d.ok === false ? 1 : 0);
400
+ }
401
+ throw new Error("usage: testbed bed collab list|grant|revoke …");
402
+ }
403
+ case "exec": {
404
+ const action = args.shift(); const id = args[0]; if (!id) throw new Error("usage: testbed bed exec start|get|cancel <bed-id> …");
405
+ const lp = `${bedPath(id)}/long-executions`;
406
+ if (action === "start") {
407
+ const argv = parseJson("argv", takeFlag(args, "argv")); if (!Array.isArray(argv)) throw new Error("--argv must be a JSON array");
408
+ 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")) });
409
+ emit(d, () => out(`job ${d.execution?.id ?? ""} ${d.execution?.state ?? ""}`));
410
+ return exit(0);
411
+ }
412
+ const jobId = args[1]; if (!jobId) throw new Error("usage: testbed bed exec get|cancel <bed-id> <job-id>");
413
+ 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); }
414
+ 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); }
415
+ throw new Error("usage: testbed bed exec start|get|cancel …");
416
+ }
417
+ case "diagnose": {
418
+ const id = args[0]; const diag = args[1]; if (!id || !diag) throw new Error("usage: testbed bed diagnose <bed-id> <diagnostic-id>");
419
+ const d = await api("GET", `${bedPath(id)}/cleanup-diagnostics/${encodeURIComponent(diag)}`);
420
+ emit(d, () => out(JSON.stringify(d.diagnostic ?? d)));
421
+ return exit(0);
422
+ }
423
+ case "llm": {
424
+ const action = args.shift();
425
+ 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); }
426
+ 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); }
427
+ throw new Error("usage: testbed bed llm preflight|diagnostic …");
428
+ }
429
+ default:
430
+ throw new Error("usage: testbed bed provision|list|get|cleanup|residue|recover|reconcile|round|report|retention|template|collab|exec|diagnose|llm …");
431
+ }
432
+ }
205
433
  case "verify": {
206
434
  const sub = args.shift();
207
435
  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.5.0",
3
+ "version": "0.6.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",
@@ -72,6 +72,20 @@ 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
+
75
89
  ## Errors
76
90
 
77
91
  - `401` → `testbed login` (agent) or check `TESTBED_TOKEN` (human/CI).