signafaide 0.0.0-stage → 0.3.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.
@@ -0,0 +1,3025 @@
1
+ import {
2
+ AGENT_KEYS_FORMAT,
3
+ AGENT_RE,
4
+ API_KEY_RE,
5
+ DEFAULT_TIMESTAMP_URL,
6
+ DRAND_NOT_CHECKED,
7
+ INSTANCE_START,
8
+ INSTANCE_STOP,
9
+ KeysRequired,
10
+ MAX_CLOCK_SKEW_MS,
11
+ SigningKey,
12
+ TOOLS,
13
+ agentName,
14
+ b64url,
15
+ b64urlDecode,
16
+ byCodePoint,
17
+ canonicalize,
18
+ checkContent,
19
+ clampTime,
20
+ contentFromText,
21
+ declaredName,
22
+ hashJson,
23
+ instanceStartContent,
24
+ instanceStopContent,
25
+ isLoopbackUrl,
26
+ isStopNote,
27
+ isUnsafeInteger,
28
+ loadDrandVerifier,
29
+ lookupTimestamps,
30
+ makeDraft,
31
+ makeRecord,
32
+ manifestEntry,
33
+ now,
34
+ parseAgentKeys,
35
+ parseHeadFile,
36
+ parseJson,
37
+ parseServiceKeys,
38
+ plural,
39
+ recordHash,
40
+ registration,
41
+ signDraft,
42
+ timestampHeights,
43
+ toolNoteName,
44
+ useNativeCrypto,
45
+ validProjectId,
46
+ validTime,
47
+ verifyLocalState,
48
+ verifyPack,
49
+ verifySig,
50
+ wireRecord,
51
+ withWallTime
52
+ } from "./chunk-6UDL5LZL.js";
53
+ import {
54
+ ADAPTERS,
55
+ Client,
56
+ DEFAULT_URL,
57
+ SignafaideError,
58
+ VERSION,
59
+ adapterField,
60
+ clientHeader,
61
+ detectAgent,
62
+ fetchJson,
63
+ telemetryEnabled
64
+ } from "./chunk-DRRRZNIH.js";
65
+
66
+ // src/nodecrypto.ts
67
+ import { createHash, createPublicKey, verify } from "crypto";
68
+ var SPKI_ED25519 = Buffer.from("302a300506032b6570032100", "hex");
69
+ var keys = /* @__PURE__ */ new Map();
70
+ function publicKey(raw) {
71
+ const id = Buffer.from(raw).toString("hex");
72
+ let k = keys.get(id);
73
+ if (k === void 0) {
74
+ try {
75
+ k = createPublicKey({ key: Buffer.concat([SPKI_ED25519, raw]), format: "der", type: "spki" });
76
+ } catch {
77
+ k = null;
78
+ }
79
+ if (keys.size > 1e4) keys.clear();
80
+ keys.set(id, k);
81
+ }
82
+ return k;
83
+ }
84
+ function installNodeCrypto() {
85
+ useNativeCrypto({
86
+ sha256: (data) => new Uint8Array(createHash("sha256").update(data).digest()),
87
+ verify: (pk, message, sig) => {
88
+ const k = publicKey(pk);
89
+ if (!k) return false;
90
+ try {
91
+ return verify(null, message, k, sig);
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+ });
97
+ }
98
+
99
+ // src/index.ts
100
+ import * as fs4 from "fs";
101
+ import * as path3 from "path";
102
+
103
+ // src/runtime.ts
104
+ import { AsyncLocalStorage } from "async_hooks";
105
+ import * as fs2 from "fs";
106
+ import * as os2 from "os";
107
+ import * as path2 from "path";
108
+
109
+ // src/nodehttp.ts
110
+ import * as http from "http";
111
+ import * as https from "https";
112
+ function nodeTransport(hold) {
113
+ return (req) => new Promise((resolve2, reject) => {
114
+ const u = new URL(req.url);
115
+ const mod = u.protocol === "https:" ? https : http;
116
+ const keep = hold();
117
+ let settled = false;
118
+ const done = (f) => {
119
+ if (settled) return;
120
+ settled = true;
121
+ clearTimeout(timer);
122
+ req.signal?.removeEventListener("abort", onAbort);
123
+ f();
124
+ };
125
+ const r = mod.request(u, { method: req.method, headers: { ...req.headers, ...req.body !== void 0 ? { "Content-Length": Buffer.byteLength(req.body) } : {} }, agent: false }, (res) => {
126
+ const chunks = [];
127
+ res.on("data", (c) => chunks.push(c));
128
+ res.on("end", () => done(() => resolve2({ status: res.statusCode ?? 0, contentType: String(res.headers["content-type"] ?? ""), text: Buffer.concat(chunks).toString("utf8") })));
129
+ res.on("error", (e) => done(() => reject(e)));
130
+ });
131
+ const timer = setTimeout(() => {
132
+ r.destroy(new Error("timeout"));
133
+ }, req.timeoutMs);
134
+ const onAbort = () => {
135
+ r.destroy(new Error("aborted"));
136
+ };
137
+ if (!keep) {
138
+ timer.unref();
139
+ r.on("socket", (s) => s.unref());
140
+ }
141
+ if (req.signal?.aborted) onAbort();
142
+ else req.signal?.addEventListener("abort", onAbort, { once: true });
143
+ r.on("error", (e) => done(() => reject(e)));
144
+ if (req.body !== void 0) r.write(req.body);
145
+ r.end();
146
+ });
147
+ }
148
+
149
+ // src/state.ts
150
+ import { createHash as createHash2, randomBytes } from "crypto";
151
+ import * as fs from "fs";
152
+ import * as os from "os";
153
+ import * as path from "path";
154
+ var MARKERS = [".git", "pyproject.toml", "package.json"];
155
+ var SEGMENT_BYTES = () => Number(process.env.SIGNAFAIDE_SEGMENT_BYTES) || 64 * 1024 * 1024;
156
+ var scopeOf = (bucket) => bucket.endsWith("/live") ? "live" : "";
157
+ var agentKeyEnvName = (agent, scope = "") => `SIGNAFAIDE_AGENT_KEY_${scope ? "LIVE_" : ""}${agent.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
158
+ function fsError(e, home) {
159
+ const code = e?.code;
160
+ if (code === "EACCES" || code === "EROFS" || code === "EPERM") {
161
+ return new StateError(
162
+ `cannot write signafAIde's local state in ${home} (${code === "EROFS" ? "read-only file system" : "permission denied"}). Set SIGNAFAIDE_HOME to a writable folder (for example a mounted volume, or /tmp/signafaide on serverless) or SIGNAFAIDE_DISABLED=1 to turn recording off; see https://signafaide.co.uk/docs/production`
163
+ );
164
+ }
165
+ return e;
166
+ }
167
+ var TOOL_SOURCE_REFRESH_MS = 36e5;
168
+ var TOOL_SOURCE_MAX_AGE_MS = 864e5;
169
+ var LOCK_STALE_MS = 1e4;
170
+ var LOCK_WAIT_MS = 15e3;
171
+ var PENDING = "_pending";
172
+ var monotonicMs = () => Number(process.hrtime.bigint() / 1000000n);
173
+ function bootId() {
174
+ try {
175
+ return fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
176
+ } catch {
177
+ return "";
178
+ }
179
+ }
180
+ function lockStamp() {
181
+ return `${process.pid} ${bootId() || "-"} ${(monotonicMs() / 1e3).toFixed(3)}`;
182
+ }
183
+ function lockAgeMs(p) {
184
+ let parts = [];
185
+ try {
186
+ parts = fs.readFileSync(p, "utf8").split(/\s+/).filter(Boolean);
187
+ } catch {
188
+ parts = [];
189
+ }
190
+ const boot = bootId();
191
+ if (parts.length === 3 && boot && parts[1] === boot && Number.isFinite(Number(parts[2]))) return monotonicMs() - Number(parts[2]) * 1e3;
192
+ return Date.now() - fs.statSync(p).mtimeMs;
193
+ }
194
+ var keyFingerprint = (apiKey) => createHash2("sha256").update(apiKey).digest("hex").slice(0, 16);
195
+ var StateError = class extends Error {
196
+ name = "StateError";
197
+ };
198
+ function findRoot(start = process.cwd()) {
199
+ start = path.resolve(start);
200
+ const home = path.resolve(os.homedir());
201
+ const chain = [];
202
+ for (let d = start; ; d = path.dirname(d)) {
203
+ chain.push(d);
204
+ if (path.dirname(d) === d) break;
205
+ }
206
+ for (const d of chain) if (isDir(path.join(d, ".signafaide"))) return d;
207
+ for (const d of chain) {
208
+ if (d === home || path.dirname(d) === d) break;
209
+ if (MARKERS.some((m) => fs.existsSync(path.join(d, m)))) return d;
210
+ }
211
+ return start;
212
+ }
213
+ var isDir = (p) => {
214
+ try {
215
+ return fs.statSync(p).isDirectory();
216
+ } catch {
217
+ return false;
218
+ }
219
+ };
220
+ var sleepSync = (ms) => {
221
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
222
+ };
223
+ var pad = (seq) => String(seq).padStart(12, "0");
224
+ var State = class _State {
225
+ constructor(home, root) {
226
+ this.home = home;
227
+ this.root = root;
228
+ }
229
+ home;
230
+ root;
231
+ /** Agent keys from a secret store, before the key files (`RecorderOptions.keyProvider`). */
232
+ keyProvider;
233
+ /** `root`: the project folder (default: found from the cwd). */
234
+ static discover(home, root) {
235
+ root = root ? path.resolve(root) : findRoot();
236
+ home = home || process.env.SIGNAFAIDE_HOME;
237
+ return new _State(home ? path.resolve(home) : path.join(root, ".signafaide"), root);
238
+ }
239
+ // --- layout -----------------------------------------------------------------
240
+ /** Create the folders. `.gitignore` is never edited here: only `signafaide init` and
241
+ * `agent setup` do that, so a CI checkout stays clean (§8). */
242
+ ensure() {
243
+ try {
244
+ for (const sub of ["agents", "chain", "outbox"]) fs.mkdirSync(path.join(this.home, sub), { recursive: true });
245
+ const gi = path.join(this.home, ".gitignore");
246
+ if (!fs.existsSync(gi)) fs.writeFileSync(gi, "*\n");
247
+ } catch (e) {
248
+ throw fsError(e, this.home);
249
+ }
250
+ }
251
+ /** Add `.signafaide/` (and `.env` when asked) to the project's .gitignore (init and agent setup only). */
252
+ ignoreInGit(...extra) {
253
+ const gi = path.join(this.root, ".gitignore");
254
+ if (this.home === path.join(this.root, ".signafaide")) ensureGitignore(gi, ".signafaide/");
255
+ for (const e of extra) ensureGitignore(gi, e);
256
+ }
257
+ keyDir(scope = "") {
258
+ return scope ? path.join(this.home, "agents", scope) : path.join(this.home, "agents");
259
+ }
260
+ keyPath(agent, scope = "") {
261
+ return path.join(this.keyDir(scope), `${agent}.key`);
262
+ }
263
+ /** The key handed in for this agent (provider, then SIGNAFAIDE_AGENT_KEY_<NAME>), or null. */
264
+ providedKey(agent, scope = "") {
265
+ let v = this.keyProvider?.(agent, scope);
266
+ if (v === void 0) v = process.env[agentKeyEnvName(agent, scope)] || (scope ? process.env[agentKeyEnvName(agent)] : void 0);
267
+ if (v === void 0 || v === "") return null;
268
+ let doc = v;
269
+ if (typeof v === "string") {
270
+ try {
271
+ doc = JSON.parse(v);
272
+ } catch {
273
+ throw new StateError(`${agentKeyEnvName(agent, scope)} must hold the agent's key file (JSON with private_key and created), as \`cat .signafaide/agents/${agent}.key\` shows it`);
274
+ }
275
+ }
276
+ if (!doc || typeof doc.private_key !== "string" || !validTime(doc.created) || doc.agent !== void 0 && doc.agent !== agent) {
277
+ throw new StateError(`the key given for agent ${agent} must be the agent's key file: {"agent", "private_key", "created", ...}`);
278
+ }
279
+ return { private_key: doc.private_key, created: doc.created };
280
+ }
281
+ chainPath(chain) {
282
+ return path.join(this.home, "chain", `${chain}.jsonl`);
283
+ }
284
+ outboxDir(chain) {
285
+ return path.join(this.home, "outbox", chain);
286
+ }
287
+ /** Every local chain id (`<project>/<env>/<agent>` or `_pending/<fp>/<agent>`). */
288
+ chains() {
289
+ return depth3(path.join(this.home, "chain")).filter((c) => c.endsWith(".jsonl")).map((c) => c.slice(0, -6)).sort();
290
+ }
291
+ agents() {
292
+ const names = new Set(this.chains().map((c) => c.split("/")[2]));
293
+ for (const scope of ["", "live"]) {
294
+ for (const f of readdir(this.keyDir(scope))) if (f.endsWith(".key")) names.add(f.slice(0, -4));
295
+ for (const f of readdir(this.retiredDir(scope))) if (f.endsWith(".key")) names.add(f.split(".")[0]);
296
+ }
297
+ for (const k of Object.keys(process.env)) {
298
+ const m = k.match(/^SIGNAFAIDE_AGENT_KEY_(?:LIVE_)?([A-Z0-9_]+)$/);
299
+ if (m && process.env[k]) {
300
+ try {
301
+ const a = JSON.parse(process.env[k]).agent;
302
+ if (typeof a === "string") names.add(a);
303
+ } catch {
304
+ }
305
+ }
306
+ }
307
+ return [...names].filter((n) => AGENT_RE.test(n)).sort();
308
+ }
309
+ // --- projects -----------------------------------------------------------------
310
+ /** `<project>/<env>` for this API key, or `_pending/<fp>` while its project is unknown. */
311
+ bucket(apiKey) {
312
+ if (!apiKey) return `${PENDING}/nokey`;
313
+ const fp = keyFingerprint(apiKey);
314
+ const p = this.config().projects?.[fp];
315
+ return p && validProjectId(p.project) && (p.env === "test" || p.env === "live") ? `${p.project}/${p.env}` : `${PENDING}/${fp}`;
316
+ }
317
+ /** Remember an API key's project. The values come from the service, so they are checked
318
+ * before they can name a folder (§8). */
319
+ learnProject(apiKey, project, env) {
320
+ if (!validProjectId(project) || env !== "test" && env !== "live") {
321
+ throw new StateError(`the service named project ${JSON.stringify(project)} env ${JSON.stringify(env)}, which is not a valid project id and env; check SIGNAFAIDE_URL`);
322
+ }
323
+ this.saveConfig({ projects: { ...this.config().projects ?? {}, [keyFingerprint(apiKey)]: { project, env } } });
324
+ }
325
+ /** Sign every draft chain of bucket `src` into `dst` (`<project>/<env>`) with its outbox
326
+ * (§8), keeping each record's time. Returns the chains left pending, with why: `exists` (that
327
+ * agent already has a chain under `dst`) or the first draft's time when it is before
328
+ * `notBefore` (the service would refuse it, §2.4). */
329
+ adopt(src, dst, key, wire, notBefore, bases = {}) {
330
+ const [project, env] = dst.split("/");
331
+ const conflicts = [];
332
+ for (const f of readdir(path.join(this.home, "chain", src)).filter((f2) => f2.endsWith(".jsonl")).sort()) {
333
+ const agent = f.slice(0, -6);
334
+ this.lock(
335
+ `${src}/${agent}`,
336
+ () => this.lock(`${dst}/${agent}`, () => {
337
+ const lines = this.lines(`${src}/${agent}`);
338
+ const target = this.chainPath(`${dst}/${agent}`);
339
+ const mapping = this.draftMap(`${dst}/${agent}`);
340
+ const first = lines[0]?.envelope.time;
341
+ const tooOld = !!notBefore && !!first && Date.parse(first) < Date.parse(notBefore);
342
+ if (fs.existsSync(target)) {
343
+ if (lines.length && lines.every((d) => recordHash(d) in mapping)) fs.unlinkSync(this.chainPath(`${src}/${agent}`));
344
+ else conflicts.push([`${src}/${agent}`, tooOld ? first : "exists"]);
345
+ return;
346
+ }
347
+ if (tooOld) {
348
+ conflicts.push([`${src}/${agent}`, first]);
349
+ return;
350
+ }
351
+ const base = bases[agent];
352
+ let prev = base ? base.record_hash : null;
353
+ let offset = base ? base.seq : 0;
354
+ const out = [];
355
+ if (base && lines.length) {
356
+ const note2 = makeRecord(key(agent, first), {
357
+ kind: "note",
358
+ project,
359
+ env,
360
+ agent,
361
+ seq: base.seq + 1,
362
+ prev,
363
+ name: INSTANCE_START,
364
+ content: instanceStartContent(base.seq, base.record_hash, !!base.stop),
365
+ ref: null,
366
+ time: first
367
+ });
368
+ prev = recordHash(note2);
369
+ offset += 1;
370
+ out.push([note2, lines[0].store_content]);
371
+ }
372
+ for (const d of lines) {
373
+ if (!d.draft) throw new StateError(`${this.chainPath(`${src}/${agent}`)} holds a signed record; pending chains hold drafts only. Move it aside and run \`signafaide verify\``);
374
+ const ref = d.envelope.ref;
375
+ const rec = signDraft(key(agent, first), d, project, env, prev, ref ? mapping[ref] ?? ref : null, d.envelope.seq + offset);
376
+ mapping[recordHash(d)] = prev = recordHash(rec);
377
+ out.push([rec, d.store_content]);
378
+ }
379
+ fs.mkdirSync(path.dirname(target), { recursive: true });
380
+ writeJson(this.mapPath(`${dst}/${agent}`), mapping);
381
+ if (base?.signed) writeJson(this.basePath(`${dst}/${agent}`), base.stop ? { ...base.signed, stop: base.stop } : base.signed);
382
+ const fd = fs.openSync(target + ".tmp", "w");
383
+ fs.writeSync(fd, out.map(([r]) => canonicalize(r) + "\n").join(""));
384
+ fs.fsyncSync(fd);
385
+ fs.closeSync(fd);
386
+ for (const [rec, store] of out) this.writeOutbox(`${dst}/${agent}`, store === void 0 ? wire(rec) : wireOf(rec, store));
387
+ fs.renameSync(target + ".tmp", target);
388
+ fs.unlinkSync(this.chainPath(`${src}/${agent}`));
389
+ })
390
+ );
391
+ }
392
+ return conflicts;
393
+ }
394
+ basePath(chain) {
395
+ return path.join(this.home, "chain", `${chain}.base.json`);
396
+ }
397
+ /** The witness's signed head `chain` continues after, when this machine started it on an
398
+ * empty disk (§8). */
399
+ chainBase(chain) {
400
+ return readJson(this.basePath(chain)) ?? void 0;
401
+ }
402
+ /** The last record of a local chain, or undefined when it is empty. */
403
+ lastRecord(chain) {
404
+ const all = this.readChain(chain);
405
+ return all[all.length - 1];
406
+ }
407
+ mapPath(chain) {
408
+ return path.join(this.home, "chain", `${chain}.drafts.json`);
409
+ }
410
+ draftMap(chain) {
411
+ return readJson(this.mapPath(chain)) ?? {};
412
+ }
413
+ /** A ref to a draft that has since been signed points at the signed record. */
414
+ translateRef(chain, ref) {
415
+ if (!ref) return ref ?? null;
416
+ return this.draftMap(chain)[ref] ?? ref;
417
+ }
418
+ // --- blocked chains, held segments ---------------------------------------------
419
+ /** Chains the witness refused, with why: `{chain: {code, message, at, seq}}` (blocked.json). */
420
+ blocked() {
421
+ return readJson(path.join(this.home, "blocked.json")) ?? {};
422
+ }
423
+ /** Record (or, with null, clear) why the witness refuses a chain. Never throws (read-only disks). */
424
+ setBlocked(chain, info) {
425
+ try {
426
+ const all = this.blocked();
427
+ if (info === null) {
428
+ if (!(chain in all)) return;
429
+ delete all[chain];
430
+ } else {
431
+ const old = all[chain];
432
+ if (old && old.code === info.code && old.message === info.message && old.seq === info.seq) return;
433
+ all[chain] = { ...info, at: now() };
434
+ }
435
+ writeJson(path.join(this.home, "blocked.json"), all);
436
+ } catch {
437
+ }
438
+ }
439
+ heldDir(chain) {
440
+ return path.join(this.home, "held", chain);
441
+ }
442
+ /** Every held segment: [chain, file, records]. */
443
+ heldSegments() {
444
+ const out = [];
445
+ for (const chain of depth3(path.join(this.home, "held"))) {
446
+ for (const f of readdir(this.heldDir(chain)).filter((x) => x.endsWith(".jsonl")).sort()) {
447
+ const p = path.join(this.heldDir(chain), f);
448
+ out.push([chain, p, fs.readFileSync(p, "utf8").split("\n").filter((l) => l.trim()).map((l) => parseLine(p, l))]);
449
+ }
450
+ }
451
+ return out;
452
+ }
453
+ /** `signafaide repair`: move the chain's records from `fromSeq` to the end (none of them
454
+ * co-signed) into `held/<chain>/<from>-<to>.jsonl`, and drop them from the outbox. The held
455
+ * file is written and flushed before the chain file is cut, so a crash never loses a line. */
456
+ holdTail(chain, fromSeq) {
457
+ return this.lock(chain, () => {
458
+ const p = this.chainPath(chain);
459
+ const cut = [];
460
+ for (const item of this.reverse(chain)) {
461
+ if (item[1].envelope.seq < fromSeq) break;
462
+ if (item[1].receipt) throw new StateError(`${chain} seq ${item[1].envelope.seq} has a receipt: co-signed records are never set aside`);
463
+ cut.push(item);
464
+ }
465
+ cut.reverse();
466
+ if (!cut.length) throw new StateError(`${chain} has no records from seq ${fromSeq}`);
467
+ const recs = cut.map((c) => c[1]);
468
+ const dir = this.heldDir(chain);
469
+ fs.mkdirSync(dir, { recursive: true });
470
+ const file = path.join(dir, `${pad(recs[0].envelope.seq)}-${pad(recs[recs.length - 1].envelope.seq)}.jsonl`);
471
+ const fd = fs.openSync(file + ".tmp", "w", 384);
472
+ fs.writeSync(fd, recs.map((r) => canonicalize(r) + "\n").join(""));
473
+ fs.fsyncSync(fd);
474
+ fs.closeSync(fd);
475
+ fs.renameSync(file + ".tmp", file);
476
+ const cfd = fs.openSync(p, "r+");
477
+ try {
478
+ fs.ftruncateSync(cfd, cut[0][0]);
479
+ fs.fsyncSync(cfd);
480
+ } finally {
481
+ fs.closeSync(cfd);
482
+ }
483
+ this.dropOutbox(chain, recs.map((r) => r.envelope.seq));
484
+ return { file, records: recs };
485
+ });
486
+ }
487
+ hasDrafts() {
488
+ return this.chains().some((c) => c.startsWith(PENDING + "/"));
489
+ }
490
+ draftCount(chain) {
491
+ return this.lines(chain).filter((r) => r.draft).length;
492
+ }
493
+ // --- keys -------------------------------------------------------------------
494
+ /** When the key was generated: the `valid_from` sent at registration, so records
495
+ * signed offline before registering still verify (§2.4). */
496
+ keyCreated(agent, scope = "") {
497
+ const given = this.providedKey(agent, scope);
498
+ if (given) return given.created;
499
+ try {
500
+ return JSON.parse(fs.readFileSync(this.keyPath(agent, scope), "utf8")).created;
501
+ } catch {
502
+ return void 0;
503
+ }
504
+ }
505
+ retiredDir(scope = "") {
506
+ return path.join(this.keyDir(scope), "retired");
507
+ }
508
+ /** [key, created] of the agent's retired keys, newest first. */
509
+ retiredKeys(agent, scope = "") {
510
+ const out = [];
511
+ for (const f of readdir(this.retiredDir(scope))) {
512
+ if (!f.startsWith(agent + ".") || !f.endsWith(".key") || f.split(".").length !== 3) continue;
513
+ try {
514
+ const doc = JSON.parse(fs.readFileSync(path.join(this.retiredDir(scope), f), "utf8"));
515
+ if (doc.agent === agent) out.push([new SigningKey(b64urlDecode(doc.private_key)), doc.created]);
516
+ } catch {
517
+ throw new StateError(`${path.join(this.retiredDir(scope), f)} is not a valid signafaide key file; restore it from backup`);
518
+ }
519
+ }
520
+ return out.sort((a, b) => a[1] < b[1] ? 1 : a[1] > b[1] ? -1 : 0);
521
+ }
522
+ /** When the service retired this key (§2.4 rotation), once it said so; null before. */
523
+ retiredAt(agent, keyId, scope = "") {
524
+ try {
525
+ const v = JSON.parse(fs.readFileSync(path.join(this.retiredDir(scope), `${agent}.${keyId}.key`), "utf8")).retired;
526
+ return typeof v === "string" && validTime(v) ? v : null;
527
+ } catch {
528
+ return null;
529
+ }
530
+ }
531
+ /** Record the service's retirement time in a retired key's file (kept for keys export). */
532
+ markRetired(agent, keyId, validTo, scope = "") {
533
+ const p = path.join(this.retiredDir(scope), `${agent}.${keyId}.key`);
534
+ if (!AGENT_RE.test(agent) || !validTime(validTo) || !/^[0-9a-f]{16}$/.test(keyId) || !fs.existsSync(p)) return false;
535
+ const doc = JSON.parse(fs.readFileSync(p, "utf8"));
536
+ if (doc.retired === validTo) return false;
537
+ doc.retired = validTo;
538
+ const tmp = p + ".tmp";
539
+ fs.writeFileSync(tmp, canonicalize(doc) + "\n", { mode: 384 });
540
+ fs.renameSync(tmp, p);
541
+ return true;
542
+ }
543
+ /** Retire the agent's key and make a new one: [old, new]. The next registration is
544
+ * signed by the old key (§2.4 rotation). */
545
+ rotateKey(agent, scope = "") {
546
+ if (!AGENT_RE.test(agent)) throw new StateError(`agent "${agent}" is invalid`);
547
+ if (this.providedKey(agent, scope)) {
548
+ throw new StateError(`agent ${agent}'s key comes from ${agentKeyEnvName(agent, scope)} (or a key provider): make a new key with \`signafaide keys rotate\` on a machine with the key file, store the new file in your secret store, and keep the old one as ${agent}.<key id>.key in .signafaide/agents/${scope ? scope + "/" : ""}retired/`);
549
+ }
550
+ const old = this.loadKey(agent, false, scope);
551
+ if (!old) throw new StateError(`agent ${agent} has no key to rotate; it gets one with its first record, or make one for a secret store with \`signafaide keys create --agent ${agent} --env ${scope === "live" ? "live" : "test"} --print\``);
552
+ fs.mkdirSync(this.retiredDir(scope), { recursive: true });
553
+ fs.renameSync(this.keyPath(agent, scope), path.join(this.retiredDir(scope), `${agent}.${old.keyId}.key`));
554
+ return [old, this.loadKey(agent, true, scope)];
555
+ }
556
+ /** A new agent key file `{agent, key_id, public_key, private_key, created}` (§8), not
557
+ * written anywhere: `keys create` registers it and prints it for a secret store, or saves it
558
+ * as the agent's key file with `saveKeyFile`. */
559
+ newKeyFile(agent) {
560
+ if (!AGENT_RE.test(agent)) throw new StateError(`agent "${agent}" is invalid`);
561
+ const key = SigningKey.generate((n) => new Uint8Array(randomBytes(n)));
562
+ return { key, doc: { agent, key_id: key.keyId, public_key: b64url(key.publicKey), private_key: b64url(key.seed()), created: now() } };
563
+ }
564
+ /** Write a key file made by `newKeyFile` as the agent's key (mode 0600); never replaces one. */
565
+ saveKeyFile(doc, scope = "") {
566
+ this.ensure();
567
+ const p = this.keyPath(doc.agent, scope);
568
+ fs.mkdirSync(path.dirname(p), { recursive: true });
569
+ let fd;
570
+ try {
571
+ fd = fs.openSync(p, "wx", 384);
572
+ } catch (e) {
573
+ if (e.code === "EEXIST") throw new StateError(`agent ${doc.agent} already has a key file (${p}); use \`signafaide keys rotate\` to replace it`);
574
+ throw fsError(e, this.home);
575
+ }
576
+ fs.writeSync(fd, canonicalize(doc) + "\n");
577
+ fs.closeSync(fd);
578
+ return p;
579
+ }
580
+ /** Every current and retired agent key: `{agent, key_id, public_key, created, retired}`
581
+ * (`retired`: when the service retired it, or null). */
582
+ agentPublicKeys() {
583
+ const out = [];
584
+ const seen = /* @__PURE__ */ new Set();
585
+ for (const agent of this.agents()) {
586
+ for (const scope of ["", "live"]) {
587
+ const k = this.loadKey(agent, false, scope);
588
+ if (k && !seen.has(k.keyId)) {
589
+ seen.add(k.keyId);
590
+ out.push({ agent, key_id: k.keyId, public_key: b64url(k.publicKey), created: this.keyCreated(agent, scope) ?? now(), retired: null });
591
+ }
592
+ for (const [rk, created] of this.retiredKeys(agent, scope)) {
593
+ if (seen.has(rk.keyId)) continue;
594
+ seen.add(rk.keyId);
595
+ out.push({ agent, key_id: rk.keyId, public_key: b64url(rk.publicKey), created, retired: this.retiredAt(agent, rk.keyId, scope) });
596
+ }
597
+ }
598
+ }
599
+ return out;
600
+ }
601
+ /** The §2.5 manifest of every current and retired key, each entry self-signed. */
602
+ exportAgentKeys() {
603
+ const keys2 = [];
604
+ const seen = /* @__PURE__ */ new Set();
605
+ for (const agent of this.agents()) {
606
+ for (const scope of ["", "live"]) {
607
+ const k = this.loadKey(agent, false, scope);
608
+ if (k && !seen.has(k.keyId)) {
609
+ seen.add(k.keyId);
610
+ keys2.push(manifestEntry(agent, k, this.keyCreated(agent, scope) ?? now()));
611
+ }
612
+ for (const [rk, created] of this.retiredKeys(agent, scope)) {
613
+ if (seen.has(rk.keyId)) continue;
614
+ seen.add(rk.keyId);
615
+ keys2.push(manifestEntry(agent, rk, created, this.retiredAt(agent, rk.keyId, scope)));
616
+ }
617
+ }
618
+ }
619
+ return { format: AGENT_KEYS_FORMAT, keys: keys2 };
620
+ }
621
+ /** `createdAt`: for a key made now to sign records written earlier (drafts signed into a live
622
+ * chain), its creation time, so it is valid from the first of them (§2.4). */
623
+ loadKey(agent, create = true, scope = "", createdAt) {
624
+ const given = this.providedKey(agent, scope);
625
+ if (given) {
626
+ try {
627
+ return new SigningKey(b64urlDecode(given.private_key));
628
+ } catch (e) {
629
+ throw new StateError(`the key given for agent ${agent} (${agentKeyEnvName(agent, scope)}) is not a valid key (${e.message})`);
630
+ }
631
+ }
632
+ const p = this.keyPath(agent, scope);
633
+ if (!fs.existsSync(p)) {
634
+ if (!create) return null;
635
+ this.ensure();
636
+ const key = SigningKey.generate((n) => new Uint8Array(randomBytes(n)));
637
+ const t = now();
638
+ const doc = { agent, key_id: key.keyId, public_key: b64url(key.publicKey), private_key: b64url(key.seed()), created: createdAt && validTime(createdAt) && createdAt < t ? createdAt : t };
639
+ let fd;
640
+ try {
641
+ fs.mkdirSync(path.dirname(p), { recursive: true });
642
+ fd = fs.openSync(p, "wx", 384);
643
+ } catch (e) {
644
+ if (e.code === "EEXIST") return this.loadKey(agent, false, scope);
645
+ throw fsError(e, this.home);
646
+ }
647
+ fs.writeSync(fd, canonicalize(doc) + "\n");
648
+ fs.closeSync(fd);
649
+ return key;
650
+ }
651
+ try {
652
+ return new SigningKey(b64urlDecode(JSON.parse(fs.readFileSync(p, "utf8")).private_key));
653
+ } catch (e) {
654
+ throw new StateError(`${p} is not a valid signafaide key file (${e.message}); restore it from backup`);
655
+ }
656
+ }
657
+ // --- locking ----------------------------------------------------------------
658
+ lock(chain, fn) {
659
+ return this.lockAt(path.join(this.home, "chain", `${chain}.lock`), () => {
660
+ this.recover(chain);
661
+ return fn();
662
+ });
663
+ }
664
+ /** Hold the lock file `p` (§8 chain locks) while `fn` runs. */
665
+ lockAt(p, fn) {
666
+ try {
667
+ fs.mkdirSync(path.dirname(p), { recursive: true });
668
+ } catch (e) {
669
+ throw fsError(e, this.home);
670
+ }
671
+ const deadline = monotonicMs() + LOCK_WAIT_MS;
672
+ for (; ; ) {
673
+ try {
674
+ const fd = fs.openSync(p, "wx", 384);
675
+ fs.writeSync(fd, lockStamp());
676
+ fs.closeSync(fd);
677
+ break;
678
+ } catch (e) {
679
+ if (e.code !== "EEXIST") throw fsError(e, this.home);
680
+ try {
681
+ if (lockAgeMs(p) > LOCK_STALE_MS) {
682
+ fs.unlinkSync(p);
683
+ continue;
684
+ }
685
+ } catch {
686
+ continue;
687
+ }
688
+ if (monotonicMs() > deadline) throw new StateError(`${p} is held by another process; delete it if no signafaide process is running`);
689
+ sleepSync(2);
690
+ }
691
+ }
692
+ try {
693
+ return fn();
694
+ } finally {
695
+ try {
696
+ fs.unlinkSync(p);
697
+ } catch {
698
+ }
699
+ }
700
+ }
701
+ /** Finish an interrupted receipt rewrite (journal `.tail`). */
702
+ recover(chain) {
703
+ const file = this.chainPath(chain);
704
+ const journal = file + ".tail";
705
+ if (!fs.existsSync(journal)) return;
706
+ const buf = fs.readFileSync(journal);
707
+ const nl = buf.indexOf(10);
708
+ const offset = Number(buf.subarray(0, nl).toString());
709
+ const fd = fs.openSync(file, "r+");
710
+ try {
711
+ fs.ftruncateSync(fd, offset);
712
+ fs.writeSync(fd, buf.subarray(nl + 1), 0, buf.length - nl - 1, offset);
713
+ fs.fsyncSync(fd);
714
+ } finally {
715
+ fs.closeSync(fd);
716
+ }
717
+ fs.unlinkSync(journal);
718
+ }
719
+ // --- chain ------------------------------------------------------------------
720
+ /** [seq, record_hash, time] of the last record in the local chain (the last sealed segment's
721
+ * when the chain file was just rolled), [0, null, null] if empty. */
722
+ tail(chain) {
723
+ for (const [, rec] of this.reverse(chain)) return [rec.envelope.seq, recordHash(rec), rec.envelope.time];
724
+ const segs = this.segments(chain);
725
+ if (segs.length) {
726
+ const lines = fs.readFileSync(segs[segs.length - 1], "utf8").split("\n").filter((l) => l.trim());
727
+ if (lines.length) {
728
+ const rec = parseLine(segs[segs.length - 1], lines[lines.length - 1]);
729
+ return [rec.envelope.seq, recordHash(rec), rec.envelope.time];
730
+ }
731
+ }
732
+ return [0, null, null];
733
+ }
734
+ segmentDir(chain) {
735
+ return path.join(this.home, "chain", `${chain}.segments`);
736
+ }
737
+ /** Sealed segments of a chain, oldest first. */
738
+ segments(chain) {
739
+ const d = this.segmentDir(chain);
740
+ return readdir(d).filter((f) => f.endsWith(".jsonl")).sort().map((f) => path.join(d, f));
741
+ }
742
+ /** Roll a large chain file into a sealed segment, under the chain lock. Only a fully shipped
743
+ * file rolls (its last line has a receipt), so receipts are never written into a segment. */
744
+ maybeRoll(chain) {
745
+ const p = this.chainPath(chain);
746
+ let size = 0;
747
+ try {
748
+ size = fs.statSync(p).size;
749
+ } catch {
750
+ return;
751
+ }
752
+ if (size < SEGMENT_BYTES()) return;
753
+ let last;
754
+ for (const [, rec] of this.reverse(chain)) {
755
+ last = rec;
756
+ break;
757
+ }
758
+ if (!last?.receipt) return;
759
+ const firstLine = fs.readFileSync(p, "utf8").split("\n", 1)[0];
760
+ const first = parseLine(p, firstLine);
761
+ fs.mkdirSync(this.segmentDir(chain), { recursive: true });
762
+ fs.renameSync(p, path.join(this.segmentDir(chain), `${pad(first.envelope.seq)}-${pad(last.envelope.seq)}.jsonl`));
763
+ fs.writeFileSync(p, "");
764
+ }
765
+ /** Under the chain lock: build the next record from (seq, prev), append it to the chain
766
+ * and put `wire(record)` in the outbox. Returns null when `stillValid()` is false once
767
+ * the lock is held (the chain was just moved under its project; resolve it again). */
768
+ append(chain, build, wire, stillValid = () => true) {
769
+ return this.appendRun(chain, [build], wire, stillValid)?.[0] ?? null;
770
+ }
771
+ /** `append` for several records that must follow each other with nothing between them (a tool
772
+ * declaration, §2.6): all of them are built and written under one hold of the chain lock, in
773
+ * one write, so no other writer's record lands inside the run. */
774
+ appendRun(chain, builds, wire, stillValid = () => true) {
775
+ this.ensure();
776
+ return this.lock(chain, () => {
777
+ if (!stillValid()) return null;
778
+ let [seq, prev, prevTime] = this.tail(chain);
779
+ const recs = [];
780
+ for (const build of builds) {
781
+ const rec = build(seq + 1, prev, prevTime);
782
+ recs.push(rec);
783
+ [seq, prev, prevTime] = [rec.envelope.seq, recordHash(rec), rec.envelope.time];
784
+ }
785
+ try {
786
+ fs.appendFileSync(this.chainPath(chain), recs.map((r) => canonicalize(r) + "\n").join(""));
787
+ } catch (e) {
788
+ throw fsError(e, this.home);
789
+ }
790
+ for (const rec of recs) if (!rec.draft) this.writeOutbox(chain, wire(rec));
791
+ return recs;
792
+ });
793
+ }
794
+ // --- tool declarations --------------------------------------------------------
795
+ /** `tools/<agent>.json` (§8): the tools each source (the SDK in an application, each gateway)
796
+ * last declared for the agent. */
797
+ toolsPath(agent) {
798
+ return path.join(this.home, "tools", `${agent}.json`);
799
+ }
800
+ /** The tools every source last declared for `agent`: source -> {tool name: recorded}. */
801
+ toolSources(agent) {
802
+ return Object.fromEntries(Object.entries(this.toolEntries(agent)).map(([src, e]) => [src, e.tools]));
803
+ }
804
+ /** The entries of `tools/<agent>.json`: source -> when it last declared or refreshed, and its tools. */
805
+ toolEntries(agent) {
806
+ const doc = readJson(this.toolsPath(agent));
807
+ const out = {};
808
+ const sources = doc?.sources;
809
+ if (!sources || typeof sources !== "object" || Array.isArray(sources)) return out;
810
+ for (const [src, entry] of Object.entries(sources)) {
811
+ const tools = entry?.tools;
812
+ if (!tools || typeof tools !== "object" || Array.isArray(tools)) continue;
813
+ out[src] = { at: typeof entry.at === "string" ? entry.at : "", tools: Object.fromEntries(Object.entries(tools).filter(([, v]) => typeof v === "boolean")) };
814
+ }
815
+ return out;
816
+ }
817
+ /** A source that ends cleanly takes its own entry out of `tools/<agent>.json`, so the next
818
+ * declaration by any source no longer counts its tools (§8). Nothing is written to the chain. */
819
+ releaseToolSource(agent, source) {
820
+ const file = this.toolsPath(agent);
821
+ if (!fs.existsSync(file)) return;
822
+ this.lockAt(path.join(this.home, "tools", `${agent}.lock`), () => {
823
+ const entries = this.toolEntries(agent);
824
+ if (!(source in entries)) return;
825
+ delete entries[source];
826
+ writeJson(file, { sources: entries });
827
+ });
828
+ }
829
+ /** Set the tools `source` declares for `agent` and call `write` with the agent's whole tool
830
+ * list: the union over every source, a tool recorded when any source records it (§8). Held
831
+ * under `tools/<agent>.lock`, so two sources never write declarations that miss each other's
832
+ * tools. The source's own entry gets the time `nowMs`; another source's entry not refreshed for
833
+ * `TOOL_SOURCE_MAX_AGE_MS` is dropped (that source has stopped), and `expired` tells `write`
834
+ * whether dropping changed the agent's tool list. The file is updated only when `write` returns. */
835
+ declareTools(agent, source, tools, write, nowMs = Date.now()) {
836
+ this.ensure();
837
+ const file = this.toolsPath(agent);
838
+ return this.lockAt(path.join(this.home, "tools", `${agent}.lock`), () => {
839
+ const union = (entries) => {
840
+ const all2 = {};
841
+ for (const src of Object.keys(entries).sort()) for (const [name, recorded] of Object.entries(entries[src].tools)) all2[name] = all2[name] === true || recorded;
842
+ return all2;
843
+ };
844
+ const sorted = (all2) => JSON.stringify(Object.keys(all2).sort().map((n) => [n, all2[n]]));
845
+ const held = { ...this.toolEntries(agent), [source]: { at: new Date(nowMs).toISOString(), tools } };
846
+ const live = Object.fromEntries(
847
+ Object.entries(held).filter(([src, e]) => {
848
+ const at = Date.parse(e.at);
849
+ return src === source || Number.isFinite(at) && nowMs - at <= TOOL_SOURCE_MAX_AGE_MS;
850
+ })
851
+ );
852
+ const all = union(live);
853
+ const out = write(all, sorted(all) !== sorted(union(held)));
854
+ try {
855
+ writeJson(file, { sources: live });
856
+ } catch (e) {
857
+ throw fsError(e, this.home);
858
+ }
859
+ return out;
860
+ });
861
+ }
862
+ writeOutbox(chain, rec) {
863
+ const d = this.outboxDir(chain);
864
+ fs.mkdirSync(d, { recursive: true });
865
+ const tmp = path.join(d, `.${pad(rec.envelope.seq)}.tmp`);
866
+ fs.writeFileSync(tmp, canonicalize(rec));
867
+ fs.renameSync(tmp, path.join(d, `${pad(rec.envelope.seq)}.json`));
868
+ }
869
+ lines(chain) {
870
+ const out = [];
871
+ for (const p of [...this.segments(chain), this.chainPath(chain)]) {
872
+ if (!fs.existsSync(p)) continue;
873
+ for (const l of fs.readFileSync(p, "utf8").split("\n")) if (l.trim()) out.push(parseLine(p, l));
874
+ }
875
+ return out;
876
+ }
877
+ /** Every line of a chain, drafts included (§8). */
878
+ readAll(chain) {
879
+ return this.lines(chain);
880
+ }
881
+ /** The signed records of a chain (drafts are skipped: they are not signed yet). */
882
+ readChain(chain) {
883
+ return this.lines(chain).filter((r) => !r.draft);
884
+ }
885
+ /** Lines from the end of the chain file: [offset, record]. */
886
+ *reverse(chain) {
887
+ const p = this.chainPath(chain);
888
+ let fd;
889
+ try {
890
+ fd = fs.openSync(p, "r");
891
+ } catch {
892
+ return;
893
+ }
894
+ try {
895
+ let pos = fs.fstatSync(fd).size;
896
+ let buf = Buffer.alloc(0);
897
+ while (pos > 0) {
898
+ const n = Math.min(65536, pos);
899
+ pos -= n;
900
+ const chunk = Buffer.alloc(n);
901
+ fs.readSync(fd, chunk, 0, n, pos);
902
+ buf = Buffer.concat([chunk, buf]);
903
+ const parts = [];
904
+ let start = 0;
905
+ for (let i = buf.indexOf(10); i !== -1; i = buf.indexOf(10, start)) {
906
+ parts.push(buf.subarray(start, i));
907
+ start = i + 1;
908
+ }
909
+ parts.push(buf.subarray(start));
910
+ const head = parts.shift();
911
+ let off = pos + head.length + 1;
912
+ const complete = parts.map((ln) => {
913
+ const item = [off, ln];
914
+ off += ln.length + 1;
915
+ return item;
916
+ });
917
+ buf = Buffer.from(head);
918
+ for (const [o, ln] of complete.reverse()) if (ln.toString().trim()) yield [o, parseLine(p, ln.toString())];
919
+ }
920
+ if (buf.toString().trim()) yield [0, parseLine(p, buf.toString())];
921
+ } finally {
922
+ fs.closeSync(fd);
923
+ }
924
+ }
925
+ /** Fill receipts into the trailing chain lines that lack one. Returns how many were filled. */
926
+ applyReceipts(chain, receipts) {
927
+ const p = this.chainPath(chain);
928
+ if (!fs.existsSync(p)) return 0;
929
+ return this.lock(chain, () => {
930
+ const suffix = [];
931
+ for (const item of this.reverse(chain)) {
932
+ if (item[1].receipt) break;
933
+ suffix.push(item);
934
+ }
935
+ suffix.reverse();
936
+ let filled = 0;
937
+ const out = suffix.map(([, rec]) => {
938
+ const r = receipts.get(recordHash(rec));
939
+ if (r) {
940
+ rec.receipt = r;
941
+ filled++;
942
+ }
943
+ return canonicalize(rec) + "\n";
944
+ });
945
+ if (filled) {
946
+ const journal = p + ".tail";
947
+ const tmp = p + ".tail.tmp";
948
+ const fd = fs.openSync(tmp, "w");
949
+ fs.writeSync(fd, `${suffix[0][0]}
950
+ ` + out.join(""));
951
+ fs.fsyncSync(fd);
952
+ fs.closeSync(fd);
953
+ fs.renameSync(tmp, journal);
954
+ this.recover(chain);
955
+ this.maybeRoll(chain);
956
+ }
957
+ return filled;
958
+ });
959
+ }
960
+ /** Re-queue chain records that have neither a receipt nor an outbox file
961
+ * (a crash between the chain write and the outbox write). */
962
+ reconcileOutbox(chain, wire) {
963
+ if (!fs.existsSync(this.chainPath(chain))) return 0;
964
+ return this.lock(chain, () => {
965
+ let n = 0;
966
+ for (const [, rec] of this.reverse(chain)) {
967
+ if (rec.receipt || rec.draft) break;
968
+ if (!fs.existsSync(path.join(this.outboxDir(chain), `${pad(rec.envelope.seq)}.json`))) {
969
+ this.writeOutbox(chain, wire(rec));
970
+ n++;
971
+ }
972
+ }
973
+ return n;
974
+ });
975
+ }
976
+ // --- outbox -----------------------------------------------------------------
977
+ outbox(chain, limit = 1e3) {
978
+ const d = this.outboxDir(chain);
979
+ const out = [];
980
+ for (const f of readdir(d).filter((f2) => f2.endsWith(".json")).sort().slice(0, limit)) {
981
+ try {
982
+ out.push(JSON.parse(fs.readFileSync(path.join(d, f), "utf8")));
983
+ } catch (e) {
984
+ if (e.code !== "ENOENT") throw e;
985
+ }
986
+ }
987
+ return out;
988
+ }
989
+ dropOutbox(chain, seqs) {
990
+ for (const s of seqs) fs.rmSync(path.join(this.outboxDir(chain), `${pad(s)}.json`), { force: true });
991
+ }
992
+ /** Chains with records waiting, optionally only those under `prefix` (a bucket). */
993
+ outboxChains(prefix) {
994
+ const d = path.join(this.home, "outbox");
995
+ return depth3(d).filter((c) => readdir(path.join(d, c)).some((f) => f.endsWith(".json")) && (!prefix || c.startsWith(prefix + "/"))).sort();
996
+ }
997
+ /** Records waiting: everything, a bucket (`<project>/<env>`) or one chain. */
998
+ outboxSize(prefix) {
999
+ const d = path.join(this.home, "outbox");
1000
+ return depth3(d).filter((c) => !prefix || `${c}/`.startsWith(prefix + "/")).reduce((n, c) => n + readdir(path.join(d, c)).filter((f) => f.endsWith(".json")).length, 0);
1001
+ }
1002
+ // --- heads, sandbox ---------------------------------------------------------
1003
+ /** `{project: {env: {agent: {head, head_sig}}}}`. */
1004
+ heads() {
1005
+ return readJson(path.join(this.home, "head.json")) ?? {};
1006
+ }
1007
+ /** The newest signed head per chain id `<project>/<env>/<agent>`. */
1008
+ chainHeads() {
1009
+ const out = {};
1010
+ for (const [p, envs] of Object.entries(this.heads())) for (const [e, agents] of Object.entries(envs)) for (const [a, sh] of Object.entries(agents)) out[`${p}/${e}/${a}`] = sh;
1011
+ return out;
1012
+ }
1013
+ /** Keep the newest head per (project, env, agent). Returns warnings for heads that go
1014
+ * backwards (the service showing a shorter chain = truncation). */
1015
+ updateHeads(signed) {
1016
+ const warnings = [];
1017
+ const heads = this.heads();
1018
+ for (const sh of signed) {
1019
+ const { project = "", env = "", agent } = sh.head ?? {};
1020
+ const slot = (heads[project] ??= {})[env] ??= {};
1021
+ const old = slot[agent]?.head;
1022
+ if (old && (sh.head.seq ?? 0) < (old.seq ?? 0)) {
1023
+ warnings.push(`service head for ${agent} went back from seq ${old.seq} to ${sh.head.seq}: keep .signafaide/head.json, it proves truncation`);
1024
+ continue;
1025
+ }
1026
+ slot[agent] = sh;
1027
+ }
1028
+ writeJson(path.join(this.home, "head.json"), heads);
1029
+ if (this.headFile) warnings.push(...this.persistHeads(signed));
1030
+ return warnings;
1031
+ }
1032
+ /** `SIGNAFAIDE_HEAD_FILE` (or `RecorderOptions.headFile`): a copy of the newest signed heads
1033
+ * kept outside this folder (a persistent volume, a mounted bucket), in the `head.json` format,
1034
+ * so an ephemeral container's heads outlive it. Several processes may share one file: each
1035
+ * merges its heads in under `<file>.lock`, keeping the higher seq per chain. */
1036
+ headFile;
1037
+ persistHeads(signed) {
1038
+ const file = this.headFile;
1039
+ try {
1040
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
1041
+ return this.lockAt(file + ".lock", () => {
1042
+ const doc = readJson(file) ?? {};
1043
+ for (const sh of signed) {
1044
+ const { project = "", env = "", agent } = sh.head ?? {};
1045
+ const slot = (doc[project] ??= {})[env] ??= {};
1046
+ if ((slot[agent]?.head?.seq ?? 0) <= (sh.head.seq ?? 0)) slot[agent] = sh;
1047
+ }
1048
+ writeJson(file, doc);
1049
+ return [];
1050
+ });
1051
+ } catch (e) {
1052
+ return [`could not write the head file ${file} (SIGNAFAIDE_HEAD_FILE): ${e.message}`];
1053
+ }
1054
+ }
1055
+ /** `config.json`: settings kept with the project (the coding agent that set it up). */
1056
+ config() {
1057
+ return readJson(path.join(this.home, "config.json")) ?? {};
1058
+ }
1059
+ saveConfig(values) {
1060
+ this.ensure();
1061
+ try {
1062
+ writeJson(path.join(this.home, "config.json"), { ...this.config(), ...values });
1063
+ } catch (e) {
1064
+ throw fsError(e, this.home);
1065
+ }
1066
+ }
1067
+ sandbox() {
1068
+ return readJson(path.join(this.home, "sandbox.json"));
1069
+ }
1070
+ saveSandbox(info) {
1071
+ this.ensure();
1072
+ writeJson(path.join(this.home, "sandbox.json"), info);
1073
+ }
1074
+ };
1075
+ function parseLine(p, line) {
1076
+ try {
1077
+ const rec = JSON.parse(line);
1078
+ if (typeof rec.envelope.seq !== "number") throw new Error();
1079
+ return rec;
1080
+ } catch {
1081
+ throw new StateError(`${p} has a damaged line; move that line aside and run \`signafaide verify\``);
1082
+ }
1083
+ }
1084
+ function depth3(d) {
1085
+ return readdir(d).flatMap((a) => readdir(path.join(d, a)).flatMap((b) => readdir(path.join(d, a, b)).filter((c) => !c.endsWith(".segments")).map((c) => `${a}/${b}/${c}`)));
1086
+ }
1087
+ function readdir(d) {
1088
+ try {
1089
+ return fs.readdirSync(d);
1090
+ } catch {
1091
+ return [];
1092
+ }
1093
+ }
1094
+ function readJson(p) {
1095
+ let text;
1096
+ try {
1097
+ text = fs.readFileSync(p, "utf8");
1098
+ } catch {
1099
+ return null;
1100
+ }
1101
+ try {
1102
+ return JSON.parse(text);
1103
+ } catch {
1104
+ throw new StateError(`${p} is not valid JSON; fix or delete it`);
1105
+ }
1106
+ }
1107
+ function writeJson(p, value) {
1108
+ const sorted = (v) => Array.isArray(v) ? v.map(sorted) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted(v[k])])) : v;
1109
+ fs.writeFileSync(p + ".tmp", JSON.stringify(sorted(value), null, 2) + "\n");
1110
+ fs.renameSync(p + ".tmp", p);
1111
+ }
1112
+ function ensureGitignore(p, entry) {
1113
+ const text = fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
1114
+ const bare = entry.replace(/^\/+|\/+$/g, "");
1115
+ if (text.split(/\r?\n/).some((l) => l.trim().replace(/^\/+|\/+$/g, "") === bare)) return;
1116
+ fs.writeFileSync(p, text + (text && !text.endsWith("\n") ? "\n" : "") + entry + "\n");
1117
+ }
1118
+ function readEnvFile(p) {
1119
+ const out = {};
1120
+ let text;
1121
+ try {
1122
+ text = fs.readFileSync(p, "utf8");
1123
+ } catch {
1124
+ return out;
1125
+ }
1126
+ for (let line of text.split(/\r?\n/)) {
1127
+ line = line.trim();
1128
+ if (!line || line.startsWith("#") || !line.includes("=")) continue;
1129
+ let k = line.slice(0, line.indexOf("=")).trim();
1130
+ let v = line.slice(line.indexOf("=") + 1).trim();
1131
+ if (k.startsWith("export ")) k = k.slice(7).trim();
1132
+ if (v.length >= 2 && v[0] === v[v.length - 1] && (v[0] === '"' || v[0] === "'")) v = v.slice(1, -1);
1133
+ out[k] = v;
1134
+ }
1135
+ return out;
1136
+ }
1137
+ function appendEnvFile(p, key, value) {
1138
+ if (/[\r\n\0]/.test(value) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new StateError(`refusing to write ${key} to ${p}: the value is not a single line`);
1139
+ let link = false;
1140
+ try {
1141
+ link = fs.lstatSync(p).isSymbolicLink();
1142
+ } catch {
1143
+ }
1144
+ if (link) throw new StateError(`refusing to write ${p}: it is a symbolic link`);
1145
+ const text = fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
1146
+ fs.appendFileSync(p, (text && !text.endsWith("\n") ? "\n" : "") + `${key}=${value}
1147
+ `, { mode: 384 });
1148
+ try {
1149
+ fs.chmodSync(p, 384);
1150
+ } catch {
1151
+ }
1152
+ }
1153
+ function wireOf(rec, store) {
1154
+ const out = { envelope: rec.envelope, key_id: rec.key_id, sig: rec.sig };
1155
+ if (store && rec.content !== void 0) {
1156
+ out.content = rec.content;
1157
+ if (rec.salt !== void 0) out.salt = rec.salt;
1158
+ }
1159
+ return out;
1160
+ }
1161
+
1162
+ // src/runtime.ts
1163
+ var BATCH_MAX = 1e3;
1164
+ var KEY_MAX_AGE_MS = 7 * 864e5 - 36e5;
1165
+ var LOCAL_CODES = /* @__PURE__ */ new Set(["too_old", "chain_exists"]);
1166
+ var noWrites = new AsyncLocalStorage();
1167
+ function recordingDisabled(env = process.env) {
1168
+ return /^(1|true|yes)$/i.test((env.SIGNAFAIDE_DISABLED ?? "").trim());
1169
+ }
1170
+ function testMode(env = process.env) {
1171
+ const v = (env.SIGNAFAIDE_TEST ?? "").trim();
1172
+ if (v) return /^(1|true|yes)$/i.test(v);
1173
+ return !!(env.VITEST || env.JEST_WORKER_ID || env.NODE_TEST_CONTEXT);
1174
+ }
1175
+ var testHomes = /* @__PURE__ */ new Set();
1176
+ function removeAtExit(dir) {
1177
+ if (!testHomes.size) process.once("exit", () => testHomes.forEach((d) => fs2.rmSync(d, { recursive: true, force: true })));
1178
+ testHomes.add(dir);
1179
+ }
1180
+ var Recorder = class {
1181
+ state;
1182
+ storeContent;
1183
+ flushIntervalMs;
1184
+ flushSize;
1185
+ quiet;
1186
+ via;
1187
+ exitFlushMs;
1188
+ /** Test mode (see `testMode()`): local only, in a temporary folder. */
1189
+ test;
1190
+ /** A strict-mode tool ran in this process (client header). */
1191
+ strictUsed = false;
1192
+ /** Write a stop note at a clean exit (see `RecorderOptions.instanceStop`). */
1193
+ instanceStop;
1194
+ /** Agents this process wrote records for since its last stop note. */
1195
+ wrote = /* @__PURE__ */ new Set();
1196
+ toldStrict = false;
1197
+ /** `<agent>\n<source>` -> the tool set this recorder last declared for it (§2.6), and the header's hash. */
1198
+ declared = /* @__PURE__ */ new Map();
1199
+ toolTimer;
1200
+ /** @internal */
1201
+ _url;
1202
+ /** @internal */
1203
+ _apiKey;
1204
+ /** `<agent>\n<scope>` -> [key, the key file's identity when loaded] */
1205
+ keys = /* @__PURE__ */ new Map();
1206
+ /** `<api key>\n<agent>\n<key id>` of each key registered by this process: a key rotated
1207
+ * while the process runs (`signafaide keys rotate`) is a new entry, so it gets registered. */
1208
+ registered = /* @__PURE__ */ new Set();
1209
+ reconciled = /* @__PURE__ */ new Set();
1210
+ /** Chains the witness refused, with the configuration they were refused under. */
1211
+ blocked = /* @__PURE__ */ new Map();
1212
+ /** A refusal that applies to every chain (quota, API key): nothing is sent until the configuration changes. */
1213
+ halted;
1214
+ shipping = Promise.resolve();
1215
+ timer;
1216
+ /** Monotonic ms (`monotonicMs`) before which the background shipper waits: a wall-clock step never shortens or stretches the backoff. */
1217
+ nextAttempt = 0;
1218
+ failures = 0;
1219
+ failing;
1220
+ pending = 0;
1221
+ inFlight = false;
1222
+ ioMode = "fg";
1223
+ bgAbort = new AbortController();
1224
+ exitAbort;
1225
+ exitHooked = false;
1226
+ toldLocal = false;
1227
+ toldBadKey = false;
1228
+ exitFlushed = false;
1229
+ closed = false;
1230
+ onHead;
1231
+ constructor(opts = {}) {
1232
+ this.test = testMode();
1233
+ const temp = opts.home === void 0 && this.test && !process.env.SIGNAFAIDE_HOME ? fs2.mkdtempSync(path2.join(os2.tmpdir(), "signafaide-test-")) : void 0;
1234
+ if (temp) removeAtExit(temp);
1235
+ const home = opts.home ?? temp;
1236
+ this.state = State.discover(home, opts.root);
1237
+ this.state.keyProvider = opts.keyProvider;
1238
+ this.state.headFile = opts.headFile || process.env.SIGNAFAIDE_HEAD_FILE || void 0;
1239
+ this.onHead = opts.onHead;
1240
+ const envStop = process.env.SIGNAFAIDE_INSTANCE_STOP;
1241
+ this.instanceStop = envStop === "0" ? false : opts.instanceStop ?? (envStop === "1" || !!this.state.headFile);
1242
+ this._url = opts.url;
1243
+ this._apiKey = opts.apiKey;
1244
+ this.storeContent = opts.storeContent ?? false;
1245
+ this.flushIntervalMs = opts.flushIntervalMs ?? 2e3;
1246
+ this.flushSize = opts.flushSize ?? 100;
1247
+ this.quiet = opts.quiet ?? false;
1248
+ this.via = opts.via ?? "sdk";
1249
+ const envMs = Number(process.env.SIGNAFAIDE_EXIT_FLUSH_MS);
1250
+ this.exitFlushMs = Math.max(0, opts.exitFlushMs ?? (process.env.SIGNAFAIDE_EXIT_FLUSH_MS !== void 0 && Number.isFinite(envMs) ? envMs : 3e3));
1251
+ }
1252
+ // --- configuration ------------------------------------------------------------
1253
+ setting(name) {
1254
+ return process.env[name] || readEnvFile(path2.join(this.state.root, ".env"))[name] || void 0;
1255
+ }
1256
+ get url() {
1257
+ return (this._url || this.setting("SIGNAFAIDE_URL") || DEFAULT_URL).replace(/\/+$/, "");
1258
+ }
1259
+ /** SIGNAFAIDE_API_KEY as set, even when it is not a valid key. */
1260
+ get rawApiKey() {
1261
+ return this._apiKey || this.setting("SIGNAFAIDE_API_KEY");
1262
+ }
1263
+ /** The API key, or undefined: none set, test mode, or not a signafAIde key (checked here, so a
1264
+ * wrong value is never sent anywhere). */
1265
+ get apiKey() {
1266
+ if (this.test) return void 0;
1267
+ const k = this.rawApiKey;
1268
+ if (!k) return void 0;
1269
+ if (!API_KEY_RE.test(k)) {
1270
+ if (!this.toldBadKey && !this.quiet) {
1271
+ this.toldBadKey = true;
1272
+ console.warn(
1273
+ `signafaide: SIGNAFAIDE_API_KEY (${k.slice(0, 8)}...) is not a signafAIde key (sfa_test_ or sfa_live_ and 32 letters or digits); nothing is sent and records stay on this machine. Fix it or remove it.`
1274
+ );
1275
+ }
1276
+ return void 0;
1277
+ }
1278
+ return k;
1279
+ }
1280
+ /** What shipping depends on: when it changes, refused chains are tried again. */
1281
+ configFp() {
1282
+ const stamp = (d) => {
1283
+ try {
1284
+ return String(fs2.statSync(d).mtimeMs);
1285
+ } catch {
1286
+ return "0";
1287
+ }
1288
+ };
1289
+ const dirs = [this.state.keyDir(""), this.state.retiredDir(""), this.state.keyDir("live"), this.state.retiredDir("live")];
1290
+ return [this.url, this.apiKey ?? "", ...dirs.map(stamp)].join("\n");
1291
+ }
1292
+ client(timeoutMs = 1e4, apiKey = this.apiKey) {
1293
+ const mode = this.ioMode;
1294
+ const transport = typeof process !== "undefined" && process.versions?.node ? nodeTransport(() => mode !== "bg") : void 0;
1295
+ const signal = mode === "bg" ? this.bgAbort.signal : mode === "exit" ? this.exitAbort?.signal : void 0;
1296
+ const timeoutLabel = mode === "exit" ? `no answer within the ${this.exitFlushMs / 1e3} s allowed at exit (SIGNAFAIDE_EXIT_FLUSH_MS)` : void 0;
1297
+ return new Client(this.url, apiKey, timeoutMs, this.clientId(), { transport, signal, timeoutLabel });
1298
+ }
1299
+ /** The coding agent stored when the project was set up (`init`, `agent setup`). It is never
1300
+ * guessed per process: a program started from a terminal inside a coding agent is not that agent. */
1301
+ agent() {
1302
+ return this.state.config().agent ?? "unknown";
1303
+ }
1304
+ clientId() {
1305
+ return clientHeader({ via: this.via, adapter: adapterField(), agent: this.agent(), strict: this.strictUsed });
1306
+ }
1307
+ /** Store the detected coding agent in config.json (not when telemetry is off). Only `init`
1308
+ * and `agent setup` call it. */
1309
+ rememberAgent() {
1310
+ const agent = detectAgent();
1311
+ if (telemetryEnabled() && agent !== "unknown" && this.state.config().agent !== agent) this.state.saveConfig({ agent });
1312
+ }
1313
+ /** Fire-and-forget usage event; never throws, skipped when telemetry is off and when there
1314
+ * is no API key (nothing is sent before a human opts in). */
1315
+ async event(name) {
1316
+ if (!telemetryEnabled() || !this.apiKey) return;
1317
+ try {
1318
+ await this.client(2e3).event(name);
1319
+ } catch {
1320
+ }
1321
+ }
1322
+ /** The Terms and Privacy notice links of this project's sandbox (from the service's answer). */
1323
+ legal() {
1324
+ const s = this.state.sandbox();
1325
+ return legalLinks(s ?? {}, s?.url ?? this.url);
1326
+ }
1327
+ /** The one agent with records here, when there is exactly one (replay and compare without
1328
+ * `--agent`). */
1329
+ onlyAgent() {
1330
+ const names = new Set(this.state.chains().map((c) => c.split("/")[2]));
1331
+ return names.size === 1 ? [...names][0] : void 0;
1332
+ }
1333
+ /** The agent name used when none is given: the project folder's name. Give agents explicit
1334
+ * names in production (one per running instance); see the production guide. */
1335
+ get defaultAgent() {
1336
+ return agentName(path2.basename(this.state.root));
1337
+ }
1338
+ /** Return the API key; create a sandbox project if there is none. Only an explicit human
1339
+ * opt-in calls this (`signafaide init`, the MCP `signafaide_init` tool): records never
1340
+ * create a sandbox by themselves. */
1341
+ async createSandbox() {
1342
+ const raw = this.rawApiKey;
1343
+ if (raw && !API_KEY_RE.test(raw)) {
1344
+ throw new SignafaideError(`SIGNAFAIDE_API_KEY (${raw.slice(0, 8)}...) is not a signafAIde key (sfa_test_ or sfa_live_ and 32 letters or digits): fix it, or remove it to create a sandbox.`, { code: "invalid_key" });
1345
+ }
1346
+ const existing = this.apiKey;
1347
+ if (existing) {
1348
+ try {
1349
+ await this.resolveProject(existing);
1350
+ } catch (e) {
1351
+ if (e.code !== "network") throw e;
1352
+ }
1353
+ return existing;
1354
+ }
1355
+ this.rememberAgent();
1356
+ const resp = await this.client().sandbox();
1357
+ if (typeof resp?.api_key !== "string" || !API_KEY_RE.test(resp.api_key) || !resp.api_key.startsWith("sfa_test_") || !validProjectId(resp.project)) {
1358
+ throw new SignafaideError(`The service at ${this.url} answered POST /v1/sandbox with an invalid api_key or project; check SIGNAFAIDE_URL.`, { code: "bad_response" });
1359
+ }
1360
+ this.state.ensure();
1361
+ return this.state.lock("~init", () => {
1362
+ const raced = this.apiKey;
1363
+ if (raced) return raced;
1364
+ const envFile = path2.join(this.state.root, ".env");
1365
+ appendEnvFile(envFile, "SIGNAFAIDE_API_KEY", resp.api_key);
1366
+ if (this.url !== DEFAULT_URL && !readEnvFile(envFile).SIGNAFAIDE_URL) appendEnvFile(envFile, "SIGNAFAIDE_URL", this.url);
1367
+ this.state.ignoreInGit(".env");
1368
+ this.state.learnProject(resp.api_key, resp.project, "test");
1369
+ const legal = legalLinks(resp, this.url);
1370
+ this.state.saveSandbox({ project: resp.project, claim_url: resp.claim_url, free_quota: resp.free_quota, url: this.url, created: now(), ...legal });
1371
+ this._apiKey = resp.api_key;
1372
+ if (!this.quiet) console.error(`signafaide: sandbox ready, key saved to .env; claim it at ${resp.claim_url}. ${termsNotice(legal)}`);
1373
+ return resp.api_key;
1374
+ });
1375
+ }
1376
+ /** Learn the API key's project (`GET /v1/project`) if it is not known yet, so envelopes
1377
+ * can name it (§2.2). Throws SignafaideError when the service cannot say. */
1378
+ async resolveProject(apiKey) {
1379
+ if (!this.state.bucket(apiKey).startsWith(PENDING + "/")) return;
1380
+ const resp = await this.client(1e4, apiKey).project();
1381
+ if (resp?.env !== envOf(apiKey) || !validProjectId(resp?.project)) {
1382
+ throw new SignafaideError(`The service at ${this.url} answered GET /v1/project with an invalid project or env; check SIGNAFAIDE_URL.`, { code: "bad_response" });
1383
+ }
1384
+ this.state.learnProject(apiKey, resp.project, resp.env);
1385
+ }
1386
+ /** Records waiting to ship, drafts included. */
1387
+ waiting() {
1388
+ return this.state.outboxSize() + this.state.chains().filter((c) => c.startsWith(PENDING + "/")).reduce((n, c) => n + this.state.draftCount(c), 0);
1389
+ }
1390
+ // --- writing --------------------------------------------------------------------
1391
+ /** `firstTime`: when a new key must sign records written earlier (drafts), its creation time. */
1392
+ key(agent, scope = "", firstTime) {
1393
+ const stamp = () => {
1394
+ if (this.state.providedKey(agent, scope)) return "provided";
1395
+ try {
1396
+ const s = fs2.statSync(this.state.keyPath(agent, scope), { bigint: true });
1397
+ return `${s.ino}:${s.mtimeNs}:${s.size}`;
1398
+ } catch {
1399
+ return "";
1400
+ }
1401
+ };
1402
+ const id = `${agent}
1403
+ ${scope}`;
1404
+ const held = this.keys.get(id);
1405
+ const now2 = stamp();
1406
+ if (held && held[1] === now2 && now2) return held[0];
1407
+ const k = this.state.loadKey(agent, true, scope, firstTime);
1408
+ this.keys.set(id, [k, stamp()]);
1409
+ return k;
1410
+ }
1411
+ /** The record's time (§2.2): never earlier than the previous record's or the key's valid_from.
1412
+ * When the clock read earlier, its reading is kept in `meta.wall_time`. */
1413
+ timed(content, prevTime, agent, scope) {
1414
+ const wall = now();
1415
+ const [time, raised] = clampTime(wall, prevTime, this.state.keyCreated(agent, scope) ?? null);
1416
+ return raised ? [time, withWallTime(content, wall)] : [time, content];
1417
+ }
1418
+ /** Sign and chain one record locally (synchronous) and queue it for shipping.
1419
+ * The chain is this API key's `<project>/<env>/<agent>` (§8). */
1420
+ write(kind, name, content, opts = {}) {
1421
+ return this.writeRun([{ kind, name, content, ref: opts.ref }], opts)[0];
1422
+ }
1423
+ /** `write` for records that must follow each other with nothing between them (a tool
1424
+ * declaration, §2.6): they are signed and appended under one hold of the chain lock. */
1425
+ writeRun(items, opts = {}) {
1426
+ const why = noWrites.getStore();
1427
+ if (why) throw new SignafaideError(`${why} must not write records: candidate functions must be side-effect free (it tried to record "${items[0]?.name}")`, { code: "no_writes" });
1428
+ const agent = opts.agent || this.defaultAgent;
1429
+ if (!AGENT_RE.test(agent)) throw new Error(`agent "${agent}" is invalid: use lowercase letters, digits, '.', '_' or '-' (max 64)`);
1430
+ const sc = opts.storeContent ?? this.storeContent;
1431
+ const apiKey = this.apiKey;
1432
+ let recs = null;
1433
+ while (!recs) {
1434
+ const bucket = this.writeBucket(apiKey, agent);
1435
+ const scope = scopeOf(bucket);
1436
+ const key = this.key(agent, scope);
1437
+ const chain = `${bucket}/${agent}`;
1438
+ if (!fs2.existsSync(this.state.chainPath(chain))) for (const e of Object.values(this.adoptInto(bucket, apiKey))) this.report(`signafaide: ${e.message}`);
1439
+ const [project, env] = bucket.split("/");
1440
+ const builds = items.map(
1441
+ ({ kind, name, content, ref }) => bucket.startsWith(PENDING + "/") ? (seq, prev, prevTime) => {
1442
+ const [time, c] = this.timed(content, prevTime, agent, scope);
1443
+ return makeDraft(key, { kind, agent, seq, prev, name, content: c, ref: ref ?? null, time }, sc);
1444
+ } : (seq, prev, prevTime) => {
1445
+ const [time, c] = this.timed(content, prevTime, agent, scope);
1446
+ return makeRecord(key, { kind, project, env, agent, seq, prev, name, content: c, ref: this.state.translateRef(chain, ref), time });
1447
+ }
1448
+ );
1449
+ recs = this.state.appendRun(chain, builds, (r) => wireRecord(r, sc), () => this.writeBucket(apiKey, agent) === bucket);
1450
+ }
1451
+ this.pending += recs.length;
1452
+ this.exitFlushed = false;
1453
+ if (items.some((i) => i.name !== INSTANCE_STOP)) this.wrote.add(agent);
1454
+ if (!apiKey && !this.toldLocal && !this.quiet && !this.test) {
1455
+ this.toldLocal = true;
1456
+ console.error(
1457
+ `signafaide: recording locally in ${this.state.home} (signed and chained; no API key, so nothing is sent). Run \`npx signafaide init\` to send hashes for co-signature and public timestamps, or set SIGNAFAIDE_API_KEY.`
1458
+ );
1459
+ }
1460
+ this.start();
1461
+ if (this.pending >= this.flushSize) void this.tick();
1462
+ return recs;
1463
+ }
1464
+ /** Declare the tools one source knows for an agent (§2.6): a `signafaide.tools` note followed
1465
+ * by one note per tool, written as one run. `source` names who declares (the SDK in this
1466
+ * program, or one gateway); the notes list the agent's whole tool list, the union over every
1467
+ * source that declared on this machine (§8 `tools/<agent>.json`), a tool recorded when any
1468
+ * source records it. Written at most once per recorder for the same set: the first time, and
1469
+ * again when the set changes. Returns the header's record hash. */
1470
+ declareTools(tools, opts) {
1471
+ const agent = opts.agent || this.defaultAgent;
1472
+ if (!AGENT_RE.test(agent)) throw new Error(`agent "${agent}" is invalid: use lowercase letters, digits, '.', '_' or '-' (max 64)`);
1473
+ const names = Object.keys(tools).sort(byCodePoint);
1474
+ const bad = names.find((n) => !n || /[\u0000-\u001f\u007f]/.test(n));
1475
+ if (bad !== void 0) throw new TypeError(`a tool name must be a non-empty string without control characters (got ${JSON.stringify(bad)})`);
1476
+ const id = `${agent}
1477
+ ${opts.source}`;
1478
+ const sig = JSON.stringify(names.map((n) => [n, tools[n]]));
1479
+ const held = this.declared.get(id);
1480
+ const hash = this.state.declareTools(agent, opts.source, tools, (all, expired) => {
1481
+ if (held?.sig === sig && !expired) return held.hash;
1482
+ const byName = /* @__PURE__ */ new Map();
1483
+ for (const [tool, recorded2] of Object.entries(all)) {
1484
+ const d = declaredName(tool);
1485
+ const prev = byName.get(d);
1486
+ if (!prev || recorded2 && !prev.recorded) byName.set(d, { tool, recorded: recorded2 });
1487
+ }
1488
+ const list = [...byName.keys()].sort(byCodePoint).map((d) => byName.get(d));
1489
+ const recorded = list.filter((t) => t.recorded).length;
1490
+ const items = [
1491
+ { kind: "note", name: TOOLS, content: { source: opts.origin, tools: list.length, recorded, unrecorded: list.length - recorded } },
1492
+ ...list.map((t) => ({ kind: "note", name: toolNoteName(t.tool, t.recorded), content: { tool: t.tool, recorded: t.recorded } }))
1493
+ ];
1494
+ return recordHash(this.writeRun(items, { agent, storeContent: opts.storeContent })[0]);
1495
+ });
1496
+ this.declared.set(id, { sig, hash, agent, source: opts.source, origin: opts.origin, tools, storeContent: opts.storeContent });
1497
+ if (!this.toolTimer && !this.closed) {
1498
+ this.toolTimer = setInterval(() => this.refreshToolSources(), TOOL_SOURCE_REFRESH_MS);
1499
+ this.toolTimer.unref?.();
1500
+ }
1501
+ return hash;
1502
+ }
1503
+ /** Refresh this recorder's entries in `tools/<agent>.json` (§8): each gets the current time,
1504
+ * so other sources keep counting its tools, and entries of sources that stopped more than 24
1505
+ * hours ago drop out. Nothing is written to the chain unless that changed the agent's tool
1506
+ * list. Runs by itself once an hour while the process lives. Never throws. */
1507
+ refreshToolSources() {
1508
+ for (const d of [...this.declared.values()]) {
1509
+ try {
1510
+ this.declareTools(d.tools, { agent: d.agent, source: d.source, origin: d.origin, storeContent: d.storeContent });
1511
+ } catch (e) {
1512
+ this.report(`signafaide: could not refresh the tool list of ${d.agent}: ${e.message}`);
1513
+ }
1514
+ }
1515
+ }
1516
+ /** A clean end of this source (§8): its entries leave `tools/<agent>.json`, so the next
1517
+ * declaration by any source no longer counts its tools. Nothing is written to the chain. With
1518
+ * `source`, only that source ends (a gateway run inside another program). Never throws. */
1519
+ releaseToolSources(source) {
1520
+ for (const [id, d] of [...this.declared]) {
1521
+ if (source !== void 0 && d.source !== source) continue;
1522
+ try {
1523
+ this.state.releaseToolSource(d.agent, d.source);
1524
+ } catch {
1525
+ }
1526
+ this.declared.delete(id);
1527
+ }
1528
+ if (!this.declared.size && this.toolTimer) {
1529
+ clearInterval(this.toolTimer);
1530
+ this.toolTimer = void 0;
1531
+ }
1532
+ }
1533
+ /** Where `agent`'s next record goes. A known project whose chain for `agent` is not on this
1534
+ * disk (an empty `SIGNAFAIDE_HOME` on Lambda or Cloud Run) may already have that chain on the
1535
+ * witness, so the record is a draft until the shipper has asked for the witness's head and
1536
+ * continues after it (§8). Writing never waits for the network. */
1537
+ writeBucket(apiKey, agent) {
1538
+ const bucket = this.state.bucket(apiKey);
1539
+ if (!apiKey || bucket.startsWith(PENDING + "/") || fs2.existsSync(this.state.chainPath(`${bucket}/${agent}`))) return bucket;
1540
+ const nokey = `${PENDING}/nokey`;
1541
+ return fs2.existsSync(this.state.chainPath(`${nokey}/${agent}`)) ? nokey : `${PENDING}/${keyFingerprint(apiKey)}`;
1542
+ }
1543
+ /** Move pending chains (written before the project was known) under `bucket`. Returns the
1544
+ * chains that stay pending, with why (chain id -> error). `bases`: agent -> the service's head,
1545
+ * for a chain whose local file is gone (an ephemeral disk): its drafts continue after it. */
1546
+ adoptInto(bucket, apiKey, bases = {}) {
1547
+ const out = {};
1548
+ if (bucket.startsWith(PENDING + "/")) return out;
1549
+ const sources = [`${PENDING}/nokey`];
1550
+ if (apiKey) sources.push(`${PENDING}/${keyFingerprint(apiKey)}`);
1551
+ const notBefore = new Date(Date.now() - KEY_MAX_AGE_MS).toISOString();
1552
+ const scope = scopeOf(bucket);
1553
+ for (const src of sources) {
1554
+ for (const [chain, why] of this.state.adopt(src, bucket, (a, first) => this.key(a, scope, first), (r) => wireRecord(r, this.storeContent), notBefore, bases)) {
1555
+ const msg = why === "exists" ? `${chain} cannot join ${bucket}, which already has that agent's chain; it stays pending (local only) and still verifies with \`signafaide verify\`` : `${chain} starts at ${why}, more than 7 days ago: the service accepts records up to 7 days old (\xA72.4), so this chain stays local; it still verifies with \`signafaide verify\`. New records of that agent start a new chain under ${bucket}`;
1556
+ out[chain] = new SignafaideError(msg, { code: why === "exists" ? "chain_exists" : "too_old" });
1557
+ }
1558
+ }
1559
+ return out;
1560
+ }
1561
+ /** The service's heads for pending agents that have no local chain under `bucket` yet (an
1562
+ * ephemeral disk lost it): their drafts continue the service's chain instead of conflicting. */
1563
+ async remoteBases(bucket, apiKey) {
1564
+ const out = {};
1565
+ const agents = /* @__PURE__ */ new Set();
1566
+ for (const c of this.state.chains()) {
1567
+ if (!c.startsWith(`${PENDING}/nokey/`) && !c.startsWith(`${PENDING}/${keyFingerprint(apiKey)}/`)) continue;
1568
+ const a = c.split("/")[2];
1569
+ if (!fs2.existsSync(this.state.chainPath(`${bucket}/${a}`))) agents.add(a);
1570
+ }
1571
+ const client = this.client(1e4, apiKey);
1572
+ for (const a of agents) {
1573
+ try {
1574
+ const sh = await client.head(a);
1575
+ const h = sh?.head;
1576
+ if (h && Number.isInteger(h.seq) && h.seq > 0 && /^[0-9a-f]{64}$/.test(h.record_hash) && `${h.project}/${h.env}` === bucket && h.agent === a) {
1577
+ out[a] = { seq: h.seq, record_hash: h.record_hash, signed: { head: h, head_sig: sh.head_sig } };
1578
+ try {
1579
+ const got = (await client.record(h.record_hash))?.record;
1580
+ if (isStopNote(got, a, h.record_hash)) out[a].stop = { envelope: got.envelope, key_id: got.key_id, sig: got.sig };
1581
+ } catch (e) {
1582
+ if (!(e instanceof SignafaideError)) throw e;
1583
+ }
1584
+ }
1585
+ } catch (e) {
1586
+ const code = e.code;
1587
+ if (code !== "not_found" && code !== "unknown_agent") throw e;
1588
+ }
1589
+ }
1590
+ return out;
1591
+ }
1592
+ // --- shipping -------------------------------------------------------------------
1593
+ /** Run shipping work one at a time in this process. */
1594
+ serial(mode, fn) {
1595
+ const run = this.shipping.then(async () => {
1596
+ this.ioMode = mode;
1597
+ try {
1598
+ return await fn();
1599
+ } finally {
1600
+ this.ioMode = "fg";
1601
+ }
1602
+ });
1603
+ this.shipping = run.catch(() => void 0);
1604
+ return run;
1605
+ }
1606
+ /** A label for errors that are about the API key, not a chain. */
1607
+ keyLabel(apiKey) {
1608
+ return `API key ${apiKey.slice(0, 13)}...`;
1609
+ }
1610
+ /** Ship the outbox of this API key's project now. `errors` maps chain id (or the API key, for
1611
+ * errors about the key) to the one-sentence fix. Records of other projects (an earlier key)
1612
+ * wait for that key. An explicit flush always tries, even after a refusal. */
1613
+ flush(timeoutMs = 1e4, mode = "fg") {
1614
+ if (mode === "fg") {
1615
+ this.halted = void 0;
1616
+ this.blocked.clear();
1617
+ }
1618
+ return this.serial(mode, async () => {
1619
+ let shipped = 0;
1620
+ const errors = {};
1621
+ if (!this.state.outboxSize() && !this.state.hasDrafts()) return { shipped, left: 0, errors };
1622
+ const key = this.apiKey;
1623
+ if (!key) return { shipped, left: this.waiting(), errors };
1624
+ const apiKey = key;
1625
+ let bases = {};
1626
+ try {
1627
+ await this.resolveProject(apiKey);
1628
+ const bucket = this.state.bucket(apiKey);
1629
+ if (this.state.hasDrafts()) bases = await this.remoteBases(bucket, apiKey);
1630
+ } catch (e) {
1631
+ if (!(e instanceof SignafaideError) && !(e instanceof StateError)) throw e;
1632
+ const err = e instanceof SignafaideError ? e : new SignafaideError(e.message, { code: "state" });
1633
+ return { shipped, left: this.waiting(), errors: { [this.keyLabel(apiKey)]: err } };
1634
+ }
1635
+ const done = /* @__PURE__ */ new Set();
1636
+ for (; ; ) {
1637
+ const bucket = this.state.bucket(apiKey);
1638
+ Object.assign(errors, this.adoptInto(bucket, apiKey, bases));
1639
+ bases = {};
1640
+ const next = this.state.outboxChains(bucket).find((c) => !done.has(c));
1641
+ if (!next) break;
1642
+ done.add(next);
1643
+ try {
1644
+ shipped += (await this.shipChain(next, apiKey, timeoutMs)).size;
1645
+ } catch (e) {
1646
+ if (!(e instanceof SignafaideError)) throw e;
1647
+ const projectWide = e.retryable || e.code === "invalid_key" || e.code === "quota_exceeded" || e.code === "plan_required";
1648
+ errors[e.code === "invalid_key" ? this.keyLabel(apiKey) : next] = e;
1649
+ if (projectWide) break;
1650
+ }
1651
+ }
1652
+ return { shipped, left: this.waiting(), errors };
1653
+ });
1654
+ }
1655
+ /** A clean stop (§8): ship everything, then write and ship a signed stop note for each agent
1656
+ * this process wrote records for. The next instance's start right after it is a clean restart,
1657
+ * with no "earlier unsent records are not known" warning. Nothing is written while any record
1658
+ * is still unsent (the next start then carries the warning, as it should). Call it in the
1659
+ * shutdown handler of an ephemeral container (SIGTERM); a program that ends by itself with a
1660
+ * head file set does it at exit. */
1661
+ async stop(timeoutMs = 1e4, mode = "fg") {
1662
+ const first = await this.flush(timeoutMs, mode);
1663
+ const apiKey = this.apiKey;
1664
+ const stopped = [];
1665
+ if (this.test || !apiKey || first.left || Object.keys(first.errors).length) return { ...first, stopped };
1666
+ const bucket = this.state.bucket(apiKey);
1667
+ if (bucket.startsWith(PENDING + "/")) return { ...first, stopped };
1668
+ for (const agent of [...this.wrote].sort()) {
1669
+ const chain = `${bucket}/${agent}`;
1670
+ const last = this.state.lastRecord(chain);
1671
+ if (!last || !("receipt" in last) || last.envelope.kind === "note" && last.envelope.name === INSTANCE_STOP) continue;
1672
+ this.write("note", INSTANCE_STOP, instanceStopContent(last.envelope.seq, recordHash(last)), { agent });
1673
+ stopped.push(chain);
1674
+ }
1675
+ this.wrote.clear();
1676
+ if (!stopped.length) return { ...first, stopped };
1677
+ const second = await this.flush(timeoutMs, mode);
1678
+ return { shipped: first.shipped + second.shipped, left: second.left, errors: second.errors, stopped: second.left ? [] : stopped };
1679
+ }
1680
+ /** One stderr line, the first time a strict tool is refused in this process (frameworks often
1681
+ * swallow the tool's error): the tool, why, and the fix. */
1682
+ warnStrictRefused(tool, err) {
1683
+ if (this.toldStrict) return;
1684
+ this.toldStrict = true;
1685
+ const fix = err.code === "no_key" ? "there is no API key, and strict mode needs the witness's receipt. Fix: run `npx signafaide init` (a free sandbox) or set SIGNAFAIDE_API_KEY, or drop strict for this tool" : err.message;
1686
+ console.warn(`signafaide: WARNING strict tool "${tool}" was refused and did not run (${err.code}): ${fix}. Later strict refusals in this process are not printed again.`);
1687
+ }
1688
+ /** Strict mode: ship `rec`'s chain now and return its receipt, or throw. */
1689
+ ensureReceipt(rec, timeoutMs = 1e4) {
1690
+ this.strictUsed = true;
1691
+ return this.serial("fg", async () => {
1692
+ const apiKey = this.apiKey;
1693
+ if (!apiKey) {
1694
+ throw new SignafaideError(
1695
+ "Strict mode needs the service's receipt, and there is no API key: run `npx signafaide init` (a free sandbox) or set SIGNAFAIDE_API_KEY.",
1696
+ { code: "no_key" }
1697
+ );
1698
+ }
1699
+ await this.resolveProject(apiKey);
1700
+ const bucket = this.state.bucket(apiKey);
1701
+ this.adoptInto(bucket, apiKey, this.state.hasDrafts() ? await this.remoteBases(bucket, apiKey) : {});
1702
+ const h = this.state.translateRef(`${bucket}/${rec.envelope.agent}`, recordHash(rec));
1703
+ const got = (await this.shipChain(`${bucket}/${rec.envelope.agent}`, apiKey, timeoutMs)).get(h);
1704
+ if (!got) throw new SignafaideError(`The service did not return a receipt for record ${h.slice(0, 12)}; check \`signafaide status\`.`, { code: "not_recorded" });
1705
+ this.adoptInto(this.state.bucket(apiKey), apiKey);
1706
+ return got;
1707
+ });
1708
+ }
1709
+ /** The witness's refusal, in words that say what happened and what to do. */
1710
+ explain(e, chain, keyId, current2) {
1711
+ const agent = chain.split("/")[2];
1712
+ let msg = `${e.message} (chain ${chain})`;
1713
+ if (e.code === "bad_signature" && /revoked|retired/.test(e.message)) {
1714
+ const scope = scopeOf(chain.split("/").slice(0, 2).join("/"));
1715
+ msg = keyId === current2 ? this.state.providedKey(agent, scope) ? `${chain}: agent key ${keyId} was revoked, so the witness refuses every record it signs. Put a new key for ${agent} in ${agentKeyEnvName(agent, scope)} (or your key provider), then run \`signafaide repair --agent ${agent}\`: it sets aside the records the revoked key signed after it ended, kept on this machine and still verifiable.` : `${chain}: agent key ${keyId} was revoked, so the witness refuses every record it signs. Run \`signafaide repair --agent ${agent}\`: it sets aside the records the revoked key signed after it ended, kept on this machine and still verifiable, and gives the agent a new key.` : `${chain}: records signed by agent key ${keyId} after it was revoked or retired cannot be co-signed, and new records wait behind them. Run \`signafaide repair --agent ${agent}\`: it sets them aside (kept on this machine, still verifiable) so the new key's records ship.`;
1716
+ } else if (/signafaide repair/.test(e.message)) {
1717
+ msg = `${e.message} (chain ${chain})`;
1718
+ } else if (/ahead of the service clock|must not be in the future/.test(e.message)) {
1719
+ msg = `${e.message} (chain ${chain}). This machine's clock is ahead of the witness's. Fix the clock (NTP); records already written with future times ship once the witness's clock reaches them, or run \`signafaide repair --agent ${agent}\` to set them aside with a fresh key now.`;
1720
+ } else if (/not active at|earlier than the time of/.test(e.message)) {
1721
+ msg = `${e.message} (chain ${chain}). Run \`signafaide repair --agent ${agent}\`: it sets the records the witness cannot take aside (kept, still verifiable) and starts a new segment with a fresh key.`;
1722
+ }
1723
+ return new SignafaideError(msg, { code: e.code, status: e.status, docs: e.docs, body: e.body });
1724
+ }
1725
+ /** Ship one chain's outbox in order. */
1726
+ async shipChain(chain, apiKey, timeoutMs) {
1727
+ const agent = chain.split("/")[2];
1728
+ const scope = scopeOf(chain.split("/").slice(0, 2).join("/"));
1729
+ const received = /* @__PURE__ */ new Map();
1730
+ const fp = this.configFp();
1731
+ const blocked = this.blocked.get(chain);
1732
+ if (blocked && blocked.fp === fp) throw blocked.err;
1733
+ this.blocked.delete(chain);
1734
+ const wire = (r) => wireRecord(r, this.storeContent);
1735
+ if (!this.reconciled.has(chain)) {
1736
+ this.state.reconcileOutbox(chain, wire);
1737
+ this.reconciled.add(chain);
1738
+ }
1739
+ const client = this.client(timeoutMs, apiKey);
1740
+ for (; ; ) {
1741
+ let batch = this.state.outbox(chain, BATCH_MAX);
1742
+ if (!batch.length) return received;
1743
+ const current2 = this.key(agent, scope).keyId;
1744
+ if (batch[0].key_id !== current2) {
1745
+ const cut = batch.findIndex((r) => r.key_id === current2);
1746
+ batch = cut < 0 ? batch : batch.slice(0, cut);
1747
+ } else await this.register(client, agent, scope);
1748
+ let resp;
1749
+ try {
1750
+ try {
1751
+ resp = await client.submit(batch);
1752
+ } catch (e) {
1753
+ if (!(e instanceof SignafaideError) || e.code !== "unknown_agent") throw e;
1754
+ this.registered.delete(`${client.apiKey}
1755
+ ${agent}
1756
+ ${this.key(agent, scope).keyId}`);
1757
+ await this.register(client, agent, scope);
1758
+ resp = await client.submit(batch);
1759
+ }
1760
+ } catch (e) {
1761
+ if (!(e instanceof SignafaideError)) throw e;
1762
+ let err;
1763
+ if (e.code === "chain_conflict") {
1764
+ const head = e.body.error?.head ?? {};
1765
+ err = new SignafaideError(
1766
+ `${e.message} (chain ${chain}: service head is seq ${head.seq} ${head.record_hash}; local outbox starts at seq ${batch[0].envelope.seq}; nothing was rewritten, run \`signafaide verify\` and \`signafaide status\`)`,
1767
+ { code: e.code, status: e.status, docs: e.docs, body: e.body }
1768
+ );
1769
+ } else err = e.permanent ? this.explain(e, chain, batch[0].key_id, current2) : e;
1770
+ if (err.permanent && !["invalid_key", "quota_exceeded", "plan_required"].includes(err.code)) {
1771
+ this.blocked.set(chain, { fp, err });
1772
+ this.state.setBlocked(chain, { code: err.code, message: err.message, seq: batch[0].envelope.seq });
1773
+ }
1774
+ throw err;
1775
+ }
1776
+ const got = new Map((resp.receipts ?? []).map((r) => [r.record_hash, r]));
1777
+ const bucket = chain.split("/").slice(0, 2).join("/");
1778
+ if ([...got.values()].some((r) => `${r.batch.project}/${r.batch.env}` !== bucket)) {
1779
+ throw new SignafaideError(`The service returned receipts for another project than ${bucket}; nothing was written. Check SIGNAFAIDE_URL and the API key.`, { code: "bad_response" });
1780
+ }
1781
+ this.state.applyReceipts(chain, got);
1782
+ this.state.dropOutbox(
1783
+ chain,
1784
+ batch.filter((r) => got.has(recordHash(r))).map((r) => r.envelope.seq)
1785
+ );
1786
+ for (const w of this.state.updateHeads(resp.heads ?? [])) console.warn(`signafaide: ${w}`);
1787
+ if (this.onHead && resp.heads?.length) {
1788
+ try {
1789
+ await this.onHead(resp.heads);
1790
+ } catch (e) {
1791
+ console.warn(`signafaide: onHead failed (the heads are still in head.json): ${e.message}`);
1792
+ }
1793
+ }
1794
+ for (const [h, r] of got) received.set(h, r);
1795
+ this.pending = 0;
1796
+ if (!got.size) throw new SignafaideError("The service accepted the batch but returned no matching receipts.", { code: "no_receipts" });
1797
+ this.state.setBlocked(chain, null);
1798
+ }
1799
+ }
1800
+ /** `signafaide keys create`: a new key for `agent` in `env`, registered with the witness under
1801
+ * the API key (which must be of that env), writing no record. With `print`, nothing is written
1802
+ * here: the key file is returned for a secret store (`SIGNAFAIDE_AGENT_KEY_LIVE_<NAME>` for
1803
+ * live). Otherwise it becomes the agent's key file. Refused when the agent already has a key
1804
+ * here or from the environment; the witness refuses it when the agent already has an active
1805
+ * key there (`keys rotate` on the machine that holds it, or revoke it in the console first). */
1806
+ async createKey(agent, env, print) {
1807
+ const apiKey = this.apiKey;
1808
+ if (!apiKey) throw new SignafaideError("keys create registers the new key with the witness: set SIGNAFAIDE_API_KEY to a key of that env first (a live key from the console for --env live).", { code: "no_key" });
1809
+ if (envOf(apiKey) !== env) throw new SignafaideError(`SIGNAFAIDE_API_KEY is a ${envOf(apiKey)} key; --env ${env} needs a ${env} key.`, { code: "invalid_key" });
1810
+ const scope = env === "live" ? "live" : "";
1811
+ if (this.state.providedKey(agent, scope)) throw new StateError(`agent ${agent} already has a key from ${agentKeyEnvName(agent, scope)} (or a key provider); unset it to make a new one, or use \`signafaide keys rotate\``);
1812
+ if (!print && fs2.existsSync(this.state.keyPath(agent, scope))) throw new StateError(`agent ${agent} already has a key file (${this.state.keyPath(agent, scope)}); use \`signafaide keys rotate\` to replace it`);
1813
+ const known = this.state.bucket(apiKey);
1814
+ let project, keyEnv;
1815
+ if (!known.startsWith(PENDING + "/")) [project, keyEnv] = known.split("/");
1816
+ else {
1817
+ const resp2 = await this.client(1e4, apiKey).project();
1818
+ if (resp2?.env !== env || !validProjectId(resp2?.project)) {
1819
+ throw new SignafaideError(`The service at ${this.url} answered GET /v1/project with an invalid project or env; check SIGNAFAIDE_URL.`, { code: "bad_response" });
1820
+ }
1821
+ [project, keyEnv] = [resp2.project, resp2.env];
1822
+ if (!print) this.state.learnProject(apiKey, project, keyEnv);
1823
+ }
1824
+ const { key, doc } = this.state.newKeyFile(agent);
1825
+ const reg = registration(project, keyEnv, agent, doc.public_key, doc.created);
1826
+ const h = hashJson(reg);
1827
+ let resp;
1828
+ try {
1829
+ resp = await this.client(1e4, apiKey).registerAgent(agent, doc.public_key, doc.created, key.sign("register", h));
1830
+ } catch (e) {
1831
+ if (e instanceof SignafaideError && e.code === "bad_signature") {
1832
+ throw new SignafaideError(
1833
+ `agent ${agent} already has an active key on the witness, so a new one needs the old key's rotation signature: run \`signafaide keys rotate --agent ${agent} --env ${env}\` where that key is, or have an admin revoke it in the console, then run keys create again.`,
1834
+ { code: "bad_signature" }
1835
+ );
1836
+ }
1837
+ throw e;
1838
+ }
1839
+ if (resp?.key_id && resp.key_id !== key.keyId) throw new SignafaideError(`The service registered key ${resp.key_id} for ${agent}, expected ${key.keyId}.`, { code: "key_mismatch" });
1840
+ this.registered.add(`${apiKey}
1841
+ ${agent}
1842
+ ${key.keyId}`);
1843
+ const out = { doc, envName: agentKeyEnvName(agent, scope), project };
1844
+ return print ? out : { ...out, file: this.state.saveKeyFile(doc, scope) };
1845
+ }
1846
+ async register(client, agent, scope) {
1847
+ const k = this.key(agent, scope);
1848
+ const id = `${client.apiKey}
1849
+ ${agent}
1850
+ ${k.keyId}`;
1851
+ if (this.registered.has(id)) return;
1852
+ const [project, env] = this.state.bucket(client.apiKey).split("/");
1853
+ const nowIso = now();
1854
+ const floor = new Date(Date.now() - KEY_MAX_AGE_MS).toISOString().slice(0, 19) + ".000Z";
1855
+ const created = this.state.keyCreated(agent, scope) ?? nowIso;
1856
+ let validFrom = created > floor ? created : floor;
1857
+ if (validFrom > nowIso) validFrom = nowIso;
1858
+ const reg = registration(project, env, agent, b64url(k.publicKey), validFrom);
1859
+ const h = hashJson(reg);
1860
+ const retired = this.state.retiredKeys(agent, scope)[0]?.[0];
1861
+ const rotation = retired ? { key_id: retired.keyId, sig: retired.sign("rotate", h) } : void 0;
1862
+ let resp;
1863
+ try {
1864
+ resp = await client.registerAgent(agent, reg.public_key, validFrom, k.sign("register", h), rotation);
1865
+ } catch (e) {
1866
+ if (e instanceof SignafaideError && e.permanent) throw this.explain(e, `${project}/${env}/${agent}`, k.keyId, k.keyId);
1867
+ throw e;
1868
+ }
1869
+ if (resp?.key_id && resp.key_id !== k.keyId) {
1870
+ throw new SignafaideError(`The service registered key ${resp.key_id} for ${agent}, expected ${k.keyId}.`, { code: "key_mismatch" });
1871
+ }
1872
+ for (const r of Array.isArray(resp?.retired) ? resp.retired : []) {
1873
+ if (r && typeof r.key_id === "string" && typeof r.valid_to === "string") this.state.markRetired(agent, r.key_id, r.valid_to, scope);
1874
+ }
1875
+ this.registered.add(id);
1876
+ }
1877
+ // --- background -----------------------------------------------------------------
1878
+ start() {
1879
+ if (this.closed) return;
1880
+ if (!this.timer) {
1881
+ this.timer = setInterval(() => void this.tick(), this.flushIntervalMs);
1882
+ this.timer.unref?.();
1883
+ }
1884
+ if (!this.exitHooked && typeof process !== "undefined") {
1885
+ this.exitHooked = true;
1886
+ process.on("beforeExit", this.onBeforeExit);
1887
+ process.on("exit", this.onExit);
1888
+ }
1889
+ }
1890
+ /** One background attempt: never two at once, never inside the backoff, and never after a
1891
+ * refusal retrying cannot fix until the configuration (API key, URL, agent keys) changes. */
1892
+ async tick() {
1893
+ if (this.closed || this.inFlight || monotonicMs() < this.nextAttempt) return;
1894
+ if (!this.apiKey || !this.waiting()) return;
1895
+ const fp = this.configFp();
1896
+ if (this.halted) {
1897
+ if (this.halted.fp === fp) return;
1898
+ this.halted = void 0;
1899
+ }
1900
+ this.inFlight = true;
1901
+ let r;
1902
+ try {
1903
+ r = await this.flushLogged(1e4, "bg");
1904
+ } finally {
1905
+ this.inFlight = false;
1906
+ }
1907
+ const errs = r ? Object.values(r.errors).filter((e) => !LOCAL_CODES.has(e.code)) : [];
1908
+ if (r && !errs.length) {
1909
+ this.failures = 0;
1910
+ this.nextAttempt = 0;
1911
+ return;
1912
+ }
1913
+ const wide = errs.find((e) => e.permanent && ["invalid_key", "quota_exceeded", "plan_required"].includes(e.code));
1914
+ if (wide) {
1915
+ this.halted = { fp, err: wide };
1916
+ this.report(`signafaide: sending stopped until the API key or SIGNAFAIDE_URL changes (\`signafaide flush\` tries again now): ${wide.message}`);
1917
+ return;
1918
+ }
1919
+ this.failures++;
1920
+ const base = Math.min(6e4, this.flushIntervalMs * 2 ** this.failures);
1921
+ this.nextAttempt = monotonicMs() + base * (0.8 + Math.random() * 0.4);
1922
+ }
1923
+ async flushLogged(timeoutMs = 1e4, mode = "fg") {
1924
+ const cut = mode === "bg" ? this.bgAbort.signal : void 0;
1925
+ let r;
1926
+ try {
1927
+ r = await this.flush(timeoutMs, mode);
1928
+ } catch (e) {
1929
+ if (cut?.aborted) return null;
1930
+ this.report(`signafaide: ${e.message}`);
1931
+ return null;
1932
+ }
1933
+ if (cut?.aborted) return r;
1934
+ const errs = Object.entries(r.errors);
1935
+ if (errs.length) this.report("signafaide: " + errs.map(([a, e]) => e.message.includes(a) ? e.message : `${a}: ${e.message}`).join("; "));
1936
+ else if (this.failing && r.shipped) {
1937
+ console.warn(`signafaide: service reachable again, shipped ${plural(r.shipped, "record")}`);
1938
+ this.failing = void 0;
1939
+ }
1940
+ return r;
1941
+ }
1942
+ report(msg) {
1943
+ if (msg !== this.failing) {
1944
+ console.warn(msg);
1945
+ this.failing = msg;
1946
+ }
1947
+ }
1948
+ /** The program has nothing left to do: one last attempt, held open at most `exitFlushMs`.
1949
+ * Background requests never keep the process alive, so they are cut off here. */
1950
+ onBeforeExit = () => {
1951
+ void this.exitAttempt();
1952
+ };
1953
+ /** The last attempt of a program that ends on its own schedule (the gateway, once its server
1954
+ * has exited): what happens by itself when a program runs out of work. Ships what is left and,
1955
+ * when stop notes are on, writes the signed stop note, all within `exitFlushMs`; then stops the
1956
+ * background timer. Never throws. */
1957
+ async shutdown() {
1958
+ await this.exitAttempt();
1959
+ this.releaseToolSources();
1960
+ this.close();
1961
+ }
1962
+ exitAttempt() {
1963
+ const stop2 = this.instanceStop && this.wrote.size > 0 && !this.test;
1964
+ if (this.exitFlushed || !this.exitFlushMs || !this.apiKey || !this.waiting() && !stop2) return void 0;
1965
+ this.exitFlushed = true;
1966
+ if (this.halted && this.halted.fp === this.configFp()) return void 0;
1967
+ this.bgAbort.abort();
1968
+ this.bgAbort = new AbortController();
1969
+ const ctrl = new AbortController();
1970
+ this.exitAbort = ctrl;
1971
+ const timer = setTimeout(() => ctrl.abort(), this.exitFlushMs);
1972
+ const done = () => {
1973
+ clearTimeout(timer);
1974
+ this.exitFlushed = true;
1975
+ };
1976
+ if (!stop2) return this.flushLogged(this.exitFlushMs, "exit").then(done, done);
1977
+ return this.stop(this.exitFlushMs, "exit").then((r) => {
1978
+ const errs = Object.entries(r.errors);
1979
+ if (errs.length && !ctrl.signal.aborted) this.report("signafaide: " + errs.map(([a, e]) => e.message.includes(a) ? e.message : `${a}: ${e.message}`).join("; "));
1980
+ }, (e) => {
1981
+ if (!ctrl.signal.aborted) this.report(`signafaide: ${e.message}`);
1982
+ }).then(done, done);
1983
+ }
1984
+ onExit = () => {
1985
+ this.releaseToolSources();
1986
+ const left = this.waiting();
1987
+ if (left && !this.quiet && this.apiKey) {
1988
+ console.error(`signafaide: ${plural(left, "record")} kept in ${path2.join(this.state.home, "outbox")}; they ship on the next run or with \`signafaide flush\``);
1989
+ }
1990
+ };
1991
+ /** Stop the background timer and exit hooks (records stay in the outbox). */
1992
+ close() {
1993
+ this.closed = true;
1994
+ if (this.toolTimer) clearInterval(this.toolTimer);
1995
+ this.toolTimer = void 0;
1996
+ if (this.timer) clearInterval(this.timer);
1997
+ this.timer = void 0;
1998
+ this.bgAbort.abort();
1999
+ if (this.exitHooked) {
2000
+ process.off("beforeExit", this.onBeforeExit);
2001
+ process.off("exit", this.onExit);
2002
+ this.exitHooked = false;
2003
+ }
2004
+ }
2005
+ // --- blocked chains and repair -----------------------------------------------------
2006
+ /** Why each chain cannot ship, found on this machine (time going back, a clock ahead, a key
2007
+ * used after it ended) or said by the witness (blocked.json). Reads only the records without
2008
+ * a receipt. */
2009
+ diagnose() {
2010
+ const out = [];
2011
+ const saved = this.state.blocked();
2012
+ const nowMs = Date.now();
2013
+ for (const chain of this.state.chains()) {
2014
+ if (chain.startsWith(PENDING + "/")) continue;
2015
+ const agent = chain.split("/")[2];
2016
+ const scope = scopeOf(chain.split("/").slice(0, 2).join("/"));
2017
+ const tail = [];
2018
+ let prevTime = null;
2019
+ for (const [, r] of this.state.reverse(chain)) {
2020
+ if (r.receipt) {
2021
+ prevTime = r.envelope.time;
2022
+ break;
2023
+ }
2024
+ tail.push(r);
2025
+ }
2026
+ tail.reverse();
2027
+ if (!tail.length) continue;
2028
+ const ended = /* @__PURE__ */ new Map();
2029
+ for (const [rk] of this.state.retiredKeys(agent, scope)) {
2030
+ const t = this.state.retiredAt(agent, rk.keyId, scope);
2031
+ if (t) ended.set(rk.keyId, t);
2032
+ }
2033
+ const created = /* @__PURE__ */ new Map();
2034
+ const cur = this.state.loadKey(agent, false, scope);
2035
+ const c0 = this.state.keyCreated(agent, scope);
2036
+ if (cur && c0) created.set(cur.keyId, c0);
2037
+ for (const [rk, c] of this.state.retiredKeys(agent, scope)) created.set(rk.keyId, c);
2038
+ const next = `run \`signafaide repair --agent ${agent}\` (sets these records aside, kept and verifiable, and starts a new segment with a fresh key)`;
2039
+ const wait = `fix the clock (NTP) and wait until it catches up with these records, which then ship, or run \`signafaide repair --agent ${agent}\` (sets them aside, kept and verifiable, and starts a new segment with a fresh key)`;
2040
+ let found = null;
2041
+ for (const r of tail) {
2042
+ const t = r.envelope.time;
2043
+ const at = (code, reason) => ({ chain, seq: r.envelope.seq, code, reason, records: tail.length - tail.indexOf(r), next });
2044
+ if (prevTime && t < prevTime) found = at("time_back", `seq ${r.envelope.seq} has time ${t}, earlier than the record before it (${prevTime}): the clock went back while an older SDK was writing`);
2045
+ else if (created.has(r.key_id) && t < created.get(r.key_id)) found = at("before_key", `seq ${r.envelope.seq} has time ${t}, before its key ${r.key_id} was made (${created.get(r.key_id)})`);
2046
+ else if (ended.has(r.key_id) && t >= ended.get(r.key_id)) found = at("key_ended", `seq ${r.envelope.seq} is signed by key ${r.key_id} after it ended at ${ended.get(r.key_id)}`);
2047
+ else if (Date.parse(t) > nowMs + MAX_CLOCK_SKEW_MS)
2048
+ found = { ...at("clock_ahead", `seq ${r.envelope.seq} has time ${t}, ahead of this machine's clock now: the clock was ahead when it was written`), next: wait };
2049
+ if (found) break;
2050
+ prevTime = t;
2051
+ }
2052
+ const w = saved[chain];
2053
+ if (!found && w) {
2054
+ const first = tail.find((r) => w.seq === null || r.envelope.seq >= w.seq) ?? tail[0];
2055
+ const msg = (w.message.startsWith(`${chain}: `) ? w.message.slice(chain.length + 2) : w.message).replace(/[\s.]+$/, "");
2056
+ const ahead = /ahead of the service clock|must not be in the future/.test(msg);
2057
+ found = { chain, seq: first.envelope.seq, code: w.code, reason: `refused by the witness: ${msg}`, records: tail.length - tail.indexOf(first), next: ahead ? wait : next };
2058
+ }
2059
+ if (found) out.push(found);
2060
+ }
2061
+ return out;
2062
+ }
2063
+ /** `signafaide repair`: for each blocked chain (of `agent`, or every agent), set the records
2064
+ * from the first one that cannot ship aside into `held/` (kept, still verifiable locally), give
2065
+ * the agent a new key (rotation, §2.4), and write a `note` named `signafaide.repair` that
2066
+ * commits to the held records' hashes, so the new segment's first co-signed record fixes them
2067
+ * in time. Co-signed records are never touched. */
2068
+ repair(opts = {}) {
2069
+ const out = [];
2070
+ for (const b of this.diagnose()) {
2071
+ const agent = b.chain.split("/")[2];
2072
+ if (opts.agent && agent !== opts.agent) continue;
2073
+ if (opts.dryRun) {
2074
+ out.push({ chain: b.chain, blocked: b });
2075
+ continue;
2076
+ }
2077
+ const bucket = b.chain.split("/").slice(0, 2).join("/");
2078
+ const scope = scopeOf(bucket);
2079
+ const { file, records } = this.state.holdTail(b.chain, b.seq);
2080
+ let newKey;
2081
+ if (!this.state.providedKey(agent, scope)) newKey = this.state.rotateKey(agent, scope)[1].keyId;
2082
+ this.keys.delete(`${agent}
2083
+ ${scope}`);
2084
+ const content = {
2085
+ held: path2.relative(this.state.home, file),
2086
+ from_seq: records[0].envelope.seq,
2087
+ to_seq: records[records.length - 1].envelope.seq,
2088
+ records: records.length,
2089
+ records_hash: hashJson(records.map((r) => recordHash(r))),
2090
+ code: b.code,
2091
+ reason: b.reason
2092
+ };
2093
+ const [project, env] = bucket.split("/");
2094
+ const key = this.key(agent, scope);
2095
+ const note2 = this.state.append(
2096
+ b.chain,
2097
+ (seq, prev, prevTime) => {
2098
+ const [time, c] = this.timed(content, prevTime, agent, scope);
2099
+ return makeRecord(key, { kind: "note", project, env, agent, seq, prev, name: "signafaide.repair", content: c, ref: null, time });
2100
+ },
2101
+ (r) => wireRecord(r, this.storeContent)
2102
+ );
2103
+ this.state.setBlocked(b.chain, null);
2104
+ this.blocked.delete(b.chain);
2105
+ this.halted = void 0;
2106
+ out.push({ chain: b.chain, blocked: b, held: file, records: records.length, newKey, note: recordHash(note2) });
2107
+ }
2108
+ return out;
2109
+ }
2110
+ // --- status ---------------------------------------------------------------------
2111
+ status() {
2112
+ const heads = this.state.chainHeads();
2113
+ const bucket = this.state.bucket(this.apiKey);
2114
+ const blocked = new Map(this.diagnose().map((b) => [b.chain, b]));
2115
+ const chains = this.state.chains().map((chain) => {
2116
+ const agent = chain.split("/")[2];
2117
+ const h = heads[chain]?.head;
2118
+ const scope = scopeOf(chain.split("/").slice(0, 2).join("/"));
2119
+ return {
2120
+ chain,
2121
+ agent,
2122
+ current: chain.startsWith(bucket + "/"),
2123
+ key_id: this.state.loadKey(agent, false, scope)?.keyId ?? null,
2124
+ records: this.state.tail(chain)[0],
2125
+ waiting: this.state.outboxSize(chain),
2126
+ receipt_seq: h?.seq ?? null,
2127
+ receipt_time: h?.server_time ?? null,
2128
+ blocked: blocked.get(chain) ?? null
2129
+ };
2130
+ });
2131
+ const held = this.state.heldSegments().map(([chain, file, recs]) => ({ chain, file, records: recs.length }));
2132
+ const key = this.apiKey;
2133
+ return {
2134
+ url: this.url,
2135
+ home: this.state.home,
2136
+ api_key: key ?? null,
2137
+ api_key_invalid: !key && !!this.rawApiKey && !this.test,
2138
+ test_mode: this.test,
2139
+ env: envOf(key),
2140
+ bucket,
2141
+ sandbox: this.state.sandbox(),
2142
+ chains,
2143
+ held,
2144
+ // Drafts wait too: for the project, or for the witness's head of a chain new to this disk.
2145
+ outbox: this.waiting()
2146
+ };
2147
+ }
2148
+ };
2149
+ function envOf(key) {
2150
+ if (!key) return null;
2151
+ return key.startsWith("sfa_test_") ? "test" : key.startsWith("sfa_live_") ? "live" : "unknown";
2152
+ }
2153
+ var current;
2154
+ function getRecorder() {
2155
+ return current ??= new Recorder();
2156
+ }
2157
+ function configure(opts = {}) {
2158
+ current?.close();
2159
+ current = new Recorder(opts);
2160
+ return current;
2161
+ }
2162
+ function legalLinks(resp, url) {
2163
+ const ok = (v) => typeof v === "string" && v.length <= 300 && /^https?:\/\/[^\s]+$/.test(v);
2164
+ const base = url.replace(/\/+$/, "");
2165
+ return { terms_url: ok(resp.terms_url) ? resp.terms_url : `${base}/legal/terms`, privacy_url: ok(resp.privacy_url) ? resp.privacy_url : `${base}/legal/privacy` };
2166
+ }
2167
+ function termsNotice(l) {
2168
+ return `By creating a sandbox you agree to the signafAIde Terms (${l.terms_url}) and Privacy notice (${l.privacy_url}).`;
2169
+ }
2170
+
2171
+ // src/record.ts
2172
+ var NotRecorded = class extends SignafaideError {
2173
+ name = "NotRecorded";
2174
+ };
2175
+ var WRAPPED = /* @__PURE__ */ new Map();
2176
+ var LISTED = /* @__PURE__ */ new Map();
2177
+ var EXPLICIT = /* @__PURE__ */ new Map();
2178
+ var toolsVersion = 0;
2179
+ var checked = /* @__PURE__ */ new WeakMap();
2180
+ var toldDeclare = false;
2181
+ function autoDeclareTools(env = process.env) {
2182
+ return !/^(0|false|no|off)$/i.test((env.SIGNAFAIDE_DECLARE_TOOLS ?? "").trim());
2183
+ }
2184
+ function remember(map, agent, name) {
2185
+ const set = map.get(agent ?? "") ?? /* @__PURE__ */ new Set();
2186
+ map.set(agent ?? "", set);
2187
+ if (!set.has(name)) {
2188
+ set.add(name);
2189
+ toolsVersion++;
2190
+ }
2191
+ }
2192
+ function namesFor(map, agent, isDefault) {
2193
+ return /* @__PURE__ */ new Set([...map.get(agent) ?? [], ...isDefault ? map.get("") ?? [] : []]);
2194
+ }
2195
+ function ownTools(rec, agent) {
2196
+ const isDefault = agent === rec.defaultAgent;
2197
+ const explicit = EXPLICIT.get(agent) ?? (isDefault ? EXPLICIT.get("") : void 0);
2198
+ if (explicit) {
2199
+ const wrapped = namesFor(WRAPPED, agent, isDefault);
2200
+ const tools = {};
2201
+ for (const n of explicit.names) tools[n] = wrapped.has(n) && !explicit.unrecorded.has(n);
2202
+ for (const n of explicit.unrecorded) tools[n] = false;
2203
+ return [tools, "declared"];
2204
+ }
2205
+ const listed = namesFor(LISTED, agent, isDefault);
2206
+ if (!listed.size || !autoDeclareTools()) return null;
2207
+ return [Object.fromEntries([...listed].map((n) => [n, true])), "wrap_tools"];
2208
+ }
2209
+ function declareOwn(rec, agentOpt, storeContent) {
2210
+ const agent = agentOpt || rec.defaultAgent;
2211
+ const own = ownTools(rec, agent);
2212
+ if (!own) return "";
2213
+ return rec.declareTools(own[0], { agent, source: "sdk", origin: own[1], storeContent });
2214
+ }
2215
+ function declareBeforeCall(rec, agentOpt) {
2216
+ const agent = agentOpt || rec.defaultAgent;
2217
+ const seen = checked.get(rec) ?? /* @__PURE__ */ new Map();
2218
+ checked.set(rec, seen);
2219
+ if (seen.get(agent) === toolsVersion || noWrites.getStore()) return;
2220
+ try {
2221
+ declareOwn(rec, agentOpt);
2222
+ seen.set(agent, toolsVersion);
2223
+ } catch (e) {
2224
+ seen.set(agent, toolsVersion);
2225
+ if (!toldDeclare) {
2226
+ toldDeclare = true;
2227
+ console.error(`signafaide: could not write the tool declaration for ${agent}: ${e.message}`);
2228
+ }
2229
+ }
2230
+ }
2231
+ function declareTools(names, opts = {}) {
2232
+ const list = [...names];
2233
+ const unrecorded = [...opts.unrecorded ?? []];
2234
+ for (const n of [...list, ...unrecorded]) if (typeof n !== "string" || !n) throw new TypeError("declareTools needs tool names: non-empty strings");
2235
+ EXPLICIT.set(opts.agent ?? "", { names: list, unrecorded: new Set(unrecorded) });
2236
+ toolsVersion++;
2237
+ if (recordingDisabled()) return "";
2238
+ const rec = getRecorder();
2239
+ const hash = declareOwn(rec, opts.agent);
2240
+ const seen = checked.get(rec) ?? /* @__PURE__ */ new Map();
2241
+ checked.set(rec, seen);
2242
+ seen.set(opts.agent || rec.defaultAgent, toolsVersion);
2243
+ return hash;
2244
+ }
2245
+ var Spec = class {
2246
+ constructor(name, opts, listed = false) {
2247
+ this.name = name;
2248
+ this.opts = opts;
2249
+ if (!name) throw new TypeError('record(fn) needs a name: record(fn, { name: "approve_payout" })');
2250
+ remember(WRAPPED, opts.agent, name);
2251
+ if (listed) remember(LISTED, opts.agent, name);
2252
+ }
2253
+ name;
2254
+ opts;
2255
+ decide(input) {
2256
+ const rec = getRecorder();
2257
+ declareBeforeCall(rec, this.opts.agent);
2258
+ const meta = { sdk: `signafaide-js/${VERSION}`, ...this.opts.meta ?? {} };
2259
+ const content = { input: toJsonable(input) ?? null, meta: toJsonable(meta) };
2260
+ return [rec, rec.write("decision", this.name, content, { agent: this.opts.agent, storeContent: this.opts.storeContent })];
2261
+ }
2262
+ outcome(rec, d, content) {
2263
+ try {
2264
+ rec.write("outcome", this.name, content, { agent: d.envelope.agent, ref: recordHash(d), storeContent: this.opts.storeContent });
2265
+ } catch (e) {
2266
+ console.error(`signafaide: could not write the outcome of ${this.name}: ${e.message}`);
2267
+ }
2268
+ }
2269
+ async strictCheck(rec, d) {
2270
+ if (rec.test) return;
2271
+ try {
2272
+ await rec.ensureReceipt(d);
2273
+ } catch (e) {
2274
+ const err = e instanceof SignafaideError ? e : new SignafaideError(e.message);
2275
+ rec.warnStrictRefused(this.name, err);
2276
+ throw new NotRecorded(`${err.message} (strict mode: ${this.name} did not run)`, { code: err.code, status: err.status, docs: err.docs, body: err.body });
2277
+ }
2278
+ }
2279
+ /** Run `fn` between a decision and an outcome. Sync stays sync unless strict. */
2280
+ call(input, fn) {
2281
+ if (recordingDisabled()) return this.opts.strict ? Promise.resolve().then(fn) : fn();
2282
+ const [rec, d] = this.decide(input);
2283
+ if (this.opts.strict) return this.strictCheck(rec, d).then(() => this.run(rec, d, fn));
2284
+ return this.run(rec, d, fn);
2285
+ }
2286
+ run(rec, d, fn) {
2287
+ let out;
2288
+ try {
2289
+ out = fn();
2290
+ } catch (e) {
2291
+ this.outcome(rec, d, errorContent(e));
2292
+ throw e;
2293
+ }
2294
+ if (out && typeof out.then === "function") {
2295
+ return out.then(
2296
+ (v) => {
2297
+ this.outcome(rec, d, { output: toJsonable(v) ?? null });
2298
+ return v;
2299
+ },
2300
+ (e) => {
2301
+ this.outcome(rec, d, errorContent(e));
2302
+ throw e;
2303
+ }
2304
+ );
2305
+ }
2306
+ this.outcome(rec, d, { output: toJsonable(out) ?? null });
2307
+ return out;
2308
+ }
2309
+ };
2310
+ function record(fn, opts = {}) {
2311
+ return recordWith(new Spec(opts.name ?? fn.name, opts), fn);
2312
+ }
2313
+ function recordWith(spec, fn) {
2314
+ const wrapped = function(...args) {
2315
+ return spec.call(args.length === 1 ? args[0] : args, () => fn.apply(this, args));
2316
+ };
2317
+ Object.defineProperty(wrapped, "name", { value: fn.name });
2318
+ return wrapped;
2319
+ }
2320
+ function note(name, content, opts = {}) {
2321
+ if (recordingDisabled()) return "";
2322
+ return recordHash(getRecorder().write("note", name, toJsonable(content), opts));
2323
+ }
2324
+ function wrapTools(tools, opts = {}) {
2325
+ if (Array.isArray(tools)) return tools.map((t) => wrapTool(t, opts));
2326
+ if (tools && typeof tools === "object" && !isTool(tools)) {
2327
+ return Object.fromEntries(Object.entries(tools).map(([k, t]) => [k, wrapTool(t, { ...opts, name: k })]));
2328
+ }
2329
+ return wrapTool(tools, opts);
2330
+ }
2331
+ var isTool = (t) => typeof t === "function" || isOpenAIAgentsTool(t) || isLangChainTool(t) || typeof t?.execute === "function";
2332
+ var isOpenAIAgentsTool = (t) => t?.type === "function" && typeof t.invoke === "function" && typeof t.name === "string";
2333
+ var isLangChainTool = (t) => Array.isArray(t?.lc_namespace) && typeof t._call === "function" && typeof t.invoke === "function";
2334
+ function wrapTool(tool, opts = {}) {
2335
+ const t = tool;
2336
+ const { declareTools: declare = true, ...ropts } = opts;
2337
+ const spec = (name) => new Spec(ropts.name ?? name, ropts, declare);
2338
+ if (isOpenAIAgentsTool(t)) {
2339
+ const s = spec(t.name);
2340
+ ADAPTERS.add("openai-agents");
2341
+ return {
2342
+ ...t,
2343
+ invoke: (runContext, input, details) => Promise.resolve(s.call(parseJson2(input), () => t.invoke(runContext, input, details)))
2344
+ };
2345
+ }
2346
+ if (isLangChainTool(t)) {
2347
+ const s = spec(t.name);
2348
+ ADAPTERS.add("langchain");
2349
+ const proxy = Object.create(t);
2350
+ proxy._call = function(arg, runManager, config) {
2351
+ return s.call(arg, () => t._call.call(proxy, arg, runManager, config));
2352
+ };
2353
+ return proxy;
2354
+ }
2355
+ if (typeof t === "function") return recordWith(spec(t.name), t);
2356
+ if (typeof t?.execute === "function") {
2357
+ const s = spec(t.name ?? "");
2358
+ ADAPTERS.add("vercel-ai");
2359
+ return { ...t, execute: (input, options) => s.call(input, () => t.execute(input, options)) };
2360
+ }
2361
+ throw new TypeError(`wrapTools cannot record ${t === null ? "null" : typeof t}: pass a function, a tool with execute(), an OpenAI Agents tool or a LangChain tool`);
2362
+ }
2363
+ function parseJson2(s) {
2364
+ try {
2365
+ return s ? JSON.parse(s) : {};
2366
+ } catch {
2367
+ return s;
2368
+ }
2369
+ }
2370
+ function errorContent(e) {
2371
+ const err = e;
2372
+ return { error: { type: err?.name ?? typeof e, message: err?.message ?? String(e) } };
2373
+ }
2374
+ function base64(b) {
2375
+ let s = "";
2376
+ for (const x of b) s += String.fromCharCode(x);
2377
+ return btoa(s);
2378
+ }
2379
+ function toJsonable(v, seen = /* @__PURE__ */ new Set()) {
2380
+ if (v === null || v === void 0) return null;
2381
+ switch (typeof v) {
2382
+ case "boolean":
2383
+ case "string":
2384
+ return v;
2385
+ case "number":
2386
+ if (!Number.isFinite(v) || isUnsafeInteger(v)) return String(v);
2387
+ return v;
2388
+ case "bigint":
2389
+ return v >= -BigInt(Number.MAX_SAFE_INTEGER) && v <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(v) : v.toString();
2390
+ case "function":
2391
+ case "symbol":
2392
+ return null;
2393
+ }
2394
+ if (seen.has(v)) return "[Circular]";
2395
+ const inner = new Set(seen).add(v);
2396
+ if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v.toISOString();
2397
+ if (v instanceof Uint8Array) return base64(v);
2398
+ if (v instanceof ArrayBuffer) return base64(new Uint8Array(v));
2399
+ if (v instanceof Error) return { name: v.name, message: v.message };
2400
+ if (Array.isArray(v)) return v.map((x) => toJsonable(x, inner));
2401
+ if (v instanceof Set) return [...v].map((x) => toJsonable(x, inner)).sort((a, b) => canonicalize(a) < canonicalize(b) ? -1 : 1);
2402
+ if (v instanceof Map) return Object.fromEntries([...v].map(([k, x]) => [String(k), toJsonable(x, inner)]));
2403
+ if (typeof v.toJSON === "function") return toJsonable(v.toJSON(), inner);
2404
+ const out = {};
2405
+ for (const [k, x] of Object.entries(v)) {
2406
+ if (x === void 0 || typeof x === "function" || typeof x === "symbol") continue;
2407
+ out[k] = toJsonable(x, inner);
2408
+ }
2409
+ return out;
2410
+ }
2411
+
2412
+ // src/replay.ts
2413
+ import * as fs3 from "fs";
2414
+ var TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
2415
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
2416
+ async function loadServiceKeys(src) {
2417
+ if (typeof src !== "string") return parseServiceKeys(src);
2418
+ return parseServiceKeys(/^https?:\/\//.test(src) ? await fetchJson(src) : JSON.parse(fs3.readFileSync(src, "utf8")));
2419
+ }
2420
+ function timeArg(value, name, end = false) {
2421
+ if (!value) return void 0;
2422
+ if (DATE_RE.test(value)) {
2423
+ const d = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
2424
+ if (Number.isNaN(d.getTime())) throw new SyntaxError(`${name} must be YYYY-MM-DD or a time like 2026-11-13T10:00:00.000Z`);
2425
+ if (end) d.setUTCDate(d.getUTCDate() + 1);
2426
+ return d.toISOString();
2427
+ }
2428
+ if (!TIME_RE.test(value)) throw new SyntaxError(`${name} must be YYYY-MM-DD or a time like 2026-11-13T10:00:00.000Z`);
2429
+ return value;
2430
+ }
2431
+ function agentChain(rec, agent) {
2432
+ const current2 = `${rec.state.bucket(rec.apiKey)}/${agent}`;
2433
+ if (fs3.existsSync(rec.state.chainPath(current2))) return current2;
2434
+ const found = rec.state.chains().filter((c) => c.split("/").pop() === agent);
2435
+ if (found.length === 1) return found[0];
2436
+ if (!found.length) throw new StateError(`no local chain for agent "${agent}" in ${rec.state.home}; agents here: ${rec.state.agents().join(", ") || "none"}`);
2437
+ throw new StateError(`agent "${agent}" has several chains (${found.join(", ")}); pass chain (--chain) to pick one`);
2438
+ }
2439
+ async function verifyChain(rec, chain, keys2) {
2440
+ const st = rec.state;
2441
+ const records = st.readAll(chain);
2442
+ let serviceKeys;
2443
+ let note2;
2444
+ if (keys2 !== void 0) {
2445
+ serviceKeys = await loadServiceKeys(keys2);
2446
+ note2 = `service keys: ${typeof keys2 === "string" ? keys2 : "given"}`;
2447
+ } else if (!records.some((r) => r.receipt)) {
2448
+ serviceKeys = [];
2449
+ note2 = "service keys: none needed (nothing co-signed yet)";
2450
+ } else {
2451
+ try {
2452
+ serviceKeys = parseServiceKeys(await rec.client().request("GET", "/.well-known/signafaide-keys.json", { auth: false }));
2453
+ note2 = `service keys: ${rec.url}/.well-known/signafaide-keys.json`;
2454
+ } catch (e) {
2455
+ if (records.some((r) => r.receipt)) throw new KeysRequired(`could not load service keys from ${rec.url} (${e.message}); pass keys (--keys <file|url>)`);
2456
+ serviceKeys = [];
2457
+ note2 = "service keys: none needed (nothing co-signed yet)";
2458
+ }
2459
+ }
2460
+ const agentKeys = st.agentPublicKeys().map((k) => ({ agent: k.agent, key_id: k.key_id, public_key: k.public_key, valid_from: null, valid_to: k.retired }));
2461
+ const heads = Object.fromEntries(Object.entries(st.chainHeads()).filter(([c]) => c === chain));
2462
+ const base = rec.state.chainBase(chain);
2463
+ const report = verifyLocalState({ records, chainIds: records.map(() => chain), agentKeys, heads, bases: base ? { [chain]: base } : {} }, serviceKeys, await loadDrandVerifier());
2464
+ report.notes.unshift(`chain: ${chain}`, note2);
2465
+ return report;
2466
+ }
2467
+ var Entry = class {
2468
+ constructor(record2, hash) {
2469
+ this.record = record2;
2470
+ this.hash = hash;
2471
+ }
2472
+ record;
2473
+ hash;
2474
+ problems = [];
2475
+ /** The outcome of a decision, or the decision of an outcome. */
2476
+ pair = null;
2477
+ get envelope() {
2478
+ return this.record.envelope;
2479
+ }
2480
+ get content() {
2481
+ const c = this.record.content;
2482
+ return c && typeof c === "object" && !Array.isArray(c) ? c : null;
2483
+ }
2484
+ get receipt() {
2485
+ return this.record.receipt ?? null;
2486
+ }
2487
+ /** Why the witness can never take this record (the chain is BLOCKED from here), or null. */
2488
+ blocked = null;
2489
+ /** `verified`, `signed` (not co-signed by the service yet), `BLOCKED` (can never be co-signed as it is) or `FAIL`. */
2490
+ get status() {
2491
+ return this.problems.length ? "FAIL" : this.receipt ? "verified" : this.blocked ? "BLOCKED" : "signed";
2492
+ }
2493
+ toJSON() {
2494
+ const e = this.envelope;
2495
+ const b = this.receipt?.batch ?? {};
2496
+ return {
2497
+ seq: e.seq,
2498
+ kind: e.kind,
2499
+ name: e.name,
2500
+ time: e.time,
2501
+ record_hash: this.hash,
2502
+ ref: e.ref,
2503
+ content_hash: e.content_hash,
2504
+ content: this.content,
2505
+ status: this.status,
2506
+ problems: this.problems,
2507
+ ...this.blocked ? { blocked: this.blocked } : {},
2508
+ batch_seq: b.seq ?? null,
2509
+ server_time: b.server_time ?? null,
2510
+ drand_round: b.drand?.round ?? null,
2511
+ pair: this.pair?.hash ?? null
2512
+ };
2513
+ }
2514
+ };
2515
+ var esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
2516
+ var Replay = class {
2517
+ constructor(agent, chain, entries, report, since, until, total) {
2518
+ this.agent = agent;
2519
+ this.chain = chain;
2520
+ this.entries = entries;
2521
+ this.report = report;
2522
+ this.since = since;
2523
+ this.until = until;
2524
+ this.total = total;
2525
+ }
2526
+ agent;
2527
+ chain;
2528
+ entries;
2529
+ report;
2530
+ since;
2531
+ until;
2532
+ total;
2533
+ /** Why the chain cannot ship from some record on (`signafaide repair` fixes it), or null. */
2534
+ blocked = null;
2535
+ get ok() {
2536
+ return this.report.ok && !this.blocked;
2537
+ }
2538
+ /** PASS, FAIL or BLOCKED (a chain the witness refuses never passes). */
2539
+ get result() {
2540
+ return this.report.result === "PASS" && this.blocked ? "BLOCKED" : this.report.result;
2541
+ }
2542
+ toJSON() {
2543
+ return {
2544
+ agent: this.agent,
2545
+ chain: this.chain,
2546
+ since: this.since,
2547
+ until: this.until,
2548
+ total: this.total,
2549
+ shown: this.entries.length,
2550
+ result: this.result,
2551
+ ...this.blocked ? { blocked: this.blocked } : {},
2552
+ verification: this.report.toJSON(),
2553
+ entries: this.entries.map((e) => e.toJSON())
2554
+ };
2555
+ }
2556
+ lines() {
2557
+ const out = [`signafAIde replay: ${this.agent} (${this.chain})`, `window: ${this.since ?? "start"} to ${this.until ?? "now"}; ${this.entries.length} of ${plural(this.total, "record")} shown`];
2558
+ out.push(`verification: ${this.report.resultLine}` + (this.report.ok ? "" : ` (first failed check: ${this.report.firstFailed()})`));
2559
+ if (this.blocked) out.push(`BLOCKED from seq ${this.blocked.seq}: ${this.blocked.reason}. Next: ${this.blocked.next}`);
2560
+ out.push(...this.report.notes.map((n) => ` ${n}`), "");
2561
+ for (const e of this.entries) {
2562
+ const env = e.envelope;
2563
+ const link = e.pair ? env.kind === "decision" ? ` -> outcome #${e.pair.envelope.seq}` : ` <- decision #${e.pair.envelope.seq}` : "";
2564
+ const rc = e.receipt;
2565
+ const where = rc ? `batch ${rc.batch.seq}` + (rc.batch.drand ? `, drand ${rc.batch.drand.round}` : "") : e.blocked ? "the witness cannot take it" : "not co-signed yet";
2566
+ out.push(`#${String(env.seq).padEnd(5)} ${env.time} ${env.kind.padEnd(8)} ${env.name}${link}`);
2567
+ out.push(` ${e.hash.slice(0, 16)} ${e.status.toUpperCase()} (${where})`);
2568
+ for (const p of e.problems) out.push(` ! ${p}`);
2569
+ for (const [k, v] of Object.entries(e.content ?? {})) {
2570
+ let text = JSON.stringify(v);
2571
+ if (text.length > 100) text = JSON.stringify(v, null, 2).split("\n").map((l, i) => i ? " " + l : l).join("\n");
2572
+ out.push(` ${k.padEnd(7)} ${text}`);
2573
+ }
2574
+ if (!e.content) out.push(" (no local content for this record)");
2575
+ out.push("");
2576
+ }
2577
+ const failed = this.entries.filter((e) => e.problems.length).length;
2578
+ const signed = this.entries.filter((e) => !e.receipt && !e.problems.length).length;
2579
+ const blockedN = this.entries.filter((e) => e.status === "BLOCKED").length;
2580
+ const what = failed ? `${failed} failed` : signed ? `each checked against its content_hash and signature; ${signed} not co-signed yet${blockedN ? ` (${blockedN} BLOCKED)` : ""}, so their receipts could not be checked` : "each checked against its content_hash, its signature and its receipt";
2581
+ out.push(`${this.result}: ${plural(this.entries.length, "record")} shown, ${what}`);
2582
+ return out;
2583
+ }
2584
+ toString() {
2585
+ return this.lines().join("\n");
2586
+ }
2587
+ /** One self-contained page: inline CSS, no scripts, no external assets. */
2588
+ toHtml() {
2589
+ const rows = this.entries.map((e) => {
2590
+ const env = e.envelope;
2591
+ const rc = e.receipt;
2592
+ const link = e.pair ? `<span class="muted">${env.kind === "decision" ? "outcome" : "decision"} <a href="#r${e.pair.envelope.seq}">#${e.pair.envelope.seq}</a></span>` : "";
2593
+ const cls = { verified: "ok", signed: "warn", BLOCKED: "bad", FAIL: "bad" }[e.status];
2594
+ const body = Object.entries(e.content ?? {}).map(([k, v]) => `<div class="kv"><span>${esc(k)}</span><pre>${esc(JSON.stringify(v, null, 2))}</pre></div>`).join("") || '<p class="muted">No local content for this record.</p>';
2595
+ const probs = e.problems.map((p) => `<li>${esc(p)}</li>`).join("");
2596
+ const receipt = rc ? `batch ${rc.batch.seq} \xB7 ${esc(rc.batch.server_time)}` + (rc.batch.drand ? ` \xB7 drand ${rc.batch.drand.round}` : "") : e.blocked ? `BLOCKED: ${esc(e.blocked)}` : "not co-signed by the service yet";
2597
+ return `<article id="r${env.seq}" class="${esc(env.kind)}"><header><span class="seq">#${env.seq}</span><span class="kind">${esc(env.kind)}</span><b>${esc(env.name)}</b>${link}<span class="st ${cls}">${e.status}</span></header><div class="meta"><code title="record_hash">${e.hash}</code> \xB7 ${esc(env.time)} \xB7 ${receipt}</div>` + (probs ? `<ul class="probs">${probs}</ul>` : "") + body + "</article>";
2598
+ });
2599
+ const label = (c) => c.status === "fail" ? "FAIL" : c.status === "not_checked" ? "NOT CHECKED" : c.status === "pending" ? "PENDING" : (c.value ?? "pass").toUpperCase();
2600
+ const checks = this.report.checks.map((c) => `<tr><td>${c.n}</td><td>${esc(c.title)}</td><td><span class="st ${c.status === "pass" ? "ok" : c.status === "fail" ? "bad" : ""}">${label(c)}</span></td><td>${esc(c.detail)}</td></tr>`).join("");
2601
+ const notes = this.report.notes.map((n) => `<li>${esc(n)}</li>`).join("");
2602
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
2603
+ <title>Replay: ${esc(this.agent)}</title><style>${CSS}</style></head><body><main>
2604
+ <p class="muted small">signafAIde replay</p><h1>${esc(this.agent)}</h1>
2605
+ <p class="muted">${esc(this.chain)} \xB7 ${esc(this.since ?? "start")} to ${esc(this.until ?? "now")} \xB7 ${this.entries.length} of ${plural(this.total, "record")}</p>
2606
+ <p class="result">Verification: <span class="st ${this.ok ? "ok" : "bad"}">${this.result}</span> <span class="muted small">${esc(this.report.resultLine)}</span></p>
2607
+ ${this.blocked ? `<p class="probs">BLOCKED from seq ${this.blocked.seq}: ${esc(this.blocked.reason)}. Next: ${esc(this.blocked.next)}</p>` : ""}
2608
+ <details><summary>Checks</summary><ul class="small muted">${notes}</ul><table>${checks}</table></details>
2609
+ ${rows.join("")}
2610
+ <footer>Each record was checked against its content_hash, its signature and the service's receipt. Generated by signafAIde from the local chain.</footer>
2611
+ </main></body></html>`;
2612
+ }
2613
+ };
2614
+ var CSS = `
2615
+ *{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#1a1d23;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
2616
+ main{max-width:960px;margin:0 auto;padding:32px 24px 64px}h1{margin:0;font-size:22px}a{color:#2456c4}
2617
+ .muted{color:#5d6472}.small{font-size:12px}.result{font-weight:600}
2618
+ table{border-collapse:collapse;width:100%;font-size:12.5px;margin:8px 0}td{padding:4px 8px;border-bottom:1px solid #e3e6ec;vertical-align:top}
2619
+ article{background:#fff;border:1px solid #e3e6ec;border-radius:8px;padding:12px 14px;margin:10px 0}
2620
+ article.outcome{margin-left:28px}article header{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
2621
+ .seq{color:#5d6472;font-variant-numeric:tabular-nums}.kind{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#5d6472}
2622
+ .meta{font-size:12px;color:#5d6472;margin:4px 0 8px;word-break:break-all}code,pre{font:12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
2623
+ pre{margin:2px 0 6px;padding:8px 10px;background:#f6f7f9;border-radius:6px;white-space:pre-wrap;word-break:break-word}
2624
+ .kv>span{font-size:11.5px;font-weight:600;color:#4a5160}
2625
+ .st{margin-left:auto;padding:1px 8px;border-radius:4px;font-size:11.5px;font-weight:700;background:#eceef2;color:#4a5160}
2626
+ .st.ok{background:#e3f4ea;color:#1d6b3f}.st.warn{background:#fdf2dc;color:#7a5200}.st.bad{background:#fbe5e5;color:#9b1c1c}
2627
+ .probs{color:#9b1c1c;font-size:12.5px;margin:4px 0}footer{margin-top:24px;font-size:12px;color:#5d6472}
2628
+ @media print{body{background:#fff}article{break-inside:avoid}}
2629
+ `;
2630
+ async function replay(opts = {}) {
2631
+ const rec = getRecorder();
2632
+ const agent = opts.agent ?? rec.onlyAgent() ?? rec.defaultAgent;
2633
+ const chain = opts.chain ?? agentChain(rec, agent);
2634
+ const lo = timeArg(opts.since, "since");
2635
+ const hi = timeArg(opts.until, "until", true);
2636
+ const report = await verifyChain(rec, chain, opts.keys);
2637
+ let entries = rec.state.readAll(chain).map((r2) => new Entry(r2, hashJson(r2.envelope)));
2638
+ const byLabel = /* @__PURE__ */ new Map();
2639
+ for (const c of report.checks) for (const f of c.failures) byLabel.set(f.item, [...byLabel.get(f.item) ?? [], `${c.title}: ${f.message}`]);
2640
+ const byHash = new Map(entries.map((e) => [e.hash, e]));
2641
+ for (const e of entries) {
2642
+ const env = e.envelope;
2643
+ const rc = e.receipt;
2644
+ e.problems = [...byLabel.get(`${chain}#${env.seq}`) ?? []];
2645
+ if (rc) e.problems.push(...byLabel.get(`batch ${rc.batch.seq}`) ?? [], ...byLabel.get(`head ${rc.head.agent}#${rc.head.seq}`) ?? []);
2646
+ const d = env.kind === "outcome" && env.ref ? byHash.get(env.ref) : void 0;
2647
+ if (d) [e.pair, d.pair] = [d, e];
2648
+ }
2649
+ const total = entries.length;
2650
+ if (opts.record) {
2651
+ const r2 = opts.record.toLowerCase();
2652
+ if (r2.length < 8) throw new SyntaxError("record must be a record hash or a prefix of at least 8 characters");
2653
+ const hit = entries.filter((e) => e.hash.startsWith(r2));
2654
+ if (hit.length !== 1) throw new StateError(`${hit.length ? hit.length : "no"} records match ${opts.record} in ${chain}`);
2655
+ entries = [hit[0], hit[0].pair].filter((x) => !!x).sort((a, b2) => a.envelope.seq - b2.envelope.seq);
2656
+ }
2657
+ const b = rec.diagnose().find((x) => x.chain === chain);
2658
+ if (b) {
2659
+ for (const e of entries) if (!e.receipt && e.envelope.seq >= b.seq) e.blocked = b.reason;
2660
+ }
2661
+ entries = entries.filter((e) => (!lo || e.envelope.time >= lo) && (!hi || e.envelope.time < hi));
2662
+ const r = new Replay(agent, chain, entries, report, lo ?? null, hi ?? null, total);
2663
+ if (b) r.blocked = { seq: b.seq, reason: b.reason, next: b.next };
2664
+ return r;
2665
+ }
2666
+
2667
+ // src/experiments.ts
2668
+ var VerificationFailed = class extends Error {
2669
+ name = "VerificationFailed";
2670
+ };
2671
+ function recordedValue(outcomeContent) {
2672
+ return "output" in outcomeContent ? outcomeContent.output : outcomeContent;
2673
+ }
2674
+ var ExperimentReport = class {
2675
+ constructor(agent, chain, name, candidateName, rows, skipped, verification, since = null, until = null, label = null) {
2676
+ this.agent = agent;
2677
+ this.chain = chain;
2678
+ this.name = name;
2679
+ this.candidateName = candidateName;
2680
+ this.rows = rows;
2681
+ this.skipped = skipped;
2682
+ this.verification = verification;
2683
+ this.since = since;
2684
+ this.until = until;
2685
+ this.label = label;
2686
+ }
2687
+ agent;
2688
+ chain;
2689
+ name;
2690
+ candidateName;
2691
+ rows;
2692
+ skipped;
2693
+ verification;
2694
+ since;
2695
+ until;
2696
+ label;
2697
+ uploaded = null;
2698
+ get n() {
2699
+ return this.rows.length;
2700
+ }
2701
+ get agreed() {
2702
+ return this.rows.filter((r) => r.result === "agreed").length;
2703
+ }
2704
+ get changed() {
2705
+ return this.rows.filter((r) => r.result === "changed").length;
2706
+ }
2707
+ get errors() {
2708
+ return this.rows.filter((r) => r.result === "error").length;
2709
+ }
2710
+ get agreement() {
2711
+ return this.n ? this.agreed / this.n : null;
2712
+ }
2713
+ toJSON() {
2714
+ return { agent: this.agent, chain: this.chain, name: this.name, candidate: this.candidateName, since: this.since, until: this.until, n: this.n, agreed: this.agreed, changed: this.changed, errors: this.errors, skipped: this.skipped, rows: this.rows };
2715
+ }
2716
+ toString() {
2717
+ const pct = this.n ? ` (${(this.agreed / this.n * 100).toFixed(1)}% agreement)` : "";
2718
+ const out = [`compare ${this.candidateName} on ${this.agent}${this.name ? ` / ${this.name}` : ""} (${this.chain})`, `n ${this.n}: agreed ${this.agreed}, changed ${this.changed}, errors ${this.errors}${pct}`];
2719
+ if (this.skipped) out.push(`skipped ${plural(this.skipped, "decision")} without a co-signed outcome yet`);
2720
+ const off = this.rows.filter((r) => r.result !== "agreed");
2721
+ for (const r of off.slice(0, 20)) {
2722
+ const what = r.result === "error" ? `error ${r.error}` : `recorded ${canonicalize(r.recorded).slice(0, 80)} candidate ${canonicalize(r.candidate).slice(0, 80)}`;
2723
+ out.push(` ${r.result.padEnd(7)} ${r.record_hash.slice(0, 16)} ${r.time} ${what}`);
2724
+ }
2725
+ if (off.length > 20) out.push(` ... and ${off.length - 20} more`);
2726
+ if (this.uploaded) out.push(`uploaded: ${this.uploaded.url}`);
2727
+ return out.join("\n");
2728
+ }
2729
+ /**
2730
+ * The `POST /v1/experiments` body. Hash-only unless `values`: record hashes, the result and
2731
+ * the hash of each candidate output; with `values`, the recorded and candidate values too
2732
+ * (accepted only for decisions whose content is stored with the service).
2733
+ */
2734
+ payload(values = false) {
2735
+ const rows = this.rows.map((r) => ({
2736
+ record_hash: r.record_hash,
2737
+ outcome_hash: r.outcome_hash,
2738
+ result: r.result,
2739
+ candidate_hash: hashJson(r.candidate),
2740
+ ...values ? { recorded: r.recorded, candidate: r.candidate } : {}
2741
+ }));
2742
+ const body = { agent: this.agent, name: this.name, label: this.label, candidate: this.candidateName, since: this.since, until: this.until, rows };
2743
+ return Object.fromEntries(Object.entries(body).filter(([, v]) => v !== null && v !== void 0));
2744
+ }
2745
+ /** Send the report to the service. `values` defaults to the recorder's `storeContent`. */
2746
+ async upload(values) {
2747
+ const rec = getRecorder();
2748
+ this.uploaded = await rec.client(6e4).request("POST", "/v1/experiments", { body: this.payload(values ?? rec.storeContent) });
2749
+ return this.uploaded;
2750
+ }
2751
+ };
2752
+ async function compare(candidate, opts = {}) {
2753
+ const rec = getRecorder();
2754
+ const agent = opts.agent ?? rec.onlyAgent() ?? rec.defaultAgent;
2755
+ const chain = opts.chain ?? agentChain(rec, agent);
2756
+ const lo = timeArg(opts.since, "since");
2757
+ const hi = timeArg(opts.until, "until", true);
2758
+ const report = await verifyChain(rec, chain, opts.keys);
2759
+ if (!report.ok) throw new VerificationFailed(`the local chain ${chain} does not verify, so it is not compared:
2760
+ ${report}`);
2761
+ const records = rec.state.readChain(chain);
2762
+ const outcomes = new Map(records.filter((r) => r.envelope.kind === "outcome" && r.receipt).map((r) => [r.envelope.ref, r]));
2763
+ let picked = [];
2764
+ let skipped = 0;
2765
+ for (const r of records) {
2766
+ const e = r.envelope;
2767
+ if (e.kind !== "decision" || opts.name && e.name !== opts.name || lo && e.time < lo || hi && e.time >= hi) continue;
2768
+ const h = hashJson(e);
2769
+ const o = outcomes.get(h);
2770
+ const isObj = (c) => !!c && typeof c === "object" && !Array.isArray(c);
2771
+ if (!r.receipt || !o || !isObj(r.content) || !isObj(o.content)) {
2772
+ skipped++;
2773
+ continue;
2774
+ }
2775
+ picked.push([h, r, o]);
2776
+ }
2777
+ if (opts.limit !== void 0) picked = opts.limit > 0 ? picked.slice(-opts.limit) : [];
2778
+ const equal = opts.same ?? ((a, b) => canonicalize(a) === canonicalize(b));
2779
+ const fname = candidate.name || "candidate";
2780
+ const rows = await noWrites.run(`compare candidate ${fname}`, async () => {
2781
+ const out = [];
2782
+ for (const [h, d, o] of picked) {
2783
+ const recorded = recordedValue(o.content);
2784
+ const row = { record_hash: h, outcome_hash: hashJson(o.envelope), time: d.envelope.time, name: d.envelope.name, input: d.content.input, recorded, candidate: null, same: null, error: null, result: "error" };
2785
+ try {
2786
+ row.candidate = toJsonable(await candidate(d.content.input)) ?? null;
2787
+ row.same = !!equal(recorded, row.candidate);
2788
+ row.result = row.same ? "agreed" : "changed";
2789
+ } catch (e) {
2790
+ const err = e;
2791
+ row.candidate = { error: { type: err?.name ?? typeof e, message: err?.message ?? String(e) } };
2792
+ row.error = `${err?.name ?? "Error"}: ${err?.message ?? String(e)}`;
2793
+ }
2794
+ out.push(row);
2795
+ }
2796
+ return out;
2797
+ });
2798
+ const result = new ExperimentReport(agent, chain, opts.name ?? null, fname, rows, skipped, report, lo ?? null, hi ?? null, opts.label ?? null);
2799
+ if (opts.upload) await result.upload();
2800
+ return result;
2801
+ }
2802
+
2803
+ // src/index.ts
2804
+ installNodeCrypto();
2805
+ async function init(opts = {}) {
2806
+ const rec = configure(opts);
2807
+ const key = rec.apiKey;
2808
+ if (!key) return rec;
2809
+ try {
2810
+ await rec.resolveProject(key);
2811
+ } catch (e) {
2812
+ if (!(e instanceof Error)) throw e;
2813
+ if (!opts.quiet) console.error(`signafaide: ${e.message} Records are kept locally until then.`);
2814
+ }
2815
+ return rec;
2816
+ }
2817
+ function flush(timeoutMs) {
2818
+ return getRecorder().flush(timeoutMs);
2819
+ }
2820
+ function stop(timeoutMs) {
2821
+ return getRecorder().stop(timeoutMs);
2822
+ }
2823
+ function status() {
2824
+ return getRecorder().status();
2825
+ }
2826
+ function chainBases(st, chainIds) {
2827
+ const out = {};
2828
+ for (const c of new Set(chainIds)) {
2829
+ const b = st.chainBase(c);
2830
+ if (b) out[c] = b;
2831
+ }
2832
+ return out;
2833
+ }
2834
+ function checkHeld(rec) {
2835
+ const keys2 = new Map(rec.state.agentPublicKeys().map((k) => [k.key_id, k]));
2836
+ const failures = [];
2837
+ let records = 0;
2838
+ const segs = rec.state.heldSegments();
2839
+ for (const [chain, file, recs] of segs) {
2840
+ let prev = null;
2841
+ for (const r of recs) {
2842
+ records++;
2843
+ const item = `${chain}#${r.envelope.seq} (held, ${path3.basename(file)})`;
2844
+ const k = keys2.get(r.key_id);
2845
+ if (!k) failures.push({ check: "record_sig", item, message: `no key ${r.key_id} for agent ${r.envelope.agent} on this machine` });
2846
+ else if (!verifySig(k.public_key, r.draft ? "draft" : "record", recordHash(r), r.sig)) failures.push({ check: "record_sig", item, message: "signature does not verify" });
2847
+ if (prev && (r.envelope.seq !== prev.envelope.seq + 1 || r.envelope.prev !== recordHash(prev))) failures.push({ check: "chain", item, message: "does not follow the held record before it" });
2848
+ try {
2849
+ const h = checkContent(r.content, r.salt, r.envelope.content_hash);
2850
+ if (h) failures.push({ check: "content", item, message: `content hashes to ${h}, envelope says ${r.envelope.content_hash}` });
2851
+ } catch (e) {
2852
+ failures.push({ check: "content", item, message: e.message });
2853
+ }
2854
+ prev = r;
2855
+ }
2856
+ }
2857
+ return { records, segments: segs.length, failures };
2858
+ }
2859
+ async function readInput(src, flag) {
2860
+ if (typeof src !== "string") return src;
2861
+ if (/^https?:\/\//.test(src)) {
2862
+ if (src.startsWith("http://")) {
2863
+ if (!isLoopbackUrl(src)) {
2864
+ throw new Error(`${flag} ${src} uses plain http://, which anyone on the network path can alter; use https:// (plain http is accepted only from this machine, e.g. http://127.0.0.1)`);
2865
+ }
2866
+ console.error(`WARNING: ${flag} ${src} uses plain http:// on this machine; the keys do not count as independent`);
2867
+ }
2868
+ return parseJson(JSON.stringify(await (await import("./http-BZI6VZM3.js")).fetchJson(src)));
2869
+ }
2870
+ return parseJson(fs4.readFileSync(src, "utf8"));
2871
+ }
2872
+ var plainHttp = (src) => typeof src === "string" && src.startsWith("http://");
2873
+ function notYet(unsent, total, hasKey) {
2874
+ if (!unsent) return [];
2875
+ const what = unsent === total ? `all ${total}` : `${unsent} of ${total}`;
2876
+ return [
2877
+ `checked here: signatures, chain links and content hashes of ${what} record${total === 1 ? "" : "s"}, using this machine's keys`,
2878
+ hasKey ? `co-signature, public timestamp: not yet for ${what} (\`signafaide flush\` sends them now, or says why it cannot)` : "co-signature, public timestamp: not yet. Run `signafaide init` to send hashes, or self-host (SIGNAFAIDE_URL)"
2879
+ ];
2880
+ }
2881
+ function loadContent(src) {
2882
+ const out = /* @__PURE__ */ new Map();
2883
+ const walk = (p) => {
2884
+ if (fs4.statSync(p).isDirectory()) for (const f of fs4.readdirSync(p).sort()) walk(path3.join(p, f));
2885
+ else if (p === src || p.endsWith(".jsonl")) contentFromText(fs4.readFileSync(p, "utf8"), out);
2886
+ };
2887
+ walk(src);
2888
+ return out;
2889
+ }
2890
+ async function verify2(pack, opts = {}) {
2891
+ const rec = getRecorder();
2892
+ const notes = [];
2893
+ let keys2 = null;
2894
+ let keysPlain = false;
2895
+ if (opts.keys !== void 0) {
2896
+ keys2 = parseServiceKeys(await readInput(opts.keys, "--keys"));
2897
+ keysPlain = plainHttp(opts.keys);
2898
+ notes.push(`service keys: ${typeof opts.keys === "string" ? opts.keys : "given"}`);
2899
+ } else if (pack == null && !Object.keys(rec.state.chainHeads()).length) {
2900
+ keys2 = [];
2901
+ } else if (pack == null || !opts.trustEmbeddedKeys) {
2902
+ const url = `${rec.url}/.well-known/signafaide-keys.json`;
2903
+ if (url.startsWith("http://") && !isLoopbackUrl(url)) {
2904
+ throw new KeysRequired(`will not fetch service keys over plain http from ${url}: anyone on the network path can alter them; pass --keys <file|https url>`);
2905
+ }
2906
+ keysPlain = url.startsWith("http://");
2907
+ try {
2908
+ keys2 = parseServiceKeys(await rec.client().request("GET", "/.well-known/signafaide-keys.json", { auth: false }));
2909
+ notes.push(`service keys: ${url}${keysPlain ? " (plain http on this machine: not independent)" : ""}`);
2910
+ } catch (e) {
2911
+ if (pack != null || Object.keys(rec.state.chainHeads()).length) {
2912
+ throw new KeysRequired(`could not load service keys from ${url} (${e.message}); pass --keys <file|url>`);
2913
+ }
2914
+ keys2 = [];
2915
+ }
2916
+ }
2917
+ const drand = await loadDrandVerifier();
2918
+ let report;
2919
+ if (pack == null) {
2920
+ const st = rec.state;
2921
+ const agentKeys = st.agentPublicKeys().map((k) => ({ agent: k.agent, key_id: k.key_id, public_key: k.public_key, valid_from: null, valid_to: k.retired }));
2922
+ const records = [];
2923
+ const chainIds = [];
2924
+ for (const chain of st.chains()) {
2925
+ for (const r of st.readAll(chain)) {
2926
+ records.push(r);
2927
+ chainIds.push(chain);
2928
+ }
2929
+ }
2930
+ report = verifyLocalState({ records, chainIds, agentKeys, heads: st.chainHeads(), bases: chainBases(st, chainIds) }, keys2 ?? [], drand, keysPlain);
2931
+ notes.unshift(`local state: ${st.home}`);
2932
+ const unsent = records.filter((r) => !r.receipt).length;
2933
+ report.after.push(...notYet(unsent, records.length, !!rec.apiKey));
2934
+ const held = checkHeld(rec);
2935
+ if (held.segments) {
2936
+ for (const f of held.failures) {
2937
+ const c = report.check(f.check);
2938
+ c.failures.push({ item: f.item, message: f.message });
2939
+ c.status = "fail";
2940
+ }
2941
+ report.after.push(`held aside by \`signafaide repair\`: ${plural(held.records, "record")} in ${plural(held.segments, "segment")} (signatures, links and content hashes checked here; not co-signed)`);
2942
+ }
2943
+ for (const b of rec.diagnose()) report.blocked.push(`${b.chain}: ${b.reason}; ${plural(b.records, "record")} cannot ship. Next: ${b.next}`);
2944
+ if (opts.failOnPendingSend) report.notSent = unsent;
2945
+ } else {
2946
+ const p = typeof pack === "string" ? parseJson(fs4.readFileSync(pack, "utf8")) : pack;
2947
+ const chainDir = path3.join(rec.state.home, "chain");
2948
+ const content = opts.content ?? (fs4.existsSync(chainDir) ? chainDir : void 0);
2949
+ let agentKeys = null;
2950
+ if (opts.agentKeys !== void 0) {
2951
+ agentKeys = parseAgentKeys(await readInput(opts.agentKeys, "--agent-keys"));
2952
+ notes.push(`agent keys: ${typeof opts.agentKeys === "string" ? opts.agentKeys : "given"}`);
2953
+ } else if (!opts.trustEmbeddedAgentKeys) {
2954
+ const own = rec.state.agentPublicKeys().map((k) => ({ agent: k.agent, key_id: k.key_id, public_key: k.public_key, valid_from: k.created, valid_to: k.retired }));
2955
+ if (own.length) {
2956
+ agentKeys = own;
2957
+ notes.push(`agent keys: this project's own (${path3.join(rec.state.home, "agents")})`);
2958
+ }
2959
+ }
2960
+ let heads = opts.head !== void 0 ? parseHeadFile(await readInput(opts.head, "--head")) : null;
2961
+ if (opts.head === void 0) {
2962
+ const own = parseHeadFile(rec.state.heads());
2963
+ if (own.length) {
2964
+ heads = own;
2965
+ notes.push(`head: this project's own (${path3.join(rec.state.home, "head.json")})`);
2966
+ }
2967
+ }
2968
+ const heights = timestampHeights(p);
2969
+ const timestamps = opts.offline || !heights.length ? null : await lookupTimestamps(opts.timestampCheck ?? DEFAULT_TIMESTAMP_URL, heights);
2970
+ report = verifyPack(p, keys2, {
2971
+ trustEmbeddedKeys: opts.trustEmbeddedKeys,
2972
+ agentKeys,
2973
+ trustEmbeddedAgentKeys: opts.trustEmbeddedAgentKeys,
2974
+ heads,
2975
+ timestamps,
2976
+ contents: content ? loadContent(content) : void 0,
2977
+ drand,
2978
+ serviceKeysPlainHttp: keysPlain,
2979
+ agentKeysPlainHttp: plainHttp(opts.agentKeys)
2980
+ });
2981
+ if (!agentKeys && !opts.trustEmbeddedAgentKeys) notes.push("no agent keys: ask the customer for `signafaide keys export -o agent-keys.json` and pass --agent-keys agent-keys.json");
2982
+ }
2983
+ if (!drand && report.check("drand").checked) notes.push(DRAND_NOT_CHECKED);
2984
+ report.notes.unshift(...notes);
2985
+ return report;
2986
+ }
2987
+
2988
+ export {
2989
+ TOOL_SOURCE_REFRESH_MS,
2990
+ TOOL_SOURCE_MAX_AGE_MS,
2991
+ StateError,
2992
+ findRoot,
2993
+ State,
2994
+ recordingDisabled,
2995
+ testMode,
2996
+ Recorder,
2997
+ envOf,
2998
+ getRecorder,
2999
+ configure,
3000
+ termsNotice,
3001
+ NotRecorded,
3002
+ autoDeclareTools,
3003
+ declareTools,
3004
+ record,
3005
+ note,
3006
+ wrapTools,
3007
+ wrapTool,
3008
+ toJsonable,
3009
+ loadServiceKeys,
3010
+ Entry,
3011
+ Replay,
3012
+ replay,
3013
+ VerificationFailed,
3014
+ recordedValue,
3015
+ ExperimentReport,
3016
+ compare,
3017
+ init,
3018
+ flush,
3019
+ stop,
3020
+ status,
3021
+ checkHeld,
3022
+ notYet,
3023
+ loadContent,
3024
+ verify2 as verify
3025
+ };