Merge remote-tracking branch 'origin/main' into feat/website-seo-metadata
This commit is contained in:
commit
8c9c5550d0
74 files changed
+1221
-217
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
Vendored
+1
-5
@@ -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),
|
||||
|
||||
Vendored
+16
@@ -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 }] },
|
||||
|
||||
Reference in new issue
Block a user