@verax-ai/body 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { readFileSync } from "node:fs";
2
3
  import { createServer } from "node:http";
3
4
  import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
4
5
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
6
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
- import { approvalsLogFor, approvePending, explain, LedgerDenyUnrecorded, loadApprovalsFromDir, } from "@verax-ai/proxy";
7
+ import { approvalsLogFor, approvePending, createApprovalBudgetGuard, explain, LedgerDenyUnrecorded, loadApprovalsFromDir, } from "@verax-ai/proxy";
7
8
  import { createVerifier, readBearer, resourceMetadataUrl, wwwAuthenticate } from "./auth.js";
8
9
  import { bumpMetric, bumpUnauthenticated } from "./metrics.js";
9
10
  import { isRevokedJti } from "./revoke.js";
@@ -11,48 +12,102 @@ import { loadOrCreateSigners } from "./keys.js";
11
12
  import { matchingInputs } from "./inputs-read.js";
12
13
  import { readPolicySnapshots } from "./policy-store.js";
13
14
  import { readHeartbeat, readWitnessPulse } from "./health-extras.js";
15
+ import { agentsWindow } from "./agents.js";
14
16
  import { inventoryHealth, readInventoryFile } from "./inventory-file.js";
15
17
  import { createBodyServices, TOOL_NAMES } from "./wiring.js";
16
- const TOOL_META = [
18
+ import { openDownstream, parseDownstreamDocument, } from "./downstream.js";
19
+ // What a brain reads before it calls. Each description says what the tool is
20
+ // for, what it does and does not do, what the gate may answer, and what comes
21
+ // back; each parameter says its format and its bounds. The answers named here
22
+ // are the proxy's: `denied:<reason>:<ref>`, `deferred:approval-required:<ref>`
23
+ // and `allowed:<ref>` (packages/proxy/src/proxy.ts).
24
+ const ID_FORMAT = "1 to 128 characters of letters, digits, '.', '_' or '-', starting with a letter or digit; case-sensitive.";
25
+ const REF_FORMAT = "1 to 64 characters of letters, digits, '.', '_' or '-', starting with a letter or digit.";
26
+ const REF_PARAM = {
27
+ type: "string",
28
+ description: "Optional reference you choose for this call, " +
29
+ REF_FORMAT +
30
+ " Resend the same call with the same _ref after an operator approved it to receive allowed:<ref>; " +
31
+ "a _ref reused for a different call is refused with denied:ref-reuse.",
32
+ };
33
+ export const TOOL_META = [
17
34
  {
18
35
  name: "memory.get",
19
- description: "Read a memory item. Stale items return { stale: true } without the body.",
36
+ description: "Reads one memory item this tenant stored earlier with memory.put, by its id. " +
37
+ "Use it to recall a fact, a setting or a note before acting on it; nothing is written. " +
38
+ "Like every call it passes the policy gate and leaves a signed decision record; an id that belongs to another tenant is answered with a signed deny. " +
39
+ "Returns the stored item as JSON: {id, body, source, validFromMs, validUntilMs, versionHash}. " +
40
+ "Outside the validity window the body is withheld: {stale: true, id, validUntilMs} after it, {notYetValid: true, id, validFromMs} before it. " +
41
+ 'An unknown id answers {error: "not-found", id}.',
20
42
  inputSchema: {
21
43
  type: "object",
22
44
  additionalProperties: false,
23
- properties: { id: { type: "string" } },
45
+ properties: {
46
+ id: { type: "string", description: "The id given to memory.put: " + ID_FORMAT },
47
+ },
24
48
  required: ["id"],
25
49
  },
26
50
  },
27
51
  {
28
52
  name: "memory.put",
29
- description: "Write a memory item. source and validUntilMs are required.",
53
+ description: "Writes one memory item for this tenant, or replaces the item with the same id, in the body's state directory on this machine. " +
54
+ "Use it to keep a fact for a later memory.get together with where it came from and how long it holds, so a stale fact is not served later. " +
55
+ "The call passes the policy gate and is recorded; the record carries the item's versionHash, a SHA-256 over id, body and validity window. " +
56
+ "Returns {ok: true, id, versionHash}. " +
57
+ 'A missing source answers {error: "source-required"}, a missing validUntilMs {error: "validUntilMs-required"}, a malformed id {error: "id-invalid"}.',
30
58
  inputSchema: {
31
59
  type: "object",
32
60
  additionalProperties: false,
33
61
  properties: {
34
- id: { type: "string" },
35
- body: {},
36
- source: { type: "object" },
37
- validFromMs: { type: "number" },
38
- validUntilMs: { type: "number" },
62
+ id: {
63
+ type: "string",
64
+ description: "Identifier to store under and read back with memory.get: " +
65
+ ID_FORMAT +
66
+ " An existing item with this id is replaced.",
67
+ },
68
+ body: {
69
+ description: "The value to keep, as any JSON: object, array, string, number or boolean. Stored as given and returned as given by memory.get.",
70
+ },
71
+ source: {
72
+ type: "object",
73
+ description: 'Where the value came from, as a JSON object of your choosing, for example {"kind": "document", "ref": "invoice-2026-09.pdf"}. Required; stored with the item so a later reader can weigh it.',
74
+ },
75
+ validFromMs: {
76
+ type: "number",
77
+ description: "Optional. Unix time in milliseconds from which the item may be served; before it memory.get answers notYetValid. Omit to serve it at once.",
78
+ },
79
+ validUntilMs: {
80
+ type: "number",
81
+ description: "Required. Unix time in milliseconds after which memory.get answers stale and withholds the body. Pick the moment the fact should no longer be trusted.",
82
+ },
39
83
  },
40
84
  required: ["id", "body", "source", "validUntilMs"],
41
85
  },
42
86
  },
43
87
  {
44
88
  name: "audit.explain",
45
- description: "Explain a decision ref against the ledger.",
89
+ description: "Reads one decision back from the signed ledger by its ref and explains it. " +
90
+ "Use it to check what the body decided about an earlier call and whether the recorded effect matched, before repeating a call or reporting on it; read-only, and the lookup itself is recorded too. " +
91
+ "Returns JSON with record (the signed decision's claims: tool, verdict, policy hash, timestamps), effect (the reconciled effect row), finding (match, mismatch or missing), witnessClass, guarantee, warnings, trustRoot (which key verified the signatures), and for a held call pair with its defer and resolution records. " +
92
+ "A ref that does not exist, or belongs to another tenant, is answered with the same signed deny, so neither case reveals the other.",
46
93
  inputSchema: {
47
94
  type: "object",
48
95
  additionalProperties: false,
49
- properties: { ref: { type: "string" } },
96
+ properties: {
97
+ ref: {
98
+ type: "string",
99
+ description: "The decision reference: the ref returned by an earlier call, also the tail of a denied:… or deferred:… answer; " +
100
+ REF_FORMAT,
101
+ },
102
+ },
50
103
  required: ["ref"],
51
104
  },
52
105
  },
53
106
  {
54
107
  name: "message.read",
55
- description: "Read the local inbox fixture as JSON.",
108
+ description: "Reads this tenant's inbox, the messages placed for it in the body's state directory on this machine, and returns them as a JSON array in arrival order, oldest first. " +
109
+ "Use it to see what has arrived before deciding what to answer. " +
110
+ "Takes no arguments; read-only; the call is recorded like every other. An empty or absent inbox answers [].",
56
111
  inputSchema: {
57
112
  type: "object",
58
113
  additionalProperties: false,
@@ -61,34 +116,63 @@ const TOOL_META = [
61
116
  },
62
117
  {
63
118
  name: "message.send",
64
- description: "Queue a message on the local outbox. Does not open a network.",
119
+ description: "Queues one message in this tenant's outbox on this machine for the delivery step the operator runs; this call opens no network connection and nothing leaves the body from it. " +
120
+ "Use it to hand off a message, not to deliver one. " +
121
+ "Like every call it passes the policy gate and leaves a signed decision record. " +
122
+ "The gate reads the host after the last '@' in to and allows it only when it is on the policy's egress allow-list; otherwise the call is refused with denied:egress-blocked, or denied:egress-host-missing when no host can be read. " +
123
+ "A policy rule in approve mode holds the call for an operator instead and answers deferred:approval-required:<ref>. " +
124
+ "Returns {queued: true, ref}, where ref is the decision reference for audit.explain.",
65
125
  inputSchema: {
66
126
  type: "object",
67
127
  additionalProperties: false,
68
128
  properties: {
69
- to: { type: "string" },
70
- text: { type: "string" },
129
+ to: {
130
+ type: "string",
131
+ description: "Recipient address with a host after the last '@', for example ops@example.com. The host, lower-cased, is matched against the policy's egress list.",
132
+ },
133
+ text: { type: "string", description: "The message body as plain text. Stored as given in the outbox row." },
134
+ _ref: REF_PARAM,
71
135
  },
72
136
  required: ["to", "text"],
73
137
  },
74
138
  },
75
139
  {
76
140
  name: "spend",
77
- description: "Authorizes a payment; does not move money.",
141
+ description: "Asks the body to authorize a payment and records the decision; the body never moves money, so authorized: true is a signed permission for a later payment step, not a transfer. " +
142
+ "Use it before any payment so that amount, currency, payee and reference are checked against the policy: the one currency the policy names, a cap per call, a payee list and a daily limit. " +
143
+ "A call outside those bounds is refused with a signed deny naming the bound: denied:spend-cap, denied:spend-payee, denied:spend-currency or denied:spend-daily. " +
144
+ "A call within them is held for an operator on this machine and answers deferred:approval-required:<ref>; once that ref is approved (verax approve, or the panel), resending the same call with the same _ref answers allowed:<ref>, and the authorization is recorded as {authorized: true, ref, amountMinor, currency, payee, reference}. " +
145
+ "Without a spend rule in the policy every call answers denied:spend-not-wired.",
78
146
  inputSchema: {
79
147
  type: "object",
80
148
  additionalProperties: false,
81
149
  properties: {
82
- amountMinor: { type: "integer" },
83
- currency: { type: "string" },
84
- payee: { type: "string" },
85
- reference: { type: "string" },
150
+ amountMinor: {
151
+ type: "integer",
152
+ description: "Amount in the currency's minor unit as a positive integer: cents, kuruş or pence, so 1250 means 12.50. Compared against the policy's cap per call and daily limit.",
153
+ },
154
+ currency: {
155
+ type: "string",
156
+ description: "ISO 4217 code in upper case, for example USD, EUR or TRY. Must equal the currency the policy's spend rule names.",
157
+ },
158
+ payee: {
159
+ type: "string",
160
+ description: "Who is to be paid, spelled exactly as the policy's payee list spells it (a merchant or account name). A payee off the list is refused.",
161
+ },
162
+ reference: {
163
+ type: "string",
164
+ description: "Your own reference for this payment, such as an invoice or order id. Recorded with the authorization and used by verax reconcile to match the card statement.",
165
+ },
166
+ _ref: REF_PARAM,
86
167
  },
87
168
  required: ["amountMinor", "currency", "payee", "reference"],
88
169
  },
89
170
  },
90
171
  ];
91
172
  const MAX_BODY_BYTES = 1024 * 1024;
173
+ // Unbounded GET /api/ledger stringified ~690 MB at 200k rows and threw Invalid string length (HTTP 500).
174
+ const DEFAULT_LEDGER_LIMIT = 1000;
175
+ const MAX_LEDGER_LIMIT = 5000;
92
176
  const responseSlot = new AsyncLocalStorage();
93
177
  function contentLengthOverLimit(req) {
94
178
  const raw = req.headers["content-length"];
@@ -139,17 +223,94 @@ function send(res, status, body, headers) {
139
223
  });
140
224
  res.end(text);
141
225
  }
226
+ /**
227
+ * Republishes a child's own `tools/list` entry under its prefixed name. The
228
+ * description is the child's; the sentence added here says what changes by
229
+ * going through the body, because the answers a caller gets back
230
+ * (`denied:…`, `deferred:…`) are the gate's, not the child's.
231
+ */
232
+ function downstreamMeta(tool) {
233
+ const own = typeof tool.description === "string" && tool.description !== "" ? `${tool.description} ` : "";
234
+ return {
235
+ name: tool.name,
236
+ description: own +
237
+ "Forwarded by this body to the downstream server that published it: the call passes the same policy gate " +
238
+ "and leaves the same signed decision under this name, so it may be answered with denied:<reason>:<ref> or " +
239
+ "deferred:approval-required:<ref> before the downstream server ever sees it.",
240
+ inputSchema: tool.inputSchema ?? { type: "object" },
241
+ };
242
+ }
243
+ async function closeAll(sessions) {
244
+ for (const session of sessions) {
245
+ await session.close().catch(() => undefined);
246
+ }
247
+ }
248
+ /**
249
+ * What an operator may see about the attached children: the prefix they call
250
+ * by, how the body reaches them, and the names it will accept.
251
+ *
252
+ * Deliberately not the address. A child's URL can carry its token in the path
253
+ * — the live Conarium's does — and a command line names a path on this host.
254
+ * Neither is needed to answer "what is standing behind this gate", so neither
255
+ * is published. The `tests/downstream-visible` guard asserts their absence.
256
+ */
257
+ function downstreamPublic(sessions, specs) {
258
+ return sessions.map((session) => {
259
+ const spec = specs.find((s) => s.prefix === session.prefix);
260
+ return {
261
+ prefix: session.prefix,
262
+ transport: spec?.url !== undefined ? "http" : "stdio",
263
+ tools: session.tools.map((t) => t.name),
264
+ };
265
+ });
266
+ }
267
+ /**
268
+ * Opens every child the document names. One child that refuses to attach
269
+ * closes the ones already open and throws: a half-attached body would serve a
270
+ * tool list its operator never wrote.
271
+ */
272
+ async function attachDownstream(file) {
273
+ if (file === null || file.trim() === "")
274
+ return { sessions: [], specs: [] };
275
+ const specs = parseDownstreamDocument(readFileSync(file, "utf8"));
276
+ const sessions = [];
277
+ for (const spec of specs) {
278
+ try {
279
+ sessions.push(await openDownstream(spec));
280
+ }
281
+ catch (err) {
282
+ await closeAll(sessions);
283
+ const detail = err instanceof Error ? err.message : "attach-failed";
284
+ throw new Error(`downstream-attach-failed:${spec.prefix}:${detail}`);
285
+ }
286
+ }
287
+ return { sessions, specs };
288
+ }
142
289
  export async function listen(config) {
143
290
  const signers = loadOrCreateSigners(config.stateDir);
144
- const services = createBodyServices({
145
- stateDir: config.stateDir,
146
- policyFile: config.policyFile,
147
- recordSigner: signers.recordSigner,
148
- effectSigner: signers.effectSigner,
149
- });
291
+ // The children are attached before the door opens. A named document the body
292
+ // cannot honour stops the start: a body that serves six tools while its
293
+ // operator wrote seven is answering for a gate it does not have.
294
+ const { sessions, specs: downstreamSpecs } = await attachDownstream(config.downstreamFile ?? null);
295
+ const extraTools = sessions.flatMap((session) => session.tools);
296
+ let services;
297
+ try {
298
+ services = createBodyServices({
299
+ stateDir: config.stateDir,
300
+ policyFile: config.policyFile,
301
+ recordSigner: signers.recordSigner,
302
+ effectSigner: signers.effectSigner,
303
+ ...(extraTools.length > 0 ? { extraTools } : {}),
304
+ });
305
+ }
306
+ catch (err) {
307
+ await closeAll(sessions);
308
+ throw err;
309
+ }
310
+ const toolMeta = [...TOOL_META, ...extraTools.map(downstreamMeta)];
150
311
  const verify = createVerifier(config.jwksUrl, config.issuer, config.audience);
151
312
  const attachHandlers = (mcp) => {
152
- mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_META }));
313
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolMeta }));
153
314
  mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
154
315
  const auth = extra?.authInfo;
155
316
  const scopes = new Set(auth?.scopes ?? []);
@@ -209,18 +370,33 @@ export async function listen(config) {
209
370
  send(res, 200, { ok: true });
210
371
  return;
211
372
  }
212
- const decisions = await services.ledger.decisions();
213
- const effects = await services.ledger.effects();
214
- const last = decisions[decisions.length - 1];
373
+ // The ledger counts its own lines as it writes them; asking it is free.
374
+ // Reading both files back to count them was one second per call on a
375
+ // 100k-decision ledger, five times a minute for as long as a panel was open.
376
+ // When the active piece cannot be read back, the fallback below counts
377
+ // the merged files; it cannot tell which rows sit in the active piece,
378
+ // so the piece fields stay out of that degraded answer.
379
+ let counted = services.ledger.counts();
380
+ if (!counted) {
381
+ const decisions = await services.ledger.decisions();
382
+ const effects = await services.ledger.effects();
383
+ const last = decisions[decisions.length - 1];
384
+ counted = {
385
+ decisions: decisions.length,
386
+ effects: effects.length,
387
+ lastDecisionMs: last ? last.claims.timestampMs : null,
388
+ };
389
+ }
215
390
  send(res, 200, {
216
391
  ok: true,
217
- decisions: decisions.length,
218
- effects: effects.length,
219
- lastDecisionMs: last ? last.claims.timestampMs : null,
392
+ ...counted,
220
393
  lock: services.ledger.lockStatus(),
221
394
  heartbeat: readHeartbeat(config.stateDir),
222
395
  witness: readWitnessPulse(config.stateDir),
223
396
  inventory: inventoryHealth(readInventoryFile(config.inventoryFile)),
397
+ // Always an array, empty when nothing is attached: a missing field
398
+ // would read as "this body is too old to tell you".
399
+ downstream: downstreamPublic(sessions, downstreamSpecs),
224
400
  });
225
401
  return;
226
402
  }
@@ -244,7 +420,8 @@ export async function listen(config) {
244
420
  const apiInventory = url.pathname === "/api/inventory";
245
421
  const contest = req.method === "POST" && url.pathname.startsWith("/api/contest/");
246
422
  const apiApprove = req.method === "POST" && url.pathname === "/api/approve";
247
- if (url.pathname !== "/mcp" && !apiLedger && !apiInventory && !contest && !apiApprove) {
423
+ const apiAgents = req.method === "GET" && url.pathname === "/api/agents";
424
+ if (url.pathname !== "/mcp" && !apiLedger && !apiInventory && !contest && !apiApprove && !apiAgents) {
248
425
  send(res, 404, { error: "not-found" });
249
426
  return;
250
427
  }
@@ -303,9 +480,8 @@ export async function listen(config) {
303
480
  send(res, 409, { error: "stale", requestHash: waiting.requestHash });
304
481
  return;
305
482
  }
306
- const decisions = await services.ledger.decisions();
307
- const defer = decisions.find((d) => d.claims.ref === ref && d.claims.decision === "defer");
308
- if (!defer) {
483
+ const defer = services.ledger.lookupByRef(ref);
484
+ if (!defer || defer.decision !== "defer") {
309
485
  send(res, 404, { error: "unknown-ref" });
310
486
  return;
311
487
  }
@@ -313,6 +489,7 @@ export async function listen(config) {
313
489
  // which says nothing about the person holding the phone; the session's
314
490
  // own subject does.
315
491
  const approver = verified.principal.brain;
492
+ const approvals = approvalsLogFor(services.ledger);
316
493
  const outcome = await approvePending({
317
494
  ledger: services.ledger,
318
495
  recordSigner: loadOrCreateSigners(config.stateDir).recordSigner,
@@ -321,18 +498,26 @@ export async function listen(config) {
321
498
  ref,
322
499
  approverId: approver,
323
500
  via: "http",
324
- policyHash: defer.claims.policyHash,
325
- approvals: approvalsLogFor(services.ledger),
501
+ policyHash: defer.policyHash,
502
+ approvals,
503
+ budgetGuard: createApprovalBudgetGuard({
504
+ policy: services.policy,
505
+ approvals,
506
+ now: () => Date.now(),
507
+ }),
326
508
  });
327
509
  if (!outcome.ok) {
328
510
  const code = outcome.reason === "unknown-ref" || outcome.reason === "snapshot-missing" ? 404 : 409;
329
- send(res, code, { error: outcome.reason });
511
+ send(res, code, {
512
+ error: outcome.reason,
513
+ ...(outcome.allowRef ? { allowRef: outcome.allowRef } : {}),
514
+ });
330
515
  return;
331
516
  }
332
517
  send(res, 200, { allowRef: outcome.allowRef, approver });
333
518
  return;
334
519
  }
335
- if (apiLedger || apiInventory || contest) {
520
+ if (apiLedger || apiInventory || contest || apiAgents) {
336
521
  // The audit doors hand out the whole ledger: every tenant's decisions, the
337
522
  // inputs documents that name their principals, and the approval snapshots
338
523
  // that carry spend arguments. `verax:read` is a brain scope, so it cannot be
@@ -349,11 +534,47 @@ export async function listen(config) {
349
534
  send(res, 200, readInventoryFile(config.inventoryFile));
350
535
  return;
351
536
  }
537
+ if (apiAgents) {
538
+ // The last day unless the caller names a window; the roster's
539
+ // agents are on the list whether or not they acted in it.
540
+ const now = Date.now();
541
+ const fromRaw = url.searchParams.get("from");
542
+ const toRaw = url.searchParams.get("to");
543
+ const from = fromRaw === null ? now - 86_400_000 : Number(fromRaw);
544
+ const to = toRaw === null ? now : Number(toRaw);
545
+ if (!Number.isFinite(from) || !Number.isFinite(to)) {
546
+ send(res, 400, { error: "bad-window" });
547
+ return;
548
+ }
549
+ send(res, 200, await agentsWindow({
550
+ ledger: services.ledger,
551
+ stateDir: config.stateDir,
552
+ inventoryFile: config.inventoryFile,
553
+ fromMs: from,
554
+ toMs: to,
555
+ }));
556
+ return;
557
+ }
352
558
  if (apiLedger) {
353
559
  const from = Number(url.searchParams.get("from") ?? "0");
354
560
  const to = Number(url.searchParams.get("to") ?? String(Number.MAX_SAFE_INTEGER));
355
- const decisions = (await services.ledger.decisions()).filter((d) => d.claims.timestampMs >= from && d.claims.timestampMs < to);
356
- const effects = (await services.ledger.effects()).filter((e) => e.row.timestampMs >= from && e.row.timestampMs < to);
561
+ const limitRaw = url.searchParams.get("limit");
562
+ const parsedLimit = limitRaw === null ? undefined : Number(limitRaw);
563
+ if (!Number.isFinite(from) ||
564
+ !Number.isFinite(to) ||
565
+ (parsedLimit !== undefined && (!Number.isFinite(parsedLimit) || parsedLimit < 0))) {
566
+ send(res, 400, { error: "bad-window" });
567
+ return;
568
+ }
569
+ const limit = parsedLimit === undefined ? DEFAULT_LEDGER_LIMIT : Math.min(MAX_LEDGER_LIMIT, Math.floor(parsedLimit));
570
+ // The window is read from the end of the files, so a day costs a
571
+ // day whatever the ledger's age. With a limit, the newest rows of
572
+ // the window come back and `more` says the rest is there to ask for.
573
+ const { rows: decisions, more, piecesTouched } = await services.ledger.decisionsWindow(from, to, limit);
574
+ // Effects belong to the decisions returned. When the limit cut the
575
+ // window, the oldest decision returned is where their window starts.
576
+ const effectsFrom = more && decisions.length > 0 ? decisions[0].claims.timestampMs : from;
577
+ const effects = await services.ledger.effectsWindow(effectsFrom, to);
357
578
  const hashes = [...new Set(decisions.map((d) => d.claims.policyHash))];
358
579
  send(res, 200, {
359
580
  decisions,
@@ -362,6 +583,9 @@ export async function listen(config) {
362
583
  policies: readPolicySnapshots(config.stateDir, hashes),
363
584
  inputs: await matchingInputs(config.stateDir, decisions),
364
585
  approvals: loadApprovalsFromDir(config.stateDir),
586
+ more,
587
+ piecesTouched,
588
+ limit,
365
589
  });
366
590
  return;
367
591
  }
@@ -478,6 +702,7 @@ export async function listen(config) {
478
702
  });
479
703
  server.on("close", () => {
480
704
  services.ledger.close();
705
+ void closeAll(sessions);
481
706
  });
482
707
  return server;
483
708
  }
@@ -1,6 +1,8 @@
1
1
  import { type Principal, type ToolCall, type ToolResult } from "@verax-ai/proxy";
2
2
  /** True when `id` exists under a different tenant. Does not read legacy `memory/`. */
3
3
  export declare function memoryBelongsToOtherTenant(stateDir: string, id: string, selfKey: string): boolean;
4
+ /** True when this tenant already has `id` under its own memory path. */
5
+ export declare function memoryExistsForTenant(stateDir: string, id: string, principal: Principal): boolean;
4
6
  export declare function readMemoryMeta(stateDir: string, id: string, principal?: Principal): Promise<{
5
7
  versionHash: string;
6
8
  validFromMs: number;
@@ -51,6 +51,13 @@ export function memoryBelongsToOtherTenant(stateDir, id, selfKey) {
51
51
  }
52
52
  return false;
53
53
  }
54
+ /** True when this tenant already has `id` under its own memory path. */
55
+ export function memoryExistsForTenant(stateDir, id, principal) {
56
+ const path = resolveMemoryPath(stateDir, id, principal);
57
+ if (path === null)
58
+ return false;
59
+ return existsSync(path);
60
+ }
54
61
  export async function readMemoryMeta(stateDir, id, principal) {
55
62
  if (!principal)
56
63
  return null;
package/dist/wiring.d.ts CHANGED
@@ -1,9 +1,14 @@
1
- import { createProxy, FileLedger, type EffectSigner, type ExplainOpts, type Principal, type RecordSigner, type ToolCall, type ToolResult } from "@verax-ai/proxy";
1
+ import { createProxy, FileLedger, type EffectSigner, type ExplainOpts, type Policy, type Principal, type RecordSigner, type ToolCall, type ToolResult } from "@verax-ai/proxy";
2
2
  export type ToolFn = (call: ToolCall, principal: Principal, ref?: string) => Promise<ToolResult>;
3
3
  export declare const TOOL_NAMES: readonly ["memory.get", "memory.put", "audit.explain", "message.read", "message.send", "spend"];
4
+ export type ExtraTool = {
5
+ name: string;
6
+ fn: ToolFn;
7
+ };
4
8
  export type BodyServices = {
5
9
  proxy: ReturnType<typeof createProxy>;
6
10
  ledger: FileLedger;
11
+ policy: Policy;
7
12
  policyHash: string;
8
13
  policyDocument: unknown;
9
14
  listTools: () => readonly string[];
@@ -11,7 +16,9 @@ export type BodyServices = {
11
16
  };
12
17
  /**
13
18
  * Tool functions live in a Map that is not exported. The only way to
14
- * reach them at runtime is `proxy.call` -> `inner`.
19
+ * reach them at runtime is `proxy.call` -> `inner`. Extra tools (a
20
+ * downstream prefix) enter that Map at construction; they still pass
21
+ * the gate.
15
22
  */
16
23
  export declare function createBodyServices(opts: {
17
24
  stateDir: string;
@@ -20,4 +27,5 @@ export declare function createBodyServices(opts: {
20
27
  effectSigner: EffectSigner;
21
28
  now?: () => number;
22
29
  nonce?: () => string;
30
+ extraTools?: readonly ExtraTool[];
23
31
  }): BodyServices;
package/dist/wiring.js CHANGED
@@ -2,11 +2,11 @@ import { createProxy, FileLedger, loadPolicy, tenantKey, } from "@verax-ai/proxy
2
2
  import { readFileSync } from "node:fs";
3
3
  import { persistPolicySnapshot } from "./policy-store.js";
4
4
  import { inputsPrincipal } from "./inputs-read.js";
5
- import { memoryBelongsToOtherTenant, memoryGet, memoryPut, readMemoryMeta } from "./tools/memory.js";
5
+ import { memoryBelongsToOtherTenant, memoryExistsForTenant, memoryGet, memoryPut, readMemoryMeta, } from "./tools/memory.js";
6
6
  import { auditExplain } from "./tools/audit.js";
7
7
  import { messageRead, messageSend } from "./tools/message.js";
8
8
  import { spendAuthorize } from "./tools/spend.js";
9
- import { requestWitnessSign } from "./witness.js";
9
+ import { requestWitnessCheckpoint, requestWitnessSign } from "./witness.js";
10
10
  export const TOOL_NAMES = [
11
11
  "memory.get",
12
12
  "memory.put",
@@ -17,11 +17,32 @@ export const TOOL_NAMES = [
17
17
  ];
18
18
  /**
19
19
  * Tool functions live in a Map that is not exported. The only way to
20
- * reach them at runtime is `proxy.call` -> `inner`.
20
+ * reach them at runtime is `proxy.call` -> `inner`. Extra tools (a
21
+ * downstream prefix) enter that Map at construction; they still pass
22
+ * the gate.
21
23
  */
22
24
  export function createBodyServices(opts) {
25
+ const extraNames = [];
26
+ const reserved = new Set(TOOL_NAMES);
27
+ for (const tool of opts.extraTools ?? []) {
28
+ if (reserved.has(tool.name) || extraNames.includes(tool.name)) {
29
+ throw new Error(`downstream-name-collision:${tool.name}`);
30
+ }
31
+ // Same shape as extraToolNameOk in downstream.ts (prefix.childName).
32
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,31}\.[A-Za-z][A-Za-z0-9._-]{0,63}$/.test(tool.name)) {
33
+ throw new Error(`downstream-name-invalid:${tool.name}`);
34
+ }
35
+ extraNames.push(tool.name);
36
+ }
23
37
  const ledger = new FileLedger(opts.stateDir);
24
38
  ledger.remoteWitness = (row, resultHash) => requestWitnessSign(opts.stateDir, row, resultHash);
39
+ ledger.onPieceClose = async (window) => {
40
+ await requestWitnessCheckpoint(opts.stateDir, {
41
+ epoch: 0,
42
+ startMs: window.startMs,
43
+ endMs: window.endMs,
44
+ });
45
+ };
25
46
  const policyText = readFileSync(opts.policyFile, "utf8");
26
47
  const policyDocument = JSON.parse(policyText);
27
48
  const policy = loadPolicy(policyText);
@@ -49,6 +70,9 @@ export function createBodyServices(opts) {
49
70
  registry.set("message.read", (call, principal) => messageRead(call, opts.stateDir, principal));
50
71
  registry.set("message.send", (call, principal, ref) => messageSend(call, opts.stateDir, ref ?? "", principal));
51
72
  registry.set("spend", (call, _principal, ref) => spendAuthorize(call, ref ?? ""));
73
+ for (const tool of opts.extraTools ?? []) {
74
+ registry.set(tool.name, tool.fn);
75
+ }
52
76
  const inner = async (call, principal, ref) => {
53
77
  const fn = registry.get(call.name);
54
78
  if (!fn) {
@@ -73,6 +97,8 @@ export function createBodyServices(opts) {
73
97
  const id = call.arguments.id;
74
98
  if (typeof id !== "string")
75
99
  return false;
100
+ if (memoryExistsForTenant(opts.stateDir, id, principal))
101
+ return false;
76
102
  return memoryBelongsToOtherTenant(opts.stateDir, id, tenantKey(principal));
77
103
  }
78
104
  if (call.name === "audit.explain") {
@@ -93,9 +119,10 @@ export function createBodyServices(opts) {
93
119
  return {
94
120
  proxy,
95
121
  ledger,
122
+ policy,
96
123
  policyHash: policy.hash,
97
124
  policyDocument,
98
- listTools: () => TOOL_NAMES,
125
+ listTools: () => (extraNames.length === 0 ? TOOL_NAMES : [...TOOL_NAMES, ...extraNames]),
99
126
  explainOpts,
100
127
  };
101
128
  }
package/dist/witness.js CHANGED
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { writeFileAtomic } from "./atomic-write.js";
6
6
  import { buildCheckpointClaims, checkpointHash, signCheckpoint, totalsFromDecisionRecords, } from "@cedulon/checkpoint";
7
7
  import { decisionRecordHash } from "@cedulon/core";
8
- import { checkpointsPath, signEffectAttestation } from "@verax-ai/proxy";
8
+ import { checkpointsPath, listPieceFiles, signEffectAttestation } from "@verax-ai/proxy";
9
9
  import { pidAlive } from "./unlock.js";
10
10
  const WITNESS_CLASS = "same-org";
11
11
  function listenPath(stateDir) {
@@ -104,21 +104,23 @@ function send(res, status, body) {
104
104
  res.end(text);
105
105
  }
106
106
  function loadDecisions(stateDir) {
107
- try {
108
- const text = readFileSync(join(stateDir, "decisions.jsonl"), "utf8");
109
- const out = [];
110
- for (const line of text.split("\n")) {
111
- if (line === "")
107
+ const out = [];
108
+ for (const piece of listPieceFiles(stateDir)) {
109
+ try {
110
+ const text = readFileSync(piece.decisions, "utf8");
111
+ for (const line of text.split("\n")) {
112
+ if (line === "")
113
+ continue;
114
+ out.push(JSON.parse(line));
115
+ }
116
+ }
117
+ catch (err) {
118
+ if (err.code === "ENOENT")
112
119
  continue;
113
- out.push(JSON.parse(line));
120
+ throw err;
114
121
  }
115
- return out;
116
- }
117
- catch (err) {
118
- if (err.code === "ENOENT")
119
- return [];
120
- throw err;
121
122
  }
123
+ return out;
122
124
  }
123
125
  function lastCheckpointHash(stateDir) {
124
126
  try {