@odla-ai/cli 0.6.0 → 0.7.1

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.
@@ -1,38 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/admin-ai.ts
4
- import process4 from "process";
3
+ // src/admin-ai-auth.ts
5
4
  import { existsSync as existsSync2 } from "fs";
5
+ import process4 from "process";
6
6
  import { requestToken as requestToken2 } from "@odla-ai/db";
7
7
 
8
- // src/open.ts
9
- import { spawn } from "child_process";
10
- import process2 from "process";
11
- async function openUrl(url, options = {}) {
12
- const command = openerFor(options.platform ?? process2.platform);
13
- const doSpawn = options.spawnImpl ?? spawn;
14
- await new Promise((resolve7, reject) => {
15
- const child = doSpawn(command.cmd, [...command.args, url], {
16
- stdio: "ignore",
17
- detached: true
18
- });
19
- child.once("error", reject);
20
- child.once("spawn", () => {
21
- child.unref();
22
- resolve7();
23
- });
24
- });
25
- }
26
- function openerFor(platform) {
27
- if (platform === "darwin") return { cmd: "open", args: [] };
28
- if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
29
- return { cmd: "xdg-open", args: [] };
30
- }
31
-
32
- // src/token.ts
33
- import { requestToken } from "@odla-ai/db";
34
- import process3 from "process";
35
-
36
8
  // src/local.ts
37
9
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
38
10
  import { dirname, isAbsolute, relative, resolve } from "path";
@@ -159,12 +131,47 @@ function displayPath(path, rootDir = process.cwd()) {
159
131
  return rel && !rel.startsWith("..") ? rel : path;
160
132
  }
161
133
 
134
+ // src/open.ts
135
+ import { spawn } from "child_process";
136
+ import process2 from "process";
137
+ async function openUrl(url, options = {}) {
138
+ const command = openerFor(options.platform ?? process2.platform);
139
+ const doSpawn = options.spawnImpl ?? spawn;
140
+ await new Promise((resolve7, reject) => {
141
+ const child = doSpawn(command.cmd, [...command.args, url], {
142
+ stdio: "ignore",
143
+ detached: true
144
+ });
145
+ child.once("error", reject);
146
+ child.once("spawn", () => {
147
+ child.unref();
148
+ resolve7();
149
+ });
150
+ });
151
+ }
152
+ function openerFor(platform) {
153
+ if (platform === "darwin") return { cmd: "open", args: [] };
154
+ if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
155
+ return { cmd: "xdg-open", args: [] };
156
+ }
157
+
162
158
  // src/token.ts
159
+ import { requestToken } from "@odla-ai/db";
160
+ import process3 from "process";
163
161
  async function getDeveloperToken(cfg, options, doFetch, out) {
164
162
  if (options.token) return options.token;
165
- if (process3.env.ODLA_DEV_TOKEN) return process3.env.ODLA_DEV_TOKEN;
163
+ const audience = platformAudience(cfg.platformUrl);
164
+ if (process3.env.ODLA_DEV_TOKEN) {
165
+ const declared = process3.env.ODLA_DEV_TOKEN_AUDIENCE;
166
+ if (declared) {
167
+ if (platformAudience(declared) !== audience) throw new Error("ODLA_DEV_TOKEN_AUDIENCE does not match the configured platform");
168
+ } else if (audience !== "https://odla.ai") {
169
+ throw new Error("ODLA_DEV_TOKEN_AUDIENCE is required for a non-default platform");
170
+ }
171
+ return process3.env.ODLA_DEV_TOKEN;
172
+ }
166
173
  const cached = readJsonFile(cfg.local.tokenFile);
167
- if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
174
+ if (cached?.token && cached.platform === audience && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
168
175
  out.log(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
169
176
  return cached.token;
170
177
  }
@@ -190,7 +197,7 @@ async function getDeveloperToken(cfg, options, doFetch, out) {
190
197
  out.log("");
191
198
  }
192
199
  });
193
- writePrivateJson(cfg.local.tokenFile, { token, expiresAt });
200
+ writePrivateJson(cfg.local.tokenFile, { platform: audience, token, expiresAt });
194
201
  out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
195
202
  return token;
196
203
  }
@@ -207,35 +214,271 @@ function handshakeUrl(platformUrl, userCode) {
207
214
  url.searchParams.set("code", userCode);
208
215
  return url.toString();
209
216
  }
217
+ function platformAudience(value) {
218
+ let url;
219
+ try {
220
+ url = new URL(value);
221
+ } catch {
222
+ throw new Error("platform must be an absolute URL");
223
+ }
224
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
225
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
226
+ throw new Error("platform credentials require HTTPS except for loopback development");
227
+ }
228
+ if (url.username || url.password || url.search || url.hash) {
229
+ throw new Error("platform URL must not contain credentials, query, or fragment");
230
+ }
231
+ return url.origin;
232
+ }
210
233
 
211
- // src/admin-ai.ts
212
- var SYSTEM_AI_PURPOSES = ["o11y.triage", "security.discovery", "security.validation"];
234
+ // src/admin-ai-auth.ts
213
235
  async function getScopedPlatformToken(options) {
236
+ return resolveAdminPlatformToken(options);
237
+ }
238
+ async function resolveAdminPlatformToken(options) {
239
+ const audience = platformAudience(options.platform);
240
+ if (options.token) return options.token;
214
241
  const fromEnv = process4.env.ODLA_ADMIN_TOKEN;
215
- if (fromEnv) return fromEnv;
242
+ if (fromEnv) return audienceBoundEnvToken(fromEnv, audience);
216
243
  return scopedToken(
217
- options.platform.replace(/\/$/, ""),
244
+ audience,
218
245
  options.scope,
219
- { action: "show", ...options },
246
+ options,
220
247
  options.fetch ?? fetch,
221
248
  options.stdout ?? console
222
249
  );
223
250
  }
251
+ function audienceBoundEnvToken(token, platform) {
252
+ const audience = platformAudience(platform);
253
+ const declared = process4.env.ODLA_ADMIN_TOKEN_AUDIENCE;
254
+ if (declared) {
255
+ if (platformAudience(declared) !== audience) throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE does not match the configured platform");
256
+ } else if (audience !== "https://odla.ai") {
257
+ throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE is required for a non-default platform");
258
+ }
259
+ return token;
260
+ }
261
+ async function scopedToken(platform, scope, options, doFetch, out) {
262
+ const audience = platformAudience(platform);
263
+ const tokenFile = options.tokenFile ?? `${process4.cwd()}/.odla/admin-token.local.json`;
264
+ const cache = readJsonFile(tokenFile);
265
+ const cached = cache?.platform === audience ? cache.tokens?.[scope] : void 0;
266
+ if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
267
+ out.log(`auth: using cached ${scope} grant (${tokenFile})`);
268
+ return cached.token;
269
+ }
270
+ const { token, expiresAt } = await requestToken2({
271
+ endpoint: audience,
272
+ label: `odla CLI admin AI (${scope})`,
273
+ scopes: [scope],
274
+ fetch: doFetch,
275
+ onCode: async ({ userCode, expiresIn }) => {
276
+ const approvalUrl = handshakeUrl(audience, userCode);
277
+ out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
278
+ const shouldOpen = options.open === true || options.open !== false && !process4.env.CI && Boolean(process4.stdin.isTTY && process4.stdout.isTTY);
279
+ if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
280
+ }
281
+ });
282
+ const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
283
+ tokens[scope] = { token, expiresAt };
284
+ if (existsSync2(`${process4.cwd()}/.git`)) ensureGitignore(process4.cwd(), [tokenFile]);
285
+ writePrivateJson(tokenFile, { platform: audience, tokens });
286
+ out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
287
+ return token;
288
+ }
289
+
290
+ // src/admin-ai-policy.ts
291
+ var SYSTEM_AI_PURPOSES = ["o11y.triage", "security.discovery", "security.validation"];
292
+ function requireSystemAiPurpose(value) {
293
+ if (!SYSTEM_AI_PURPOSES.includes(value)) {
294
+ throw new Error(`purpose must be one of: ${SYSTEM_AI_PURPOSES.join(", ")}`);
295
+ }
296
+ return value;
297
+ }
298
+
299
+ // src/admin-ai.ts
300
+ import process5 from "process";
301
+
302
+ // src/admin-ai-audit.ts
303
+ function adminAiAuditQuery(filters) {
304
+ if (filters.limit === void 0) return "";
305
+ if (!Number.isSafeInteger(filters.limit) || filters.limit < 1 || filters.limit > 200) {
306
+ throw new Error("audit limit must be an integer from 1 to 200");
307
+ }
308
+ return `?limit=${filters.limit}`;
309
+ }
310
+ async function readAdminAiAudit(request) {
311
+ const response = await request.fetch(`${request.platform}/registry/platform/ai-audit${request.query}`, {
312
+ headers: request.headers
313
+ });
314
+ const body = await responseBody(response);
315
+ if (!response.ok) throw new Error(apiError(response.status, body));
316
+ if (request.json) {
317
+ request.stdout.log(JSON.stringify(body, null, 2));
318
+ return;
319
+ }
320
+ const events = isRecord(body) && Array.isArray(body.events) ? body.events.filter(isRecord) : [];
321
+ request.stdout.log("when change target before -> after actor");
322
+ for (const event of events) {
323
+ const before = isRecord(event.oldPolicy) ? event.oldPolicy : void 0;
324
+ const after = isRecord(event.newPolicy) ? event.newPolicy : void 0;
325
+ const route = before && after ? `${String(before.provider)}/${String(before.model)}@v${String(before.version)} -> ${String(after.provider)}/${String(after.model)}@v${String(after.version)}` : "value not retained";
326
+ request.stdout.log([
327
+ timestamp(event.createdAt),
328
+ String(event.changeKind ?? ""),
329
+ String(event.purpose ?? event.provider ?? ""),
330
+ route,
331
+ `${String(event.actorType ?? "")}:${String(event.actorId ?? "")}`
332
+ ].join(" "));
333
+ }
334
+ }
335
+ async function responseBody(response) {
336
+ const text = await response.text();
337
+ if (!text) return {};
338
+ try {
339
+ return JSON.parse(text);
340
+ } catch {
341
+ return { message: text.slice(0, 300) };
342
+ }
343
+ }
344
+ function apiError(status, body) {
345
+ const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
346
+ const message = error && typeof error.message === "string" ? error.message : isRecord(body) && typeof body.message === "string" ? body.message : "request failed";
347
+ return `read System AI admin changes failed (${status}): ${message}`;
348
+ }
349
+ function timestamp(value) {
350
+ if (typeof value !== "number" || !Number.isFinite(value)) return String(value ?? "");
351
+ const date = new Date(value);
352
+ return Number.isFinite(date.valueOf()) ? date.toISOString() : "";
353
+ }
354
+ function isRecord(value) {
355
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
356
+ }
357
+
358
+ // src/admin-ai-usage.ts
359
+ function adminAiUsageQuery(filters) {
360
+ const params = new URLSearchParams();
361
+ if (filters.appId?.trim()) params.set("appId", filters.appId.trim());
362
+ if (filters.env?.trim()) params.set("env", filters.env.trim());
363
+ if (filters.runId?.trim()) params.set("runId", filters.runId.trim());
364
+ if (filters.limit !== void 0) params.set("limit", String(usageLimit(filters.limit)));
365
+ const query = params.toString();
366
+ return query ? `?${query}` : "";
367
+ }
368
+ async function readAdminAiUsage(request) {
369
+ const res = await request.fetch(`${request.platform}/registry/platform/ai-usage${request.query}`, {
370
+ headers: request.headers
371
+ });
372
+ const body = await responseBody2(res);
373
+ if (!res.ok) throw new Error(apiError2("read platform AI usage", res.status, body));
374
+ if (request.json) request.stdout.log(JSON.stringify(body, null, 2));
375
+ else printUsage(body, request.stdout);
376
+ }
377
+ function usageLimit(value) {
378
+ if (!Number.isSafeInteger(value) || value < 1 || value > 500) {
379
+ throw new Error("usage limit must be an integer from 1 to 500");
380
+ }
381
+ return value;
382
+ }
383
+ function printUsage(body, out) {
384
+ const aggregates = isRecord2(body) && isRecord2(body.aggregates) && Array.isArray(body.aggregates.byStatus) ? body.aggregates.byStatus.filter(isRecord2) : [];
385
+ out.log(`All retained hosted-security status totals (up to ${String(isRecord2(body) ? body.retentionDays ?? 90 : 90)} days; o11y triage excluded)
386
+ status calls input tokens output tokens cost unpriced calls`);
387
+ for (const row of aggregates) {
388
+ out.log([
389
+ String(row.status ?? ""),
390
+ String(row.calls ?? ""),
391
+ String(row.input_tokens ?? ""),
392
+ String(row.output_tokens ?? ""),
393
+ formatMicrousd(row.cost_microusd),
394
+ String(row.unpriced_calls ?? "")
395
+ ].join(" "));
396
+ }
397
+ const events = isRecord2(body) && Array.isArray(body.events) ? body.events.filter(isRecord2) : [];
398
+ out.log(`Latest ${String(isRecord2(body) ? body.eventLimit ?? events.length : events.length)} hosted-security events
399
+ when app/env run actor purpose/role route / policy tokens cost status`);
400
+ for (const event of events) {
401
+ const input = numeric(event.input_tokens);
402
+ const output = numeric(event.output_tokens);
403
+ const priced = event.priced === true || event.priced === 1;
404
+ out.log([
405
+ timestamp2(event.created_at),
406
+ `${String(event.app_id ?? "")}/${String(event.env ?? "")}`,
407
+ String(event.run_id ?? ""),
408
+ `${String(event.actor_type ?? "")}:${String(event.actor_id ?? "")}`,
409
+ `${String(event.purpose ?? "")}/${String(event.role ?? "")}`,
410
+ `${String(event.provider ?? "")}/${String(event.model ?? "")}@v${String(event.policy_version ?? "unknown")}`,
411
+ String(input + output),
412
+ priced ? formatMicrousd(event.cost_microusd) : "unpriced",
413
+ `${String(event.status ?? "")}${event.error_code ? `:${String(event.error_code)}` : ""}`
414
+ ].join(" "));
415
+ }
416
+ }
417
+ function numeric(value) {
418
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
419
+ }
420
+ function formatMicrousd(value) {
421
+ return typeof value === "number" && Number.isFinite(value) ? `$${(value / 1e6).toFixed(6)}` : "unpriced";
422
+ }
423
+ function timestamp2(value) {
424
+ if (typeof value !== "number" || !Number.isFinite(value)) return String(value ?? "");
425
+ const date = new Date(value);
426
+ return Number.isFinite(date.valueOf()) ? date.toISOString() : "";
427
+ }
428
+ async function responseBody2(res) {
429
+ const text = await res.text();
430
+ if (!text) return {};
431
+ try {
432
+ return JSON.parse(text);
433
+ } catch {
434
+ return { message: text.slice(0, 300) };
435
+ }
436
+ }
437
+ function apiError2(action, status, body) {
438
+ const error = isRecord2(body) && isRecord2(body.error) ? body.error : void 0;
439
+ const message = error && typeof error.message === "string" ? error.message : isRecord2(body) && typeof body.message === "string" ? body.message : "request failed";
440
+ return `${action} failed (${status}): ${message}`;
441
+ }
442
+ function isRecord2(value) {
443
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
444
+ }
445
+
446
+ // src/admin-ai.ts
224
447
  async function adminAi(options) {
225
- const platform = (options.platform ?? process4.env.ODLA_PLATFORM ?? "https://odla.ai").replace(/\/$/, "");
448
+ const platform = platformAudience(options.platform ?? process5.env.ODLA_PLATFORM ?? "https://odla.ai");
226
449
  const doFetch = options.fetch ?? fetch;
227
450
  const out = options.stdout ?? console;
228
- const scope = options.action === "set" || options.action === "credential-set" ? "platform:ai:write" : "platform:ai:read";
229
- const token = options.token ?? process4.env.ODLA_ADMIN_TOKEN ?? await scopedToken(platform, scope, options, doFetch, out);
451
+ const usageQuery = options.action === "usage" ? adminAiUsageQuery(options) : void 0;
452
+ const auditQuery = options.action === "audit" ? adminAiAuditQuery(options) : void 0;
453
+ const scope = options.action === "set" ? "platform:ai:policy:write" : options.action === "credential-set" ? "platform:ai:credential:write" : options.action === "credentials" ? "platform:ai:credential:read" : options.action === "usage" ? "platform:ai:usage:read" : options.action === "audit" ? "platform:ai:audit:read" : "platform:ai:policy:read";
454
+ const token = await resolveAdminPlatformToken({
455
+ platform,
456
+ scope,
457
+ token: options.token,
458
+ open: options.open,
459
+ fetch: doFetch,
460
+ stdout: out,
461
+ openApprovalUrl: options.openApprovalUrl,
462
+ tokenFile: options.tokenFile
463
+ });
230
464
  const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
465
+ if (options.action === "usage") {
466
+ await readAdminAiUsage({ platform, query: usageQuery ?? "", headers, json: options.json, fetch: doFetch, stdout: out });
467
+ return;
468
+ }
469
+ if (options.action === "audit") {
470
+ await readAdminAiAudit({ platform, query: auditQuery ?? "", headers, json: options.json, fetch: doFetch, stdout: out });
471
+ return;
472
+ }
231
473
  if (options.action === "credentials") {
232
474
  const res2 = await doFetch(`${platform}/registry/platform/ai-credentials`, { headers });
233
- const body2 = await responseBody(res2);
234
- if (!res2.ok) throw new Error(apiError("read platform AI credential status", res2.status, body2));
235
- const credentials = isRecord(body2) && isRecord(body2.credentials) ? body2.credentials : body2;
475
+ const body2 = await responseBody3(res2);
476
+ if (!res2.ok) throw new Error(apiError3("read platform AI credential status", res2.status, body2));
477
+ const credentials = isRecord3(body2) && isRecord3(body2.credentials) ? body2.credentials : body2;
236
478
  if (options.json) out.log(JSON.stringify({ credentials }, null, 2));
237
- else for (const [provider, status] of Object.entries(isRecord(credentials) ? credentials : {})) {
238
- out.log(`${provider} ${isRecord(status) && status.set === true ? "set" : "not set"}`);
479
+ else for (const [provider, status] of Object.entries(isRecord3(credentials) ? credentials : {})) {
480
+ const label = isRecord3(status) && status.set === true ? status.readable === true ? "vault readable" : "stored; vault unreadable" : "not set";
481
+ out.log(`${provider} ${label}`);
239
482
  }
240
483
  return;
241
484
  }
@@ -247,18 +490,25 @@ async function adminAi(options) {
247
490
  headers,
248
491
  body: JSON.stringify({ value })
249
492
  });
250
- const body2 = await responseBody(res2);
251
- if (!res2.ok) throw new Error(apiError(`store ${provider} platform credential`, res2.status, body2));
493
+ const body2 = await responseBody3(res2);
494
+ if (!res2.ok) throw new Error(apiError3(`store ${provider} platform credential`, res2.status, body2));
252
495
  out.log(`stored ${provider} platform credential (write-only; value was not returned)`);
253
496
  return;
254
497
  }
255
- if (options.action === "show") {
498
+ if (options.action === "show" || options.action === "models") {
256
499
  const res2 = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
257
- const body2 = await responseBody(res2);
258
- if (!res2.ok) throw new Error(apiError("read platform AI policies", res2.status, body2));
500
+ const body2 = await responseBody3(res2);
501
+ if (!res2.ok) throw new Error(apiError3("read platform AI policies", res2.status, body2));
502
+ if (options.action === "models") {
503
+ const models = catalogModels(body2).filter((model) => !options.provider || model.provider === options.provider);
504
+ if (!models.length) throw new Error(options.provider ? `no System AI models found for provider ${options.provider}` : "platform returned no System AI models");
505
+ if (options.json) out.log(JSON.stringify({ models }, null, 2));
506
+ else for (const model of models) out.log(`${model.provider} ${model.id}`);
507
+ return;
508
+ }
259
509
  const policies = policyArray(body2);
260
510
  if (options.json) {
261
- out.log(JSON.stringify({ policies }, null, 2));
511
+ out.log(JSON.stringify({ policies, models: catalogModels(body2) }, null, 2));
262
512
  return;
263
513
  }
264
514
  out.log("purpose enabled provider model max calls max input bytes max output tokens");
@@ -275,59 +525,68 @@ async function adminAi(options) {
275
525
  }
276
526
  return;
277
527
  }
278
- const purpose = requirePurpose(options.purpose);
279
- if (!options.provider?.trim()) throw new Error("--provider is required");
280
- if (!options.model?.trim()) throw new Error("--model is required");
528
+ if (options.purpose === "security") {
529
+ const currentResponse = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
530
+ const currentBody = await responseBody3(currentResponse);
531
+ if (!currentResponse.ok) throw new Error(apiError3("read current security AI policies", currentResponse.status, currentBody));
532
+ const policies = policyArray(currentBody);
533
+ const discovery = policies.find((policy2) => policy2.purpose === "security.discovery");
534
+ const validation = policies.find((policy2) => policy2.purpose === "security.validation");
535
+ if (!discovery || !validation || !Number.isSafeInteger(discovery.version) || !Number.isSafeInteger(validation.version)) {
536
+ throw new Error("platform returned invalid security AI policy revisions");
537
+ }
538
+ const shared = {
539
+ ...options.enabled === void 0 ? {} : { enabled: options.enabled },
540
+ ...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
541
+ ...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
542
+ ...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
543
+ };
544
+ const discoveryUpdate = {
545
+ ...shared,
546
+ ...options.discoveryProvider?.trim() ? { provider: options.discoveryProvider.trim() } : {},
547
+ ...options.discoveryModel?.trim() ? { model: options.discoveryModel.trim() } : {}
548
+ };
549
+ const validationUpdate = {
550
+ ...shared,
551
+ ...options.validationProvider?.trim() ? { provider: options.validationProvider.trim() } : {},
552
+ ...options.validationModel?.trim() ? { model: options.validationModel.trim() } : {}
553
+ };
554
+ if (!Object.keys(discoveryUpdate).length && !Object.keys(validationUpdate).length) {
555
+ throw new Error("security set requires a route, enabled state, or budget change");
556
+ }
557
+ const res2 = await doFetch(`${platform}/registry/platform/ai-policies/security`, {
558
+ method: "PUT",
559
+ headers,
560
+ body: JSON.stringify({
561
+ expectedVersions: { discovery: discovery.version, validation: validation.version },
562
+ discovery: discoveryUpdate,
563
+ validation: validationUpdate
564
+ })
565
+ });
566
+ const response2 = await responseBody3(res2);
567
+ if (!res2.ok) throw new Error(apiError3("update security review routes", res2.status, response2));
568
+ out.log(options.json ? JSON.stringify(response2, null, 2) : "updated security review discovery and independent validation routes atomically");
569
+ return;
570
+ }
571
+ const purpose = requireSystemAiPurpose(options.purpose);
281
572
  const body = {
282
- provider: options.provider.trim(),
283
- model: options.model.trim(),
573
+ ...options.provider?.trim() ? { provider: options.provider.trim() } : {},
574
+ ...options.model?.trim() ? { model: options.model.trim() } : {},
284
575
  ...options.enabled === void 0 ? {} : { enabled: options.enabled },
285
576
  ...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
286
577
  ...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
287
578
  ...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
288
579
  };
580
+ if (!Object.keys(body).length) throw new Error("set requires a provider, model, enabled state, or budget change");
289
581
  const res = await doFetch(`${platform}/registry/platform/ai-policies/${encodeURIComponent(purpose)}`, {
290
582
  method: "PUT",
291
583
  headers,
292
584
  body: JSON.stringify(body)
293
585
  });
294
- const response = await responseBody(res);
295
- if (!res.ok) throw new Error(apiError(`update ${purpose}`, res.status, response));
296
- const policy = isRecord(response) && isRecord(response.policy) ? response.policy : isRecord(response) ? response : {};
297
- out.log(options.json ? JSON.stringify({ policy }, null, 2) : `updated ${purpose}: ${String(policy.provider ?? body.provider)}/${String(policy.model ?? body.model)}`);
298
- }
299
- async function scopedToken(platform, scope, options, doFetch, out) {
300
- const tokenFile = options.tokenFile ?? `${process4.cwd()}/.odla/admin-token.local.json`;
301
- const cache = readJsonFile(tokenFile);
302
- const cached = cache?.platform === platform ? cache.tokens?.[scope] : void 0;
303
- if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
304
- out.log(`auth: using cached ${scope} grant (${tokenFile})`);
305
- return cached.token;
306
- }
307
- const { token, expiresAt } = await requestToken2({
308
- endpoint: platform,
309
- label: `odla CLI admin AI (${scope})`,
310
- scopes: [scope],
311
- fetch: doFetch,
312
- onCode: async ({ userCode, expiresIn }) => {
313
- const approvalUrl = handshakeUrl(platform, userCode);
314
- out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
315
- const shouldOpen = options.open === true || options.open !== false && !process4.env.CI && Boolean(process4.stdin.isTTY && process4.stdout.isTTY);
316
- if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
317
- }
318
- });
319
- const tokens = cache?.platform === platform ? { ...cache.tokens ?? {} } : {};
320
- tokens[scope] = { token, expiresAt };
321
- if (existsSync2(`${process4.cwd()}/.git`)) ensureGitignore(process4.cwd(), [tokenFile]);
322
- writePrivateJson(tokenFile, { platform, tokens });
323
- out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
324
- return token;
325
- }
326
- function requirePurpose(value) {
327
- if (!SYSTEM_AI_PURPOSES.includes(value)) {
328
- throw new Error(`purpose must be one of: ${SYSTEM_AI_PURPOSES.join(", ")}`);
329
- }
330
- return value;
586
+ const response = await responseBody3(res);
587
+ if (!res.ok) throw new Error(apiError3(`update ${purpose}`, res.status, response));
588
+ const policy = isRecord3(response) && isRecord3(response.policy) ? response.policy : isRecord3(response) ? response : {};
589
+ out.log(options.json ? JSON.stringify({ policy }, null, 2) : `updated ${purpose}: ${String(policy.provider ?? "unchanged")}/${String(policy.model ?? "unchanged")}`);
331
590
  }
332
591
  function requireProvider(value) {
333
592
  if (value !== "anthropic" && value !== "openai" && value !== "google") {
@@ -338,7 +597,7 @@ function requireProvider(value) {
338
597
  async function credentialValue(options) {
339
598
  if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
340
599
  let value;
341
- if (options.fromEnv) value = process4.env[options.fromEnv];
600
+ if (options.fromEnv) value = process5.env[options.fromEnv];
342
601
  else if (options.stdin) value = await (options.readStdin ?? readStdin)();
343
602
  else throw new Error("credential input required: use --from-env <NAME> or --stdin; values are never accepted as arguments");
344
603
  value = value?.replace(/[\r\n]+$/, "");
@@ -348,18 +607,24 @@ async function credentialValue(options) {
348
607
  }
349
608
  async function readStdin() {
350
609
  let value = "";
351
- for await (const chunk of process4.stdin) {
610
+ for await (const chunk of process5.stdin) {
352
611
  value += String(chunk);
353
612
  if (value.length > 64 * 1024) throw new Error("credential exceeds 64 KiB");
354
613
  }
355
614
  return value;
356
615
  }
357
616
  function policyArray(body) {
358
- if (isRecord(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord);
359
- if (isRecord(body) && isRecord(body.policies)) return Object.values(body.policies).filter(isRecord);
617
+ if (isRecord3(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord3);
618
+ if (isRecord3(body) && isRecord3(body.policies)) return Object.values(body.policies).filter(isRecord3);
360
619
  throw new Error("platform returned an invalid AI policy response");
361
620
  }
362
- async function responseBody(res) {
621
+ function catalogModels(body) {
622
+ if (!isRecord3(body) || !isRecord3(body.catalog) || !Array.isArray(body.catalog.models)) {
623
+ throw new Error("platform returned an invalid System AI model catalog");
624
+ }
625
+ return body.catalog.models.filter((value) => isRecord3(value) && typeof value.id === "string" && typeof value.provider === "string");
626
+ }
627
+ async function responseBody3(res) {
363
628
  const text = await res.text();
364
629
  if (!text) return {};
365
630
  try {
@@ -368,12 +633,12 @@ async function responseBody(res) {
368
633
  return { message: text.slice(0, 300) };
369
634
  }
370
635
  }
371
- function apiError(action, status, body) {
372
- const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
373
- const message = error && typeof error.message === "string" ? error.message : isRecord(body) && typeof body.message === "string" ? body.message : "request failed";
636
+ function apiError3(action, status, body) {
637
+ const error = isRecord3(body) && isRecord3(body.error) ? body.error : void 0;
638
+ const message = error && typeof error.message === "string" ? error.message : isRecord3(body) && typeof body.message === "string" ? body.message : "request failed";
374
639
  return `${action} failed (${status}): ${message}`;
375
640
  }
376
- function isRecord(value) {
641
+ function isRecord3(value) {
377
642
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
378
643
  }
379
644
 
@@ -386,7 +651,7 @@ var CAPABILITIES = {
386
651
  "push db schema/rules and configure platform AI, auth, and deployment links",
387
652
  "validate config offline and smoke-test a provisioned db environment",
388
653
  "run app-attributed hosted security discovery and independent validation without provider keys",
389
- "let admins manage system AI routes/credentials through short-lived exact-scope approval"
654
+ "let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
390
655
  ],
391
656
  agent: [
392
657
  "install and import the selected odla SDKs",
@@ -991,7 +1256,7 @@ function assertWranglerConfig(cfg) {
991
1256
  // src/provision.ts
992
1257
  import { createAppsClient, tenantIdFor as tenantIdFor2 } from "@odla-ai/apps";
993
1258
  import { DEFAULT_SECRET_NAMES, putSecret } from "@odla-ai/ai";
994
- import process5 from "process";
1259
+ import process6 from "process";
995
1260
 
996
1261
  // src/provision-credentials.ts
997
1262
  import { tenantIdFor } from "@odla-ai/apps";
@@ -1222,7 +1487,7 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
1222
1487
  out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
1223
1488
  }
1224
1489
  if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
1225
- const key = process5.env[cfg.ai.keyEnv];
1490
+ const key = process6.env[cfg.ai.keyEnv];
1226
1491
  if (key) {
1227
1492
  const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
1228
1493
  await putSecret({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
@@ -1384,10 +1649,14 @@ function printSummary(out, appId, env, run, report, output) {
1384
1649
  out.log(`security: ${appId}/${env} run=${run.runId} profile=${run.profileVersion}`);
1385
1650
  out.log(` discovery: ${run.discovery.identity.provider}/${run.discovery.identity.model}`);
1386
1651
  out.log(` validation: ${run.validation.identity.provider}/${run.validation.identity.model}`);
1387
- out.log(` coverage: ${report.coverageStatus} ${complete}/${report.coverage.length} blocked=${report.metrics.blockedCells} shallow=${report.metrics.shallowCells} unscheduled=${report.metrics.unscheduledCells}`);
1652
+ out.log(` coverage: ${report.coverageStatus} ${complete}/${report.coverage.length} blocked=${report.metrics.blockedCells} shallow=${report.metrics.shallowCells} unscheduled=${report.metrics.unscheduledCells} budget_exhausted=${report.metrics.budgetExhaustedCells}`);
1653
+ if (report.callBudget) out.log(` calls: discovery=${formatBudget(report.callBudget.discovery)} validation=${formatBudget(report.callBudget.validation)}`);
1388
1654
  out.log(` findings: confirmed=${report.metrics.confirmed} needs_reproduction=${report.metrics.needsReproduction} candidates=${report.metrics.candidates}`);
1389
1655
  out.log(` report: ${resolve5(output, "REPORT.md")}`);
1390
1656
  }
1657
+ function formatBudget(usage) {
1658
+ return usage ? `${usage.usedCalls}/${usage.maxCalls} skipped=${usage.skippedCalls}` : "caller-managed";
1659
+ }
1391
1660
 
1392
1661
  // src/skill.ts
1393
1662
  import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync3 } from "fs";
@@ -1816,9 +2085,14 @@ Usage:
1816
2085
  odla-ai doctor [--config odla.config.mjs]
1817
2086
  odla-ai capabilities [--json]
1818
2087
  odla-ai admin ai show [--platform https://odla.ai] [--json]
1819
- odla-ai admin ai set <purpose> --provider <id> --model <id> [--enabled|--no-enabled]
2088
+ odla-ai admin ai models [--provider <id>] [--json]
2089
+ odla-ai admin ai set <purpose> [--provider <id>] [--model <id>] [--enabled|--no-enabled]
2090
+ odla-ai admin ai set security --discovery-provider <id> --discovery-model <id>
2091
+ --validation-provider <id> --validation-model <id> [--enabled|--no-enabled]
1820
2092
  odla-ai admin ai credentials [--json]
1821
2093
  odla-ai admin ai credential set <provider> (--from-env <NAME>|--stdin)
2094
+ odla-ai admin ai usage [--app-id <id>] [--env <env>] [--run-id <id>] [--limit <1-500>] [--json]
2095
+ odla-ai admin ai audit [--limit <1-200>] [--json]
1822
2096
  odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
1823
2097
  odla-ai security run [target] --self --ack-redacted-source
1824
2098
  odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
@@ -1832,7 +2106,7 @@ Commands:
1832
2106
  init Create a generic odla.config.mjs plus starter schema/rules files.
1833
2107
  doctor Validate and summarize the project config without network calls.
1834
2108
  capabilities Show what the CLI automates vs agent edits and human checkpoints.
1835
- admin Manage platform-funded AI routing with a narrow admin-approved device grant.
2109
+ admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
1836
2110
  security Run hosted discovery + independent validation with app/run attribution.
1837
2111
  provision Register services, configure them, persist credentials, optionally push secrets.
1838
2112
  smoke Verify local credentials, public-config, live schema, and db aggregate.
@@ -1894,12 +2168,14 @@ async function runCli(argv = process.argv.slice(2)) {
1894
2168
  const action = parsed.positionals[2];
1895
2169
  const credentialSet = action === "credential" && parsed.positionals[3] === "set";
1896
2170
  const credentials = action === "credentials";
1897
- if (area !== "ai" || action !== "show" && action !== "set" && !credentialSet && !credentials) {
2171
+ const models = action === "models";
2172
+ const usage = action === "usage";
2173
+ const audit = action === "audit";
2174
+ if (area !== "ai" || action !== "show" && action !== "set" && !credentialSet && !credentials && !models && !usage && !audit) {
1898
2175
  throw new Error('unknown admin command. Try "odla-ai admin ai show".');
1899
2176
  }
1900
2177
  assertArgs(parsed, [
1901
2178
  "platform",
1902
- "token",
1903
2179
  "json",
1904
2180
  "open",
1905
2181
  "provider",
@@ -1909,10 +2185,18 @@ async function runCli(argv = process.argv.slice(2)) {
1909
2185
  "max-output-tokens",
1910
2186
  "max-calls-per-run",
1911
2187
  "from-env",
1912
- "stdin"
2188
+ "stdin",
2189
+ "discovery-provider",
2190
+ "discovery-model",
2191
+ "validation-provider",
2192
+ "validation-model",
2193
+ "app-id",
2194
+ "env",
2195
+ "run-id",
2196
+ "limit"
1913
2197
  ], credentialSet ? 5 : action === "set" ? 4 : 3);
1914
2198
  await adminAi({
1915
- action: credentialSet ? "credential-set" : credentials ? "credentials" : action,
2199
+ action: credentialSet ? "credential-set" : credentials ? "credentials" : models ? "models" : usage ? "usage" : audit ? "audit" : action,
1916
2200
  purpose: action === "set" ? parsed.positionals[3] : void 0,
1917
2201
  provider: stringOpt(parsed.options.provider),
1918
2202
  model: stringOpt(parsed.options.model),
@@ -1921,12 +2205,19 @@ async function runCli(argv = process.argv.slice(2)) {
1921
2205
  maxOutputTokens: numberOpt(parsed.options["max-output-tokens"], "--max-output-tokens"),
1922
2206
  maxCallsPerRun: numberOpt(parsed.options["max-calls-per-run"], "--max-calls-per-run"),
1923
2207
  platform: stringOpt(parsed.options.platform),
1924
- token: stringOpt(parsed.options.token),
1925
2208
  json: parsed.options.json === true,
1926
2209
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
1927
2210
  credentialProvider: credentialSet ? parsed.positionals[4] : void 0,
1928
2211
  fromEnv: stringOpt(parsed.options["from-env"]),
1929
- stdin: parsed.options.stdin === true
2212
+ stdin: parsed.options.stdin === true,
2213
+ discoveryProvider: stringOpt(parsed.options["discovery-provider"]),
2214
+ discoveryModel: stringOpt(parsed.options["discovery-model"]),
2215
+ validationProvider: stringOpt(parsed.options["validation-provider"]),
2216
+ validationModel: stringOpt(parsed.options["validation-model"]),
2217
+ appId: stringOpt(parsed.options["app-id"]),
2218
+ env: stringOpt(parsed.options.env),
2219
+ runId: stringOpt(parsed.options["run-id"]),
2220
+ limit: numberOpt(parsed.options.limit, "--limit")
1930
2221
  });
1931
2222
  return;
1932
2223
  }
@@ -1968,6 +2259,9 @@ async function runCli(argv = process.argv.slice(2)) {
1968
2259
  return getScopedPlatformToken({ platform: request.platform, scope: request.scope, open });
1969
2260
  }
1970
2261
  const cfg = await loadProjectConfig(configPath);
2262
+ if (platformAudience(cfg.platformUrl) !== platformAudience(request.platform)) {
2263
+ throw new Error("--platform cannot reuse a project developer token from another platform; update odla.config.mjs and authenticate there");
2264
+ }
1971
2265
  return getDeveloperToken(cfg, { configPath, open }, fetch, console);
1972
2266
  }
1973
2267
  });
@@ -2088,8 +2382,8 @@ function harnessOption(value, flag) {
2088
2382
  }
2089
2383
 
2090
2384
  export {
2091
- SYSTEM_AI_PURPOSES,
2092
2385
  getScopedPlatformToken,
2386
+ SYSTEM_AI_PURPOSES,
2093
2387
  adminAi,
2094
2388
  CAPABILITIES,
2095
2389
  printCapabilities,
@@ -2104,4 +2398,4 @@ export {
2104
2398
  smoke,
2105
2399
  runCli
2106
2400
  };
2107
- //# sourceMappingURL=chunk-OERLHVLH.js.map
2401
+ //# sourceMappingURL=chunk-643B2AKG.js.map