mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(process): preserve observation-root failures and omitted symlinks
Keep typed search denial and acquired snapshot facts through cleanup and finalization. Record unfollowed symlink coverage at the snapshot owner, preserve physically enumerated parents, and retain uncertainty beneath omitted links without losing proven sibling deletions. Update parsed omission contracts, public CLI/MCP regressions and documentation without introducing a new lifecycle or coverage framework. Fixes #6446 Fixes #6444
This commit is contained in:
+12
-1
@@ -241,7 +241,12 @@ A missing entry proves creation or deletion only when the corresponding
|
||||
observation root's path enumeration was exhausted. Otherwise its effect has
|
||||
`status: "unknown"`, the available before/after state, and a reason; a null
|
||||
state in this variant means unobserved. Paths below symlinks that REA did not
|
||||
follow also remain unknown. Partial enumeration of one root does not erase
|
||||
follow also remain unknown. Each observed symlink records a
|
||||
`symlink_not_followed` enumeration omission with a null system code, including
|
||||
a stable selected root. The link's own metadata can remain unchanged while
|
||||
descendant coverage and filesystem comparison remain unknown. Physical
|
||||
parent-directory enumeration still proves unrelated sibling absence.
|
||||
Partial enumeration of one root does not erase
|
||||
known effects in another, and unavailable content hashes do not by themselves
|
||||
make path absence unknown.
|
||||
|
||||
@@ -261,6 +266,12 @@ denied or otherwise unavailable lookup records an `entry_unavailable` omission
|
||||
for that path instead, and its root stays incomplete. Cancellation and
|
||||
unexpected I/O failures still stop capture.
|
||||
|
||||
An initial selected-root permission denial returns `access_denied` with the
|
||||
selected path, native `EACCES` or `EPERM`, and filesystem-read recovery. Capture
|
||||
does not launch the target. Earlier snapshot facts and the denied root's
|
||||
omission remain in the partial observation; unresolved read cleanup retains its
|
||||
own classification until its exact resources are released.
|
||||
|
||||
Terminal frame `data` is the comparison text after the selected normalization.
|
||||
New captures also preserve `raw_data` when normalization changes a PTY chunk;
|
||||
otherwise `data` is already the original text. Older captures may lack the
|
||||
|
||||
@@ -92,7 +92,7 @@ export type FileState = FileStateIdentity &
|
||||
export interface ProcessFilesystemSnapshot {
|
||||
readonly files: readonly FileState[];
|
||||
readonly truncated: boolean;
|
||||
/** Root aliases whose path enumeration was exhausted, regardless of hash coverage. */
|
||||
/** Physical root enumeration exhausted; unfollowed symlink scopes remain unknown. */
|
||||
readonly completeRoots: readonly string[];
|
||||
readonly coverage: FilesystemCoverage;
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ it.each([
|
||||
{ path: "root_0:.", reason: "entry_unavailable", system_code: null },
|
||||
{ path: "root_0:.", reason: "identity_changed", system_code: "ENOENT" },
|
||||
{ path: "root_0:.", reason: "directory_unavailable", system_code: "ENOENT" },
|
||||
{ path: "root_0:.", reason: "symlink_not_followed", system_code: null },
|
||||
] as const)("rejects an inconsistent omission %j", (omission) => {
|
||||
expect(() =>
|
||||
parseProcessCapture(captureWithEnumerationOmission(omission)),
|
||||
@@ -177,6 +178,69 @@ it("requires entry-unavailability details and matching aggregate reasons", () =>
|
||||
).toThrow("enumeration_omissions");
|
||||
});
|
||||
|
||||
it("binds unfollowed coverage to observed symlink metadata without inventing an OS failure", () => {
|
||||
const partial = captureWithEnumerationOmission({
|
||||
path: "root_0:.",
|
||||
reason: "symlink_not_followed",
|
||||
system_code: null,
|
||||
});
|
||||
const link = {
|
||||
path: "root_0:.",
|
||||
type: "symlink" as const,
|
||||
mode: 0o120777,
|
||||
size: 7,
|
||||
sha256: null,
|
||||
symlink_target: "reports",
|
||||
};
|
||||
const effect = {
|
||||
path: link.path,
|
||||
status: "created" as const,
|
||||
before: null,
|
||||
after: link,
|
||||
};
|
||||
const capture = {
|
||||
...partial,
|
||||
files_after: [link],
|
||||
filesystem_effects: [effect],
|
||||
filesystem_checkpoints: partial.filesystem_checkpoints.map((checkpoint) =>
|
||||
checkpoint.name === "after_settlement"
|
||||
? { ...checkpoint, files: [link], effects: [effect] }
|
||||
: checkpoint,
|
||||
),
|
||||
};
|
||||
expect(parseProcessCapture(capture)).toEqual(capture);
|
||||
for (const changes of [
|
||||
{ enumeration_omissions: [] },
|
||||
{
|
||||
enumeration_omissions: [
|
||||
{
|
||||
path: link.path,
|
||||
reason: "symlink_not_followed",
|
||||
system_code: "ENOENT",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(() =>
|
||||
parseProcessCapture({
|
||||
...capture,
|
||||
truncation_details: {
|
||||
...capture.truncation_details,
|
||||
filesystem_after: {
|
||||
...capture.truncation_details.filesystem_after,
|
||||
...changes,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow("enumeration_omissions");
|
||||
expect(() =>
|
||||
parseProcessCapture({
|
||||
...capture,
|
||||
files_after: [{ ...link, type: "directory", symlink_target: null }],
|
||||
}),
|
||||
).toThrow("enumeration_omissions");
|
||||
});
|
||||
|
||||
it("never considers truncated captures equivalent", () => {
|
||||
const complete = emptyCapture();
|
||||
const capture = {
|
||||
|
||||
@@ -15,6 +15,7 @@ const enumerationOmissionReasonSchema = z.enum([
|
||||
"identity_changed",
|
||||
"directory_unavailable",
|
||||
"entry_unavailable",
|
||||
"symlink_not_followed",
|
||||
]);
|
||||
|
||||
/** Filesystem enumeration and whole-file digest coverage for one checkpoint. */
|
||||
|
||||
@@ -378,18 +378,25 @@ const validateCoverage = (
|
||||
coverage.enumeration_omissions.some(
|
||||
({ reason }) => reason === "entry_unavailable",
|
||||
)) &&
|
||||
coverage.enumeration_omissions.every(
|
||||
(omission) =>
|
||||
coverage.enumeration_reasons.includes(omission.reason) &&
|
||||
(omission.reason === "directory_unavailable"
|
||||
? entryTypes.get(omission.path) === "directory" &&
|
||||
omission.system_code !== null
|
||||
: (entryTypes.get(omission.path) === undefined ||
|
||||
entryTypes.get(omission.path) === "directory") &&
|
||||
(omission.reason === "entry_unavailable"
|
||||
? omission.system_code !== null
|
||||
: omission.system_code === null)),
|
||||
), `truncation_details.${name}.enumeration_omissions`, "every enumeration omission must identify an unavailable entry or retained directory, with a matching reason and system error only for unavailable reads");
|
||||
(!coverage.enumeration_reasons.includes("symlink_not_followed") ||
|
||||
coverage.enumeration_omissions.some(
|
||||
({ reason }) => reason === "symlink_not_followed",
|
||||
)) &&
|
||||
coverage.enumeration_omissions.every((omission) => {
|
||||
if (!coverage.enumeration_reasons.includes(omission.reason))
|
||||
return false;
|
||||
const entryType = entryTypes.get(omission.path);
|
||||
if (omission.reason === "symlink_not_followed")
|
||||
return entryType === "symlink" && omission.system_code === null;
|
||||
if (omission.reason === "directory_unavailable")
|
||||
return entryType === "directory" && omission.system_code !== null;
|
||||
return (
|
||||
(entryType === undefined || entryType === "directory") &&
|
||||
(omission.reason === "entry_unavailable"
|
||||
? omission.system_code !== null
|
||||
: omission.system_code === null)
|
||||
);
|
||||
}), `truncation_details.${name}.enumeration_omissions`, "every enumeration omission must identify an unavailable entry, retained directory or unfollowed symlink, with a matching reason and system error only for unavailable reads");
|
||||
require(coverage.hashed_bytes ===
|
||||
files
|
||||
.filter((file) => file.type === "file" && file.sha256 !== null)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
|
||||
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
|
||||
import { AnalysisAccessDeniedError } from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { emptyProcessCapture } from "../../domain/process/processCapture.fixture.js";
|
||||
import { parseProcessScenario } from "../../domain/process/processScenario.js";
|
||||
import { ProcessCaptureSnapshotFailure } from "./FilesystemSnapshot.js";
|
||||
import { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import { prepareProcessCapture } from "./ProcessCaptureLifecycle.js";
|
||||
|
||||
it.each(["EACCES", "EPERM"] as const)(
|
||||
"retains %s admission facts through snapshot finalization and exact cleanup retry",
|
||||
async (systemCode) => {
|
||||
const selected = "/fixture/denied/selected";
|
||||
const native = Object.assign(new Error("selected root lookup denied"), {
|
||||
code: systemCode,
|
||||
});
|
||||
const primary = new AnalysisAccessDeniedError(
|
||||
"capture_process_scenario",
|
||||
selected,
|
||||
systemCode,
|
||||
{ cause: native },
|
||||
);
|
||||
const snapshot = {
|
||||
files: [],
|
||||
completeRoots: ["root_0"],
|
||||
truncated: true,
|
||||
coverage: {
|
||||
...emptyProcessCapture().truncation_details.filesystem_before,
|
||||
enumeration_truncated: true,
|
||||
enumeration_reasons: ["entry_unavailable" as const],
|
||||
enumeration_omissions: [
|
||||
{
|
||||
path: "root_1:.",
|
||||
reason: "entry_unavailable" as const,
|
||||
system_code: systemCode,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
let closes = 0;
|
||||
const lease = new AnalysisCleanupLease(async () => {
|
||||
closes++;
|
||||
}, ["fixture:earlier-snapshot-descriptor"]);
|
||||
const snapshotFailure = new ProcessCaptureSnapshotFailure({
|
||||
cause: primary,
|
||||
snapshot,
|
||||
cleanupLeases: [lease],
|
||||
});
|
||||
const scenario = parseProcessScenario({
|
||||
executable: process.execPath,
|
||||
filesystem_observation_paths: ["/fixture/absent", selected],
|
||||
});
|
||||
const failure = await prepareProcessCapture(
|
||||
scenario,
|
||||
undefined,
|
||||
async () => {
|
||||
throw snapshotFailure;
|
||||
},
|
||||
{
|
||||
createTemporaryRoot: async () => {
|
||||
throw new Error("admission must precede allocation");
|
||||
},
|
||||
cleanup: async () => {
|
||||
throw new Error("no temporary root was allocated");
|
||||
},
|
||||
},
|
||||
).catch((cause: unknown) => cause);
|
||||
if (!(failure instanceof ProcessCaptureError))
|
||||
throw new Error("Expected retained snapshot finalization failure");
|
||||
expect(primary.cause).toBe(native);
|
||||
expect(closes).toBe(0);
|
||||
expect(projectAnalysisError(failure)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
resources: ["fixture:earlier-snapshot-descriptor"],
|
||||
partial_observation: {
|
||||
observations: {
|
||||
target_pid: { state: "unavailable" },
|
||||
filesystem_snapshots: {
|
||||
before: { state: "available", value: snapshot },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await lease.release();
|
||||
await lease.release();
|
||||
expect(closes).toBe(1);
|
||||
expect(
|
||||
analysisErrorProjectionSchema.parse(projectAnalysisError(failure)),
|
||||
).toMatchObject({
|
||||
code: "access_denied",
|
||||
category: "unavailable",
|
||||
details: {
|
||||
path: selected,
|
||||
system_code: systemCode,
|
||||
boundary: "filesystem-read",
|
||||
partial_observation: {
|
||||
observations: {
|
||||
filesystem_snapshots: {
|
||||
before: { state: "available", value: snapshot },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -7,6 +7,7 @@ import type { ProcessScenario } from "../../domain/process/processScenario.js";
|
||||
import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js";
|
||||
import type { Stats } from "node:fs";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { AnalysisAccessDeniedError } from "../../domain/analysisErrorCore.js";
|
||||
import type {
|
||||
FileState,
|
||||
ProcessFilesystemSnapshot,
|
||||
@@ -209,6 +210,9 @@ export class ProcessCaptureSnapshotFailure extends ProcessCaptureError {
|
||||
"snapshot handle cleanup failed";
|
||||
super(message, {
|
||||
cause: primary,
|
||||
...(options.cause instanceof AnalysisAccessDeniedError
|
||||
? { cleanupPrimary: options.cause }
|
||||
: {}),
|
||||
...(cancellation
|
||||
? { userCategory: "cancelled" as const, reason: "cancelled" as const }
|
||||
: {}),
|
||||
@@ -457,7 +461,16 @@ export const snapshotRoots = async (
|
||||
sha256: null,
|
||||
symlink_target: target.value,
|
||||
});
|
||||
return true;
|
||||
truncated = true;
|
||||
enumerationReasons.add("symlink_not_followed");
|
||||
enumerationOmissions.push({
|
||||
path: `${rootAlias}:${relativePath}`,
|
||||
reason: "symlink_not_followed",
|
||||
system_code: null,
|
||||
});
|
||||
// A link leaves its descendants unknown, while a physically enumerated
|
||||
// parent still proves absence of unrelated sibling names.
|
||||
return depth > 0;
|
||||
}
|
||||
if (stats.isFile()) {
|
||||
if (remainingBytes < stats.size) {
|
||||
@@ -605,7 +618,8 @@ export const snapshotRoots = async (
|
||||
files_limit: scenario.limits.files,
|
||||
depth_limit: scenario.limits.filesystem_depth,
|
||||
enumeration_truncated:
|
||||
completeRoots.length !== scenario.filesystem_observation_paths.length,
|
||||
completeRoots.length !== scenario.filesystem_observation_paths.length ||
|
||||
enumerationOmissions.length > 0,
|
||||
enumeration_reasons: [...enumerationReasons],
|
||||
enumeration_omissions: enumerationOmissions,
|
||||
hash_budget_bytes: scenario.limits.file_bytes,
|
||||
@@ -622,8 +636,24 @@ export const snapshotRoots = async (
|
||||
const alias = `root_${String(index)}`;
|
||||
const lookup = await lookupPath(root, signal);
|
||||
if (lookup.state !== "present") {
|
||||
if (admission === undefined && lookup.system_code !== "ENOENT")
|
||||
if (admission === undefined && lookup.system_code !== "ENOENT") {
|
||||
if (
|
||||
lookup.system_code === "EACCES" ||
|
||||
lookup.system_code === "EPERM"
|
||||
) {
|
||||
omitEntry(`${alias}:.`, {
|
||||
reason: "entry_unavailable",
|
||||
system_code: lookup.system_code,
|
||||
});
|
||||
throw new AnalysisAccessDeniedError(
|
||||
"capture_process_scenario",
|
||||
root,
|
||||
lookup.system_code,
|
||||
{ cause: lookup.cause },
|
||||
);
|
||||
}
|
||||
throw lookup.cause;
|
||||
}
|
||||
if (lookup.state === "absent") completeRoots.push(alias);
|
||||
else
|
||||
omitEntry(`${alias}:.`, {
|
||||
|
||||
@@ -63,7 +63,7 @@ const createScenario = async (removeOnSecondRead: boolean) => {
|
||||
};
|
||||
|
||||
captureCliTest(
|
||||
"keeps a stable symlink observation complete through the public CLI",
|
||||
"retains a stable symlink while reporting its unfollowed scope through the public CLI",
|
||||
async ({ cli }) => {
|
||||
const fixture = await createScenario(false);
|
||||
const result = await cli.run({
|
||||
@@ -83,7 +83,7 @@ captureCliTest(
|
||||
expect((await lstat(fixture.link)).isSymbolicLink()).toBe(true);
|
||||
expect(result.json).toMatchObject({
|
||||
normalized_result: {
|
||||
truncated: false,
|
||||
truncated: true,
|
||||
files_before: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "root_0:vanishing-link",
|
||||
@@ -99,7 +99,17 @@ captureCliTest(
|
||||
}),
|
||||
]),
|
||||
truncation_details: {
|
||||
filesystem_after: { enumeration_truncated: false },
|
||||
filesystem_after: {
|
||||
enumeration_truncated: true,
|
||||
enumeration_reasons: ["symlink_not_followed"],
|
||||
enumeration_omissions: [
|
||||
{
|
||||
path: "root_0:vanishing-link",
|
||||
reason: "symlink_not_followed",
|
||||
system_code: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
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 { analysisCliErrorEnvelopeSchema } from "../../../src/contracts/errorSchemas.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { compareProcessCaptures } from "../../../src/domain/process/processComparison.js";
|
||||
import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js";
|
||||
@@ -13,6 +21,7 @@ import { FINALIZED_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/p
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import {
|
||||
CAPTURE_SKIP_REASON,
|
||||
itWithCaptureCapability,
|
||||
@@ -36,7 +45,7 @@ const captureViaCli = async (scenario: Record<string, unknown>) => {
|
||||
return parseEvidence(JSON.parse(stdout));
|
||||
};
|
||||
|
||||
const captureViaMcp = async (scenario: Record<string, unknown>) => {
|
||||
const callCaptureViaMcp = async (scenario: Record<string, unknown>) => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Process capture must not launch a binary provider");
|
||||
});
|
||||
@@ -55,12 +64,137 @@ const captureViaMcp = async (scenario: Record<string, unknown>) => {
|
||||
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;
|
||||
return result;
|
||||
};
|
||||
|
||||
const captureViaMcp = async (scenario: Record<string, unknown>) => {
|
||||
const result = await callCaptureViaMcp(scenario);
|
||||
expect(result.isError, JSON.stringify(result)).not.toBe(true);
|
||||
return parseEvidence(result.structuredContent);
|
||||
};
|
||||
|
||||
it
|
||||
.skipIf(
|
||||
CAPTURE_SKIP_REASON ||
|
||||
process.platform === "win32" ||
|
||||
process.getuid?.() === 0,
|
||||
)
|
||||
.for(["cli", "mcp"] as const)(
|
||||
"preserves selected-root permission denial and earlier snapshot facts through %s",
|
||||
{ timeout: 20_000 },
|
||||
async (adapter, context) => {
|
||||
const root = await createTestTempDirectory("rea-fs-root-denial-adapter-");
|
||||
const denied = join(root, "denied");
|
||||
const selected = join(denied, "unobserved");
|
||||
const healthy = join(root, "healthy.txt");
|
||||
const launched = join(root, "launched.txt");
|
||||
const bytes = "retained before denied root";
|
||||
await mkdir(denied);
|
||||
await writeFile(healthy, bytes);
|
||||
await chmod(denied, 0);
|
||||
try {
|
||||
const lookup = await lstat(selected).catch((cause: unknown) => cause);
|
||||
if (
|
||||
lookup instanceof Error &&
|
||||
"code" in lookup &&
|
||||
lookup.code === "ENOENT"
|
||||
) {
|
||||
context.skip("The host does not enforce directory search denial.");
|
||||
return;
|
||||
}
|
||||
if (!(lookup instanceof Error) || !("code" in lookup))
|
||||
throw new Error("Expected a native selected-root lookup failure");
|
||||
expect(["EACCES", "EPERM"]).toContain(lookup.code);
|
||||
const scenario = {
|
||||
executable: process.execPath,
|
||||
arguments: [
|
||||
"-e",
|
||||
"require('node:fs').writeFileSync('launched.txt','ran')",
|
||||
],
|
||||
working_directory: root,
|
||||
filesystem_observation_paths: [healthy, selected],
|
||||
};
|
||||
let output;
|
||||
if (adapter === "cli") {
|
||||
const failure = await captureViaCli(scenario).catch(
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
if (
|
||||
!(failure instanceof Error) ||
|
||||
!("stdout" in failure) ||
|
||||
typeof failure.stdout !== "string"
|
||||
)
|
||||
throw new Error("Expected the CLI to refuse capture before launch");
|
||||
output = analysisCliErrorEnvelopeSchema.parse(
|
||||
JSON.parse(failure.stdout),
|
||||
);
|
||||
} else {
|
||||
const response = await callCaptureViaMcp(scenario);
|
||||
output = parseMcpToolError(response).error;
|
||||
}
|
||||
expect(output).toMatchObject({
|
||||
code: "access_denied",
|
||||
category: "unavailable",
|
||||
details: {
|
||||
operation: "capture_process_scenario",
|
||||
path: selected,
|
||||
system_code: lookup.code,
|
||||
boundary: "filesystem-read",
|
||||
partial_observation: {
|
||||
observations: {
|
||||
target_pid: { state: "unavailable" },
|
||||
filesystem_snapshots: {
|
||||
before: {
|
||||
state: "available",
|
||||
value: {
|
||||
files: [
|
||||
expect.objectContaining({
|
||||
path: "root_0:.",
|
||||
sha256: createHash("sha256")
|
||||
.update(bytes)
|
||||
.digest("hex"),
|
||||
}),
|
||||
],
|
||||
completeRoots: ["root_0"],
|
||||
coverage: {
|
||||
enumeration_truncated: true,
|
||||
enumeration_omissions: [
|
||||
{
|
||||
path: "root_1:.",
|
||||
reason: "entry_unavailable",
|
||||
system_code: lookup.code,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(output).toMatchObject({
|
||||
remediation: { action: expect.stringContaining("read access") },
|
||||
});
|
||||
await expect(lstat(launched)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await chmod(denied, 0o700);
|
||||
const evidence = await (
|
||||
adapter === "cli" ? captureViaCli : captureViaMcp
|
||||
)(scenario);
|
||||
expect(
|
||||
parseProcessCapture(evidence.normalized_result).exit,
|
||||
).toMatchObject({
|
||||
code: 0,
|
||||
reason: "exited",
|
||||
});
|
||||
expect(await readFile(launched, "utf8")).toBe("ran");
|
||||
} finally {
|
||||
await chmod(denied, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("documents a finalization scenario and exit record that match the contract", async () => {
|
||||
const document = await readFile("docs/process-capture.md", "utf8");
|
||||
const blocks = [
|
||||
@@ -317,6 +451,76 @@ it.skipIf(CAPTURE_SKIP_REASON || process.platform === "win32")(
|
||||
20_000,
|
||||
);
|
||||
|
||||
it
|
||||
.skipIf(CAPTURE_SKIP_REASON || process.platform === "win32")
|
||||
.each(["cli", "mcp"] as const)(
|
||||
"retains stable root and nested symlink uncertainty with known sibling effects through %s",
|
||||
async (adapter) => {
|
||||
const root = await createTestTempDirectory("rea-fs-stable-link-adapter-");
|
||||
const reports = join(root, "real", "reports");
|
||||
await mkdir(reports, { recursive: true });
|
||||
await symlink("real/reports", join(root, "link"));
|
||||
await writeFile(join(root, "removed.txt"), "known sibling");
|
||||
const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)({
|
||||
executable: process.execPath,
|
||||
arguments: [
|
||||
"-e",
|
||||
"const fs=require('node:fs');fs.writeFileSync('link/final.txt','done');fs.unlinkSync('removed.txt');",
|
||||
],
|
||||
working_directory: root,
|
||||
filesystem_observation_paths: [join(root, "link"), reports, root],
|
||||
});
|
||||
const capture = parseProcessCapture(evidence.normalized_result);
|
||||
expect(capture.exit).toMatchObject({ code: 0, reason: "exited" });
|
||||
expect(await readFile(join(reports, "final.txt"), "utf8")).toBe("done");
|
||||
for (const path of ["root_0:.", "root_2:link"])
|
||||
expect(capture.filesystem_effects).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path,
|
||||
status: "unchanged",
|
||||
after: expect.objectContaining({
|
||||
type: "symlink",
|
||||
symlink_target: "real/reports",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(capture.filesystem_effects).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "root_2:removed.txt",
|
||||
status: "deleted",
|
||||
}),
|
||||
);
|
||||
expect(capture.filesystem_effects).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "root_1:final.txt",
|
||||
status: "created",
|
||||
after: expect.objectContaining({
|
||||
sha256: createHash("sha256").update("done").digest("hex"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
capture.files_after.some(({ path }) => path === "root_0:final.txt"),
|
||||
).toBe(false);
|
||||
for (const phase of ["filesystem_before", "filesystem_after"] as const)
|
||||
expect(capture.truncation_details[phase]).toMatchObject({
|
||||
enumeration_truncated: true,
|
||||
enumeration_reasons: ["symlink_not_followed"],
|
||||
enumeration_omissions: ["root_0:.", "root_2:link"].map((path) => ({
|
||||
path,
|
||||
reason: "symlink_not_followed",
|
||||
system_code: null,
|
||||
})),
|
||||
});
|
||||
expect(capture.residual_unknowns).toContainEqual({
|
||||
scope: "filesystem",
|
||||
reason: expect.stringContaining("symlink_not_followed"),
|
||||
});
|
||||
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) => {
|
||||
@@ -350,9 +554,18 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)(
|
||||
before: expect.objectContaining({ type: "directory" }),
|
||||
}),
|
||||
]);
|
||||
const symlink = replacement.includes("symlinkSync");
|
||||
expect(capture.truncation_details.filesystem_after).toMatchObject({
|
||||
enumeration_truncated: false,
|
||||
enumeration_omissions: [],
|
||||
enumeration_truncated: symlink,
|
||||
enumeration_omissions: symlink
|
||||
? [
|
||||
{
|
||||
path: "root_1:.",
|
||||
reason: "symlink_not_followed",
|
||||
system_code: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user