Files
rea/tests/boundary/process/processFilesystemCoverageAdapters.test.ts
T
Kaoru0822andClaude Opus 5.5 b6007e0a58 fix(process): keep capture evidence when an admitted root's ancestor changes (#6264)
Final filesystem observation reconstructed root admission from the initial
snapshot's root entries, so an initially absent root (no entry) whose ancestor
became a regular file failed capture with ENOTDIR after the child completed.

A returned initial snapshot now admits every selected root for the run. One
tagged path lookup classifies ENOENT and ENOTDIR as observed absence and other
unavailable lookups (EACCES, ELOOP, ...) as entry_unavailable omissions that
keep the root incomplete. Selected roots that are obstructed or denied before
launch still fail capture.

Fixes #6213

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:28:26 -07:00

419 lines
15 KiB
TypeScript

import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, onTestFinished } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { compareProcessCaptures } from "../../../src/domain/process/processComparison.js";
import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js";
import { parseProcessScenario } from "../../../src/domain/process/processScenario.js";
import { FINALIZED_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
CAPTURE_SKIP_REASON,
itWithCaptureCapability,
} from "./processCaptureCapability.js";
const exec = promisify(execFile);
const captureViaCli = async (scenario: Record<string, unknown>) => {
const scenarioPath = join(
await createTestTempDirectory("rea-fs-scenario-"),
"scenario.json",
);
await writeFile(scenarioPath, JSON.stringify(scenario));
const { stdout } = await exec(process.execPath, [
"scripts/rea.mjs",
"capture-process",
scenarioPath,
"--format",
"json",
]);
return parseEvidence(JSON.parse(stdout));
};
const captureViaMcp = async (scenario: Record<string, unknown>) => {
const session = createTestBinarySession(() => {
throw new Error("Process capture must not launch a binary provider");
});
const server = createServer({ kind: "session", session });
const client = new Client({ name: "filesystem-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);
const evidence = parseEvidence(result.structuredContent);
await client.ping();
return evidence;
};
it("documents a finalization scenario and exit record that match the contract", async () => {
const document = await readFile("docs/process-capture.md", "utf8");
const blocks = [
...(document.split("### Finalization interval")[1] ?? "").matchAll(
/```json\n([\s\S]*?)\n```/gu,
),
].map((match) => match[1]);
const [scenario, exit] = blocks;
if (scenario === undefined || exit === undefined)
throw new Error("Missing finalization examples");
expect(
parseProcessScenario(JSON.parse(scenario)).finalization_ms,
"the documented scenario is accepted and carries the interval",
).toBe(1_500);
expect(
JSON.parse(exit),
"the documented exit record is the canonical finalized example",
).toEqual(FINALIZED_PROCESS_CAPTURE_EXAMPLE.exit);
});
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"hashes the documented complete report despite omitted terminal output through %s",
async (adapter) => {
const document = await readFile("docs/process-capture.md", "utf8");
const example = document
.split("## Hash a report independently of terminal output")[1]
?.match(/```json\n([\s\S]*?)\n```/u)?.[1];
if (example === undefined)
throw new Error("Missing report capture example");
const scenario = parseProcessScenario(JSON.parse(example));
const root = await createTestTempDirectory("rea-full-report-adapter-");
const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)({
...scenario,
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [join(root, "reports")],
});
const capture = parseProcessCapture(evidence.normalized_result);
const bytes = await readFile(join(root, "reports", "final.json"));
expect(JSON.parse(bytes.toString())).toEqual({
status: "complete",
data: "X".repeat(131072),
});
expect(bytes.length).toBeGreaterThan(scenario.limits.output_bytes);
expect(capture.files_after).toContainEqual(
expect.objectContaining({
path: "root_0:final.json",
size: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
}),
);
expect(capture.truncation_details.filesystem_after).toMatchObject({
enumeration_truncated: false,
hash_budget_bytes: scenario.limits.file_bytes,
hashed_bytes: bytes.length,
hash_omissions: [],
});
expect(capture.truncation_details.raw_terminal.retained_bytes).toBeLessThan(
capture.truncation_details.raw_terminal.observed_bytes,
);
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"accounts for exact cumulative hash budgets independently at each %s checkpoint",
async (adapter) => {
const runCapture = adapter === "cli" ? captureViaCli : captureViaMcp;
for (const shortfall of [0, 1]) {
const root = await createTestTempDirectory("rea-report-cumulative-");
const periodic = Buffer.from('{"status":"running"}');
const final = Buffer.from('{"status":"complete"}');
await writeFile(join(root, "periodic.json"), periodic);
const budget = periodic.length + final.length - shortfall;
const evidence = await runCapture({
executable: process.execPath,
arguments: [
"-e",
"require('node:fs').writeFileSync('final.json','{\"status\":\"complete\"}')",
],
working_directory: root,
filesystem_observation_paths: [root],
limits: { output_bytes: 1, file_bytes: budget },
});
const capture = parseProcessCapture(evidence.normalized_result);
expect(capture.truncation_details.filesystem_before).toMatchObject({
hashed_bytes: periodic.length,
hash_omissions: [],
});
expect(capture.files_after).toContainEqual(
expect.objectContaining({
path: "root_0:final.json",
sha256: createHash("sha256").update(final).digest("hex"),
}),
);
expect(capture.truncation_details.filesystem_after).toMatchObject({
enumeration_truncated: false,
hashed_bytes: final.length + (shortfall === 0 ? periodic.length : 0),
hash_omissions:
shortfall === 0
? []
: [
{
path: "root_0:periodic.json",
reason: "file_bytes_budget",
remaining_budget_bytes: periodic.length - 1,
},
],
});
expect(
capture.files_after.find(({ path }) => path === "root_0:periodic.json")
?.sha256,
).toBe(
shortfall === 0
? createHash("sha256").update(periodic).digest("hex")
: null,
);
}
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"reports unknown absence instead of deletion in truncated %s capture Evidence",
async (adapter) => {
const root = await createTestTempDirectory("rea-fs-coverage-adapter-");
await writeFile(join(root, "z.txt"), "unchanged");
const scenario = {
executable: process.execPath,
// Delete the known file and overflow the remaining directory capacity.
// Incomplete enumeration still cannot prove its absence, regardless of
// which new entry the filesystem returns first.
arguments: [
"-e",
"const fs=require('node:fs');fs.unlinkSync('z.txt');fs.writeFileSync('a.txt','added');fs.writeFileSync('b.txt','added');",
],
working_directory: root,
filesystem_observation_paths: [root],
limits: { files: 2 },
};
const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)(
scenario,
);
const capture = parseProcessCapture(evidence.normalized_result);
expect(capture.truncated).toBe(true);
await expect(readFile(join(root, "z.txt"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
expect(
capture.filesystem_effects.find(({ path }) => path === "root_0:z.txt")
?.status,
).toBe("unknown");
expect(
capture.filesystem_checkpoints
.find(({ name }) => name === "after_settlement")
?.effects.find(({ path }) => path === "root_0:z.txt")?.status,
).toBe("unknown");
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"reports unknown content after a same-size rewrite without digests through %s",
async (adapter) => {
const root = await createTestTempDirectory("rea-fs-content-adapter-");
const file = join(root, "changed.txt");
await writeFile(file, "before");
const scenario = {
executable: process.execPath,
arguments: [
"-e",
"require('node:fs').writeFileSync('changed.txt','after!')",
],
working_directory: root,
filesystem_observation_paths: [root],
limits: { file_bytes: 1 },
};
const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)(
scenario,
);
const capture = parseProcessCapture(evidence.normalized_result);
expect(await readFile(file, "utf8")).toBe("after!");
const effect = capture.filesystem_effects.find(
({ path }) => path === "root_0:changed.txt",
);
expect(effect).toMatchObject({
status: "unknown",
reason: expect.any(String),
before: { size: 6, sha256: null },
after: { size: 6, sha256: null },
});
if (effect?.status !== "unknown")
throw new Error("Expected unknown file contents");
expect(
capture.filesystem_checkpoints.find(
({ name }) => name === "after_settlement",
)?.effects,
).toContainEqual(effect);
expect(capture.residual_unknowns).toContainEqual({
scope: "filesystem",
reason: effect.reason,
});
expect(capture.limitations).toContain(effect.reason);
expect(
capture.truncation_details.filesystem_after.enumeration_truncated,
).toBe(false);
expect(compareProcessCaptures(capture, capture).filesystem).toBe("unknown");
},
20_000,
);
it.skipIf(CAPTURE_SKIP_REASON || process.platform === "win32")(
"keeps unfollowed descendant absence unknown through MCP Evidence and comparison",
async () => {
const root = await createTestTempDirectory("rea-fs-symlink-adapter-");
const target = await createTestTempDirectory(
"rea-fs-symlink-adapter-target-",
);
await mkdir(join(root, "subtree"));
await writeFile(join(root, "subtree", "z.txt"), "unchanged");
await writeFile(join(target, "z.txt"), "unchanged");
await writeFile(join(root, "removed.txt"), "removed");
const evidence = await captureViaMcp({
executable: process.execPath,
arguments: [
"-e",
"const fs=require('node:fs');fs.rmSync('subtree',{recursive:true});fs.symlinkSync(process.argv[1],'subtree');fs.unlinkSync('removed.txt');",
target,
],
working_directory: root,
filesystem_observation_paths: [root],
});
const capture = parseProcessCapture(evidence.normalized_result);
expect(await readFile(join(root, "subtree", "z.txt"), "utf8")).toBe(
"unchanged",
);
expect(
capture.filesystem_effects.find(
({ path }) => path === "root_0:subtree/z.txt",
)?.status,
).toBe("unknown");
expect(
capture.filesystem_effects.find(
({ path }) => path === "root_0:removed.txt",
)?.status,
).toBe("deleted");
expect(capture.residual_unknowns).toContainEqual({
scope: "filesystem",
reason: expect.any(String),
});
expect(compareProcessCaptures(capture, capture).filesystem).toBe("unknown");
},
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"observes absence when selected roots lose their directory ancestor through %s",
async (adapter) => {
const runCapture = adapter === "cli" ? captureViaCli : captureViaMcp;
// A non-directory ancestor proves that no entry exists below it, for an
// initially absent root and through a symlink to a regular file alike.
for (const replacement of [
"fs.writeFileSync('state','regular file');",
"fs.writeFileSync('target','regular file');fs.symlinkSync('target','state');",
]) {
const root = await createTestTempDirectory("rea-fs-obstructed-root-");
await mkdir(join(root, "state"));
const evidence = await runCapture({
executable: process.execPath,
arguments: [
"-e",
`const fs=require('node:fs');fs.renameSync('state','saved-state');${replacement}`,
],
working_directory: root,
filesystem_observation_paths: [
join(root, "state", "reports"),
join(root, "state"),
],
});
const capture = parseProcessCapture(evidence.normalized_result);
expect(capture.exit).toMatchObject({ code: 0, reason: "exited" });
expect(capture.filesystem_effects).toEqual([
expect.objectContaining({
path: "root_1:.",
status: "modified",
before: expect.objectContaining({ type: "directory" }),
}),
]);
expect(capture.truncation_details.filesystem_after).toMatchObject({
enumeration_truncated: false,
enumeration_omissions: [],
});
}
},
20_000,
);
it
.skipIf(
CAPTURE_SKIP_REASON ||
process.platform === "win32" ||
process.getuid?.() === 0,
)
.each(["cli", "mcp"] as const)(
"keeps roots behind a newly unsearchable ancestor unobserved through %s",
async (adapter) => {
const root = await createTestTempDirectory("rea-fs-denied-root-");
await mkdir(join(root, "state"));
await writeFile(join(root, "state", "present.json"), "payload");
await symlink("state", join(root, "alias"));
try {
const evidence = await (
adapter === "cli" ? captureViaCli : captureViaMcp
)({
executable: process.execPath,
arguments: ["-e", "require('node:fs').chmodSync('state',0)"],
working_directory: root,
filesystem_observation_paths: [
join(root, "state", "present.json"),
join(root, "state", "absent.json"),
join(root, "alias", "present.json"),
],
});
const capture = parseProcessCapture(evidence.normalized_result);
expect(capture.exit).toMatchObject({ code: 0, reason: "exited" });
// A denied lookup is not absence: the observed file's fate stays
// unknown, and each affected root records why it is incomplete.
expect(capture.filesystem_effects).toEqual(
["root_0:.", "root_2:."].map((path) =>
expect.objectContaining({
path,
status: "unknown",
before: expect.objectContaining({ type: "file" }),
after: null,
}),
),
);
expect(capture.truncation_details.filesystem_after).toMatchObject({
enumeration_truncated: true,
enumeration_reasons: ["entry_unavailable"],
enumeration_omissions: ["root_0:.", "root_1:.", "root_2:."].map(
(path) => ({
path,
reason: "entry_unavailable",
system_code: "EACCES",
}),
),
});
} finally {
await chmod(join(root, "state"), 0o700);
}
},
20_000,
);