@agent-finops/core 0.8.1 → 0.9.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 +5 -3
- package/dist/actionPlanner.d.ts +140 -0
- package/dist/actionPlanner.js +938 -0
- package/dist/actionVerification.d.ts +1240 -0
- package/dist/actionVerification.js +1028 -0
- package/dist/activitySnapshot.d.ts +142 -50
- package/dist/activitySnapshot.js +145 -6
- package/dist/activitySnapshotCache.d.ts +8 -1
- package/dist/activitySnapshotCache.js +103 -7
- package/dist/agentDraftToken.d.ts +80 -0
- package/dist/agentDraftToken.js +188 -0
- package/dist/agentEconomicsReceipt.d.ts +74 -74
- package/dist/agentLoopContract.d.ts +27 -0
- package/dist/agentLoopContract.js +36 -0
- package/dist/glance.d.ts +27 -1
- package/dist/glance.js +151 -12
- package/dist/guidedAnswer.d.ts +51 -0
- package/dist/guidedAnswer.js +352 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.js +13 -1
- package/dist/localAgentFormats/gemini.js +2 -2
- package/dist/localAgentFormats/registry.js +6 -2
- package/dist/localAgentFormats/runtimeRegistry.js +5 -2
- package/dist/localAgentFormats/types.d.ts +2 -1
- package/dist/localAgentLogs.d.ts +362 -3
- package/dist/localAgentLogs.js +1964 -165
- package/dist/modelPricing.d.ts +1 -1
- package/dist/modelPricing.js +1 -1
- package/dist/projectEconomics.d.ts +617 -0
- package/dist/projectEconomics.js +620 -0
- package/dist/projectEconomicsBuilder.d.ts +89 -0
- package/dist/projectEconomicsBuilder.js +473 -0
- package/dist/projectIndexStore.d.ts +545 -0
- package/dist/projectIndexStore.js +606 -0
- package/dist/providerConnectors.d.ts +161 -1
- package/dist/providerConnectors.js +406 -11
- package/dist/qualitativeIndexCache.d.ts +494 -0
- package/dist/qualitativeIndexCache.js +930 -0
- package/dist/resultCard.d.ts +350 -0
- package/dist/resultCard.js +604 -0
- package/dist/runtimeCommands.d.ts +36 -0
- package/dist/runtimeCommands.js +50 -0
- package/dist/scanGuard.d.ts +3 -1
- package/dist/scanGuard.js +164 -4
- package/dist/schema.d.ts +33 -31
- package/dist/schema.js +9 -1
- package/dist/sessionVitals.d.ts +145 -0
- package/dist/sessionVitals.js +521 -0
- package/dist/toolInvocations.d.ts +40 -1
- package/dist/toolInvocations.js +101 -20
- package/package.json +1 -1
package/dist/activitySnapshot.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { aggregateCalls, dedupeCumulativeSessionCalls } from "./localAgentLogs.js";
|
|
3
3
|
import { localAgentFormatDescriptors } from "./localAgentFormats/registry.js";
|
|
4
4
|
import { canPriceTokenUsageAtScope, estimateTokenCostUsd, PRICING_TABLE_AS_OF } from "./modelPricing.js";
|
|
5
|
+
import { subscriptionPlans } from "./planMath.js";
|
|
5
6
|
import { isBundledSampleUsage } from "./schema.js";
|
|
6
7
|
import { sourceValidationCoverageValues } from "./sourceStatus.js";
|
|
7
8
|
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
@@ -163,6 +164,8 @@ export const activitySnapshotSubscriptionAgentSchema = z.object({
|
|
|
163
164
|
agent: agentSchema,
|
|
164
165
|
billing: z.literal("subscription"),
|
|
165
166
|
planId: planIdSchema,
|
|
167
|
+
/** C-lane §2.1: detected-plan list price; null when the plan is unpriced. */
|
|
168
|
+
committedUsdPerMonth: usdSchema.nullable(),
|
|
166
169
|
apiEquivalent: activitySnapshotApiEquivalentWindowsSchema,
|
|
167
170
|
limits: z.array(activitySnapshotLimitSchema).max(2),
|
|
168
171
|
pressure: z.enum(["extra_usage_credits_exhausted"]).nullable()
|
|
@@ -215,6 +218,41 @@ const activitySnapshotUnresolvedSchema = z.object({
|
|
|
215
218
|
});
|
|
216
219
|
}
|
|
217
220
|
});
|
|
221
|
+
/**
|
|
222
|
+
* C-lane §2.1: a provider-billed subscription with no local agent (cursor
|
|
223
|
+
* today). The writer includes a billed30d amount ONLY when its financial
|
|
224
|
+
* evidence is "verified"; the renderer independently drops anything else
|
|
225
|
+
* (double lock: estimated/unverified provider dollars can never reach a
|
|
226
|
+
* statusline segment).
|
|
227
|
+
*/
|
|
228
|
+
export const activitySnapshotProviderSubscriptionSchema = z.object({
|
|
229
|
+
provider: z.enum(activitySnapshotProviderValues),
|
|
230
|
+
billing: z.literal("subscription"),
|
|
231
|
+
planLabel: z.string().min(1).max(64).nullable(),
|
|
232
|
+
committedUsdPerMonth: usdSchema.nullable(),
|
|
233
|
+
billed30d: activitySnapshotBilledWindowSchema
|
|
234
|
+
}).strict();
|
|
235
|
+
/** C-lane §2.1: the committed $/mo total across every detected subscription. */
|
|
236
|
+
export const activitySnapshotCommittedTotalSchema = z.object({
|
|
237
|
+
amountUsd: usdSchema.nullable(),
|
|
238
|
+
pricedSubs: countSchema,
|
|
239
|
+
totalSubs: countSchema
|
|
240
|
+
}).strict().superRefine((total, context) => {
|
|
241
|
+
if (total.pricedSubs > total.totalSubs) {
|
|
242
|
+
context.addIssue({
|
|
243
|
+
code: "custom",
|
|
244
|
+
path: ["pricedSubs"],
|
|
245
|
+
message: "Priced subscriptions cannot exceed total subscriptions."
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if ((total.amountUsd === null) !== (total.pricedSubs === 0)) {
|
|
249
|
+
context.addIssue({
|
|
250
|
+
code: "custom",
|
|
251
|
+
path: ["amountUsd"],
|
|
252
|
+
message: "A committed total exists exactly when at least one subscription is priced."
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
});
|
|
218
256
|
export const activitySnapshotOverageSchema = z.object({
|
|
219
257
|
amountUsd: z.number().finite().positive(),
|
|
220
258
|
currency: z.literal("USD"),
|
|
@@ -330,7 +368,10 @@ export const activitySnapshotCoverageSchema = z.object({
|
|
|
330
368
|
});
|
|
331
369
|
export const activitySnapshotSchema = z.object({
|
|
332
370
|
kind: z.literal("aibill.activity_snapshot"),
|
|
333
|
-
|
|
371
|
+
// v2 (C-lane §2.1): adds per-agent committedUsdPerMonth, provider-billed
|
|
372
|
+
// subscriptions, and the committed total. The writer dual-writes a v1
|
|
373
|
+
// payload for already-installed v1 runners during the deprecation window.
|
|
374
|
+
schemaVersion: z.literal(2),
|
|
334
375
|
currency: z.literal("USD"),
|
|
335
376
|
asOf: isoTimestampSchema,
|
|
336
377
|
generatedAt: isoTimestampSchema,
|
|
@@ -347,6 +388,8 @@ export const activitySnapshotSchema = z.object({
|
|
|
347
388
|
subscription: activitySnapshotSubscriptionSchema.nullable(),
|
|
348
389
|
metered: activitySnapshotMeteredSchema.nullable(),
|
|
349
390
|
unresolved: activitySnapshotUnresolvedSchema.nullable(),
|
|
391
|
+
providers: z.array(activitySnapshotProviderSubscriptionSchema).max(5).nullable(),
|
|
392
|
+
committedTotal: activitySnapshotCommittedTotalSchema,
|
|
350
393
|
overage: activitySnapshotOverageSchema.nullable(),
|
|
351
394
|
coverage: activitySnapshotCoverageSchema,
|
|
352
395
|
networkUploaded: z.literal(false)
|
|
@@ -372,6 +415,20 @@ export const activitySnapshotSchema = z.object({
|
|
|
372
415
|
(snapshot.subscription || snapshot.metered || snapshot.unresolved || snapshot.overage)) {
|
|
373
416
|
invalid("Empty and error snapshots cannot carry financial cohorts.", ["mode"]);
|
|
374
417
|
}
|
|
418
|
+
if ((snapshot.mode === "empty" || snapshot.mode === "error") &&
|
|
419
|
+
(snapshot.providers || snapshot.committedTotal.amountUsd !== null ||
|
|
420
|
+
snapshot.committedTotal.totalSubs !== 0)) {
|
|
421
|
+
invalid("Empty and error snapshots cannot carry subscription pricing.", ["committedTotal"]);
|
|
422
|
+
}
|
|
423
|
+
if (snapshot.providers &&
|
|
424
|
+
new Set(snapshot.providers.map((provider) => provider.provider)).size !== snapshot.providers.length) {
|
|
425
|
+
invalid("A provider subscription may appear only once.", ["providers"]);
|
|
426
|
+
}
|
|
427
|
+
const expectedTotalSubs = (snapshot.subscription?.agents.length ?? 0) +
|
|
428
|
+
(snapshot.providers?.length ?? 0);
|
|
429
|
+
if (snapshot.committedTotal.totalSubs !== expectedTotalSubs) {
|
|
430
|
+
invalid("The committed total must count every subscription row exactly once.", ["committedTotal"]);
|
|
431
|
+
}
|
|
375
432
|
if (snapshot.mode === "error" && snapshot.refresh.status !== "error") {
|
|
376
433
|
invalid("Error mode requires an error refresh state.", ["refresh"]);
|
|
377
434
|
}
|
|
@@ -559,9 +616,32 @@ export function buildActivitySnapshot(input) {
|
|
|
559
616
|
} : null;
|
|
560
617
|
const overage = buildOverage(meteredBilledRecords, overageIds, asOfMs);
|
|
561
618
|
const coverage = buildCoverage(classified.map((entry) => entry.record), scans, providers, input.pricingAsOf ?? PRICING_TABLE_AS_OF, deduplicated.conflictingIds);
|
|
619
|
+
const finalSubscription = mode === "metered" || mode === "unresolved" || mode === "empty"
|
|
620
|
+
? null
|
|
621
|
+
: subscription;
|
|
622
|
+
// Writer-side lock (C-lane §2.1): a provider-billed amount survives only
|
|
623
|
+
// when its supplied window is verified; anything else degrades to missing.
|
|
624
|
+
const providerSubscriptionRows = mode === "empty"
|
|
625
|
+
? []
|
|
626
|
+
: (input.providerSubscriptions ?? []).map((row) => ({
|
|
627
|
+
provider: row.provider,
|
|
628
|
+
billing: "subscription",
|
|
629
|
+
planLabel: row.planLabel,
|
|
630
|
+
committedUsdPerMonth: row.committedUsdPerMonth,
|
|
631
|
+
billed30d: row.billed30d &&
|
|
632
|
+
row.billed30d.financialEvidence === "verified" &&
|
|
633
|
+
row.billed30d.amountUsd !== null
|
|
634
|
+
? row.billed30d
|
|
635
|
+
: missingBilledWindow()
|
|
636
|
+
}));
|
|
637
|
+
const committedRows = [
|
|
638
|
+
...(finalSubscription?.agents ?? []).map((agent) => agent.committedUsdPerMonth),
|
|
639
|
+
...providerSubscriptionRows.map((row) => row.committedUsdPerMonth)
|
|
640
|
+
];
|
|
641
|
+
const pricedRows = committedRows.filter((amount) => amount !== null);
|
|
562
642
|
return activitySnapshotSchema.parse({
|
|
563
643
|
kind: "aibill.activity_snapshot",
|
|
564
|
-
schemaVersion:
|
|
644
|
+
schemaVersion: 2,
|
|
565
645
|
currency: "USD",
|
|
566
646
|
asOf: new Date(asOfMs).toISOString(),
|
|
567
647
|
generatedAt,
|
|
@@ -569,24 +649,80 @@ export function buildActivitySnapshot(input) {
|
|
|
569
649
|
lastSuccessAt: generatedAt,
|
|
570
650
|
refresh: { status: "ok" },
|
|
571
651
|
mode,
|
|
572
|
-
subscription:
|
|
573
|
-
? null
|
|
574
|
-
: subscription,
|
|
652
|
+
subscription: finalSubscription,
|
|
575
653
|
metered: mode === "subscription" || mode === "unresolved" || mode === "empty"
|
|
576
654
|
? null
|
|
577
655
|
: metered,
|
|
578
656
|
unresolved,
|
|
657
|
+
providers: providerSubscriptionRows.length > 0 ? providerSubscriptionRows : null,
|
|
658
|
+
committedTotal: {
|
|
659
|
+
amountUsd: pricedRows.length > 0
|
|
660
|
+
? roundUsd(pricedRows.reduce((total, amount) => total + amount, 0))
|
|
661
|
+
: null,
|
|
662
|
+
pricedSubs: pricedRows.length,
|
|
663
|
+
totalSubs: committedRows.length
|
|
664
|
+
},
|
|
579
665
|
overage: mode === "metered" || mode === "mixed" ? overage : null,
|
|
580
666
|
coverage,
|
|
581
667
|
networkUploaded: false
|
|
582
668
|
});
|
|
583
669
|
}
|
|
670
|
+
/** Detected-plan list price (subscriptionPlans); null when unpriced. */
|
|
671
|
+
function committedPriceForPlanId(planId) {
|
|
672
|
+
if (!planId)
|
|
673
|
+
return null;
|
|
674
|
+
return subscriptionPlans.find((plan) => plan.id === planId)?.monthlyUsd ?? null;
|
|
675
|
+
}
|
|
676
|
+
function missingBilledWindow() {
|
|
677
|
+
return {
|
|
678
|
+
amountUsd: null,
|
|
679
|
+
recordCount: 0,
|
|
680
|
+
basis: "provider_billed",
|
|
681
|
+
financialEvidence: "missing",
|
|
682
|
+
coverage: "missing"
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* The v1 dual-write payload (C-lane §2.1 fleet back-compat): today's fields
|
|
687
|
+
* only, so an already-installed v1 runner keeps rendering fresh data instead
|
|
688
|
+
* of decaying into permanent staleness. Exact v1 key set; no v2 fields.
|
|
689
|
+
*/
|
|
690
|
+
export function activitySnapshotV1Payload(snapshot) {
|
|
691
|
+
return {
|
|
692
|
+
kind: snapshot.kind,
|
|
693
|
+
schemaVersion: 1,
|
|
694
|
+
currency: snapshot.currency,
|
|
695
|
+
asOf: snapshot.asOf,
|
|
696
|
+
generatedAt: snapshot.generatedAt,
|
|
697
|
+
lastAttemptAt: snapshot.lastAttemptAt,
|
|
698
|
+
lastSuccessAt: snapshot.lastSuccessAt,
|
|
699
|
+
refresh: snapshot.refresh,
|
|
700
|
+
mode: snapshot.mode,
|
|
701
|
+
subscription: snapshot.subscription
|
|
702
|
+
? {
|
|
703
|
+
agents: snapshot.subscription.agents.map((agent) => ({
|
|
704
|
+
agent: agent.agent,
|
|
705
|
+
billing: agent.billing,
|
|
706
|
+
planId: agent.planId,
|
|
707
|
+
apiEquivalent: agent.apiEquivalent,
|
|
708
|
+
limits: agent.limits,
|
|
709
|
+
pressure: agent.pressure
|
|
710
|
+
}))
|
|
711
|
+
}
|
|
712
|
+
: null,
|
|
713
|
+
metered: snapshot.metered,
|
|
714
|
+
unresolved: snapshot.unresolved,
|
|
715
|
+
overage: snapshot.overage,
|
|
716
|
+
coverage: snapshot.coverage,
|
|
717
|
+
networkUploaded: snapshot.networkUploaded
|
|
718
|
+
};
|
|
719
|
+
}
|
|
584
720
|
/** A bounded no-evidence state for an initial failed refresh. */
|
|
585
721
|
export function createActivitySnapshotError(attemptedAt, errorCode) {
|
|
586
722
|
const timestamp = new Date(parseTimestamp(attemptedAt, "attemptedAt")).toISOString();
|
|
587
723
|
return activitySnapshotSchema.parse({
|
|
588
724
|
kind: "aibill.activity_snapshot",
|
|
589
|
-
schemaVersion:
|
|
725
|
+
schemaVersion: 2,
|
|
590
726
|
currency: "USD",
|
|
591
727
|
asOf: timestamp,
|
|
592
728
|
generatedAt: timestamp,
|
|
@@ -597,6 +733,8 @@ export function createActivitySnapshotError(attemptedAt, errorCode) {
|
|
|
597
733
|
subscription: null,
|
|
598
734
|
metered: null,
|
|
599
735
|
unresolved: null,
|
|
736
|
+
providers: null,
|
|
737
|
+
committedTotal: { amountUsd: null, pricedSubs: 0, totalSubs: 0 },
|
|
600
738
|
overage: null,
|
|
601
739
|
coverage: {
|
|
602
740
|
agents: [],
|
|
@@ -650,6 +788,7 @@ function activitySubscriptionAgents(records, calls, allCalls, plans, scans, trus
|
|
|
650
788
|
agent,
|
|
651
789
|
billing: "subscription",
|
|
652
790
|
planId: isKnownPlanId(plan.planId) ? plan.planId : null,
|
|
791
|
+
committedUsdPerMonth: committedPriceForPlanId(plan.planId),
|
|
653
792
|
apiEquivalent: buildApiWindows(agentRecords, allCalls.filter((call) => call.agent === agent), trustedProviderIds, asOfMs, localCoverageForAgent(agent, scans)),
|
|
654
793
|
limits: latestReportedLimits(agentCalls, asOfMs),
|
|
655
794
|
pressure: plan.limitSignal === "extra-usage credits exhausted"
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { type ActivitySnapshot, type ActivitySnapshotRefreshErrorCode } from "./activitySnapshot.js";
|
|
2
2
|
export declare const activitySnapshotCacheEnvironmentVariable = "AIBILL_CACHE_DIR";
|
|
3
|
-
|
|
3
|
+
/** The v2 snapshot cache (C-lane §2.1). */
|
|
4
|
+
export declare const activitySnapshotCacheFileName = "statusline-v2.json";
|
|
5
|
+
/**
|
|
6
|
+
* Deprecation-window dual-write target: installed v1 runners are frozen
|
|
7
|
+
* copies that read only this file, so every v2 write also refreshes a v1
|
|
8
|
+
* payload here (today's fields only) instead of stranding them stale.
|
|
9
|
+
*/
|
|
10
|
+
export declare const activitySnapshotLegacyCacheFileName = "statusline-v1.json";
|
|
4
11
|
export declare const activitySnapshotCacheMaxBytes: number;
|
|
5
12
|
export type ActivitySnapshotCacheOptions = {
|
|
6
13
|
/** Test/embedding override. Production defaults to ~/.aibill/cache. */
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
3
|
import { open, lstat, mkdir, chmod, realpath, rename, unlink } from "node:fs/promises";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
5
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
6
|
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
-
import {
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { activitySnapshotSchema, activitySnapshotV1Payload, createActivitySnapshotError } from "./activitySnapshot.js";
|
|
8
10
|
export const activitySnapshotCacheEnvironmentVariable = "AIBILL_CACHE_DIR";
|
|
9
|
-
|
|
11
|
+
/** The v2 snapshot cache (C-lane §2.1). */
|
|
12
|
+
export const activitySnapshotCacheFileName = "statusline-v2.json";
|
|
13
|
+
/**
|
|
14
|
+
* Deprecation-window dual-write target: installed v1 runners are frozen
|
|
15
|
+
* copies that read only this file, so every v2 write also refreshes a v1
|
|
16
|
+
* payload here (today's fields only) instead of stranding them stale.
|
|
17
|
+
*/
|
|
18
|
+
export const activitySnapshotLegacyCacheFileName = "statusline-v1.json";
|
|
10
19
|
export const activitySnapshotCacheMaxBytes = 64 * 1_024;
|
|
20
|
+
// The lock name is shared with pre-v2 writers on purpose: during the fleet
|
|
21
|
+
// deprecation window both CLI generations serialize through one lock.
|
|
11
22
|
const lockFileName = ".statusline-v1.lock";
|
|
12
23
|
const defaultLockTimeoutMs = 2_000;
|
|
13
24
|
const staleLockMs = 15_000;
|
|
14
25
|
const lockPollMs = 20;
|
|
15
26
|
const lockMetadataMaxBytes = 512;
|
|
27
|
+
const execFile = promisify(execFileCallback);
|
|
16
28
|
export class ActivitySnapshotCacheError extends Error {
|
|
17
29
|
code;
|
|
18
30
|
constructor(code, message) {
|
|
@@ -207,7 +219,7 @@ async function readSnapshotFile(directory) {
|
|
|
207
219
|
catch {
|
|
208
220
|
return { status: "error", code: "malformed" };
|
|
209
221
|
}
|
|
210
|
-
if (isRecord(value) && value.schemaVersion !== undefined && value.schemaVersion !==
|
|
222
|
+
if (isRecord(value) && value.schemaVersion !== undefined && value.schemaVersion !== 2) {
|
|
211
223
|
return { status: "error", code: "unsupported_version" };
|
|
212
224
|
}
|
|
213
225
|
const parsed = activitySnapshotSchema.safeParse(value);
|
|
@@ -225,11 +237,17 @@ async function readSnapshotFile(directory) {
|
|
|
225
237
|
}
|
|
226
238
|
}
|
|
227
239
|
async function atomicWriteSnapshot(directory, snapshot) {
|
|
228
|
-
|
|
240
|
+
await atomicWriteCacheFile(directory, activitySnapshotCacheFileName, `${JSON.stringify(snapshot)}\n`);
|
|
241
|
+
// C-lane §2.1 fleet back-compat: dual-write the v1 payload so an
|
|
242
|
+
// already-installed v1 runner keeps rendering fresh data during the
|
|
243
|
+
// deprecation window instead of decaying into permanent staleness.
|
|
244
|
+
await atomicWriteCacheFile(directory, activitySnapshotLegacyCacheFileName, `${JSON.stringify(activitySnapshotV1Payload(snapshot))}\n`);
|
|
245
|
+
}
|
|
246
|
+
async function atomicWriteCacheFile(directory, fileName, contents) {
|
|
229
247
|
if (Buffer.byteLength(contents, "utf8") > activitySnapshotCacheMaxBytes) {
|
|
230
248
|
throw new ActivitySnapshotCacheError("invalid_snapshot", "Activity snapshot exceeds the 64 KiB cache limit.");
|
|
231
249
|
}
|
|
232
|
-
const filePath = join(directory,
|
|
250
|
+
const filePath = join(directory, fileName);
|
|
233
251
|
const existing = await lstat(filePath).catch((error) => {
|
|
234
252
|
if (isNodeError(error, "ENOENT"))
|
|
235
253
|
return undefined;
|
|
@@ -238,7 +256,7 @@ async function atomicWriteSnapshot(directory, snapshot) {
|
|
|
238
256
|
if (existing?.isSymbolicLink() || (existing && !existing.isFile())) {
|
|
239
257
|
throw new ActivitySnapshotCacheError("unsafe_file", "Activity snapshot cache path is not a regular file.");
|
|
240
258
|
}
|
|
241
|
-
const temporaryPath = join(directory, `.${
|
|
259
|
+
const temporaryPath = join(directory, `.${fileName}.${process.pid}.${randomUUID()}.tmp`);
|
|
242
260
|
let handle;
|
|
243
261
|
try {
|
|
244
262
|
handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
|
|
@@ -329,6 +347,84 @@ async function ensureDefaultParent(homeDirectory, create) {
|
|
|
329
347
|
}
|
|
330
348
|
if (create)
|
|
331
349
|
await chmod(parent, 0o700);
|
|
350
|
+
await ensureDefaultCacheGitPrivacy(parent, create);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* When a synthetic or real HOME is itself inside a Git worktree, protect the
|
|
354
|
+
* complete top-level private state directory before any cache child is
|
|
355
|
+
* created. Explicit AIBILL_CACHE_DIR/cacheDirectory overrides intentionally
|
|
356
|
+
* remain caller-owned and never receive repository files from this helper.
|
|
357
|
+
*/
|
|
358
|
+
async function ensureDefaultCacheGitPrivacy(aibillDirectory, create) {
|
|
359
|
+
const gitRoot = await findEnclosingGitRoot(aibillDirectory);
|
|
360
|
+
if (!gitRoot)
|
|
361
|
+
return;
|
|
362
|
+
const marker = join(aibillDirectory, ".gitignore");
|
|
363
|
+
let handle;
|
|
364
|
+
try {
|
|
365
|
+
handle = await open(marker, constants.O_RDONLY | noFollowFlag());
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (!isNodeError(error, "ENOENT"))
|
|
369
|
+
throw error;
|
|
370
|
+
if (!create) {
|
|
371
|
+
const missing = new Error("Private aibill Git privacy marker does not exist.");
|
|
372
|
+
missing.code = "ENOENT";
|
|
373
|
+
throw missing;
|
|
374
|
+
}
|
|
375
|
+
handle = await open(marker, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
|
|
376
|
+
await handle.writeFile("*\n", "utf8");
|
|
377
|
+
await handle.sync();
|
|
378
|
+
await handle.close();
|
|
379
|
+
handle = await open(marker, constants.O_RDONLY | noFollowFlag());
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
const info = await handle.stat();
|
|
383
|
+
if (!info.isFile() || !hasPrivatePermissions(info.mode) || info.size !== 2) {
|
|
384
|
+
throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill Git privacy marker is unsafe.");
|
|
385
|
+
}
|
|
386
|
+
const buffer = Buffer.alloc(2);
|
|
387
|
+
const { bytesRead } = await handle.read(buffer, 0, 2, 0);
|
|
388
|
+
if (bytesRead !== 2 || buffer.toString("utf8") !== "*\n") {
|
|
389
|
+
throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill Git privacy marker is invalid.");
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
finally {
|
|
393
|
+
await handle.close().catch(() => undefined);
|
|
394
|
+
}
|
|
395
|
+
const relativeDirectory = relative(gitRoot, aibillDirectory);
|
|
396
|
+
const tracked = await execFile("git", ["-C", gitRoot, "ls-files", "--", relativeDirectory], {
|
|
397
|
+
encoding: "utf8",
|
|
398
|
+
maxBuffer: 64 * 1024
|
|
399
|
+
}).then(({ stdout }) => stdout.trim()).catch(() => {
|
|
400
|
+
throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill cache tracking status could not be verified.");
|
|
401
|
+
});
|
|
402
|
+
if (tracked) {
|
|
403
|
+
throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill cache is already tracked by Git.");
|
|
404
|
+
}
|
|
405
|
+
const ignored = await execFile("git", [
|
|
406
|
+
"-C", gitRoot, "check-ignore", "--quiet", "--no-index", "--",
|
|
407
|
+
join(relativeDirectory, "cache", "privacy-probe.json")
|
|
408
|
+
]).then(() => true).catch(() => false);
|
|
409
|
+
if (!ignored) {
|
|
410
|
+
throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill cache is not proven ignored by Git.");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async function findEnclosingGitRoot(path) {
|
|
414
|
+
let current = resolve(path);
|
|
415
|
+
while (true) {
|
|
416
|
+
const gitEntry = await lstat(join(current, ".git")).catch((error) => {
|
|
417
|
+
if (isNodeError(error, "ENOENT") || isNodeError(error, "ENOTDIR"))
|
|
418
|
+
return undefined;
|
|
419
|
+
throw error;
|
|
420
|
+
});
|
|
421
|
+
if (gitEntry)
|
|
422
|
+
return current;
|
|
423
|
+
const parent = dirname(current);
|
|
424
|
+
if (parent === current)
|
|
425
|
+
return undefined;
|
|
426
|
+
current = parent;
|
|
427
|
+
}
|
|
332
428
|
}
|
|
333
429
|
function configuredCacheDirectory(options) {
|
|
334
430
|
const configured = options.cacheDirectory?.trim() ||
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ab1.` agent-draft token: how a conversationally drafted improve plan
|
|
3
|
+
* travels from `draft_improve_command` (MCP, read-only) to `aibill improve
|
|
4
|
+
* --draft` (terminal, human-approved) as ONE argv token.
|
|
5
|
+
*
|
|
6
|
+
* Design: AGENT_NATIVE_LOOP_DESIGN.md §2a (REV 2, QA-PASSED). The base64url
|
|
7
|
+
* alphabet contains no shell metacharacter, quote, or whitespace, so the
|
|
8
|
+
* token cannot break out of its argv slot in sh/bash/zsh/fish/pwsh and
|
|
9
|
+
* cannot be mangled by smart quotes, wrapping, or locale. Decoding NEVER
|
|
10
|
+
* throws — every failure is a tagged reason so a bad draft is set aside
|
|
11
|
+
* with copy, not a crash.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* AUTHORITATIVE bound (m11): the whole token is at most 20,000 characters,
|
|
15
|
+
* which implies decoded JSON ≤ ~15,000 bytes. There is no separate
|
|
16
|
+
* decoded-byte cap.
|
|
17
|
+
*/
|
|
18
|
+
export declare const MAX_AGENT_DRAFT_TOKEN_CHARS = 20000;
|
|
19
|
+
export type AgentDraftV1 = {
|
|
20
|
+
v: 1;
|
|
21
|
+
experimentId: string;
|
|
22
|
+
revisionId: string;
|
|
23
|
+
change: string;
|
|
24
|
+
rollback: string;
|
|
25
|
+
canary: string;
|
|
26
|
+
};
|
|
27
|
+
export type AgentDraftDecodeFailureReason = "not_a_token" | "token_too_long" | "not_base64url_json" | "not_a_plain_object" | "unexpected_keys" | "unsupported_version" | "invalid_experiment_id" | "invalid_revision_id" | "invalid_sentence";
|
|
28
|
+
export type AgentDraftDecodeResult = {
|
|
29
|
+
ok: true;
|
|
30
|
+
draft: AgentDraftV1;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
reason: AgentDraftDecodeFailureReason;
|
|
34
|
+
};
|
|
35
|
+
/** Cheap argv-time shape check shared with parseArgs (full decode comes later). */
|
|
36
|
+
export declare function looksLikeAgentDraftToken(value: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Decode and structurally validate an `ab1.` token. Hardening (m11, exact
|
|
39
|
+
* spec): the payload must JSON.parse to a plain object; keys are compared
|
|
40
|
+
* as a strict SET against the six expected keys (`Object.keys` DOES surface
|
|
41
|
+
* `__proto__` as an own key after JSON.parse, so `__proto__`/`constructor`/
|
|
42
|
+
* any extra key fails the set check); values are copied field-by-field onto
|
|
43
|
+
* a fresh null-prototype object before any further use. JSON duplicate keys
|
|
44
|
+
* are last-win in JSON.parse and undetectable post-parse: the decoded
|
|
45
|
+
* object is declared authoritative, and every value still passes the
|
|
46
|
+
* classifier gate afterwards.
|
|
47
|
+
*/
|
|
48
|
+
export declare function decodeAgentDraftTokenV1(token: string): AgentDraftDecodeResult;
|
|
49
|
+
export type AgentDraftEncodeResult = {
|
|
50
|
+
ok: true;
|
|
51
|
+
token: string;
|
|
52
|
+
} | {
|
|
53
|
+
ok: false;
|
|
54
|
+
reason: AgentDraftDecodeFailureReason;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Compose an `ab1.` token from validated fields. The only sanctioned caller
|
|
58
|
+
* is `draft_improve_command`; encoding enforces the same structural rules as
|
|
59
|
+
* decoding so a composing bug cannot emit an undecodable token.
|
|
60
|
+
*/
|
|
61
|
+
export declare function encodeAgentDraftTokenV1(draft: Omit<AgentDraftV1, "v">): AgentDraftEncodeResult;
|
|
62
|
+
export type AgentDraftSentenceVerdict = {
|
|
63
|
+
ok: true;
|
|
64
|
+
value: string;
|
|
65
|
+
} | {
|
|
66
|
+
ok: false;
|
|
67
|
+
reason: string;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* The ONE screening path a drafted plan sentence takes, used verbatim by
|
|
71
|
+
* `draft_improve_command` at composition and by `improve --draft` before a
|
|
72
|
+
* prefill can render: sanitize exactly like typed input, then classify with
|
|
73
|
+
* the shared hardened prose classifier. Because both surfaces call this
|
|
74
|
+
* function, MCP-preview and CLI-gate verdicts cannot diverge (QA 12).
|
|
75
|
+
*
|
|
76
|
+
* Rejection reasons are the terminal's own reprompt copy; credential
|
|
77
|
+
* rejections never echo the text.
|
|
78
|
+
*/
|
|
79
|
+
export declare function screenAgentDraftSentence(sentence: string): AgentDraftSentenceVerdict;
|
|
80
|
+
//# sourceMappingURL=agentDraftToken.d.ts.map
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ab1.` agent-draft token: how a conversationally drafted improve plan
|
|
3
|
+
* travels from `draft_improve_command` (MCP, read-only) to `aibill improve
|
|
4
|
+
* --draft` (terminal, human-approved) as ONE argv token.
|
|
5
|
+
*
|
|
6
|
+
* Design: AGENT_NATIVE_LOOP_DESIGN.md §2a (REV 2, QA-PASSED). The base64url
|
|
7
|
+
* alphabet contains no shell metacharacter, quote, or whitespace, so the
|
|
8
|
+
* token cannot break out of its argv slot in sh/bash/zsh/fish/pwsh and
|
|
9
|
+
* cannot be mangled by smart quotes, wrapping, or locale. Decoding NEVER
|
|
10
|
+
* throws — every failure is a tagged reason so a bad draft is set aside
|
|
11
|
+
* with copy, not a crash.
|
|
12
|
+
*/
|
|
13
|
+
import { classifyGuidedAnswer, looksLikeCredential } from "./guidedAnswer.js";
|
|
14
|
+
import { sanitizeLocalActivityText } from "./localAgentLogs.js";
|
|
15
|
+
/** `ab1` = aibill draft v1; `.` is outside base64url so the prefix is unambiguous. */
|
|
16
|
+
const TOKEN_PREFIX = "ab1.";
|
|
17
|
+
/**
|
|
18
|
+
* AUTHORITATIVE bound (m11): the whole token is at most 20,000 characters,
|
|
19
|
+
* which implies decoded JSON ≤ ~15,000 bytes. There is no separate
|
|
20
|
+
* decoded-byte cap.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_AGENT_DRAFT_TOKEN_CHARS = 20_000;
|
|
23
|
+
const TOKEN_SHAPE = /^ab1\.[A-Za-z0-9_-]{16,}$/;
|
|
24
|
+
const EXPERIMENT_ID_SHAPE = /^tre_v0_[a-f0-9]{64}$/;
|
|
25
|
+
// The design sketched {1,64}, but real revision ids are `trev_v0_<64hex>`
|
|
26
|
+
// (72 chars, actionVerification.ts L124) — widened to 128, same charset.
|
|
27
|
+
const REVISION_ID_SHAPE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
28
|
+
/** Single-line plain text: no C0/C1 controls (same refine the MCP schema uses). */
|
|
29
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/;
|
|
30
|
+
const MAX_SENTENCE_CHARS = 1_000;
|
|
31
|
+
const EXPECTED_KEYS = [
|
|
32
|
+
"canary", "change", "experimentId", "revisionId", "rollback", "v"
|
|
33
|
+
];
|
|
34
|
+
/** Cheap argv-time shape check shared with parseArgs (full decode comes later). */
|
|
35
|
+
export function looksLikeAgentDraftToken(value) {
|
|
36
|
+
return value.length <= MAX_AGENT_DRAFT_TOKEN_CHARS && TOKEN_SHAPE.test(value);
|
|
37
|
+
}
|
|
38
|
+
function validSentence(value) {
|
|
39
|
+
return typeof value === "string" &&
|
|
40
|
+
value.length >= 1 &&
|
|
41
|
+
value.length <= MAX_SENTENCE_CHARS &&
|
|
42
|
+
!CONTROL_CHARACTERS.test(value);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decode and structurally validate an `ab1.` token. Hardening (m11, exact
|
|
46
|
+
* spec): the payload must JSON.parse to a plain object; keys are compared
|
|
47
|
+
* as a strict SET against the six expected keys (`Object.keys` DOES surface
|
|
48
|
+
* `__proto__` as an own key after JSON.parse, so `__proto__`/`constructor`/
|
|
49
|
+
* any extra key fails the set check); values are copied field-by-field onto
|
|
50
|
+
* a fresh null-prototype object before any further use. JSON duplicate keys
|
|
51
|
+
* are last-win in JSON.parse and undetectable post-parse: the decoded
|
|
52
|
+
* object is declared authoritative, and every value still passes the
|
|
53
|
+
* classifier gate afterwards.
|
|
54
|
+
*/
|
|
55
|
+
export function decodeAgentDraftTokenV1(token) {
|
|
56
|
+
if (typeof token !== "string" || !token.startsWith(TOKEN_PREFIX)) {
|
|
57
|
+
return { ok: false, reason: "not_a_token" };
|
|
58
|
+
}
|
|
59
|
+
if (token.length > MAX_AGENT_DRAFT_TOKEN_CHARS) {
|
|
60
|
+
return { ok: false, reason: "token_too_long" };
|
|
61
|
+
}
|
|
62
|
+
if (!TOKEN_SHAPE.test(token)) {
|
|
63
|
+
return { ok: false, reason: "not_a_token" };
|
|
64
|
+
}
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
const payload = Buffer.from(token.slice(TOKEN_PREFIX.length), "base64url");
|
|
68
|
+
parsed = JSON.parse(payload.toString("utf8"));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return { ok: false, reason: "not_base64url_json" };
|
|
72
|
+
}
|
|
73
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
74
|
+
return { ok: false, reason: "not_a_plain_object" };
|
|
75
|
+
}
|
|
76
|
+
const keys = Object.keys(parsed).sort();
|
|
77
|
+
if (keys.length !== EXPECTED_KEYS.length ||
|
|
78
|
+
keys.some((key, index) => key !== EXPECTED_KEYS[index])) {
|
|
79
|
+
return { ok: false, reason: "unexpected_keys" };
|
|
80
|
+
}
|
|
81
|
+
// Field-by-field copy onto a null-prototype object; the parsed object is
|
|
82
|
+
// never spread or merged into a prototyped object.
|
|
83
|
+
const record = parsed;
|
|
84
|
+
const draft = Object.assign(Object.create(null), {
|
|
85
|
+
v: 1,
|
|
86
|
+
experimentId: "",
|
|
87
|
+
revisionId: "",
|
|
88
|
+
change: "",
|
|
89
|
+
rollback: "",
|
|
90
|
+
canary: ""
|
|
91
|
+
});
|
|
92
|
+
if (record.v !== 1)
|
|
93
|
+
return { ok: false, reason: "unsupported_version" };
|
|
94
|
+
if (typeof record.experimentId !== "string" ||
|
|
95
|
+
!EXPERIMENT_ID_SHAPE.test(record.experimentId)) {
|
|
96
|
+
return { ok: false, reason: "invalid_experiment_id" };
|
|
97
|
+
}
|
|
98
|
+
if (typeof record.revisionId !== "string" ||
|
|
99
|
+
!REVISION_ID_SHAPE.test(record.revisionId)) {
|
|
100
|
+
return { ok: false, reason: "invalid_revision_id" };
|
|
101
|
+
}
|
|
102
|
+
if (!validSentence(record.change) || !validSentence(record.rollback) ||
|
|
103
|
+
!validSentence(record.canary)) {
|
|
104
|
+
return { ok: false, reason: "invalid_sentence" };
|
|
105
|
+
}
|
|
106
|
+
draft.experimentId = record.experimentId;
|
|
107
|
+
draft.revisionId = record.revisionId;
|
|
108
|
+
draft.change = record.change;
|
|
109
|
+
draft.rollback = record.rollback;
|
|
110
|
+
draft.canary = record.canary;
|
|
111
|
+
return { ok: true, draft };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Compose an `ab1.` token from validated fields. The only sanctioned caller
|
|
115
|
+
* is `draft_improve_command`; encoding enforces the same structural rules as
|
|
116
|
+
* decoding so a composing bug cannot emit an undecodable token.
|
|
117
|
+
*/
|
|
118
|
+
export function encodeAgentDraftTokenV1(draft) {
|
|
119
|
+
if (!EXPERIMENT_ID_SHAPE.test(draft.experimentId)) {
|
|
120
|
+
return { ok: false, reason: "invalid_experiment_id" };
|
|
121
|
+
}
|
|
122
|
+
if (!REVISION_ID_SHAPE.test(draft.revisionId)) {
|
|
123
|
+
return { ok: false, reason: "invalid_revision_id" };
|
|
124
|
+
}
|
|
125
|
+
if (!validSentence(draft.change) || !validSentence(draft.rollback) ||
|
|
126
|
+
!validSentence(draft.canary)) {
|
|
127
|
+
return { ok: false, reason: "invalid_sentence" };
|
|
128
|
+
}
|
|
129
|
+
const payload = JSON.stringify({
|
|
130
|
+
v: 1,
|
|
131
|
+
experimentId: draft.experimentId,
|
|
132
|
+
revisionId: draft.revisionId,
|
|
133
|
+
change: draft.change,
|
|
134
|
+
rollback: draft.rollback,
|
|
135
|
+
canary: draft.canary
|
|
136
|
+
});
|
|
137
|
+
const token = TOKEN_PREFIX + Buffer.from(payload, "utf8").toString("base64url");
|
|
138
|
+
if (token.length > MAX_AGENT_DRAFT_TOKEN_CHARS) {
|
|
139
|
+
return { ok: false, reason: "token_too_long" };
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, token };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The ONE screening path a drafted plan sentence takes, used verbatim by
|
|
145
|
+
* `draft_improve_command` at composition and by `improve --draft` before a
|
|
146
|
+
* prefill can render: sanitize exactly like typed input, then classify with
|
|
147
|
+
* the shared hardened prose classifier. Because both surfaces call this
|
|
148
|
+
* function, MCP-preview and CLI-gate verdicts cannot diverge (QA 12).
|
|
149
|
+
*
|
|
150
|
+
* Rejection reasons are the terminal's own reprompt copy; credential
|
|
151
|
+
* rejections never echo the text.
|
|
152
|
+
*/
|
|
153
|
+
export function screenAgentDraftSentence(sentence) {
|
|
154
|
+
// A credential anywhere in the RAW draft sets the whole field aside (the
|
|
155
|
+
// never-echoed A3 fallback path). Sanitizing it away and accepting the
|
|
156
|
+
// mutated remainder would record a sentence nobody wrote under the
|
|
157
|
+
// "Drafted with your agent" label (impl QA m-1).
|
|
158
|
+
if (looksLikeCredential(sentence)) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
reason: "That draft contains something credential-shaped. aibill never stores credentials — the draft was set aside."
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// Sanitize before classification so no rejection reason can echo raw
|
|
165
|
+
// fragments. An over-long sentence is rejected by the classifier's own
|
|
166
|
+
// length rule, with its own copy — never silently truncated.
|
|
167
|
+
const sanitized = sanitizeLocalActivityText(sentence).trim();
|
|
168
|
+
const verdict = classifyGuidedAnswer("prose", sanitized);
|
|
169
|
+
if (verdict.outcome === "accept") {
|
|
170
|
+
// `keep` is a terminal-only escape hatch, meaningless in a draft.
|
|
171
|
+
if (verdict.value === "keep") {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: "That is aibill's own reserved vocabulary, not a plan sentence."
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return { ok: true, value: verdict.value };
|
|
178
|
+
}
|
|
179
|
+
if (verdict.outcome === "reject") {
|
|
180
|
+
return { ok: false, reason: verdict.message };
|
|
181
|
+
}
|
|
182
|
+
// navigate/skip: the sentence collided with reserved navigation words.
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
reason: "That is aibill's own navigation vocabulary (back/cancel), not a plan sentence."
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=agentDraftToken.js.map
|