@alashi/cli 0.14.0 → 0.16.0
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/assets/v1/chat.js +298 -57
- package/assets/v1/system.css +45 -0
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +358 -78
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{store-DoAGOynU.mjs → store-DvLX0Kft.mjs} +1215 -55
- package/dist/store-DvLX0Kft.mjs.map +1 -0
- package/package.json +3 -3
- package/templates/basic-app/index.mjs +1 -1
- package/templates/basic-app/package.json +1 -1
- package/dist/store-DoAGOynU.mjs.map +0 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
3
|
-
import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION, evaluateAppConfig, interpolateConfig, manifestConfigDefaults, mergeConfig, parseAgentFile, parseManifestWithWarnings } from "@alashi/apps-sdk";
|
|
3
|
+
import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION, compilePolicy, describeRule, evaluateAppConfig, evaluatePolicy, interpolateConfig, manifestConfigDefaults, mergeConfig, parseAgentFile, parseManifestWithWarnings, permissionsPolicy } from "@alashi/apps-sdk";
|
|
4
4
|
import semver from "semver";
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
|
-
import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { appendFile, cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
7
7
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -368,6 +368,25 @@ function round(value) {
|
|
|
368
368
|
function formatUsd(value) {
|
|
369
369
|
return `$${value.toFixed(value >= 1 ? 2 : 4)}`;
|
|
370
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* The metering a turn actually contributes, or `undefined` when the engine
|
|
373
|
+
* reported nothing usable.
|
|
374
|
+
*
|
|
375
|
+
* A turn counts when its cost is a finite, non-negative number **or** it
|
|
376
|
+
* reported per-model tokens — an engine that omits both cannot corrupt the
|
|
377
|
+
* totals. This is the single gate for both writes: the ledger line and the
|
|
378
|
+
* usage.json fold must agree on what a turn is, or a rebuild from the ledger
|
|
379
|
+
* would disagree with the live aggregate.
|
|
380
|
+
*/
|
|
381
|
+
function meteredTurn(turn) {
|
|
382
|
+
const costValid = typeof turn.costUsd === "number" && Number.isFinite(turn.costUsd) && turn.costUsd >= 0;
|
|
383
|
+
const modelUsage = turn.modelUsage !== void 0 && Object.keys(turn.modelUsage).length > 0 ? turn.modelUsage : void 0;
|
|
384
|
+
if (!costValid && modelUsage === void 0) return void 0;
|
|
385
|
+
return {
|
|
386
|
+
costUsd: costValid ? turn.costUsd : 0,
|
|
387
|
+
...modelUsage !== void 0 && { modelUsage }
|
|
388
|
+
};
|
|
389
|
+
}
|
|
371
390
|
/** Merges per-app summaries into the host-wide view. */
|
|
372
391
|
function combineSummaries(summaries) {
|
|
373
392
|
const combined = {
|
|
@@ -453,38 +472,59 @@ function sanitizeModels(models) {
|
|
|
453
472
|
}
|
|
454
473
|
/**
|
|
455
474
|
* Per-app AI spend and token usage, persisted as OS bookkeeping in the app
|
|
456
|
-
* container
|
|
457
|
-
*
|
|
458
|
-
*
|
|
475
|
+
* container next to jobs.json and chat.json, in two files:
|
|
476
|
+
*
|
|
477
|
+
* - `apps/<name>/turns.jsonl` — the ledger: one line per metered turn,
|
|
478
|
+
* append-only, oldest first, carrying the session attribution the
|
|
479
|
+
* aggregates throw away.
|
|
480
|
+
* - `apps/<name>/usage.json` — a fold over that ledger: a per-UTC-day series
|
|
481
|
+
* trimmed to the retention window, plus per-month rollups kept forever.
|
|
482
|
+
*
|
|
483
|
+
* The two are written together inside one per-app queue, so a turn can never
|
|
484
|
+
* land in one and not the other, and concurrent turns can neither lose an
|
|
485
|
+
* update nor interleave a half-written line. Each app has its own queue, so a
|
|
486
|
+
* slow write for one app never delays another.
|
|
459
487
|
*
|
|
460
488
|
* Cost and per-model tokens arrive from the `result` event, whose fields the
|
|
461
489
|
* SDK contract marks optional: engines that report neither simply contribute
|
|
462
|
-
* nothing.
|
|
463
|
-
* update, and each app has its own queue so a slow write for one app never
|
|
464
|
-
* delays another.
|
|
490
|
+
* nothing.
|
|
465
491
|
*/
|
|
466
492
|
var UsageRecorder = class {
|
|
467
493
|
store;
|
|
468
494
|
retentionDays;
|
|
469
|
-
/** Serializes usage.json read-modify-write per app. */
|
|
495
|
+
/** Serializes the usage.json read-modify-write and ledger append per app. */
|
|
470
496
|
queues = /* @__PURE__ */ new Map();
|
|
497
|
+
/** Apps whose ledger has already been compacted in this process. */
|
|
498
|
+
compacted = /* @__PURE__ */ new Set();
|
|
471
499
|
constructor(store, retentionDays = 90) {
|
|
472
500
|
this.store = store;
|
|
473
501
|
this.retentionDays = retentionDays;
|
|
474
502
|
}
|
|
475
503
|
/**
|
|
476
|
-
*
|
|
477
|
-
* and month of `at`. A missing, non-finite, or
|
|
478
|
-
* ignored; a turn that reports neither valid cost nor
|
|
479
|
-
* not counted and nothing is written, so a
|
|
480
|
-
* cannot corrupt the totals.
|
|
504
|
+
* Appends the turn to the app's ledger and adds its cost and token usage to
|
|
505
|
+
* the totals for the UTC day and month of `at`. A missing, non-finite, or
|
|
506
|
+
* negative `costUsd` is ignored; a turn that reports neither valid cost nor
|
|
507
|
+
* any model usage is not counted and nothing is written at all, so a
|
|
508
|
+
* provider that omits metering cannot corrupt the totals.
|
|
509
|
+
*
|
|
510
|
+
* Both writes happen in the same queued unit of work, ledger first: if the
|
|
511
|
+
* aggregate write then fails, the ledger is *ahead* of usage.json and
|
|
512
|
+
* `rebuild()` can recover the turn. The other order would lose it.
|
|
481
513
|
*/
|
|
482
514
|
record(appName, turn, at = /* @__PURE__ */ new Date()) {
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const
|
|
515
|
+
const metered = meteredTurn(turn);
|
|
516
|
+
if (metered === void 0) return Promise.resolve();
|
|
517
|
+
const { costUsd, modelUsage } = metered;
|
|
518
|
+
const line = {
|
|
519
|
+
at: at.toISOString(),
|
|
520
|
+
...turn.sessionId !== void 0 && turn.sessionId !== "" && { sessionId: turn.sessionId },
|
|
521
|
+
...turn.model !== void 0 && turn.model !== "" && { model: turn.model },
|
|
522
|
+
costUsd,
|
|
523
|
+
...modelUsage !== void 0 && { modelUsage }
|
|
524
|
+
};
|
|
487
525
|
return this.enqueue(appName, async () => {
|
|
526
|
+
await this.compactOnce(appName, at);
|
|
527
|
+
await this.store.appendTurnRecord(appName, line);
|
|
488
528
|
const state = this.normalize(await this.store.readUsageState(appName));
|
|
489
529
|
const date = utcDay(at);
|
|
490
530
|
const day = state.days.find((entry) => entry.date === date);
|
|
@@ -539,6 +579,117 @@ var UsageRecorder = class {
|
|
|
539
579
|
months
|
|
540
580
|
};
|
|
541
581
|
}
|
|
582
|
+
/**
|
|
583
|
+
* The app's metered turns, oldest first, with expired lines excluded.
|
|
584
|
+
*
|
|
585
|
+
* `since` is an inclusive lower bound compared lexically against the ISO
|
|
586
|
+
* `at` (so a plain `YYYY-MM-DD` works as "from that day on"), `sessionId`
|
|
587
|
+
* narrows to one provider session, and both are ANDed. This is the one
|
|
588
|
+
* reader for the ledger: everything that needs per-turn detail goes through
|
|
589
|
+
* it rather than parsing the file itself.
|
|
590
|
+
*/
|
|
591
|
+
async turns(appName, query = {}, at = /* @__PURE__ */ new Date()) {
|
|
592
|
+
const cutoff = this.cutoff(at);
|
|
593
|
+
return (await this.store.readTurnRecords(appName)).filter((record) => this.inWindow(record, cutoff) && (query.since === void 0 || record.at >= query.since) && (query.sessionId === void 0 || record.sessionId === query.sessionId)).sort((a, b) => a.at.localeCompare(b.at));
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Rewrites the app's ledger without the lines that have aged out of the
|
|
597
|
+
* retention window (the same window usage.json days use). Unparseable lines
|
|
598
|
+
* are dropped by the same rewrite, since only parsed records are written
|
|
599
|
+
* back.
|
|
600
|
+
*
|
|
601
|
+
* Runs inside the per-app queue, so it can never race a turn being
|
|
602
|
+
* appended. Pure bookkeeping: callers should treat a failure as noise, not
|
|
603
|
+
* as a reason to fail whatever they were doing.
|
|
604
|
+
*/
|
|
605
|
+
compact(appName, at = /* @__PURE__ */ new Date()) {
|
|
606
|
+
return this.enqueue(appName, async () => {
|
|
607
|
+
await this.compactNow(appName, at);
|
|
608
|
+
this.compacted.add(appName);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Rebuilds usage.json from turns.jsonl, for when the two have drifted apart
|
|
613
|
+
* (a lost write, a crash mid-fold, a hand edit). The result is exactly what
|
|
614
|
+
* the incremental fold in `record()` would have produced from the same
|
|
615
|
+
* lines: same day and month buckets, same turn counts, same per-model
|
|
616
|
+
* totals, same rounding.
|
|
617
|
+
*
|
|
618
|
+
* Caveat, by construction: the ledger is pruned at `retentionDays` but
|
|
619
|
+
* usage.json months are kept forever, so months the ledger no longer covers
|
|
620
|
+
* cannot be recomputed. Those keep their existing rollup and only the
|
|
621
|
+
* months the ledger does cover are replaced — a rebuild never silently
|
|
622
|
+
* erases older history. The one month that is *partially* covered (the one
|
|
623
|
+
* the retention cutoff falls in) is replaced by its in-window part, which is
|
|
624
|
+
* all the ledger can still account for.
|
|
625
|
+
*
|
|
626
|
+
* Returns the state it wrote.
|
|
627
|
+
*/
|
|
628
|
+
rebuild(appName, at = /* @__PURE__ */ new Date()) {
|
|
629
|
+
return this.enqueue(appName, async () => {
|
|
630
|
+
const cutoff = this.cutoff(at);
|
|
631
|
+
const [records, existing] = await Promise.all([this.store.readTurnRecords(appName), this.store.readUsageState(appName)]);
|
|
632
|
+
const previous = this.normalize(existing);
|
|
633
|
+
const days = /* @__PURE__ */ new Map();
|
|
634
|
+
const months = /* @__PURE__ */ new Map();
|
|
635
|
+
for (const record of records) {
|
|
636
|
+
if (!this.inWindow(record, cutoff)) continue;
|
|
637
|
+
const metered = meteredTurn(record);
|
|
638
|
+
if (metered === void 0) continue;
|
|
639
|
+
const date = record.at.slice(0, 10);
|
|
640
|
+
const monthKey = record.at.slice(0, 7);
|
|
641
|
+
const day = days.get(date) ?? {
|
|
642
|
+
date,
|
|
643
|
+
costUsd: 0,
|
|
644
|
+
turns: 0
|
|
645
|
+
};
|
|
646
|
+
this.apply(day, metered.costUsd, metered.modelUsage);
|
|
647
|
+
days.set(date, day);
|
|
648
|
+
const month = months.get(monthKey) ?? {
|
|
649
|
+
month: monthKey,
|
|
650
|
+
costUsd: 0,
|
|
651
|
+
turns: 0
|
|
652
|
+
};
|
|
653
|
+
this.apply(month, metered.costUsd, metered.modelUsage);
|
|
654
|
+
months.set(monthKey, month);
|
|
655
|
+
}
|
|
656
|
+
const kept = previous.months.filter((month) => !months.has(month.month));
|
|
657
|
+
const state = this.prune({
|
|
658
|
+
days: [...days.values()],
|
|
659
|
+
months: [...kept, ...months.values()]
|
|
660
|
+
}, at);
|
|
661
|
+
await this.store.writeUsageState(appName, state);
|
|
662
|
+
return state;
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
/** The oldest UTC day still inside the retention window at `at`. */
|
|
666
|
+
cutoff(at) {
|
|
667
|
+
return utcDay(/* @__PURE__ */ new Date(at.getTime() - (this.retentionDays - 1) * DAY_MS));
|
|
668
|
+
}
|
|
669
|
+
inWindow(record, cutoff) {
|
|
670
|
+
return record.at.slice(0, 10) >= cutoff;
|
|
671
|
+
}
|
|
672
|
+
/** The compaction body; assumes it is already inside the app's queue. */
|
|
673
|
+
async compactNow(appName, at) {
|
|
674
|
+
const cutoff = this.cutoff(at);
|
|
675
|
+
const records = await this.store.readTurnRecords(appName);
|
|
676
|
+
const kept = records.filter((record) => this.inWindow(record, cutoff));
|
|
677
|
+
if (kept.length === records.length) return;
|
|
678
|
+
await this.store.writeTurnRecords(appName, kept);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Trims the ledger on the app's first append of the process, so a host that
|
|
682
|
+
* is never restarted still sheds expired lines. Compaction is bookkeeping
|
|
683
|
+
* and must never break a turn, so a failure is swallowed — the next process
|
|
684
|
+
* tries again, and meanwhile `turns()` filters expired lines out anyway.
|
|
685
|
+
*/
|
|
686
|
+
async compactOnce(appName, at) {
|
|
687
|
+
if (this.compacted.has(appName)) return;
|
|
688
|
+
this.compacted.add(appName);
|
|
689
|
+
try {
|
|
690
|
+
await this.compactNow(appName, at);
|
|
691
|
+
} catch {}
|
|
692
|
+
}
|
|
542
693
|
apply(bucket, costUsd, modelUsage) {
|
|
543
694
|
bucket.costUsd = round(bucket.costUsd + costUsd);
|
|
544
695
|
bucket.turns += 1;
|
|
@@ -582,7 +733,7 @@ var UsageRecorder = class {
|
|
|
582
733
|
}
|
|
583
734
|
/** Drops days outside the retention window and normalizes ordering/values. Months are kept forever. */
|
|
584
735
|
prune(state, at) {
|
|
585
|
-
const cutoff =
|
|
736
|
+
const cutoff = this.cutoff(at);
|
|
586
737
|
return {
|
|
587
738
|
days: state.days.filter((entry) => typeof entry?.date === "string" && entry.date >= cutoff && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => {
|
|
588
739
|
const models = sanitizeModels(entry.models);
|
|
@@ -1355,8 +1506,14 @@ function createLogger(prefix) {
|
|
|
1355
1506
|
* event reaches the caller. This sits under *every* session an app opens —
|
|
1356
1507
|
* chat turns, cron job handlers, and app routes alike — so spend is recorded
|
|
1357
1508
|
* no matter who opened the session.
|
|
1509
|
+
*
|
|
1510
|
+
* The session id and model come from the `session` event, which a provider
|
|
1511
|
+
* emits once and which then holds for every later turn — so they are
|
|
1512
|
+
* remembered in this closure, not per `send()` call.
|
|
1358
1513
|
*/
|
|
1359
1514
|
function meterSession(session, recordUsage) {
|
|
1515
|
+
let sessionId;
|
|
1516
|
+
let model;
|
|
1360
1517
|
return {
|
|
1361
1518
|
get id() {
|
|
1362
1519
|
return session.id;
|
|
@@ -1365,16 +1522,81 @@ function meterSession(session, recordUsage) {
|
|
|
1365
1522
|
const events = session.send(input);
|
|
1366
1523
|
return (async function* metered() {
|
|
1367
1524
|
for await (const event of events) {
|
|
1368
|
-
if (event.type === "
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
}
|
|
1525
|
+
if (event.type === "session") {
|
|
1526
|
+
if (event.sessionId !== "") sessionId = event.sessionId;
|
|
1527
|
+
if (event.model !== void 0 && event.model !== "") model = event.model;
|
|
1528
|
+
}
|
|
1529
|
+
if (event.type === "result") {
|
|
1530
|
+
const id = sessionId ?? (session.id === "" ? void 0 : session.id);
|
|
1531
|
+
await recordUsage({
|
|
1532
|
+
costUsd: event.costUsd,
|
|
1533
|
+
modelUsage: event.modelUsage,
|
|
1534
|
+
...id !== void 0 && { sessionId: id },
|
|
1535
|
+
...model !== void 0 && { model }
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1372
1538
|
yield event;
|
|
1373
1539
|
}
|
|
1374
1540
|
})();
|
|
1375
1541
|
},
|
|
1376
1542
|
interrupt: () => session.interrupt(),
|
|
1377
|
-
close: () => session.close()
|
|
1543
|
+
close: () => session.close(),
|
|
1544
|
+
...session.respondToApproval !== void 0 && { respondToApproval: session.respondToApproval.bind(session) }
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Wraps a session so its approval traffic is mirrored into the host-wide
|
|
1549
|
+
* ApprovalRegistry. Layered like `meterSession` under every session an app
|
|
1550
|
+
* opens, which is what makes a cron job's approval visible in the host's
|
|
1551
|
+
* pending list instead of hanging silently until timeout.
|
|
1552
|
+
*
|
|
1553
|
+
* A stream that ends while approvals are still pending (crash, close) abandons
|
|
1554
|
+
* them, so the registry never carries a pending entry no session can answer.
|
|
1555
|
+
*/
|
|
1556
|
+
function superviseSession(session, app, registry) {
|
|
1557
|
+
return {
|
|
1558
|
+
get id() {
|
|
1559
|
+
return session.id;
|
|
1560
|
+
},
|
|
1561
|
+
send(input) {
|
|
1562
|
+
const events = session.send(input);
|
|
1563
|
+
const open = /* @__PURE__ */ new Set();
|
|
1564
|
+
return (async function* supervised() {
|
|
1565
|
+
try {
|
|
1566
|
+
for await (const event of events) {
|
|
1567
|
+
if (event.type === "approval_required") {
|
|
1568
|
+
open.add(event.approvalId);
|
|
1569
|
+
registry.track(app, event, (decision) => session.respondToApproval ? session.respondToApproval(event.approvalId, decision) : Promise.resolve());
|
|
1570
|
+
}
|
|
1571
|
+
if (event.type === "approval_resolved") {
|
|
1572
|
+
open.delete(event.approvalId);
|
|
1573
|
+
registry.resolved(event);
|
|
1574
|
+
}
|
|
1575
|
+
yield event;
|
|
1576
|
+
}
|
|
1577
|
+
} finally {
|
|
1578
|
+
for (const approvalId of open) registry.abandon(approvalId);
|
|
1579
|
+
}
|
|
1580
|
+
})();
|
|
1581
|
+
},
|
|
1582
|
+
interrupt: () => session.interrupt(),
|
|
1583
|
+
close: () => session.close(),
|
|
1584
|
+
...session.respondToApproval !== void 0 && { respondToApproval: session.respondToApproval.bind(session) }
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
/** Runs `onClose` once when the session closes (drops its gateway grants). */
|
|
1588
|
+
function endOnClose(session, onClose) {
|
|
1589
|
+
return {
|
|
1590
|
+
get id() {
|
|
1591
|
+
return session.id;
|
|
1592
|
+
},
|
|
1593
|
+
send: (input) => session.send(input),
|
|
1594
|
+
interrupt: () => session.interrupt(),
|
|
1595
|
+
close: async () => {
|
|
1596
|
+
onClose();
|
|
1597
|
+
await session.close();
|
|
1598
|
+
},
|
|
1599
|
+
...session.respondToApproval !== void 0 && { respondToApproval: session.respondToApproval.bind(session) }
|
|
1378
1600
|
};
|
|
1379
1601
|
}
|
|
1380
1602
|
/**
|
|
@@ -1393,26 +1615,104 @@ function scopeProvider(provider, defaults) {
|
|
|
1393
1615
|
...defaults.mcpServers,
|
|
1394
1616
|
...options.mcpServers
|
|
1395
1617
|
};
|
|
1396
|
-
|
|
1618
|
+
if (defaults.gateSession) {
|
|
1619
|
+
const { allowedTools: callAllowed, askTools: callAsk, ...rest } = options;
|
|
1620
|
+
const gate = defaults.gateSession({
|
|
1621
|
+
...callAllowed !== void 0 && { allowedTools: callAllowed },
|
|
1622
|
+
...callAsk !== void 0 && { askTools: callAsk }
|
|
1623
|
+
});
|
|
1624
|
+
const agents = {
|
|
1625
|
+
...defaults.agents,
|
|
1626
|
+
...rest.agents
|
|
1627
|
+
};
|
|
1628
|
+
const skillDirs = [.../* @__PURE__ */ new Set([...defaults.skillDirs, ...rest.skillDirs ?? []])];
|
|
1629
|
+
let session = await provider.createSession({
|
|
1630
|
+
cwd: defaults.cwd,
|
|
1631
|
+
...defaults.instructions !== void 0 && { systemPrompt: defaults.instructions },
|
|
1632
|
+
...rest,
|
|
1633
|
+
...Object.keys(mcpServers).length > 0 && { mcpServers },
|
|
1634
|
+
toolGate: gate.gate,
|
|
1635
|
+
...Object.keys(agents).length > 0 && { agents },
|
|
1636
|
+
...skillDirs.length > 0 && { skillDirs }
|
|
1637
|
+
});
|
|
1638
|
+
session = endOnClose(session, gate.end);
|
|
1639
|
+
if (defaults.supervise) session = defaults.supervise(session);
|
|
1640
|
+
return defaults.recordUsage ? meterSession(session, defaults.recordUsage) : session;
|
|
1641
|
+
}
|
|
1642
|
+
const granted = (defaults.readGrants ? await defaults.readGrants() : []).filter((tool) => defaults.askTools.includes(tool));
|
|
1643
|
+
const allowedTools = [.../* @__PURE__ */ new Set([
|
|
1644
|
+
...defaults.allowedTools,
|
|
1645
|
+
...granted,
|
|
1646
|
+
...options.allowedTools ?? []
|
|
1647
|
+
])];
|
|
1648
|
+
const askTools = [.../* @__PURE__ */ new Set([...defaults.askTools.filter((tool) => !granted.includes(tool)), ...options.askTools ?? []])];
|
|
1397
1649
|
const agents = {
|
|
1398
1650
|
...defaults.agents,
|
|
1399
1651
|
...options.agents
|
|
1400
1652
|
};
|
|
1401
1653
|
const skillDirs = [.../* @__PURE__ */ new Set([...defaults.skillDirs, ...options.skillDirs ?? []])];
|
|
1402
|
-
|
|
1654
|
+
let session = await provider.createSession({
|
|
1403
1655
|
cwd: defaults.cwd,
|
|
1404
1656
|
...defaults.instructions !== void 0 && { systemPrompt: defaults.instructions },
|
|
1405
1657
|
...options,
|
|
1406
1658
|
...Object.keys(mcpServers).length > 0 && { mcpServers },
|
|
1407
1659
|
...allowedTools.length > 0 && { allowedTools },
|
|
1660
|
+
...askTools.length > 0 && { askTools },
|
|
1408
1661
|
...Object.keys(agents).length > 0 && { agents },
|
|
1409
1662
|
...skillDirs.length > 0 && { skillDirs }
|
|
1410
1663
|
});
|
|
1664
|
+
if (defaults.supervise) session = defaults.supervise(session);
|
|
1411
1665
|
return defaults.recordUsage ? meterSession(session, defaults.recordUsage) : session;
|
|
1412
1666
|
}
|
|
1413
1667
|
};
|
|
1414
1668
|
}
|
|
1415
1669
|
/**
|
|
1670
|
+
* A summary that says "nothing has been metered", for a context built without
|
|
1671
|
+
* a recorder (tests, and callers that only need config and state).
|
|
1672
|
+
*
|
|
1673
|
+
* `recorded: false` is the whole point: every number here is an *absence of
|
|
1674
|
+
* data*, not a measured zero, and a reader that treats the difference
|
|
1675
|
+
* seriously must be able to tell them apart. Built fresh per call so a caller
|
|
1676
|
+
* that mutates `days` or `models` cannot poison the next reader.
|
|
1677
|
+
*/
|
|
1678
|
+
function unrecordedSummary() {
|
|
1679
|
+
return {
|
|
1680
|
+
date: utcDay(),
|
|
1681
|
+
todayCostUsd: 0,
|
|
1682
|
+
todayTurns: 0,
|
|
1683
|
+
todayModels: {},
|
|
1684
|
+
totalCostUsd: 0,
|
|
1685
|
+
models: {},
|
|
1686
|
+
retentionDays: 90,
|
|
1687
|
+
recorded: false,
|
|
1688
|
+
days: [],
|
|
1689
|
+
months: []
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* The app's read-only window onto its own spend, in-process like `ctx.state`.
|
|
1694
|
+
*
|
|
1695
|
+
* The app name is closed over here and never taken as an argument, so an app
|
|
1696
|
+
* can only ever read its own ledger: there is no parameter to point at another
|
|
1697
|
+
* app, no HTTP hop to spoof, and nothing on this surface that writes. The
|
|
1698
|
+
* recorder's own reads already filter to the retention window, so what an app
|
|
1699
|
+
* sees is exactly what the host kept.
|
|
1700
|
+
*
|
|
1701
|
+
* Without a recorder nothing is metered, so the honest answer is "no data":
|
|
1702
|
+
* `turns()` is empty and `summary()` reports `recorded: false` rather than a
|
|
1703
|
+
* fabricated zero.
|
|
1704
|
+
*/
|
|
1705
|
+
function appUsage(name, usage) {
|
|
1706
|
+
if (usage === void 0) return {
|
|
1707
|
+
turns: () => Promise.resolve([]),
|
|
1708
|
+
summary: () => Promise.resolve(unrecordedSummary())
|
|
1709
|
+
};
|
|
1710
|
+
return {
|
|
1711
|
+
turns: (query) => usage.turns(name, query),
|
|
1712
|
+
summary: () => usage.summary(name)
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1416
1716
|
* Reads and parses the manifest-declared agent files. The agent's name is
|
|
1417
1717
|
* the filename without the `.md` extension.
|
|
1418
1718
|
*/
|
|
@@ -1450,10 +1750,13 @@ async function resolveAppConfig(store, manifest) {
|
|
|
1450
1750
|
* while required keys are missing the scoped provider fails closed.
|
|
1451
1751
|
*
|
|
1452
1752
|
* When a `usage` recorder is supplied, every session opened through the
|
|
1453
|
-
* returned context reports its per-turn cost to it,
|
|
1454
|
-
*
|
|
1753
|
+
* returned context reports its per-turn cost to it, the app's host-set daily
|
|
1754
|
+
* spend cap (if any) is enforced on every createSession, and `ctx.usage` reads
|
|
1755
|
+
* back the app's own ledger. Without one there is no metering, no cap, and
|
|
1756
|
+
* `ctx.usage` answers "nothing recorded" — the member is required by the
|
|
1757
|
+
* contract, so it is always present.
|
|
1455
1758
|
*/
|
|
1456
|
-
async function buildAppContext(store, manifest, provider, usage) {
|
|
1759
|
+
async function buildAppContext(store, manifest, provider, usage, approvals, gateway) {
|
|
1457
1760
|
const name = manifest.name;
|
|
1458
1761
|
const dataDir = store.appDataDir(name);
|
|
1459
1762
|
const repoDir = store.appRepoDir(name);
|
|
@@ -1470,6 +1773,39 @@ async function buildAppContext(store, manifest, provider, usage) {
|
|
|
1470
1773
|
const mcpServers = interpolate(Object.fromEntries(Object.entries(manifest.mcpServers).filter(([server]) => !excluded.includes(server)).map(([server, { optional: _optional, ...decl }]) => [server, decl])));
|
|
1471
1774
|
const allowedTools = manifest.permissions.allowedTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
|
|
1472
1775
|
const logger = createLogger(name);
|
|
1776
|
+
let askTools = manifest.permissions.askTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
|
|
1777
|
+
const agents = await loadAgents(repoDir, manifest.agents);
|
|
1778
|
+
const gated = gateway !== void 0 && provider.supportsToolGate === true && provider.supportsApprovals === true;
|
|
1779
|
+
const declaresPolicy = manifest.permissions.rules.length > 0 || manifest.permissions.default !== "deny";
|
|
1780
|
+
let gateSession;
|
|
1781
|
+
if (gated) {
|
|
1782
|
+
const base = permissionsPolicy({
|
|
1783
|
+
...manifest.permissions,
|
|
1784
|
+
allowedTools,
|
|
1785
|
+
askTools
|
|
1786
|
+
});
|
|
1787
|
+
const implicit = Object.keys(agents).length > 0 ? [{
|
|
1788
|
+
id: "implicit.Agent",
|
|
1789
|
+
tool: "Agent",
|
|
1790
|
+
decision: "allow"
|
|
1791
|
+
}] : [];
|
|
1792
|
+
gateSession = (extra) => gateway.session(name, compilePolicy({
|
|
1793
|
+
default: base.default,
|
|
1794
|
+
rules: [
|
|
1795
|
+
...base.rules,
|
|
1796
|
+
...implicit,
|
|
1797
|
+
...permissionsPolicy(extra).rules
|
|
1798
|
+
]
|
|
1799
|
+
}));
|
|
1800
|
+
} else if (declaresPolicy) {
|
|
1801
|
+
logger.warn(`AI engine "${provider.name}" cannot enforce policy rules; all tools are disabled for this app`);
|
|
1802
|
+
allowedTools.length = 0;
|
|
1803
|
+
askTools = [];
|
|
1804
|
+
}
|
|
1805
|
+
if (!gated && askTools.length > 0 && provider.supportsApprovals !== true) {
|
|
1806
|
+
logger.warn(`AI engine "${provider.name}" does not support interactive approvals; ask-tier tools are disabled`, { askTools });
|
|
1807
|
+
askTools = [];
|
|
1808
|
+
}
|
|
1473
1809
|
const recordUsage = usage ? async (turn) => {
|
|
1474
1810
|
try {
|
|
1475
1811
|
await usage.record(name, turn);
|
|
@@ -1483,10 +1819,15 @@ async function buildAppContext(store, manifest, provider, usage) {
|
|
|
1483
1819
|
config,
|
|
1484
1820
|
logger,
|
|
1485
1821
|
state: new JsonFileStore(join(dataDir, "state.json")),
|
|
1822
|
+
usage: appUsage(name, usage),
|
|
1486
1823
|
ai: scopeProvider(provider, {
|
|
1487
1824
|
mcpServers,
|
|
1488
1825
|
allowedTools,
|
|
1489
|
-
|
|
1826
|
+
askTools,
|
|
1827
|
+
...gateSession !== void 0 && { gateSession },
|
|
1828
|
+
...(gated || askTools.length > 0) && approvals !== void 0 && { supervise: (session) => superviseSession(session, name, approvals) },
|
|
1829
|
+
...!gated && askTools.length > 0 && { readGrants: async () => (await store.readGrants(name)).tools },
|
|
1830
|
+
agents,
|
|
1490
1831
|
skillDirs: manifest.skills.map((dir) => resolve(repoDir, dir)),
|
|
1491
1832
|
...instructions !== void 0 && { instructions },
|
|
1492
1833
|
cwd: dataDir,
|
|
@@ -1496,6 +1837,255 @@ async function buildAppContext(store, manifest, provider, usage) {
|
|
|
1496
1837
|
})
|
|
1497
1838
|
};
|
|
1498
1839
|
}
|
|
1840
|
+
const APPROVAL_LOG_CAP = 100;
|
|
1841
|
+
/** Settled records kept in memory so a late responder gets 409, not 404. */
|
|
1842
|
+
const SETTLED_CAP = 200;
|
|
1843
|
+
/**
|
|
1844
|
+
* Host-wide bookkeeping for interactive tool approvals. Sessions wrapped by
|
|
1845
|
+
* `superviseSession` register every `approval_required` here, so chat turns
|
|
1846
|
+
* and cron jobs alike show up in one pending list; decisions flow back through
|
|
1847
|
+
* `respond()` to the provider, and every outcome is appended to the app's
|
|
1848
|
+
* `approvals.json` log. The registry also arms the host's timeout policy:
|
|
1849
|
+
* a request nobody decides is denied after {@link DEFAULT_APPROVAL_TIMEOUT_MS}
|
|
1850
|
+
* (overridable via `approvalTimeoutSeconds` in the global config — host
|
|
1851
|
+
* policy, like budgets).
|
|
1852
|
+
*
|
|
1853
|
+
* Always-allow grants (`grants.json`) are written here too, so ask→allow
|
|
1854
|
+
* promotion stays inside manifest bounds in one place.
|
|
1855
|
+
*/
|
|
1856
|
+
var ApprovalRegistry = class {
|
|
1857
|
+
#store;
|
|
1858
|
+
#timeoutMs;
|
|
1859
|
+
#pending = /* @__PURE__ */ new Map();
|
|
1860
|
+
#settled = /* @__PURE__ */ new Map();
|
|
1861
|
+
/** Serializes approvals.json read-modify-write across apps and timers. */
|
|
1862
|
+
#logQueue = Promise.resolve();
|
|
1863
|
+
constructor(store, options = {}) {
|
|
1864
|
+
this.#store = store;
|
|
1865
|
+
this.#timeoutMs = options.timeoutMs ?? 3e5;
|
|
1866
|
+
}
|
|
1867
|
+
/** Applies the host-config timeout override; safe to fire-and-forget at boot. */
|
|
1868
|
+
async init() {
|
|
1869
|
+
const seconds = (await this.#store.readGlobalConfig()).approvalTimeoutSeconds;
|
|
1870
|
+
if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0) this.#timeoutMs = seconds * 1e3;
|
|
1871
|
+
}
|
|
1872
|
+
/** Registers a provider-born approval request and arms the auto-deny timer. */
|
|
1873
|
+
track(app, event, respond) {
|
|
1874
|
+
if (this.#pending.has(event.approvalId)) return;
|
|
1875
|
+
const now = Date.now();
|
|
1876
|
+
const record = {
|
|
1877
|
+
approvalId: event.approvalId,
|
|
1878
|
+
app,
|
|
1879
|
+
tool: event.tool,
|
|
1880
|
+
input: event.input,
|
|
1881
|
+
requestedAt: new Date(now).toISOString(),
|
|
1882
|
+
expiresAt: new Date(now + this.#timeoutMs).toISOString(),
|
|
1883
|
+
status: "pending",
|
|
1884
|
+
...event.ruleId !== void 0 && { ruleId: event.ruleId },
|
|
1885
|
+
...event.reason !== void 0 && { reason: event.reason }
|
|
1886
|
+
};
|
|
1887
|
+
const timer = setTimeout(() => this.#timeout(event.approvalId), this.#timeoutMs);
|
|
1888
|
+
timer.unref?.();
|
|
1889
|
+
this.#pending.set(event.approvalId, {
|
|
1890
|
+
record,
|
|
1891
|
+
respond,
|
|
1892
|
+
timer
|
|
1893
|
+
});
|
|
1894
|
+
this.#mutateLog(app, (approvals) => approvals.push({ ...record }));
|
|
1895
|
+
}
|
|
1896
|
+
/** Attaches the conversation a chat-born approval belongs to (cron ones have none). */
|
|
1897
|
+
annotate(approvalId, fields) {
|
|
1898
|
+
const pending = this.#pending.get(approvalId);
|
|
1899
|
+
if (!pending) return;
|
|
1900
|
+
pending.record.conversationId = fields.conversationId;
|
|
1901
|
+
this.#updateLog(pending.record);
|
|
1902
|
+
}
|
|
1903
|
+
/** The pending or settled record, for endpoint lookups. */
|
|
1904
|
+
get(approvalId) {
|
|
1905
|
+
return this.#pending.get(approvalId)?.record ?? this.#settled.get(approvalId);
|
|
1906
|
+
}
|
|
1907
|
+
/** Snapshots of pending records, optionally narrowed; newest last. */
|
|
1908
|
+
pending(filter = {}) {
|
|
1909
|
+
const records = [];
|
|
1910
|
+
for (const { record } of this.#pending.values()) {
|
|
1911
|
+
if (filter.app !== void 0 && record.app !== filter.app) continue;
|
|
1912
|
+
if (filter.conversationId !== void 0 && record.conversationId !== filter.conversationId) continue;
|
|
1913
|
+
records.push({ ...record });
|
|
1914
|
+
}
|
|
1915
|
+
return records;
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Applies a user decision: settles the record, then forwards the decision to
|
|
1919
|
+
* the session that is suspended on it. Returns 'unknown' for an id this host
|
|
1920
|
+
* never saw and 'resolved' for one already decided, so the endpoint can tell
|
|
1921
|
+
* 404 from 409.
|
|
1922
|
+
*/
|
|
1923
|
+
respond(approvalId, decision) {
|
|
1924
|
+
const pending = this.#pending.get(approvalId);
|
|
1925
|
+
if (!pending) return this.#settled.has(approvalId) ? "resolved" : "unknown";
|
|
1926
|
+
this.#settle(pending, decision.behavior === "allow" ? "allowed" : "denied", decision.behavior === "allow" ? decision.scope : void 0);
|
|
1927
|
+
pending.respond(decision).catch(() => void 0);
|
|
1928
|
+
return "ok";
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* Observes a provider-side resolution (session closed, interrupt) arriving
|
|
1932
|
+
* through the event stream. A registry-initiated decision already settled
|
|
1933
|
+
* the record, so this is a no-op for it.
|
|
1934
|
+
*/
|
|
1935
|
+
resolved(event) {
|
|
1936
|
+
const pending = this.#pending.get(event.approvalId);
|
|
1937
|
+
if (!pending) return;
|
|
1938
|
+
const status = event.behavior === "allow" ? "allowed" : event.reason === "timeout" ? "timeout" : "denied";
|
|
1939
|
+
this.#settle(pending, status);
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Marks an approval whose session's event stream ended without a resolution
|
|
1943
|
+
* (crash, abrupt close) as denied, so nothing stays pending forever.
|
|
1944
|
+
*/
|
|
1945
|
+
abandon(approvalId) {
|
|
1946
|
+
const pending = this.#pending.get(approvalId);
|
|
1947
|
+
if (!pending) return;
|
|
1948
|
+
this.#settle(pending, "denied");
|
|
1949
|
+
}
|
|
1950
|
+
/** Reads the app's always-allow grants. */
|
|
1951
|
+
async grants(app) {
|
|
1952
|
+
return (await this.#store.readGrants(app)).tools;
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Persists an always-allow grant. Validated against the manifest's askTools:
|
|
1956
|
+
* a grant can promote ask→allow but never allow a tool the manifest did not
|
|
1957
|
+
* declare askable.
|
|
1958
|
+
*/
|
|
1959
|
+
async addGrant(app, tool, askTools) {
|
|
1960
|
+
if (!askTools.includes(tool)) throw new AppSdkError(`Tool "${tool}" is not an ask-tier tool of app "${app}"`);
|
|
1961
|
+
const state = await this.#store.readGrants(app);
|
|
1962
|
+
if (state.tools.includes(tool)) return;
|
|
1963
|
+
state.tools.push(tool);
|
|
1964
|
+
await this.#store.writeGrants(app, state);
|
|
1965
|
+
}
|
|
1966
|
+
/** Clears timers and denies everything still pending (host shutdown). */
|
|
1967
|
+
close() {
|
|
1968
|
+
for (const approvalId of [...this.#pending.keys()]) this.abandon(approvalId);
|
|
1969
|
+
}
|
|
1970
|
+
#timeout(approvalId) {
|
|
1971
|
+
const pending = this.#pending.get(approvalId);
|
|
1972
|
+
if (!pending) return;
|
|
1973
|
+
this.#settle(pending, "timeout");
|
|
1974
|
+
pending.respond({
|
|
1975
|
+
behavior: "deny",
|
|
1976
|
+
message: "Approval timed out",
|
|
1977
|
+
reason: "timeout"
|
|
1978
|
+
}).catch(() => void 0);
|
|
1979
|
+
}
|
|
1980
|
+
#settle(pending, status, scope) {
|
|
1981
|
+
clearTimeout(pending.timer);
|
|
1982
|
+
this.#pending.delete(pending.record.approvalId);
|
|
1983
|
+
pending.record.status = status;
|
|
1984
|
+
pending.record.decidedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1985
|
+
if (scope !== void 0) pending.record.scope = scope;
|
|
1986
|
+
this.#settled.set(pending.record.approvalId, pending.record);
|
|
1987
|
+
if (this.#settled.size > SETTLED_CAP) {
|
|
1988
|
+
const oldest = this.#settled.keys().next().value;
|
|
1989
|
+
if (oldest !== void 0) this.#settled.delete(oldest);
|
|
1990
|
+
}
|
|
1991
|
+
this.#updateLog(pending.record);
|
|
1992
|
+
}
|
|
1993
|
+
#updateLog(record) {
|
|
1994
|
+
this.#mutateLog(record.app, (approvals) => {
|
|
1995
|
+
const index = approvals.findIndex((entry) => entry.approvalId === record.approvalId);
|
|
1996
|
+
if (index === -1) approvals.push({ ...record });
|
|
1997
|
+
else approvals[index] = { ...record };
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
#mutateLog(app, mutate) {
|
|
2001
|
+
const update = this.#logQueue.then(async () => {
|
|
2002
|
+
const log = await this.#store.readApprovalLog(app);
|
|
2003
|
+
mutate(log.approvals);
|
|
2004
|
+
if (log.approvals.length > APPROVAL_LOG_CAP) log.approvals.splice(0, log.approvals.length - APPROVAL_LOG_CAP);
|
|
2005
|
+
await this.#store.writeApprovalLog(app, log);
|
|
2006
|
+
});
|
|
2007
|
+
this.#logQueue = update.catch(() => void 0);
|
|
2008
|
+
}
|
|
2009
|
+
/** Resolves when all queued approvals.json writes have landed (tests). */
|
|
2010
|
+
flush() {
|
|
2011
|
+
return this.#logQueue.then(() => void 0);
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
2014
|
+
//#endregion
|
|
2015
|
+
//#region src/chat-bus.ts
|
|
2016
|
+
const RING_CAP = 200;
|
|
2017
|
+
/**
|
|
2018
|
+
* Per-conversation event bus: POST /chat enqueues a turn whose events are
|
|
2019
|
+
* published here, and any number of SSE subscribers replay then follow them.
|
|
2020
|
+
* Ids are monotonic per bus; the ring keeps the last {@link RING_CAP} entries
|
|
2021
|
+
* so a reconnecting subscriber can resume from its Last-Event-ID.
|
|
2022
|
+
*/
|
|
2023
|
+
var ConversationBus = class {
|
|
2024
|
+
#nextId = 1;
|
|
2025
|
+
#ring = [];
|
|
2026
|
+
#wakers = [];
|
|
2027
|
+
publish(event) {
|
|
2028
|
+
const entry = {
|
|
2029
|
+
id: this.#nextId++,
|
|
2030
|
+
event
|
|
2031
|
+
};
|
|
2032
|
+
this.#ring.push(entry);
|
|
2033
|
+
if (this.#ring.length > RING_CAP) this.#ring.shift();
|
|
2034
|
+
for (const wake of this.#wakers.splice(0)) wake();
|
|
2035
|
+
return entry.id;
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Replay then live. With a `lastEventId` still covered by the ring, replay
|
|
2039
|
+
* resumes right after it; one that scrolled out yields a `resync` first.
|
|
2040
|
+
* Without one (a fresh subscriber), replay starts at the current turn — the
|
|
2041
|
+
* entry after the newest `result` — because everything before it is already
|
|
2042
|
+
* served by the history endpoint and would render twice.
|
|
2043
|
+
*
|
|
2044
|
+
* The iterator only ends via the signal (client disconnect); the bus itself
|
|
2045
|
+
* never closes.
|
|
2046
|
+
*/
|
|
2047
|
+
async *subscribe(lastEventId, signal) {
|
|
2048
|
+
let cursor;
|
|
2049
|
+
if (lastEventId === void 0) cursor = this.#turnStart();
|
|
2050
|
+
else {
|
|
2051
|
+
const oldest = this.#ring[0]?.id;
|
|
2052
|
+
if (oldest !== void 0 && lastEventId < oldest - 1) {
|
|
2053
|
+
yield {
|
|
2054
|
+
id: this.#nextId - 1,
|
|
2055
|
+
event: { type: "resync" }
|
|
2056
|
+
};
|
|
2057
|
+
cursor = this.#turnStart();
|
|
2058
|
+
} else cursor = lastEventId + 1;
|
|
2059
|
+
}
|
|
2060
|
+
while (!signal?.aborted) {
|
|
2061
|
+
const next = this.#ring.find((entry) => entry.id >= cursor);
|
|
2062
|
+
if (next) {
|
|
2063
|
+
cursor = next.id + 1;
|
|
2064
|
+
yield next;
|
|
2065
|
+
continue;
|
|
2066
|
+
}
|
|
2067
|
+
await this.#nextPublish(signal);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
/** The id right after the newest `result` in the ring — the in-flight turn's start. */
|
|
2071
|
+
#turnStart() {
|
|
2072
|
+
for (let i = this.#ring.length - 1; i >= 0; i--) {
|
|
2073
|
+
const entry = this.#ring[i];
|
|
2074
|
+
if (entry !== void 0 && entry.event.type === "result") return entry.id + 1;
|
|
2075
|
+
}
|
|
2076
|
+
return this.#ring[0]?.id ?? this.#nextId;
|
|
2077
|
+
}
|
|
2078
|
+
#nextPublish(signal) {
|
|
2079
|
+
return new Promise((resolve) => {
|
|
2080
|
+
const onAbort = () => resolve();
|
|
2081
|
+
this.#wakers.push(() => {
|
|
2082
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2083
|
+
resolve();
|
|
2084
|
+
});
|
|
2085
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
};
|
|
1499
2089
|
//#endregion
|
|
1500
2090
|
//#region src/scheduler.ts
|
|
1501
2091
|
const RUN_LOG_CAP = 50;
|
|
@@ -1955,6 +2545,45 @@ function roCard(title, children) {
|
|
|
1955
2545
|
]);
|
|
1956
2546
|
}
|
|
1957
2547
|
|
|
2548
|
+
// Every tool call passes the host's tool gateway: user overrides first
|
|
2549
|
+
// (never past a manifest deny), then the manifest's rules, then the default.
|
|
2550
|
+
function ruleRow(r) {
|
|
2551
|
+
return el('div', { class: 'ro-item' }, [
|
|
2552
|
+
el('span', { class: 'chip ' + (r.decision === 'allow' ? 'on' : r.decision === 'deny' ? 'off' : ''),
|
|
2553
|
+
text: r.decision }),
|
|
2554
|
+
document.createTextNode(' '),
|
|
2555
|
+
el('code', { text: r.summary }),
|
|
2556
|
+
el('div', { class: 'desc', text: (r.description && r.description !== r.summary ? r.description + ' · ' : '') + 'id: ' + r.id }),
|
|
2557
|
+
]);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
function policyCard(p) {
|
|
2561
|
+
const body = [];
|
|
2562
|
+
if (!p.gated) {
|
|
2563
|
+
body.push(el('div', { class: 'desc', text: 'This AI engine cannot gate individual tool calls; only name-level allow/ask lists apply.' }));
|
|
2564
|
+
}
|
|
2565
|
+
if (p.user.length > 0) {
|
|
2566
|
+
body.push(el('h3', { text: 'Your overrides' }), ...p.user.map(ruleRow));
|
|
2567
|
+
}
|
|
2568
|
+
body.push(el('h3', { text: 'Declared by the app' }));
|
|
2569
|
+
body.push(...(p.manifest.length > 0 ? p.manifest.map(ruleRow) : [el('div', { class: 'desc', text: 'No rules.' })]));
|
|
2570
|
+
body.push(el('div', { class: 'desc', text: 'Anything else: ' + p.default + '.' }));
|
|
2571
|
+
return roCard('Tool policy', body);
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
function toolCallsCard(calls) {
|
|
2575
|
+
return roCard('Recent tool calls', [usageTable(
|
|
2576
|
+
['When', 'Tool', 'Decision', 'Why', 'Input'],
|
|
2577
|
+
calls.slice().reverse().map((c) => [
|
|
2578
|
+
new Date(c.at).toLocaleString(),
|
|
2579
|
+
c.tool,
|
|
2580
|
+
c.decision + ' (' + c.source + ')',
|
|
2581
|
+
c.reason,
|
|
2582
|
+
c.inputPreview.length > 80 ? c.inputPreview.slice(0, 80) + '…' : c.inputPreview,
|
|
2583
|
+
]),
|
|
2584
|
+
)]);
|
|
2585
|
+
}
|
|
2586
|
+
|
|
1958
2587
|
function renderManifest(m) {
|
|
1959
2588
|
const root = document.getElementById('manifest-cards');
|
|
1960
2589
|
root.replaceChildren();
|
|
@@ -2000,10 +2629,8 @@ function renderManifest(m) {
|
|
|
2000
2629
|
]))));
|
|
2001
2630
|
}
|
|
2002
2631
|
|
|
2003
|
-
if (m.
|
|
2004
|
-
|
|
2005
|
-
el('div', {}, [el('code', { text: t })]))));
|
|
2006
|
-
}
|
|
2632
|
+
if (m.policy) cards.push(policyCard(m.policy));
|
|
2633
|
+
if (m.toolCalls && m.toolCalls.length > 0) cards.push(toolCallsCard(m.toolCalls));
|
|
2007
2634
|
|
|
2008
2635
|
if (m.jobs.length > 0) {
|
|
2009
2636
|
cards.push(roCard('Jobs', m.jobs.map((j) => el('div', { class: 'ro-item' }, [
|
|
@@ -2037,7 +2664,16 @@ async function load() {
|
|
|
2037
2664
|
return;
|
|
2038
2665
|
}
|
|
2039
2666
|
render(await res.json());
|
|
2040
|
-
if (manRes.ok)
|
|
2667
|
+
if (manRes.ok) {
|
|
2668
|
+
const m = await manRes.json();
|
|
2669
|
+
const [policyRes, callsRes] = await Promise.all([
|
|
2670
|
+
fetch('/api/apps/' + APP + '/policy'),
|
|
2671
|
+
fetch('/api/apps/' + APP + '/tool-calls?limit=20'),
|
|
2672
|
+
]);
|
|
2673
|
+
if (policyRes.ok) m.policy = await policyRes.json();
|
|
2674
|
+
if (callsRes.ok) m.toolCalls = (await callsRes.json()).calls;
|
|
2675
|
+
renderManifest(m);
|
|
2676
|
+
}
|
|
2041
2677
|
}
|
|
2042
2678
|
|
|
2043
2679
|
document.getElementById('save').addEventListener('click', async () => {
|
|
@@ -2065,6 +2701,193 @@ load();
|
|
|
2065
2701
|
`;
|
|
2066
2702
|
}
|
|
2067
2703
|
//#endregion
|
|
2704
|
+
//#region src/tool-gateway.ts
|
|
2705
|
+
const INPUT_PREVIEW_CHARS = 1e3;
|
|
2706
|
+
/** Calls the gate answered `ask` and nobody has decided yet, bounded per host. */
|
|
2707
|
+
const PENDING_ASK_CAP = 500;
|
|
2708
|
+
/** The audit ledger is trimmed to this many newest lines... */
|
|
2709
|
+
const AUDIT_KEEP = 5e3;
|
|
2710
|
+
/** ...checked once every this many appends per app. */
|
|
2711
|
+
const AUDIT_TRIM_EVERY = 500;
|
|
2712
|
+
/**
|
|
2713
|
+
* The host's tool gateway: every tool call any app session makes — chat,
|
|
2714
|
+
* cron, app routes; built-in, MCP, subagent — is decided here (see
|
|
2715
|
+
* `SessionOptions.toolGate`). Layers, in order:
|
|
2716
|
+
*
|
|
2717
|
+
* 1. the manifest policy (rules, then `allowedTools`/`askTools` sugar);
|
|
2718
|
+
* 2. the user's overrides (`policy.json`, plus legacy `grants.json`) — which
|
|
2719
|
+
* may tighten anything but never loosen past a manifest `deny`;
|
|
2720
|
+
* 3. session grants ("approve for session"), keyed by the rule that asked;
|
|
2721
|
+
*
|
|
2722
|
+
* and every decision is appended to the app's `tool-calls.jsonl` audit
|
|
2723
|
+
* ledger. `ask` verdicts go on to the provider's interactive approval flow;
|
|
2724
|
+
* `applyDecision` turns the user's answer into a session grant or a
|
|
2725
|
+
* persistent user rule.
|
|
2726
|
+
*/
|
|
2727
|
+
var ToolGateway = class {
|
|
2728
|
+
#store;
|
|
2729
|
+
#sessionGrants = /* @__PURE__ */ new Map();
|
|
2730
|
+
#pendingAsks = /* @__PURE__ */ new Map();
|
|
2731
|
+
#appendCounts = /* @__PURE__ */ new Map();
|
|
2732
|
+
/** Serializes ledger appends/trims and policy.json writes. */
|
|
2733
|
+
#queue = Promise.resolve();
|
|
2734
|
+
constructor(store) {
|
|
2735
|
+
this.#store = store;
|
|
2736
|
+
}
|
|
2737
|
+
/** Builds the gate for one new session of `app`. */
|
|
2738
|
+
session(app, manifestPolicy) {
|
|
2739
|
+
const sessionKey = randomUUID();
|
|
2740
|
+
const gate = async (call) => this.#decide(app, sessionKey, manifestPolicy, call);
|
|
2741
|
+
return {
|
|
2742
|
+
gate,
|
|
2743
|
+
end: () => this.#sessionGrants.delete(sessionKey)
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
/** The user override layer as stored: policy.json rules, then legacy grants. */
|
|
2747
|
+
async userPolicy(app) {
|
|
2748
|
+
const [policy, grants] = await Promise.all([this.#store.readUserPolicy(app), this.#store.readGrants(app)]);
|
|
2749
|
+
return { rules: [...policy.rules, ...grants.tools.map((tool) => ({
|
|
2750
|
+
id: `grants.${tool}`,
|
|
2751
|
+
tool,
|
|
2752
|
+
decision: "allow"
|
|
2753
|
+
}))] };
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Applies the scope of a user's answer to a call this gateway sent to
|
|
2757
|
+
* `ask`: `session` grants the asking rule for the rest of the session,
|
|
2758
|
+
* `always` appends an allow rule with the asking rule's scope to the app's
|
|
2759
|
+
* policy.json. Returns false for approvals this gateway never saw (legacy
|
|
2760
|
+
* name-level sessions), so the caller can fall back.
|
|
2761
|
+
*/
|
|
2762
|
+
async applyDecision(approvalId, decision) {
|
|
2763
|
+
const pending = this.#pendingAsks.get(approvalId);
|
|
2764
|
+
if (!pending) return false;
|
|
2765
|
+
this.#pendingAsks.delete(approvalId);
|
|
2766
|
+
if (decision.behavior !== "allow") return true;
|
|
2767
|
+
if (decision.scope === "session" || decision.scope === "always") {
|
|
2768
|
+
let grants = this.#sessionGrants.get(pending.sessionKey);
|
|
2769
|
+
if (!grants) {
|
|
2770
|
+
grants = /* @__PURE__ */ new Set();
|
|
2771
|
+
this.#sessionGrants.set(pending.sessionKey, grants);
|
|
2772
|
+
}
|
|
2773
|
+
grants.add(pending.grantKey);
|
|
2774
|
+
}
|
|
2775
|
+
if (decision.scope === "always") await this.#persistAlways(pending);
|
|
2776
|
+
return true;
|
|
2777
|
+
}
|
|
2778
|
+
/** The newest audit lines for an app, newest last. */
|
|
2779
|
+
async recentCalls(app, limit = 100) {
|
|
2780
|
+
await this.flush();
|
|
2781
|
+
return (await this.#store.readToolCalls(app)).slice(-limit);
|
|
2782
|
+
}
|
|
2783
|
+
/** Resolves when queued ledger and policy writes have landed (tests). */
|
|
2784
|
+
flush() {
|
|
2785
|
+
return this.#queue.then(() => void 0);
|
|
2786
|
+
}
|
|
2787
|
+
async #decide(app, sessionKey, manifestPolicy, call) {
|
|
2788
|
+
const manifest = evaluatePolicy(manifestPolicy, call);
|
|
2789
|
+
let verdict = manifest;
|
|
2790
|
+
let source = manifest.ruleId !== void 0 ? "manifest" : "default";
|
|
2791
|
+
if (manifest.decision !== "deny") {
|
|
2792
|
+
const user = evaluatePolicy(compilePolicy({
|
|
2793
|
+
default: "deny",
|
|
2794
|
+
...await this.userPolicy(app)
|
|
2795
|
+
}), call);
|
|
2796
|
+
if (user.ruleId !== void 0) {
|
|
2797
|
+
verdict = user;
|
|
2798
|
+
source = "user";
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
let result;
|
|
2802
|
+
if (verdict.decision === "ask") {
|
|
2803
|
+
const grantKey = verdict.ruleId ?? `default:${call.tool}`;
|
|
2804
|
+
if (this.#sessionGrants.get(sessionKey)?.has(grantKey)) {
|
|
2805
|
+
source = "session";
|
|
2806
|
+
result = { decision: "allow" };
|
|
2807
|
+
} else {
|
|
2808
|
+
this.#trackAsk(call.toolUseId, {
|
|
2809
|
+
app,
|
|
2810
|
+
sessionKey,
|
|
2811
|
+
grantKey,
|
|
2812
|
+
tool: call.tool,
|
|
2813
|
+
...verdict.rule !== void 0 && { rule: verdict.rule }
|
|
2814
|
+
});
|
|
2815
|
+
result = {
|
|
2816
|
+
decision: "ask",
|
|
2817
|
+
...verdict.ruleId !== void 0 && { ruleId: verdict.ruleId },
|
|
2818
|
+
reason: verdict.reason
|
|
2819
|
+
};
|
|
2820
|
+
}
|
|
2821
|
+
} else if (verdict.decision === "allow") result = { decision: "allow" };
|
|
2822
|
+
else result = {
|
|
2823
|
+
decision: "deny",
|
|
2824
|
+
reason: `Denied by policy: ${verdict.reason}`
|
|
2825
|
+
};
|
|
2826
|
+
this.#audit(app, {
|
|
2827
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2828
|
+
tool: call.tool,
|
|
2829
|
+
toolUseId: call.toolUseId,
|
|
2830
|
+
inputPreview: preview(call.input),
|
|
2831
|
+
decision: result.decision,
|
|
2832
|
+
source,
|
|
2833
|
+
...verdict.ruleId !== void 0 && { ruleId: verdict.ruleId },
|
|
2834
|
+
reason: source === "session" ? `session grant: ${verdict.reason}` : verdict.reason
|
|
2835
|
+
});
|
|
2836
|
+
return result;
|
|
2837
|
+
}
|
|
2838
|
+
#trackAsk(toolUseId, pending) {
|
|
2839
|
+
this.#pendingAsks.set(toolUseId, pending);
|
|
2840
|
+
if (this.#pendingAsks.size > PENDING_ASK_CAP) {
|
|
2841
|
+
const oldest = this.#pendingAsks.keys().next().value;
|
|
2842
|
+
if (oldest !== void 0) this.#pendingAsks.delete(oldest);
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
#persistAlways(pending) {
|
|
2846
|
+
const scope = pending.rule ?? {
|
|
2847
|
+
tool: pending.tool,
|
|
2848
|
+
decision: "ask"
|
|
2849
|
+
};
|
|
2850
|
+
const rule = {
|
|
2851
|
+
id: `always.${pending.grantKey}`,
|
|
2852
|
+
tool: scope.tool,
|
|
2853
|
+
...scope.input !== void 0 && { input: scope.input },
|
|
2854
|
+
decision: "allow",
|
|
2855
|
+
description: `Always allowed: ${describeRule(scope)}`
|
|
2856
|
+
};
|
|
2857
|
+
const write = this.#queue.then(async () => {
|
|
2858
|
+
const policy = await this.#store.readUserPolicy(pending.app);
|
|
2859
|
+
if (policy.rules.some((existing) => existing.id === rule.id)) return;
|
|
2860
|
+
policy.rules.unshift(rule);
|
|
2861
|
+
await this.#store.writeUserPolicy(pending.app, policy);
|
|
2862
|
+
});
|
|
2863
|
+
this.#queue = write.catch(() => void 0);
|
|
2864
|
+
return write;
|
|
2865
|
+
}
|
|
2866
|
+
#audit(app, record) {
|
|
2867
|
+
const write = this.#queue.then(async () => {
|
|
2868
|
+
await this.#store.appendToolCall(app, record);
|
|
2869
|
+
const count = (this.#appendCounts.get(app) ?? 0) + 1;
|
|
2870
|
+
this.#appendCounts.set(app, count);
|
|
2871
|
+
if (count % AUDIT_TRIM_EVERY === 0) {
|
|
2872
|
+
const records = await this.#store.readToolCalls(app);
|
|
2873
|
+
if (records.length > AUDIT_KEEP) await this.#store.writeToolCalls(app, records.slice(-5e3));
|
|
2874
|
+
}
|
|
2875
|
+
});
|
|
2876
|
+
this.#queue = write.catch((err) => {
|
|
2877
|
+
console.warn(`alashi: failed to write tool-call audit for "${app}": ${String(err)}`);
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
};
|
|
2881
|
+
function preview(input) {
|
|
2882
|
+
let json;
|
|
2883
|
+
try {
|
|
2884
|
+
json = JSON.stringify(input) ?? String(input);
|
|
2885
|
+
} catch {
|
|
2886
|
+
json = String(input);
|
|
2887
|
+
}
|
|
2888
|
+
return json.length > INPUT_PREVIEW_CHARS ? `${json.slice(0, INPUT_PREVIEW_CHARS)}…` : json;
|
|
2889
|
+
}
|
|
2890
|
+
//#endregion
|
|
2068
2891
|
//#region src/server.ts
|
|
2069
2892
|
const CONTENT_TYPES = {
|
|
2070
2893
|
".html": "text/html; charset=utf-8",
|
|
@@ -2136,21 +2959,42 @@ function appErrorPage(name, err) {
|
|
|
2136
2959
|
* GET /api/apps/:name/usage recorded AI spend: today's total, a daily series,
|
|
2137
2960
|
* per-model token totals, monthly rollups,
|
|
2138
2961
|
* `recorded`, and the budget status
|
|
2962
|
+
* GET /api/apps/:name/turns the per-turn ledger that summary folds: `{ turns }`,
|
|
2963
|
+
* oldest first, each with `at`, `costUsd`, and the
|
|
2964
|
+
* session/model attribution the buckets drop.
|
|
2965
|
+
* ?since=<timestamp> (inclusive) and ?session=<id>
|
|
2966
|
+
* narrow it, ANDed; an unreadable `since` is a 400
|
|
2139
2967
|
* GET /api/apps/:name/budget today's spend against the app's daily cap,
|
|
2140
2968
|
* plus `recorded`
|
|
2141
2969
|
* PUT /api/apps/:name/budget { dailyUsd: number | null } -> set or clear the cap
|
|
2142
2970
|
* (a host setting; apps cannot set their own)
|
|
2143
|
-
* POST /api/apps/:name/chat { message, conversationId? } ->
|
|
2144
|
-
*
|
|
2145
|
-
*
|
|
2146
|
-
*
|
|
2971
|
+
* POST /api/apps/:name/chat { message, conversationId? } -> 202 { conversationId };
|
|
2972
|
+
* the turn runs detached and its AgentEvents are published
|
|
2973
|
+
* on the conversation's event bus (omit conversationId to
|
|
2974
|
+
* start a new conversation; the id is also echoed in the
|
|
2975
|
+
* x-conversation-id response header; 402 when the app is
|
|
2976
|
+
* over its daily spend cap)
|
|
2977
|
+
* GET /api/apps/:name/chat/:conversationId/events standing SSE from the conversation
|
|
2978
|
+
* bus: replay from Last-Event-ID (or ?lastEventId=), then
|
|
2979
|
+
* live; a scrolled-out id gets a `resync` event first
|
|
2980
|
+
* GET /api/apps/:name/chat/:conversationId/pending-approvals reconnect snapshot of
|
|
2981
|
+
* undecided tool approvals for this conversation
|
|
2982
|
+
* POST /api/apps/:name/chat/:conversationId/approvals/:approvalId
|
|
2983
|
+
* { behavior: "allow"|"deny", scope?, message? } -> decide a
|
|
2984
|
+
* pending approval (scope "always" persists a grant);
|
|
2985
|
+
* 404 unknown/expired, 409 already decided
|
|
2986
|
+
* GET /api/approvals host-wide pending tool approvals (chat and cron alike)
|
|
2987
|
+
* POST /api/approvals/:approvalId decide a pending approval regardless of source
|
|
2147
2988
|
* GET /api/apps/:name/chat/conversations conversations, most recent first
|
|
2148
2989
|
* POST /api/apps/:name/chat/conversations { sessionId?, title? } -> create a conversation,
|
|
2149
2990
|
* optionally adopting an existing provider session (e.g. an
|
|
2150
2991
|
* app-run investigation) so chat continues where it left off
|
|
2151
2992
|
* GET /api/apps/:name/chat/:conversationId/history past turns from the provider transcript
|
|
2993
|
+
* plus the conversation's settled approval records
|
|
2152
2994
|
* GET /api/apps/:name/jobs manifest-declared jobs with schedule/status
|
|
2153
2995
|
* POST /api/apps/:name/jobs/:job/run trigger a job now (409 while already running)
|
|
2996
|
+
* GET /api/apps/:name/policy effective tool policy: user overrides + manifest rules
|
|
2997
|
+
* GET /api/apps/:name/tool-calls?limit= newest gated tool calls (audit ledger)
|
|
2154
2998
|
* GET /api/apps/:name/manifest read-only app definition: instructions, agents,
|
|
2155
2999
|
* skills (with dir contents), allowed tools,
|
|
2156
3000
|
* MCP servers (with configured state), jobs, model
|
|
@@ -2168,9 +3012,19 @@ function createServer(store, provider, options = {}) {
|
|
|
2168
3012
|
const apps = /* @__PURE__ */ new Map();
|
|
2169
3013
|
const sessions = /* @__PURE__ */ new Map();
|
|
2170
3014
|
const usage = new UsageRecorder(store);
|
|
3015
|
+
const approvals = new ApprovalRegistry(store);
|
|
3016
|
+
const gateway = new ToolGateway(store);
|
|
3017
|
+
approvals.init().catch((err) => {
|
|
3018
|
+
console.warn(`alashi: failed to read approval timeout config: ${String(err)}`);
|
|
3019
|
+
});
|
|
3020
|
+
store.listInstalled().then((records) => Promise.all(records.map((record) => usage.compact(record.name).catch((err) => {
|
|
3021
|
+
console.warn(`alashi: failed to compact turns.jsonl for "${record.name}": ${String(err)}`);
|
|
3022
|
+
})))).catch((err) => {
|
|
3023
|
+
console.warn(`alashi: failed to compact turn ledgers: ${String(err)}`);
|
|
3024
|
+
});
|
|
2171
3025
|
async function startApp(name) {
|
|
2172
3026
|
const { definition, manifest } = await loadApp(store.appRepoDir(name));
|
|
2173
|
-
const ctx = await buildAppContext(store, manifest, provider, usage);
|
|
3027
|
+
const ctx = await buildAppContext(store, manifest, provider, usage, approvals, gateway);
|
|
2174
3028
|
return {
|
|
2175
3029
|
manifest,
|
|
2176
3030
|
ctx,
|
|
@@ -2215,6 +3069,28 @@ function createServer(store, provider, options = {}) {
|
|
|
2215
3069
|
}
|
|
2216
3070
|
return session;
|
|
2217
3071
|
}
|
|
3072
|
+
/** One bus per conversation; created on first use, kept for reconnects. */
|
|
3073
|
+
const buses = /* @__PURE__ */ new Map();
|
|
3074
|
+
function getBus(name, conversationId) {
|
|
3075
|
+
const key = `${name}/${conversationId}`;
|
|
3076
|
+
let bus = buses.get(key);
|
|
3077
|
+
if (!bus) {
|
|
3078
|
+
bus = new ConversationBus();
|
|
3079
|
+
buses.set(key, bus);
|
|
3080
|
+
}
|
|
3081
|
+
return bus;
|
|
3082
|
+
}
|
|
3083
|
+
/**
|
|
3084
|
+
* Per-conversation turn FIFO: POST /chat enqueues and returns 202, the
|
|
3085
|
+
* detached runner drains one send() at a time (providers reject concurrent
|
|
3086
|
+
* turns on one session). Runners publish their own failures to the bus, so
|
|
3087
|
+
* a queued promise never rejects into the void.
|
|
3088
|
+
*/
|
|
3089
|
+
const turnQueues = /* @__PURE__ */ new Map();
|
|
3090
|
+
function enqueueTurn(key, run) {
|
|
3091
|
+
const next = (turnQueues.get(key) ?? Promise.resolve()).then(run);
|
|
3092
|
+
turnQueues.set(key, next.catch(() => void 0));
|
|
3093
|
+
}
|
|
2218
3094
|
const scheduler = new Scheduler(store, getApp);
|
|
2219
3095
|
/** Rebuilds the request with `prefix` stripped and hands it to the app's handler. */
|
|
2220
3096
|
function dispatch(handler, raw, prefix) {
|
|
@@ -2329,6 +3205,22 @@ ${rows}
|
|
|
2329
3205
|
</tbody>
|
|
2330
3206
|
<tfoot><tr><td colspan="4" class="muted" title="${escapeHtml(TODAY_COLUMN_TITLE)}">AI spend today (UTC)</td><td class="muted" style="text-align:right">${totalToday}</td><td></td></tr></tfoot>
|
|
2331
3207
|
</table>`;
|
|
3208
|
+
const pendingApprovals = approvals.pending();
|
|
3209
|
+
const approvalsSection = pendingApprovals.length === 0 ? "" : `<h2>Pending tool approvals</h2>
|
|
3210
|
+
<div class="panel">
|
|
3211
|
+
<table>
|
|
3212
|
+
<thead><tr><th>App</th><th>Tool</th><th>Requested</th><th>Expires</th><th>Where</th></tr></thead>
|
|
3213
|
+
<tbody>
|
|
3214
|
+
${pendingApprovals.map((approval) => `<tr>
|
|
3215
|
+
<td><a href="/app/${escapeHtml(approval.app)}/">${escapeHtml(approval.app)}</a></td>
|
|
3216
|
+
<td><code>${escapeHtml(approval.tool)}</code></td>
|
|
3217
|
+
<td class="muted">${escapeHtml(approval.requestedAt)}</td>
|
|
3218
|
+
<td class="muted">${escapeHtml(approval.expiresAt)}</td>
|
|
3219
|
+
<td class="muted">${approval.conversationId ? "chat" : "background"}</td>
|
|
3220
|
+
</tr>`).join("\n")}
|
|
3221
|
+
</tbody>
|
|
3222
|
+
</table>
|
|
3223
|
+
</div>`;
|
|
2332
3224
|
return c.html(`<!doctype html>
|
|
2333
3225
|
<html lang="en">
|
|
2334
3226
|
<head>
|
|
@@ -2345,6 +3237,7 @@ ${rows}
|
|
|
2345
3237
|
<div class="panel">
|
|
2346
3238
|
${body}
|
|
2347
3239
|
</div>
|
|
3240
|
+
${approvalsSection}
|
|
2348
3241
|
</main>
|
|
2349
3242
|
</body>
|
|
2350
3243
|
</html>
|
|
@@ -2390,6 +3283,33 @@ ${body}
|
|
|
2390
3283
|
budget: budgetStatusFor(summary, appBudget(config, name))
|
|
2391
3284
|
});
|
|
2392
3285
|
});
|
|
3286
|
+
/**
|
|
3287
|
+
* The per-turn ledger the usage summary is a fold over: one entry per metered
|
|
3288
|
+
* turn, oldest first, carrying the session attribution the day/month buckets
|
|
3289
|
+
* throw away. `?since=` and `?session=` are optional and ANDed.
|
|
3290
|
+
*
|
|
3291
|
+
* `since` is normalised to a canonical ISO instant before it reaches the
|
|
3292
|
+
* recorder, whose bound is compared lexically against the stored `at`. That
|
|
3293
|
+
* keeps a bare `2026-03-02` working as "from that day on" while making an
|
|
3294
|
+
* offset-bearing timestamp (`...T12:00+05:30`) mean the instant it names
|
|
3295
|
+
* rather than whatever its digits happen to sort next to.
|
|
3296
|
+
*/
|
|
3297
|
+
app.get("/api/apps/:name/turns", async (c) => {
|
|
3298
|
+
const name = c.req.param("name");
|
|
3299
|
+
if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
3300
|
+
const rawSince = c.req.query("since");
|
|
3301
|
+
let since;
|
|
3302
|
+
if (rawSince !== void 0) {
|
|
3303
|
+
const parsed = Date.parse(rawSince);
|
|
3304
|
+
if (Number.isNaN(parsed)) return c.json({ error: `"since" must be a timestamp, got "${rawSince}"` }, 400);
|
|
3305
|
+
since = new Date(parsed).toISOString();
|
|
3306
|
+
}
|
|
3307
|
+
const sessionId = c.req.query("session");
|
|
3308
|
+
return c.json({ turns: await usage.turns(name, {
|
|
3309
|
+
...since !== void 0 && { since },
|
|
3310
|
+
...sessionId !== void 0 && { sessionId }
|
|
3311
|
+
}) });
|
|
3312
|
+
});
|
|
2393
3313
|
app.get("/api/apps/:name/budget", async (c) => {
|
|
2394
3314
|
const name = c.req.param("name");
|
|
2395
3315
|
if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
@@ -2458,7 +3378,12 @@ ${body}
|
|
|
2458
3378
|
const convo = conversations.find((conv) => conv.id === conversationId);
|
|
2459
3379
|
if (!convo) return c.json({ error: "Unknown conversation" }, 404);
|
|
2460
3380
|
const messages = convo.sessionId ? await provider.history?.(convo.sessionId) ?? [] : [];
|
|
2461
|
-
|
|
3381
|
+
await approvals.flush();
|
|
3382
|
+
const settled = (await store.readApprovalLog(name)).approvals.filter((entry) => entry.conversationId === conversationId && entry.status !== "pending");
|
|
3383
|
+
return c.json({
|
|
3384
|
+
messages,
|
|
3385
|
+
approvals: settled
|
|
3386
|
+
});
|
|
2462
3387
|
});
|
|
2463
3388
|
/** Drops the cached app and its warm sessions so the next request rebuilds with fresh config. */
|
|
2464
3389
|
function restartApp(name) {
|
|
@@ -2469,6 +3394,33 @@ ${body}
|
|
|
2469
3394
|
session.then((s) => s.close()).catch(() => void 0);
|
|
2470
3395
|
}
|
|
2471
3396
|
}
|
|
3397
|
+
app.get("/api/apps/:name/policy", async (c) => {
|
|
3398
|
+
const name = c.req.param("name");
|
|
3399
|
+
const running = await getApp(name);
|
|
3400
|
+
if (!running) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
3401
|
+
const manifestPolicy = permissionsPolicy(running.manifest.permissions);
|
|
3402
|
+
const user = await gateway.userPolicy(name);
|
|
3403
|
+
const describe = (rule) => ({
|
|
3404
|
+
...rule,
|
|
3405
|
+
summary: describeRule(rule)
|
|
3406
|
+
});
|
|
3407
|
+
return c.json({
|
|
3408
|
+
gated: provider.supportsToolGate === true && provider.supportsApprovals === true,
|
|
3409
|
+
default: manifestPolicy.default,
|
|
3410
|
+
user: user.rules.map(describe),
|
|
3411
|
+
manifest: manifestPolicy.rules.map((rule, index) => describe({
|
|
3412
|
+
...rule,
|
|
3413
|
+
id: rule.id ?? `rules.${index}`
|
|
3414
|
+
}))
|
|
3415
|
+
});
|
|
3416
|
+
});
|
|
3417
|
+
app.get("/api/apps/:name/tool-calls", async (c) => {
|
|
3418
|
+
const name = c.req.param("name");
|
|
3419
|
+
if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
3420
|
+
const raw = Number(c.req.query("limit") ?? 100);
|
|
3421
|
+
const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 1e3) : 100;
|
|
3422
|
+
return c.json({ calls: await gateway.recentCalls(name, limit) });
|
|
3423
|
+
});
|
|
2472
3424
|
app.get("/api/apps/:name/manifest", async (c) => {
|
|
2473
3425
|
const name = c.req.param("name");
|
|
2474
3426
|
const running = await getApp(name);
|
|
@@ -2611,8 +3563,12 @@ ${body}
|
|
|
2611
3563
|
} catch (err) {
|
|
2612
3564
|
return c.json({ error: `Failed to start AI session: ${String(err)}` }, 502);
|
|
2613
3565
|
}
|
|
2614
|
-
|
|
2615
|
-
|
|
3566
|
+
const bus = getBus(name, conversation.id);
|
|
3567
|
+
bus.publish({
|
|
3568
|
+
type: "user_message",
|
|
3569
|
+
text: message
|
|
3570
|
+
});
|
|
3571
|
+
enqueueTurn(`${name}/${conversation.id}`, async () => {
|
|
2616
3572
|
try {
|
|
2617
3573
|
for await (const event of session.send(message)) {
|
|
2618
3574
|
if (event.type === "session") {
|
|
@@ -2620,23 +3576,111 @@ ${body}
|
|
|
2620
3576
|
conversation.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2621
3577
|
await store.writeChatState(name, state);
|
|
2622
3578
|
}
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
data: JSON.stringify(event)
|
|
2626
|
-
});
|
|
3579
|
+
if (event.type === "approval_required") approvals.annotate(event.approvalId, { conversationId: conversation.id });
|
|
3580
|
+
bus.publish(event);
|
|
2627
3581
|
}
|
|
2628
3582
|
} catch (err) {
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
ok: false,
|
|
2634
|
-
error: String(err)
|
|
2635
|
-
})
|
|
3583
|
+
bus.publish({
|
|
3584
|
+
type: "result",
|
|
3585
|
+
ok: false,
|
|
3586
|
+
error: String(err)
|
|
2636
3587
|
});
|
|
2637
3588
|
}
|
|
2638
3589
|
});
|
|
3590
|
+
c.header("x-conversation-id", conversation.id);
|
|
3591
|
+
return c.json({ conversationId: conversation.id }, 202);
|
|
2639
3592
|
});
|
|
3593
|
+
app.get("/api/apps/:name/chat/:conversationId/events", async (c) => {
|
|
3594
|
+
const name = c.req.param("name");
|
|
3595
|
+
if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
3596
|
+
const conversationId = c.req.param("conversationId");
|
|
3597
|
+
const { conversations } = await getChatState(name);
|
|
3598
|
+
if (!conversations.some((conv) => conv.id === conversationId)) return c.json({ error: "Unknown conversation" }, 404);
|
|
3599
|
+
const raw = c.req.header("last-event-id") ?? c.req.query("lastEventId");
|
|
3600
|
+
const lastEventId = raw !== void 0 && /^\d+$/.test(raw) ? Number(raw) : void 0;
|
|
3601
|
+
const bus = getBus(name, conversationId);
|
|
3602
|
+
const signal = c.req.raw.signal;
|
|
3603
|
+
return streamSSE(c, async (stream) => {
|
|
3604
|
+
for await (const entry of bus.subscribe(lastEventId, signal)) await stream.writeSSE({
|
|
3605
|
+
id: String(entry.id),
|
|
3606
|
+
data: JSON.stringify(entry.event)
|
|
3607
|
+
});
|
|
3608
|
+
});
|
|
3609
|
+
});
|
|
3610
|
+
app.get("/api/apps/:name/chat/:conversationId/pending-approvals", async (c) => {
|
|
3611
|
+
const name = c.req.param("name");
|
|
3612
|
+
if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
3613
|
+
return c.json({ approvals: approvals.pending({
|
|
3614
|
+
app: name,
|
|
3615
|
+
conversationId: c.req.param("conversationId")
|
|
3616
|
+
}) });
|
|
3617
|
+
});
|
|
3618
|
+
/** Parses an approval decision body; returns undefined (with a 400 sent) when invalid. */
|
|
3619
|
+
function parseDecision(body) {
|
|
3620
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return void 0;
|
|
3621
|
+
const { behavior, scope, message } = body;
|
|
3622
|
+
if (behavior === "allow") {
|
|
3623
|
+
if (scope !== void 0 && scope !== "once" && scope !== "session" && scope !== "always") return;
|
|
3624
|
+
return {
|
|
3625
|
+
behavior: "allow",
|
|
3626
|
+
...scope !== void 0 && { scope }
|
|
3627
|
+
};
|
|
3628
|
+
}
|
|
3629
|
+
if (behavior === "deny") {
|
|
3630
|
+
if (message !== void 0 && typeof message !== "string") return void 0;
|
|
3631
|
+
return {
|
|
3632
|
+
behavior: "deny",
|
|
3633
|
+
...message !== void 0 && { message },
|
|
3634
|
+
reason: "user"
|
|
3635
|
+
};
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
/**
|
|
3639
|
+
* Decides one pending approval. `expect` narrows which records this route
|
|
3640
|
+
* may touch (the conversation-scoped route cannot decide another app's or
|
|
3641
|
+
* conversation's approvals).
|
|
3642
|
+
*/
|
|
3643
|
+
async function decideApproval(c, approvalId, expect) {
|
|
3644
|
+
let body;
|
|
3645
|
+
try {
|
|
3646
|
+
body = await c.req.json();
|
|
3647
|
+
} catch {
|
|
3648
|
+
return c.json({ error: "Body must be JSON" }, 400);
|
|
3649
|
+
}
|
|
3650
|
+
const decision = parseDecision(body);
|
|
3651
|
+
if (!decision) return c.json({ error: "Body must be { \"behavior\": \"allow\"|\"deny\", \"scope\"?, \"message\"? }" }, 400);
|
|
3652
|
+
const record = approvals.get(approvalId);
|
|
3653
|
+
if (!record || expect !== void 0 && (record.app !== expect.app || expect.conversationId !== void 0 && record.conversationId !== expect.conversationId)) return c.json({ error: `Unknown approval "${approvalId}"` }, 404);
|
|
3654
|
+
let gatewayHandled;
|
|
3655
|
+
try {
|
|
3656
|
+
gatewayHandled = record.status === "pending" && await gateway.applyDecision(approvalId, decision);
|
|
3657
|
+
} catch (err) {
|
|
3658
|
+
return c.json({ error: `Failed to persist grant: ${String(err)}` }, 500);
|
|
3659
|
+
}
|
|
3660
|
+
if (!gatewayHandled && decision.behavior === "allow" && decision.scope === "always") {
|
|
3661
|
+
const running = await getApp(record.app);
|
|
3662
|
+
if (running) try {
|
|
3663
|
+
await approvals.addGrant(record.app, record.tool, running.manifest.permissions.askTools);
|
|
3664
|
+
} catch (err) {
|
|
3665
|
+
return c.json({ error: String(err) }, 400);
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
const outcome = approvals.respond(approvalId, decision);
|
|
3669
|
+
if (outcome === "unknown") return c.json({ error: `Unknown approval "${approvalId}"` }, 404);
|
|
3670
|
+
if (outcome === "resolved") return c.json({ error: `Approval "${approvalId}" is already decided` }, 409);
|
|
3671
|
+
return c.json({ approval: approvals.get(approvalId) });
|
|
3672
|
+
}
|
|
3673
|
+
app.post("/api/apps/:name/chat/:conversationId/approvals/:approvalId", async (c) => {
|
|
3674
|
+
const name = c.req.param("name");
|
|
3675
|
+
if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
3676
|
+
return decideApproval(c, c.req.param("approvalId"), {
|
|
3677
|
+
app: name,
|
|
3678
|
+
conversationId: c.req.param("conversationId")
|
|
3679
|
+
});
|
|
3680
|
+
});
|
|
3681
|
+
/** Host-wide views: cron/app-route approvals have no conversation to scope by. */
|
|
3682
|
+
app.get("/api/approvals", (c) => c.json({ approvals: approvals.pending() }));
|
|
3683
|
+
app.post("/api/approvals/:approvalId", (c) => decideApproval(c, c.req.param("approvalId")));
|
|
2640
3684
|
app.all("/app/:name/api/*", async (c) => {
|
|
2641
3685
|
const name = c.req.param("name");
|
|
2642
3686
|
const running = await getApp(name);
|
|
@@ -2668,6 +3712,7 @@ ${body}
|
|
|
2668
3712
|
auth,
|
|
2669
3713
|
async close() {
|
|
2670
3714
|
scheduler.stop();
|
|
3715
|
+
approvals.close();
|
|
2671
3716
|
const open = [...sessions.values()];
|
|
2672
3717
|
sessions.clear();
|
|
2673
3718
|
await Promise.all(open.map(async (p) => {
|
|
@@ -2739,9 +3784,24 @@ var Store = class {
|
|
|
2739
3784
|
appJobStatePath(name) {
|
|
2740
3785
|
return join(this.appDir(name), "jobs.json");
|
|
2741
3786
|
}
|
|
3787
|
+
appApprovalLogPath(name) {
|
|
3788
|
+
return join(this.appDir(name), "approvals.json");
|
|
3789
|
+
}
|
|
3790
|
+
appGrantsPath(name) {
|
|
3791
|
+
return join(this.appDir(name), "grants.json");
|
|
3792
|
+
}
|
|
3793
|
+
appPolicyPath(name) {
|
|
3794
|
+
return join(this.appDir(name), "policy.json");
|
|
3795
|
+
}
|
|
3796
|
+
appToolCallsPath(name) {
|
|
3797
|
+
return join(this.appDir(name), "tool-calls.jsonl");
|
|
3798
|
+
}
|
|
2742
3799
|
appUsagePath(name) {
|
|
2743
3800
|
return join(this.appDir(name), "usage.json");
|
|
2744
3801
|
}
|
|
3802
|
+
appTurnsPath(name) {
|
|
3803
|
+
return join(this.appDir(name), "turns.jsonl");
|
|
3804
|
+
}
|
|
2745
3805
|
registryCachePath(name) {
|
|
2746
3806
|
if (!APP_NAME_PATTERN.test(name)) throw new AppSdkError(`Invalid registry name: ${JSON.stringify(name)}`);
|
|
2747
3807
|
return join(this.registriesDir, `${name}.json`);
|
|
@@ -2770,6 +3830,61 @@ var Store = class {
|
|
|
2770
3830
|
async writeChatState(name, state) {
|
|
2771
3831
|
await writeJson(this.appChatStatePath(name), state);
|
|
2772
3832
|
}
|
|
3833
|
+
async readApprovalLog(name) {
|
|
3834
|
+
return { approvals: (await readJson(this.appApprovalLogPath(name)))?.approvals ?? [] };
|
|
3835
|
+
}
|
|
3836
|
+
async writeApprovalLog(name, log) {
|
|
3837
|
+
await writeJson(this.appApprovalLogPath(name), log);
|
|
3838
|
+
}
|
|
3839
|
+
async readGrants(name) {
|
|
3840
|
+
return { tools: (await readJson(this.appGrantsPath(name)))?.tools ?? [] };
|
|
3841
|
+
}
|
|
3842
|
+
async writeGrants(name, state) {
|
|
3843
|
+
await writeJson(this.appGrantsPath(name), state);
|
|
3844
|
+
}
|
|
3845
|
+
async readUserPolicy(name) {
|
|
3846
|
+
const state = await readJson(this.appPolicyPath(name));
|
|
3847
|
+
return { rules: Array.isArray(state?.rules) ? state.rules : [] };
|
|
3848
|
+
}
|
|
3849
|
+
async writeUserPolicy(name, policy) {
|
|
3850
|
+
await writeJson(this.appPolicyPath(name), policy);
|
|
3851
|
+
}
|
|
3852
|
+
/** Appends one line to the app's tool-call audit ledger. */
|
|
3853
|
+
async appendToolCall(name, record) {
|
|
3854
|
+
const path = this.appToolCallsPath(name);
|
|
3855
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
3856
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
3857
|
+
}
|
|
3858
|
+
/**
|
|
3859
|
+
* Parseable lines of the tool-call ledger, oldest first. Like the turn
|
|
3860
|
+
* ledger, a truncated or hand-mangled line costs only itself.
|
|
3861
|
+
*/
|
|
3862
|
+
async readToolCalls(name) {
|
|
3863
|
+
let content;
|
|
3864
|
+
try {
|
|
3865
|
+
content = await readFile(this.appToolCallsPath(name), "utf8");
|
|
3866
|
+
} catch (err) {
|
|
3867
|
+
if (err.code === "ENOENT") return [];
|
|
3868
|
+
throw err;
|
|
3869
|
+
}
|
|
3870
|
+
const records = [];
|
|
3871
|
+
for (const line of content.split("\n")) {
|
|
3872
|
+
if (line.trim() === "") continue;
|
|
3873
|
+
try {
|
|
3874
|
+
const record = JSON.parse(line);
|
|
3875
|
+
if (typeof record?.at === "string" && typeof record.tool === "string") records.push(record);
|
|
3876
|
+
} catch {}
|
|
3877
|
+
}
|
|
3878
|
+
return records;
|
|
3879
|
+
}
|
|
3880
|
+
/** Replaces the tool-call ledger atomically (tmp file + rename), for trimming. */
|
|
3881
|
+
async writeToolCalls(name, records) {
|
|
3882
|
+
const path = this.appToolCallsPath(name);
|
|
3883
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
3884
|
+
const tmp = `${path}.tmp`;
|
|
3885
|
+
await writeFile(tmp, records.map((r) => `${JSON.stringify(r)}\n`).join(""), "utf8");
|
|
3886
|
+
await rename(tmp, path);
|
|
3887
|
+
}
|
|
2773
3888
|
async readJobState(name) {
|
|
2774
3889
|
const state = await readJson(this.appJobStatePath(name));
|
|
2775
3890
|
return {
|
|
@@ -2790,6 +3905,51 @@ var Store = class {
|
|
|
2790
3905
|
async writeUsageState(name, state) {
|
|
2791
3906
|
await writeJson(this.appUsagePath(name), state);
|
|
2792
3907
|
}
|
|
3908
|
+
/** Appends one line to the app's turn ledger, creating the container if needed. */
|
|
3909
|
+
async appendTurnRecord(name, record) {
|
|
3910
|
+
const path = this.appTurnsPath(name);
|
|
3911
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
3912
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
3913
|
+
}
|
|
3914
|
+
/**
|
|
3915
|
+
* Every parseable line of the app's turn ledger, in file order (oldest
|
|
3916
|
+
* first); an absent file reads as no turns.
|
|
3917
|
+
*
|
|
3918
|
+
* An append-only log written across crashes and hand edits will eventually
|
|
3919
|
+
* contain a truncated or malformed line. One bad line must cost only itself,
|
|
3920
|
+
* so unparseable lines — and lines without an `at` timestamp, which nothing
|
|
3921
|
+
* downstream could place in time — are skipped rather than thrown on.
|
|
3922
|
+
*/
|
|
3923
|
+
async readTurnRecords(name) {
|
|
3924
|
+
let content;
|
|
3925
|
+
try {
|
|
3926
|
+
content = await readFile(this.appTurnsPath(name), "utf8");
|
|
3927
|
+
} catch (err) {
|
|
3928
|
+
if (err.code === "ENOENT") return [];
|
|
3929
|
+
throw err;
|
|
3930
|
+
}
|
|
3931
|
+
const records = [];
|
|
3932
|
+
for (const line of content.split("\n")) {
|
|
3933
|
+
if (line.trim() === "") continue;
|
|
3934
|
+
let parsed;
|
|
3935
|
+
try {
|
|
3936
|
+
parsed = JSON.parse(line);
|
|
3937
|
+
} catch {
|
|
3938
|
+
continue;
|
|
3939
|
+
}
|
|
3940
|
+
const record = parsed;
|
|
3941
|
+
if (typeof record?.at === "string") records.push(record);
|
|
3942
|
+
}
|
|
3943
|
+
return records;
|
|
3944
|
+
}
|
|
3945
|
+
/** Replaces the app's turn ledger atomically (tmp file + rename), for compaction. */
|
|
3946
|
+
async writeTurnRecords(name, records) {
|
|
3947
|
+
const path = this.appTurnsPath(name);
|
|
3948
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
3949
|
+
const tmp = `${path}.tmp`;
|
|
3950
|
+
await writeFile(tmp, records.map((record) => `${JSON.stringify(record)}\n`).join(""), "utf8");
|
|
3951
|
+
await rename(tmp, path);
|
|
3952
|
+
}
|
|
2793
3953
|
get authStatePath() {
|
|
2794
3954
|
return join(this.root, "auth.json");
|
|
2795
3955
|
}
|
|
@@ -2823,4 +3983,4 @@ var Store = class {
|
|
|
2823
3983
|
//#endregion
|
|
2824
3984
|
export { UsageRecorder as A, BudgetExceededError as C, budgetStatusFor as D, budgetStatus as E, createAuthMiddleware as F, isConsoleRequest as I, registerAuthRoutes as L, utcDay as M, AuthService as N, setAppBudget as O, SESSION_COOKIE as P, requiredLevel as R, BUDGET_EXCEEDED_CODE as S, assertWithinBudget as T, fetchSource as _, EXPORT_FORMAT_VERSION as a, loadApp as b, installApp as c, updateApp as d, addRegistry as f, resolveApp as g, removeRegistry as h, resolveAppConfig as i, formatUsd as j, USAGE_RETENTION_DAYS as k, installDependencies as l, refreshRegistry as m, createServer as n, exportApp as o, readCachedIndex as p, buildAppContext as r, importApp as s, Store as t, removeApp as u, parseSourceArg as v, appBudget as w, checkSdkCompatibility as x, updateSource as y };
|
|
2825
3985
|
|
|
2826
|
-
//# sourceMappingURL=store-
|
|
3986
|
+
//# sourceMappingURL=store-DvLX0Kft.mjs.map
|