[GH-ISSUE #6213] [Bug] Capture fails when an initially absent observation root gains a non-directory ancestor #3782

Closed
opened 2026-10-31 00:44:00 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 22, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6213

Originally assigned to: @kaoru0822-kitauji on GitHub.

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Process capture or runtime observation

Observed behavior

A capture_process_scenario MCP request selects state/reports, initially absent beneath an existing directory. The child renames state, replaces it with a regular file, prints finished, and exits 0. Final filesystem observation then returns process_capture_failed / execution_failure with ENOTDIR instead of complete Capture Evidence. Exit and output remain in the partial observations. This reproduced in both target requests through the production MCP registration; process capture and the filesystem were not mocked.

Reproduction

With the baseline dependencies already installed, save the following test under tests/boundary/mcp/reproAbsentRoot.test.ts. The temporary filename and private fixture paths are generalized; the test body is the executed probe. It runs two target requests plus four controls sequentially in one diagnostic test. Both target requests reproduced the failure; each control ran once.

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
import { silentLogger } from "../../../src/logger.js";

it("checks absent selected roots through registered MCP capture", async () => {
  const scratch = process.env.VERIFY_ROOT!;
  const harness = await createApplicationMcpHarness({ serverOptions: { logger: silentLogger } });
  const receipts = [];
  try {
    for (const mode of ["absent-obstructed", "absent-obstructed", "absent-renamed", "existing-obstructed", "parent-selected", "initial-obstructed"]) {
      const root = await mkdtemp(join(scratch, "fixture-"));
      try {
        if (mode === "initial-obstructed") await writeFile(join(root, "state"), "file");
        else await mkdir(join(root, "state"));
        if (mode === "existing-obstructed") await writeFile(join(root, "state", "reports"), "payload");
        const script = mode === "initial-obstructed" ? "console.log('finished')" :
          `const fs = require('node:fs'); fs.renameSync('state','saved-state'); ${mode === "absent-renamed" ? "" : "fs.writeFileSync('state','file');"} console.log('finished');`;
        const response = await harness.client.callTool({
          name: "capture_process_scenario",
          arguments: {
            executable: process.execPath, arguments: ["-e", script], working_directory: root,
            filesystem_observation_paths: [mode === "parent-selected" ? root : join(root, "state", "reports")],
            timeout_ms: 5000, idle_timeout_ms: 5000, settle_ms: 100,
          },
        });
        receipts.push({ mode, response });
        console.log(mode, JSON.stringify(response));
      } finally { await rm(root, { recursive: true, force: true }); }
    }
    await writeFile(join(scratch, "mcp-receipts.json"), JSON.stringify(receipts, null, 2));
    expect(receipts).toHaveLength(6);
  } finally { await harness.close(); }
}, 60000);

Run from the checkout root with isolated configuration directories (private path values generalized):

verify_root="$(mktemp -d)"
mkdir -p "$verify_root"/{home,dsh,config,cache,fixtures}
HOME="$verify_root/home" DSH_HOME="$verify_root/dsh" XDG_CONFIG_HOME="$verify_root/config" XDG_CACHE_HOME="$verify_root/cache" VERIFY_ROOT="$verify_root/fixtures" node node_modules/vitest/vitest.mjs run tests/boundary/mcp/reproAbsentRoot.test.ts --maxWorkers=1

The diagnostic test completed successfully; inspect its returned MCP observations rather than treating the test pass as restored behavior. The first two responses contain the failure below. The renamed-parent-only control and initially existing selected-file control succeed; selecting the common ancestor succeeds; an initial obstruction fails before launch.

Expected behavior

Complete the capture with exit code 0, no filesystem effects for the selected root, and complete before/after absence. The filesystem observation contract states at lines 254-255 that a path missing at initial lookup remains an observed absence. An obstruction already present at initial admission should continue failing. No newly created descendant or inaccessible unknown state is being treated as absent in this fixture: a descendant cannot exist below a regular file.

Impact and scope

Both target requests failed after the child completed. The caller loses completed Capture Evidence, while partial terminal output, exit status and snapshot facts remain available. Selecting the existing common ancestor is a demonstrated safe bypass for this small fixture and preserves absence evidence with additional observed paths; it can increase snapshot budget use on larger trees. Other platforms and population frequency are NOT_ENOUGH_INFO; they were not tested. This is a bounded capture-completion defect, assessed P2, with no demonstrated security-boundary crossing.

REA version

Source commit c465ff5a81 (main)

Runtime and platform

Node.js v26.12.0; macOS 27.0.1; arm64. The repository pins Node.js 24.18.0; the executed runtime is in its supported Node 26 engine range.

Target and artifact context

Self-owned local Node.js child and disposable filesystem fixture; selected root state/reports is absent before and after execution. No analysis provider or target binary is required.

Interface

MCP

Earliest failure stage

Capture or observation

Relevant output or evidence

Observed twice through registered capture_process_scenario requests, with fixture paths replaced below by <fixture>:

error.code: process_capture_failed
error.category: execution_failure
error.message: Process filesystem snapshot failed: ENOTDIR: not a directory, lstat '<fixture>/state/reports'
partial_observation.observations.exit:
  state: available
  value: {code: 0, signal: 0, reason: exited}
partial_observation.observations.frames.value[0].data: "finished\r\n"
partial_observation.observations.filesystem_snapshots.before.value:
  files: []
  completeRoots: [root_0]
  truncated: false
partial_observation.observations.filesystem_snapshots.after.value:
  files: []
  completeRoots: []
  truncated: true

All cleanup report resources were marked cleaned. One target response also retained a platform_binary_environment_withheld process-observation limitation; the second did not, and both failed identically. The renamed-parent-only control returned exit 0 and empty effects with complete coverage. The initially existing-file control returned a deleted effect. The initial-obstruction control reported no PTY child was spawned.

Typed outcome or recovery expected

Return successful Capture Evidence with complete absence for the previously admitted missing root. Preserve the initial-obstruction failure and genuine unexpected-I/O or cancellation failures with their partial observations.

Analysis or suspected cause

Source-derived explanation: prior root admission is reconstructed only from previous.files. An absent root has no file entry; initial missing-root handling records it in completeRoots. Consequently, the final root lookup does not qualify for the previously observed ENOTDIR exception. Capture finalization passes the initial snapshot but receives that failure after process settlement.

Additional context

Related #3238 fixes the initially existing-file case: its report, fix and maintainer closure describe retaining a deleted file after ancestor obstruction. The existing-file control here confirms that behavior works. This report covers a root whose initial complete observation is absence. Related PR #3263 adds cancellation coverage for the existing-file case.

Searched open and closed issues and PRs using snapshotRoots, ENOTDIR absent, and FilesystemSnapshot; no matching initially absent-root report was found. Further broader searches hit the API rate limit. Fetched current upstream main and found no later commits affecting the cited snapshot implementation or process-capture documentation; this is a source freshness check, not a runtime run on that revision. Last-known-good behavior for this exact absent-root case is NOT_ENOUGH_INFO; no historical passing baseline was tested.

Originally created by @coygeek on GitHub (Oct 22, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6213 Originally assigned to: @kaoru0822-kitauji on GitHub. ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Process capture or runtime observation ### Observed behavior A `capture_process_scenario` MCP request selects `state/reports`, initially absent beneath an existing directory. The child renames `state`, replaces it with a regular file, prints `finished`, and exits 0. Final filesystem observation then returns `process_capture_failed` / `execution_failure` with `ENOTDIR` instead of complete Capture Evidence. Exit and output remain in the partial observations. This reproduced in both target requests through the production MCP registration; process capture and the filesystem were not mocked. ### Reproduction With the baseline dependencies already installed, save the following test under `tests/boundary/mcp/reproAbsentRoot.test.ts`. The temporary filename and private fixture paths are generalized; the test body is the executed probe. It runs two target requests plus four controls sequentially in one diagnostic test. Both target requests reproduced the failure; each control ran once. ```ts import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { silentLogger } from "../../../src/logger.js"; it("checks absent selected roots through registered MCP capture", async () => { const scratch = process.env.VERIFY_ROOT!; const harness = await createApplicationMcpHarness({ serverOptions: { logger: silentLogger } }); const receipts = []; try { for (const mode of ["absent-obstructed", "absent-obstructed", "absent-renamed", "existing-obstructed", "parent-selected", "initial-obstructed"]) { const root = await mkdtemp(join(scratch, "fixture-")); try { if (mode === "initial-obstructed") await writeFile(join(root, "state"), "file"); else await mkdir(join(root, "state")); if (mode === "existing-obstructed") await writeFile(join(root, "state", "reports"), "payload"); const script = mode === "initial-obstructed" ? "console.log('finished')" : `const fs = require('node:fs'); fs.renameSync('state','saved-state'); ${mode === "absent-renamed" ? "" : "fs.writeFileSync('state','file');"} console.log('finished');`; const response = await harness.client.callTool({ name: "capture_process_scenario", arguments: { executable: process.execPath, arguments: ["-e", script], working_directory: root, filesystem_observation_paths: [mode === "parent-selected" ? root : join(root, "state", "reports")], timeout_ms: 5000, idle_timeout_ms: 5000, settle_ms: 100, }, }); receipts.push({ mode, response }); console.log(mode, JSON.stringify(response)); } finally { await rm(root, { recursive: true, force: true }); } } await writeFile(join(scratch, "mcp-receipts.json"), JSON.stringify(receipts, null, 2)); expect(receipts).toHaveLength(6); } finally { await harness.close(); } }, 60000); ``` Run from the checkout root with isolated configuration directories (private path values generalized): ```sh verify_root="$(mktemp -d)" mkdir -p "$verify_root"/{home,dsh,config,cache,fixtures} HOME="$verify_root/home" DSH_HOME="$verify_root/dsh" XDG_CONFIG_HOME="$verify_root/config" XDG_CACHE_HOME="$verify_root/cache" VERIFY_ROOT="$verify_root/fixtures" node node_modules/vitest/vitest.mjs run tests/boundary/mcp/reproAbsentRoot.test.ts --maxWorkers=1 ``` The diagnostic test completed successfully; inspect its returned MCP observations rather than treating the test pass as restored behavior. The first two responses contain the failure below. The renamed-parent-only control and initially existing selected-file control succeed; selecting the common ancestor succeeds; an initial obstruction fails before launch. ### Expected behavior Complete the capture with exit code 0, no filesystem effects for the selected root, and complete before/after absence. The [filesystem observation contract](https://github.com/morluto/rea/blob/c465ff5a814b8e1a386fc547cbcd94df5c35f086/docs/process-capture.md#L239) states at lines 254-255 that a path missing at initial lookup remains an observed absence. An obstruction already present at initial admission should continue failing. No newly created descendant or inaccessible unknown state is being treated as absent in this fixture: a descendant cannot exist below a regular file. ### Impact and scope Both target requests failed after the child completed. The caller loses completed Capture Evidence, while partial terminal output, exit status and snapshot facts remain available. Selecting the existing common ancestor is a demonstrated safe bypass for this small fixture and preserves absence evidence with additional observed paths; it can increase snapshot budget use on larger trees. Other platforms and population frequency are NOT_ENOUGH_INFO; they were not tested. This is a bounded capture-completion defect, assessed P2, with no demonstrated security-boundary crossing. ### REA version Source commit c465ff5a814b8e1a386fc547cbcd94df5c35f086 (main) ### Runtime and platform Node.js v26.12.0; macOS 27.0.1; arm64. The repository pins Node.js 24.18.0; the executed runtime is in its supported Node 26 engine range. ### Target and artifact context Self-owned local Node.js child and disposable filesystem fixture; selected root `state/reports` is absent before and after execution. No analysis provider or target binary is required. ### Interface MCP ### Earliest failure stage Capture or observation ### Relevant output or evidence Observed twice through registered `capture_process_scenario` requests, with fixture paths replaced below by `<fixture>`: ```text error.code: process_capture_failed error.category: execution_failure error.message: Process filesystem snapshot failed: ENOTDIR: not a directory, lstat '<fixture>/state/reports' partial_observation.observations.exit: state: available value: {code: 0, signal: 0, reason: exited} partial_observation.observations.frames.value[0].data: "finished\r\n" partial_observation.observations.filesystem_snapshots.before.value: files: [] completeRoots: [root_0] truncated: false partial_observation.observations.filesystem_snapshots.after.value: files: [] completeRoots: [] truncated: true ``` All cleanup report resources were marked cleaned. One target response also retained a `platform_binary_environment_withheld` process-observation limitation; the second did not, and both failed identically. The renamed-parent-only control returned exit 0 and empty effects with complete coverage. The initially existing-file control returned a deleted effect. The initial-obstruction control reported no PTY child was spawned. ### Typed outcome or recovery expected Return successful Capture Evidence with complete absence for the previously admitted missing root. Preserve the initial-obstruction failure and genuine unexpected-I/O or cancellation failures with their partial observations. ### Analysis or suspected cause Source-derived explanation: [prior root admission](https://github.com/morluto/rea/blob/c465ff5a814b8e1a386fc547cbcd94df5c35f086/src/process/capture/FilesystemSnapshot.ts#L333) is reconstructed only from `previous.files`. An absent root has no file entry; [initial missing-root handling](https://github.com/morluto/rea/blob/c465ff5a814b8e1a386fc547cbcd94df5c35f086/src/process/capture/FilesystemSnapshot.ts#L592) records it in `completeRoots`. Consequently, the final root lookup does not qualify for the [previously observed ENOTDIR exception](https://github.com/morluto/rea/blob/c465ff5a814b8e1a386fc547cbcd94df5c35f086/src/process/capture/FilesystemSnapshot.ts#L113). [Capture finalization](https://github.com/morluto/rea/blob/c465ff5a814b8e1a386fc547cbcd94df5c35f086/src/process/capture/ProcessHarness.ts#L542) passes the initial snapshot but receives that failure after process settlement. ### Additional context Related #3238 fixes the initially existing-file case: its report, fix and maintainer closure describe retaining a deleted file after ancestor obstruction. The existing-file control here confirms that behavior works. This report covers a root whose initial complete observation is absence. Related PR #3263 adds cancellation coverage for the existing-file case. Searched open and closed issues and PRs using `snapshotRoots`, `ENOTDIR absent`, and `FilesystemSnapshot`; no matching initially absent-root report was found. Further broader searches hit the API rate limit. Fetched current upstream main and found no later commits affecting the cited snapshot implementation or process-capture documentation; this is a source freshness check, not a runtime run on that revision. Last-known-good behavior for this exact absent-root case is NOT_ENOUGH_INFO; no historical passing baseline was tested.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3782
No description provided.