@ornncompute/cli 1.0.75 → 1.0.107

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.
Files changed (54) hide show
  1. package/README.md +38 -5
  2. package/package.json +4 -2
  3. package/src/admin-capacity-reservations.mjs +4 -1
  4. package/src/admin-command-handlers.mjs +4 -0
  5. package/src/admin-node-diagnostics.mjs +1 -5
  6. package/src/admin-node-lifecycle.mjs +23 -125
  7. package/src/admin-node-sanitize.mjs +243 -0
  8. package/src/admin-ssh.mjs +113 -0
  9. package/src/api-client.mjs +4 -1
  10. package/src/catalog-shell.mjs +6 -6
  11. package/src/evidence-commands.mjs +4 -6
  12. package/src/external-bucket-commands.mjs +71 -23
  13. package/src/interaction-runtime.mjs +3 -0
  14. package/src/kubernetes-commands.mjs +16 -83
  15. package/src/machine-reference.mjs +33 -56
  16. package/src/slurm-commands.mjs +13 -2
  17. package/src/vm-commands.mjs +39 -59
  18. package/vendor/api-client/commerce.js +745 -0
  19. package/vendor/capabilities/cli.js +7756 -7
  20. package/vendor/capabilities/capabilities/admin-capacity-reservations.js +0 -101
  21. package/vendor/capabilities/capabilities/admin-enrollment.js +0 -322
  22. package/vendor/capabilities/capabilities/admin-node-diagnostics.js +0 -88
  23. package/vendor/capabilities/capabilities/admin-nodes-lifecycle.js +0 -137
  24. package/vendor/capabilities/capabilities/admin.js +0 -91
  25. package/vendor/capabilities/capabilities/cli.js +0 -156
  26. package/vendor/capabilities/capabilities/clusters.js +0 -248
  27. package/vendor/capabilities/capabilities/commerce-market.js +0 -405
  28. package/vendor/capabilities/capabilities/compute.js +0 -523
  29. package/vendor/capabilities/capabilities/credits.js +0 -83
  30. package/vendor/capabilities/capabilities/evidence.js +0 -145
  31. package/vendor/capabilities/capabilities/external-buckets.js +0 -191
  32. package/vendor/capabilities/capabilities/fleet.js +0 -386
  33. package/vendor/capabilities/capabilities/identity.js +0 -129
  34. package/vendor/capabilities/capabilities/listings.js +0 -398
  35. package/vendor/capabilities/capabilities/locality.js +0 -42
  36. package/vendor/capabilities/capabilities/market-shared.js +0 -46
  37. package/vendor/capabilities/capabilities/markets.js +0 -482
  38. package/vendor/capabilities/capabilities/mounts.js +0 -176
  39. package/vendor/capabilities/capabilities/native-listing.js +0 -99
  40. package/vendor/capabilities/capabilities/networks.js +0 -1264
  41. package/vendor/capabilities/capabilities/observability.js +0 -253
  42. package/vendor/capabilities/capabilities/organization-invites.js +0 -53
  43. package/vendor/capabilities/capabilities/reservation-list.js +0 -68
  44. package/vendor/capabilities/capabilities/staff-shared.js +0 -64
  45. package/vendor/capabilities/capabilities/tokens.js +0 -161
  46. package/vendor/capabilities/capabilities/users.js +0 -157
  47. package/vendor/capabilities/capabilities/volumes.js +0 -178
  48. package/vendor/capabilities/capability.js +0 -99
  49. package/vendor/capabilities/cli-catalog.js +0 -73
  50. package/vendor/capabilities/cli-migrations.js +0 -264
  51. package/vendor/capabilities/names.js +0 -6
  52. package/vendor/capabilities/software-catalog.js +0 -82
  53. package/vendor/capabilities/spec-catalog.js +0 -151
  54. package/vendor/capabilities/supplier-directory.js +0 -20
package/README.md CHANGED
@@ -7,8 +7,17 @@ sends browser and CLI traffic only through the API gateway.
7
7
  For the customer install and usage guide, see [`docs/cli.md`](../../docs/cli.md).
8
8
  The shared capability catalog in `packages/capabilities` is the authority for
9
9
  command identity, role visibility, input schemas, help, and resource discovery.
10
- The CLI imports `@ornn/capabilities/cli`; packaging includes only that entrypoint's
11
- dependencies, excluding staff commerce and CRM handlers.
10
+ The CLI imports `@ornn/capabilities/cli`; packaging includes its dependencies,
11
+ including `@ornn/api-client`, and excludes MCP-only catalog handlers. These
12
+ packages run without workspace links or React.
13
+
14
+ Native Commerce requests use the caller’s bearer directly through the gateway:
15
+ `/commerce/*` for public APIs and `/internal/v1/*` for staff APIs.
16
+ The gateway and Commerce enforce the caller’s organization and staff permissions.
17
+ All Commerce operations use `@ornn/api-client/commerce`, including the reservation
18
+ reads inside launch, connect, and evidence flows. Customer reservation pickers
19
+ explicitly scope every page to the selected organization, including for staff.
20
+ The gateway retains its Commerce workflow adapters for already-published CLI versions.
12
21
 
13
22
  For GCS imports, start with the [source-bucket permissions and version-specific CLI guide](https://docs.ornn.com/cli#gcs-imports-and-storage-transfer-service).
14
23
  It documents the current CLI 1.x GCS verification limitation. Scoped
@@ -82,6 +91,9 @@ filesystems deploy | show | wait | delete
82
91
  ```
83
92
 
84
93
  `ssh-keys list` uses backend cursors; see [pagination](../../docs/cli.md#advanced-customer-resources).
94
+ Organization key deletion saves removal on reservation lists before revoking the
95
+ account key. If preparation fails, retry the deletion; re-registering the key
96
+ does not restore its removed reservation access.
85
97
 
86
98
  Market commands call one gateway route each and print that route's JSON.
87
99
  `capacity-offers list|show|terms` reads native Commerce capacity
@@ -104,7 +116,9 @@ CLI does not send them yet), end a fill with
104
116
  on a whole primary listing for one of its terms (`--listing-term-id`, optional
105
117
  `--duration-seconds`), list the offers your organization made or received, and
106
118
  accept or cancel offers by `--offer-id`.
107
- Prices are integer micro-USD per GPU-hour (`1000000` is $1.00). Writes execute
119
+ Prices are integer micro-USD per GPU-hour (`1000000` is $1.00); CPU capacity
120
+ (a null GPU type, traded on the `cpu` book) is priced and counted per vCPU in
121
+ the same fields. Writes execute
108
122
  immediately; use `--format json` for machine-readable output and
109
123
  `--idempotency-key` for safe retries. Customer payment methods and
110
124
  billing stay in the web app. Launch consumes already allocated
@@ -125,8 +139,10 @@ Reuse the key and payload on retries. Creation requires an eligible human
125
139
  terminal. Native capacity creation takes
126
140
  `--gpu-slug`, `--gpus-per-node`, `--facility-id`, `--provisioning-type`,
127
141
  `--state`, `--terms` (a JSON array) and `--idempotency-key`, plus optional
128
- `--organization-id`. Give `--capacity-node-count`, or `--node-ids` (a JSON array)
129
- to attach nodes at creation; the count then defaults to the number of nodes.
142
+ `--organization-id`. CPU capacity takes `--gpu-slug cpu` and
143
+ `--cores-per-node` (a whole number of at least 1; each core is 2 vCPUs) instead
144
+ of `--gpus-per-node`; its term prices are per vCPU-hour. Give
145
+ `--capacity-node-count`, or `--node-ids` (a JSON array) to attach nodes at creation; the count then defaults to the number of nodes.
130
146
  Primary capacity takes `--days-to-rfs` (0 when ready now) or both
131
147
  `--available-at` and `--available-until`, not both. Reuse the key and the same
132
148
  body on retries. Prices are integer micro-USD inside terms, with explicit
@@ -187,6 +203,17 @@ include the final newline required by OpenSSH, both in files and with `--print`.
187
203
  `ornn admin nodes enroll` takes optional `--filter COMPARTMENT_IDS`
188
204
  (omit to run every compartment step); the retired `--mode` flag is gone. The
189
205
  gateway, not CLI metadata, authoritatively enforces the admin role.
206
+ `ornn admin nodes sanitize`, `sanitize-status`, and `sanitize-decide` drive one
207
+ node's held sanitize job, sharing the capability with MCP
208
+ `admin_node_sanitize_*` and Slack `node_sanitize_*`. `sanitize` needs
209
+ `--operation`, its exact `--confirmation` phrase, and `--recovery-uuid` for
210
+ `ram_roundtrip` (refused for `data_wipe`); the job then waits for staff approval.
211
+ `sanitize-decide --confirmation "ACCEPT MANUAL CLEANUP"` also needs
212
+ `--reservation`, `--release-operation` (the Orchestrator release operation),
213
+ `--reason`, `--evidence-file` (only its SHA-256 is sent), and each attestation
214
+ flag: `--tenant-access-revoked`, `--data-drives-sanitized`, and
215
+ `--acknowledge-retained-os`. Sanitize writes run only in an eligible human
216
+ terminal. See [manual cleanup with the boot OS retained](../../docs/provisioning/dedicated-machine-runbook.md#manual-cleanup-with-the-boot-os-retained).
190
217
  Suppliers are Authorization supplier organizations: `ornn admin suppliers list`
191
218
  returns their `id` and `name`, and `--supplier UUID` on `facilities list`,
192
219
  `nodes list`, `enrollment-tokens create`, and `nodes enroll` takes that
@@ -430,3 +457,9 @@ request conflicts, and an expired incomplete request requires a new key.
430
457
  `ornn volumes refresh <volume>` reconciles recorded usage against stored files.
431
458
  The upload replay contract requires the matching gateway/orchestrator release;
432
459
  see [the service contract](../../services/orchestrator/README.md#file-upload-replay-and-usage).
460
+
461
+ Staff can use `ornn admin ssh <node-id>` for certificate access on explicitly
462
+ opted-in nodes. It requires an interactive staff login, creates a temporary
463
+ local key, obtains a 15-minute certificate through the gateway and launches
464
+ OpenSSH with normal host-key verification. Temporary files are removed when
465
+ the command exits. Shared and per-node key access remain available.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornncompute/cli",
3
- "version": "1.0.75",
3
+ "version": "1.0.107",
4
4
  "description": "Command-line interface for Ornn compute access workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,7 +35,9 @@
35
35
  "test": "node --test 'test/*.test.mjs'"
36
36
  },
37
37
  "devDependencies": {
38
- "@ornn/capabilities": "0.1.0"
38
+ "@ornn/api-client": "0.1.0",
39
+ "@ornn/capabilities": "0.1.0",
40
+ "esbuild": "^0.28.2"
39
41
  },
40
42
  "engines": {
41
43
  "node": ">=20"
@@ -153,7 +153,10 @@ function reservationCreateReview(input) {
153
153
  { label: "Nodes", value: input.nodes },
154
154
  { label: "Starts at", value: input.startsAt },
155
155
  { label: "Ends at", value: input.endsAt },
156
- { label: "Price per GPU-hour (micro-USD)", value: input.pricePerGpuHourMicroUsd },
156
+ {
157
+ label: "Price per unit-hour (micro-USD; per vCPU on CPU capacity)",
158
+ value: input.pricePerGpuHourMicroUsd,
159
+ },
157
160
  { label: "Idempotency key", value: input.idempotencyKey },
158
161
  ],
159
162
  consequence:
@@ -1,16 +1,20 @@
1
+ import { adminSshHandlers } from "./admin-ssh.mjs";
1
2
  import { adminCapacityReservationHandlers } from "./admin-capacity-reservations.mjs";
2
3
  import { adminCreditHandlers } from "./admin-credits.mjs";
3
4
  import { adminEnrollmentHandlers } from "./admin-enrollment.mjs";
4
5
  import { adminNodeDiagnosticsHandlers } from "./admin-node-diagnostics.mjs";
5
6
  import { adminNodeLifecycleHandlers } from "./admin-node-lifecycle.mjs";
7
+ import { adminNodeSanitizeHandlers } from "./admin-node-sanitize.mjs";
6
8
  import { ADMIN_MUTATION_SAFETY, assertAdminMutationContext } from "./admin-mutations.mjs";
7
9
 
8
10
  const handlers = [
11
+ ...adminSshHandlers,
9
12
  ...adminCapacityReservationHandlers,
10
13
  ...adminCreditHandlers,
11
14
  ...adminEnrollmentHandlers,
12
15
  ...adminNodeDiagnosticsHandlers,
13
16
  ...adminNodeLifecycleHandlers,
17
+ ...adminNodeSanitizeHandlers,
14
18
  ];
15
19
 
16
20
  export function adminCommandHandler(capability) {
@@ -112,7 +112,6 @@ function nodeContext(payload, expectedNodeId) {
112
112
  }
113
113
  const explicitReachability =
114
114
  presentString(payload.reachability?.status) ??
115
- presentString(payload.off_grid_reachability?.status) ??
116
115
  (typeof payload.reachable === "boolean"
117
116
  ? payload.reachable
118
117
  ? "reachable"
@@ -127,10 +126,7 @@ function nodeContext(payload, expectedNodeId) {
127
126
  presentString(payload.status) ??
128
127
  "unknown",
129
128
  reachability: explicitReachability ?? "unknown",
130
- observed_at:
131
- presentString(payload.observed_at) ??
132
- presentString(payload.last_heartbeat_at) ??
133
- presentString(payload.off_grid_reachability?.last_checked_at),
129
+ observed_at: presentString(payload.observed_at) ?? presentString(payload.last_heartbeat_at),
134
130
  };
135
131
  }
136
132
 
@@ -1,3 +1,4 @@
1
+ import { getReservation } from "../vendor/api-client/commerce.js";
1
2
  import { ADMIN_MUTATION_SAFETY, AdminMutationError, runAdminMutation } from "./admin-mutations.mjs";
2
3
  import { isRecoverableWaitFailure } from "./interaction-runtime.mjs";
3
4
  import { waitForGoal as waitForReadGoal } from "./wait-runtime.mjs";
@@ -9,8 +10,6 @@ const POLL_INTERVAL_MS = 1_000;
9
10
  const COMMAND_BY_CAPABILITY = Object.freeze({
10
11
  "nodes.reboot": "reboot",
11
12
  "nodes.hard_reset": "hard-reset",
12
- "node.off-grid": "suspend-management",
13
- "node.on-grid": "resume-management",
14
13
  "node.terminate": "terminate",
15
14
  "nodes.assign": "assign",
16
15
  });
@@ -25,30 +24,18 @@ const USAGE = Object.freeze({
25
24
  reboot: "Usage: ornn admin nodes reboot <node-ref> [--no-wait] [--timeout SECONDS]",
26
25
  "hard-reset":
27
26
  "Usage: ornn admin nodes hard-reset <node-ref> --erase-local-data [--no-wait] [--timeout SECONDS]",
28
- "suspend-management":
29
- "Usage: ornn admin nodes suspend-management <node-ref> [--reason TEXT] [--idempotency-key KEY] [--wait|--no-wait] [--timeout SECONDS]",
30
- "resume-management":
31
- "Usage: ornn admin nodes resume-management <node-ref> [--idempotency-key KEY] [--wait|--no-wait] [--timeout SECONDS]",
32
27
  terminate:
33
- "Usage: ornn admin nodes terminate <node-ref> --delete-node-record [--skip-cleanup] [--reason TEXT] [--idempotency-key KEY]",
28
+ "Usage: ornn admin nodes terminate <node-ref> --delete-node-record [--reason TEXT] [--idempotency-key KEY]",
34
29
  assign:
35
30
  "Usage: ornn admin nodes assign <node-ref> --reservation RESERVATION [--idempotency-key KEY] [--wait|--no-wait] [--timeout SECONDS]",
36
31
  });
37
32
 
38
- const BOOLEAN_OPTIONS = new Set([
39
- "delete-node-record",
40
- "erase-local-data",
41
- "no-wait",
42
- "skip-cleanup",
43
- "wait",
44
- ]);
33
+ const BOOLEAN_OPTIONS = new Set(["delete-node-record", "erase-local-data", "no-wait", "wait"]);
45
34
  const VALUE_OPTIONS = new Set(["idempotency-key", "reason", "reservation", "timeout"]);
46
35
  const ALLOWED_OPTIONS = Object.freeze({
47
36
  reboot: new Set(["wait", "no-wait", "timeout"]),
48
37
  "hard-reset": new Set(["erase-local-data", "wait", "no-wait", "timeout"]),
49
- "suspend-management": new Set(["reason", "idempotency-key", "wait", "no-wait", "timeout"]),
50
- "resume-management": new Set(["idempotency-key", "wait", "no-wait", "timeout"]),
51
- terminate: new Set(["delete-node-record", "skip-cleanup", "reason", "idempotency-key"]),
38
+ terminate: new Set(["delete-node-record", "reason", "idempotency-key"]),
52
39
  assign: new Set(["reservation", "idempotency-key", "wait", "no-wait", "timeout"]),
53
40
  });
54
41
 
@@ -152,13 +139,9 @@ export function parseAdminNodeLifecycleInput(command, argv) {
152
139
  });
153
140
  }
154
141
  if (name !== "hard-reset" && options["erase-local-data"]) throw mutationError(usage);
155
- if (name !== "terminate" && (options["delete-node-record"] || options["skip-cleanup"])) {
156
- throw mutationError(usage);
157
- }
142
+ if (name !== "terminate" && options["delete-node-record"]) throw mutationError(usage);
158
143
  if (name !== "assign" && options.reservation) throw mutationError(usage);
159
- if (!name.includes("management") && name !== "terminate" && options.reason) {
160
- throw mutationError(usage);
161
- }
144
+ if (name !== "terminate" && options.reason) throw mutationError(usage);
162
145
 
163
146
  const idempotencyKey = options["idempotency-key"]?.trim();
164
147
  const safety =
@@ -173,12 +156,7 @@ export function parseAdminNodeLifecycleInput(command, argv) {
173
156
  ...(options.wait ? { wait: true } : options["no-wait"] ? { wait: false } : {}),
174
157
  ...(options.timeout ? { timeout_seconds: durationSeconds(options.timeout, usage) } : {}),
175
158
  ...(name === "hard-reset" ? { erase_local_data: true } : {}),
176
- ...(name === "terminate"
177
- ? {
178
- delete_node_record: true,
179
- ...(options["skip-cleanup"] ? { skip_cleanup: true } : {}),
180
- }
181
- : {}),
159
+ ...(name === "terminate" ? { delete_node_record: true } : {}),
182
160
  ...(name === "assign" ? { reservation_id: options.reservation } : {}),
183
161
  },
184
162
  mutation: {
@@ -209,16 +187,7 @@ function nodeSnapshot(node) {
209
187
  organization_id: text(node?.organization_id),
210
188
  facility_id: text(node?.facility_id ?? node?.facilityId),
211
189
  reservation_id: reservationId(node),
212
- off_grid: node?.off_grid === true,
213
190
  cleanup_state: text(node?.cleanup_state),
214
- reachability:
215
- node?.reachable === false || node?.health_readiness?.reachable === false
216
- ? "unreachable"
217
- : text(
218
- node?.reachability?.status ??
219
- node?.off_grid_reachability?.status ??
220
- node?.health_readiness?.reachability
221
- ),
222
191
  };
223
192
  }
224
193
 
@@ -226,18 +195,6 @@ function sameSnapshot(left, right) {
226
195
  return JSON.stringify(nodeSnapshot(left)) === JSON.stringify(nodeSnapshot(right));
227
196
  }
228
197
 
229
- function isOffGrid(node) {
230
- const snapshot = nodeSnapshot(node);
231
- return snapshot.off_grid || ["off-grid", "off_grid"].includes(snapshot.status.toLowerCase());
232
- }
233
-
234
- function isUnreachable(node) {
235
- const snapshot = nodeSnapshot(node);
236
- return (
237
- isOffGrid(node) || ["unreachable", "offline"].includes(snapshot.reachability.toLowerCase())
238
- );
239
- }
240
-
241
198
  function nodeRows(payload) {
242
199
  if (Array.isArray(payload)) return payload;
243
200
  return Array.isArray(payload?.nodes) ? payload.nodes : [];
@@ -348,20 +305,18 @@ async function resolveLiveMachine(fetch, node) {
348
305
  function reservationSnapshot(reservation, requestedId) {
349
306
  return {
350
307
  reservation_id: text(reservation?.reservationId) || requestedId,
351
- organization_id: text(reservation?.organizationId),
308
+ organization_id: text(reservation?.customerId ?? reservation?.organizationId),
352
309
  listing_id: text(reservation?.listingId),
353
310
  kind: text(reservation?.kind),
354
311
  status: text(reservation?.status),
355
- node_count: Number(reservation?.nodeCount ?? 0) || 0,
312
+ node_count: Number(reservation?.reservedNodeCount ?? 0) || 0,
356
313
  starts_at: text(reservation?.startsAt),
357
314
  ends_at: text(reservation?.endsAt),
358
315
  };
359
316
  }
360
317
 
361
318
  async function readReservation(fetch, id) {
362
- const reservation = await fetch(`/commerce/reservations/${encodeURIComponent(id)}`, {
363
- method: "GET",
364
- });
319
+ const reservation = await getReservation(fetch, id);
365
320
  return reservationSnapshot(reservation, id);
366
321
  }
367
322
 
@@ -403,11 +358,9 @@ function safeOwnerResult(result) {
403
358
  const safe = {};
404
359
  for (const key of [
405
360
  "deleted",
406
- "forced",
407
361
  "id",
408
362
  "mode",
409
363
  "node_id",
410
- "off_grid",
411
364
  "packet_id",
412
365
  "request_id",
413
366
  "reservation_id",
@@ -475,47 +428,13 @@ async function prepareLifecycle({ command, fetch, input, machine }) {
475
428
  };
476
429
  }
477
430
 
478
- if (command === "suspend-management") {
479
- fields.push({ label: "Host power", value: "leave powered on" });
480
- fields.push({ label: "Managed access", value: "remove agent management and managed SSH keys" });
481
- if (input.reason) fields.push({ label: "Reason", value: input.reason });
482
- return {
483
- review: {
484
- title: "Review management suspension",
485
- fields,
486
- consequence: "Ornn management and managed access end while the host remains powered on.",
487
- },
488
- value,
489
- };
490
- }
491
-
492
- if (command === "resume-management") {
493
- fields.push({ label: "Management", value: "remotely re-enroll the node" });
494
- fields.push({ label: "Managed access", value: "restore after successful enrollment" });
495
- return {
496
- review: {
497
- title: "Review management resumption",
498
- fields,
499
- consequence: "Ornn uses the stored admin access to reinstall management on this host.",
500
- },
501
- value,
502
- };
503
- }
504
-
505
431
  if (command === "terminate") {
506
- if (input.skip_cleanup && !isUnreachable(node)) {
507
- throw mutationError(
508
- "--skip-cleanup is allowed only when the authoritative node read says the node is unreachable or off-grid.",
509
- { code: "skip_cleanup_requires_unreachable", field: "skip-cleanup" }
510
- );
511
- }
512
432
  fields.push({
513
433
  label: "Host cleanup",
514
- value: input.skip_cleanup
515
- ? "SKIP; the node is authoritatively unreachable/off-grid"
516
- : "not dispatched by termination; complete any required host wipe separately before reuse",
434
+ value:
435
+ "not dispatched by termination; complete any required host wipe separately before reuse",
517
436
  });
518
- fields.push({ label: "Node record", value: "PERMANENTLY DELETE" });
437
+ fields.push({ label: "Node record", value: "MARK TERMINATED" });
519
438
  fields.push({
520
439
  label: "Reservation/capacity consequence",
521
440
  value: reservationId(node)
@@ -529,7 +448,7 @@ async function prepareLifecycle({ command, fetch, input, machine }) {
529
448
  title: "Review node termination",
530
449
  fields,
531
450
  consequence:
532
- "The node record is permanently deleted; this does not cancel its capacity reservation.",
451
+ "The node record is marked terminated; this does not cancel its capacity reservation.",
533
452
  },
534
453
  value,
535
454
  };
@@ -573,11 +492,11 @@ async function reconcileLifecycle({ fetch, input, value }) {
573
492
  data: { node_id: id, status: "already_absent", deleted: false },
574
493
  };
575
494
  }
576
- if (input.skip_cleanup && !isUnreachable(current)) {
495
+ // Terminate keeps the record, so a re-run finds it terminated, not gone.
496
+ if (isTerminated(current)) {
577
497
  return {
578
- status: "conflict",
579
- message:
580
- "The node is no longer authoritatively unreachable/off-grid; renew review without --skip-cleanup.",
498
+ status: "satisfied",
499
+ data: { node_id: id, status: "terminated", deleted: true },
581
500
  };
582
501
  }
583
502
  if (!sameSnapshot(reviewedNode, current)) {
@@ -590,29 +509,6 @@ async function reconcileLifecycle({ fetch, input, value }) {
590
509
  }
591
510
  if (!current) return { status: "conflict", message: "The exact node no longer exists." };
592
511
 
593
- if (command === "suspend-management") {
594
- if (isOffGrid(current)) {
595
- return { status: "satisfied", data: { node_id: id, status: "off-grid", off_grid: true } };
596
- }
597
- if (!sameSnapshot(reviewedNode, current)) {
598
- return { status: "conflict", message: "The node's management goal changed after review." };
599
- }
600
- return { status: "apply" };
601
- }
602
-
603
- if (command === "resume-management") {
604
- if (!isOffGrid(current)) {
605
- return {
606
- status: "satisfied",
607
- data: { node_id: id, status: nodeSnapshot(current).status, off_grid: false },
608
- };
609
- }
610
- if (!sameSnapshot(reviewedNode, current)) {
611
- return { status: "conflict", message: "The node's management goal changed after review." };
612
- }
613
- return { status: "apply" };
614
- }
615
-
616
512
  if (command === "assign") {
617
513
  const currentReservation = reservationId(current);
618
514
  if (currentReservation === input.reservation_id) {
@@ -660,15 +556,17 @@ async function reconcileLifecycle({ fetch, input, value }) {
660
556
  }
661
557
 
662
558
  function goalSatisfied(command, node, input) {
663
- if (command === "terminate") return node === null;
559
+ if (command === "terminate") return node === null || isTerminated(node);
664
560
  if (!node) return false;
665
- if (command === "suspend-management") return isOffGrid(node);
666
- if (command === "resume-management") return !isOffGrid(node);
667
561
  if (command === "assign") return reservationId(node) === input.reservation_id;
668
562
  if (cleanupState(node) === "failed") return false;
669
563
  return !restartInProgress(node);
670
564
  }
671
565
 
566
+ function isTerminated(node) {
567
+ return text(node?.status).toLowerCase() === "terminated";
568
+ }
569
+
672
570
  function cleanupState(node) {
673
571
  return text(node?.cleanup_state).toLowerCase();
674
572
  }
@@ -0,0 +1,243 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ import { ADMIN_MUTATION_SAFETY, AdminMutationError, runAdminMutation } from "./admin-mutations.mjs";
5
+ import { parseSemanticArguments } from "./interaction-runtime.mjs";
6
+
7
+ const MANUAL_CLEANUP = "ACCEPT MANUAL CLEANUP";
8
+
9
+ function usageError(capability, detail, field) {
10
+ return new AdminMutationError(
11
+ [`Usage: ${capability.cli.vnext.human}`, detail].filter(Boolean).join("\n"),
12
+ { code: "usage_error", ...(field ? { field } : {}) }
13
+ );
14
+ }
15
+
16
+ function semanticArguments(argv, capability) {
17
+ const { $positionals, ...options } = parseSemanticArguments(argv);
18
+ if ($positionals.length !== 1 || !$positionals[0]) {
19
+ throw usageError(capability, "Pass exactly one canonical node ID.", "node-id");
20
+ }
21
+ return { nodeId: $positionals[0], options };
22
+ }
23
+
24
+ function rejectExtra(capability, extra) {
25
+ const [name] = Object.keys(extra);
26
+ if (name) throw usageError(capability, `Unknown option: --${name}`, name);
27
+ }
28
+
29
+ function validated(capability, input) {
30
+ const parsed = capability.input.safeParse(input);
31
+ if (!parsed.success) throw usageError(capability, parsed.error.message);
32
+ return parsed.data;
33
+ }
34
+
35
+ function evidenceSha256(capability, path) {
36
+ try {
37
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
38
+ } catch (error) {
39
+ throw usageError(
40
+ capability,
41
+ `Could not read --evidence-file: ${error instanceof Error ? error.message : String(error)}`,
42
+ "evidence-file"
43
+ );
44
+ }
45
+ }
46
+
47
+ function parseStart(argv, capability) {
48
+ const {
49
+ nodeId,
50
+ options: { operation, confirmation, jobId, batch, recoveryUuid, ...extra },
51
+ } = semanticArguments(argv, capability);
52
+ rejectExtra(capability, extra);
53
+ if (typeof operation !== "string") throw usageError(capability, "--operation is required.");
54
+ const input = validated(capability, {
55
+ node_id: nodeId,
56
+ job_id: jobId ?? randomUUID(),
57
+ operation,
58
+ ...(batch ? { maintenance_batch_id: batch } : {}),
59
+ ...(recoveryUuid ? { recovery_uuid: recoveryUuid } : {}),
60
+ confirmation,
61
+ });
62
+ return {
63
+ input,
64
+ mutation: { idempotencyKey: input.job_id, safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly },
65
+ };
66
+ }
67
+
68
+ function parseStatus(argv, capability) {
69
+ const {
70
+ nodeId,
71
+ options: { job, ...extra },
72
+ } = semanticArguments(argv, capability);
73
+ rejectExtra(capability, extra);
74
+ return { input: validated(capability, { node_id: nodeId, job_id: job }) };
75
+ }
76
+
77
+ function parseDecision(argv, capability) {
78
+ const {
79
+ nodeId,
80
+ options: {
81
+ job,
82
+ confirmation,
83
+ reservation,
84
+ releaseOperation,
85
+ reason,
86
+ evidenceFile,
87
+ tenantAccessRevoked,
88
+ dataDrivesSanitized,
89
+ acknowledgeRetainedOs,
90
+ ...extra
91
+ },
92
+ } = semanticArguments(argv, capability);
93
+ rejectExtra(capability, extra);
94
+ const cleanupOptions = {
95
+ reservation,
96
+ releaseOperation,
97
+ reason,
98
+ evidenceFile,
99
+ tenantAccessRevoked,
100
+ dataDrivesSanitized,
101
+ acknowledgeRetainedOs,
102
+ };
103
+ if (confirmation !== MANUAL_CLEANUP) {
104
+ const [supplied] =
105
+ Object.entries(cleanupOptions).find(([, value]) => value !== undefined) ?? [];
106
+ if (supplied) {
107
+ throw usageError(
108
+ capability,
109
+ `Manual cleanup options require --confirmation "${MANUAL_CLEANUP}".`
110
+ );
111
+ }
112
+ return {
113
+ input: validated(capability, { node_id: nodeId, job_id: job, confirmation }),
114
+ mutation: { safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly },
115
+ };
116
+ }
117
+ // Each attestation is the operator's own flag, never a default.
118
+ for (const [name, value] of Object.entries(cleanupOptions)) {
119
+ if (value === undefined || value === "") {
120
+ const flag = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
121
+ throw usageError(capability, `--${flag} is required for manual cleanup.`, flag);
122
+ }
123
+ }
124
+ const input = validated(capability, {
125
+ node_id: nodeId,
126
+ job_id: job,
127
+ confirmation,
128
+ manual_cleanup: {
129
+ reservation_id: reservation,
130
+ lifecycle_operation_id: releaseOperation,
131
+ reason,
132
+ evidence_sha256: evidenceSha256(capability, evidenceFile),
133
+ acknowledge_retained_os: true,
134
+ tenant_access_revoked: true,
135
+ data_drives_sanitized: true,
136
+ },
137
+ });
138
+ return { input, mutation: { safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly } };
139
+ }
140
+
141
+ const startConsequence = {
142
+ data_wipe: "Erases every local data drive; the OS drive is preserved.",
143
+ ram_roundtrip: "Boots the node through RAM and back without erasing drives.",
144
+ node_turnover: "Erases the whole node, including the OS drive, and restores a host image.",
145
+ host_restore: "Restores the host OS from its trusted image.",
146
+ };
147
+
148
+ function decisionReview(input) {
149
+ const fields = [
150
+ { label: "Node", value: input.node_id },
151
+ { label: "Sanitize job", value: input.job_id },
152
+ { label: "Decision", value: input.confirmation },
153
+ ];
154
+ if (input.confirmation === "APPROVE SANITIZATION") {
155
+ return { fields, consequence: "Starts the held destructive maintenance on this node." };
156
+ }
157
+ if (input.confirmation === "SKIP SANITIZATION") {
158
+ return {
159
+ fields,
160
+ consequence: "Skips an eligible first-use wipe; nothing is erased or certified.",
161
+ };
162
+ }
163
+ const cleanup = input.manual_cleanup;
164
+ return {
165
+ fields: [
166
+ ...fields,
167
+ { label: "Reservation", value: cleanup.reservation_id },
168
+ { label: "Release operation", value: cleanup.lifecycle_operation_id },
169
+ { label: "Reason", value: cleanup.reason },
170
+ { label: "Evidence SHA-256", value: cleanup.evidence_sha256 },
171
+ {
172
+ label: "You attest",
173
+ value:
174
+ "tenant access revoked; every local data drive sanitized; existing OS retained and not certified",
175
+ },
176
+ ],
177
+ consequence:
178
+ "Lets the held release finish without a full wipe. The boot disk and prior root exposure remain.",
179
+ };
180
+ }
181
+
182
+ async function runStart({ execute, input, mutation, runtime }) {
183
+ return runAdminMutation({
184
+ ...runtime,
185
+ ...mutation,
186
+ prepare: async () => ({
187
+ review: {
188
+ title: "Start node sanitization",
189
+ fields: [
190
+ { label: "Node", value: input.node_id },
191
+ { label: "Operation", value: input.operation ?? "data_wipe" },
192
+ ...(input.recovery_uuid ? [{ label: "Recovery UUID", value: input.recovery_uuid }] : []),
193
+ { label: "Sanitize job", value: input.job_id },
194
+ { label: "Confirmation", value: input.confirmation },
195
+ ],
196
+ consequence: `${startConsequence[input.operation ?? "data_wipe"]} The job waits for staff approval.`,
197
+ },
198
+ value: input,
199
+ }),
200
+ execute: (value) => execute(value, { confirmed: true }),
201
+ });
202
+ }
203
+
204
+ async function runStatus({ execute, input }) {
205
+ return {
206
+ data: await execute(input),
207
+ exitCode: 0,
208
+ outcome: { class: "ready", phase: "complete" },
209
+ };
210
+ }
211
+
212
+ async function runDecision({ execute, input, mutation, runtime }) {
213
+ return runAdminMutation({
214
+ ...runtime,
215
+ ...mutation,
216
+ prepare: async () => ({
217
+ review: { title: "Decide node sanitize job", ...decisionReview(input) },
218
+ value: input,
219
+ }),
220
+ execute: (value) => execute(value, { confirmed: true }),
221
+ });
222
+ }
223
+
224
+ export const adminNodeSanitizeHandlers = [
225
+ {
226
+ capabilityId: "node.sanitize.start",
227
+ parse: parseStart,
228
+ run: runStart,
229
+ safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
230
+ },
231
+ {
232
+ capabilityId: "node.sanitize.show",
233
+ parse: parseStatus,
234
+ run: runStatus,
235
+ safety: ADMIN_MUTATION_SAFETY.direct,
236
+ },
237
+ {
238
+ capabilityId: "node.sanitize.update",
239
+ parse: parseDecision,
240
+ run: runDecision,
241
+ safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
242
+ },
243
+ ];