@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/bin.cjs
CHANGED
|
@@ -31,9 +31,11 @@ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
|
31
31
|
var import_security2 = require("@odla-ai/security");
|
|
32
32
|
|
|
33
33
|
// src/admin-ai.ts
|
|
34
|
-
var
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
var import_node_process4 = __toESM(require("process"), 1);
|
|
35
|
+
|
|
36
|
+
// src/token.ts
|
|
37
|
+
var import_db = require("@odla-ai/db");
|
|
38
|
+
var import_node_process2 = __toESM(require("process"), 1);
|
|
37
39
|
|
|
38
40
|
// src/open.ts
|
|
39
41
|
var import_node_child_process = require("child_process");
|
|
@@ -59,10 +61,6 @@ function openerFor(platform) {
|
|
|
59
61
|
return { cmd: "xdg-open", args: [] };
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
// src/token.ts
|
|
63
|
-
var import_db = require("@odla-ai/db");
|
|
64
|
-
var import_node_process2 = __toESM(require("process"), 1);
|
|
65
|
-
|
|
66
64
|
// src/local.ts
|
|
67
65
|
var import_node_fs = require("fs");
|
|
68
66
|
var import_node_path = require("path");
|
|
@@ -192,9 +190,18 @@ function displayPath(path, rootDir = process.cwd()) {
|
|
|
192
190
|
// src/token.ts
|
|
193
191
|
async function getDeveloperToken(cfg, options, doFetch, out) {
|
|
194
192
|
if (options.token) return options.token;
|
|
195
|
-
|
|
193
|
+
const audience = platformAudience(cfg.platformUrl);
|
|
194
|
+
if (import_node_process2.default.env.ODLA_DEV_TOKEN) {
|
|
195
|
+
const declared = import_node_process2.default.env.ODLA_DEV_TOKEN_AUDIENCE;
|
|
196
|
+
if (declared) {
|
|
197
|
+
if (platformAudience(declared) !== audience) throw new Error("ODLA_DEV_TOKEN_AUDIENCE does not match the configured platform");
|
|
198
|
+
} else if (audience !== "https://odla.ai") {
|
|
199
|
+
throw new Error("ODLA_DEV_TOKEN_AUDIENCE is required for a non-default platform");
|
|
200
|
+
}
|
|
201
|
+
return import_node_process2.default.env.ODLA_DEV_TOKEN;
|
|
202
|
+
}
|
|
196
203
|
const cached = readJsonFile(cfg.local.tokenFile);
|
|
197
|
-
if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
204
|
+
if (cached?.token && cached.platform === audience && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
198
205
|
out.log(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
199
206
|
return cached.token;
|
|
200
207
|
}
|
|
@@ -220,7 +227,7 @@ async function getDeveloperToken(cfg, options, doFetch, out) {
|
|
|
220
227
|
out.log("");
|
|
221
228
|
}
|
|
222
229
|
});
|
|
223
|
-
writePrivateJson(cfg.local.tokenFile, { token, expiresAt });
|
|
230
|
+
writePrivateJson(cfg.local.tokenFile, { platform: audience, token, expiresAt });
|
|
224
231
|
out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
225
232
|
return token;
|
|
226
233
|
}
|
|
@@ -237,35 +244,271 @@ function handshakeUrl(platformUrl, userCode) {
|
|
|
237
244
|
url.searchParams.set("code", userCode);
|
|
238
245
|
return url.toString();
|
|
239
246
|
}
|
|
247
|
+
function platformAudience(value) {
|
|
248
|
+
let url;
|
|
249
|
+
try {
|
|
250
|
+
url = new URL(value);
|
|
251
|
+
} catch {
|
|
252
|
+
throw new Error("platform must be an absolute URL");
|
|
253
|
+
}
|
|
254
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
255
|
+
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
|
|
256
|
+
throw new Error("platform credentials require HTTPS except for loopback development");
|
|
257
|
+
}
|
|
258
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
259
|
+
throw new Error("platform URL must not contain credentials, query, or fragment");
|
|
260
|
+
}
|
|
261
|
+
return url.origin;
|
|
262
|
+
}
|
|
240
263
|
|
|
241
|
-
// src/admin-ai.ts
|
|
242
|
-
var
|
|
264
|
+
// src/admin-ai-auth.ts
|
|
265
|
+
var import_node_fs2 = require("fs");
|
|
266
|
+
var import_node_process3 = __toESM(require("process"), 1);
|
|
267
|
+
var import_db2 = require("@odla-ai/db");
|
|
243
268
|
async function getScopedPlatformToken(options) {
|
|
269
|
+
return resolveAdminPlatformToken(options);
|
|
270
|
+
}
|
|
271
|
+
async function resolveAdminPlatformToken(options) {
|
|
272
|
+
const audience = platformAudience(options.platform);
|
|
273
|
+
if (options.token) return options.token;
|
|
244
274
|
const fromEnv = import_node_process3.default.env.ODLA_ADMIN_TOKEN;
|
|
245
|
-
if (fromEnv) return fromEnv;
|
|
275
|
+
if (fromEnv) return audienceBoundEnvToken(fromEnv, audience);
|
|
246
276
|
return scopedToken(
|
|
247
|
-
|
|
277
|
+
audience,
|
|
248
278
|
options.scope,
|
|
249
|
-
|
|
279
|
+
options,
|
|
250
280
|
options.fetch ?? fetch,
|
|
251
281
|
options.stdout ?? console
|
|
252
282
|
);
|
|
253
283
|
}
|
|
284
|
+
function audienceBoundEnvToken(token, platform) {
|
|
285
|
+
const audience = platformAudience(platform);
|
|
286
|
+
const declared = import_node_process3.default.env.ODLA_ADMIN_TOKEN_AUDIENCE;
|
|
287
|
+
if (declared) {
|
|
288
|
+
if (platformAudience(declared) !== audience) throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE does not match the configured platform");
|
|
289
|
+
} else if (audience !== "https://odla.ai") {
|
|
290
|
+
throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE is required for a non-default platform");
|
|
291
|
+
}
|
|
292
|
+
return token;
|
|
293
|
+
}
|
|
294
|
+
async function scopedToken(platform, scope, options, doFetch, out) {
|
|
295
|
+
const audience = platformAudience(platform);
|
|
296
|
+
const tokenFile = options.tokenFile ?? `${import_node_process3.default.cwd()}/.odla/admin-token.local.json`;
|
|
297
|
+
const cache = readJsonFile(tokenFile);
|
|
298
|
+
const cached = cache?.platform === audience ? cache.tokens?.[scope] : void 0;
|
|
299
|
+
if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
300
|
+
out.log(`auth: using cached ${scope} grant (${tokenFile})`);
|
|
301
|
+
return cached.token;
|
|
302
|
+
}
|
|
303
|
+
const { token, expiresAt } = await (0, import_db2.requestToken)({
|
|
304
|
+
endpoint: audience,
|
|
305
|
+
label: `odla CLI admin AI (${scope})`,
|
|
306
|
+
scopes: [scope],
|
|
307
|
+
fetch: doFetch,
|
|
308
|
+
onCode: async ({ userCode, expiresIn }) => {
|
|
309
|
+
const approvalUrl = handshakeUrl(audience, userCode);
|
|
310
|
+
out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
311
|
+
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);
|
|
312
|
+
if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
|
|
316
|
+
tokens[scope] = { token, expiresAt };
|
|
317
|
+
if ((0, import_node_fs2.existsSync)(`${import_node_process3.default.cwd()}/.git`)) ensureGitignore(import_node_process3.default.cwd(), [tokenFile]);
|
|
318
|
+
writePrivateJson(tokenFile, { platform: audience, tokens });
|
|
319
|
+
out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
|
|
320
|
+
return token;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/admin-ai-audit.ts
|
|
324
|
+
function adminAiAuditQuery(filters) {
|
|
325
|
+
if (filters.limit === void 0) return "";
|
|
326
|
+
if (!Number.isSafeInteger(filters.limit) || filters.limit < 1 || filters.limit > 200) {
|
|
327
|
+
throw new Error("audit limit must be an integer from 1 to 200");
|
|
328
|
+
}
|
|
329
|
+
return `?limit=${filters.limit}`;
|
|
330
|
+
}
|
|
331
|
+
async function readAdminAiAudit(request) {
|
|
332
|
+
const response = await request.fetch(`${request.platform}/registry/platform/ai-audit${request.query}`, {
|
|
333
|
+
headers: request.headers
|
|
334
|
+
});
|
|
335
|
+
const body = await responseBody(response);
|
|
336
|
+
if (!response.ok) throw new Error(apiError(response.status, body));
|
|
337
|
+
if (request.json) {
|
|
338
|
+
request.stdout.log(JSON.stringify(body, null, 2));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const events = isRecord(body) && Array.isArray(body.events) ? body.events.filter(isRecord) : [];
|
|
342
|
+
request.stdout.log("when change target before -> after actor");
|
|
343
|
+
for (const event of events) {
|
|
344
|
+
const before = isRecord(event.oldPolicy) ? event.oldPolicy : void 0;
|
|
345
|
+
const after = isRecord(event.newPolicy) ? event.newPolicy : void 0;
|
|
346
|
+
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";
|
|
347
|
+
request.stdout.log([
|
|
348
|
+
timestamp(event.createdAt),
|
|
349
|
+
String(event.changeKind ?? ""),
|
|
350
|
+
String(event.purpose ?? event.provider ?? ""),
|
|
351
|
+
route,
|
|
352
|
+
`${String(event.actorType ?? "")}:${String(event.actorId ?? "")}`
|
|
353
|
+
].join(" "));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async function responseBody(response) {
|
|
357
|
+
const text = await response.text();
|
|
358
|
+
if (!text) return {};
|
|
359
|
+
try {
|
|
360
|
+
return JSON.parse(text);
|
|
361
|
+
} catch {
|
|
362
|
+
return { message: text.slice(0, 300) };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function apiError(status, body) {
|
|
366
|
+
const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
|
|
367
|
+
const message = error && typeof error.message === "string" ? error.message : isRecord(body) && typeof body.message === "string" ? body.message : "request failed";
|
|
368
|
+
return `read System AI admin changes failed (${status}): ${message}`;
|
|
369
|
+
}
|
|
370
|
+
function timestamp(value) {
|
|
371
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return String(value ?? "");
|
|
372
|
+
const date = new Date(value);
|
|
373
|
+
return Number.isFinite(date.valueOf()) ? date.toISOString() : "";
|
|
374
|
+
}
|
|
375
|
+
function isRecord(value) {
|
|
376
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/admin-ai-policy.ts
|
|
380
|
+
var SYSTEM_AI_PURPOSES = ["o11y.triage", "security.discovery", "security.validation"];
|
|
381
|
+
function requireSystemAiPurpose(value) {
|
|
382
|
+
if (!SYSTEM_AI_PURPOSES.includes(value)) {
|
|
383
|
+
throw new Error(`purpose must be one of: ${SYSTEM_AI_PURPOSES.join(", ")}`);
|
|
384
|
+
}
|
|
385
|
+
return value;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/admin-ai-usage.ts
|
|
389
|
+
function adminAiUsageQuery(filters) {
|
|
390
|
+
const params = new URLSearchParams();
|
|
391
|
+
if (filters.appId?.trim()) params.set("appId", filters.appId.trim());
|
|
392
|
+
if (filters.env?.trim()) params.set("env", filters.env.trim());
|
|
393
|
+
if (filters.runId?.trim()) params.set("runId", filters.runId.trim());
|
|
394
|
+
if (filters.limit !== void 0) params.set("limit", String(usageLimit(filters.limit)));
|
|
395
|
+
const query = params.toString();
|
|
396
|
+
return query ? `?${query}` : "";
|
|
397
|
+
}
|
|
398
|
+
async function readAdminAiUsage(request) {
|
|
399
|
+
const res = await request.fetch(`${request.platform}/registry/platform/ai-usage${request.query}`, {
|
|
400
|
+
headers: request.headers
|
|
401
|
+
});
|
|
402
|
+
const body = await responseBody2(res);
|
|
403
|
+
if (!res.ok) throw new Error(apiError2("read platform AI usage", res.status, body));
|
|
404
|
+
if (request.json) request.stdout.log(JSON.stringify(body, null, 2));
|
|
405
|
+
else printUsage(body, request.stdout);
|
|
406
|
+
}
|
|
407
|
+
function usageLimit(value) {
|
|
408
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 500) {
|
|
409
|
+
throw new Error("usage limit must be an integer from 1 to 500");
|
|
410
|
+
}
|
|
411
|
+
return value;
|
|
412
|
+
}
|
|
413
|
+
function printUsage(body, out) {
|
|
414
|
+
const aggregates = isRecord2(body) && isRecord2(body.aggregates) && Array.isArray(body.aggregates.byStatus) ? body.aggregates.byStatus.filter(isRecord2) : [];
|
|
415
|
+
out.log(`All retained hosted-security status totals (up to ${String(isRecord2(body) ? body.retentionDays ?? 90 : 90)} days; o11y triage excluded)
|
|
416
|
+
status calls input tokens output tokens cost unpriced calls`);
|
|
417
|
+
for (const row of aggregates) {
|
|
418
|
+
out.log([
|
|
419
|
+
String(row.status ?? ""),
|
|
420
|
+
String(row.calls ?? ""),
|
|
421
|
+
String(row.input_tokens ?? ""),
|
|
422
|
+
String(row.output_tokens ?? ""),
|
|
423
|
+
formatMicrousd(row.cost_microusd),
|
|
424
|
+
String(row.unpriced_calls ?? "")
|
|
425
|
+
].join(" "));
|
|
426
|
+
}
|
|
427
|
+
const events = isRecord2(body) && Array.isArray(body.events) ? body.events.filter(isRecord2) : [];
|
|
428
|
+
out.log(`Latest ${String(isRecord2(body) ? body.eventLimit ?? events.length : events.length)} hosted-security events
|
|
429
|
+
when app/env run actor purpose/role route / policy tokens cost status`);
|
|
430
|
+
for (const event of events) {
|
|
431
|
+
const input = numeric(event.input_tokens);
|
|
432
|
+
const output = numeric(event.output_tokens);
|
|
433
|
+
const priced = event.priced === true || event.priced === 1;
|
|
434
|
+
out.log([
|
|
435
|
+
timestamp2(event.created_at),
|
|
436
|
+
`${String(event.app_id ?? "")}/${String(event.env ?? "")}`,
|
|
437
|
+
String(event.run_id ?? ""),
|
|
438
|
+
`${String(event.actor_type ?? "")}:${String(event.actor_id ?? "")}`,
|
|
439
|
+
`${String(event.purpose ?? "")}/${String(event.role ?? "")}`,
|
|
440
|
+
`${String(event.provider ?? "")}/${String(event.model ?? "")}@v${String(event.policy_version ?? "unknown")}`,
|
|
441
|
+
String(input + output),
|
|
442
|
+
priced ? formatMicrousd(event.cost_microusd) : "unpriced",
|
|
443
|
+
`${String(event.status ?? "")}${event.error_code ? `:${String(event.error_code)}` : ""}`
|
|
444
|
+
].join(" "));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function numeric(value) {
|
|
448
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
449
|
+
}
|
|
450
|
+
function formatMicrousd(value) {
|
|
451
|
+
return typeof value === "number" && Number.isFinite(value) ? `$${(value / 1e6).toFixed(6)}` : "unpriced";
|
|
452
|
+
}
|
|
453
|
+
function timestamp2(value) {
|
|
454
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return String(value ?? "");
|
|
455
|
+
const date = new Date(value);
|
|
456
|
+
return Number.isFinite(date.valueOf()) ? date.toISOString() : "";
|
|
457
|
+
}
|
|
458
|
+
async function responseBody2(res) {
|
|
459
|
+
const text = await res.text();
|
|
460
|
+
if (!text) return {};
|
|
461
|
+
try {
|
|
462
|
+
return JSON.parse(text);
|
|
463
|
+
} catch {
|
|
464
|
+
return { message: text.slice(0, 300) };
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function apiError2(action, status, body) {
|
|
468
|
+
const error = isRecord2(body) && isRecord2(body.error) ? body.error : void 0;
|
|
469
|
+
const message = error && typeof error.message === "string" ? error.message : isRecord2(body) && typeof body.message === "string" ? body.message : "request failed";
|
|
470
|
+
return `${action} failed (${status}): ${message}`;
|
|
471
|
+
}
|
|
472
|
+
function isRecord2(value) {
|
|
473
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/admin-ai.ts
|
|
254
477
|
async function adminAi(options) {
|
|
255
|
-
const platform = (options.platform ??
|
|
478
|
+
const platform = platformAudience(options.platform ?? import_node_process4.default.env.ODLA_PLATFORM ?? "https://odla.ai");
|
|
256
479
|
const doFetch = options.fetch ?? fetch;
|
|
257
480
|
const out = options.stdout ?? console;
|
|
258
|
-
const
|
|
259
|
-
const
|
|
481
|
+
const usageQuery = options.action === "usage" ? adminAiUsageQuery(options) : void 0;
|
|
482
|
+
const auditQuery = options.action === "audit" ? adminAiAuditQuery(options) : void 0;
|
|
483
|
+
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";
|
|
484
|
+
const token = await resolveAdminPlatformToken({
|
|
485
|
+
platform,
|
|
486
|
+
scope,
|
|
487
|
+
token: options.token,
|
|
488
|
+
open: options.open,
|
|
489
|
+
fetch: doFetch,
|
|
490
|
+
stdout: out,
|
|
491
|
+
openApprovalUrl: options.openApprovalUrl,
|
|
492
|
+
tokenFile: options.tokenFile
|
|
493
|
+
});
|
|
260
494
|
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
495
|
+
if (options.action === "usage") {
|
|
496
|
+
await readAdminAiUsage({ platform, query: usageQuery ?? "", headers, json: options.json, fetch: doFetch, stdout: out });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (options.action === "audit") {
|
|
500
|
+
await readAdminAiAudit({ platform, query: auditQuery ?? "", headers, json: options.json, fetch: doFetch, stdout: out });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
261
503
|
if (options.action === "credentials") {
|
|
262
504
|
const res2 = await doFetch(`${platform}/registry/platform/ai-credentials`, { headers });
|
|
263
|
-
const body2 = await
|
|
264
|
-
if (!res2.ok) throw new Error(
|
|
265
|
-
const credentials =
|
|
505
|
+
const body2 = await responseBody3(res2);
|
|
506
|
+
if (!res2.ok) throw new Error(apiError3("read platform AI credential status", res2.status, body2));
|
|
507
|
+
const credentials = isRecord3(body2) && isRecord3(body2.credentials) ? body2.credentials : body2;
|
|
266
508
|
if (options.json) out.log(JSON.stringify({ credentials }, null, 2));
|
|
267
|
-
else for (const [provider, status] of Object.entries(
|
|
268
|
-
|
|
509
|
+
else for (const [provider, status] of Object.entries(isRecord3(credentials) ? credentials : {})) {
|
|
510
|
+
const label = isRecord3(status) && status.set === true ? status.readable === true ? "vault readable" : "stored; vault unreadable" : "not set";
|
|
511
|
+
out.log(`${provider} ${label}`);
|
|
269
512
|
}
|
|
270
513
|
return;
|
|
271
514
|
}
|
|
@@ -277,18 +520,25 @@ async function adminAi(options) {
|
|
|
277
520
|
headers,
|
|
278
521
|
body: JSON.stringify({ value })
|
|
279
522
|
});
|
|
280
|
-
const body2 = await
|
|
281
|
-
if (!res2.ok) throw new Error(
|
|
523
|
+
const body2 = await responseBody3(res2);
|
|
524
|
+
if (!res2.ok) throw new Error(apiError3(`store ${provider} platform credential`, res2.status, body2));
|
|
282
525
|
out.log(`stored ${provider} platform credential (write-only; value was not returned)`);
|
|
283
526
|
return;
|
|
284
527
|
}
|
|
285
|
-
if (options.action === "show") {
|
|
528
|
+
if (options.action === "show" || options.action === "models") {
|
|
286
529
|
const res2 = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
|
|
287
|
-
const body2 = await
|
|
288
|
-
if (!res2.ok) throw new Error(
|
|
530
|
+
const body2 = await responseBody3(res2);
|
|
531
|
+
if (!res2.ok) throw new Error(apiError3("read platform AI policies", res2.status, body2));
|
|
532
|
+
if (options.action === "models") {
|
|
533
|
+
const models = catalogModels(body2).filter((model) => !options.provider || model.provider === options.provider);
|
|
534
|
+
if (!models.length) throw new Error(options.provider ? `no System AI models found for provider ${options.provider}` : "platform returned no System AI models");
|
|
535
|
+
if (options.json) out.log(JSON.stringify({ models }, null, 2));
|
|
536
|
+
else for (const model of models) out.log(`${model.provider} ${model.id}`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
289
539
|
const policies = policyArray(body2);
|
|
290
540
|
if (options.json) {
|
|
291
|
-
out.log(JSON.stringify({ policies }, null, 2));
|
|
541
|
+
out.log(JSON.stringify({ policies, models: catalogModels(body2) }, null, 2));
|
|
292
542
|
return;
|
|
293
543
|
}
|
|
294
544
|
out.log("purpose enabled provider model max calls max input bytes max output tokens");
|
|
@@ -305,59 +555,68 @@ async function adminAi(options) {
|
|
|
305
555
|
}
|
|
306
556
|
return;
|
|
307
557
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
558
|
+
if (options.purpose === "security") {
|
|
559
|
+
const currentResponse = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
|
|
560
|
+
const currentBody = await responseBody3(currentResponse);
|
|
561
|
+
if (!currentResponse.ok) throw new Error(apiError3("read current security AI policies", currentResponse.status, currentBody));
|
|
562
|
+
const policies = policyArray(currentBody);
|
|
563
|
+
const discovery = policies.find((policy2) => policy2.purpose === "security.discovery");
|
|
564
|
+
const validation = policies.find((policy2) => policy2.purpose === "security.validation");
|
|
565
|
+
if (!discovery || !validation || !Number.isSafeInteger(discovery.version) || !Number.isSafeInteger(validation.version)) {
|
|
566
|
+
throw new Error("platform returned invalid security AI policy revisions");
|
|
567
|
+
}
|
|
568
|
+
const shared = {
|
|
569
|
+
...options.enabled === void 0 ? {} : { enabled: options.enabled },
|
|
570
|
+
...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
|
|
571
|
+
...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
|
|
572
|
+
...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
|
|
573
|
+
};
|
|
574
|
+
const discoveryUpdate = {
|
|
575
|
+
...shared,
|
|
576
|
+
...options.discoveryProvider?.trim() ? { provider: options.discoveryProvider.trim() } : {},
|
|
577
|
+
...options.discoveryModel?.trim() ? { model: options.discoveryModel.trim() } : {}
|
|
578
|
+
};
|
|
579
|
+
const validationUpdate = {
|
|
580
|
+
...shared,
|
|
581
|
+
...options.validationProvider?.trim() ? { provider: options.validationProvider.trim() } : {},
|
|
582
|
+
...options.validationModel?.trim() ? { model: options.validationModel.trim() } : {}
|
|
583
|
+
};
|
|
584
|
+
if (!Object.keys(discoveryUpdate).length && !Object.keys(validationUpdate).length) {
|
|
585
|
+
throw new Error("security set requires a route, enabled state, or budget change");
|
|
586
|
+
}
|
|
587
|
+
const res2 = await doFetch(`${platform}/registry/platform/ai-policies/security`, {
|
|
588
|
+
method: "PUT",
|
|
589
|
+
headers,
|
|
590
|
+
body: JSON.stringify({
|
|
591
|
+
expectedVersions: { discovery: discovery.version, validation: validation.version },
|
|
592
|
+
discovery: discoveryUpdate,
|
|
593
|
+
validation: validationUpdate
|
|
594
|
+
})
|
|
595
|
+
});
|
|
596
|
+
const response2 = await responseBody3(res2);
|
|
597
|
+
if (!res2.ok) throw new Error(apiError3("update security review routes", res2.status, response2));
|
|
598
|
+
out.log(options.json ? JSON.stringify(response2, null, 2) : "updated security review discovery and independent validation routes atomically");
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const purpose = requireSystemAiPurpose(options.purpose);
|
|
311
602
|
const body = {
|
|
312
|
-
provider: options.provider.trim(),
|
|
313
|
-
model: options.model.trim(),
|
|
603
|
+
...options.provider?.trim() ? { provider: options.provider.trim() } : {},
|
|
604
|
+
...options.model?.trim() ? { model: options.model.trim() } : {},
|
|
314
605
|
...options.enabled === void 0 ? {} : { enabled: options.enabled },
|
|
315
606
|
...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
|
|
316
607
|
...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
|
|
317
608
|
...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
|
|
318
609
|
};
|
|
610
|
+
if (!Object.keys(body).length) throw new Error("set requires a provider, model, enabled state, or budget change");
|
|
319
611
|
const res = await doFetch(`${platform}/registry/platform/ai-policies/${encodeURIComponent(purpose)}`, {
|
|
320
612
|
method: "PUT",
|
|
321
613
|
headers,
|
|
322
614
|
body: JSON.stringify(body)
|
|
323
615
|
});
|
|
324
|
-
const response = await
|
|
325
|
-
if (!res.ok) throw new Error(
|
|
326
|
-
const policy =
|
|
327
|
-
out.log(options.json ? JSON.stringify({ policy }, null, 2) : `updated ${purpose}: ${String(policy.provider ??
|
|
328
|
-
}
|
|
329
|
-
async function scopedToken(platform, scope, options, doFetch, out) {
|
|
330
|
-
const tokenFile = options.tokenFile ?? `${import_node_process3.default.cwd()}/.odla/admin-token.local.json`;
|
|
331
|
-
const cache = readJsonFile(tokenFile);
|
|
332
|
-
const cached = cache?.platform === platform ? cache.tokens?.[scope] : void 0;
|
|
333
|
-
if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
334
|
-
out.log(`auth: using cached ${scope} grant (${tokenFile})`);
|
|
335
|
-
return cached.token;
|
|
336
|
-
}
|
|
337
|
-
const { token, expiresAt } = await (0, import_db2.requestToken)({
|
|
338
|
-
endpoint: platform,
|
|
339
|
-
label: `odla CLI admin AI (${scope})`,
|
|
340
|
-
scopes: [scope],
|
|
341
|
-
fetch: doFetch,
|
|
342
|
-
onCode: async ({ userCode, expiresIn }) => {
|
|
343
|
-
const approvalUrl = handshakeUrl(platform, userCode);
|
|
344
|
-
out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
345
|
-
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);
|
|
346
|
-
if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
|
|
347
|
-
}
|
|
348
|
-
});
|
|
349
|
-
const tokens = cache?.platform === platform ? { ...cache.tokens ?? {} } : {};
|
|
350
|
-
tokens[scope] = { token, expiresAt };
|
|
351
|
-
if ((0, import_node_fs2.existsSync)(`${import_node_process3.default.cwd()}/.git`)) ensureGitignore(import_node_process3.default.cwd(), [tokenFile]);
|
|
352
|
-
writePrivateJson(tokenFile, { platform, tokens });
|
|
353
|
-
out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
|
|
354
|
-
return token;
|
|
355
|
-
}
|
|
356
|
-
function requirePurpose(value) {
|
|
357
|
-
if (!SYSTEM_AI_PURPOSES.includes(value)) {
|
|
358
|
-
throw new Error(`purpose must be one of: ${SYSTEM_AI_PURPOSES.join(", ")}`);
|
|
359
|
-
}
|
|
360
|
-
return value;
|
|
616
|
+
const response = await responseBody3(res);
|
|
617
|
+
if (!res.ok) throw new Error(apiError3(`update ${purpose}`, res.status, response));
|
|
618
|
+
const policy = isRecord3(response) && isRecord3(response.policy) ? response.policy : isRecord3(response) ? response : {};
|
|
619
|
+
out.log(options.json ? JSON.stringify({ policy }, null, 2) : `updated ${purpose}: ${String(policy.provider ?? "unchanged")}/${String(policy.model ?? "unchanged")}`);
|
|
361
620
|
}
|
|
362
621
|
function requireProvider(value) {
|
|
363
622
|
if (value !== "anthropic" && value !== "openai" && value !== "google") {
|
|
@@ -368,7 +627,7 @@ function requireProvider(value) {
|
|
|
368
627
|
async function credentialValue(options) {
|
|
369
628
|
if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
|
|
370
629
|
let value;
|
|
371
|
-
if (options.fromEnv) value =
|
|
630
|
+
if (options.fromEnv) value = import_node_process4.default.env[options.fromEnv];
|
|
372
631
|
else if (options.stdin) value = await (options.readStdin ?? readStdin)();
|
|
373
632
|
else throw new Error("credential input required: use --from-env <NAME> or --stdin; values are never accepted as arguments");
|
|
374
633
|
value = value?.replace(/[\r\n]+$/, "");
|
|
@@ -378,18 +637,24 @@ async function credentialValue(options) {
|
|
|
378
637
|
}
|
|
379
638
|
async function readStdin() {
|
|
380
639
|
let value = "";
|
|
381
|
-
for await (const chunk of
|
|
640
|
+
for await (const chunk of import_node_process4.default.stdin) {
|
|
382
641
|
value += String(chunk);
|
|
383
642
|
if (value.length > 64 * 1024) throw new Error("credential exceeds 64 KiB");
|
|
384
643
|
}
|
|
385
644
|
return value;
|
|
386
645
|
}
|
|
387
646
|
function policyArray(body) {
|
|
388
|
-
if (
|
|
389
|
-
if (
|
|
647
|
+
if (isRecord3(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord3);
|
|
648
|
+
if (isRecord3(body) && isRecord3(body.policies)) return Object.values(body.policies).filter(isRecord3);
|
|
390
649
|
throw new Error("platform returned an invalid AI policy response");
|
|
391
650
|
}
|
|
392
|
-
|
|
651
|
+
function catalogModels(body) {
|
|
652
|
+
if (!isRecord3(body) || !isRecord3(body.catalog) || !Array.isArray(body.catalog.models)) {
|
|
653
|
+
throw new Error("platform returned an invalid System AI model catalog");
|
|
654
|
+
}
|
|
655
|
+
return body.catalog.models.filter((value) => isRecord3(value) && typeof value.id === "string" && typeof value.provider === "string");
|
|
656
|
+
}
|
|
657
|
+
async function responseBody3(res) {
|
|
393
658
|
const text = await res.text();
|
|
394
659
|
if (!text) return {};
|
|
395
660
|
try {
|
|
@@ -398,12 +663,12 @@ async function responseBody(res) {
|
|
|
398
663
|
return { message: text.slice(0, 300) };
|
|
399
664
|
}
|
|
400
665
|
}
|
|
401
|
-
function
|
|
402
|
-
const error =
|
|
403
|
-
const message = error && typeof error.message === "string" ? error.message :
|
|
666
|
+
function apiError3(action, status, body) {
|
|
667
|
+
const error = isRecord3(body) && isRecord3(body.error) ? body.error : void 0;
|
|
668
|
+
const message = error && typeof error.message === "string" ? error.message : isRecord3(body) && typeof body.message === "string" ? body.message : "request failed";
|
|
404
669
|
return `${action} failed (${status}): ${message}`;
|
|
405
670
|
}
|
|
406
|
-
function
|
|
671
|
+
function isRecord3(value) {
|
|
407
672
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
408
673
|
}
|
|
409
674
|
|
|
@@ -491,7 +756,7 @@ var CAPABILITIES = {
|
|
|
491
756
|
"push db schema/rules and configure platform AI, auth, and deployment links",
|
|
492
757
|
"validate config offline and smoke-test a provisioned db environment",
|
|
493
758
|
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
494
|
-
"let admins manage system AI
|
|
759
|
+
"let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
|
|
495
760
|
],
|
|
496
761
|
agent: [
|
|
497
762
|
"install and import the selected odla SDKs",
|
|
@@ -1023,7 +1288,7 @@ function relativeDisplay(path, rootDir) {
|
|
|
1023
1288
|
// src/provision.ts
|
|
1024
1289
|
var import_apps2 = require("@odla-ai/apps");
|
|
1025
1290
|
var import_ai = require("@odla-ai/ai");
|
|
1026
|
-
var
|
|
1291
|
+
var import_node_process5 = __toESM(require("process"), 1);
|
|
1027
1292
|
|
|
1028
1293
|
// src/provision-credentials.ts
|
|
1029
1294
|
var import_apps = require("@odla-ai/apps");
|
|
@@ -1327,7 +1592,7 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
1327
1592
|
out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
|
|
1328
1593
|
}
|
|
1329
1594
|
if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
1330
|
-
const key =
|
|
1595
|
+
const key = import_node_process5.default.env[cfg.ai.keyEnv];
|
|
1331
1596
|
if (key) {
|
|
1332
1597
|
const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
|
|
1333
1598
|
await (0, import_ai.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
|
|
@@ -1482,10 +1747,14 @@ function printSummary(out, appId, env, run, report, output) {
|
|
|
1482
1747
|
out.log(`security: ${appId}/${env} run=${run.runId} profile=${run.profileVersion}`);
|
|
1483
1748
|
out.log(` discovery: ${run.discovery.identity.provider}/${run.discovery.identity.model}`);
|
|
1484
1749
|
out.log(` validation: ${run.validation.identity.provider}/${run.validation.identity.model}`);
|
|
1485
|
-
out.log(` coverage: ${report.coverageStatus} ${complete}/${report.coverage.length} blocked=${report.metrics.blockedCells} shallow=${report.metrics.shallowCells} unscheduled=${report.metrics.unscheduledCells}`);
|
|
1750
|
+
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}`);
|
|
1751
|
+
if (report.callBudget) out.log(` calls: discovery=${formatBudget(report.callBudget.discovery)} validation=${formatBudget(report.callBudget.validation)}`);
|
|
1486
1752
|
out.log(` findings: confirmed=${report.metrics.confirmed} needs_reproduction=${report.metrics.needsReproduction} candidates=${report.metrics.candidates}`);
|
|
1487
1753
|
out.log(` report: ${(0, import_node_path6.resolve)(output, "REPORT.md")}`);
|
|
1488
1754
|
}
|
|
1755
|
+
function formatBudget(usage) {
|
|
1756
|
+
return usage ? `${usage.usedCalls}/${usage.maxCalls} skipped=${usage.skippedCalls}` : "caller-managed";
|
|
1757
|
+
}
|
|
1489
1758
|
|
|
1490
1759
|
// src/help.ts
|
|
1491
1760
|
var import_node_fs7 = require("fs");
|
|
@@ -1502,9 +1771,14 @@ Usage:
|
|
|
1502
1771
|
odla-ai doctor [--config odla.config.mjs]
|
|
1503
1772
|
odla-ai capabilities [--json]
|
|
1504
1773
|
odla-ai admin ai show [--platform https://odla.ai] [--json]
|
|
1505
|
-
odla-ai admin ai
|
|
1774
|
+
odla-ai admin ai models [--provider <id>] [--json]
|
|
1775
|
+
odla-ai admin ai set <purpose> [--provider <id>] [--model <id>] [--enabled|--no-enabled]
|
|
1776
|
+
odla-ai admin ai set security --discovery-provider <id> --discovery-model <id>
|
|
1777
|
+
--validation-provider <id> --validation-model <id> [--enabled|--no-enabled]
|
|
1506
1778
|
odla-ai admin ai credentials [--json]
|
|
1507
1779
|
odla-ai admin ai credential set <provider> (--from-env <NAME>|--stdin)
|
|
1780
|
+
odla-ai admin ai usage [--app-id <id>] [--env <env>] [--run-id <id>] [--limit <1-500>] [--json]
|
|
1781
|
+
odla-ai admin ai audit [--limit <1-200>] [--json]
|
|
1508
1782
|
odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
|
|
1509
1783
|
odla-ai security run [target] --self --ack-redacted-source
|
|
1510
1784
|
odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
@@ -1518,7 +1792,7 @@ Commands:
|
|
|
1518
1792
|
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
1519
1793
|
doctor Validate and summarize the project config without network calls.
|
|
1520
1794
|
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
1521
|
-
admin Manage platform-funded AI routing with
|
|
1795
|
+
admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
|
|
1522
1796
|
security Run hosted discovery + independent validation with app/run attribution.
|
|
1523
1797
|
provision Register services, configure them, persist credentials, optionally push secrets.
|
|
1524
1798
|
smoke Verify local credentials, public-config, live schema, and db aggregate.
|
|
@@ -1914,12 +2188,14 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1914
2188
|
const action = parsed.positionals[2];
|
|
1915
2189
|
const credentialSet = action === "credential" && parsed.positionals[3] === "set";
|
|
1916
2190
|
const credentials = action === "credentials";
|
|
1917
|
-
|
|
2191
|
+
const models = action === "models";
|
|
2192
|
+
const usage = action === "usage";
|
|
2193
|
+
const audit = action === "audit";
|
|
2194
|
+
if (area !== "ai" || action !== "show" && action !== "set" && !credentialSet && !credentials && !models && !usage && !audit) {
|
|
1918
2195
|
throw new Error('unknown admin command. Try "odla-ai admin ai show".');
|
|
1919
2196
|
}
|
|
1920
2197
|
assertArgs(parsed, [
|
|
1921
2198
|
"platform",
|
|
1922
|
-
"token",
|
|
1923
2199
|
"json",
|
|
1924
2200
|
"open",
|
|
1925
2201
|
"provider",
|
|
@@ -1929,10 +2205,18 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1929
2205
|
"max-output-tokens",
|
|
1930
2206
|
"max-calls-per-run",
|
|
1931
2207
|
"from-env",
|
|
1932
|
-
"stdin"
|
|
2208
|
+
"stdin",
|
|
2209
|
+
"discovery-provider",
|
|
2210
|
+
"discovery-model",
|
|
2211
|
+
"validation-provider",
|
|
2212
|
+
"validation-model",
|
|
2213
|
+
"app-id",
|
|
2214
|
+
"env",
|
|
2215
|
+
"run-id",
|
|
2216
|
+
"limit"
|
|
1933
2217
|
], credentialSet ? 5 : action === "set" ? 4 : 3);
|
|
1934
2218
|
await adminAi({
|
|
1935
|
-
action: credentialSet ? "credential-set" : credentials ? "credentials" : action,
|
|
2219
|
+
action: credentialSet ? "credential-set" : credentials ? "credentials" : models ? "models" : usage ? "usage" : audit ? "audit" : action,
|
|
1936
2220
|
purpose: action === "set" ? parsed.positionals[3] : void 0,
|
|
1937
2221
|
provider: stringOpt(parsed.options.provider),
|
|
1938
2222
|
model: stringOpt(parsed.options.model),
|
|
@@ -1941,12 +2225,19 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1941
2225
|
maxOutputTokens: numberOpt(parsed.options["max-output-tokens"], "--max-output-tokens"),
|
|
1942
2226
|
maxCallsPerRun: numberOpt(parsed.options["max-calls-per-run"], "--max-calls-per-run"),
|
|
1943
2227
|
platform: stringOpt(parsed.options.platform),
|
|
1944
|
-
token: stringOpt(parsed.options.token),
|
|
1945
2228
|
json: parsed.options.json === true,
|
|
1946
2229
|
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
1947
2230
|
credentialProvider: credentialSet ? parsed.positionals[4] : void 0,
|
|
1948
2231
|
fromEnv: stringOpt(parsed.options["from-env"]),
|
|
1949
|
-
stdin: parsed.options.stdin === true
|
|
2232
|
+
stdin: parsed.options.stdin === true,
|
|
2233
|
+
discoveryProvider: stringOpt(parsed.options["discovery-provider"]),
|
|
2234
|
+
discoveryModel: stringOpt(parsed.options["discovery-model"]),
|
|
2235
|
+
validationProvider: stringOpt(parsed.options["validation-provider"]),
|
|
2236
|
+
validationModel: stringOpt(parsed.options["validation-model"]),
|
|
2237
|
+
appId: stringOpt(parsed.options["app-id"]),
|
|
2238
|
+
env: stringOpt(parsed.options.env),
|
|
2239
|
+
runId: stringOpt(parsed.options["run-id"]),
|
|
2240
|
+
limit: numberOpt(parsed.options.limit, "--limit")
|
|
1950
2241
|
});
|
|
1951
2242
|
return;
|
|
1952
2243
|
}
|
|
@@ -1988,6 +2279,9 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1988
2279
|
return getScopedPlatformToken({ platform: request.platform, scope: request.scope, open });
|
|
1989
2280
|
}
|
|
1990
2281
|
const cfg = await loadProjectConfig(configPath);
|
|
2282
|
+
if (platformAudience(cfg.platformUrl) !== platformAudience(request.platform)) {
|
|
2283
|
+
throw new Error("--platform cannot reuse a project developer token from another platform; update odla.config.mjs and authenticate there");
|
|
2284
|
+
}
|
|
1991
2285
|
return getDeveloperToken(cfg, { configPath, open }, fetch, console);
|
|
1992
2286
|
}
|
|
1993
2287
|
});
|