Merge remote-tracking branch 'origin/main' into feat/website-seo-metadata

This commit is contained in:
N0zoM1z0 committed 2026-10-09 22:19:04 +08:00
commit 8c9c5550d0
74 files changed
+1221 -217

No files matched your search

+5 -2
View File
@@ -294,8 +294,11 @@ do not select a later entry. Rejected references retain the package metadata
path, original target, failed constraint, and importing source location.
HTML script references resolve to exact inventoried files after applying the
document base and query/fragment rules. CommonJS module lookups retain extension
and directory resolution.
document base and query/fragment rules. As in a browser, the script URL and base
href are read without surrounding whitespace or embedded tabs and newlines, and
their percent-encoded path bytes are decoded; encoded dot and separator bytes
are still rejected. CommonJS module lookups retain extension and directory
resolution.
Unresolved HTML references retain their declaration, source range, and resolution
reason in the renderer observations.
HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR
+9
View File
@@ -77,6 +77,15 @@ inventories, and do not claim that no short-lived descendant existed.
## Progress and cancellation
Tool execution failures return `isError: true` and the complete canonical
`{ "error": ... }` diagnostic as JSON in text `content`. Read that diagnostic
for the error code, target details, partial observations, and remediation.
Failures omit `structuredContent`, since each advertised `outputSchema`
describes successful results. Successful replies retain their schema-validated
structured data and matching text projection. Oversized errors are retained
as Evidence before delivery of a bounded diagnostic with an export reference;
if retention is unavailable or fails, the diagnostic reports that reason.
REA accepts ordinary `tools/call` progress tokens. Updates are monotonic,
rate-bounded to at most one intermediate update per 100 ms, and always allow a
terminal update. Unknown totals are omitted; REA does not fabricate percentages.
+30 -1
View File
@@ -119,7 +119,7 @@ all endpoint numbers in comparison text. Captures commit the port-normalization
version so older broad numeric normalization cannot silently compare as the
same contract.
Output, file count, file size, process sampling, filesystem depth, total
Output retention, file count, aggregate file-hashing bytes, process sampling, filesystem depth, total
runtime, idle time, and post-exit settlement are bounded by the scenario's
limits. The result marks truncated observations and residual unknowns rather
than treating missing data as proof of equivalence. Cancellation and timeout
@@ -127,6 +127,35 @@ run the same owned-process cleanup path. Settlement reports whether the
sampled process group quiesced or whether cleanup was needed or unverifiable;
sampling cannot prove that every short-lived or detached descendant was seen.
New captures include `truncation_details`, with separate accounting for:
- `raw_terminal`: original UTF-8 PTY chunk bytes and observed/retained chunk
counts. A chunk that does not fit `limits.output_bytes` is omitted whole;
a later smaller chunk can still fit.
- `rendered_terminal`: cumulative UTF-8 serialized-state and visible-line
bytes, plus observed/retained frame counts. This is independent of the raw
chunk budget and does not count JSON encoding. Repeated screen snapshots can
exhaust it even when all raw output fits. Rendered states use retained PTY
input, so raw omissions also limit rendered coverage.
- `filesystem_before` and `filesystem_after`: file-count/depth limits,
enumeration failures, whole-file hash budget and bytes successfully hashed.
Each retained regular file without a digest has an aliased path, size,
remaining budget and reason: `file_bytes_budget` or
`file_changed_or_short_read`. A file too large for the remaining budget is
skipped; a later smaller file can still be hashed. Hash omissions do not
imply incomplete path enumeration.
- `process`: sampling limit and whether sampling ended partially. Coverage
remains `sampled`, including when that limit was not exhausted.
The legacy `truncated` flag summarizes these observations. Comparisons retain
results for unaffected dimensions and mark affected dimensions unknown; an
incomplete capture cannot locate the first divergence across all dimensions.
Trace assertions use coverage for the sources they select, so an assertion
about complete raw terminal output need not fail because rendered snapshots
were omitted. Older truncated captures without these details retain the
conservative comparison behavior. These diagnostics use the existing scenario
budgets; they do not introduce a separate file-hashing budget.
When the host withholds an unrelated process’s ownership token, REA leaves that
process untouched and records its PID and reason in `cleanup.unverified_processes`
and process residual unknowns. Successful cleanup verifies the owned group;
+3 -2
View File
@@ -1,3 +1,4 @@
import { requireMcpToolError } from "./mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
@@ -158,11 +159,11 @@ const verifyDefaultScreenshotDelivery = async ({
);
assert.equal(constrained.isError, true);
assert.equal(
constrained.structuredContent?.error?.details?.resource,
requireMcpToolError(constrained)?.details?.resource,
"transport",
);
const reference =
constrained.structuredContent?.error?.details?.reported_limits
requireMcpToolError(constrained)?.details?.reported_limits
?.evidence_reference;
assert.equal(reference?.kind, "retained-evidence");
await client.ping();
@@ -1,3 +1,8 @@
import {
requireMcpToolError,
requireMcpLifecycleResult,
requireMcpEvidenceResult,
} from "./mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
@@ -15,10 +20,6 @@ import {
} from "../../dist/domain/analysisSnapshot.js";
import { createEvidence } from "../../dist/domain/evidence.js";
import { createEvidenceBundle } from "../../dist/domain/evidenceBundle.js";
import {
requireMcpLifecycleResult,
requireMcpEvidenceResult,
} from "./mcp-verifier-results.mjs";
/** Reject a valid older snapshot whose instruction-only collector omitted a real edge. */
export async function verifyLegacyGhidraReferenceSnapshot(
@@ -124,7 +125,7 @@ export async function verifyLegacyGhidraReferenceSnapshot(
},
});
assert.equal(reply.isError, true);
const mcpRejection = reply.structuredContent.error;
const mcpRejection = requireMcpToolError(reply);
assert.equal(mcpRejection.code, "evidence_integrity_mismatch");
assert.match(mcpRejection.details.reason, /profile_mismatch/u);
assert.match(
+7
View File
@@ -1,3 +1,4 @@
import { analysisErrorProjectionSchema } from "../../dist/contracts/errorSchemas.js";
import { toolContract } from "../../dist/contracts/toolContracts.js";
/** Read the textual JSON projection from one MCP tool call. */
@@ -9,6 +10,12 @@ export const mcpTextValue = (result) => {
const jsonValue = (result) => JSON.parse(mcpTextValue(result));
/** Read the complete canonical diagnostic from an MCP tool failure. */
export const requireMcpToolError = (result) => {
if (result.isError !== true) throw new Error("Expected an MCP tool failure");
return analysisErrorProjectionSchema.parse(jsonValue(result).error);
};
const requireOutput = (result, operation, contract) => {
if (result.isError === true)
throw new Error(`${operation} failed: ${mcpTextValue(result)}`);
@@ -1,3 +1,4 @@
import { requireMcpToolError } from "./mcp-verifier-results.mjs";
import assert from "node:assert/strict";
/** Exercise segmented-address truncation through real CLI/MCP reads and edits. */
@@ -40,13 +41,14 @@ export async function verifyGhidraAddressBoundaries(client, entry, cli) {
{ timeout: 240000 },
);
assert.equal(reply.isError, true, `${name} accepted ${address}`);
const error = reply.structuredContent?.error;
const error = requireMcpToolError(reply);
assert.equal(error?.code, "invalid_request", JSON.stringify(reply));
assert.equal(error.details.operation, name);
const issues = JSON.stringify(error.details.issues);
assert.match(issues, /cannot be represented without truncation/u);
assert.ok(issues.includes(address), issues);
assert.equal(reply.structuredContent.evidence, undefined);
// A rejected request produces no Evidence; errors carry text only.
assert.equal(reply.structuredContent, undefined);
}
}
assert.deepEqual(
+16 -17
View File
@@ -1,3 +1,8 @@
import {
requireMcpToolError,
mcpTextValue,
requireMcpOperationResult,
} from "./mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
@@ -7,11 +12,9 @@ import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { analysisErrorProjectionSchema } from "../../dist/contracts/errorSchemas.js";
import {
mcpTextValue,
requireMcpOperationResult,
} from "./mcp-verifier-results.mjs";
import { verifyLegacyGhidraReferenceSnapshot } from "./ghidra-reference-snapshot-e2e.mjs";
import { verifyGhidraSnapshotLifecycle } from "./real-ghidra-snapshot-lifecycle.mjs";
import { verifyGhidraTargetAdmission } from "./real-ghidra-target-admission.mjs";
@@ -61,23 +64,19 @@ export async function verifyGhidraBoundaries(
true,
`${name} accepted ${JSON.stringify(args)}`,
);
// SDK input-schema rejections are text-only; application errors use the
// canonical structured error projection, separately from success schemas.
if (reply.structuredContent !== undefined) {
analysisErrorProjectionSchema.parse(reply.structuredContent.error);
assert.deepEqual(
reply.structuredContent,
JSON.parse(mcpTextValue(reply)),
);
}
// SDK input rejections are prose; application errors carry canonical JSON
// text without publishing data outside the advertised success schema.
const error = mcpTextValue(reply).startsWith("{")
? requireMcpToolError(reply)
: undefined;
assert.equal(reply.structuredContent, undefined);
if (diagnostic !== undefined) {
const error = reply.structuredContent?.error;
assert.equal(error?.code, "invalid_request");
assert.equal(error?.details?.operation, name);
assert.match(JSON.stringify(error.details.issues), diagnostic);
}
rejectedCalls++;
return reply.structuredContent?.error;
return error;
};
const cli = async (command, value, flags = []) => {
const { stdout } = await promisify(execFile)(
@@ -481,7 +480,7 @@ export async function verifyGhidraBoundaries(
options,
);
assert.equal(regexFailure.isError, true);
const regexError = regexFailure.structuredContent.error;
const regexError = requireMcpToolError(regexFailure);
assert.equal(regexError.code, "resource_constraint");
assert.equal(regexError.details.operation, "search_strings");
assert.equal(regexError.details.resource, "memory");
@@ -503,7 +502,7 @@ export async function verifyGhidraBoundaries(
options,
);
assert.equal(compileFailure.isError, true);
const compileError = compileFailure.structuredContent.error;
const compileError = requireMcpToolError(compileFailure);
assert.equal(compileError.code, "resource_constraint");
assert.equal(compileError.details.operation, "search_procedures");
assert.match(compileError.details.reason, /stack while compiling pattern/u);
@@ -1,10 +1,13 @@
import {
requireMcpToolError,
requireMcpOperationResult,
} from "./mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseAnalysisSnapshot } from "../../dist/domain/analysisSnapshot.js";
import { parseEvidenceBundle } from "../../dist/domain/evidenceBundle.js";
import { requireMcpOperationResult } from "./mcp-verifier-results.mjs";
/** Keep mutable Ghidra observations out of immutable snapshots across target opens. */
export async function verifyGhidraSnapshotLifecycle(
@@ -31,7 +34,7 @@ export async function verifyGhidraSnapshotLifecycle(
true,
`${name} accepted a mutated snapshot binding`,
);
const error = reply.structuredContent?.error;
const error = requireMcpToolError(reply);
assert.equal(
error?.code,
"evidence_integrity_mismatch",
+4 -3
View File
@@ -1,3 +1,4 @@
import { requireMcpToolError } from "./lib/mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
@@ -232,12 +233,12 @@ const checkHardenedRuntime = async (directory, binary) => {
{ timeout: CLI_TIMEOUT_MS },
);
assert.equal(deniedMcp.isError, true, JSON.stringify(deniedMcp));
assert.equal(deniedMcp.structuredContent.error.code, denied.code);
assert.equal(requireMcpToolError(deniedMcp).code, denied.code);
assert.match(
deniedMcp.structuredContent.error.details.reason,
requireMcpToolError(deniedMcp).details.reason,
/^debugger-attach-denied: /u,
);
assert.ok(deniedMcp.structuredContent.error.details.partial_observation);
assert.ok(requireMcpToolError(deniedMcp).details.partial_observation);
});
const allowed = join(directory, "debuggable");
const entitlements = join(directory, "get-task-allow.plist");
+2 -1
View File
@@ -1,3 +1,4 @@
import { requireMcpToolError } from "./lib/mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { join } from "node:path";
import { execFile } from "node:child_process";
@@ -50,7 +51,7 @@ const verifyMcpUnknownProvider = async (client, mcpOptions) => {
);
if (
unknownProvider.isError !== true ||
unknownProvider.structuredContent?.error?.details?.selection_reason !==
requireMcpToolError(unknownProvider)?.details?.selection_reason !==
"unknown_provider"
)
throw new Error("packaged MCP accepted an unknown analysis provider");
+2 -1
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { requireMcpToolError } from "./lib/mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
@@ -172,7 +173,7 @@ const mcp = async (cliDossier) => {
arguments: { address: "not-an-address" },
});
assert.equal(invalid.isError, true);
assert.equal(invalid.structuredContent.error.code, "invalid_request");
assert.equal(requireMcpToolError(invalid).code, "invalid_request");
await call("close_binary", {});
opened = false;
report.checks.push(
+8 -8
View File
@@ -1,3 +1,4 @@
import { requireMcpToolError } from "../../lib/mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import {
access,
@@ -208,13 +209,12 @@ export async function verifyAndroidFailureRecovery({
{ timeout: 150_000 },
);
assert.equal(response.isError, true);
assert.equal(response.structuredContent.error.code, "invalid_request");
assert.deepEqual(
response.structuredContent.error.details.issues[0].path,
["class_name"],
);
assert.equal(requireMcpToolError(response).code, "invalid_request");
assert.deepEqual(requireMcpToolError(response).details.issues[0].path, [
"class_name",
]);
assert.match(
response.structuredContent.error.details.issues[0].message,
requireMcpToolError(response).details.issues[0].message,
/search_android_classes/u,
);
assert.doesNotMatch(JSON.stringify(response), /rea doctor/u);
@@ -231,8 +231,8 @@ export async function verifyAndroidFailureRecovery({
},
{ timeout: 150_000 },
);
assert.equal(method.structuredContent.error.code, "invalid_request");
assert.deepEqual(method.structuredContent.error.details.issues[0].path, [
assert.equal(requireMcpToolError(method).code, "invalid_request");
assert.deepEqual(requireMcpToolError(method).details.issues[0].path, [
"method_name",
]);
await client.ping();
+3 -2
View File
@@ -1,3 +1,4 @@
import { requireMcpToolError } from "../../lib/mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { execFile, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
@@ -172,11 +173,11 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
{ timeout: 150_000 },
);
const capture = active.isError
? active.structuredContent.error.details.partial_observation.capture
? requireMcpToolError(active).details.partial_observation.capture
: active.structuredContent.normalized_result;
if (active.isError) {
assert.equal(
active.structuredContent.error.code,
requireMcpToolError(active).code,
"cleanup_incomplete",
JSON.stringify(active),
);
@@ -5,7 +5,9 @@ import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptA
import {
admitsCanonicalPathSyntax,
hasScheme,
htmlUrlText,
looksExternal,
percentDecodeUrlPath,
stripQueryAndFragment,
} from "../../domain/artifactPathSyntax.js";
@@ -248,7 +250,7 @@ const hasContainerCandidate = (
const htmlCandidate = (
input: ResolveArtifactPathInput,
): string | ArtifactPathResolution => {
const declared = stripQueryAndFragment(input.declaredPath);
const declared = stripQueryAndFragment(htmlUrlText(input.declaredPath));
if (looksExternal(declared))
return unresolvedOutcome(input, "external", [
"External HTML references are not mapped to local artifact assets.",
@@ -257,14 +259,19 @@ const htmlCandidate = (
const base =
rawBase === undefined || rawBase === null
? rawBase
: stripQueryAndFragment(rawBase);
: stripQueryAndFragment(htmlUrlText(rawBase));
if (base !== undefined && base !== null && looksExternal(base))
return unresolvedOutcome(input, "external", [
"The document base href is external, so its script reference is not a local artifact path.",
]);
if (declared.startsWith("/")) return declared.slice(1);
const declaredPath = percentDecodeUrlPath(declared);
if (declaredPath === null)
return unresolvedOutcome(input, "rejected", [
"The HTML reference path percent-decodes to NUL or to bytes that are not UTF-8.",
]);
if (declaredPath.startsWith("/")) return declaredPath.slice(1);
if (base === undefined || base === null || base === "")
return posix.join(posix.dirname(input.sourcePath), declared);
return posix.join(posix.dirname(input.sourcePath), declaredPath);
// A local base href is a second untrusted path input; apply the same
// admission rules a declared path gets so it cannot smuggle traversal or
// separator syntax past canonicalization.
@@ -272,10 +279,15 @@ const htmlCandidate = (
return unresolvedOutcome(input, "rejected", [
"The document base href uses NUL, backslash, or encoded dot and separator bytes that are not admitted for canonical artifact paths.",
]);
const basePath = base.startsWith("/")
? base.slice(1)
: posix.join(posix.dirname(input.sourcePath), base);
return posix.join(htmlBaseDirectory(base, basePath), declared);
const decodedBase = percentDecodeUrlPath(base);
if (decodedBase === null)
return unresolvedOutcome(input, "rejected", [
"The document base href path percent-decodes to NUL or to bytes that are not UTF-8.",
]);
const basePath = decodedBase.startsWith("/")
? decodedBase.slice(1)
: posix.join(posix.dirname(input.sourcePath), decodedBase);
return posix.join(htmlBaseDirectory(decodedBase, basePath), declaredPath);
};
/**
@@ -109,3 +109,67 @@ describe("HTML base href path syntax", () => {
});
});
});
describe("HTML reference URL text", () => {
const urlPath = (declared: string, base: string) =>
decodeURIComponent(
new URL(declared.trim(), new URL(base.trim(), documentUrl)).pathname,
).replace(/^\/+/, "");
it.each([
["%61pp.js", "/assets/", "assets/app.js"],
[" app.js ", "/assets/", "assets/app.js"],
["\napp.js\t", "/assets/", "assets/app.js"],
["a\npp.js", "/assets/", "assets/app.js"],
["app.js", "%61/", "renderer/a/app.js"],
["app.js", " /%61ssets/ ", "assets/app.js"],
["/%61ssets/app.js", "", "assets/app.js"],
])(
"resolves %j against base %j like the URL parser",
(declared, base, expected) => {
if (base !== "") expect(urlPath(declared, base)).toBe(expected);
expect(resolve(declared, base)).toMatchObject({
declared_path: declared,
resolution_status: "resolved",
resolved_path: expected,
});
},
);
it("keeps a literal percent sign that starts no escape", () => {
expect(resolve("100%.js", "")).toMatchObject({
resolution_status: "not-found",
limitations: [
"The exact HTML resource renderer/100%.js was not found among the selected application files.",
],
});
});
it.each(["%00app.js", "%ff.js"])(
"rejects %j, which decodes to no canonical artifact path",
(declared) => {
expect(resolve(declared, "")).toMatchObject({
resolution_status: "rejected",
resolved_path: null,
});
},
);
it("rejects a base href that decodes to NUL", () => {
expect(resolve("app.js", "/a%00/")).toMatchObject({
resolution_status: "rejected",
resolved_path: null,
});
});
it.each([" https://external.test/app.js", "\t//external.test/app.js"])(
"reports %j as external after removing URL whitespace",
(declared) => {
expect(resolve(declared, "")).toMatchObject({
resolution_status: "external",
});
},
);
it.each(["%2e%2e/app.js", "a%2fapp.js"])(
"still rejects encoded dot and separator bytes in %j",
(declared) => {
expect(resolve(declared, "")).toMatchObject({
resolution_status: "rejected",
});
},
);
});
+49
View File
@@ -32,3 +32,52 @@ export const admitsCanonicalPathSyntax = (value: string): boolean =>
!value.includes("\0") &&
!value.includes("\\") &&
!/%(?:2e|2f|5c)/iu.test(value);
/** C0 controls and space, which the URL parser strips around its input. */
const isUrlTrimmed = (code: number): boolean => code <= 0x20;
/**
* The URL text a browser parses from an HTML URL attribute: HTML strips
* surrounding ASCII whitespace, and the URL parser strips surrounding C0
* controls and spaces and removes every tab and newline.
*/
export const htmlUrlText = (value: string): string => {
let start = 0;
let end = value.length;
while (start < end && isUrlTrimmed(value.charCodeAt(start))) start += 1;
while (end > start && isUrlTrimmed(value.charCodeAt(end - 1))) end -= 1;
return value.slice(start, end).replace(/[\t\n\r]/gu, "");
};
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
const UTF8_ENCODER = new TextEncoder();
/**
* Percent-decode a URL path as the URL standard does: valid `%XX` sequences
* become bytes and any other `%` stays literal. Returns null when the bytes
* are not UTF-8 or include NUL, because no canonical artifact path can match
* them. Callers refuse encoded dot and separator bytes before decoding.
*/
export const percentDecodeUrlPath = (value: string): string | null => {
if (!/%[0-9a-f]{2}/iu.test(value)) return value;
const bytes: number[] = [];
for (let index = 0; index < value.length;) {
const escape = value.slice(index, index + 3);
if (/^%[0-9a-f]{2}$/iu.test(escape)) {
bytes.push(Number.parseInt(escape.slice(1), 16));
index += 3;
continue;
}
const character = String.fromCodePoint(value.codePointAt(index) ?? 0);
bytes.push(...UTF8_ENCODER.encode(character));
index += character.length;
}
try {
const decoded = UTF8.decode(new Uint8Array(bytes));
return decoded.includes("\0") ? null : decoded;
} catch (cause: unknown) {
// Invalid UTF-8 cannot name an inventoried path.
void cause;
return null;
}
};
+7
View File
@@ -3,6 +3,10 @@ import { jsonValueSchema } from "../jsonValue.js";
import { normalizationSchema } from "./processScenario.js";
import { collectProcessCaptureIssues } from "./processCaptureValidation.js";
import {
processCaptureTruncationDetailsSchema,
type ProcessCaptureTruncationDetails,
} from "./processCaptureCoverage.js";
export * from "./processScenario.js";
@@ -206,6 +210,8 @@ export interface UnverifiedProcessCapture {
readonly files_after: readonly FileState[];
readonly filesystem_effects: readonly FileEffect[];
readonly truncated: boolean;
/** Optional on older captures; identifies each producer's actual coverage. */
readonly truncation_details?: ProcessCaptureTruncationDetails | undefined;
readonly limitations: readonly string[];
readonly residual_unknowns: readonly {
readonly scope:
@@ -477,6 +483,7 @@ const processCaptureShapeSchema = z.strictObject({
files_after: z.array(fileStateSchema),
filesystem_effects: z.array(fileEffectSchema),
truncated: z.boolean(),
truncation_details: processCaptureTruncationDetailsSchema.optional(),
limitations: z.array(z.string()),
residual_unknowns: z.array(
z.object({
@@ -0,0 +1,101 @@
import { z } from "zod";
import type { UnverifiedProcessCapture } from "./processCapture.js";
import type { ProcessObservationSource } from "./processObservation.js";
const byteRetentionSchema = z.strictObject({
budget_bytes: z.number().int().nonnegative(),
observed_bytes: z.number().int().nonnegative(),
retained_bytes: z.number().int().nonnegative(),
observed_frames: z.number().int().nonnegative(),
retained_frames: z.number().int().nonnegative(),
});
/** Filesystem enumeration and whole-file digest coverage for one checkpoint. */
export const filesystemCoverageSchema = z.strictObject({
files_limit: z.number().int().nonnegative(),
depth_limit: z.number().int().nonnegative(),
enumeration_truncated: z.boolean(),
enumeration_reasons: z.array(
z.enum(["files_limit", "depth_limit", "identity_changed"]),
),
hash_budget_bytes: z.number().int().nonnegative(),
hashed_bytes: z.number().int().nonnegative(),
hash_omissions: z.array(
z.strictObject({
path: z.string(),
size_bytes: z.number().int().nonnegative(),
remaining_budget_bytes: z.number().int().nonnegative(),
reason: z.enum(["file_bytes_budget", "file_changed_or_short_read"]),
}),
),
});
export type FilesystemCoverage = z.infer<typeof filesystemCoverageSchema>;
/** Actual budget accounting; rendered bytes count state and visible lines, not JSON. */
export const processCaptureTruncationDetailsSchema = z.strictObject({
raw_terminal: byteRetentionSchema.describe(
"UTF-8 bytes of original PTY chunks; whole chunks are retained or omitted.",
),
rendered_terminal: byteRetentionSchema.describe(
"Cumulative UTF-8 bytes of serialized state plus visible lines, evaluated from retained PTY input; whole frames are retained or omitted.",
),
filesystem_before: filesystemCoverageSchema,
filesystem_after: filesystemCoverageSchema,
process: z.strictObject({
sample_limit: z.number().int().nonnegative(),
sampling_partial: z.boolean(),
retained_samples: z.number().int().nonnegative(),
sample_limit_reached: z.boolean(),
sampling_failures: z.number().int().nonnegative(),
first_sampling_failure: z.string().nullable(),
coverage: z.literal("sampled"),
}),
});
export type ProcessCaptureTruncationDetails = z.infer<
typeof processCaptureTruncationDetailsSchema
>;
export type TerminalRetention = ProcessCaptureTruncationDetails["raw_terminal"];
export type ProcessSamplingCoverage =
ProcessCaptureTruncationDetails["process"];
const omitted = (retention: TerminalRetention): boolean =>
retention.observed_frames > retention.retained_frames;
const filesystemTruncated = (coverage: FilesystemCoverage): boolean =>
coverage.enumeration_truncated || coverage.hash_omissions.length > 0;
/** Derive the legacy aggregate flag from independent producer observations. */
export const hasCaptureTruncation = (
details: ProcessCaptureTruncationDetails,
): boolean =>
omitted(details.raw_terminal) ||
omitted(details.rendered_terminal) ||
filesystemTruncated(details.filesystem_before) ||
filesystemTruncated(details.filesystem_after) ||
details.process.sampling_partial;
/** Older truncated captures lack enough information to localize missing observations. */
export const processSourceTruncated = (
capture: UnverifiedProcessCapture,
source: ProcessObservationSource,
): boolean => {
const details = capture.truncation_details;
if (details === undefined) return capture.truncated;
switch (source) {
case "terminal_raw":
return omitted(details.raw_terminal);
case "terminal_rendered":
return (
omitted(details.raw_terminal) || omitted(details.rendered_terminal)
);
case "filesystem":
return (
filesystemTruncated(details.filesystem_before) ||
filesystemTruncated(details.filesystem_after)
);
case "process":
return details.process.sampling_partial;
case "interaction":
case "lifecycle":
return false;
}
};
@@ -1,5 +1,6 @@
import type { UnverifiedProcessCapture } from "./processCapture.js";
import { digestProcessCommitment } from "./processScenario.js";
import { hasCaptureTruncation } from "./processCaptureCoverage.js";
/** One pure semantic validation failure in a shaped process capture. */
export interface ProcessCaptureValidationIssue {
@@ -121,6 +122,81 @@ const validateLifecycle = (
null, "exit", "deadline termination cannot declare a normal exit code");
};
const validateCoverage = (
capture: UnverifiedProcessCapture,
require: RequireInvariant,
): void => {
const details = capture.truncation_details;
if (details === undefined) return;
require(details.process.retained_samples === capture.process_samples.length &&
details.process.retained_samples <= details.process.sample_limit &&
details.process.sampling_partial ===
(details.process.sample_limit_reached ||
details.process.sampling_failures > 0) &&
(details.process.sampling_failures === 0) ===
(details.process.first_sampling_failure ===
null), "truncation_details.process", "sampling coverage must agree with retained samples and observed failures");
require(capture.truncated ===
hasCaptureTruncation(
details,
), "truncation_details", "aggregate truncation must match producer coverage");
for (const [name, retention, count] of [
["raw_terminal", details.raw_terminal, capture.frames.length],
[
"rendered_terminal",
details.rendered_terminal,
capture.rendered_frames.length,
],
] as const) {
require(retention.retained_frames === count &&
retention.observed_frames >= count &&
retention.observed_bytes >= retention.retained_bytes &&
retention.retained_bytes <=
retention.budget_bytes, `truncation_details.${name}`, "retained observations and budget accounting do not agree");
}
for (const [name, coverage, files, checkpoint] of [
[
"filesystem_before",
details.filesystem_before,
capture.files_before,
capture.filesystem_checkpoints[0],
],
[
"filesystem_after",
details.filesystem_after,
capture.files_after,
capture.filesystem_checkpoints[1],
],
] as const) {
const unhashedFiles = new Map(
files
.filter((file) => file.type === "file" && file.sha256 === null)
.map((file) => [file.path, file]),
);
require(coverage.hash_omissions.length === unhashedFiles.size &&
new Set(coverage.hash_omissions.map(({ path }) => path)).size ===
unhashedFiles.size &&
coverage.hash_omissions.every(
(omission) =>
unhashedFiles.get(omission.path)?.size === omission.size_bytes &&
omission.remaining_budget_bytes <= coverage.hash_budget_bytes,
), `truncation_details.${name}.hash_omissions`, "every retained regular file without a digest must have one matching omission reason");
require(coverage.hashed_bytes ===
files
.filter((file) => file.type === "file" && file.sha256 !== null)
.reduce((sum, file) => sum + file.size, 0) &&
coverage.hashed_bytes <= coverage.hash_budget_bytes &&
files.length <=
coverage.files_limit, `truncation_details.${name}`, "filesystem budget accounting does not match retained observations");
require(coverage.enumeration_truncated ===
coverage.enumeration_reasons.length > 0 &&
checkpoint?.truncated ===
(coverage.enumeration_truncated ||
coverage.hash_omissions.length >
0), `truncation_details.${name}`, "filesystem checkpoint truncation must match enumeration and digest coverage");
}
};
/** Recompute commitments and cross-field invariants without side effects. */
export const collectProcessCaptureIssues = (
capture: UnverifiedProcessCapture,
@@ -133,5 +209,6 @@ export const collectProcessCaptureIssues = (
validateOrdering(capture, require);
validateEventJournal(capture, require);
validateLifecycle(capture, require);
validateCoverage(capture, require);
return issues;
};
+52 -24
View File
@@ -1,5 +1,7 @@
import canonicalize from "canonicalize";
import { z } from "zod";
import { processSourceTruncated } from "./processCaptureCoverage.js";
import type { ProcessObservationSource } from "./processObservation.js";
import type { ProcessCapture } from "./processCapture.js";
import { comparableTerminalFrame } from "./processObservation.js";
@@ -363,8 +365,13 @@ const compareDimensions = (
scope: ProcessCapture["residual_unknowns"][number]["scope"],
leftValues: readonly unknown[],
rightValues: readonly unknown[],
source: ProcessObservationSource,
): ComparisonStatus =>
!sameNormalization || hasUnknown(left, scope) || hasUnknown(right, scope)
!sameNormalization ||
hasUnknown(left, scope) ||
hasUnknown(right, scope) ||
processSourceTruncated(left, source) ||
processSourceTruncated(right, source)
? "unknown"
: classifyCollection(leftValues, rightValues);
return {
@@ -372,11 +379,13 @@ const compareDimensions = (
"terminal",
terminalObservations(left),
terminalObservations(right),
"terminal_rendered",
),
interaction: classify(
"interaction",
left.interaction_events,
right.interaction_events,
"interaction",
),
exit:
hasUnknown(left, "exit") || hasUnknown(right, "exit")
@@ -391,8 +400,14 @@ const compareDimensions = (
"filesystem",
filesystemObservations(left),
filesystemObservations(right),
"filesystem",
),
process: classify(
"process",
left.process_samples,
right.process_samples,
"process",
),
process: classify("process", left.process_samples, right.process_samples),
};
};
@@ -412,7 +427,10 @@ export const compareProcessCaptures = (
} = {},
): ProcessCaptureComparison => {
assertComparable(left, right, options);
if (left.truncated || right.truncated) {
if (
(left.truncated && left.truncation_details === undefined) ||
(right.truncated && right.truncation_details === undefined)
) {
const truncated = truncatedComparison();
return options.traceSpecification === undefined
? truncated
@@ -438,11 +456,13 @@ export const compareProcessCaptures = (
right,
]);
const divergenceCandidates = PROCESS_COMPARISON_DIMENSIONS.map((dimension) =>
firstCollectionDivergence(
dimension,
processDimensionObservations(left, dimension),
processDimensionObservations(right, dimension),
),
dimensions[dimension] === "unknown"
? null
: firstCollectionDivergence(
dimension,
processDimensionObservations(left, dimension),
processDimensionObservations(right, dimension),
),
);
const observedFirstDivergence = chooseFirstDivergence(
divergenceCandidates.map((candidate) =>
@@ -454,22 +474,30 @@ export const compareProcessCaptures = (
),
);
const firstDivergence =
trace?.verdict === "equivalent" &&
deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => dimensions[dimension]),
) === "unchanged"
? ({ status: "none" } as const)
: observedFirstDivergence.status === "found"
? observedFirstDivergence
: !sameNormalization ||
left.residual_unknowns.length > 0 ||
right.residual_unknowns.length > 0
? ({
status: "unknown",
reason:
"Residual unknowns prevent proving that no divergence occurred.",
} as const)
: observedFirstDivergence;
left.truncated || right.truncated
? ({
status: "unknown",
reason:
"Incomplete observations prevent locating the first divergence; unaffected dimensions retain their comparisons.",
} as const)
: trace?.verdict === "equivalent" &&
deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map(
(dimension) => dimensions[dimension],
),
) === "unchanged"
? ({ status: "none" } as const)
: observedFirstDivergence.status === "found"
? observedFirstDivergence
: !sameNormalization ||
left.residual_unknowns.length > 0 ||
right.residual_unknowns.length > 0
? ({
status: "unknown",
reason:
"Residual unknowns prevent proving that no divergence occurred.",
} as const)
: observedFirstDivergence;
const observedStatus = deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => dimensions[dimension]),
);
+2 -1
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { JsonValue } from "../jsonValue.js";
import type { ProcessCapture } from "./processCapture.js";
import { processSourceTruncated } from "./processCaptureCoverage.js";
import {
processObservationLocationSchema,
projectProcessObservation,
@@ -414,7 +415,7 @@ export const evaluateProcessTraceSide = (
const sources = new Set(specification.events.map(({ source }) => source));
const relevantScopes = scopesFor(sources);
const incomplete =
capture.truncated ||
[...sources].some((source) => processSourceTruncated(capture, source)) ||
capture.residual_unknowns.some(({ scope }) => relevantScopes.has(scope));
if (capture.event_journal.length === 0)
return {
+39 -2
View File
@@ -7,12 +7,14 @@ import type {
ProcessScenario,
} from "../../domain/process/processCapture.js";
import type { Stats } from "node:fs";
import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js";
export interface SnapshotResult {
readonly files: readonly FileState[];
readonly truncated: boolean;
/** Root aliases whose path enumeration was exhausted, regardless of hash coverage. */
readonly completeRoots: readonly string[];
readonly coverage?: FilesystemCoverage;
}
const hasSameIdentity = (
@@ -97,6 +99,10 @@ export const snapshotRoots = async (
const completeRoots: string[] = [];
let remainingBytes = scenario.limits.file_bytes;
let truncated = false;
const enumerationReasons = new Set<
FilesystemCoverage["enumeration_reasons"][number]
>();
const hashOmissions: FilesystemCoverage["hash_omissions"] = [];
const visit = async (
root: string,
rootAlias: string,
@@ -109,6 +115,10 @@ export const snapshotRoots = async (
depth > scenario.limits.filesystem_depth
) {
truncated = true;
if (entries.length >= scenario.limits.files)
enumerationReasons.add("files_limit");
if (depth > scenario.limits.filesystem_depth)
enumerationReasons.add("depth_limit");
return false;
}
const stats = await lstatIfPresent(path);
@@ -119,6 +129,7 @@ export const snapshotRoots = async (
const afterRead = await lstat(path);
if (!hasSameIdentity(stats, afterRead, "symlink")) {
truncated = true;
enumerationReasons.add("identity_changed");
return false;
}
entries.push({
@@ -137,7 +148,18 @@ export const snapshotRoots = async (
? await hashFile(path, stats, remainingBytes, signal)
: null;
remainingBytes -= sha256 === null ? 0 : stats.size;
if (sha256 === null) truncated = true;
if (sha256 === null) {
truncated = true;
hashOmissions.push({
path: `${rootAlias}:${relativePath}`,
size_bytes: stats.size,
remaining_budget_bytes: remainingBytes,
reason:
remainingBytes < stats.size
? "file_bytes_budget"
: "file_changed_or_short_read",
});
}
entries.push({
path: `${rootAlias}:${relativePath}`,
type: "file",
@@ -162,6 +184,7 @@ export const snapshotRoots = async (
const afterRead = await lstat(path);
if (!hasSameIdentity(stats, afterRead, "directory")) {
truncated = true;
enumerationReasons.add("identity_changed");
return false;
}
let complete = true;
@@ -179,5 +202,19 @@ export const snapshotRoots = async (
}
if (await visit(root, alias, root, 0)) completeRoots.push(alias);
}
return { files: entries, truncated, completeRoots };
return {
files: entries,
truncated,
completeRoots,
coverage: {
files_limit: scenario.limits.files,
depth_limit: scenario.limits.filesystem_depth,
enumeration_truncated:
completeRoots.length !== scenario.filesystem_observation_paths.length,
enumeration_reasons: [...enumerationReasons],
hash_budget_bytes: scenario.limits.file_bytes,
hashed_bytes: scenario.limits.file_bytes - remainingBytes,
hash_omissions: hashOmissions,
},
};
};
+81 -5
View File
@@ -59,6 +59,11 @@ import {
} from "./ProcessNormalization.js";
import { PROCESS_PROVIDER } from "../../domain/process/processEvidenceProvider.js";
import { TerminalRenderer } from "./TerminalRenderer.js";
import {
hasCaptureTruncation,
type TerminalRetention,
type ProcessCaptureTruncationDetails,
} from "../../domain/process/processCaptureCoverage.js";
import { scheduleProcessInterval, type ProcessTimer } from "./ProcessTimer.js";
interface TerminalExitOptions {
@@ -84,6 +89,7 @@ interface CaptureResultOptions {
readonly before: SnapshotResult;
readonly after: SnapshotResult;
readonly truncated: boolean;
readonly truncationDetails?: ProcessCaptureTruncationDetails;
readonly scenario: ProcessScenario;
readonly rootPid: number;
readonly samplingPartial: boolean;
@@ -279,7 +285,13 @@ export const buildCaptureResult = (
files_before: options.before.files,
files_after: options.after.files,
filesystem_effects: filesystemEffects,
truncated: options.truncated,
truncated:
options.truncationDetails === undefined
? options.truncated
: hasCaptureTruncation(options.truncationDetails),
...(options.truncationDetails === undefined
? {}
: { truncation_details: options.truncationDetails }),
limitations: [
"The executable digest is a prelaunch file sample; matching path metadata immediately after spawn does not prove an atomic operating-system image binding.",
"Process trees are sampled and may omit short-lived descendants.",
@@ -290,6 +302,11 @@ export const buildCaptureResult = (
"Inherited host environment variables are not recorded and may affect results.",
...(!hasFilesystemObservations ? [filesystemObservationUnknown] : []),
...(hasUnknownFilesystemEffects ? [incompleteFilesystemUnknown] : []),
...(options.truncationDetails === undefined
? []
: captureCoverageUnknowns(options.truncationDetails).map(
({ reason }) => reason,
)),
...(hasSensitiveScriptedInput ? [sensitiveInputUnknown] : []),
],
residual_unknowns: [
@@ -330,10 +347,61 @@ export const buildCaptureResult = (
...(hasSensitiveScriptedInput
? [{ scope: "interaction" as const, reason: sensitiveInputUnknown }]
: []),
...(options.truncationDetails === undefined
? []
: captureCoverageUnknowns(options.truncationDetails).filter(
({ scope }) => scope !== "terminal",
)),
],
};
};
const captureCoverageUnknowns = (
details: ProcessCaptureTruncationDetails,
): UnverifiedProcessCapture["residual_unknowns"] => {
const unknowns: Array<UnverifiedProcessCapture["residual_unknowns"][number]> =
[];
if (
details.raw_terminal.observed_frames > details.raw_terminal.retained_frames
)
unknowns.push({
scope: "terminal",
reason:
"Raw PTY chunks exceeded output_bytes and were omitted; rendered states also lack those inputs. See truncation_details.raw_terminal.",
});
if (
details.rendered_terminal.observed_frames >
details.rendered_terminal.retained_frames
)
unknowns.push({
scope: "terminal",
reason:
"Cumulative rendered state and visible-line bytes exceeded output_bytes; whole rendered frames were omitted. Raw PTY coverage is reported separately. See truncation_details.rendered_terminal.",
});
for (const [name, coverage] of [
["before", details.filesystem_before],
["after_settlement", details.filesystem_after],
] as const) {
if (coverage.enumeration_truncated)
unknowns.push({
scope: "filesystem",
reason: `Filesystem ${name} path enumeration was incomplete: ${coverage.enumeration_reasons.join(", ")}. See truncation_details.`,
});
if (coverage.hash_omissions.length > 0)
unknowns.push({
scope: "filesystem",
reason: `Filesystem ${name} omitted ${String(coverage.hash_omissions.length)} whole-file digests; per-path reasons and remaining file_bytes budget are in truncation_details.`,
});
}
if (details.process.sampling_partial)
unknowns.push({
scope: "process",
reason:
"Process sampling stopped with incomplete observations; sample_limit is reported in truncation_details.process.",
});
return unknowns;
};
interface ExecutableFileIdentity {
readonly dev: bigint;
readonly ino: bigint;
@@ -840,14 +908,16 @@ export const captureTerminalFrames = (options: {
readonly onOutput: () => void;
readonly renderer: TerminalRenderer;
readonly recordEvent: RecordProcessCaptureEvent;
}): (() => boolean) => {
}): (() => TerminalRetention) => {
let outputBytes = 0;
let truncated = false;
let observedBytes = 0;
let observedFrames = 0;
options.terminal.onData((data) => {
options.onOutput();
const bytes = Buffer.byteLength(data);
observedBytes += bytes;
observedFrames += 1;
if (outputBytes + bytes > options.scenario.limits.output_bytes) {
truncated = true;
return;
}
outputBytes += bytes;
@@ -871,7 +941,13 @@ export const captureTerminalFrames = (options: {
options.renderer.write(data, atMs);
options.recordEvent("frames", sequence);
});
return () => truncated;
return () => ({
budget_bytes: options.scenario.limits.output_bytes,
observed_bytes: observedBytes,
retained_bytes: outputBytes,
observed_frames: observedFrames,
retained_frames: options.frames.length,
});
};
export const resolveProcessResult = (
+20 -6
View File
@@ -1,4 +1,5 @@
import type { IPty } from "@lydell/node-pty";
import type { TerminalRetention } from "../../domain/process/processCaptureCoverage.js";
import type {
InteractionEvent,
ProcessCapture,
@@ -65,8 +66,8 @@ interface StartedCaptureRuntime {
readonly startedAt: Date;
readonly executableIdentity: ReturnType<typeof observeLaunchedExecutable>;
readonly lastOutput: () => number;
readonly framesTruncated: () => boolean;
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
readonly rawTerminalRetention: () => TerminalRetention;
readonly stopSampler: ReturnType<typeof startProcessSampler>;
}
const cleanupFailedStartup = async (options: {
@@ -200,7 +201,7 @@ const startCaptureRuntime = async (
scenario.executable,
selectedExecutable,
);
const framesTruncated = captureTerminalFrames({
const rawTerminalRetention = captureTerminalFrames({
...options,
terminal,
started,
@@ -228,7 +229,7 @@ const startCaptureRuntime = async (
startedAt,
executableIdentity,
lastOutput: () => lastOutput,
framesTruncated,
rawTerminalRetention,
stopSampler,
};
} catch (cause: unknown) {
@@ -365,7 +366,8 @@ const completeCapture = async (options: {
state: "available",
value: settlement,
};
const samplingPartial = (await runtime.stopSampler()).partial;
const sampling = await runtime.stopSampler();
const samplingPartial = sampling.partial;
await settleProcessCaptureJournal(options.eventJournal);
assertNotCancelled(options.signal);
let after: Awaited<ReturnType<typeof snapshotRoots>>;
@@ -439,7 +441,8 @@ const completeCapture = async (options: {
const truncated =
options.initiallyTruncated ||
after.truncated ||
runtime.framesTruncated() ||
runtime.rawTerminalRetention().observed_frames >
runtime.rawTerminalRetention().retained_frames ||
checkpoints.some(({ truncated: partial }) => partial) ||
samplingPartial ||
runtime.renderer.truncated();
@@ -450,6 +453,17 @@ const completeCapture = async (options: {
before: options.before,
after,
truncated,
...(options.before.coverage === undefined || after.coverage === undefined
? {}
: {
truncationDetails: {
raw_terminal: runtime.rawTerminalRetention(),
rendered_terminal: runtime.renderer.retention(),
filesystem_before: options.before.coverage,
filesystem_after: after.coverage,
process: sampling.coverage,
},
}),
scenario,
rootPid: runtime.terminal.pid,
samplingPartial,
+30 -3
View File
@@ -1,4 +1,5 @@
import { readFile, readdir } from "node:fs/promises";
import type { ProcessSamplingCoverage } from "../../domain/process/processCaptureCoverage.js";
import type {
ProcessSample,
RecordProcessCaptureEvent,
@@ -455,7 +456,10 @@ export const startProcessSampler = (options: {
readonly limit: number;
readonly samples: ProcessSample[];
readonly recordEvent?: RecordProcessCaptureEvent;
}): (() => Promise<{ readonly partial: boolean }>) => {
}): (() => Promise<{
readonly partial: boolean;
readonly coverage: ProcessSamplingCoverage;
}>) => {
const { rootPid, runId, started, limit, samples } = options;
const identities = new Map<number, string>();
const lastObservations = new Map<number, string>();
@@ -463,6 +467,9 @@ export const startProcessSampler = (options: {
let stopped = false;
let abortCurrent: (() => void) | undefined;
let partial = false;
let limitReached = false;
let samplingFailures = 0;
let firstSamplingFailure: string | null = null;
let rootInitialized = false;
const sample = (): void => {
@@ -501,6 +508,7 @@ export const startProcessSampler = (options: {
if (lastObservations.get(value.pid) === observation) continue;
if (samples.length >= limit) {
partial = true;
limitReached = true;
continue;
}
lastObservations.set(value.pid, observation);
@@ -510,8 +518,16 @@ export const startProcessSampler = (options: {
}
})
.catch((cause: unknown) => {
if (!(cause instanceof Error && cause.name === "AbortError"))
if (!(cause instanceof Error && cause.name === "AbortError")) {
partial = true;
samplingFailures += 1;
firstSamplingFailure ??=
cause instanceof Error
? `${cause.name}: ${cause.message}`
: typeof cause === "string"
? cause
: `Unexpected sampling rejection (${typeof cause})`;
}
})
.then(() => {
abortCurrent = undefined;
@@ -527,6 +543,17 @@ export const startProcessSampler = (options: {
clearInterval(timer);
abortCurrent?.();
await pending;
return { partial };
return {
partial,
coverage: {
sample_limit: limit,
sampling_partial: partial,
retained_samples: samples.length,
sample_limit_reached: limitReached,
sampling_failures: samplingFailures,
first_sampling_failure: firstSamplingFailure,
coverage: "sampled",
},
};
};
};
+16
View File
@@ -1,4 +1,5 @@
import { createRequire } from "node:module";
import type { TerminalRetention } from "../../domain/process/processCaptureCoverage.js";
import type {
RecordProcessCaptureEvent,
@@ -36,6 +37,8 @@ export class TerminalRenderer {
readonly #frames: RenderedTerminalFrame[] = [];
#pending: Promise<void> = Promise.resolve();
#capturedBytes = 0;
#observedBytes = 0;
#observedFrames = 0;
#truncated = false;
constructor(private readonly options: TerminalRendererOptions) {
@@ -80,6 +83,17 @@ export class TerminalRenderer {
return this.#truncated;
}
/** Budget accounting after awaiting queued writes with frames(). */
retention(): TerminalRetention {
return {
budget_bytes: this.options.maxBytes,
observed_bytes: this.#observedBytes,
retained_bytes: this.#capturedBytes,
observed_frames: this.#observedFrames,
retained_frames: this.#frames.length,
};
}
/** Release addon and terminal resources after all writes settle. */
async dispose(): Promise<void> {
await this.#pending;
@@ -107,6 +121,8 @@ export class TerminalRenderer {
const bytes =
Buffer.byteLength(serializedState) +
lines.reduce((total, line) => total + Buffer.byteLength(line), 0);
this.#observedBytes += bytes;
this.#observedFrames += 1;
if (this.#capturedBytes + bytes > this.options.maxBytes) {
this.#truncated = true;
return;
+12 -1
View File
@@ -122,7 +122,18 @@ class ErrorEvidenceTransport implements Transport {
: undefined;
const tool = id === undefined ? undefined : this.pendingTools.get(id);
try {
await this.transport.send(this.recoverError(message, tool), options);
const recovered = this.recoverError(message, tool);
if (
isJSONRPCResultResponse(recovered) &&
isCallToolResult(recovered.result) &&
recovered.result.isError === true
) {
// Keep the complete typed JSON in content. Error projections are not
// success outputSchema data; oversized recovery must consume their
// private structured carrier before it is removed from the wire.
const { structuredContent: _diagnostic, ...result } = recovered.result;
await this.transport.send({ ...recovered, result }, options);
} else await this.transport.send(recovered, options);
} finally {
if (!isJSONRPCRequest(message) && id !== undefined)
this.pendingTools.delete(id);
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -67,8 +68,8 @@ for (const { jarConfigured, setting } of [
arguments: { path: apk },
});
expect(response.isError).toBe(true);
expect(response.structuredContent).toEqual({ error: cliResult.json });
expect(JSON.stringify(response.structuredContent)).toContain(setting);
expect(parseMcpToolError(response)).toEqual({ error: cliResult.json });
expect(JSON.stringify(parseMcpToolError(response))).toContain(setting);
},
);
@@ -92,8 +93,8 @@ cliTest.skipIf(process.platform !== "linux")(
arguments: { path },
});
expect(response.isError).toBe(true);
expect(response.structuredContent).toEqual({ error: cliResult.json });
expect(JSON.stringify(response.structuredContent)).toContain(
expect(parseMcpToolError(response)).toEqual({ error: cliResult.json });
expect(JSON.stringify(parseMcpToolError(response))).toContain(
"REA_BINWALK_COMMAND",
);
},
@@ -145,8 +146,8 @@ cliTest(
arguments: { path, output_directory: join(root, "output") },
});
expect(response.isError).toBe(true);
expect(response.structuredContent).toEqual({ error: cliResult.json });
expect(JSON.stringify(response.structuredContent)).toContain(
expect(parseMcpToolError(response)).toEqual({ error: cliResult.json });
expect(JSON.stringify(parseMcpToolError(response))).toContain(
"REA_WAKARU_COMMAND",
);
},
@@ -183,7 +184,7 @@ cliTest(
});
expect(result.exitCode).toBe(1);
expect(response.isError).toBe(true);
expect(response.structuredContent).toEqual({ error: result.json });
expect(parseMcpToolError(response)).toEqual({ error: result.json });
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
@@ -7,6 +7,7 @@ import { expect, it } from "vitest";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../../src/domain/webNetworkCapture.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
@@ -56,7 +57,7 @@ it("reports real HAR heap exhaustion through CLI and MCP and preserves server us
sensitive_values: ["caller-marked"],
});
expect(response.isError).toBe(true);
expect(response.structuredContent).toMatchObject({
expect(parseMcpToolError(response)).toMatchObject({
error: {
code: "resource_constraint",
retryable: false,
@@ -110,7 +111,7 @@ it("reports real HAR heap exhaustion through CLI and MCP and preserves server us
capture_path: path,
format: "har",
});
expect(oversized.structuredContent).toMatchObject({
expect(parseMcpToolError(oversized)).toMatchObject({
error: { code: "invalid_request" },
});
}, 60_000);
@@ -6,6 +6,7 @@ import { expect, it, onTestFinished } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("observes a real Node Inspector and releases attachments after cancellation and failures", async () => {
@@ -62,7 +63,7 @@ it("observes a real Node Inspector and releases attachments after cancellation a
target_id: "../missing-target",
});
expect(missing.isError).toBe(true);
expect(missing.structuredContent).toMatchObject({
expect(parseMcpToolError(missing)).toMatchObject({
error: {
code: "target_unavailable",
message: expect.stringContaining("Refresh target discovery"),
@@ -114,7 +115,7 @@ it("observes a real Node Inspector and releases attachments after cancellation a
inspector_endpoint: endpoint,
});
expect(unreachable.isError).toBe(true);
expect(unreachable.structuredContent).toMatchObject({
expect(parseMcpToolError(unreachable)).toMatchObject({
error: {
code: "provider_unavailable",
message: expect.stringContaining(
@@ -9,6 +9,7 @@ import { toolContract } from "../../../src/contracts/toolContracts.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { itWithCaptureCapability as captureTest } from "../../boundary/process/processCaptureCapability.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
@@ -180,7 +181,7 @@ captureTest(
const failed = await call("capture_process_scenario", {
executable: missing,
});
expect(failed.structuredContent).toMatchObject({
expect(parseMcpToolError(failed)).toMatchObject({
error: {
code: "process_capture_failed",
message: expect.stringContaining(missing),
@@ -206,16 +207,16 @@ captureTest(
60_000,
);
captureTest.each(["unknown", "truncated"] as const)(
captureTest.each(["untruncated", "truncated"] as const)(
"captures real process choices and composes a %s self-comparison",
async (status) => {
async (captureState) => {
const root = await createTestTempDirectory("rea-local-capture-");
const { call } = await connectLocalToolsMcp();
const captured = await call("capture_process_scenario", {
executable: process.execPath,
arguments: [
"-e",
`require('node:fs').writeFileSync('state','selected');console.log(process.env['app.setting'],process.env.MY_PASSWORD,process.env['REA_PROCESS_RUN_ID'+String.fromCharCode(10)]);${status === "truncated" ? "console.log('x'.repeat(5000))" : ""}`,
`require('node:fs').writeFileSync('state','selected');console.log(process.env['app.setting'],process.env.MY_PASSWORD,process.env['REA_PROCESS_RUN_ID'+String.fromCharCode(10)]);${captureState === "truncated" ? "console.log('x'.repeat(5000))" : ""}`,
],
working_directory: root,
environment: {
@@ -225,14 +226,14 @@ captureTest.each(["unknown", "truncated"] as const)(
"REA_PROCESS_RUN_ID\n": "caller-value",
},
filesystem_observation_paths: [root],
limits: status === "truncated" ? { output_bytes: 128 } : {},
limits: captureState === "truncated" ? { output_bytes: 128 } : {},
});
expect(captured.isError, JSON.stringify(captured)).not.toBe(true);
const capture = toolContract("capture_process_scenario").outputSchema.parse(
captured.structuredContent,
);
const source = capture;
if (status === "unknown")
if (captureState === "untruncated")
expect(
capture.normalized_result.frames.map((frame) => frame.data).join(""),
).toContain("value ordinary-evidence caller-value");
@@ -265,7 +266,13 @@ captureTest.each(["unknown", "truncated"] as const)(
const comparison = toolContract(
"compare_process_captures",
).outputSchema.parse(compared.structuredContent);
expect(comparison.normalized_result.status).toBe(status);
expect(comparison.normalized_result).toMatchObject({
status: "unknown",
terminal: captureState === "truncated" ? "unknown" : "unchanged",
interaction: "unchanged",
exit: "unchanged",
process: "unknown",
});
const verification = await call("verify_reconstruction", {
specification: {
name: "Self comparison retains uncertainty",
@@ -303,7 +310,7 @@ captureTest.each(["unknown", "truncated"] as const)(
});
expect(behavior.isError, JSON.stringify(behavior)).not.toBe(true);
expect(behavior.structuredContent).toMatchObject({
normalized_result: { behavior_status: status },
normalized_result: { behavior_status: "unknown" },
});
const unknowns = await call("list_unknowns", {
domain: "process-comparison",
@@ -55,6 +55,24 @@ const cases: readonly {
files: { "app.js": "globalThis.owned = true;" },
expected: ["app.js"],
},
{
name: "percent-encoded script letters",
html: '<script src="%61pp.js"></script>',
files: { "app.js": "globalThis.owned = true;" },
expected: ["app.js"],
},
{
name: "script URL surrounded by whitespace",
html: '<script src=" app.js "></script>',
files: { "app.js": "globalThis.owned = true;" },
expected: ["app.js"],
},
{
name: "percent-encoded base directory",
html: '<base href="%61ssets/"><script src="app.js"></script>',
files: { "assets/app.js": "globalThis.owned = true;" },
expected: ["assets/app.js"],
},
];
it.each(cases)(
+4 -6
View File
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it, onTestFinished } from "vitest";
@@ -161,7 +162,7 @@ it("retains an oversized selected observation and keeps subsequent views usable"
}),
}),
})
.parse(response.structuredContent).error.details
.parse(parseMcpToolError(response)).error.details
.reported_limits.evidence_reference;
expect(
session.evidenceById(reference.evidence_id)?.normalized_result,
@@ -219,11 +220,8 @@ it("returns a JavaScript summary without graph payloads from a retained referenc
view: { kind: "summary" },
},
});
expect(stale).toMatchObject({
isError: true,
structuredContent: {
error: { details: { reason: "missing" } },
},
expect(parseMcpToolError(stale)).toMatchObject({
error: { details: { reason: "missing" } },
});
});
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import type { Client } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
@@ -122,16 +123,13 @@ async function assertRejectedEvidenceReferences(
application: record,
},
});
expect(rejected).toMatchObject({
isError: true,
structuredContent: {
// A recorded Evidence with the wrong operation or predicate is an
// invalid caller input, not a provider failure.
error: {
code: "invalid_request",
category: "invalid_input",
retryable: true,
},
expect(parseMcpToolError(rejected)).toMatchObject({
// A recorded Evidence with the wrong operation or predicate is an
// invalid caller input, not a provider failure.
error: {
code: "invalid_request",
category: "invalid_input",
retryable: true,
},
});
}
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -64,7 +65,7 @@ it("inspects a standalone keyed archive through MCP with original object identit
arguments: { path: "other.plist" },
});
expect(rejected.isError).toBe(true);
expect(rejected.structuredContent).toMatchObject({
expect(parseMcpToolError(rejected)).toMatchObject({
error: {
code: "invalid_request",
details: {
@@ -83,7 +84,7 @@ it("inspects a standalone keyed archive through MCP with original object identit
arguments: { root: "missing" },
});
expect(missingRoot.isError).toBe(true);
expect(missingRoot.structuredContent).toMatchObject({
expect(parseMcpToolError(missingRoot)).toMatchObject({
error: {
code: "invalid_request",
details: {
+2 -1
View File
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { ok as resultOk } from "../../../src/domain/result.js";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
@@ -64,7 +65,7 @@ it.each([
arguments: {},
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
expect(parseMcpToolError(result)).toEqual({
error: {
code: "artifact_integrity_mismatch",
category: "integrity_mismatch",
+2 -1
View File
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { ok as resultOk } from "../../../src/domain/result.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
@@ -274,7 +275,7 @@ it("does not attach to a target outside the request's allowed origin scope", asy
},
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toMatchObject({
expect(parseMcpToolError(result)).toMatchObject({
error: {
details: {
operation: "inspect_web_page",
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { ok as resultOk } from "../../../src/domain/result.js";
import { rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -131,7 +132,7 @@ describe("bundle comparison MCP integration", () => {
},
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toMatchObject({
expect(parseMcpToolError(result)).toMatchObject({
error: {
code: "evidence_integrity_mismatch",
message: expect.stringContaining(
@@ -1,10 +1,12 @@
import { execFile } from "node:child_process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { isCallToolResult } from "@modelcontextprotocol/server";
import { expect, it } from "vitest";
import { z } from "zod";
import { GENERATED_MCP_TOOL_CATALOG } from "../../fixtures/mcpToolCatalog.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
const execute = promisify(execFile);
const fixture = fileURLToPath(
@@ -41,6 +43,8 @@ it("preserves advertised schemas and handler meaning when presentation order cha
expect(advertised.outputSchema).toEqual(canonical?.outputSchema);
}
for (const { name, response } of result.responses) {
if (!isCallToolResult(response))
throw new Error(`Invalid tools/call fixture result for ${name}`);
if (name === "reconcile_javascript_runtime") {
expect(response.isError).not.toBe(true);
expect(response.structuredContent).toMatchObject({
@@ -48,7 +52,7 @@ it("preserves advertised schemas and handler meaning when presentation order cha
});
} else {
expect(response.isError, name).toBe(true);
expect(response.structuredContent, name).toMatchObject({
expect(parseMcpToolError(response), name).toMatchObject({
error: { details: { operation: name } },
});
}
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -107,7 +108,7 @@ it("traces dylib resolution for an opened app bundle and rejects a changed targe
arguments: {},
});
expect(changed.isError).toBe(true);
expect(JSON.stringify(changed.structuredContent)).toContain("integrity");
expect(JSON.stringify(parseMcpToolError(changed))).toContain("integrity");
});
});
@@ -126,7 +127,7 @@ it("rejects targets that are not Mach-O", async () => {
arguments: {},
});
expect(called.isError).toBe(true);
expect(called.structuredContent).toMatchObject({
expect(parseMcpToolError(called)).toMatchObject({
error: { code: "unsupported_target", category: "unsupported_target" },
});
});
@@ -153,7 +154,7 @@ it("reports target kinds that artifact operations cannot inspect as unsupported"
const open = { name: "open_binary", arguments: { path } };
expect((await client.callTool(open)).isError, path).not.toBe(true);
const called = await client.callTool({ name: operation, arguments: {} });
expect(called.structuredContent, operation).toMatchObject({
expect(parseMcpToolError(called), operation).toMatchObject({
error: {
code: "unsupported_target",
message: expect.stringContaining(`${operation} at ${path}`),
@@ -230,7 +231,7 @@ it.skipIf(process.getuid?.() === 0)(
arguments: {},
});
expect(called.isError).toBe(true);
expect(JSON.stringify(called.structuredContent)).toMatch(
expect(JSON.stringify(parseMcpToolError(called))).toMatch(
/Permission denied \(EACCES\)/u,
);
} finally {
@@ -442,7 +443,7 @@ it("reports caller-selected roots that cannot be traced as invalid input", async
arguments: { roots: ["Contents/MacOS/App", root] },
});
expect(called.isError, root).toBe(true);
expect(called.structuredContent, root).toMatchObject({
expect(parseMcpToolError(called), root).toMatchObject({
error: {
code: "invalid_request",
details: {
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { afterEach, describe, expect, it } from "vitest";
import {
@@ -109,7 +110,7 @@ describe("enhanced MCP tools", () => {
expect(result.isError).toBe(true);
const text = result.content.find((item) => item.type === "text");
expect(text?.type === "text" ? text.text : "").toBe(
JSON.stringify(result.structuredContent),
JSON.stringify(parseMcpToolError(result)),
);
});
});
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { afterEach, describe, expect, it } from "vitest";
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
@@ -26,7 +27,7 @@ describe("analyze_function MCP producer validation", () => {
expect(result.isError).toBe(true);
expect(result.content).toContainEqual({
type: "text",
text: JSON.stringify(result.structuredContent),
text: JSON.stringify(parseMcpToolError(result)),
});
});
@@ -55,7 +56,7 @@ describe("analyze_function MCP producer validation", () => {
expect(result.isError).toBe(true);
expect(result.content).toContainEqual({
type: "text",
text: JSON.stringify(result.structuredContent),
text: JSON.stringify(parseMcpToolError(result)),
});
});
});
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { afterEach, describe, expect, it } from "vitest";
import { closeEnhancedToolResources, connect } from "./enhancedToolsHarness.js";
@@ -41,7 +42,7 @@ describe("enhanced MCP input validation", () => {
expect(result.isError).toBe(true);
const text = result.content.find((item) => item.type === "text");
expect(text?.type === "text" ? text.text : "").toBe(
JSON.stringify(result.structuredContent),
JSON.stringify(parseMcpToolError(result)),
);
});
});
+8 -2
View File
@@ -1,6 +1,10 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { type JsonValue } from "../../../src/domain/jsonValue.js";
import {
jsonValueSchema,
type JsonValue,
} from "../../../src/domain/jsonValue.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
import { createServer } from "../../../src/server/createServer.js";
@@ -38,4 +42,6 @@ export const closeEnhancedToolResources = async (): Promise<void> => {
};
export const jsonResult = (result: CallToolResult): JsonValue =>
parseEvidence(result.structuredContent).normalized_result;
result.isError === true
? jsonValueSchema.parse(parseMcpToolError(result))
: parseEvidence(result.structuredContent).normalized_result;
+2 -1
View File
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { describe, expect, it } from "vitest";
import { EnhancedTools } from "../../../src/application/EnhancedTools.js";
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
@@ -52,7 +53,7 @@ it("rejects contradictory annotation readback across the provider and MCP bounda
},
});
expect(reply.isError).toBe(true);
expect(reply.structuredContent).toMatchObject({
expect(parseMcpToolError(reply)).toMatchObject({
error: { code: "unreadable_output" },
});
}
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { expect, it } from "vitest";
import { logCliCommand } from "../../../src/cliLogging.js";
@@ -64,7 +65,7 @@ it("preserves queue failure diagnostics through the provider, CLI adapter and SD
arguments: {},
});
expect(mcp.isError).toBe(true);
expect(mcp.structuredContent).toMatchObject({ error: parsed });
expect(parseMcpToolError(mcp)).toMatchObject({ error: parsed });
expect(mcp.content).toContainEqual(
expect.objectContaining({
type: "text",
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { expect, it } from "vitest";
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
import { ok } from "../../../src/domain/result.js";
@@ -100,11 +101,11 @@ it("rejects contradictory provider output before emitting MCP Evidence", async (
arguments: probe.arguments,
});
expect(rejected.isError, probe.name).toBe(true);
expect(rejected.structuredContent).toMatchObject({
expect(parseMcpToolError(rejected)).toMatchObject({
error: { code: "unreadable_output" },
});
expect(rejected.structuredContent).not.toHaveProperty("evidence_id");
expect(rejected.structuredContent).not.toHaveProperty("result");
expect(parseMcpToolError(rejected)).not.toHaveProperty("evidence_id");
expect(parseMcpToolError(rejected)).not.toHaveProperty("result");
}
} finally {
await harness.close();
+2 -1
View File
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { describe, expect, it } from "vitest";
import { connectGhidraMcp } from "./ghidraMcpHarness.js";
@@ -28,7 +29,7 @@ describe("Ghidra MCP capability routing", () => {
arguments: { address: "0x1000", comment: "must not mutate" },
});
expect(denied.isError).toBe(true);
expect(denied.structuredContent).toMatchObject({
expect(parseMcpToolError(denied)).toMatchObject({
error: {
code: "capability_unavailable",
category: "unsupported_provider",
+2 -1
View File
@@ -25,6 +25,7 @@ import { registerOfficialTools } from "../../../src/server/registerOfficialTools
import { ToolResultDelivery } from "../../../src/server/toolResult.js";
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
import { encodeToolResult } from "../../../src/server/toolResultEncoding.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
const delivery = new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE);
@@ -188,7 +189,7 @@ describe("large complete Evidence MCP delivery", () => {
}),
}),
})
.parse(result.structuredContent).error.details.reported_limits;
.parse(parseMcpToolError(result)).error.details.reported_limits;
const retained = parseEvidence(
records.get(limits.evidence_reference.evidence_id),
);
+4 -5
View File
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -218,11 +219,8 @@ const verifyManagedCatalogAndNativeWorkflow = async (
],
},
});
expect(wrong).toMatchObject({
isError: true,
structuredContent: {
error: { code: "evidence_integrity_mismatch" },
},
expect(parseMcpToolError(wrong)).toMatchObject({
error: { code: "evidence_integrity_mismatch" },
});
};
@@ -426,6 +424,7 @@ const verifyImport = async (
};
const structured = (result: CallToolResult): Record<string, unknown> => {
if (result.isError === true) return parseMcpToolError(result);
if (
typeof result.structuredContent !== "object" ||
result.structuredContent === null
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -260,7 +261,7 @@ it.each([
});
expect(called.isError).toBe(true);
expect(called.structuredContent).toMatchObject({
expect(parseMcpToolError(called)).toMatchObject({
error: {
code: expectedCode,
details: {
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
@@ -40,7 +41,7 @@ it("rejects changed signature targets through MCP and accepts reopening the new
arguments: {},
});
expect(changed.isError).toBe(true);
expect(changed.structuredContent).toMatchObject({
expect(parseMcpToolError(changed)).toMatchObject({
error: { code: "artifact_changed", details: { path } },
});
expect((await open()).isError).not.toBe(true);
+2 -1
View File
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { Ajv2020 } from "ajv/dist/2020.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, describe, expect, it } from "vitest";
@@ -140,7 +141,7 @@ describe("official Hopper proxy tools", () => {
expect(result.isError).toBe(true);
expect(result.content[0]).toEqual({
type: "text",
text: JSON.stringify(result.structuredContent),
text: JSON.stringify(parseMcpToolError(result)),
});
});
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { McpServer } from "@modelcontextprotocol/server";
@@ -138,7 +139,7 @@ mcpTest.for(failedAdapters)(
arguments: example.input,
});
expect(unavailable.isError).toBe(true);
expect(unavailable.structuredContent).toMatchObject({
expect(parseMcpToolError(unavailable)).toMatchObject({
error: {
details: {
provider_id: providerId,
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import {
STDIO_DEFAULT_MAX_BUFFER_SIZE,
@@ -9,7 +10,8 @@ import { z } from "zod";
import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js";
import type { EvidenceWriter } from "../../../src/application/investigation/InvestigationRecordPort.js";
import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js";
import { err } from "../../../src/domain/result.js";
import type { Evidence } from "../../../src/domain/evidence.js";
import { err, ok } from "../../../src/domain/result.js";
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
import { ToolResultDelivery } from "../../../src/server/toolResult.js";
@@ -28,6 +30,7 @@ const oversizedFailure = () =>
const exerciseDeliveryFailure = async (
recordEvidence: EvidenceWriter["recordEvidence"] | undefined,
retention: "retained" | "unavailable" | "failed",
) => {
const server = new EvidenceMcpServer(
{ name: "oversized-error-test", version: "1" },
@@ -57,7 +60,8 @@ const exerciseDeliveryFailure = async (
arguments: {},
});
expect(failure.isError).toBe(true);
expect(failure.structuredContent).toMatchObject({
expect(failure.structuredContent).toBeUndefined();
expect(parseMcpToolError(failure)).toMatchObject({
error: {
code: "resource_constraint",
details: {
@@ -76,8 +80,8 @@ const exerciseDeliveryFailure = async (
}),
}),
})
.parse(failure.structuredContent).error.details.reported_limits;
expect(failure.structuredContent).toMatchObject({
.parse(parseMcpToolError(failure)).error.details.reported_limits;
expect(parseMcpToolError(failure)).toMatchObject({
error: {
details: {
reported_limits: {
@@ -86,11 +90,17 @@ const exerciseDeliveryFailure = async (
},
},
});
expect(reportedLimits).not.toHaveProperty("evidence_reference");
expect(reportedLimits.retention).toBe(
recordEvidence === undefined ? "unavailable" : "failed",
);
if (recordEvidence !== undefined)
if (retention === "retained") {
expect(reportedLimits.evidence_reference).toMatchObject({
kind: "retained-evidence",
evidence_id: expect.any(String),
});
expect(reportedLimits).not.toHaveProperty("retention");
} else {
expect(reportedLimits).not.toHaveProperty("evidence_reference");
expect(reportedLimits.retention).toBe(retention);
}
if (retention === "failed")
expect(reportedLimits.retention_error).toMatchObject({
code: "evidence_integrity_mismatch",
});
@@ -109,10 +119,27 @@ const exerciseDeliveryFailure = async (
};
it("keeps the SDK connection usable when oversized-error retention is unavailable or fails", async () => {
await exerciseDeliveryFailure(undefined);
await exerciseDeliveryFailure(() =>
err(
new EvidenceIntegrityError("fixture ledger rejected the Evidence record"),
),
await exerciseDeliveryFailure(undefined, "unavailable");
await exerciseDeliveryFailure(
() =>
err(
new EvidenceIntegrityError(
"fixture ledger rejected the Evidence record",
),
),
"failed",
);
});
it("retains the complete oversized diagnostic before removing its private structured carrier", async () => {
const retained: Evidence[] = [];
await exerciseDeliveryFailure((evidence) => {
retained.push(evidence);
return ok("added");
}, "retained");
expect(retained).toHaveLength(1);
expect(retained[0]?.normalized_result).toEqual(
oversizedFailure().structuredContent,
);
expect(retained[0]?.raw_result).toEqual(oversizedFailure());
});
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -70,7 +71,7 @@ it("names differing comparison contract fields through MCP and the CLI", async (
),
expected: ["working_directory"],
};
expect(response.structuredContent).toMatchObject({
expect(parseMcpToolError(response)).toMatchObject({
error: { code: "invalid_request", details: { issues: [issue] } },
});
@@ -97,7 +98,7 @@ it("names the stale capture that exceeds max_capture_age_ms", async () => {
},
});
expect(response.isError).toBe(true);
expect(response.structuredContent).toMatchObject({
expect(parseMcpToolError(response)).toMatchObject({
error: {
code: "invalid_request",
details: {
@@ -146,7 +147,7 @@ it("names the side and constraint when Evidence is not a usable capture", async
name: "compare_process_captures",
arguments: { left: unrelated, right: capture },
});
expect(wrongKind.structuredContent).toMatchObject({
expect(parseMcpToolError(wrongKind)).toMatchObject({
error: {
code: "invalid_request",
details: {
@@ -170,7 +171,7 @@ it("names the side and constraint when Evidence is not a usable capture", async
right: { ...capture, parameters: { side: "changed" } },
},
});
expect(tampered.structuredContent).toMatchObject({
expect(parseMcpToolError(tampered)).toMatchObject({
error: {
code: "evidence_integrity_mismatch",
message: expect.stringContaining(
@@ -189,7 +190,7 @@ it("names the side and constraint when Evidence is not a usable capture", async
name: "compare_process_captures",
arguments: { left: capture, right: invalidResult },
});
expect(malformed.structuredContent).toMatchObject({
expect(parseMcpToolError(malformed)).toMatchObject({
error: {
code: "evidence_integrity_mismatch",
message: expect.stringContaining(
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -290,7 +291,7 @@ for (const name of providerNames)
for (const { expected: _expected, ...call } of consumerCalls(invalid)) {
const response = await client.callTool(call);
expect(response.isError, call.name).toBe(true);
expect(response.structuredContent, call.name).toMatchObject({
expect(parseMcpToolError(response), call.name).toMatchObject({
error: { code: "invalid_request" },
});
}
@@ -329,7 +330,7 @@ it("separately rejects a tampered digest before admission to any consumer or ses
)) {
const response = await client.callTool(call);
expect(response.isError, call.name).toBe(true);
expect(response.structuredContent).toMatchObject({
expect(parseMcpToolError(response)).toMatchObject({
error: { code: "invalid_request" },
});
}
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -228,4 +229,6 @@ const capability = (provider: ProviderIdentity): CapabilityDescriptor => ({
});
const structured = (result: CallToolResult): Record<string, unknown> =>
z.record(z.string(), z.unknown()).parse(result.structuredContent);
result.isError === true
? parseMcpToolError(result)
: z.record(z.string(), z.unknown()).parse(result.structuredContent);
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { ok as resultOk } from "../../../src/domain/result.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
@@ -43,7 +44,7 @@ describe("reconstruction coverage MCP", () => {
},
});
expect(unknown.isError).toBe(true);
expect(unknown.structuredContent).toMatchObject({
expect(parseMcpToolError(unknown)).toMatchObject({
error: {
code: "invalid_request",
details: {
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, expect, it } from "vitest";
import { Ajv2020 } from "ajv/dist/2020.js";
@@ -136,15 +137,12 @@ it("keeps references session-scoped and expires them when close_binary clears th
},
};
const foreign = await second.client.callTool(request);
expect(foreign).toMatchObject({
isError: true,
structuredContent: {
error: {
details: {
evidence_id: application.evidence_id,
reason: "missing",
actual: null,
},
expect(parseMcpToolError(foreign)).toMatchObject({
error: {
details: {
evidence_id: application.evidence_id,
reason: "missing",
actual: null,
},
},
});
@@ -156,10 +154,9 @@ it("keeps references session-scoped and expires them when close_binary clears th
arguments: {},
});
expect(closed.isError).not.toBe(true);
expect(await first.client.callTool(request)).toMatchObject({
isError: true,
structuredContent: { error: { details: { reason: "missing" } } },
});
expect(parseMcpToolError(await first.client.callTool(request))).toMatchObject(
{ error: { details: { reason: "missing" } } },
);
for (const harness of [first, second]) {
const portable = await harness.client.callTool({
name: "trace_application_feature",
@@ -199,11 +196,8 @@ it("preserves validation for the referenced record's operation, predicate, and s
application: retained(record.evidence_id),
},
});
expect(response).toMatchObject({
isError: true,
structuredContent: {
error: { code: "invalid_request", category: "invalid_input" },
},
expect(parseMcpToolError(response)).toMatchObject({
error: { code: "invalid_request", category: "invalid_input" },
});
}
for (const reference of [
+2 -1
View File
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { ok as resultOk } from "../../../src/domain/result.js";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -275,7 +276,7 @@ describe("active-target availability over MCP", () => {
name: "inspect_macho",
arguments: {},
});
expect(call.structuredContent).toMatchObject({
expect(parseMcpToolError(call)).toMatchObject({
error: { code: "target_unavailable" },
});
await openTarget(client, plist);
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterEach, expect, it } from "vitest";
@@ -37,6 +38,7 @@ const text = (result: CallToolResult): string => {
};
const structured = (result: CallToolResult): Record<string, unknown> => {
if (result.isError === true) return parseMcpToolError(result);
if (
typeof result.structuredContent !== "object" ||
result.structuredContent === null
@@ -61,7 +63,7 @@ it("projects remote failures without provider or bridge details", async () => {
category: "execution_failure",
},
});
expect(text(result)).toBe(JSON.stringify(result.structuredContent));
expect(text(result)).toBe(JSON.stringify(parseMcpToolError(result)));
});
it("lets the SDK validate a tool call before invoking its handler", async () => {
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterEach, expect, it } from "vitest";
@@ -68,6 +69,7 @@ const providerWithCapabilities = (
};
const structured = (result: CallToolResult): Record<string, unknown> => {
if (result.isError === true) return parseMcpToolError(result);
if (
typeof result.structuredContent !== "object" ||
result.structuredContent === null
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { ok as resultOk } from "../../../src/domain/result.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
@@ -37,6 +38,7 @@ const connect = async (analysis: AnalysisOperationPort) => {
};
const structured = (result: CallToolResult): Record<string, unknown> => {
if (result.isError === true) return parseMcpToolError(result);
if (
typeof result.structuredContent !== "object" ||
result.structuredContent === null
+2
View File
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { readFile, realpath, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
@@ -496,6 +497,7 @@ const text = (result: CallToolResult): string => {
};
const structured = (result: CallToolResult): Record<string, unknown> => {
if (result.isError === true) return parseMcpToolError(result);
if (
typeof result.structuredContent !== "object" ||
result.structuredContent === null
+2
View File
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -151,6 +152,7 @@ export const snapshotAndRecordUnknown = async (
};
const structured = (result: CallToolResult): Record<string, unknown> => {
if (result.isError === true) return parseMcpToolError(result);
if (
typeof result.structuredContent !== "object" ||
result.structuredContent === null
@@ -0,0 +1,50 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
it("delivers typed tool failures without violating the advertised success schema over stdio", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-tool-error-schema-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const missing = join(root, "not-present.bin");
const { client, call } = await connectLocalToolsMcp();
const tools = (await client.listTools()).tools;
const failures: string[] = [];
const requests = [
{ name: "list_documents", arguments: {} },
{ name: "open_binary", arguments: { path: missing } },
{
name: "analyze_javascript_application",
arguments: { input_path: missing },
},
{ name: "inspect_artifact", arguments: {} },
];
for (const request of requests) {
const tool = tools.find(({ name }) => name === request.name);
if (tool?.outputSchema === undefined)
throw new Error(`${request.name} omitted its output schema`);
const reply = await client.callTool(request);
const diagnostic = parseMcpToolError(reply);
expect(diagnostic.error.message, request.name).not.toBe("");
if ("path" in request.arguments || "input_path" in request.arguments)
expect(JSON.stringify(diagnostic), request.name).toContain(missing);
if (reply.structuredContent !== undefined) {
const validate = new Ajv2020({
strict: false,
validateFormats: false,
}).compile(z.record(z.string(), z.unknown()).parse(tool.outputSchema));
if (!validate(reply.structuredContent)) failures.push(request.name);
}
}
expect(failures).toEqual([]);
const successful = await call("get_evidence_bundle", {});
expect(successful.isError).not.toBe(true);
expect(successful.structuredContent).toBeDefined();
await client.ping();
});
@@ -1,3 +1,4 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it, onTestFinished } from "vitest";
@@ -92,7 +93,7 @@ it("publishes valid SDK schemas and retains historical inline Evidence with the
arguments: { ...args, sensitive_values: ["secret"], mysecret: true },
});
expect(invalidSensitive.isError).toBe(true);
expect(invalidSensitive.structuredContent).toMatchObject({
expect(parseMcpToolError(invalidSensitive)).toMatchObject({
error: { code: "invalid_request", details: { issues: [{ path: [] }] } },
});
expect(JSON.stringify(invalidSensitive)).not.toContain("secret");
@@ -129,7 +129,7 @@ itWithLinuxCaptureCapability.each([false, true])(
state: "quiesced",
cleanup_outcome: "not_required",
});
expect(capture.cleanup).toEqual({
expect(capture.cleanup).toMatchObject({
owned_process_group: "verified",
temporary_root: "removed",
});
@@ -0,0 +1,270 @@
import { execFile } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, onTestFinished } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import {
compareProcessCaptures,
parseProcessCapture,
processCaptureSchema,
} from "../../../src/domain/process/processCapture.js";
import { processSourceTruncated } from "../../../src/domain/process/processCaptureCoverage.js";
import { compareProcessTraces } from "../../../src/domain/process/processTraceComparison.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { itWithCaptureCapability } from "./processCaptureCapability.js";
const exec = promisify(execFile);
const capture = async (
adapter: "cli" | "mcp",
scenario: Record<string, unknown>,
) => {
if (adapter === "cli") {
const path = join(
await createTestTempDirectory("rea-truncation-scenario-"),
"scenario.json",
);
await writeFile(path, JSON.stringify(scenario));
const { stdout } = await exec(process.execPath, [
"scripts/rea.mjs",
"capture-process",
path,
"--format",
"json",
]);
return parseProcessCapture(
parseEvidence(JSON.parse(stdout)).normalized_result,
);
}
const session = createTestBinarySession(() => {
throw new Error("Capture must not launch a binary provider");
});
const server = createServer(session, session);
const client = new Client({ name: "truncation-coverage", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({
name: "capture_process_scenario",
arguments: scenario,
});
expect(result.isError, JSON.stringify(result)).not.toBe(true);
await client.ping();
return parseProcessCapture(
parseEvidence(result.structuredContent).normalized_result,
);
};
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"localizes sparse rendered-state exhaustion through %s",
async (adapter) => {
const root = await createTestTempDirectory("rea-rendered-budget-");
const result = await capture(adapter, {
executable: process.execPath,
working_directory: root,
arguments: [
"-e",
"let n=0;const timer=setInterval(()=>{console.log('board '+String(n).padStart(2,'0')+' completed; score '+String(n*10));if(++n===65)clearInterval(timer)},20)",
],
normalization: {
ports: false,
pids: false,
paths: false,
time_bucket_ms: 1,
},
limits: { output_bytes: 30_000 },
});
const details = result.truncation_details;
if (details === undefined) throw new Error("Expected producer coverage");
expect(result.frames.map(({ data }) => data).join("")).toContain(
"board 64 completed; score 640",
);
expect(details.raw_terminal.observed_bytes).toBe(2004);
expect(details.raw_terminal.retained_bytes).toBe(
details.raw_terminal.observed_bytes,
);
expect(details.raw_terminal.observed_frames).toBe(result.frames.length);
expect(details.rendered_terminal.observed_frames).toBeGreaterThan(
result.rendered_frames.length,
);
expect(details.rendered_terminal.retained_bytes).toBeLessThanOrEqual(
30_000,
);
expect(details.process.coverage).toBe("sampled");
expect(processSourceTruncated(result, "terminal_raw")).toBe(false);
expect(processSourceTruncated(result, "terminal_rendered")).toBe(true);
const events = result.frames.map(({ sequence, at_ms, data }) => ({
id: `chunk_${String(sequence)}`,
source: "terminal_raw" as const,
exact: { sequence, at_ms, data },
cardinality: { kind: "required" as const },
}));
const specification = {
events,
language: {
kind: "finite_traces" as const,
variants: [{ id: "observed", trace: events.map(({ id }) => id) }],
},
};
expect(compareProcessTraces(result, result, specification).verdict).toBe(
"equivalent",
);
const legacy = parseProcessCapture({
...result,
truncation_details: undefined,
});
expect(compareProcessTraces(legacy, legacy, specification).verdict).toBe(
"unknown",
);
expect(compareProcessCaptures(result, result)).toMatchObject({
terminal: "unknown",
interaction: "unchanged",
exit: "unchanged",
});
expect(
processCaptureSchema.safeParse({ ...result, truncated: false }).success,
).toBe(false);
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"distinguishes raw chunk omission and filesystem exhaustion through %s",
async (adapter) => {
const root = await createTestTempDirectory("rea-raw-files-budget-");
await writeFile(join(root, "a.txt"), "abc");
await writeFile(join(root, "b.txt"), "12345678");
const result = await capture(adapter, {
executable: process.execPath,
working_directory: root,
arguments: [
"-e",
"process.stdout.write('x'.repeat(1000));require('node:fs').writeFileSync('c.txt','new');setTimeout(()=>{},100)",
],
filesystem_observation_paths: [root],
limits: { output_bytes: 50, files: 3, file_bytes: 4 },
});
const details = result.truncation_details;
if (details === undefined) throw new Error("Expected producer coverage");
expect(details.raw_terminal.observed_bytes).toBe(1000);
expect(details.raw_terminal.retained_bytes).toBeLessThan(
details.raw_terminal.observed_bytes,
);
expect(details.filesystem_before).toMatchObject({
enumeration_truncated: false,
hashed_bytes: 3,
hash_omissions: [
{
path: "root_0:b.txt",
size_bytes: 8,
remaining_budget_bytes: 1,
reason: "file_bytes_budget",
},
],
});
expect(details.filesystem_after.enumeration_reasons).toContain(
"files_limit",
);
expect(
result.files_after.find(({ path }) => path === "root_0:b.txt")?.sha256,
).toBeNull();
expect(processSourceTruncated(result, "terminal_rendered")).toBe(true);
expect(compareProcessCaptures(result, result)).toMatchObject({
terminal: "unknown",
filesystem: "unknown",
interaction: "unchanged",
exit: "unchanged",
});
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"keeps terminal facts comparable when only file digests are omitted through %s",
async (adapter) => {
const root = await createTestTempDirectory("rea-hash-budget-");
await writeFile(join(root, "a.txt"), "abc");
await writeFile(join(root, "b.txt"), "12345678");
await writeFile(join(root, "c.txt"), "z");
const result = await capture(adapter, {
executable: process.execPath,
working_directory: root,
arguments: ["-e", "console.log('done');setTimeout(()=>{},100)"],
filesystem_observation_paths: [root],
limits: { file_bytes: 4 },
});
expect(result.truncation_details?.filesystem_after).toMatchObject({
enumeration_truncated: false,
hashed_bytes: 4,
hash_omissions: [
{
path: "root_0:b.txt",
reason: "file_bytes_budget",
remaining_budget_bytes: 1,
},
],
});
expect(
result.files_after.find(({ path }) => path === "root_0:c.txt")?.sha256,
).toMatch(/^[a-f0-9]{64}$/u);
expect(compareProcessCaptures(result, result)).toMatchObject({
terminal: "unchanged",
filesystem: "unknown",
interaction: "unchanged",
exit: "unchanged",
});
const differentExit = parseProcessCapture({
...result,
exit: { ...result.exit, code: 1 },
});
expect(compareProcessCaptures(result, differentExit)).toMatchObject({
status: "changed",
terminal: "unchanged",
filesystem: "unknown",
exit: "changed",
first_divergence: { status: "unknown" },
});
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"reports process sample-limit exhaustion without erasing other dimensions through %s",
async (adapter) => {
const root = await createTestTempDirectory("rea-process-budget-");
const result = await capture(adapter, {
executable: process.execPath,
working_directory: root,
arguments: [
"-e",
"setTimeout(()=>require('node:child_process').spawn(process.execPath,['-e','setTimeout(()=>{},800)'],{stdio:'ignore'}),100);console.log('ready')",
],
limits: { processes: 1 },
});
expect(result.truncation_details?.process).toMatchObject({
sample_limit: 1,
sampling_partial: true,
retained_samples: 1,
sample_limit_reached: true,
sampling_failures: 0,
first_sampling_failure: null,
coverage: "sampled",
});
expect(compareProcessCaptures(result, result)).toMatchObject({
terminal: "unchanged",
interaction: "unchanged",
exit: "unchanged",
process: "unknown",
});
},
20_000,
);
+1 -5
View File
@@ -47,11 +47,7 @@ export async function connectLocalToolsMcp() {
const call = async (name: string, arguments_: Record<string, unknown>) => {
const response = await client.callTool({ name, arguments: arguments_ });
const validate = validators.get(name);
if (
response.isError !== true &&
response.structuredContent !== undefined &&
validate !== undefined
)
if (response.structuredContent !== undefined && validate !== undefined)
expect(
validate(response.structuredContent),
JSON.stringify(validate.errors),
+16
View File
@@ -0,0 +1,16 @@
import type { CallToolResult } from "@modelcontextprotocol/server";
import { expect } from "vitest";
import { z } from "zod";
import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js";
/** Read and validate the complete typed diagnostic in an MCP tool failure. */
export function parseMcpToolError(result: CallToolResult) {
expect(result.isError).toBe(true);
const text = result.content.find((item) => item.type === "text");
if (text?.type !== "text") throw new Error("Tool error omitted text content");
return z
.object({ error: analysisErrorProjectionSchema })
.strict()
.parse(JSON.parse(text.text));
}
@@ -9,6 +9,7 @@ import { ok } from "../../src/domain/result.js";
import { run } from "../../src/main.js";
import { createServer } from "../../src/server/createServer.js";
import type { EvidenceMcpServer } from "../../src/server/EvidenceMcpServer.js";
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
const resources: Array<{ close(): Promise<void> }> = [];
afterEach(async () => {
@@ -89,8 +90,10 @@ it("keeps independent server budgets for successful results and oversized errors
}),
),
);
if (smallResult === undefined || largeResult === undefined)
throw new Error("Missing result from selected-budget server");
expect(smallResult?.isError).toBe(true);
expect(smallResult?.structuredContent).toMatchObject({
expect(parseMcpToolError(smallResult)).toMatchObject({
error: {
code: "resource_constraint",
details: {
@@ -110,7 +113,9 @@ it("keeps independent server budgets for successful results and oversized errors
client.callTool({ name: "selected_failure", arguments: {} }),
),
);
expect(smallFailure?.structuredContent).toMatchObject({
if (smallFailure === undefined || largeFailure === undefined)
throw new Error("Missing failure from selected-budget server");
expect(parseMcpToolError(smallFailure)).toMatchObject({
error: {
code: "resource_constraint",
details: {
@@ -122,7 +127,7 @@ it("keeps independent server budgets for successful results and oversized errors
},
},
});
expect(largeFailure?.structuredContent).toMatchObject({
expect(parseMcpToolError(largeFailure)).toMatchObject({
error: {
code: "invalid_request",
details: { issues: [{ message: diagnostic }] },