@ornncompute/cli 0.2.23 → 1.0.25

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 (85) hide show
  1. package/README.md +304 -420
  2. package/package.json +12 -6
  3. package/src/admin-capacity-reservations.mjs +280 -0
  4. package/src/admin-command-handlers.mjs +57 -0
  5. package/src/admin-enrollment.mjs +680 -0
  6. package/src/admin-mutations.mjs +187 -0
  7. package/src/admin-node-diagnostics.mjs +412 -0
  8. package/src/admin-node-lifecycle.mjs +908 -0
  9. package/src/admin-shell.mjs +322 -0
  10. package/src/api-client.mjs +81 -28
  11. package/src/atomic-output.mjs +112 -0
  12. package/src/auth-store.mjs +182 -11
  13. package/src/capability-catalog.mjs +7 -0
  14. package/src/catalog-shell.mjs +1350 -0
  15. package/src/cli.mjs +123 -10266
  16. package/src/connect-mode-handlers.mjs +170 -0
  17. package/src/device-auth.mjs +178 -61
  18. package/src/evidence-commands.mjs +1340 -0
  19. package/src/external-bucket-commands.mjs +1374 -0
  20. package/src/interaction-runtime.mjs +310 -0
  21. package/src/kubernetes-commands.mjs +2229 -0
  22. package/src/launch-mode-handlers.mjs +119 -0
  23. package/src/machine-reference.mjs +277 -0
  24. package/src/mount-commands.mjs +1146 -0
  25. package/src/session-commands.mjs +565 -0
  26. package/src/slurm-commands.mjs +263 -0
  27. package/src/startup.mjs +34 -11
  28. package/src/storage-commands.mjs +1144 -0
  29. package/src/update.mjs +172 -24
  30. package/src/vm-commands.mjs +4273 -0
  31. package/src/vpn-secret-output.mjs +150 -0
  32. package/src/wait-runtime.mjs +231 -0
  33. package/vendor/capabilities/capabilities/admin-capacity-reservations.js +126 -0
  34. package/vendor/capabilities/capabilities/admin-enrollment.js +321 -0
  35. package/vendor/capabilities/capabilities/admin-node-diagnostics.js +88 -0
  36. package/vendor/capabilities/capabilities/admin-nodes-lifecycle.js +235 -0
  37. package/vendor/capabilities/capabilities/admin.js +89 -0
  38. package/vendor/capabilities/capabilities/capacity-offers.js +368 -0
  39. package/vendor/capabilities/capabilities/cli.js +156 -0
  40. package/vendor/capabilities/capabilities/clusters.js +150 -1
  41. package/vendor/capabilities/capabilities/compute.js +522 -0
  42. package/vendor/capabilities/capabilities/evidence.js +145 -0
  43. package/vendor/capabilities/capabilities/external-buckets.js +191 -0
  44. package/vendor/capabilities/capabilities/fleet.js +21 -5
  45. package/vendor/capabilities/capabilities/identity.js +114 -3
  46. package/vendor/capabilities/capabilities/listings.js +14 -8
  47. package/vendor/capabilities/capabilities/mounts.js +176 -0
  48. package/vendor/capabilities/capabilities/networks.js +1257 -0
  49. package/vendor/capabilities/capabilities/observability.js +38 -8
  50. package/vendor/capabilities/capabilities/tokens.js +20 -8
  51. package/vendor/capabilities/capabilities/volumes.js +178 -0
  52. package/vendor/capabilities/capability.js +19 -13
  53. package/vendor/capabilities/cli-catalog.js +65 -0
  54. package/vendor/capabilities/cli-migrations.js +288 -0
  55. package/vendor/capabilities/cli.js +7 -0
  56. package/vendor/capabilities/software-catalog.js +36 -0
  57. package/vendor/capabilities/spec-catalog.js +2 -2
  58. package/src/catalog-dispatch.mjs +0 -605
  59. package/src/color.mjs +0 -71
  60. package/vendor/capabilities/capabilities/clusters.d.ts +0 -2
  61. package/vendor/capabilities/capabilities/fleet.d.ts +0 -8
  62. package/vendor/capabilities/capabilities/identity.d.ts +0 -3
  63. package/vendor/capabilities/capabilities/listings.d.ts +0 -51
  64. package/vendor/capabilities/capabilities/observability.d.ts +0 -5
  65. package/vendor/capabilities/capabilities/staff-access.d.ts +0 -29
  66. package/vendor/capabilities/capabilities/staff-access.js +0 -278
  67. package/vendor/capabilities/capabilities/staff-commerce.d.ts +0 -160
  68. package/vendor/capabilities/capabilities/staff-commerce.js +0 -689
  69. package/vendor/capabilities/capabilities/staff-fleet.d.ts +0 -140
  70. package/vendor/capabilities/capabilities/staff-fleet.js +0 -538
  71. package/vendor/capabilities/capabilities/staff-gtm.d.ts +0 -142
  72. package/vendor/capabilities/capabilities/staff-gtm.js +0 -521
  73. package/vendor/capabilities/capabilities/staff-shared.d.ts +0 -47
  74. package/vendor/capabilities/capabilities/staff-shared.js +0 -98
  75. package/vendor/capabilities/capabilities/tokens.d.ts +0 -10
  76. package/vendor/capabilities/capabilities/users.d.ts +0 -2
  77. package/vendor/capabilities/capability.d.ts +0 -93
  78. package/vendor/capabilities/catalog.d.ts +0 -3
  79. package/vendor/capabilities/catalog.js +0 -26
  80. package/vendor/capabilities/index.d.ts +0 -16
  81. package/vendor/capabilities/index.js +0 -16
  82. package/vendor/capabilities/names.d.ts +0 -6
  83. package/vendor/capabilities/role.d.ts +0 -6
  84. package/vendor/capabilities/role.js +0 -17
  85. package/vendor/capabilities/spec-catalog.d.ts +0 -78
@@ -0,0 +1,187 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ export const ADMIN_MUTATION_SAFETY = Object.freeze({
4
+ eligibleHumanOnly: "eligible-human-only",
5
+ reconcile: "reconcile",
6
+ });
7
+
8
+ export class AdminMutationError extends Error {
9
+ constructor(message, { code = "admin_mutation_invalid", field } = {}) {
10
+ super(message);
11
+ this.name = "AdminMutationError";
12
+ this.code = code;
13
+ this.field = field;
14
+ }
15
+ }
16
+
17
+ function normalizedReviewValue(value) {
18
+ const rendered =
19
+ value === null || value === undefined
20
+ ? "—"
21
+ : typeof value === "object"
22
+ ? JSON.stringify(value)
23
+ : String(value);
24
+ return rendered.replaceAll(/[\r\n\t]+/g, " ").trim();
25
+ }
26
+
27
+ export function writeAdminMutationReview(stdout, review) {
28
+ if (!review?.title || !Array.isArray(review.fields) || review.fields.length === 0) {
29
+ throw new AdminMutationError("Admin mutation review is incomplete.", {
30
+ code: "admin_review_incomplete",
31
+ });
32
+ }
33
+ stdout.write(`Review: ${normalizedReviewValue(review.title)}\n`);
34
+ for (const field of review.fields) {
35
+ if (!field?.label) {
36
+ throw new AdminMutationError("Admin mutation review is incomplete.", {
37
+ code: "admin_review_incomplete",
38
+ });
39
+ }
40
+ stdout.write(
41
+ ` ${normalizedReviewValue(field.label)}: ${normalizedReviewValue(field.value)}\n`
42
+ );
43
+ }
44
+ if (review.consequence) {
45
+ stdout.write(` Consequence: ${normalizedReviewValue(review.consequence)}\n`);
46
+ }
47
+ }
48
+
49
+ async function defaultNoConsent(stdin, stdout) {
50
+ const readline = createInterface({ input: stdin, output: stdout, terminal: false });
51
+ try {
52
+ const answer = await readline.question("Proceed? [y/N] ");
53
+ return /^(y|yes)$/i.test(answer.trim());
54
+ } catch (error) {
55
+ if (
56
+ error?.code === "ERR_USE_AFTER_CLOSE" ||
57
+ error?.code === "ABORT_ERR" ||
58
+ error?.name === "AbortError"
59
+ ) {
60
+ return false;
61
+ }
62
+ throw error;
63
+ } finally {
64
+ readline.close();
65
+ }
66
+ }
67
+
68
+ export function assertAdminMutationContext({
69
+ eligibleHuman,
70
+ idempotencyKey,
71
+ machine,
72
+ safety,
73
+ yes,
74
+ }) {
75
+ if (safety === ADMIN_MUTATION_SAFETY.eligibleHumanOnly) {
76
+ if (!eligibleHuman || machine) {
77
+ throw new AdminMutationError(
78
+ "This admin mutation is supported only in an eligible human terminal and cannot run with redirected or machine output.",
79
+ { code: "unsupported_in_machine_mode" }
80
+ );
81
+ }
82
+ return;
83
+ }
84
+ if (safety !== ADMIN_MUTATION_SAFETY.reconcile) {
85
+ throw new AdminMutationError("Admin mutation safety policy is missing.", {
86
+ code: "admin_mutation_policy_missing",
87
+ });
88
+ }
89
+ if (!eligibleHuman && yes !== true) {
90
+ throw new AdminMutationError("Noninteractive admin mutations require --yes.", {
91
+ code: "consent_required",
92
+ field: "yes",
93
+ });
94
+ }
95
+ if (!eligibleHuman && !idempotencyKey?.trim()) {
96
+ throw new AdminMutationError(
97
+ "Noninteractive admin mutations require --idempotency-key with a caller-stable value.",
98
+ { code: "idempotency_key_required", field: "idempotency-key" }
99
+ );
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Shared privileged-mutation gate. All noninteractive prerequisites are checked
105
+ * before prepare/reconcile/execute can perform I/O. Domain handlers own semantic
106
+ * validation and exact goal comparison; this function owns context safety,
107
+ * normalized review, default-no consent, and the apply/satisfied/conflict flow.
108
+ */
109
+ export async function runAdminMutation({
110
+ eligibleHuman,
111
+ execute,
112
+ idempotencyKey,
113
+ machine,
114
+ prepare,
115
+ reconcile,
116
+ safety,
117
+ stdin,
118
+ stdout,
119
+ yes = false,
120
+ }) {
121
+ assertAdminMutationContext({ eligibleHuman, idempotencyKey, machine, safety, yes });
122
+ if (typeof prepare !== "function" || typeof execute !== "function") {
123
+ throw new AdminMutationError("Admin mutation handler is incomplete.", {
124
+ code: "admin_mutation_handler_incomplete",
125
+ });
126
+ }
127
+ if (safety === ADMIN_MUTATION_SAFETY.reconcile && typeof reconcile !== "function") {
128
+ throw new AdminMutationError("Reconciled admin mutation is missing an exact goal read.", {
129
+ code: "admin_reconciliation_missing",
130
+ });
131
+ }
132
+
133
+ let prepared = await prepare();
134
+ if (!prepared || typeof prepared !== "object") {
135
+ throw new AdminMutationError("Admin mutation preparation returned no review.", {
136
+ code: "admin_review_incomplete",
137
+ });
138
+ }
139
+
140
+ if (safety === ADMIN_MUTATION_SAFETY.reconcile) {
141
+ const reconciliation = await reconcile(prepared.value);
142
+ if (reconciliation?.status === "satisfied") {
143
+ return {
144
+ data: reconciliation.data,
145
+ exitCode: 0,
146
+ outcome: { class: "ready", phase: "reconciled" },
147
+ reconciled: true,
148
+ status: "complete",
149
+ };
150
+ }
151
+ if (reconciliation?.status === "conflict") {
152
+ throw new AdminMutationError(
153
+ reconciliation.message ?? "Current owner state conflicts with the requested admin goal.",
154
+ { code: reconciliation.code ?? "admin_goal_conflict" }
155
+ );
156
+ }
157
+ if (reconciliation?.status !== "apply") {
158
+ throw new AdminMutationError("Admin mutation reconciliation returned an invalid state.", {
159
+ code: "admin_reconciliation_invalid",
160
+ });
161
+ }
162
+ prepared = {
163
+ ...prepared,
164
+ ...(reconciliation.review ? { review: reconciliation.review } : {}),
165
+ ...(reconciliation.value !== undefined ? { value: reconciliation.value } : {}),
166
+ };
167
+ }
168
+
169
+ if (!machine) writeAdminMutationReview(stdout, prepared.review);
170
+ if (eligibleHuman && yes !== true && !(await defaultNoConsent(stdin, stdout))) {
171
+ return {
172
+ data: { submitted: false },
173
+ exitCode: 1,
174
+ outcome: { class: "ended", phase: "declined" },
175
+ reconciled: false,
176
+ status: "declined",
177
+ };
178
+ }
179
+
180
+ return {
181
+ data: await execute(prepared.value),
182
+ exitCode: 0,
183
+ outcome: { class: "ready", phase: "complete" },
184
+ reconciled: false,
185
+ status: "complete",
186
+ };
187
+ }
@@ -0,0 +1,412 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { ADMIN_MUTATION_SAFETY, AdminMutationError, runAdminMutation } from "./admin-mutations.mjs";
4
+
5
+ const DIAGNOSE_USAGE = "Usage: ornn admin nodes diagnose <node-id>";
6
+ const EXEC_USAGE = "Usage: ornn admin nodes exec <node-id> --command COMMAND --timeout DURATION";
7
+ const OUTPUT_TRUNCATION_MARKER = "\n[ornn] output truncated";
8
+
9
+ function usageError(message, field) {
10
+ return new AdminMutationError(message, {
11
+ code: "usage_error",
12
+ ...(field ? { field } : {}),
13
+ });
14
+ }
15
+
16
+ function optionValue(argv, index, name, usage) {
17
+ const argument = argv[index];
18
+ const separator = argument.indexOf("=");
19
+ if (separator !== -1) return { consumed: 0, value: argument.slice(separator + 1) };
20
+ if (index + 1 >= argv.length) throw usageError(`${usage}\n--${name} requires a value.`, name);
21
+ return { consumed: 1, value: argv[index + 1] };
22
+ }
23
+
24
+ function parseOptions(argv, allowed, usage) {
25
+ const options = {};
26
+ const positionals = [];
27
+ for (let index = 0; index < argv.length; index += 1) {
28
+ const argument = argv[index];
29
+ if (!argument.startsWith("--")) {
30
+ positionals.push(argument);
31
+ continue;
32
+ }
33
+ const separator = argument.indexOf("=");
34
+ const name = argument.slice(2, separator === -1 ? undefined : separator);
35
+ if (!allowed.has(name)) throw usageError(`${usage}\nUnknown option: --${name}`, name);
36
+ if (options[name] !== undefined) {
37
+ throw usageError(`${usage}\n--${name} may be supplied only once.`, name);
38
+ }
39
+ if (name === "yes") {
40
+ if (separator !== -1) throw usageError(`${usage}\n--yes does not take a value.`, "yes");
41
+ options.yes = true;
42
+ continue;
43
+ }
44
+ const parsed = optionValue(argv, index, name, usage);
45
+ options[name] = parsed.value;
46
+ index += parsed.consumed;
47
+ }
48
+ return { options, positionals };
49
+ }
50
+
51
+ function timeoutSeconds(value) {
52
+ if (typeof value !== "string")
53
+ throw usageError(`${EXEC_USAGE}\n--timeout is required.`, "timeout");
54
+ const match = value.match(/^([1-9]\d*)(s|m)?$/);
55
+ if (!match) {
56
+ throw usageError("--timeout must be whole seconds or minutes from 1s through 15m.", "timeout");
57
+ }
58
+ const amount = Number(match[1]);
59
+ const seconds = amount * (match[2] === "m" ? 60 : 1);
60
+ if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 900) {
61
+ throw usageError("--timeout must be whole seconds or minutes from 1s through 15m.", "timeout");
62
+ }
63
+ return seconds;
64
+ }
65
+
66
+ export function parseAdminNodeDiagnoseArgs(argv) {
67
+ const { options, positionals } = parseOptions(
68
+ argv,
69
+ new Set(["yes", "idempotency-key"]),
70
+ DIAGNOSE_USAGE
71
+ );
72
+ if (positionals.length !== 1 || !positionals[0]) throw usageError(DIAGNOSE_USAGE, "node-id");
73
+ return {
74
+ input: { node_id: positionals[0] },
75
+ mutation: {
76
+ idempotencyKey: options["idempotency-key"],
77
+ safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
78
+ yes: options.yes === true,
79
+ },
80
+ };
81
+ }
82
+
83
+ export function parseAdminNodeExecArgs(argv) {
84
+ const { options, positionals } = parseOptions(
85
+ argv,
86
+ new Set(["command", "timeout", "yes", "idempotency-key"]),
87
+ EXEC_USAGE
88
+ );
89
+ if (positionals.length !== 1 || !positionals[0]) throw usageError(EXEC_USAGE, "node-id");
90
+ if (typeof options.command !== "string" || options.command.length === 0) {
91
+ throw usageError(`${EXEC_USAGE}\n--command is required and must not be empty.`, "command");
92
+ }
93
+ return {
94
+ input: {
95
+ node_id: positionals[0],
96
+ command: options.command,
97
+ timeout_seconds: timeoutSeconds(options.timeout),
98
+ },
99
+ mutation: {
100
+ idempotencyKey: options["idempotency-key"],
101
+ safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
102
+ yes: options.yes === true,
103
+ },
104
+ };
105
+ }
106
+
107
+ function presentString(value) {
108
+ return typeof value === "string" && value.length ? value : null;
109
+ }
110
+
111
+ function nodeContext(payload, expectedNodeId) {
112
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
113
+ throw new AdminMutationError("The node lookup returned an invalid response.", {
114
+ code: "node_context_invalid",
115
+ });
116
+ }
117
+ const actualNodeId = presentString(payload.id) ?? presentString(payload.node_id);
118
+ if (actualNodeId !== expectedNodeId) {
119
+ throw new AdminMutationError("The node lookup did not return the exact requested node.", {
120
+ code: "exact_node_mismatch",
121
+ field: "node-id",
122
+ });
123
+ }
124
+ const explicitReachability =
125
+ presentString(payload.reachability?.status) ??
126
+ presentString(payload.off_grid_reachability?.status) ??
127
+ (typeof payload.reachable === "boolean"
128
+ ? payload.reachable
129
+ ? "reachable"
130
+ : "unreachable"
131
+ : null);
132
+ return {
133
+ node_id: actualNodeId,
134
+ hostname: presentString(payload.hostname),
135
+ lifecycle:
136
+ presentString(payload.lifecycle?.phase) ??
137
+ presentString(payload.lifecycle) ??
138
+ presentString(payload.status) ??
139
+ "unknown",
140
+ reachability: explicitReachability ?? "unknown",
141
+ observed_at:
142
+ presentString(payload.observed_at) ??
143
+ presentString(payload.last_heartbeat_at) ??
144
+ presentString(payload.off_grid_reachability?.last_checked_at),
145
+ };
146
+ }
147
+
148
+ function reviewFields(target) {
149
+ return [
150
+ { label: "Node", value: target.node_id },
151
+ { label: "Hostname", value: target.hostname ?? "unknown" },
152
+ { label: "Lifecycle", value: target.lifecycle },
153
+ { label: "Reachability", value: target.reachability },
154
+ ];
155
+ }
156
+
157
+ async function prepareTarget(input, runtime, fetch) {
158
+ const payload = await fetch(`/internal/nodes/${encodeURIComponent(input.node_id)}`, {
159
+ method: "GET",
160
+ ...(runtime.signal ? { signal: runtime.signal } : {}),
161
+ });
162
+ return nodeContext(payload, input.node_id);
163
+ }
164
+
165
+ function requestCorrelation(input) {
166
+ return {
167
+ command_sha256: createHash("sha256").update(input.command, "utf8").digest("hex"),
168
+ command_utf8_bytes: Buffer.byteLength(input.command, "utf8"),
169
+ timeout_seconds: input.timeout_seconds,
170
+ };
171
+ }
172
+
173
+ function diagnosisResult(raw, target) {
174
+ const jobId = presentString(raw?.job_id) ?? presentString(raw?.id);
175
+ return {
176
+ data: {
177
+ node: target,
178
+ diagnosis: {
179
+ job_id: jobId,
180
+ status: presentString(raw?.status) ?? "queued",
181
+ },
182
+ },
183
+ exitCode: 0,
184
+ outcome: { class: "working", phase: "diagnosis_queued" },
185
+ action: null,
186
+ resume: null,
187
+ errors: [],
188
+ };
189
+ }
190
+
191
+ function outputValue(value) {
192
+ return typeof value === "string" ? value : "";
193
+ }
194
+
195
+ function executionResult(raw, input, target) {
196
+ const status = presentString(raw?.status) ?? "failed";
197
+ const stdout = outputValue(raw?.stdout);
198
+ const stderr = outputValue(raw?.stderr);
199
+ const timedOut = raw?.timed_out === true || status === "timed_out";
200
+ const outputTruncated =
201
+ stdout.endsWith(OUTPUT_TRUNCATION_MARKER) || stderr.endsWith(OUTPUT_TRUNCATION_MARKER);
202
+ const partialOutput = timedOut && (stdout.length > 0 || stderr.length > 0);
203
+ const data = {
204
+ node: target,
205
+ request: requestCorrelation(input),
206
+ result: {
207
+ status: timedOut ? "timed_out" : status,
208
+ exit_code: Number.isInteger(raw?.exit_code) ? raw.exit_code : null,
209
+ signal: presentString(raw?.signal),
210
+ stdout,
211
+ stderr,
212
+ partial_output: partialOutput,
213
+ output_truncated: outputTruncated,
214
+ },
215
+ };
216
+
217
+ if (timedOut) {
218
+ return {
219
+ data,
220
+ exitCode: 124,
221
+ outcome: { class: partialOutput ? "partial" : "failed", phase: "command_timeout" },
222
+ action: null,
223
+ resume: null,
224
+ errors: [
225
+ {
226
+ code: "remote_command_timeout",
227
+ message: "The remote command reached its explicit timeout.",
228
+ retryable: false,
229
+ },
230
+ ],
231
+ };
232
+ }
233
+ if (status === "partial") {
234
+ return {
235
+ data,
236
+ exitCode: 4,
237
+ outcome: { class: "partial", phase: "partial_output" },
238
+ action: null,
239
+ resume: null,
240
+ errors: [
241
+ {
242
+ code: "remote_output_partial",
243
+ message: "The remote command returned only partial output.",
244
+ retryable: false,
245
+ },
246
+ ],
247
+ };
248
+ }
249
+ if (status !== "succeeded" || raw?.exit_code !== 0) {
250
+ return {
251
+ data,
252
+ exitCode: 1,
253
+ outcome: { class: "failed", phase: "command_exit" },
254
+ action: null,
255
+ resume: null,
256
+ errors: [
257
+ {
258
+ code: "remote_command_failed",
259
+ message: "The remote command exited without success.",
260
+ retryable: false,
261
+ },
262
+ ],
263
+ };
264
+ }
265
+ return {
266
+ data,
267
+ exitCode: 0,
268
+ outcome: { class: "ready", phase: "command_complete" },
269
+ action: null,
270
+ resume: null,
271
+ errors: [],
272
+ };
273
+ }
274
+
275
+ function declinedResult(input) {
276
+ return {
277
+ data: {
278
+ declined: true,
279
+ node: { node_id: input.node_id },
280
+ ...(input.command ? { request: requestCorrelation(input) } : {}),
281
+ },
282
+ exitCode: 1,
283
+ outcome: { class: "failed", phase: "consent_declined" },
284
+ action: null,
285
+ resume: null,
286
+ errors: [
287
+ {
288
+ code: "consent_declined",
289
+ message: "The action was declined; no mutation was submitted.",
290
+ retryable: false,
291
+ },
292
+ ],
293
+ };
294
+ }
295
+
296
+ function interruptedResult(input) {
297
+ return {
298
+ data: {
299
+ node: { node_id: input.node_id },
300
+ ...(input.command ? { request: requestCorrelation(input) } : {}),
301
+ result: { status: "interrupted", remote_outcome: "unknown" },
302
+ },
303
+ exitCode: 130,
304
+ outcome: { class: "partial", phase: "interrupted" },
305
+ action: null,
306
+ resume: null,
307
+ errors: [
308
+ {
309
+ code: "local_interruption",
310
+ message: "Local execution was interrupted; the remote outcome is unknown.",
311
+ retryable: false,
312
+ },
313
+ ],
314
+ };
315
+ }
316
+
317
+ function wasInterrupted(error, runtime) {
318
+ return (
319
+ runtime.signal?.aborted === true || error?.name === "AbortError" || error?.code === "ABORT_ERR"
320
+ );
321
+ }
322
+
323
+ async function runDiagnosis({ input, mutation, runtime, execute, fetch }) {
324
+ let target;
325
+ try {
326
+ const result = await runAdminMutation({
327
+ ...runtime,
328
+ ...mutation,
329
+ stdout: runtime.stderr,
330
+ prepare: async () => {
331
+ target = await prepareTarget(input, runtime, fetch);
332
+ return {
333
+ review: {
334
+ title: "Queue node diagnosis",
335
+ fields: [
336
+ ...reviewFields(target),
337
+ { label: "Workflow", value: "CLI diagnosis with zero burn time" },
338
+ ],
339
+ consequence: "A staff-authorized diagnosis job will be queued for this exact node.",
340
+ },
341
+ value: target,
342
+ };
343
+ },
344
+ execute: async () => execute({ node_id: input.node_id }, { confirmed: true }),
345
+ });
346
+ if (result.status === "declined") return declinedResult(input);
347
+ return diagnosisResult(result.data, target);
348
+ } catch (error) {
349
+ if (wasInterrupted(error, runtime)) return interruptedResult(input);
350
+ throw error;
351
+ }
352
+ }
353
+
354
+ async function runExecution({ input, mutation, runtime, execute, fetch }) {
355
+ let target;
356
+ try {
357
+ const result = await runAdminMutation({
358
+ ...runtime,
359
+ ...mutation,
360
+ stdout: runtime.stderr,
361
+ prepare: async () => {
362
+ target = await prepareTarget(input, runtime, fetch);
363
+ return {
364
+ review: {
365
+ title: "Execute unrestricted remote command",
366
+ fields: [
367
+ ...reviewFields(target),
368
+ { label: "Exact command", value: JSON.stringify(input.command) },
369
+ {
370
+ label: "Command UTF-8 bytes",
371
+ value: String(Buffer.byteLength(input.command, "utf8")),
372
+ },
373
+ { label: "Timeout", value: `${input.timeout_seconds}s` },
374
+ ],
375
+ consequence:
376
+ "This exact command will run with administrative access on the selected node.",
377
+ },
378
+ value: target,
379
+ };
380
+ },
381
+ execute: async () =>
382
+ execute(
383
+ {
384
+ node_id: input.node_id,
385
+ command: input.command,
386
+ timeout_seconds: input.timeout_seconds,
387
+ },
388
+ { confirmed: true }
389
+ ),
390
+ });
391
+ if (result.status === "declined") return declinedResult(input);
392
+ return executionResult(result.data, input, target);
393
+ } catch (error) {
394
+ if (wasInterrupted(error, runtime)) return interruptedResult(input);
395
+ throw error;
396
+ }
397
+ }
398
+
399
+ export const adminNodeDiagnosticsHandlers = [
400
+ {
401
+ capabilityId: "node.diagnose",
402
+ safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
403
+ parse: (argv) => parseAdminNodeDiagnoseArgs(argv),
404
+ run: runDiagnosis,
405
+ },
406
+ {
407
+ capabilityId: "node.exec",
408
+ safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
409
+ parse: (argv) => parseAdminNodeExecArgs(argv),
410
+ run: runExecution,
411
+ },
412
+ ];