@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.
- package/README.md +42 -9
- package/dist/bin.cjs +386 -92
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-OERLHVLH.js → chunk-643B2AKG.js} +411 -117
- package/dist/chunk-643B2AKG.js.map +1 -0
- package/dist/index.cjs +386 -92
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +36 -20
- package/dist/index.d.ts +36 -20
- package/dist/index.js +1 -1
- package/llms.txt +79 -30
- package/package.json +4 -4
- package/skills/odla/references/build.md +6 -4
- package/skills/odla/references/sdks.md +2 -2
- package/skills/odla-migrate/references/phase-2-db.md +2 -2
- package/skills/odla-migrate/references/phase-5-cutover.md +2 -2
- package/skills/odla-migrate/references/secrets-map.md +1 -1
- package/dist/chunk-OERLHVLH.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -57,9 +57,11 @@ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
|
57
57
|
var import_security2 = require("@odla-ai/security");
|
|
58
58
|
|
|
59
59
|
// src/admin-ai.ts
|
|
60
|
-
var
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
var import_node_process4 = __toESM(require("process"), 1);
|
|
61
|
+
|
|
62
|
+
// src/token.ts
|
|
63
|
+
var import_db = require("@odla-ai/db");
|
|
64
|
+
var import_node_process2 = __toESM(require("process"), 1);
|
|
63
65
|
|
|
64
66
|
// src/open.ts
|
|
65
67
|
var import_node_child_process = require("child_process");
|
|
@@ -85,10 +87,6 @@ function openerFor(platform) {
|
|
|
85
87
|
return { cmd: "xdg-open", args: [] };
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
// src/token.ts
|
|
89
|
-
var import_db = require("@odla-ai/db");
|
|
90
|
-
var import_node_process2 = __toESM(require("process"), 1);
|
|
91
|
-
|
|
92
90
|
// src/local.ts
|
|
93
91
|
var import_node_fs = require("fs");
|
|
94
92
|
var import_node_path = require("path");
|
|
@@ -218,9 +216,18 @@ function displayPath(path, rootDir = process.cwd()) {
|
|
|
218
216
|
// src/token.ts
|
|
219
217
|
async function getDeveloperToken(cfg, options, doFetch, out) {
|
|
220
218
|
if (options.token) return options.token;
|
|
221
|
-
|
|
219
|
+
const audience = platformAudience(cfg.platformUrl);
|
|
220
|
+
if (import_node_process2.default.env.ODLA_DEV_TOKEN) {
|
|
221
|
+
const declared = import_node_process2.default.env.ODLA_DEV_TOKEN_AUDIENCE;
|
|
222
|
+
if (declared) {
|
|
223
|
+
if (platformAudience(declared) !== audience) throw new Error("ODLA_DEV_TOKEN_AUDIENCE does not match the configured platform");
|
|
224
|
+
} else if (audience !== "https://odla.ai") {
|
|
225
|
+
throw new Error("ODLA_DEV_TOKEN_AUDIENCE is required for a non-default platform");
|
|
226
|
+
}
|
|
227
|
+
return import_node_process2.default.env.ODLA_DEV_TOKEN;
|
|
228
|
+
}
|
|
222
229
|
const cached = readJsonFile(cfg.local.tokenFile);
|
|
223
|
-
if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
230
|
+
if (cached?.token && cached.platform === audience && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
224
231
|
out.log(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
225
232
|
return cached.token;
|
|
226
233
|
}
|
|
@@ -246,7 +253,7 @@ async function getDeveloperToken(cfg, options, doFetch, out) {
|
|
|
246
253
|
out.log("");
|
|
247
254
|
}
|
|
248
255
|
});
|
|
249
|
-
writePrivateJson(cfg.local.tokenFile, { token, expiresAt });
|
|
256
|
+
writePrivateJson(cfg.local.tokenFile, { platform: audience, token, expiresAt });
|
|
250
257
|
out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
251
258
|
return token;
|
|
252
259
|
}
|
|
@@ -263,35 +270,271 @@ function handshakeUrl(platformUrl, userCode) {
|
|
|
263
270
|
url.searchParams.set("code", userCode);
|
|
264
271
|
return url.toString();
|
|
265
272
|
}
|
|
273
|
+
function platformAudience(value) {
|
|
274
|
+
let url;
|
|
275
|
+
try {
|
|
276
|
+
url = new URL(value);
|
|
277
|
+
} catch {
|
|
278
|
+
throw new Error("platform must be an absolute URL");
|
|
279
|
+
}
|
|
280
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
281
|
+
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
|
|
282
|
+
throw new Error("platform credentials require HTTPS except for loopback development");
|
|
283
|
+
}
|
|
284
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
285
|
+
throw new Error("platform URL must not contain credentials, query, or fragment");
|
|
286
|
+
}
|
|
287
|
+
return url.origin;
|
|
288
|
+
}
|
|
266
289
|
|
|
267
|
-
// src/admin-ai.ts
|
|
268
|
-
var
|
|
290
|
+
// src/admin-ai-auth.ts
|
|
291
|
+
var import_node_fs2 = require("fs");
|
|
292
|
+
var import_node_process3 = __toESM(require("process"), 1);
|
|
293
|
+
var import_db2 = require("@odla-ai/db");
|
|
269
294
|
async function getScopedPlatformToken(options) {
|
|
295
|
+
return resolveAdminPlatformToken(options);
|
|
296
|
+
}
|
|
297
|
+
async function resolveAdminPlatformToken(options) {
|
|
298
|
+
const audience = platformAudience(options.platform);
|
|
299
|
+
if (options.token) return options.token;
|
|
270
300
|
const fromEnv = import_node_process3.default.env.ODLA_ADMIN_TOKEN;
|
|
271
|
-
if (fromEnv) return fromEnv;
|
|
301
|
+
if (fromEnv) return audienceBoundEnvToken(fromEnv, audience);
|
|
272
302
|
return scopedToken(
|
|
273
|
-
|
|
303
|
+
audience,
|
|
274
304
|
options.scope,
|
|
275
|
-
|
|
305
|
+
options,
|
|
276
306
|
options.fetch ?? fetch,
|
|
277
307
|
options.stdout ?? console
|
|
278
308
|
);
|
|
279
309
|
}
|
|
310
|
+
function audienceBoundEnvToken(token, platform) {
|
|
311
|
+
const audience = platformAudience(platform);
|
|
312
|
+
const declared = import_node_process3.default.env.ODLA_ADMIN_TOKEN_AUDIENCE;
|
|
313
|
+
if (declared) {
|
|
314
|
+
if (platformAudience(declared) !== audience) throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE does not match the configured platform");
|
|
315
|
+
} else if (audience !== "https://odla.ai") {
|
|
316
|
+
throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE is required for a non-default platform");
|
|
317
|
+
}
|
|
318
|
+
return token;
|
|
319
|
+
}
|
|
320
|
+
async function scopedToken(platform, scope, options, doFetch, out) {
|
|
321
|
+
const audience = platformAudience(platform);
|
|
322
|
+
const tokenFile = options.tokenFile ?? `${import_node_process3.default.cwd()}/.odla/admin-token.local.json`;
|
|
323
|
+
const cache = readJsonFile(tokenFile);
|
|
324
|
+
const cached = cache?.platform === audience ? cache.tokens?.[scope] : void 0;
|
|
325
|
+
if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
326
|
+
out.log(`auth: using cached ${scope} grant (${tokenFile})`);
|
|
327
|
+
return cached.token;
|
|
328
|
+
}
|
|
329
|
+
const { token, expiresAt } = await (0, import_db2.requestToken)({
|
|
330
|
+
endpoint: audience,
|
|
331
|
+
label: `odla CLI admin AI (${scope})`,
|
|
332
|
+
scopes: [scope],
|
|
333
|
+
fetch: doFetch,
|
|
334
|
+
onCode: async ({ userCode, expiresIn }) => {
|
|
335
|
+
const approvalUrl = handshakeUrl(audience, userCode);
|
|
336
|
+
out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
337
|
+
const shouldOpen = options.open === true || options.open !== false && !import_node_process3.default.env.CI && Boolean(import_node_process3.default.stdin.isTTY && import_node_process3.default.stdout.isTTY);
|
|
338
|
+
if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
|
|
342
|
+
tokens[scope] = { token, expiresAt };
|
|
343
|
+
if ((0, import_node_fs2.existsSync)(`${import_node_process3.default.cwd()}/.git`)) ensureGitignore(import_node_process3.default.cwd(), [tokenFile]);
|
|
344
|
+
writePrivateJson(tokenFile, { platform: audience, tokens });
|
|
345
|
+
out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
|
|
346
|
+
return token;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/admin-ai-audit.ts
|
|
350
|
+
function adminAiAuditQuery(filters) {
|
|
351
|
+
if (filters.limit === void 0) return "";
|
|
352
|
+
if (!Number.isSafeInteger(filters.limit) || filters.limit < 1 || filters.limit > 200) {
|
|
353
|
+
throw new Error("audit limit must be an integer from 1 to 200");
|
|
354
|
+
}
|
|
355
|
+
return `?limit=${filters.limit}`;
|
|
356
|
+
}
|
|
357
|
+
async function readAdminAiAudit(request) {
|
|
358
|
+
const response = await request.fetch(`${request.platform}/registry/platform/ai-audit${request.query}`, {
|
|
359
|
+
headers: request.headers
|
|
360
|
+
});
|
|
361
|
+
const body = await responseBody(response);
|
|
362
|
+
if (!response.ok) throw new Error(apiError(response.status, body));
|
|
363
|
+
if (request.json) {
|
|
364
|
+
request.stdout.log(JSON.stringify(body, null, 2));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const events = isRecord(body) && Array.isArray(body.events) ? body.events.filter(isRecord) : [];
|
|
368
|
+
request.stdout.log("when change target before -> after actor");
|
|
369
|
+
for (const event of events) {
|
|
370
|
+
const before = isRecord(event.oldPolicy) ? event.oldPolicy : void 0;
|
|
371
|
+
const after = isRecord(event.newPolicy) ? event.newPolicy : void 0;
|
|
372
|
+
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";
|
|
373
|
+
request.stdout.log([
|
|
374
|
+
timestamp(event.createdAt),
|
|
375
|
+
String(event.changeKind ?? ""),
|
|
376
|
+
String(event.purpose ?? event.provider ?? ""),
|
|
377
|
+
route,
|
|
378
|
+
`${String(event.actorType ?? "")}:${String(event.actorId ?? "")}`
|
|
379
|
+
].join(" "));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async function responseBody(response) {
|
|
383
|
+
const text = await response.text();
|
|
384
|
+
if (!text) return {};
|
|
385
|
+
try {
|
|
386
|
+
return JSON.parse(text);
|
|
387
|
+
} catch {
|
|
388
|
+
return { message: text.slice(0, 300) };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function apiError(status, body) {
|
|
392
|
+
const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
|
|
393
|
+
const message = error && typeof error.message === "string" ? error.message : isRecord(body) && typeof body.message === "string" ? body.message : "request failed";
|
|
394
|
+
return `read System AI admin changes failed (${status}): ${message}`;
|
|
395
|
+
}
|
|
396
|
+
function timestamp(value) {
|
|
397
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return String(value ?? "");
|
|
398
|
+
const date = new Date(value);
|
|
399
|
+
return Number.isFinite(date.valueOf()) ? date.toISOString() : "";
|
|
400
|
+
}
|
|
401
|
+
function isRecord(value) {
|
|
402
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/admin-ai-policy.ts
|
|
406
|
+
var SYSTEM_AI_PURPOSES = ["o11y.triage", "security.discovery", "security.validation"];
|
|
407
|
+
function requireSystemAiPurpose(value) {
|
|
408
|
+
if (!SYSTEM_AI_PURPOSES.includes(value)) {
|
|
409
|
+
throw new Error(`purpose must be one of: ${SYSTEM_AI_PURPOSES.join(", ")}`);
|
|
410
|
+
}
|
|
411
|
+
return value;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// src/admin-ai-usage.ts
|
|
415
|
+
function adminAiUsageQuery(filters) {
|
|
416
|
+
const params = new URLSearchParams();
|
|
417
|
+
if (filters.appId?.trim()) params.set("appId", filters.appId.trim());
|
|
418
|
+
if (filters.env?.trim()) params.set("env", filters.env.trim());
|
|
419
|
+
if (filters.runId?.trim()) params.set("runId", filters.runId.trim());
|
|
420
|
+
if (filters.limit !== void 0) params.set("limit", String(usageLimit(filters.limit)));
|
|
421
|
+
const query = params.toString();
|
|
422
|
+
return query ? `?${query}` : "";
|
|
423
|
+
}
|
|
424
|
+
async function readAdminAiUsage(request) {
|
|
425
|
+
const res = await request.fetch(`${request.platform}/registry/platform/ai-usage${request.query}`, {
|
|
426
|
+
headers: request.headers
|
|
427
|
+
});
|
|
428
|
+
const body = await responseBody2(res);
|
|
429
|
+
if (!res.ok) throw new Error(apiError2("read platform AI usage", res.status, body));
|
|
430
|
+
if (request.json) request.stdout.log(JSON.stringify(body, null, 2));
|
|
431
|
+
else printUsage(body, request.stdout);
|
|
432
|
+
}
|
|
433
|
+
function usageLimit(value) {
|
|
434
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 500) {
|
|
435
|
+
throw new Error("usage limit must be an integer from 1 to 500");
|
|
436
|
+
}
|
|
437
|
+
return value;
|
|
438
|
+
}
|
|
439
|
+
function printUsage(body, out) {
|
|
440
|
+
const aggregates = isRecord2(body) && isRecord2(body.aggregates) && Array.isArray(body.aggregates.byStatus) ? body.aggregates.byStatus.filter(isRecord2) : [];
|
|
441
|
+
out.log(`All retained hosted-security status totals (up to ${String(isRecord2(body) ? body.retentionDays ?? 90 : 90)} days; o11y triage excluded)
|
|
442
|
+
status calls input tokens output tokens cost unpriced calls`);
|
|
443
|
+
for (const row of aggregates) {
|
|
444
|
+
out.log([
|
|
445
|
+
String(row.status ?? ""),
|
|
446
|
+
String(row.calls ?? ""),
|
|
447
|
+
String(row.input_tokens ?? ""),
|
|
448
|
+
String(row.output_tokens ?? ""),
|
|
449
|
+
formatMicrousd(row.cost_microusd),
|
|
450
|
+
String(row.unpriced_calls ?? "")
|
|
451
|
+
].join(" "));
|
|
452
|
+
}
|
|
453
|
+
const events = isRecord2(body) && Array.isArray(body.events) ? body.events.filter(isRecord2) : [];
|
|
454
|
+
out.log(`Latest ${String(isRecord2(body) ? body.eventLimit ?? events.length : events.length)} hosted-security events
|
|
455
|
+
when app/env run actor purpose/role route / policy tokens cost status`);
|
|
456
|
+
for (const event of events) {
|
|
457
|
+
const input = numeric(event.input_tokens);
|
|
458
|
+
const output = numeric(event.output_tokens);
|
|
459
|
+
const priced = event.priced === true || event.priced === 1;
|
|
460
|
+
out.log([
|
|
461
|
+
timestamp2(event.created_at),
|
|
462
|
+
`${String(event.app_id ?? "")}/${String(event.env ?? "")}`,
|
|
463
|
+
String(event.run_id ?? ""),
|
|
464
|
+
`${String(event.actor_type ?? "")}:${String(event.actor_id ?? "")}`,
|
|
465
|
+
`${String(event.purpose ?? "")}/${String(event.role ?? "")}`,
|
|
466
|
+
`${String(event.provider ?? "")}/${String(event.model ?? "")}@v${String(event.policy_version ?? "unknown")}`,
|
|
467
|
+
String(input + output),
|
|
468
|
+
priced ? formatMicrousd(event.cost_microusd) : "unpriced",
|
|
469
|
+
`${String(event.status ?? "")}${event.error_code ? `:${String(event.error_code)}` : ""}`
|
|
470
|
+
].join(" "));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function numeric(value) {
|
|
474
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
475
|
+
}
|
|
476
|
+
function formatMicrousd(value) {
|
|
477
|
+
return typeof value === "number" && Number.isFinite(value) ? `$${(value / 1e6).toFixed(6)}` : "unpriced";
|
|
478
|
+
}
|
|
479
|
+
function timestamp2(value) {
|
|
480
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return String(value ?? "");
|
|
481
|
+
const date = new Date(value);
|
|
482
|
+
return Number.isFinite(date.valueOf()) ? date.toISOString() : "";
|
|
483
|
+
}
|
|
484
|
+
async function responseBody2(res) {
|
|
485
|
+
const text = await res.text();
|
|
486
|
+
if (!text) return {};
|
|
487
|
+
try {
|
|
488
|
+
return JSON.parse(text);
|
|
489
|
+
} catch {
|
|
490
|
+
return { message: text.slice(0, 300) };
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function apiError2(action, status, body) {
|
|
494
|
+
const error = isRecord2(body) && isRecord2(body.error) ? body.error : void 0;
|
|
495
|
+
const message = error && typeof error.message === "string" ? error.message : isRecord2(body) && typeof body.message === "string" ? body.message : "request failed";
|
|
496
|
+
return `${action} failed (${status}): ${message}`;
|
|
497
|
+
}
|
|
498
|
+
function isRecord2(value) {
|
|
499
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// src/admin-ai.ts
|
|
280
503
|
async function adminAi(options) {
|
|
281
|
-
const platform = (options.platform ??
|
|
504
|
+
const platform = platformAudience(options.platform ?? import_node_process4.default.env.ODLA_PLATFORM ?? "https://odla.ai");
|
|
282
505
|
const doFetch = options.fetch ?? fetch;
|
|
283
506
|
const out = options.stdout ?? console;
|
|
284
|
-
const
|
|
285
|
-
const
|
|
507
|
+
const usageQuery = options.action === "usage" ? adminAiUsageQuery(options) : void 0;
|
|
508
|
+
const auditQuery = options.action === "audit" ? adminAiAuditQuery(options) : void 0;
|
|
509
|
+
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";
|
|
510
|
+
const token = await resolveAdminPlatformToken({
|
|
511
|
+
platform,
|
|
512
|
+
scope,
|
|
513
|
+
token: options.token,
|
|
514
|
+
open: options.open,
|
|
515
|
+
fetch: doFetch,
|
|
516
|
+
stdout: out,
|
|
517
|
+
openApprovalUrl: options.openApprovalUrl,
|
|
518
|
+
tokenFile: options.tokenFile
|
|
519
|
+
});
|
|
286
520
|
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
521
|
+
if (options.action === "usage") {
|
|
522
|
+
await readAdminAiUsage({ platform, query: usageQuery ?? "", headers, json: options.json, fetch: doFetch, stdout: out });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (options.action === "audit") {
|
|
526
|
+
await readAdminAiAudit({ platform, query: auditQuery ?? "", headers, json: options.json, fetch: doFetch, stdout: out });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
287
529
|
if (options.action === "credentials") {
|
|
288
530
|
const res2 = await doFetch(`${platform}/registry/platform/ai-credentials`, { headers });
|
|
289
|
-
const body2 = await
|
|
290
|
-
if (!res2.ok) throw new Error(
|
|
291
|
-
const credentials =
|
|
531
|
+
const body2 = await responseBody3(res2);
|
|
532
|
+
if (!res2.ok) throw new Error(apiError3("read platform AI credential status", res2.status, body2));
|
|
533
|
+
const credentials = isRecord3(body2) && isRecord3(body2.credentials) ? body2.credentials : body2;
|
|
292
534
|
if (options.json) out.log(JSON.stringify({ credentials }, null, 2));
|
|
293
|
-
else for (const [provider, status] of Object.entries(
|
|
294
|
-
|
|
535
|
+
else for (const [provider, status] of Object.entries(isRecord3(credentials) ? credentials : {})) {
|
|
536
|
+
const label = isRecord3(status) && status.set === true ? status.readable === true ? "vault readable" : "stored; vault unreadable" : "not set";
|
|
537
|
+
out.log(`${provider} ${label}`);
|
|
295
538
|
}
|
|
296
539
|
return;
|
|
297
540
|
}
|
|
@@ -303,18 +546,25 @@ async function adminAi(options) {
|
|
|
303
546
|
headers,
|
|
304
547
|
body: JSON.stringify({ value })
|
|
305
548
|
});
|
|
306
|
-
const body2 = await
|
|
307
|
-
if (!res2.ok) throw new Error(
|
|
549
|
+
const body2 = await responseBody3(res2);
|
|
550
|
+
if (!res2.ok) throw new Error(apiError3(`store ${provider} platform credential`, res2.status, body2));
|
|
308
551
|
out.log(`stored ${provider} platform credential (write-only; value was not returned)`);
|
|
309
552
|
return;
|
|
310
553
|
}
|
|
311
|
-
if (options.action === "show") {
|
|
554
|
+
if (options.action === "show" || options.action === "models") {
|
|
312
555
|
const res2 = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
|
|
313
|
-
const body2 = await
|
|
314
|
-
if (!res2.ok) throw new Error(
|
|
556
|
+
const body2 = await responseBody3(res2);
|
|
557
|
+
if (!res2.ok) throw new Error(apiError3("read platform AI policies", res2.status, body2));
|
|
558
|
+
if (options.action === "models") {
|
|
559
|
+
const models = catalogModels(body2).filter((model) => !options.provider || model.provider === options.provider);
|
|
560
|
+
if (!models.length) throw new Error(options.provider ? `no System AI models found for provider ${options.provider}` : "platform returned no System AI models");
|
|
561
|
+
if (options.json) out.log(JSON.stringify({ models }, null, 2));
|
|
562
|
+
else for (const model of models) out.log(`${model.provider} ${model.id}`);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
315
565
|
const policies = policyArray(body2);
|
|
316
566
|
if (options.json) {
|
|
317
|
-
out.log(JSON.stringify({ policies }, null, 2));
|
|
567
|
+
out.log(JSON.stringify({ policies, models: catalogModels(body2) }, null, 2));
|
|
318
568
|
return;
|
|
319
569
|
}
|
|
320
570
|
out.log("purpose enabled provider model max calls max input bytes max output tokens");
|
|
@@ -331,59 +581,68 @@ async function adminAi(options) {
|
|
|
331
581
|
}
|
|
332
582
|
return;
|
|
333
583
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
584
|
+
if (options.purpose === "security") {
|
|
585
|
+
const currentResponse = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
|
|
586
|
+
const currentBody = await responseBody3(currentResponse);
|
|
587
|
+
if (!currentResponse.ok) throw new Error(apiError3("read current security AI policies", currentResponse.status, currentBody));
|
|
588
|
+
const policies = policyArray(currentBody);
|
|
589
|
+
const discovery = policies.find((policy2) => policy2.purpose === "security.discovery");
|
|
590
|
+
const validation = policies.find((policy2) => policy2.purpose === "security.validation");
|
|
591
|
+
if (!discovery || !validation || !Number.isSafeInteger(discovery.version) || !Number.isSafeInteger(validation.version)) {
|
|
592
|
+
throw new Error("platform returned invalid security AI policy revisions");
|
|
593
|
+
}
|
|
594
|
+
const shared = {
|
|
595
|
+
...options.enabled === void 0 ? {} : { enabled: options.enabled },
|
|
596
|
+
...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
|
|
597
|
+
...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
|
|
598
|
+
...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
|
|
599
|
+
};
|
|
600
|
+
const discoveryUpdate = {
|
|
601
|
+
...shared,
|
|
602
|
+
...options.discoveryProvider?.trim() ? { provider: options.discoveryProvider.trim() } : {},
|
|
603
|
+
...options.discoveryModel?.trim() ? { model: options.discoveryModel.trim() } : {}
|
|
604
|
+
};
|
|
605
|
+
const validationUpdate = {
|
|
606
|
+
...shared,
|
|
607
|
+
...options.validationProvider?.trim() ? { provider: options.validationProvider.trim() } : {},
|
|
608
|
+
...options.validationModel?.trim() ? { model: options.validationModel.trim() } : {}
|
|
609
|
+
};
|
|
610
|
+
if (!Object.keys(discoveryUpdate).length && !Object.keys(validationUpdate).length) {
|
|
611
|
+
throw new Error("security set requires a route, enabled state, or budget change");
|
|
612
|
+
}
|
|
613
|
+
const res2 = await doFetch(`${platform}/registry/platform/ai-policies/security`, {
|
|
614
|
+
method: "PUT",
|
|
615
|
+
headers,
|
|
616
|
+
body: JSON.stringify({
|
|
617
|
+
expectedVersions: { discovery: discovery.version, validation: validation.version },
|
|
618
|
+
discovery: discoveryUpdate,
|
|
619
|
+
validation: validationUpdate
|
|
620
|
+
})
|
|
621
|
+
});
|
|
622
|
+
const response2 = await responseBody3(res2);
|
|
623
|
+
if (!res2.ok) throw new Error(apiError3("update security review routes", res2.status, response2));
|
|
624
|
+
out.log(options.json ? JSON.stringify(response2, null, 2) : "updated security review discovery and independent validation routes atomically");
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const purpose = requireSystemAiPurpose(options.purpose);
|
|
337
628
|
const body = {
|
|
338
|
-
provider: options.provider.trim(),
|
|
339
|
-
model: options.model.trim(),
|
|
629
|
+
...options.provider?.trim() ? { provider: options.provider.trim() } : {},
|
|
630
|
+
...options.model?.trim() ? { model: options.model.trim() } : {},
|
|
340
631
|
...options.enabled === void 0 ? {} : { enabled: options.enabled },
|
|
341
632
|
...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
|
|
342
633
|
...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
|
|
343
634
|
...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
|
|
344
635
|
};
|
|
636
|
+
if (!Object.keys(body).length) throw new Error("set requires a provider, model, enabled state, or budget change");
|
|
345
637
|
const res = await doFetch(`${platform}/registry/platform/ai-policies/${encodeURIComponent(purpose)}`, {
|
|
346
638
|
method: "PUT",
|
|
347
639
|
headers,
|
|
348
640
|
body: JSON.stringify(body)
|
|
349
641
|
});
|
|
350
|
-
const response = await
|
|
351
|
-
if (!res.ok) throw new Error(
|
|
352
|
-
const policy =
|
|
353
|
-
out.log(options.json ? JSON.stringify({ policy }, null, 2) : `updated ${purpose}: ${String(policy.provider ??
|
|
354
|
-
}
|
|
355
|
-
async function scopedToken(platform, scope, options, doFetch, out) {
|
|
356
|
-
const tokenFile = options.tokenFile ?? `${import_node_process3.default.cwd()}/.odla/admin-token.local.json`;
|
|
357
|
-
const cache = readJsonFile(tokenFile);
|
|
358
|
-
const cached = cache?.platform === platform ? cache.tokens?.[scope] : void 0;
|
|
359
|
-
if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
360
|
-
out.log(`auth: using cached ${scope} grant (${tokenFile})`);
|
|
361
|
-
return cached.token;
|
|
362
|
-
}
|
|
363
|
-
const { token, expiresAt } = await (0, import_db2.requestToken)({
|
|
364
|
-
endpoint: platform,
|
|
365
|
-
label: `odla CLI admin AI (${scope})`,
|
|
366
|
-
scopes: [scope],
|
|
367
|
-
fetch: doFetch,
|
|
368
|
-
onCode: async ({ userCode, expiresIn }) => {
|
|
369
|
-
const approvalUrl = handshakeUrl(platform, userCode);
|
|
370
|
-
out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
371
|
-
const shouldOpen = options.open === true || options.open !== false && !import_node_process3.default.env.CI && Boolean(import_node_process3.default.stdin.isTTY && import_node_process3.default.stdout.isTTY);
|
|
372
|
-
if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
const tokens = cache?.platform === platform ? { ...cache.tokens ?? {} } : {};
|
|
376
|
-
tokens[scope] = { token, expiresAt };
|
|
377
|
-
if ((0, import_node_fs2.existsSync)(`${import_node_process3.default.cwd()}/.git`)) ensureGitignore(import_node_process3.default.cwd(), [tokenFile]);
|
|
378
|
-
writePrivateJson(tokenFile, { platform, tokens });
|
|
379
|
-
out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
|
|
380
|
-
return token;
|
|
381
|
-
}
|
|
382
|
-
function requirePurpose(value) {
|
|
383
|
-
if (!SYSTEM_AI_PURPOSES.includes(value)) {
|
|
384
|
-
throw new Error(`purpose must be one of: ${SYSTEM_AI_PURPOSES.join(", ")}`);
|
|
385
|
-
}
|
|
386
|
-
return value;
|
|
642
|
+
const response = await responseBody3(res);
|
|
643
|
+
if (!res.ok) throw new Error(apiError3(`update ${purpose}`, res.status, response));
|
|
644
|
+
const policy = isRecord3(response) && isRecord3(response.policy) ? response.policy : isRecord3(response) ? response : {};
|
|
645
|
+
out.log(options.json ? JSON.stringify({ policy }, null, 2) : `updated ${purpose}: ${String(policy.provider ?? "unchanged")}/${String(policy.model ?? "unchanged")}`);
|
|
387
646
|
}
|
|
388
647
|
function requireProvider(value) {
|
|
389
648
|
if (value !== "anthropic" && value !== "openai" && value !== "google") {
|
|
@@ -394,7 +653,7 @@ function requireProvider(value) {
|
|
|
394
653
|
async function credentialValue(options) {
|
|
395
654
|
if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
|
|
396
655
|
let value;
|
|
397
|
-
if (options.fromEnv) value =
|
|
656
|
+
if (options.fromEnv) value = import_node_process4.default.env[options.fromEnv];
|
|
398
657
|
else if (options.stdin) value = await (options.readStdin ?? readStdin)();
|
|
399
658
|
else throw new Error("credential input required: use --from-env <NAME> or --stdin; values are never accepted as arguments");
|
|
400
659
|
value = value?.replace(/[\r\n]+$/, "");
|
|
@@ -404,18 +663,24 @@ async function credentialValue(options) {
|
|
|
404
663
|
}
|
|
405
664
|
async function readStdin() {
|
|
406
665
|
let value = "";
|
|
407
|
-
for await (const chunk of
|
|
666
|
+
for await (const chunk of import_node_process4.default.stdin) {
|
|
408
667
|
value += String(chunk);
|
|
409
668
|
if (value.length > 64 * 1024) throw new Error("credential exceeds 64 KiB");
|
|
410
669
|
}
|
|
411
670
|
return value;
|
|
412
671
|
}
|
|
413
672
|
function policyArray(body) {
|
|
414
|
-
if (
|
|
415
|
-
if (
|
|
673
|
+
if (isRecord3(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord3);
|
|
674
|
+
if (isRecord3(body) && isRecord3(body.policies)) return Object.values(body.policies).filter(isRecord3);
|
|
416
675
|
throw new Error("platform returned an invalid AI policy response");
|
|
417
676
|
}
|
|
418
|
-
|
|
677
|
+
function catalogModels(body) {
|
|
678
|
+
if (!isRecord3(body) || !isRecord3(body.catalog) || !Array.isArray(body.catalog.models)) {
|
|
679
|
+
throw new Error("platform returned an invalid System AI model catalog");
|
|
680
|
+
}
|
|
681
|
+
return body.catalog.models.filter((value) => isRecord3(value) && typeof value.id === "string" && typeof value.provider === "string");
|
|
682
|
+
}
|
|
683
|
+
async function responseBody3(res) {
|
|
419
684
|
const text = await res.text();
|
|
420
685
|
if (!text) return {};
|
|
421
686
|
try {
|
|
@@ -424,12 +689,12 @@ async function responseBody(res) {
|
|
|
424
689
|
return { message: text.slice(0, 300) };
|
|
425
690
|
}
|
|
426
691
|
}
|
|
427
|
-
function
|
|
428
|
-
const error =
|
|
429
|
-
const message = error && typeof error.message === "string" ? error.message :
|
|
692
|
+
function apiError3(action, status, body) {
|
|
693
|
+
const error = isRecord3(body) && isRecord3(body.error) ? body.error : void 0;
|
|
694
|
+
const message = error && typeof error.message === "string" ? error.message : isRecord3(body) && typeof body.message === "string" ? body.message : "request failed";
|
|
430
695
|
return `${action} failed (${status}): ${message}`;
|
|
431
696
|
}
|
|
432
|
-
function
|
|
697
|
+
function isRecord3(value) {
|
|
433
698
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
434
699
|
}
|
|
435
700
|
|
|
@@ -517,7 +782,7 @@ var CAPABILITIES = {
|
|
|
517
782
|
"push db schema/rules and configure platform AI, auth, and deployment links",
|
|
518
783
|
"validate config offline and smoke-test a provisioned db environment",
|
|
519
784
|
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
520
|
-
"let admins manage system AI
|
|
785
|
+
"let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
|
|
521
786
|
],
|
|
522
787
|
agent: [
|
|
523
788
|
"install and import the selected odla SDKs",
|
|
@@ -1049,7 +1314,7 @@ function relativeDisplay(path, rootDir) {
|
|
|
1049
1314
|
// src/provision.ts
|
|
1050
1315
|
var import_apps2 = require("@odla-ai/apps");
|
|
1051
1316
|
var import_ai = require("@odla-ai/ai");
|
|
1052
|
-
var
|
|
1317
|
+
var import_node_process5 = __toESM(require("process"), 1);
|
|
1053
1318
|
|
|
1054
1319
|
// src/provision-credentials.ts
|
|
1055
1320
|
var import_apps = require("@odla-ai/apps");
|
|
@@ -1353,7 +1618,7 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
1353
1618
|
out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
|
|
1354
1619
|
}
|
|
1355
1620
|
if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
1356
|
-
const key =
|
|
1621
|
+
const key = import_node_process5.default.env[cfg.ai.keyEnv];
|
|
1357
1622
|
if (key) {
|
|
1358
1623
|
const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
|
|
1359
1624
|
await (0, import_ai.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
|
|
@@ -1508,10 +1773,14 @@ function printSummary(out, appId, env, run, report, output) {
|
|
|
1508
1773
|
out.log(`security: ${appId}/${env} run=${run.runId} profile=${run.profileVersion}`);
|
|
1509
1774
|
out.log(` discovery: ${run.discovery.identity.provider}/${run.discovery.identity.model}`);
|
|
1510
1775
|
out.log(` validation: ${run.validation.identity.provider}/${run.validation.identity.model}`);
|
|
1511
|
-
out.log(` coverage: ${report.coverageStatus} ${complete}/${report.coverage.length} blocked=${report.metrics.blockedCells} shallow=${report.metrics.shallowCells} unscheduled=${report.metrics.unscheduledCells}`);
|
|
1776
|
+
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}`);
|
|
1777
|
+
if (report.callBudget) out.log(` calls: discovery=${formatBudget(report.callBudget.discovery)} validation=${formatBudget(report.callBudget.validation)}`);
|
|
1512
1778
|
out.log(` findings: confirmed=${report.metrics.confirmed} needs_reproduction=${report.metrics.needsReproduction} candidates=${report.metrics.candidates}`);
|
|
1513
1779
|
out.log(` report: ${(0, import_node_path6.resolve)(output, "REPORT.md")}`);
|
|
1514
1780
|
}
|
|
1781
|
+
function formatBudget(usage) {
|
|
1782
|
+
return usage ? `${usage.usedCalls}/${usage.maxCalls} skipped=${usage.skippedCalls}` : "caller-managed";
|
|
1783
|
+
}
|
|
1515
1784
|
|
|
1516
1785
|
// src/help.ts
|
|
1517
1786
|
var import_node_fs7 = require("fs");
|
|
@@ -1528,9 +1797,14 @@ Usage:
|
|
|
1528
1797
|
odla-ai doctor [--config odla.config.mjs]
|
|
1529
1798
|
odla-ai capabilities [--json]
|
|
1530
1799
|
odla-ai admin ai show [--platform https://odla.ai] [--json]
|
|
1531
|
-
odla-ai admin ai
|
|
1800
|
+
odla-ai admin ai models [--provider <id>] [--json]
|
|
1801
|
+
odla-ai admin ai set <purpose> [--provider <id>] [--model <id>] [--enabled|--no-enabled]
|
|
1802
|
+
odla-ai admin ai set security --discovery-provider <id> --discovery-model <id>
|
|
1803
|
+
--validation-provider <id> --validation-model <id> [--enabled|--no-enabled]
|
|
1532
1804
|
odla-ai admin ai credentials [--json]
|
|
1533
1805
|
odla-ai admin ai credential set <provider> (--from-env <NAME>|--stdin)
|
|
1806
|
+
odla-ai admin ai usage [--app-id <id>] [--env <env>] [--run-id <id>] [--limit <1-500>] [--json]
|
|
1807
|
+
odla-ai admin ai audit [--limit <1-200>] [--json]
|
|
1534
1808
|
odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
|
|
1535
1809
|
odla-ai security run [target] --self --ack-redacted-source
|
|
1536
1810
|
odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
@@ -1544,7 +1818,7 @@ Commands:
|
|
|
1544
1818
|
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
1545
1819
|
doctor Validate and summarize the project config without network calls.
|
|
1546
1820
|
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
1547
|
-
admin Manage platform-funded AI routing with
|
|
1821
|
+
admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
|
|
1548
1822
|
security Run hosted discovery + independent validation with app/run attribution.
|
|
1549
1823
|
provision Register services, configure them, persist credentials, optionally push secrets.
|
|
1550
1824
|
smoke Verify local credentials, public-config, live schema, and db aggregate.
|
|
@@ -1940,12 +2214,14 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1940
2214
|
const action = parsed.positionals[2];
|
|
1941
2215
|
const credentialSet = action === "credential" && parsed.positionals[3] === "set";
|
|
1942
2216
|
const credentials = action === "credentials";
|
|
1943
|
-
|
|
2217
|
+
const models = action === "models";
|
|
2218
|
+
const usage = action === "usage";
|
|
2219
|
+
const audit = action === "audit";
|
|
2220
|
+
if (area !== "ai" || action !== "show" && action !== "set" && !credentialSet && !credentials && !models && !usage && !audit) {
|
|
1944
2221
|
throw new Error('unknown admin command. Try "odla-ai admin ai show".');
|
|
1945
2222
|
}
|
|
1946
2223
|
assertArgs(parsed, [
|
|
1947
2224
|
"platform",
|
|
1948
|
-
"token",
|
|
1949
2225
|
"json",
|
|
1950
2226
|
"open",
|
|
1951
2227
|
"provider",
|
|
@@ -1955,10 +2231,18 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1955
2231
|
"max-output-tokens",
|
|
1956
2232
|
"max-calls-per-run",
|
|
1957
2233
|
"from-env",
|
|
1958
|
-
"stdin"
|
|
2234
|
+
"stdin",
|
|
2235
|
+
"discovery-provider",
|
|
2236
|
+
"discovery-model",
|
|
2237
|
+
"validation-provider",
|
|
2238
|
+
"validation-model",
|
|
2239
|
+
"app-id",
|
|
2240
|
+
"env",
|
|
2241
|
+
"run-id",
|
|
2242
|
+
"limit"
|
|
1959
2243
|
], credentialSet ? 5 : action === "set" ? 4 : 3);
|
|
1960
2244
|
await adminAi({
|
|
1961
|
-
action: credentialSet ? "credential-set" : credentials ? "credentials" : action,
|
|
2245
|
+
action: credentialSet ? "credential-set" : credentials ? "credentials" : models ? "models" : usage ? "usage" : audit ? "audit" : action,
|
|
1962
2246
|
purpose: action === "set" ? parsed.positionals[3] : void 0,
|
|
1963
2247
|
provider: stringOpt(parsed.options.provider),
|
|
1964
2248
|
model: stringOpt(parsed.options.model),
|
|
@@ -1967,12 +2251,19 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1967
2251
|
maxOutputTokens: numberOpt(parsed.options["max-output-tokens"], "--max-output-tokens"),
|
|
1968
2252
|
maxCallsPerRun: numberOpt(parsed.options["max-calls-per-run"], "--max-calls-per-run"),
|
|
1969
2253
|
platform: stringOpt(parsed.options.platform),
|
|
1970
|
-
token: stringOpt(parsed.options.token),
|
|
1971
2254
|
json: parsed.options.json === true,
|
|
1972
2255
|
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
1973
2256
|
credentialProvider: credentialSet ? parsed.positionals[4] : void 0,
|
|
1974
2257
|
fromEnv: stringOpt(parsed.options["from-env"]),
|
|
1975
|
-
stdin: parsed.options.stdin === true
|
|
2258
|
+
stdin: parsed.options.stdin === true,
|
|
2259
|
+
discoveryProvider: stringOpt(parsed.options["discovery-provider"]),
|
|
2260
|
+
discoveryModel: stringOpt(parsed.options["discovery-model"]),
|
|
2261
|
+
validationProvider: stringOpt(parsed.options["validation-provider"]),
|
|
2262
|
+
validationModel: stringOpt(parsed.options["validation-model"]),
|
|
2263
|
+
appId: stringOpt(parsed.options["app-id"]),
|
|
2264
|
+
env: stringOpt(parsed.options.env),
|
|
2265
|
+
runId: stringOpt(parsed.options["run-id"]),
|
|
2266
|
+
limit: numberOpt(parsed.options.limit, "--limit")
|
|
1976
2267
|
});
|
|
1977
2268
|
return;
|
|
1978
2269
|
}
|
|
@@ -2014,6 +2305,9 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
2014
2305
|
return getScopedPlatformToken({ platform: request.platform, scope: request.scope, open });
|
|
2015
2306
|
}
|
|
2016
2307
|
const cfg = await loadProjectConfig(configPath);
|
|
2308
|
+
if (platformAudience(cfg.platformUrl) !== platformAudience(request.platform)) {
|
|
2309
|
+
throw new Error("--platform cannot reuse a project developer token from another platform; update odla.config.mjs and authenticate there");
|
|
2310
|
+
}
|
|
2017
2311
|
return getDeveloperToken(cfg, { configPath, open }, fetch, console);
|
|
2018
2312
|
}
|
|
2019
2313
|
});
|