@ornncompute/cli 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -17,9 +17,10 @@ curl -fsSL https://compute.ornn.com/cli/install | sh
17
17
 
18
18
  The installer requires Node.js 20 or newer and npm. It installs `@ornncompute/cli`
19
19
  globally, exposes the `ornn` binary, adds the npm global bin directory to the
20
- user's shell profile when needed, and writes
21
- `ORNN_AUTH_BASE_URL` for the Ornn host that served the installer. The
22
- installer prints a short Ornn Compute welcome banner and login prompt; set
20
+ user's shell profile when needed, and records the Ornn host that served it as
21
+ `authBaseUrl` in `~/.config/ornn/config.json`, so `ornn login` reaches the right
22
+ host from the shell that ran the installer. The
23
+ installer prints a short Ornn Compute welcome banner and next steps; set
23
24
  `ORNN_INSTALL_BANNER=0` to skip it or `ORNN_INSTALL_ANIMATION=0` to keep it
24
25
  static.
25
26
 
@@ -35,28 +36,31 @@ ornn listings list [--gpu-type <type>] [--facility <name>] [--operator <name>] [
35
36
  ornn listings show <listing-id> [--open] [--json]
36
37
  ornn buy <listing-id> [--no-open] [--json]
37
38
  ornn exchange create <listing-id> --gpu-count <n> --min-gpu-count <n> --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd> [--no-open] [--json]
38
- ornn exchange list [--json]
39
+ ornn exchange list [--limit <1-500>] [--cursor <last-id>] [--json]
39
40
  ornn exchange show <bid-id> [--open] [--json]
40
41
  ornn exchange update <bid-id> --gpu-count <n> --min-gpu-count <n> --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd>
41
42
  ornn exchange withdraw <bid-id>
42
- ornn gpus list [--status <status>] [--json]
43
+ ornn gpus list [--status <status>] [--limit <1-500>] [--cursor <last-id>] [--json]
43
44
  ornn gpus show <reservation-id> [--open] [--json]
44
45
  ornn gpus checkout <reservation-id> [--no-open] [--json]
45
46
  ornn nodes list [--json]
46
47
  ornn nodes show <node-id> [--json]
47
48
  ornn nodes launch <reservation-id> --key <path|id|label> [--mode bare-metal|vm] [--username <name>] [--network public|private] [--storage-load-drive-id <id>] [--storage-save-drive-id <id>] [--wait] [--json]
49
+ ornn nodes switch <reservation-id> --network public|private --key <path|id|label> [--mode bare-metal|vm] [--username <name>] [--wait] [--json]
48
50
  ornn nodes wait <node-or-reservation-id> [--timeout <seconds>] [--json]
49
- ornn nodes start <node-id> [--json]
50
- ornn nodes stop <node-id> [--json]
51
- ornn nodes teardown <node-id> [--json]
52
- ornn nodes revoke <node-id> [--json]
51
+ ornn nodes reboot <node-id> [--json]
52
+ ornn nodes hard-reset <node-id> [--json]
53
53
  ornn nodes ssh-command <node-or-reservation-id> [--json]
54
54
  ornn nodes keys attach <node-id> --key <path|id|label> [--json]
55
55
  ornn nodes keys list <node-id> [--json]
56
56
  ornn node health <node-id> [--json]
57
57
  ornn node diagnose <node-id> [--json]
58
- ornn node drain <node-id> [--reason <text>] [--json]
59
- ornn node restore <node-id> [--reason <text>] [--json]
58
+ ornn node deenroll <node-id> [--reason <text>] [--keep-record] [--json]
59
+ ornn fleet clean <ip>... --operator <id-or-slug> --ib-island <name> --identity-file <path> --dry-run [--source-user <user>]... [--preserve-user <user>]... [--ssh-user ubuntu|admin|ornn] [--json]
60
+ ornn fleet clean <failed-fleet-id> --identity-file <path> --dry-run [--source-user <user>]... [--preserve-user <user>]... [--json]
61
+ ornn fleet clean <fleet-id> --identity-file <path> --confirm-clean <plan-hash> [--source-user <user>]... [--preserve-user <user>]... [--json]
62
+ ornn fleet enroll <fleet-id> --identity-file <path> [--confirm-takeover <ip[,ip...]>] [--json]
63
+ ornn fleet deploy <fleet-id> --tenant <contact-email> --user <email-or-id> [--commerce-reservation <id>] [--network public|private] [--json]
60
64
  ornn ssh <node-or-reservation-id> [--print] [--identity-file <path>] [--user <name>] [--json]
61
65
  ornn metrics nodes [--json]
62
66
  ornn metrics node <node-id> [--json]
@@ -75,6 +79,16 @@ ornn clusters ssh <reservation-id> [--print] [--identity-file <path>] [--user <n
75
79
  ornn clusters add-node <reservation-id> --node <node-id> [--json]
76
80
  ornn clusters remove-node <reservation-id> --node <node-id> [--json]
77
81
  ornn clusters teardown <reservation-id> [--type kubernetes|slurm] [--json]
82
+ ornn slurm launch <reservation-id> [--network public|private] [--node <node-id>] [--node-count <n>] [--wait] [--wait-timeout <seconds>] [--json]
83
+ ornn slurm teardown <reservation-id> [--json]
84
+ ornn slurm status <reservation-id> [--json]
85
+ ornn slurm credentials <reservation-id> [--json]
86
+ ornn slurm ssh <reservation-id> [--print] [--identity-file <path>] [--user <name>] [--json]
87
+ ornn kubernetes launch <reservation-id> [--network public|private] [--node <node-id>] [--node-count <n>] [--wait] [--wait-timeout <seconds>] [--json]
88
+ ornn kubernetes teardown <reservation-id> [--json]
89
+ ornn kubernetes status <reservation-id> [--json]
90
+ ornn kubernetes credentials <reservation-id> [--json]
91
+ ornn kubernetes kubeconfig <reservation-id> [--output <path>] [--json]
78
92
  ornn networks list [--json]
79
93
  ornn networks show <network-id> [--json]
80
94
  ornn networks create --name <name> [--cidr <cidr>] [--description <text>] [--json]
@@ -91,6 +105,8 @@ ornn storage volumes clear <drive-id> [--json]
91
105
  ornn storage volumes delete <drive-id> [--json]
92
106
  ornn storage deploy <drive-id> --reservation <reservation-id> [--mount-path <path>] [--read-only|--read-write] [--json]
93
107
  ornn storage deploy status --reservation <reservation-id> [--json]
108
+ ornn storage unmount --reservation <reservation-id> [--json]
109
+ ornn storage undeploy --reservation <reservation-id> [--json]
94
110
  ornn storage buckets list [--json]
95
111
  ornn storage buckets show <drive-id> [--json]
96
112
  ornn storage buckets connect gcs|s3|r2 --bucket <bucket>|--url <url> [--name <name>] [--prefix <prefix>] [--region <region>] [--account-id <id>|--endpoint-url <url>] [--access-key-id <id> --secret-access-key-file <path>] [--read-only|--read-write] [--verify] [--json]
@@ -102,6 +118,7 @@ ornn keys add [<public-key-file>] [--public-key <key>] [--label <label>] [--json
102
118
  ornn keys delete <key-id> [--json]
103
119
  ornn access show <reservation-id> [--json]
104
120
  ornn access activate <reservation-id> --key <path|id|label> [--mode bare-metal|vm] [--username <name>] [--network public|private] [--storage-load-drive-id <id>] [--storage-save-drive-id <id>] [--wait] [--no-open] [--json]
121
+ ornn access switch <reservation-id> --network public|private --key <path|id|label> [--mode bare-metal|vm] [--username <name>] [--wait] [--json]
105
122
  ornn access push-keys <reservation-id> --ssh-key-id <id> [--json]
106
123
  ornn access keys list <reservation-id> [--json]
107
124
  ornn access keys add <reservation-id> --public-key <key> [--label <label>] [--json]
@@ -120,33 +137,141 @@ ornn api <get|post|patch|put|delete> <compute-path> [--data <json>] [--raw]
120
137
  ornn logout
121
138
  ```
122
139
 
140
+ `ornn access keys add` is safe before or after launch. When the reservation has
141
+ active VM or Bare Metal machines, adding the key automatically queues the
142
+ current user's complete reservation key set on those machines. Use
143
+ `ornn access keys push` to explicitly re-sync keys that are already attached.
144
+
145
+ `ornn storage deploy status` prints reconciled object and byte progress once the
146
+ managed transfer has reported progress. AWS S3 and external GCS sources use the
147
+ managed transfer path. Cloudflare R2 support is coming soon.
148
+
123
149
  UI aliases are first-class in the CLI: `listings` maps to the older
124
150
  `availability` command, `exchange` maps to `bid`, and `gpus` maps to
125
151
  `reservations`. The older command names remain available for scripts.
152
+ Bid and reservation list commands return a bounded page (500 rows by default);
153
+ use `--limit` and `--cursor` to traverse older history. Pass the final row ID
154
+ from one page as the next page's cursor. `show` and `checkout`
155
+ use exact tenant-owned item lookups, so they are not limited to the current page.
126
156
 
127
157
  ## Operator commands
128
158
 
129
159
  The `ornn node` commands are for Ornn operators and reviewers, not tenants. They
130
- strictly wrap the internal-reviewer-gated compute endpoints, so they do not use
131
- the tenant CLI login. Instead they talk to the compute service directly and
132
- authenticate with the shared reviewer secret:
160
+ wrap internal-reviewer-gated compute endpoints. Prefer `ornn login` as an
161
+ internal-allowed staff user (`ORNN_AUTH_BASE_URL`); the CLI calls
162
+ `/api/cli/operator/*` and the web server attaches the review secret — it never
163
+ downloads to the laptop. Optionally set `ORNN_INTERNAL_REVIEW_SECRET` plus
164
+ `ORNN_COMPUTE_BASE_URL` for direct compute access (CI / break-glass):
133
165
 
134
166
  - `ornn node health <node-id>` shows launch readiness (ready/blocked plus
135
167
  reasons), passive fault reasons, and the last validation status.
136
- - `ornn node diagnose <node-id>` queues an active health run for the node.
137
- - `ornn node drain <node-id>` / `ornn node restore <node-id>` take the node out
138
- of and back into service. Both accept an optional `--reason`.
168
+ - `ornn node diagnose <node-id>` queues a manual node validation test.
169
+ - `ornn node list [--operator <id-or-slug>] [--facility <id>]` lists GPU nodes,
170
+ optionally filtered by operator and/or facility. A UUID operator is matched by
171
+ id; anything else is treated as the operator slug.
172
+ - `ornn node reboot <node-id>` queues an OS reboot of the host backing the node
173
+ (storage and SSH keys preserved).
174
+ - `ornn node hard-reset <node-id>` wipes tenant storage/users and reboots, then
175
+ re-pushes the tenant's keys on reconnect. The tenant keeps ownership of the
176
+ host.
177
+ - `ornn node off-grid <node-id> [--reason <text>]` queues the agentless handoff
178
+ that removes Ornn agent management from the still-powered-on host.
179
+ - `ornn node on-grid <node-id>` re-enrolls an off-grid host (SSH in with the
180
+ stored admin key and re-run the installer) to restore Ornn management.
181
+ - `ornn node terminate <node-id> [--reason <text>] [--force]` best-effort cleans
182
+ the host, removes the Ornn agent, and hard-deletes the node record from the
183
+ platform. It does not return the host to a tenant.
184
+ - `ornn node admin-key <node-id>` prints the Ornn admin SSH keypair for the node
185
+ (including the private key) so an operator can SSH into the host. Treat the
186
+ output as a secret.
187
+ - `ornn node deenroll <node-id>` removes a node from the environment: it first
188
+ gracefully terminates the enrollment (revokes the node claim, retires any
189
+ listing, and queues the node-agent self-uninstall packet), then dereferences
190
+ (hard-deletes) the `gpu_nodes` record so nothing lingers. Pass `--keep-record`
191
+ to stop after termination and leave the row in a `deenrolled` state, or
192
+ `--reason <text>` to annotate the audit log. The command is idempotent: an
193
+ already-removed node reports success. Terminate is forced by default so a
194
+ dead/unreachable node does not block on workload drain.
195
+
196
+ The operator resource commands share the same reviewer auth:
197
+
198
+ - `ornn operators list` lists enrolled operators.
199
+ - `ornn facilities list [--operator <id-or-slug>]` lists facilities, optionally
200
+ filtered to one operator.
201
+ - `ornn tokens list` lists enrollment tokens.
202
+ - `ornn tokens create --operator <id> [--facility <id>]
203
+ [--expires-in <seconds>] [--mode bare-metal|vm] [--ip <addr>]` mints an
204
+ enrollment token and prints a ready-to-run `curl … | sudo bash` install
205
+ command.
206
+ - `ornn tokens revoke <token-id>` revokes an enrollment token.
207
+
208
+ Fleet handoff is an ordered, Compute-backed workflow:
209
+
210
+ - Before first contact, independently verify each host key and add it to the
211
+ operator's SSH `known_hosts`; fleet SSH uses strict host-key checking and will
212
+ not trust a key first presented during cleanup.
213
+ - `fleet clean ... --dry-run` tries `ubuntu`, `admin`, then `ornn`, records each
214
+ host's hardware identity and exact deletion plan, and prints one fleet plan
215
+ hash. Repeat `--source-user` for Linux accounts owned by the departing tenant
216
+ (including an explicitly reviewed orphaned `/home/<user>` directory) and
217
+ `--preserve-user` for reviewed host accounts. Package/service-owned
218
+ accounts are protected automatically; any other account stops planning for
219
+ explicit review. Re-running with `--confirm-clean <plan-hash>` and the same
220
+ account flags removes the approved tenant
221
+ users, keys, containers/images/volumes/networks/build cache, workload paths,
222
+ mounts, services, and tenant-scoped logs. Apply refuses plans with running
223
+ containers, locks the management/root passwords, enforces key-only SSH, trims
224
+ supported filesystems, preserves existing root and management keys and host
225
+ logs, and stores per-node proof in Compute. A failed
226
+ cleanup can be dry-run again with the same fleet ID; only failed members are
227
+ replanned. Residual GPU
228
+ consumers or tenant/platform listeners fail verification instead of being
229
+ silently accepted.
230
+ - `fleet enroll <fleet-id>` refuses nodes without successful cleanup proof,
231
+ issues and revokes one cleanup-bound token per host, installs the agent,
232
+ assigns the IB island, verifies the same management account still has
233
+ key-based SSH and passwordless sudo, and records the resulting GPU node IDs
234
+ in Compute. Generic enrollment tokens retain the legacy enrollment sanitizer.
235
+ - `fleet deploy <fleet-id> --tenant <contact-email> --user <email-or-id>
236
+ [--commerce-reservation <id>]` validates the exact tenant/user and, when
237
+ supplied, the Commerce reservation. It succeeds only after every selected
238
+ user's SSH key is installed on every running node and passwordless sudo is
239
+ reported ready.
240
+
241
+ Compute Postgres is authoritative; the mode-0600 files under
242
+ `~/.config/ornn/fleets/` are only a secret-free cache. Cleanup proves “no
243
+ observable residue under the verification scope,” not certified physical-media
244
+ erasure. Root is never the preserved management account. `--confirm-takeover`
245
+ names only fleet IPs whose existing node claims may be replaced. The IB-island
246
+ is a locality label, not cluster creation. Test scheduling remains a separate
247
+ control-plane policy.
248
+
249
+ Reservation lifecycle verbs also run under the operator surface:
250
+
251
+ - `ornn reservations withdraw <reservation-id>` withdraws a reservation's
252
+ acceptance, returning the node(s) to tenant inventory.
253
+ - `ornn reservations transfer <reservation-id> --target-tenant <id>
254
+ [--target-user <id>] [--node <id>] [--strategy reject|park] [--confirm]`
255
+ transfers a reservation to another tenant.
256
+ - `ornn reservations deploy <node-id> --target-tenant <id> [--target-user <id>]
257
+ [--network public|private]` deploys a node into a tenant's inventory.
139
258
 
140
- Set these environment variables before running them:
259
+ The tenant `ornn reservations list|show|checkout` verbs still use the tenant CLI
260
+ login and `/api/cli` proxy; `withdraw|transfer|deploy` use the operator path
261
+ above (staff session proxy or optional review secret).
141
262
 
142
- - `ORNN_COMPUTE_BASE_URL` — origin of the internal compute service (for example
143
- `http://localhost:8000`). The review secret travels as a request header, so
144
- in any non-local deployment this should be an `https://` origin.
145
- - `ORNN_INTERNAL_REVIEW_SECRET` — the reviewer secret, sent as the
146
- `X-Internal-Review-Secret` header.
263
+ Staff happy path:
147
264
 
148
- If either is missing the command fails with a clear error before making any
149
- network request.
265
+ - `ORNN_AUTH_BASE_URL` web/auth origin
266
+ - `ornn login` as a user allowed by `INTERNAL_ALLOWED_*` on that environment
267
+
268
+ Optional direct compute override:
269
+
270
+ - `ORNN_COMPUTE_BASE_URL` — compute origin (for example `http://localhost:8000`)
271
+ - `ORNN_INTERNAL_REVIEW_SECRET` — reviewer secret (`X-Internal-Review-Secret`)
272
+
273
+ Without a staff login session or the review-secret pair, operator commands fail
274
+ with a clear error before making a network request.
150
275
 
151
276
  Commands print concise human-readable output by default. Use `--json` on
152
277
  read/show/list commands and transaction handoffs when you need structured
@@ -161,6 +286,19 @@ the terminal keeps polling until the login is approved or expires.
161
286
  Run `ornn --help` for the full command list and `ornn api` for the
162
287
  allowlisted compute API escape hatch.
163
288
 
289
+ WireGuard client configs use the current tenant-scoped `/nodes` routes:
290
+
291
+ ```bash
292
+ ornn api get /nodes/reservations/<reservation-id>/vpn/peers --raw
293
+ ornn api post /nodes/reservations/<reservation-id>/vpn/peers \
294
+ --data '{"public_key":"<wireguard-public-key>","label":"Laptop"}' --raw
295
+ ornn api delete /nodes/reservations/<reservation-id>/vpn/peers/<peer-id> --raw
296
+ ```
297
+
298
+ The legacy `/standalone-vms/*` prefix is not exposed through the CLI proxy.
299
+ Deleting a peer is the supported way to replace its key; switching a node
300
+ between Public and Private VPN preserves the peer.
301
+
164
302
  ## Updating
165
303
 
166
304
  The CLI checks the npm registry for a newer version at most once per day and prints a one-line notice on stderr when one exists. Run `ornn update` to install it (the command detects npm, pnpm, yarn, or bun installs). The check is skipped in CI and non-interactive shells.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornncompute/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Command-line interface for Ornn compute access workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- import { clearAuthSession, loadAuthSession } from "./auth-store.mjs";
1
+ import { clearAuthSession, configuredAuthBaseUrl, loadAuthSession } from "./auth-store.mjs";
2
2
  import { resolveAuthBaseUrl } from "./device-auth.mjs";
3
3
 
4
4
  export class CliApiError extends Error {
@@ -11,13 +11,22 @@ export class CliApiError extends Error {
11
11
  }
12
12
 
13
13
  export function resolveApiBaseUrl({ env = process.env, explicit, session } = {}) {
14
- return (
15
- explicit?.trim() ||
16
- env.ORNN_API_BASE_URL?.trim() ||
17
- env.ORNN_AUTH_BASE_URL?.trim() ||
18
- session?.authBaseUrl?.trim() ||
19
- resolveAuthBaseUrl({ env })
20
- ).replace(/\/+$/, "");
14
+ // Prefer the first configured source; do not fall through when it strips empty.
15
+ const candidates = [
16
+ [explicit, "API base URL"],
17
+ [env.ORNN_API_BASE_URL, "ORNN_API_BASE_URL"],
18
+ [env.ORNN_AUTH_BASE_URL, "ORNN_AUTH_BASE_URL"],
19
+ [session?.authBaseUrl, "session authBaseUrl"],
20
+ ];
21
+ for (const [value, sourceName] of candidates) {
22
+ const trimmed = value?.trim();
23
+ if (!trimmed) {
24
+ continue;
25
+ }
26
+ return requireValidBaseUrl(trimmed, sourceName);
27
+ }
28
+
29
+ return requireValidBaseUrl(resolveAuthBaseUrl({ env }), "API base URL");
21
30
  }
22
31
 
23
32
  export async function loadRequiredSession({ env = process.env } = {}) {
@@ -101,32 +110,111 @@ export function resolveComputeBaseUrl({ env = process.env } = {}) {
101
110
  "Set ORNN_COMPUTE_BASE_URL to the internal compute service origin to run operator commands.",
102
111
  );
103
112
  }
104
- return base.replace(/\/+$/, "");
113
+ return requireValidBaseUrl(base, "ORNN_COMPUTE_BASE_URL");
114
+ }
115
+
116
+ /**
117
+ * Origin used in `curl …/enrollment/install` one-liners.
118
+ *
119
+ * Prefer an explicit compute/installer URL. When staff use the web operator
120
+ * proxy (no local review secret), derive compute from the auth/web origin:
121
+ * - `https://<slug>.fabric.ornn.com` → `https://<slug>-api.fabric.ornn.com`
122
+ * - `https://compute.ornn.com` → `https://api-compute.ornn.com`
123
+ * - `http://localhost:3000` → `http://localhost:8000`
124
+ */
125
+ export function resolveInstallerBaseUrl({ env = process.env, session } = {}) {
126
+ const explicit =
127
+ env.ORNN_INSTALLER_URL?.trim() ||
128
+ env.ORNN_COMPUTE_BASE_URL?.trim() ||
129
+ env.NEXT_PUBLIC_INSTALLER_URL?.trim();
130
+ if (explicit) {
131
+ return requireValidBaseUrl(explicit, "installer base URL");
132
+ }
133
+
134
+ // The session records where the caller actually logged in, so it outranks the
135
+ // installed default.
136
+ const authCandidates = [
137
+ env.ORNN_AUTH_BASE_URL,
138
+ env.ORNN_API_BASE_URL,
139
+ session?.authBaseUrl,
140
+ configuredAuthBaseUrl(env),
141
+ ];
142
+ for (const candidate of authCandidates) {
143
+ const trimmed = candidate?.trim();
144
+ if (!trimmed) {
145
+ continue;
146
+ }
147
+ const authOrigin = requireValidBaseUrl(trimmed, "ORNN_AUTH_BASE_URL");
148
+ return deriveInstallerOriginFromAuth(authOrigin);
149
+ }
150
+ return null;
151
+ }
152
+
153
+ function deriveInstallerOriginFromAuth(authOrigin) {
154
+ const url = new URL(authOrigin);
155
+ if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
156
+ url.port = "8000";
157
+ url.protocol = "http:";
158
+ return url.origin;
159
+ }
160
+ const fabricMatch = url.hostname.match(/^([a-z0-9-]+)\.fabric\.ornn\.com$/i);
161
+ if (fabricMatch && !fabricMatch[1].endsWith("-api")) {
162
+ url.hostname = `${fabricMatch[1]}-api.fabric.ornn.com`;
163
+ return url.origin;
164
+ }
165
+ if (url.hostname === "compute.ornn.com") {
166
+ url.hostname = "api-compute.ornn.com";
167
+ return url.origin;
168
+ }
169
+ return url.origin;
105
170
  }
106
171
 
107
172
  export function resolveInternalReviewSecret({ env = process.env } = {}) {
108
173
  const secret = env.ORNN_INTERNAL_REVIEW_SECRET?.trim();
109
174
  if (!secret) {
110
175
  throw new CliApiError(
111
- "Set ORNN_INTERNAL_REVIEW_SECRET to authenticate operator commands.",
176
+ "Set ORNN_INTERNAL_REVIEW_SECRET to authenticate operator commands, or run `ornn login` as Ornn staff.",
112
177
  );
113
178
  }
114
179
  return secret;
115
180
  }
116
181
 
117
- export async function operatorRequest({
182
+ function operatorProxyEndpoint(endpoint) {
183
+ return `/api/cli/operator${normalizeComputePath(endpoint)}`;
184
+ }
185
+
186
+ async function operatorRequestViaStaffSession({
187
+ body,
188
+ endpoint,
189
+ env = process.env,
190
+ fetchImpl = fetch,
191
+ method = "GET",
192
+ } = {}) {
193
+ // Staff path: CLI bearer → web /api/cli/operator → compute with server-side review secret.
194
+ return cliRequest({
195
+ body,
196
+ endpoint: operatorProxyEndpoint(endpoint),
197
+ env,
198
+ fetchImpl,
199
+ method,
200
+ authRequired: true,
201
+ });
202
+ }
203
+
204
+ async function operatorRequestViaReviewSecret({
118
205
  body,
119
206
  endpoint,
120
207
  env = process.env,
121
208
  fetchImpl = fetch,
122
209
  method = "GET",
123
210
  } = {}) {
124
- // Operator path: hit compute directly with the review secret (not the tenant /api/cli proxy).
211
+ // Break-glass / CI: hit compute directly with the review secret.
125
212
  const baseUrl = resolveComputeBaseUrl({ env });
126
213
  const secret = resolveInternalReviewSecret({ env });
127
214
  const url = new URL(endpoint, `${baseUrl}/`);
128
215
  const headers = {
129
216
  Accept: "application/json",
217
+ "X-Internal-Actor": env.ORNN_INTERNAL_ACTOR?.trim() || "break-glass-cli",
130
218
  "X-Internal-Review-Secret": secret,
131
219
  };
132
220
 
@@ -157,6 +245,32 @@ export async function operatorRequest({
157
245
  return data;
158
246
  }
159
247
 
248
+ export async function operatorRequest({
249
+ body,
250
+ endpoint,
251
+ env = process.env,
252
+ fetchImpl = fetch,
253
+ method = "GET",
254
+ } = {}) {
255
+ // Prefer an explicit review secret (CI / break-glass). Otherwise use a staff
256
+ // `ornn login` session through the web operator proxy — the secret never
257
+ // leaves the server.
258
+ if (env.ORNN_INTERNAL_REVIEW_SECRET?.trim()) {
259
+ return operatorRequestViaReviewSecret({ body, endpoint, env, fetchImpl, method });
260
+ }
261
+
262
+ const session = await loadAuthSession({ env });
263
+ if (session?.accessToken) {
264
+ return operatorRequestViaStaffSession({ body, endpoint, env, fetchImpl, method });
265
+ }
266
+
267
+ throw new CliApiError(
268
+ "Operator commands need Ornn staff auth: run `ornn login` as an internal user " +
269
+ "(ORNN_AUTH_BASE_URL), or set ORNN_INTERNAL_REVIEW_SECRET (+ ORNN_COMPUTE_BASE_URL) for direct compute access.",
270
+ { status: 401 },
271
+ );
272
+ }
273
+
160
274
  export function computeEndpoint(path) {
161
275
  const normalized = normalizeComputePath(path);
162
276
  return `/api/cli/compute${normalized}`;
@@ -173,6 +287,28 @@ export function normalizeComputePath(path) {
173
287
  return trimmed;
174
288
  }
175
289
 
290
+ function requireValidBaseUrl(raw, sourceName) {
291
+ const stripped = raw.replace(/\/+$/, "");
292
+ if (!stripped) {
293
+ throw new CliApiError(
294
+ `${sourceName} must be a valid origin (got empty value after removing trailing slashes).`,
295
+ );
296
+ }
297
+ let parsed;
298
+ try {
299
+ parsed = new URL(stripped);
300
+ } catch {
301
+ throw new CliApiError(`${sourceName} must be a valid origin (got ${JSON.stringify(raw)}).`);
302
+ }
303
+ // Path/search/hash are dropped when composing absolute endpoints against this base.
304
+ if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
305
+ throw new CliApiError(
306
+ `${sourceName} must be a valid origin with no path (got ${JSON.stringify(raw)}).`,
307
+ );
308
+ }
309
+ return stripped;
310
+ }
311
+
176
312
  function parseJson(text, url, response) {
177
313
  try {
178
314
  return JSON.parse(text);
@@ -1,9 +1,12 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
3
  import { dirname, join, resolve } from "node:path";
3
4
  import { homedir } from "node:os";
4
5
 
5
6
  const APP_DIR = "ornn";
6
7
  const AUTH_FILE = "auth.json";
8
+ const CONFIG_FILE = "config.json";
9
+ const FLEETS_DIR = "fleets";
7
10
 
8
11
  export function getAuthConfigDir(env = process.env) {
9
12
  if (env.ORNN_CONFIG_HOME?.trim()) {
@@ -21,6 +24,24 @@ export function getAuthConfigPath(env = process.env) {
21
24
  return join(getAuthConfigDir(env), AUTH_FILE);
22
25
  }
23
26
 
27
+ export function getCliConfigPath(env = process.env) {
28
+ return join(getAuthConfigDir(env), CONFIG_FILE);
29
+ }
30
+
31
+ export function getFleetConfigDir(env = process.env) {
32
+ return join(getAuthConfigDir(env), FLEETS_DIR);
33
+ }
34
+
35
+ // Read synchronously so callers resolving a base URL stay synchronous.
36
+ export function configuredAuthBaseUrl(env = process.env) {
37
+ try {
38
+ const value = JSON.parse(readFileSync(getCliConfigPath(env), "utf8"))?.authBaseUrl;
39
+ return typeof value === "string" ? value.trim() : "";
40
+ } catch {
41
+ return "";
42
+ }
43
+ }
44
+
24
45
  export async function loadAuthSession({ env = process.env } = {}) {
25
46
  const path = getAuthConfigPath(env);
26
47