@verax-ai/body 0.1.0 → 0.1.2

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/doctor.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { indexCoverage, listPieceFiles } from "@verax-ai/proxy";
3
4
  import { loadConfig, isLoopbackHost } from "./config.js";
4
5
  import { pidAlive, readLockFile } from "./unlock.js";
5
6
  const SECRET_RE = /sk-|-----BEGIN|Bearer |eyJ[A-Za-z0-9_-]{10,}\./;
@@ -263,7 +264,51 @@ function heartbeatMaxMs(env) {
263
264
  }
264
265
  function evidenceChecks(stateDir, env) {
265
266
  const checks = [];
266
- const src = countJsonl(join(stateDir, "decisions.jsonl"));
267
+ let pieces;
268
+ try {
269
+ pieces = listPieceFiles(stateDir);
270
+ }
271
+ catch (err) {
272
+ checks.push({ id: "ledger-manifest", level: "fail", detail: err.message });
273
+ return checks;
274
+ }
275
+ const srcLines = pieces.reduce((s, p) => s + countJsonl(p.decisions).lines, 0);
276
+ const decisionN = pieces.reduce((s, p) => s + (p.closed ? p.n : countJsonl(p.decisions).lines), 0);
277
+ if (decisionN > 0) {
278
+ const known = new Set(pieces.map((p) => p.id));
279
+ const cov = indexCoverage(stateDir);
280
+ if (cov === null) {
281
+ checks.push({
282
+ id: "ledger-index",
283
+ level: "fail",
284
+ detail: `index names 0 of ${decisionN} decisions; the next open rebuilds it, a running body misses the rest until then`,
285
+ });
286
+ }
287
+ else {
288
+ const ghost = [...cov.pieces].find((id) => !known.has(id));
289
+ if (ghost !== undefined) {
290
+ checks.push({
291
+ id: "ledger-index",
292
+ level: "fail",
293
+ detail: `index names piece ${ghost} that the manifest does not`,
294
+ });
295
+ }
296
+ else if (cov.refs === decisionN) {
297
+ checks.push({
298
+ id: "ledger-index",
299
+ level: "ok",
300
+ detail: `index names ${decisionN} of ${decisionN} decisions`,
301
+ });
302
+ }
303
+ else {
304
+ checks.push({
305
+ id: "ledger-index",
306
+ level: "fail",
307
+ detail: `index names ${cov.refs} of ${decisionN} decisions; the next open rebuilds it, a running body misses the rest until then`,
308
+ });
309
+ }
310
+ }
311
+ }
267
312
  const hbPath = join(stateDir, "heartbeat.json");
268
313
  let hb = null;
269
314
  if (existsSync(hbPath)) {
@@ -274,7 +319,7 @@ function evidenceChecks(stateDir, env) {
274
319
  hb = null;
275
320
  }
276
321
  }
277
- if (src.lines > 0 || hb) {
322
+ if (srcLines > 0 || hb) {
278
323
  if (!hb || typeof hb.atMs !== "number") {
279
324
  checks.push({
280
325
  id: "heartbeat",
@@ -297,46 +342,58 @@ function evidenceChecks(stateDir, env) {
297
342
  });
298
343
  }
299
344
  }
300
- // Both halves of the evidence are mirrored, so both are compared: an effects
301
- // copy that quietly drops rows is the same silence as a missing decision copy.
302
- const srcEffects = countJsonl(join(stateDir, "effects.jsonl"));
303
- const copy = countJsonl(join(stateDir, "evidence-copy", "decisions.jsonl"));
304
- const copyEffects = countJsonl(join(stateDir, "evidence-copy", "effects.jsonl"));
305
- const anything = src.lines > 0 ||
306
- srcEffects.lines > 0 ||
307
- copy.lines > 0 ||
308
- copyEffects.lines > 0 ||
309
- copy.corrupt ||
310
- copyEffects.corrupt;
311
- if (anything) {
345
+ // Each piece is mirrored on its own, so a short copy on one piece is a fail
346
+ // even when another piece's copy still matches.
347
+ let anything = false;
348
+ let fail = null;
349
+ let decisionLines = 0;
350
+ let effectLines = 0;
351
+ for (const piece of pieces) {
352
+ const src = countJsonl(piece.decisions);
353
+ const srcEffects = countJsonl(piece.effects);
354
+ const copy = countJsonl(piece.copyDecisions);
355
+ const copyEffects = countJsonl(piece.copyEffects);
356
+ decisionLines += src.lines;
357
+ effectLines += srcEffects.lines;
358
+ const pieceAnything = src.lines > 0 ||
359
+ srcEffects.lines > 0 ||
360
+ copy.lines > 0 ||
361
+ copyEffects.lines > 0 ||
362
+ copy.corrupt ||
363
+ copyEffects.corrupt;
364
+ if (!pieceAnything)
365
+ continue;
366
+ anything = true;
367
+ if (fail)
368
+ continue;
312
369
  if (copy.corrupt || copyEffects.corrupt) {
313
- checks.push({
370
+ fail = {
314
371
  id: "evidence-copy",
315
372
  level: "fail",
316
- detail: `evidence-copy/${copy.corrupt ? "decisions" : "effects"}.jsonl is corrupt`,
317
- });
373
+ detail: `evidence-copy piece ${piece.id} ${copy.corrupt ? "decisions" : "effects"}.jsonl is corrupt`,
374
+ };
318
375
  }
319
376
  else if (copy.lines < src.lines) {
320
- checks.push({
377
+ fail = {
321
378
  id: "evidence-copy",
322
379
  level: "fail",
323
- detail: `evidence copy is stale: ${copy.lines} lines behind source ${src.lines}`,
324
- });
380
+ detail: `evidence copy is stale on piece ${piece.id}: ${copy.lines} lines behind source ${src.lines}`,
381
+ };
325
382
  }
326
383
  else if (copyEffects.lines < srcEffects.lines) {
327
- checks.push({
384
+ fail = {
328
385
  id: "evidence-copy",
329
386
  level: "fail",
330
- detail: `evidence copy is stale on effects: ${copyEffects.lines} effect line(s) behind source ${srcEffects.lines}`,
331
- });
332
- }
333
- else {
334
- checks.push({
335
- id: "evidence-copy",
336
- level: "ok",
337
- detail: `evidence copy has ${copy.lines} decision line(s) and ${copyEffects.lines} effect line(s)`,
338
- });
387
+ detail: `evidence copy is stale on piece ${piece.id} effects: ${copyEffects.lines} effect line(s) behind source ${srcEffects.lines}`,
388
+ };
339
389
  }
340
390
  }
391
+ if (anything) {
392
+ checks.push(fail ?? {
393
+ id: "evidence-copy",
394
+ level: "ok",
395
+ detail: `evidence copy has ${decisionLines} decision line(s) and ${effectLines} effect line(s)`,
396
+ });
397
+ }
341
398
  return checks;
342
399
  }
@@ -0,0 +1,28 @@
1
+ import type { Principal, ToolCall, ToolResult } from "@verax-ai/proxy";
2
+ export type DownstreamToolFn = (call: ToolCall, principal: Principal, ref?: string) => Promise<ToolResult>;
3
+ export type DownstreamSpec = {
4
+ prefix: string;
5
+ command: string;
6
+ args?: string[];
7
+ cwd?: string;
8
+ env?: Record<string, string>;
9
+ timeoutMs?: number;
10
+ };
11
+ export type DownstreamTool = {
12
+ name: string;
13
+ fn: DownstreamToolFn;
14
+ };
15
+ export type DownstreamSession = {
16
+ prefix: string;
17
+ tools: readonly DownstreamTool[];
18
+ close(): Promise<void>;
19
+ };
20
+ export declare class DownstreamCallError extends Error {
21
+ readonly prefix: string;
22
+ readonly tool: string;
23
+ constructor(prefix: string, tool: string, cause?: unknown);
24
+ }
25
+ export declare function prefixedName(prefix: string, childName: string): string;
26
+ export declare function extraToolNameOk(name: string): boolean;
27
+ export declare function parseDownstreamJson(raw: string): DownstreamSpec;
28
+ export declare function openDownstream(spec: DownstreamSpec): Promise<DownstreamSession>;
@@ -0,0 +1,183 @@
1
+ import { Readable } from "node:stream";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
+ const PREFIX_RE = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
5
+ const CHILD_TOOL_RE = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/;
6
+ /** Prefixed name a caller may put on extraTools: `prefix.childName`. */
7
+ const EXTRA_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,31}\.[A-Za-z][A-Za-z0-9._-]{0,63}$/;
8
+ export class DownstreamCallError extends Error {
9
+ prefix;
10
+ tool;
11
+ constructor(prefix, tool, cause) {
12
+ const detail = cause instanceof Error ? cause.message : "call-failed";
13
+ super(`downstream-call-failed:${prefix}.${tool}:${detail}`);
14
+ this.name = "DownstreamCallError";
15
+ this.prefix = prefix;
16
+ this.tool = tool;
17
+ if (cause !== undefined)
18
+ this.cause = cause;
19
+ }
20
+ }
21
+ export function prefixedName(prefix, childName) {
22
+ return `${prefix}.${childName}`;
23
+ }
24
+ export function extraToolNameOk(name) {
25
+ return EXTRA_NAME_RE.test(name);
26
+ }
27
+ export function parseDownstreamJson(raw) {
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(raw);
31
+ }
32
+ catch {
33
+ throw new Error("downstream-json-invalid");
34
+ }
35
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
36
+ throw new Error("downstream-json-not-object");
37
+ }
38
+ const rec = parsed;
39
+ if (typeof rec.prefix !== "string" || !PREFIX_RE.test(rec.prefix)) {
40
+ throw new Error("downstream-prefix-invalid");
41
+ }
42
+ if (typeof rec.command !== "string" || rec.command.trim() === "") {
43
+ throw new Error("downstream-command-invalid");
44
+ }
45
+ const spec = { prefix: rec.prefix, command: rec.command };
46
+ if (rec.args !== undefined) {
47
+ if (!Array.isArray(rec.args) || rec.args.some((a) => typeof a !== "string")) {
48
+ throw new Error("downstream-args-invalid");
49
+ }
50
+ spec.args = rec.args;
51
+ }
52
+ if (rec.cwd !== undefined) {
53
+ if (typeof rec.cwd !== "string" || rec.cwd === "") {
54
+ throw new Error("downstream-cwd-invalid");
55
+ }
56
+ spec.cwd = rec.cwd;
57
+ }
58
+ if (rec.env !== undefined) {
59
+ if (rec.env === null || typeof rec.env !== "object" || Array.isArray(rec.env)) {
60
+ throw new Error("downstream-env-invalid");
61
+ }
62
+ const env = {};
63
+ for (const [k, v] of Object.entries(rec.env)) {
64
+ if (typeof v !== "string")
65
+ throw new Error("downstream-env-invalid");
66
+ env[k] = v;
67
+ }
68
+ spec.env = env;
69
+ }
70
+ if (rec.timeoutMs !== undefined) {
71
+ if (typeof rec.timeoutMs !== "number" || !Number.isInteger(rec.timeoutMs) || rec.timeoutMs <= 0) {
72
+ throw new Error("downstream-timeout-invalid");
73
+ }
74
+ spec.timeoutMs = rec.timeoutMs;
75
+ }
76
+ return spec;
77
+ }
78
+ // The SDK result is a union (current shape with an index signature, or the
79
+ // legacy { toolResult } shape), so it is narrowed here rather than trusted.
80
+ function asTextResult(raw) {
81
+ const rec = raw !== null && typeof raw === "object" ? raw : {};
82
+ const content = [];
83
+ const items = Array.isArray(rec.content) ? rec.content : [];
84
+ for (const item of items) {
85
+ if (item !== null &&
86
+ typeof item === "object" &&
87
+ item.type === "text" &&
88
+ typeof item.text === "string") {
89
+ content.push({ type: "text", text: item.text });
90
+ continue;
91
+ }
92
+ content.push({ type: "text", text: JSON.stringify(item) });
93
+ }
94
+ return {
95
+ content: content.length > 0 ? content : [{ type: "text", text: "" }],
96
+ isError: rec.isError === true,
97
+ };
98
+ }
99
+ async function shut(client, transport) {
100
+ await client.close().catch(() => undefined);
101
+ await transport.close().catch(() => undefined);
102
+ }
103
+ export async function openDownstream(spec) {
104
+ if (!PREFIX_RE.test(spec.prefix)) {
105
+ throw new Error("downstream-prefix-invalid");
106
+ }
107
+ if (spec.command.trim() === "") {
108
+ throw new Error("downstream-command-invalid");
109
+ }
110
+ const timeoutMs = spec.timeoutMs ?? 10_000;
111
+ // Safe inherit + operator overlay. Not process.env: that would copy
112
+ // VERAX_* tokens the body already holds.
113
+ const env = { ...getDefaultEnvironment(), ...(spec.env ?? {}) };
114
+ const transport = new StdioClientTransport({
115
+ command: spec.command,
116
+ args: spec.args,
117
+ cwd: spec.cwd,
118
+ env,
119
+ stderr: "pipe",
120
+ });
121
+ // The SDK types the pipe as Stream; only a Readable can be drained.
122
+ const stderr = transport.stderr;
123
+ if (stderr instanceof Readable)
124
+ stderr.resume();
125
+ const client = new Client({ name: "verax-downstream", version: "0.0.0" });
126
+ let listed;
127
+ try {
128
+ await client.connect(transport);
129
+ listed = await client.listTools();
130
+ }
131
+ catch (err) {
132
+ await shut(client, transport);
133
+ throw err;
134
+ }
135
+ const tools = [];
136
+ const seen = new Set();
137
+ for (const tool of listed.tools) {
138
+ if (!CHILD_TOOL_RE.test(tool.name)) {
139
+ await shut(client, transport);
140
+ throw new Error(`downstream-child-name-invalid:${tool.name}`);
141
+ }
142
+ const name = prefixedName(spec.prefix, tool.name);
143
+ if (seen.has(name)) {
144
+ await shut(client, transport);
145
+ throw new Error(`downstream-name-collision:${name}`);
146
+ }
147
+ seen.add(name);
148
+ const childName = tool.name;
149
+ tools.push({
150
+ name,
151
+ fn: async (call) => {
152
+ let raw;
153
+ try {
154
+ // Mutation check: stub this call and the allow test expecting
155
+ // "pong":true from the child goes red.
156
+ raw = await client.callTool({ name: childName, arguments: call.arguments }, undefined, { timeout: timeoutMs });
157
+ }
158
+ catch (err) {
159
+ throw new DownstreamCallError(spec.prefix, childName, err);
160
+ }
161
+ const result = asTextResult(raw);
162
+ if (result.isError) {
163
+ throw new DownstreamCallError(spec.prefix, childName, textOf(result) || "isError");
164
+ }
165
+ return result;
166
+ },
167
+ });
168
+ }
169
+ let closed = false;
170
+ return {
171
+ prefix: spec.prefix,
172
+ tools,
173
+ async close() {
174
+ if (closed)
175
+ return;
176
+ closed = true;
177
+ await shut(client, transport);
178
+ },
179
+ };
180
+ }
181
+ function textOf(result) {
182
+ return result.content.map((c) => c.text).join("");
183
+ }
@@ -1,3 +1,10 @@
1
+ /**
2
+ * The inputs documents behind these decisions, keyed by ref, each checked
3
+ * against the hash its decision recorded. inputs.jsonl is written in the
4
+ * same order as decisions.jsonl, so the rows for a window of decisions sit
5
+ * near its end: the file is read from there, and the read stops once every
6
+ * ref has been seen. The newest row for a ref is the one that counts.
7
+ */
1
8
  export declare function matchingInputs(stateDir: string, decisions: {
2
9
  claims: {
3
10
  ref: string | null;
@@ -1,57 +1,64 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
3
  import { canonical } from "@cedulon/core";
4
+ import { listPieceFiles, readLedgerTail } from "@verax-ai/proxy";
5
5
  function sha256Canonical(value) {
6
6
  return createHash("sha256").update(canonical(value), "utf8").digest("hex");
7
7
  }
8
+ /**
9
+ * The inputs documents behind these decisions, keyed by ref, each checked
10
+ * against the hash its decision recorded. inputs.jsonl is written in the
11
+ * same order as decisions.jsonl, so the rows for a window of decisions sit
12
+ * near its end: the file is read from there, and the read stops once every
13
+ * ref has been seen. The newest row for a ref is the one that counts.
14
+ */
8
15
  export async function matchingInputs(stateDir, decisions) {
9
- let text;
10
- try {
11
- text = await readFile(join(stateDir, "inputs.jsonl"), "utf8");
12
- }
13
- catch (err) {
14
- if (err.code === "ENOENT")
15
- return {};
16
- throw err;
17
- }
18
- const byRef = new Map();
19
- for (const line of text.split("\n")) {
20
- if (line === "")
21
- continue;
22
- const row = JSON.parse(line);
23
- if (typeof row.ref === "string")
24
- byRef.set(row.ref, row.inputs);
16
+ const wanted = new Map();
17
+ for (const d of decisions) {
18
+ if (d.claims.ref && typeof d.claims.inputsHash === "string")
19
+ wanted.set(d.claims.ref, d.claims.inputsHash);
25
20
  }
26
21
  const out = {};
27
- for (const d of decisions) {
28
- const ref = d.claims.ref;
29
- if (!ref || typeof d.claims.inputsHash !== "string")
30
- continue;
31
- const doc = byRef.get(ref);
32
- if (doc !== undefined && sha256Canonical(doc) === d.claims.inputsHash) {
33
- out[ref] = doc;
34
- }
22
+ if (wanted.size === 0)
23
+ return out;
24
+ const seen = new Set();
25
+ const pieces = [...listPieceFiles(stateDir)].reverse();
26
+ for (const piece of pieces) {
27
+ if (seen.size === wanted.size)
28
+ break;
29
+ await readLedgerTail(piece.inputs, (row) => {
30
+ if (typeof row.ref !== "string" || seen.has(row.ref))
31
+ return "skip";
32
+ const hash = wanted.get(row.ref);
33
+ if (hash === undefined)
34
+ return "skip";
35
+ seen.add(row.ref);
36
+ if (sha256Canonical(row.inputs) === hash)
37
+ out[row.ref] = row.inputs;
38
+ return seen.size === wanted.size ? "stop" : "skip";
39
+ });
35
40
  }
36
41
  return out;
37
42
  }
38
43
  export async function inputsPrincipal(stateDir, ref) {
39
- let text;
40
- try {
41
- text = await readFile(join(stateDir, "inputs.jsonl"), "utf8");
42
- }
43
- catch (err) {
44
- if (err.code === "ENOENT")
45
- return null;
46
- throw err;
47
- }
48
44
  let found = null;
49
- for (const line of text.split("\n")) {
50
- if (line === "")
51
- continue;
52
- const row = JSON.parse(line);
53
- if (row.ref === ref)
54
- found = row.inputs?.principal ?? null;
45
+ for (const piece of listPieceFiles(stateDir)) {
46
+ let text;
47
+ try {
48
+ text = await readFile(piece.inputs, "utf8");
49
+ }
50
+ catch (err) {
51
+ if (err.code === "ENOENT")
52
+ continue;
53
+ throw err;
54
+ }
55
+ for (const line of text.split("\n")) {
56
+ if (line === "")
57
+ continue;
58
+ const row = JSON.parse(line);
59
+ if (row.ref === ref)
60
+ found = row.inputs?.principal ?? null;
61
+ }
55
62
  }
56
63
  if (!found || typeof found.brain !== "string")
57
64
  return null;
@@ -26,6 +26,9 @@ const CREATE_REQUIRE = /\bcreateRequire\b/;
26
26
  const CONCAT_TOOLS = /["']\.\/to["']\s*\+\s*["']ols/;
27
27
  const CHILD = /\bchild_process\b/;
28
28
  const WORKER = /\bworker_threads\b/;
29
+ // The SDK stdio client transport spawns a process without naming
30
+ // child_process in this tree. Only src/downstream.ts may use it.
31
+ const SDK_STDIO = /client\/stdio\.js|\bStdioClientTransport\b/;
29
32
  function lineAt(text, index) {
30
33
  return text.slice(0, index).split(/\n/).length;
31
34
  }
@@ -51,6 +54,8 @@ export function scanNoBypass(base = pkgRoot, extra = []) {
51
54
  const wiring = rel === "src/wiring.ts";
52
55
  // Desktop supervises issuer/body/panel. It is not a tool-call path.
53
56
  const desktop = rel === "src/desktop.ts";
57
+ // The downstream spike attaches one stdio MCP server. Not a tool-call path.
58
+ const downstream = rel === "src/downstream.ts";
54
59
  const originalLines = text.split(/\r?\n/);
55
60
  for (const match of text.matchAll(DYNAMIC_IMPORT)) {
56
61
  const index = match.index ?? 0;
@@ -89,6 +94,8 @@ export function scanNoBypass(base = pkgRoot, extra = []) {
89
94
  push("child_process");
90
95
  if (WORKER.test(line))
91
96
  push("worker_threads");
97
+ if (SDK_STDIO.test(line) && !downstream)
98
+ push("sdk-stdio-spawn");
92
99
  }
93
100
  }
94
101
  return hits;