@agent-finops/core 0.5.9 → 0.6.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 +6 -0
- package/dist/activitySnapshot.d.ts +676 -0
- package/dist/activitySnapshot.js +1220 -0
- package/dist/activitySnapshotCache.d.ts +54 -0
- package/dist/activitySnapshotCache.js +489 -0
- package/dist/discovery.d.ts +6 -2
- package/dist/discovery.js +36 -14
- package/dist/glance.d.ts +6 -2
- package/dist/glance.js +62 -13
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/localAgentLogs.d.ts +68 -2
- package/dist/localAgentLogs.js +972 -59
- package/dist/modelPricing.js +0 -1
- package/dist/providerConnectors.d.ts +13 -2
- package/dist/providerConnectors.js +708 -95
- package/dist/sampleData.js +4 -3
- package/dist/schema.d.ts +31 -29
- package/dist/schema.js +27 -3
- package/dist/sourceRegistry.d.ts +30 -5
- package/dist/sourceRegistry.js +250 -21
- package/dist/sourceStatus.d.ts +65 -0
- package/dist/sourceStatus.js +147 -0
- package/dist/stateTrust.d.ts +37 -0
- package/dist/stateTrust.js +277 -0
- package/package.json +1 -1
- package/samples/openai-usage.csv +2 -2
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type ActivitySnapshot, type ActivitySnapshotRefreshErrorCode } from "./activitySnapshot.js";
|
|
2
|
+
export declare const activitySnapshotCacheEnvironmentVariable = "AIBILL_CACHE_DIR";
|
|
3
|
+
export declare const activitySnapshotCacheFileName = "statusline-v1.json";
|
|
4
|
+
export declare const activitySnapshotCacheMaxBytes: number;
|
|
5
|
+
export type ActivitySnapshotCacheOptions = {
|
|
6
|
+
/** Test/embedding override. Production defaults to ~/.aibill/cache. */
|
|
7
|
+
cacheDirectory?: string;
|
|
8
|
+
/** Test-only home override for validating the default private path. */
|
|
9
|
+
homeDirectory?: string;
|
|
10
|
+
/** Bounded wait for a concurrent writer. */
|
|
11
|
+
lockTimeoutMs?: number;
|
|
12
|
+
};
|
|
13
|
+
export type ActivitySnapshotCacheReadErrorCode = "unsafe_directory" | "unsafe_file" | "oversized" | "malformed" | "unsupported_version" | "permission" | "io";
|
|
14
|
+
export type ActivitySnapshotCacheReadResult = {
|
|
15
|
+
status: "ok";
|
|
16
|
+
snapshot: ActivitySnapshot;
|
|
17
|
+
} | {
|
|
18
|
+
status: "missing";
|
|
19
|
+
} | {
|
|
20
|
+
status: "error";
|
|
21
|
+
code: ActivitySnapshotCacheReadErrorCode;
|
|
22
|
+
};
|
|
23
|
+
export type ActivitySnapshotCacheWriteResult = {
|
|
24
|
+
status: "written";
|
|
25
|
+
snapshot: ActivitySnapshot;
|
|
26
|
+
} | {
|
|
27
|
+
status: "skipped_older";
|
|
28
|
+
snapshot: ActivitySnapshot;
|
|
29
|
+
};
|
|
30
|
+
export declare class ActivitySnapshotCacheError extends Error {
|
|
31
|
+
readonly code: ActivitySnapshotCacheReadErrorCode | "lock_timeout" | "invalid_snapshot";
|
|
32
|
+
constructor(code: ActivitySnapshotCacheError["code"], message: string);
|
|
33
|
+
}
|
|
34
|
+
/** Resolve the fixed cache filename without creating or trusting it. */
|
|
35
|
+
export declare function activitySnapshotCachePath(options?: ActivitySnapshotCacheOptions): string;
|
|
36
|
+
/**
|
|
37
|
+
* Read at most 64 KiB and validate the complete strict v1 contract. All
|
|
38
|
+
* expected unsafe/malformed states are returned as data so status surfaces can
|
|
39
|
+
* fail closed without printing local filesystem details.
|
|
40
|
+
*/
|
|
41
|
+
export declare function readActivitySnapshot(options?: ActivitySnapshotCacheOptions): Promise<ActivitySnapshotCacheReadResult>;
|
|
42
|
+
/**
|
|
43
|
+
* Atomically publish a valid snapshot. Writers serialize through one bounded
|
|
44
|
+
* private lock; comparison happens while the lock is held so a late, older
|
|
45
|
+
* scan cannot replace newer evidence.
|
|
46
|
+
*/
|
|
47
|
+
export declare function writeActivitySnapshot(snapshot: ActivitySnapshot, options?: ActivitySnapshotCacheOptions): Promise<ActivitySnapshotCacheWriteResult>;
|
|
48
|
+
/**
|
|
49
|
+
* Preserve the last-good financial values after a failed refresh. Only the
|
|
50
|
+
* typed attempt timestamp and sanitized error code change. With no prior good
|
|
51
|
+
* value, write a bounded error snapshot instead of manufacturing data.
|
|
52
|
+
*/
|
|
53
|
+
export declare function recordActivitySnapshotRefreshFailure(attemptedAt: string, errorCode: ActivitySnapshotRefreshErrorCode, options?: ActivitySnapshotCacheOptions): Promise<ActivitySnapshotCacheWriteResult>;
|
|
54
|
+
//# sourceMappingURL=activitySnapshotCache.d.ts.map
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, lstat, mkdir, chmod, realpath, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { activitySnapshotSchema, createActivitySnapshotError } from "./activitySnapshot.js";
|
|
8
|
+
export const activitySnapshotCacheEnvironmentVariable = "AIBILL_CACHE_DIR";
|
|
9
|
+
export const activitySnapshotCacheFileName = "statusline-v1.json";
|
|
10
|
+
export const activitySnapshotCacheMaxBytes = 64 * 1_024;
|
|
11
|
+
const lockFileName = ".statusline-v1.lock";
|
|
12
|
+
const defaultLockTimeoutMs = 2_000;
|
|
13
|
+
const staleLockMs = 15_000;
|
|
14
|
+
const lockPollMs = 20;
|
|
15
|
+
const lockMetadataMaxBytes = 512;
|
|
16
|
+
export class ActivitySnapshotCacheError extends Error {
|
|
17
|
+
code;
|
|
18
|
+
constructor(code, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "ActivitySnapshotCacheError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Resolve the fixed cache filename without creating or trusting it. */
|
|
25
|
+
export function activitySnapshotCachePath(options = {}) {
|
|
26
|
+
return join(configuredCacheDirectory(options), activitySnapshotCacheFileName);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read at most 64 KiB and validate the complete strict v1 contract. All
|
|
30
|
+
* expected unsafe/malformed states are returned as data so status surfaces can
|
|
31
|
+
* fail closed without printing local filesystem details.
|
|
32
|
+
*/
|
|
33
|
+
export async function readActivitySnapshot(options = {}) {
|
|
34
|
+
let directory;
|
|
35
|
+
try {
|
|
36
|
+
directory = await resolveCacheDirectory(false, options);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (isNodeError(error, "ENOENT"))
|
|
40
|
+
return { status: "missing" };
|
|
41
|
+
return { status: "error", code: cacheReadErrorCode(error, "unsafe_directory") };
|
|
42
|
+
}
|
|
43
|
+
return readSnapshotFile(directory);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Atomically publish a valid snapshot. Writers serialize through one bounded
|
|
47
|
+
* private lock; comparison happens while the lock is held so a late, older
|
|
48
|
+
* scan cannot replace newer evidence.
|
|
49
|
+
*/
|
|
50
|
+
export async function writeActivitySnapshot(snapshot, options = {}) {
|
|
51
|
+
let candidate;
|
|
52
|
+
try {
|
|
53
|
+
candidate = activitySnapshotSchema.parse(snapshot);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new ActivitySnapshotCacheError("invalid_snapshot", "Activity snapshot does not match the strict v1 contract.");
|
|
57
|
+
}
|
|
58
|
+
return withWriterLock(options, async (directory) => {
|
|
59
|
+
const existing = await readSnapshotFile(directory);
|
|
60
|
+
if (existing.status === "ok" && !isNewer(candidate, existing.snapshot)) {
|
|
61
|
+
return { status: "skipped_older", snapshot: existing.snapshot };
|
|
62
|
+
}
|
|
63
|
+
if (existing.status === "error" &&
|
|
64
|
+
(existing.code === "unsafe_file" || existing.code === "permission" ||
|
|
65
|
+
existing.code === "unsupported_version")) {
|
|
66
|
+
throw new ActivitySnapshotCacheError(existing.code, "Refusing to replace an unsafe or inaccessible activity snapshot cache file.");
|
|
67
|
+
}
|
|
68
|
+
await atomicWriteSnapshot(directory, candidate);
|
|
69
|
+
return { status: "written", snapshot: candidate };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Preserve the last-good financial values after a failed refresh. Only the
|
|
74
|
+
* typed attempt timestamp and sanitized error code change. With no prior good
|
|
75
|
+
* value, write a bounded error snapshot instead of manufacturing data.
|
|
76
|
+
*/
|
|
77
|
+
export async function recordActivitySnapshotRefreshFailure(attemptedAt, errorCode, options = {}) {
|
|
78
|
+
const parsedAttempt = new Date(attemptedAt);
|
|
79
|
+
if (!Number.isFinite(parsedAttempt.getTime())) {
|
|
80
|
+
throw new ActivitySnapshotCacheError("invalid_snapshot", "Refresh attempt time must be a valid ISO timestamp.");
|
|
81
|
+
}
|
|
82
|
+
const normalizedAttempt = parsedAttempt.toISOString();
|
|
83
|
+
return withWriterLock(options, async (directory) => {
|
|
84
|
+
const existing = await readSnapshotFile(directory);
|
|
85
|
+
if (existing.status === "ok") {
|
|
86
|
+
const newestExistingEvent = Math.max(Date.parse(existing.snapshot.lastAttemptAt), Date.parse(existing.snapshot.generatedAt));
|
|
87
|
+
if (Date.parse(normalizedAttempt) <= newestExistingEvent) {
|
|
88
|
+
return { status: "skipped_older", snapshot: existing.snapshot };
|
|
89
|
+
}
|
|
90
|
+
const failed = activitySnapshotSchema.parse({
|
|
91
|
+
...existing.snapshot,
|
|
92
|
+
lastAttemptAt: normalizedAttempt,
|
|
93
|
+
refresh: { status: "error", errorCode }
|
|
94
|
+
});
|
|
95
|
+
await atomicWriteSnapshot(directory, failed);
|
|
96
|
+
return { status: "written", snapshot: failed };
|
|
97
|
+
}
|
|
98
|
+
if (existing.status === "error" &&
|
|
99
|
+
(existing.code === "unsafe_file" || existing.code === "permission" ||
|
|
100
|
+
existing.code === "unsupported_version")) {
|
|
101
|
+
throw new ActivitySnapshotCacheError(existing.code, "Refusing to replace an unsafe or inaccessible activity snapshot cache file.");
|
|
102
|
+
}
|
|
103
|
+
const failed = createActivitySnapshotError(normalizedAttempt, errorCode);
|
|
104
|
+
await atomicWriteSnapshot(directory, failed);
|
|
105
|
+
return { status: "written", snapshot: failed };
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
async function withWriterLock(options, operation) {
|
|
109
|
+
const directory = await resolveCacheDirectory(true, options);
|
|
110
|
+
const lockPath = join(directory, lockFileName);
|
|
111
|
+
const timeout = boundedLockTimeout(options.lockTimeoutMs);
|
|
112
|
+
const started = Date.now();
|
|
113
|
+
let lockHandle;
|
|
114
|
+
let lockIdentity;
|
|
115
|
+
while (!lockHandle) {
|
|
116
|
+
let candidateHandle;
|
|
117
|
+
let candidateIdentity;
|
|
118
|
+
try {
|
|
119
|
+
const owner = { pid: process.pid, token: randomUUID() };
|
|
120
|
+
candidateHandle = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
|
|
121
|
+
const candidateInfo = await candidateHandle.stat();
|
|
122
|
+
candidateIdentity = { ...owner, dev: candidateInfo.dev, ino: candidateInfo.ino };
|
|
123
|
+
await candidateHandle.writeFile(`${JSON.stringify(owner)}\n`, "utf8");
|
|
124
|
+
await candidateHandle.sync();
|
|
125
|
+
lockHandle = candidateHandle;
|
|
126
|
+
lockIdentity = candidateIdentity;
|
|
127
|
+
candidateHandle = undefined;
|
|
128
|
+
candidateIdentity = undefined;
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
await candidateHandle?.close().catch(() => undefined);
|
|
132
|
+
if (candidateIdentity)
|
|
133
|
+
await releaseOwnedLock(lockPath, candidateIdentity);
|
|
134
|
+
if (!isNodeError(error, "EEXIST")) {
|
|
135
|
+
if (isNodeError(error, "ELOOP")) {
|
|
136
|
+
throw new ActivitySnapshotCacheError("unsafe_file", "Activity snapshot writer lock is a symbolic link.");
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
await removeStaleLock(lockPath);
|
|
141
|
+
if (Date.now() - started >= timeout) {
|
|
142
|
+
throw new ActivitySnapshotCacheError("lock_timeout", "Timed out waiting for the activity snapshot writer lock.");
|
|
143
|
+
}
|
|
144
|
+
await delay(lockPollMs);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
return await operation(directory);
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
await lockHandle.close().catch(() => undefined);
|
|
152
|
+
if (lockIdentity)
|
|
153
|
+
await releaseOwnedLock(lockPath, lockIdentity);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function readSnapshotFile(directory) {
|
|
157
|
+
const filePath = join(directory, activitySnapshotCacheFileName);
|
|
158
|
+
let fileInfo;
|
|
159
|
+
try {
|
|
160
|
+
fileInfo = await lstat(filePath);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (isNodeError(error, "ENOENT"))
|
|
164
|
+
return { status: "missing" };
|
|
165
|
+
return { status: "error", code: cacheReadErrorCode(error, "io") };
|
|
166
|
+
}
|
|
167
|
+
if (fileInfo.isSymbolicLink() || !fileInfo.isFile()) {
|
|
168
|
+
return { status: "error", code: "unsafe_file" };
|
|
169
|
+
}
|
|
170
|
+
if (!hasPrivatePermissions(fileInfo.mode)) {
|
|
171
|
+
return { status: "error", code: "unsafe_file" };
|
|
172
|
+
}
|
|
173
|
+
if (fileInfo.size > activitySnapshotCacheMaxBytes) {
|
|
174
|
+
return { status: "error", code: "oversized" };
|
|
175
|
+
}
|
|
176
|
+
let handle;
|
|
177
|
+
try {
|
|
178
|
+
handle = await open(filePath, constants.O_RDONLY | noFollowFlag());
|
|
179
|
+
const openedInfo = await handle.stat();
|
|
180
|
+
if (!openedInfo.isFile())
|
|
181
|
+
return { status: "error", code: "unsafe_file" };
|
|
182
|
+
if (!hasPrivatePermissions(openedInfo.mode)) {
|
|
183
|
+
return { status: "error", code: "unsafe_file" };
|
|
184
|
+
}
|
|
185
|
+
if (openedInfo.size > activitySnapshotCacheMaxBytes) {
|
|
186
|
+
return { status: "error", code: "oversized" };
|
|
187
|
+
}
|
|
188
|
+
// Bound the actual read, not only the preceding stat: the file may grow
|
|
189
|
+
// after inspection. One extra byte is enough to detect overflow without
|
|
190
|
+
// ever allocating or reading an attacker-controlled whole file.
|
|
191
|
+
const bounded = Buffer.allocUnsafe(activitySnapshotCacheMaxBytes + 1);
|
|
192
|
+
let bytesRead = 0;
|
|
193
|
+
while (bytesRead < bounded.length) {
|
|
194
|
+
const result = await handle.read(bounded, bytesRead, bounded.length - bytesRead, bytesRead);
|
|
195
|
+
if (result.bytesRead === 0)
|
|
196
|
+
break;
|
|
197
|
+
bytesRead += result.bytesRead;
|
|
198
|
+
}
|
|
199
|
+
if (bytesRead > activitySnapshotCacheMaxBytes) {
|
|
200
|
+
return { status: "error", code: "oversized" };
|
|
201
|
+
}
|
|
202
|
+
const contents = bounded.subarray(0, bytesRead).toString("utf8");
|
|
203
|
+
let value;
|
|
204
|
+
try {
|
|
205
|
+
value = JSON.parse(contents);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return { status: "error", code: "malformed" };
|
|
209
|
+
}
|
|
210
|
+
if (isRecord(value) && value.schemaVersion !== undefined && value.schemaVersion !== 1) {
|
|
211
|
+
return { status: "error", code: "unsupported_version" };
|
|
212
|
+
}
|
|
213
|
+
const parsed = activitySnapshotSchema.safeParse(value);
|
|
214
|
+
return parsed.success
|
|
215
|
+
? { status: "ok", snapshot: parsed.data }
|
|
216
|
+
: { status: "error", code: "malformed" };
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (isNodeError(error, "ELOOP"))
|
|
220
|
+
return { status: "error", code: "unsafe_file" };
|
|
221
|
+
return { status: "error", code: cacheReadErrorCode(error, "io") };
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
await handle?.close().catch(() => undefined);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function atomicWriteSnapshot(directory, snapshot) {
|
|
228
|
+
const contents = `${JSON.stringify(snapshot)}\n`;
|
|
229
|
+
if (Buffer.byteLength(contents, "utf8") > activitySnapshotCacheMaxBytes) {
|
|
230
|
+
throw new ActivitySnapshotCacheError("invalid_snapshot", "Activity snapshot exceeds the 64 KiB cache limit.");
|
|
231
|
+
}
|
|
232
|
+
const filePath = join(directory, activitySnapshotCacheFileName);
|
|
233
|
+
const existing = await lstat(filePath).catch((error) => {
|
|
234
|
+
if (isNodeError(error, "ENOENT"))
|
|
235
|
+
return undefined;
|
|
236
|
+
throw error;
|
|
237
|
+
});
|
|
238
|
+
if (existing?.isSymbolicLink() || (existing && !existing.isFile())) {
|
|
239
|
+
throw new ActivitySnapshotCacheError("unsafe_file", "Activity snapshot cache path is not a regular file.");
|
|
240
|
+
}
|
|
241
|
+
const temporaryPath = join(directory, `.${activitySnapshotCacheFileName}.${process.pid}.${randomUUID()}.tmp`);
|
|
242
|
+
let handle;
|
|
243
|
+
try {
|
|
244
|
+
handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
|
|
245
|
+
await handle.writeFile(contents, "utf8");
|
|
246
|
+
await handle.sync();
|
|
247
|
+
await handle.close();
|
|
248
|
+
handle = undefined;
|
|
249
|
+
await rename(temporaryPath, filePath);
|
|
250
|
+
await chmod(filePath, 0o600);
|
|
251
|
+
await syncDirectory(directory);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
await handle?.close().catch(() => undefined);
|
|
255
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async function resolveCacheDirectory(create, options) {
|
|
260
|
+
const usesDefaultDirectory = !options.cacheDirectory?.trim() &&
|
|
261
|
+
!process.env[activitySnapshotCacheEnvironmentVariable]?.trim();
|
|
262
|
+
if (usesDefaultDirectory) {
|
|
263
|
+
await ensureDefaultParent(options.homeDirectory ?? homedir(), create);
|
|
264
|
+
}
|
|
265
|
+
const requested = configuredCacheDirectory(options);
|
|
266
|
+
let info = await lstat(requested).catch((error) => {
|
|
267
|
+
if (isNodeError(error, "ENOENT"))
|
|
268
|
+
return undefined;
|
|
269
|
+
throw error;
|
|
270
|
+
});
|
|
271
|
+
if (!info && create) {
|
|
272
|
+
if (usesDefaultDirectory) {
|
|
273
|
+
await mkdir(requested, { mode: 0o700 }).catch((error) => {
|
|
274
|
+
if (!isNodeError(error, "EEXIST"))
|
|
275
|
+
throw error;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
await mkdir(requested, { recursive: true, mode: 0o700 });
|
|
280
|
+
}
|
|
281
|
+
info = await lstat(requested);
|
|
282
|
+
}
|
|
283
|
+
if (!info) {
|
|
284
|
+
const error = new Error("Activity snapshot cache directory does not exist.");
|
|
285
|
+
error.code = "ENOENT";
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
289
|
+
throw new ActivitySnapshotCacheError("unsafe_directory", "Activity snapshot cache directory is not a real directory.");
|
|
290
|
+
}
|
|
291
|
+
if (!create && !hasPrivatePermissions(info.mode)) {
|
|
292
|
+
throw new ActivitySnapshotCacheError("unsafe_directory", "Activity snapshot cache directory is not private.");
|
|
293
|
+
}
|
|
294
|
+
const canonical = await realpath(requested);
|
|
295
|
+
const confirmed = await lstat(requested);
|
|
296
|
+
if (confirmed.isSymbolicLink() || !confirmed.isDirectory()) {
|
|
297
|
+
throw new ActivitySnapshotCacheError("unsafe_directory", "Activity snapshot cache directory changed during validation.");
|
|
298
|
+
}
|
|
299
|
+
if (create)
|
|
300
|
+
await chmod(canonical, 0o700);
|
|
301
|
+
return canonical;
|
|
302
|
+
}
|
|
303
|
+
async function ensureDefaultParent(homeDirectory, create) {
|
|
304
|
+
const parent = join(homeDirectory, ".aibill");
|
|
305
|
+
let info = await lstat(parent).catch((error) => {
|
|
306
|
+
if (isNodeError(error, "ENOENT"))
|
|
307
|
+
return undefined;
|
|
308
|
+
throw error;
|
|
309
|
+
});
|
|
310
|
+
if (!info && create) {
|
|
311
|
+
await mkdir(parent, { mode: 0o700 }).catch((error) => {
|
|
312
|
+
// Two first-ever writers may both observe ENOENT. The winner creates
|
|
313
|
+
// the directory; the loser must re-validate it rather than fail.
|
|
314
|
+
if (!isNodeError(error, "EEXIST"))
|
|
315
|
+
throw error;
|
|
316
|
+
});
|
|
317
|
+
info = await lstat(parent);
|
|
318
|
+
}
|
|
319
|
+
if (!info) {
|
|
320
|
+
const error = new Error("The private aibill directory does not exist.");
|
|
321
|
+
error.code = "ENOENT";
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
325
|
+
throw new ActivitySnapshotCacheError("unsafe_directory", "The private aibill directory is not a real directory.");
|
|
326
|
+
}
|
|
327
|
+
if (!create && !hasPrivatePermissions(info.mode)) {
|
|
328
|
+
throw new ActivitySnapshotCacheError("unsafe_directory", "The private aibill directory is not private.");
|
|
329
|
+
}
|
|
330
|
+
if (create)
|
|
331
|
+
await chmod(parent, 0o700);
|
|
332
|
+
}
|
|
333
|
+
function configuredCacheDirectory(options) {
|
|
334
|
+
const configured = options.cacheDirectory?.trim() ||
|
|
335
|
+
process.env[activitySnapshotCacheEnvironmentVariable]?.trim();
|
|
336
|
+
const value = configured && configured.length > 0
|
|
337
|
+
? configured
|
|
338
|
+
: join(options.homeDirectory ?? homedir(), ".aibill", "cache");
|
|
339
|
+
return resolve(value);
|
|
340
|
+
}
|
|
341
|
+
async function removeStaleLock(lockPath) {
|
|
342
|
+
let handle;
|
|
343
|
+
try {
|
|
344
|
+
handle = await open(lockPath, constants.O_RDONLY | noFollowFlag());
|
|
345
|
+
const info = await handle.stat();
|
|
346
|
+
if (!info.isFile() || !hasPrivatePermissions(info.mode)) {
|
|
347
|
+
throw new ActivitySnapshotCacheError("unsafe_file", "Activity snapshot writer lock is not a private regular file.");
|
|
348
|
+
}
|
|
349
|
+
if (Date.now() - info.mtimeMs <= staleLockMs)
|
|
350
|
+
return;
|
|
351
|
+
const owner = await readLockOwner(handle);
|
|
352
|
+
// Age alone never proves abandonment: a live writer may be paused while
|
|
353
|
+
// holding the lock. Unknown/malformed ownership also fails closed.
|
|
354
|
+
if (!owner || processIsAlive(owner.pid))
|
|
355
|
+
return;
|
|
356
|
+
await handle.close();
|
|
357
|
+
handle = undefined;
|
|
358
|
+
await releaseOwnedLock(lockPath, { ...owner, dev: info.dev, ino: info.ino });
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
if (isNodeError(error, "ENOENT"))
|
|
362
|
+
return;
|
|
363
|
+
if (isNodeError(error, "ELOOP")) {
|
|
364
|
+
throw new ActivitySnapshotCacheError("unsafe_file", "Activity snapshot writer lock is a symbolic link.");
|
|
365
|
+
}
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
await handle?.close().catch(() => undefined);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async function releaseOwnedLock(lockPath, identity) {
|
|
373
|
+
let handle;
|
|
374
|
+
try {
|
|
375
|
+
handle = await open(lockPath, constants.O_RDONLY | noFollowFlag());
|
|
376
|
+
const info = await handle.stat();
|
|
377
|
+
if (!info.isFile() || info.dev !== identity.dev || info.ino !== identity.ino)
|
|
378
|
+
return;
|
|
379
|
+
const owner = await readLockOwner(handle);
|
|
380
|
+
if (!owner || owner.pid !== identity.pid || owner.token !== identity.token)
|
|
381
|
+
return;
|
|
382
|
+
await handle.close();
|
|
383
|
+
handle = undefined;
|
|
384
|
+
const confirmed = await lstat(lockPath).catch((error) => {
|
|
385
|
+
if (isNodeError(error, "ENOENT"))
|
|
386
|
+
return undefined;
|
|
387
|
+
throw error;
|
|
388
|
+
});
|
|
389
|
+
if (!confirmed || confirmed.isSymbolicLink() ||
|
|
390
|
+
confirmed.dev !== identity.dev || confirmed.ino !== identity.ino) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
// A live owner cannot be legitimately evicted, so once both inode and
|
|
394
|
+
// unguessable token match, unlinking cannot release another writer's lock.
|
|
395
|
+
await unlink(lockPath).catch((error) => {
|
|
396
|
+
if (!isNodeError(error, "ENOENT"))
|
|
397
|
+
throw error;
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
if (!isNodeError(error, "ENOENT") && !isNodeError(error, "ELOOP"))
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
await handle?.close().catch(() => undefined);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
async function readLockOwner(handle) {
|
|
409
|
+
const buffer = Buffer.allocUnsafe(lockMetadataMaxBytes + 1);
|
|
410
|
+
const result = await handle.read(buffer, 0, buffer.length, 0);
|
|
411
|
+
if (result.bytesRead === 0 || result.bytesRead > lockMetadataMaxBytes)
|
|
412
|
+
return undefined;
|
|
413
|
+
let value;
|
|
414
|
+
try {
|
|
415
|
+
value = JSON.parse(buffer.subarray(0, result.bytesRead).toString("utf8"));
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
if (!isRecord(value) || !Number.isSafeInteger(value.pid) ||
|
|
421
|
+
value.pid <= 0 || typeof value.token !== "string" ||
|
|
422
|
+
value.token.length < 16 || value.token.length > 128) {
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
return { pid: value.pid, token: value.token };
|
|
426
|
+
}
|
|
427
|
+
function processIsAlive(pid) {
|
|
428
|
+
try {
|
|
429
|
+
process.kill(pid, 0);
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
return !isNodeError(error, "ESRCH");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function syncDirectory(directory) {
|
|
437
|
+
let handle;
|
|
438
|
+
try {
|
|
439
|
+
handle = await open(directory, constants.O_RDONLY);
|
|
440
|
+
await handle.sync().catch((error) => {
|
|
441
|
+
if (!isNodeError(error, "EINVAL") && !isNodeError(error, "ENOTSUP"))
|
|
442
|
+
throw error;
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
await handle?.close().catch(() => undefined);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function isNewer(candidate, existing) {
|
|
450
|
+
const attemptDifference = Date.parse(candidate.lastAttemptAt) - Date.parse(existing.lastAttemptAt);
|
|
451
|
+
if (attemptDifference !== 0)
|
|
452
|
+
return attemptDifference > 0;
|
|
453
|
+
const generatedDifference = Date.parse(candidate.generatedAt) - Date.parse(existing.generatedAt);
|
|
454
|
+
if (generatedDifference !== 0)
|
|
455
|
+
return generatedDifference > 0;
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
function boundedLockTimeout(value) {
|
|
459
|
+
if (value === undefined)
|
|
460
|
+
return defaultLockTimeoutMs;
|
|
461
|
+
if (!Number.isFinite(value))
|
|
462
|
+
return defaultLockTimeoutMs;
|
|
463
|
+
return Math.max(0, Math.min(10_000, Math.floor(value)));
|
|
464
|
+
}
|
|
465
|
+
function cacheReadErrorCode(error, fallback) {
|
|
466
|
+
if (error instanceof ActivitySnapshotCacheError &&
|
|
467
|
+
(error.code === "unsafe_directory" || error.code === "unsafe_file" ||
|
|
468
|
+
error.code === "oversized" || error.code === "malformed" ||
|
|
469
|
+
error.code === "unsupported_version" || error.code === "permission" ||
|
|
470
|
+
error.code === "io")) {
|
|
471
|
+
return error.code;
|
|
472
|
+
}
|
|
473
|
+
if (isNodeError(error, "EACCES") || isNodeError(error, "EPERM"))
|
|
474
|
+
return "permission";
|
|
475
|
+
return fallback;
|
|
476
|
+
}
|
|
477
|
+
function noFollowFlag() {
|
|
478
|
+
return typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
479
|
+
}
|
|
480
|
+
function hasPrivatePermissions(mode) {
|
|
481
|
+
return process.platform === "win32" || (mode & 0o077) === 0;
|
|
482
|
+
}
|
|
483
|
+
function isRecord(value) {
|
|
484
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
485
|
+
}
|
|
486
|
+
function isNodeError(error, code) {
|
|
487
|
+
return error instanceof Error && error.code === code;
|
|
488
|
+
}
|
|
489
|
+
//# sourceMappingURL=activitySnapshotCache.js.map
|
package/dist/discovery.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type UsageSignalKind = "dependency" | "config" | "environment" | "source_
|
|
|
2
2
|
export type UsageSignal = {
|
|
3
3
|
provider: string;
|
|
4
4
|
kind: UsageSignalKind;
|
|
5
|
+
/** Deterministic opaque reference; never a repository-controlled filename. */
|
|
5
6
|
filePath: string;
|
|
6
7
|
/** Stable rule identity; present on scanner-produced signals. */
|
|
7
8
|
ruleId?: string;
|
|
@@ -12,6 +13,7 @@ export type UsageSignal = {
|
|
|
12
13
|
confidence: number;
|
|
13
14
|
};
|
|
14
15
|
export type UsageSignalEvidence = {
|
|
16
|
+
/** Same deterministic opaque reference as UsageSignal.filePath. */
|
|
15
17
|
file: string;
|
|
16
18
|
provider: string;
|
|
17
19
|
signal: UsageSignalKind;
|
|
@@ -20,12 +22,14 @@ export type UsageSignalEvidence = {
|
|
|
20
22
|
export type LocalDiscoveryResult = {
|
|
21
23
|
rootPath: string;
|
|
22
24
|
scannedFiles: number;
|
|
25
|
+
/** Deterministic opaque references for denied/heavy descendant directories. */
|
|
23
26
|
skippedDirectories: string[];
|
|
24
|
-
/**
|
|
27
|
+
/** Opaque references for symbolic links below the approved root. They are never followed. */
|
|
25
28
|
skippedSymlinks: string[];
|
|
26
|
-
/**
|
|
29
|
+
/** Opaque references for unreadable descendants — skipped, never fatal. */
|
|
27
30
|
unreadablePaths: string[];
|
|
28
31
|
signals: UsageSignal[];
|
|
32
|
+
/** Deterministic opaque references for detected secret assignments. */
|
|
29
33
|
secretsDetected: string[];
|
|
30
34
|
redactedEvidence: string[];
|
|
31
35
|
};
|