@ornncompute/cli 1.0.25 → 1.0.35

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 (35) hide show
  1. package/README.md +82 -32
  2. package/package.json +1 -1
  3. package/src/admin-capacity-reservations.mjs +68 -43
  4. package/src/admin-enrollment.mjs +14 -13
  5. package/src/admin-node-lifecycle.mjs +10 -13
  6. package/src/admin-shell.mjs +146 -12
  7. package/src/catalog-shell.mjs +58 -45
  8. package/src/device-auth.mjs +1 -1
  9. package/src/evidence-commands.mjs +25 -11
  10. package/src/gpu-catalog.mjs +21 -0
  11. package/src/identity-file.mjs +28 -0
  12. package/src/kubernetes-commands.mjs +12 -6
  13. package/src/machine-reference.mjs +10 -2
  14. package/src/mount-commands.mjs +4 -2
  15. package/src/slurm-commands.mjs +11 -0
  16. package/src/storage-commands.mjs +27 -7
  17. package/src/vm-commands.mjs +42 -7
  18. package/vendor/capabilities/capabilities/admin-capacity-reservations.js +31 -61
  19. package/vendor/capabilities/capabilities/admin-enrollment.js +9 -9
  20. package/vendor/capabilities/capabilities/admin-nodes-lifecycle.js +6 -6
  21. package/vendor/capabilities/capabilities/admin.js +7 -7
  22. package/vendor/capabilities/capabilities/commerce-market.js +330 -0
  23. package/vendor/capabilities/capabilities/fleet.js +57 -21
  24. package/vendor/capabilities/capabilities/listings.js +137 -146
  25. package/vendor/capabilities/capabilities/market-shared.js +44 -0
  26. package/vendor/capabilities/capabilities/markets.js +449 -0
  27. package/vendor/capabilities/capabilities/networks.js +7 -0
  28. package/vendor/capabilities/capabilities/tokens.js +12 -9
  29. package/vendor/capabilities/capability.js +2 -1
  30. package/vendor/capabilities/cli-catalog.js +4 -2
  31. package/vendor/capabilities/cli-migrations.js +16 -43
  32. package/vendor/capabilities/cli.js +1 -1
  33. package/vendor/capabilities/spec-catalog.js +26 -3
  34. package/vendor/capabilities/supplier-directory.js +20 -0
  35. package/vendor/capabilities/capabilities/capacity-offers.js +0 -368
@@ -1,7 +1,9 @@
1
1
  import { adminCommandHandler } from "./admin-command-handlers.mjs";
2
+ import { shellSingleQuote } from "./identity-file.mjs";
2
3
 
3
4
  const DEFAULT_PAGE_LIMIT = 50;
4
5
  const MAX_PAGE_LIMIT = 500;
6
+ const LOG_CURSOR_PREFIX = "ornn-admin-logs-v1.";
5
7
 
6
8
  export class AdminUsageError extends Error {
7
9
  constructor(message, { code = "usage_error", field } = {}) {
@@ -88,7 +90,7 @@ export function resolveAdminRequest(catalog, roots, argv) {
88
90
  );
89
91
  }
90
92
 
91
- if (argv.length === 1) {
93
+ if (argv.length === 1 || (argv.length === 2 && ["--help", "-h"].includes(argv[1]))) {
92
94
  const root = roots.find((candidate) => candidate.name === argv[0]);
93
95
  if (root) return { kind: "root", command: `admin ${root.name}`, root: root.name };
94
96
  }
@@ -168,7 +170,7 @@ function parseAdminInput(capability, argv) {
168
170
  const limit = positiveInteger(options.limit, "limit", MAX_PAGE_LIMIT) ?? DEFAULT_PAGE_LIMIT;
169
171
  const input = {};
170
172
  if (nodeCommand) input.node_id = positionals[0];
171
- if (options.operator) input.operator = options.operator;
173
+ if (options.supplier) input.organization_id = options.supplier;
172
174
  if (options.facility) input.facility_id = options.facility;
173
175
  if (options.status) input.status = options.status;
174
176
  if (options.email) input.email = options.email;
@@ -186,20 +188,100 @@ function parseAdminInput(capability, argv) {
186
188
  const count = positiveInteger(options.count, "count", 500);
187
189
  if (count !== undefined) input.count = count;
188
190
 
191
+ if (command === "admin nodes logs") return parseLogPage(input);
192
+
193
+ const ownerPagination = ["facilities.list", "nodes.list"].includes(capability.id);
194
+ if (ownerPagination) {
195
+ input.limit = limit;
196
+ if (options.cursor) input.cursor = options.cursor;
197
+ }
198
+
189
199
  return {
190
200
  input,
191
201
  pagination: {
192
202
  cursor: options.cursor,
193
- limit: command === "admin nodes logs" ? (count ?? 200) : limit,
203
+ limit,
204
+ ownerPagination,
205
+ },
206
+ };
207
+ }
208
+
209
+ function invalidLogCursor(message) {
210
+ throw new AdminUsageError(message, { code: "invalid_cursor", field: "cursor" });
211
+ }
212
+
213
+ function parseLogPage(input) {
214
+ let offset = 0;
215
+ if (input.cursor?.startsWith(LOG_CURSOR_PREFIX)) {
216
+ let saved;
217
+ try {
218
+ saved = JSON.parse(
219
+ Buffer.from(input.cursor.slice(LOG_CURSOR_PREFIX.length), "base64url").toString("utf8")
220
+ );
221
+ } catch {
222
+ invalidLogCursor("The log cursor is invalid; restart from the first page.");
223
+ }
224
+ if (
225
+ !saved ||
226
+ typeof saved !== "object" ||
227
+ saved.node_id !== input.node_id ||
228
+ !["kernel", "serial"].includes(saved.stream) ||
229
+ typeof saved.before !== "string" ||
230
+ !Number.isFinite(Date.parse(saved.before)) ||
231
+ !Number.isInteger(saved.count) ||
232
+ saved.count < 1 ||
233
+ saved.count > 500 ||
234
+ !Number.isSafeInteger(saved.offset) ||
235
+ saved.offset < 0 ||
236
+ !(
237
+ saved.ownerCursor === null ||
238
+ (typeof saved.ownerCursor === "string" && saved.ownerCursor.length > 0)
239
+ )
240
+ )
241
+ invalidLogCursor("The log cursor is invalid or belongs to a different node.");
242
+ for (const field of ["stream", "before", "count"]) {
243
+ if (input[field] !== undefined && input[field] !== saved[field]) {
244
+ invalidLogCursor(
245
+ `The log cursor has a different ${field}; restart from the first page to change filters.`
246
+ );
247
+ }
248
+ input[field] = saved[field];
249
+ }
250
+ input.cursor = saved.ownerCursor ?? undefined;
251
+ offset = saved.offset;
252
+ } else if (input.cursor && !input.before) {
253
+ invalidLogCursor(
254
+ "A legacy log cursor requires the original --before value; otherwise restart from the first page."
255
+ );
256
+ }
257
+ input.before ??= new Date().toISOString();
258
+ input.stream ??= "kernel";
259
+ input.count ??= 200;
260
+ if (!Number.isFinite(Date.parse(input.before))) {
261
+ throw new AdminUsageError("--before must be a valid timestamp.", { field: "before" });
262
+ }
263
+ return {
264
+ input,
265
+ pagination: {
266
+ limit: input.count,
267
+ logQuery: {
268
+ node_id: input.node_id,
269
+ before: input.before,
270
+ stream: input.stream,
271
+ count: input.count,
272
+ },
273
+ ownerCursor: input.cursor ?? null,
274
+ offset,
194
275
  },
195
276
  };
196
277
  }
197
278
 
198
279
  function collectionKey(capability) {
199
280
  const [, root, leaf] = vnextCommand(capability);
200
- if (root === "enrollment-tokens") return "enrollment_tokens";
201
281
  if (root === "nodes" && leaf === "logs") return "logs";
202
- return ["facilities", "nodes", "operators", "reservations", "users"].includes(root) ? root : null;
282
+ if (!["list", "search"].includes(leaf)) return null;
283
+ if (root === "enrollment-tokens") return "enrollment_tokens";
284
+ return ["facilities", "nodes", "reservations", "suppliers", "users"].includes(root) ? root : null;
203
285
  }
204
286
 
205
287
  function isSensitiveAdminField(key) {
@@ -234,7 +316,16 @@ function sanitizeAdminValue(value) {
234
316
  function collectionRows(payload, key) {
235
317
  if (Array.isArray(payload)) return payload;
236
318
  if (!payload || typeof payload !== "object") return [];
237
- for (const candidate of [key, "items", "results", "data", "entries", "events", "records"]) {
319
+ for (const candidate of [
320
+ key,
321
+ ...(key === "logs" ? ["lines"] : []),
322
+ "items",
323
+ "results",
324
+ "data",
325
+ "entries",
326
+ "events",
327
+ "records",
328
+ ]) {
238
329
  if (Array.isArray(payload[candidate])) return payload[candidate];
239
330
  }
240
331
  return [];
@@ -269,16 +360,44 @@ export function finalizeAdminResult(capability, payload, pagination) {
269
360
  const key = collectionKey(capability);
270
361
  if (!key) return { data: sanitizeAdminValue(payload), page: null };
271
362
  const rows = collectionRows(payload, key);
272
- const offset = decodeCursor(command, pagination.cursor);
273
- const selected = rows.slice(offset, offset + pagination.limit).map(sanitizeAdminValue);
363
+ const offset = pagination.ownerPagination
364
+ ? 0
365
+ : pagination.logQuery
366
+ ? pagination.offset
367
+ : decodeCursor(command, pagination.cursor);
368
+ const selected = (
369
+ pagination.ownerPagination ? rows : rows.slice(offset, offset + pagination.limit)
370
+ ).map(sanitizeAdminValue);
274
371
  const ownerNextCursor =
275
372
  payload && !Array.isArray(payload)
276
373
  ? (payload.next_cursor ?? payload.nextCursor ?? payload.page?.next_cursor ?? null)
277
374
  : null;
278
- const nextCursor =
279
- offset + selected.length < rows.length
375
+ let nextCursor =
376
+ !pagination.ownerPagination && offset + selected.length < rows.length
280
377
  ? encodeCursor(command, offset + selected.length)
281
378
  : (ownerNextCursor ?? null);
379
+ if (pagination.ownerPagination && nextCursor !== null && nextCursor === pagination.cursor) {
380
+ throw new AdminUsageError(
381
+ "The service repeated its pagination cursor; restart from the first page.",
382
+ {
383
+ code: "pagination_not_advancing",
384
+ field: "cursor",
385
+ }
386
+ );
387
+ }
388
+ if (pagination.logQuery && nextCursor !== null) {
389
+ const localPage = offset + selected.length < rows.length;
390
+ nextCursor =
391
+ LOG_CURSOR_PREFIX +
392
+ Buffer.from(
393
+ JSON.stringify({
394
+ ...pagination.logQuery,
395
+ ownerCursor: localPage ? pagination.ownerCursor : ownerNextCursor,
396
+ offset: localPage ? offset + selected.length : 0,
397
+ }),
398
+ "utf8"
399
+ ).toString("base64url");
400
+ }
282
401
  return {
283
402
  data: { [key]: selected },
284
403
  page: {
@@ -304,7 +423,7 @@ export function writeAdminHelpHuman(stdout, capability) {
304
423
  stdout.write(`Usage:\n ${data.human}\n\nAutomation:\n ${data.automation}\n`);
305
424
  }
306
425
 
307
- export function writeAdminResultHuman(stdout, command, result) {
426
+ export function writeAdminResultHuman(stdout, command, result, input = {}) {
308
427
  const key = result.page ? Object.keys(result.data)[0] : null;
309
428
  if (!key) {
310
429
  stdout.write(`${JSON.stringify(result.data, null, 2)}\n`);
@@ -317,6 +436,21 @@ export function writeAdminResultHuman(stdout, command, result) {
317
436
  for (const row of rows) stdout.write(`${JSON.stringify(row)}\n`);
318
437
  }
319
438
  if (result.page.next_cursor) {
320
- stdout.write(`Next: ornn ${command} --cursor ${result.page.next_cursor}\n`);
439
+ const target = command === "admin nodes logs" ? ` ${shellSingleQuote(input.node_id)}` : "";
440
+ const filters = [
441
+ ["supplier", input.organization_id],
442
+ ["facility", input.facility_id],
443
+ ["status", input.status],
444
+ ["email", input.email],
445
+ ["organization", input.organization],
446
+ ["name", input.name],
447
+ ["limit", input.limit],
448
+ ]
449
+ .filter(([, value]) => value !== undefined)
450
+ .map(([name, value]) => ` --${name} ${shellSingleQuote(String(value))}`)
451
+ .join("");
452
+ stdout.write(
453
+ `Next: ornn ${command}${target} --cursor ${shellSingleQuote(result.page.next_cursor)}${filters}\n`
454
+ );
321
455
  }
322
456
  }
@@ -36,7 +36,7 @@ import {
36
36
  executeExternalBucketCommand,
37
37
  writeExternalBucketResultHuman,
38
38
  } from "./external-bucket-commands.mjs";
39
- import { executeEvidenceCommand } from "./evidence-commands.mjs";
39
+ import { executeEvidenceCommand, supplierOwnedRecord } from "./evidence-commands.mjs";
40
40
  import {
41
41
  executeKubernetesClusterCommand,
42
42
  kubernetesConnectModeHandler,
@@ -753,11 +753,8 @@ const CUSTOMER_FORBIDDEN_FIELDS = {
753
753
  clientSecret: true,
754
754
  payment_secret: true,
755
755
  paymentSecret: true,
756
- operator: true,
757
- operatorId: true,
758
- operatorName: true,
759
- operator_id: true,
760
- operator_name: true,
756
+ organizationName: true,
757
+ organization_name: true,
761
758
  internal_owner: true,
762
759
  internalOwner: true,
763
760
  payment_intent_client_secret: true,
@@ -771,12 +768,12 @@ const CUSTOMER_FORBIDDEN_FIELDS = {
771
768
  payment_provider: true,
772
769
  paymentProvider: true,
773
770
  provider: true,
774
- site_operator: true,
775
- siteOperator: true,
776
- site_operator_id: true,
777
- site_operator_name: true,
778
- siteOperatorId: true,
779
- siteOperatorName: true,
771
+ site_supplier: true,
772
+ siteSupplier: true,
773
+ site_supplier_id: true,
774
+ site_supplier_name: true,
775
+ siteSupplierId: true,
776
+ siteSupplierName: true,
780
777
  reconciliation: true,
781
778
  reconciliation_status: true,
782
779
  reconciliationStatus: true,
@@ -789,22 +786,34 @@ const CUSTOMER_FORBIDDEN_FIELDS = {
789
786
  supplier_name: true,
790
787
  supplierId: true,
791
788
  supplierName: true,
789
+ supplier_organization_id: true,
790
+ supplier_organization_name: true,
791
+ supplierOrganizationId: true,
792
+ supplierOrganizationName: true,
792
793
  };
794
+ const SUPPLIER_OWNER_FIELDS = { organizationId: true, organization_id: true };
793
795
 
794
- function assertCustomerResultSafe(value) {
796
+ // The caller's own organization on a listing it relisted is not supplier identity.
797
+ function assertCustomerResultSafe(value, ownOrganizationId) {
795
798
  if (Array.isArray(value)) {
796
- for (const item of value) assertCustomerResultSafe(item);
799
+ for (const item of value) assertCustomerResultSafe(item, ownOrganizationId);
797
800
  return;
798
801
  }
799
802
  if (!value || typeof value !== "object") return;
803
+ const ownedBySupplier = supplierOwnedRecord(value);
800
804
  for (const [key, child] of Object.entries(value)) {
801
- if (CUSTOMER_FORBIDDEN_FIELDS[key] === true) {
805
+ if (
806
+ CUSTOMER_FORBIDDEN_FIELDS[key] === true ||
807
+ (ownedBySupplier &&
808
+ SUPPLIER_OWNER_FIELDS[key] === true &&
809
+ !(ownOrganizationId && child === ownOrganizationId))
810
+ ) {
802
811
  throw new VnextContractError(
803
812
  "The gateway returned an unexpected response shape.",
804
813
  "customer_result_unsafe"
805
814
  );
806
815
  }
807
- assertCustomerResultSafe(child);
816
+ assertCustomerResultSafe(child, ownOrganizationId);
808
817
  }
809
818
  }
810
819
 
@@ -824,6 +833,28 @@ function parseCapabilityInput(capability, rawInput) {
824
833
  });
825
834
  }
826
835
 
836
+ // Commerce's market routes and Markets serve every caller on one path and
837
+ // decide from the session what it may do; a staff session is an admin there.
838
+ const ROLE_AWARE_PREFIXES = ["/commerce/listings", "/commerce/reservations", "/commerce/fees", "/v1/markets"];
839
+
840
+ function isRoleAwarePath(path) {
841
+ const pathname = String(path).split("?")[0];
842
+ return ROLE_AWARE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
843
+ }
844
+
845
+ function adminFetch(io, path, init = {}) {
846
+ const request = {
847
+ endpoint: path,
848
+ env: io.env,
849
+ fetchImpl: io.fetchImpl,
850
+ method: init.method ?? "GET",
851
+ ...(init.body !== undefined ? { body: init.body } : {}),
852
+ ...(init.headers !== undefined ? { headers: init.headers } : {}),
853
+ ...((init.signal ?? io.signal) !== undefined ? { signal: init.signal ?? io.signal } : {}),
854
+ };
855
+ return isRoleAwarePath(path) ? cliRequest(request) : operatorRequest(request);
856
+ }
857
+
827
858
  async function customerCapabilityFetch(io, path, init = {}, sessionSnapshot) {
828
859
  const authRequired = path !== "/v1/cli/status";
829
860
  const session = sessionSnapshot?.current;
@@ -976,24 +1007,13 @@ async function executeCapability(
976
1007
  });
977
1008
  }
978
1009
  if (audience === "admin") {
979
- const search = new URL(path, "https://cli.invalid").search;
1010
+ // Shared observability capabilities use their owner namespace;
1011
+ // the CLI reaches those same node-scoped routes through staff auth.
980
1012
  const endpoint =
981
- capability.id === "telemetry.latest"
982
- ? `/telemetry/latest${search}`
983
- : capability.id === "logs.latest"
984
- ? `/logs/latest${search}`
985
- : path;
986
- return operatorRequest({
987
- endpoint,
988
- env: io.env,
989
- fetchImpl: io.fetchImpl,
990
- method: init.method ?? "GET",
991
- ...(init.body !== undefined ? { body: init.body } : {}),
992
- ...(init.headers !== undefined ? { headers: init.headers } : {}),
993
- ...((init.signal ?? io.signal) !== undefined
994
- ? { signal: init.signal ?? io.signal }
995
- : {}),
996
- });
1013
+ capability.id === "telemetry.latest" || capability.id === "logs.latest"
1014
+ ? path.replace(/^\/v1\/observability\//, "/")
1015
+ : path;
1016
+ return adminFetch(io, endpoint, init);
997
1017
  }
998
1018
  return customerCapabilityFetch(io, path, init, authSnapshot);
999
1019
  },
@@ -1016,7 +1036,9 @@ async function executeCapability(
1016
1036
  } finally {
1017
1037
  interaction?.close();
1018
1038
  }
1019
- if (audience === "customer") assertCustomerResultSafe(data);
1039
+ if (audience === "customer") {
1040
+ assertCustomerResultSafe(data, authSnapshot?.current?.selectedOrganizationId);
1041
+ }
1020
1042
  if (audience === "admin" || sessionCommand || isInteractionExecution(data)) return data;
1021
1043
  return {
1022
1044
  action: null,
@@ -1174,16 +1196,7 @@ export async function runCatalogCli(argv, io, { version }) {
1174
1196
  input,
1175
1197
  role,
1176
1198
  });
1177
- const fetch = (path, init = {}) =>
1178
- operatorRequest({
1179
- endpoint: path,
1180
- env: io.env,
1181
- fetchImpl: io.fetchImpl,
1182
- method: init.method ?? "GET",
1183
- ...(init.body !== undefined ? { body: init.body } : {}),
1184
- ...(init.headers !== undefined ? { headers: init.headers } : {}),
1185
- ...(init.signal !== undefined ? { signal: init.signal } : {}),
1186
- });
1199
+ const fetch = (path, init = {}) => adminFetch({ ...io, signal: undefined }, path, init);
1187
1200
  const execution = adminRequest.handler
1188
1201
  ? await adminRequest.handler.run({
1189
1202
  capability: adminRequest.capability,
@@ -1228,7 +1241,7 @@ export async function runCatalogCli(argv, io, { version }) {
1228
1241
  resume: execution.resume ?? null,
1229
1242
  };
1230
1243
  if (machine) writeMachine(stdout, format, envelope);
1231
- else writeAdminResultHuman(stdout, adminRequest.command, result);
1244
+ else writeAdminResultHuman(stdout, adminRequest.command, result, adminRequest.input);
1232
1245
  return {
1233
1246
  exitCode: execution.exitCode ?? CLI_EXIT_STATUS.success,
1234
1247
  suppressUpdateNotification:
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { configuredAuthBaseUrl } from "./auth-store.mjs";
4
4
  import { readUntilDeadline, waitForDelay } from "./wait-runtime.mjs";
5
5
 
6
- const DEFAULT_AUTH_BASE_URL = "http://localhost:3000";
6
+ const DEFAULT_AUTH_BASE_URL = "https://compute.ornn.com";
7
7
  const DEFAULT_POLL_INTERVAL_SECONDS = 2;
8
8
  const DEFAULT_TIMEOUT_SECONDS = 600;
9
9
 
@@ -1,3 +1,4 @@
1
+ import { gpuLabeler } from "./gpu-catalog.mjs";
1
2
  import { clusterFactLabel, reservationFactsForCluster } from "./kubernetes-commands.mjs";
2
3
  import { listOwnedActiveMachines, resolveMachineReference } from "./machine-reference.mjs";
3
4
  import { machineReachability } from "./vm-commands.mjs";
@@ -21,7 +22,7 @@ const SAFE_METRICS = new Set([
21
22
  ]);
22
23
  const TERMINAL_MACHINE_STATES = new Set(["deleted", "ended", "released", "terminated"]);
23
24
  const UNSAFE_LOG_MESSAGE = [
24
- /\b(?:operator(?:name|id|_name|_id)?|provider(?:name|id|_name|_id)?|site_operator|supplier(?:name|id|_name|_id)?)\s*[:=]/i,
25
+ /\b(?:provider(?:name|id|_name|_id)?|site_supplier|supplier(?:name|id|_name|_id|_organization_id|_organization_name)?|organization_?name)\s*[:=]/i,
25
26
  /\b(?:authorization|api[_ -]?key|access[_ -]?token|refresh[_ -]?token|password|secret|private[_ -]?key)\s*[:=]/i,
26
27
  /-----BEGIN [A-Z ]*PRIVATE KEY-----/i,
27
28
  /\/(?:internal|v1\/(?:orchestrator|observability|cli\/operator))(?:\/|\b)/i,
@@ -30,9 +31,7 @@ const UNSAFE_LOG_MESSAGE = [
30
31
  const UNSAFE_KEYS = new Set([
31
32
  "authorization",
32
33
  "clientsecret",
33
- "operator",
34
- "operatorid",
35
- "operatorname",
34
+ "organizationname",
36
35
  "password",
37
36
  "paymentsecret",
38
37
  "privatekey",
@@ -40,14 +39,16 @@ const UNSAFE_KEYS = new Set([
40
39
  "providerid",
41
40
  "providername",
42
41
  "refreshtoken",
43
- "siteoperator",
44
- "siteoperatorid",
45
- "siteoperatorname",
42
+ "sitesupplier",
43
+ "sitesupplierid",
44
+ "sitesuppliername",
46
45
  "staffactivity",
47
46
  "stripeclientsecret",
48
47
  "supplier",
49
48
  "supplierid",
50
49
  "suppliername",
50
+ "supplierorganizationid",
51
+ "supplierorganizationname",
51
52
  "token",
52
53
  ]);
53
54
  const STABLE_SOURCE_CODES = new Set([
@@ -73,13 +74,25 @@ function normalizedKey(value) {
73
74
  .toLowerCase();
74
75
  }
75
76
 
77
+ // A node, listing, or facility's organization is its supplier; a reservation's
78
+ // or drive's is the buyer, which the caller may see.
79
+ export function supplierOwnedRecord(value) {
80
+ const keys = new Set(Object.keys(record(value)).map(normalizedKey));
81
+ if (keys.has("driveid")) return false;
82
+ if (keys.has("nodeid")) return true;
83
+ if (keys.has("reservationid")) return false;
84
+ return keys.has("listingid") || keys.has("facilityid");
85
+ }
86
+
76
87
  function containsUnsafeFields(value) {
77
88
  if (Array.isArray(value)) return value.some(containsUnsafeFields);
78
89
  if (!value || typeof value !== "object") return false;
90
+ const ownedBySupplier = supplierOwnedRecord(value);
79
91
  return Object.entries(value).some(([key, child]) => {
80
92
  const normalized = normalizedKey(key);
81
93
  return (
82
94
  UNSAFE_KEYS.has(normalized) ||
95
+ (ownedBySupplier && normalized === "organizationid") ||
83
96
  normalized.includes("secret") ||
84
97
  normalized.includes("token") ||
85
98
  normalized.includes("password") ||
@@ -416,7 +429,7 @@ async function clusters(fetch, interaction) {
416
429
  );
417
430
  }
418
431
 
419
- function resourceChoice(resource) {
432
+ function resourceChoice(resource, gpuLabel) {
420
433
  if (resource.kind === "machine") {
421
434
  return {
422
435
  label: `VM/Bare Metal · ${resource.machine.hostname ?? "Unnamed machine"} · ${resource.machine.lifecycle.class}`,
@@ -424,7 +437,7 @@ function resourceChoice(resource) {
424
437
  };
425
438
  }
426
439
  return {
427
- label: clusterFactLabel(resource.cluster, resource.reservation_facts),
440
+ label: clusterFactLabel(resource.cluster, resource.reservation_facts, gpuLabel),
428
441
  value: `cluster:${resource.cluster.id}`,
429
442
  };
430
443
  }
@@ -519,9 +532,10 @@ async function resolveResource(command, input, fetch, interaction) {
519
532
  };
520
533
  })
521
534
  );
522
- const choices = enriched.map(resourceChoice);
535
+ const gpuLabel = await gpuLabeler(fetch);
536
+ const choices = enriched.map((resource) => resourceChoice(resource, gpuLabel));
523
537
  const selected = await interaction.ask({ label: "Resource", choices });
524
- const match = enriched.find((resource) => resourceChoice(resource).value === selected);
538
+ const match = enriched.find((resource) => resourceChoice(resource, gpuLabel).value === selected);
525
539
  if (!match)
526
540
  interaction.usage("Choose one exact machine or cluster.", { code: "resource_ambiguous" });
527
541
  return match;
@@ -0,0 +1,21 @@
1
+ import { gpuDisplay, HARDWARE_CATALOG_PATH, parseHardwareCatalog } from "../vendor/capabilities/cli.js";
2
+
3
+ const catalogs = new WeakMap();
4
+
5
+ /**
6
+ * Renders GPU slugs by their catalog display name, reading the catalog once
7
+ * per fetch. An unreadable catalog renders GPUs as unknown instead of
8
+ * failing the command.
9
+ */
10
+ export async function gpuLabeler(fetch, path = HARDWARE_CATALOG_PATH) {
11
+ let terms = catalogs.get(fetch);
12
+ if (!terms) {
13
+ terms = Promise.resolve()
14
+ .then(() => fetch(path, { method: "GET" }))
15
+ .then((payload) => parseHardwareCatalog(payload).terms)
16
+ .catch(() => []);
17
+ catalogs.set(fetch, terms);
18
+ }
19
+ const resolved = await terms;
20
+ return (slug) => gpuDisplay(resolved, slug);
21
+ }
@@ -0,0 +1,28 @@
1
+ import { stat as statDefault } from "node:fs/promises";
2
+
3
+ /** Quote one argument for a POSIX `sh` copy-paste command. */
4
+ export function shellSingleQuote(value) {
5
+ return `'${String(value).replaceAll("'", "'\"'\"'")}'`;
6
+ }
7
+
8
+ /**
9
+ * OpenSSH refuses a private key that other users can read. Report that before
10
+ * handing the terminal to ssh so the fix is a single chmod. Windows file modes
11
+ * are not POSIX permissions, so the check only runs on POSIX systems; a
12
+ * missing file is left for ssh to report.
13
+ */
14
+ export async function identityFilePermissionProblem(
15
+ path,
16
+ { platform = process.platform, stat = statDefault } = {}
17
+ ) {
18
+ if (!path || platform === "win32") return null;
19
+ let info;
20
+ try {
21
+ info = await stat(path);
22
+ } catch {
23
+ return null;
24
+ }
25
+ if ((info.mode & 0o077) === 0) return null;
26
+ const mode = (info.mode & 0o777).toString(8).padStart(3, "0");
27
+ return `SSH private key ${path} can be read by other users (mode ${mode}). Restrict it with: chmod 600 -- ${shellSingleQuote(path)}`;
28
+ }
@@ -7,7 +7,8 @@ import {
7
7
  } from "./atomic-output.mjs";
8
8
  import { isRecoverableWaitFailure } from "./interaction-runtime.mjs";
9
9
  import { waitForGoal as waitForReadGoal } from "./wait-runtime.mjs";
10
- import { reservationChoiceLabel } from "./machine-reference.mjs";
10
+ import { gpuLabeler } from "./gpu-catalog.mjs";
11
+ import { reservationLabeler } from "./machine-reference.mjs";
11
12
  import { keyInputShapePreflight, keyPlanFromInput, materializeKey } from "./vm-commands.mjs";
12
13
 
13
14
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -822,12 +823,13 @@ async function resolveLaunchReservation(context, runtime, input, accessMode) {
822
823
  if (active.length === 1) {
823
824
  return fetchReservation(context.fetch, runtime, text(record(active[0]).id), accessMode);
824
825
  }
826
+ const reservationLabel = await reservationLabeler(context.fetch);
825
827
  const selected = await runtime.ask({
826
828
  label: "Capacity reservation",
827
829
  choices: active.flatMap((value) => {
828
830
  const reservation = record(value);
829
831
  const id = text(reservation.id);
830
- return id ? [{ label: reservationChoiceLabel(reservation), value: id }] : [];
832
+ return id ? [{ label: reservationLabel(reservation), value: id }] : [];
831
833
  }),
832
834
  });
833
835
  if (!selected)
@@ -1915,9 +1917,10 @@ export async function reservationFactsForCluster(fetch, runtime, cluster) {
1915
1917
  return projectReservationFacts(payload, cluster.reservation_id, runtime);
1916
1918
  }
1917
1919
 
1918
- export function clusterFactLabel(cluster, reservation) {
1920
+ export function clusterFactLabel(cluster, reservation, gpuLabel) {
1919
1921
  const location = reservation.facility ?? reservation.region ?? "location unknown";
1920
- const hardware = reservation.hardware ?? cluster.gpu_type ?? "hardware unknown";
1922
+ const gpu = reservation.hardware ?? cluster.gpu_type;
1923
+ const hardware = gpu ? gpuLabel(gpu) : "hardware unknown";
1921
1924
  const nodeCount =
1922
1925
  integer(cluster.membership_count) ??
1923
1926
  integer(cluster.node_count) ??
@@ -1937,11 +1940,13 @@ export async function clusterModeConnectCandidates(state, accessMode) {
1937
1940
  const available = (await clusters)
1938
1941
  .filter((cluster) => cluster.access_mode === accessMode && cluster.lifecycle.class !== "ended")
1939
1942
  .map((cluster) => requireValidCluster(runtime, cluster));
1943
+ const gpuLabel = await gpuLabeler(context.fetch);
1940
1944
  return Promise.all(
1941
1945
  available.map(async (cluster) => ({
1942
1946
  label: clusterFactLabel(
1943
1947
  cluster,
1944
- await reservationFactsForCluster(context.fetch, runtime, cluster)
1948
+ await reservationFactsForCluster(context.fetch, runtime, cluster),
1949
+ gpuLabel
1945
1950
  ),
1946
1951
  reference: cluster.id,
1947
1952
  }))
@@ -1984,10 +1989,11 @@ export async function resolveClusterModeConnectTarget(state, accessMode) {
1984
1989
  selected = available[0];
1985
1990
  } else {
1986
1991
  const choices = [];
1992
+ const gpuLabel = await gpuLabeler(context.fetch);
1987
1993
  for (const cluster of available) {
1988
1994
  const reservation = await reservationFactsForCluster(context.fetch, runtime, cluster);
1989
1995
  choices.push({
1990
- label: clusterFactLabel(cluster, reservation),
1996
+ label: clusterFactLabel(cluster, reservation, gpuLabel),
1991
1997
  value: cluster.id,
1992
1998
  });
1993
1999
  }
@@ -1,3 +1,5 @@
1
+ import { gpuLabeler } from "./gpu-catalog.mjs";
2
+
1
3
  const CANONICAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2
4
  const PAGE_SIZE = 500;
3
5
  const MAX_RESERVATION_PAGES = 20;
@@ -59,9 +61,10 @@ function reservationModeLabel(value) {
59
61
  }
60
62
  }
61
63
 
62
- export function reservationChoiceLabel(value) {
64
+ export function reservationChoiceLabel(value, gpuLabel) {
63
65
  const reservation = record(value);
64
- const hardware = text(reservation.gpu_type) ?? text(reservation.hardware) ?? "GPU capacity";
66
+ const gpu = text(reservation.gpu_type);
67
+ const hardware = gpu ? gpuLabel(gpu) : (text(reservation.hardware) ?? "GPU capacity");
65
68
  const quantity = Number(reservation.node_count ?? reservation.machine_count ?? 1);
66
69
  const machines = Number.isInteger(quantity) && quantity > 0 ? quantity : 1;
67
70
  const location =
@@ -83,6 +86,11 @@ export function reservationChoiceLabel(value) {
83
86
  .join(" · ");
84
87
  }
85
88
 
89
+ export async function reservationLabeler(fetch) {
90
+ const gpuLabel = await gpuLabeler(fetch);
91
+ return (reservation) => reservationChoiceLabel(reservation, gpuLabel);
92
+ }
93
+
86
94
  function isActiveDirectReservation(value) {
87
95
  const source = record(value);
88
96
  const status = (text(source.status) ?? text(source.state) ?? "").toLowerCase();