[GH-ISSUE #3303] [Bug] Process capture reports a retargeted symlink as unchanged #1178

Closed
opened 2026-10-15 18:31:46 +00:00 by bsakaguchi · 1 comment
Owner

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

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

capture-process reports a symlink as unchanged after the captured command changes it from a working link to a broken link. Two different, equal-length target strings containing .. collapse to the same stored symlink_target, so the complete before/after snapshots lose the target change. This hides an observable filesystem effect even with normalization.paths: false and truncated: false.

The public CLI exits successfully. The failure occurred in all three executed runs of this target change. Both stored target fields contain the same absolute path ending in target, and the original target strings cannot be recovered from those fields. Independent before/after readlink and existence checks distinguish the working link from the replacement broken link.

Reproduction

  1. Start from a clean checkout at 8dde6370d0809ad04cda3342d4e6f290f48aa063, install the locked dependencies, and build the runtime. These are preparation commands for a clean checkout; the observed reproduction used the built source checkout.
git clone https://github.com/morluto/rea.git rea-repro
cd rea-repro
git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063
npm ci
npm run build:cached
  1. From the checkout root, run the following script. It creates every input file inline in an isolated directory, supplies an isolated home to REA, invokes the public CLI, prints the relevant observations, and removes its files.
node --input-type=module <<'JS'
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';

const cli = path.resolve('scripts/rea.mjs');
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-symlink-repro-'));
try {
  const root = path.join(work, 'files');
  const home = path.join(work, 'home');
  fs.mkdirSync(path.join(root, 'dir1'), { recursive: true });
  fs.mkdirSync(home);
  fs.writeFileSync(path.join(root, 'target'), 'data');
  const link = path.join(root, 'link');
  fs.symlinkSync('dir1/../target', link);
  const before = { target: fs.readlinkSync(link), exists: fs.existsSync(link) };
  const scenario = {
    executable: process.execPath,
    arguments: ['-e', "const fs=require('node:fs');fs.unlinkSync('link');fs.symlinkSync('dir2/../target','link');"],
    working_directory: root,
    filesystem_observation_paths: ['.'],
    timeout_ms: 5000,
    normalization: { paths: false }
  };
  const scenarioPath = path.join(work, 'scenario.json');
  fs.writeFileSync(scenarioPath, JSON.stringify(scenario));
  const run = spawnSync(process.execPath, [cli, 'capture-process', scenarioPath, '--format', 'json'], {
    encoding: 'utf8',
    env: { ...process.env, HOME: home, TMPDIR: work },
    timeout: 20000,
    maxBuffer: 1024 * 1024
  });
  if (run.status !== 0) throw new Error(run.stderr || run.stdout);
  const result = JSON.parse(run.stdout).normalized_result;
  const effect = result.filesystem_effects.find(item => item.path === 'root_0:link');
  console.log(JSON.stringify({
    cli_status: run.status,
    before,
    after: { target: fs.readlinkSync(link), exists: fs.existsSync(link) },
    effect: effect.status,
    snapshot_targets_equal: effect.before.symlink_target === effect.after.symlink_target,
    truncated: result.truncated
  }, null, 2));
} finally {
  fs.rmSync(work, { recursive: true, force: true });
}
JS
  1. Observe that before.target is dir1/../target and resolves successfully, while after.target is dir2/../target and does not resolve. REA nevertheless reports effect: unchanged, equal snapshot targets, and no truncation. dir1 exists and dir2 does not. Both literal target strings have 14 bytes. The operating system must traverse the nonexistent dir2 component before processing .., so the replacement cannot resolve to the existing file named target.

Expected behavior

The before/after evidence should distinguish the two observed symlink targets and report root_0:link as modified. Capturing the link's metadata should preserve its target without requiring traversal of the linked file. A lexical absolute path that removes meaningful components does not establish the link's operating-system target.

docs/process-capture.md:228 promises before/after filesystem snapshots and classification of observed entries. AGENTS.md:13 requires preservation of local paths and metadata. src/process/capture/FilesystemCoverage.test.ts:312 pins modified symlink targets and unchanged stable targets without requiring content hashes.

Impact and scope

Impact level: P2, supported. Complete filesystem Evidence falsely marks a persistent working-to-broken symlink change as unchanged and discards the original target distinction. This affects target spellings that collapse to the same lexical path with matching metadata; it does not establish general capture failure or data modification by REA.

Workaround status: available. Record the literal target with readlink before and after the run. The reproduction directly observes both different target strings with Node's readlinkSync; those separate observations preserve the missing fact, although they are not included in REA's filesystem snapshots.

REA version

6.4.0 (main at 8dde6370d0)

Runtime and platform

Node.js v26.10.0; macOS 27.0.1; arm64; built source runtime. Runtime reproduction was executed on macOS; Linux behavior was not executed.

Target and artifact context

An isolated local filesystem fixture contains the regular file target with contents data, directory dir1, and symlink link. A Node child replaces link from dir1/../target to dir2/../target, with dir2 absent. The scenario selects filesystem_observation_paths: ['.'], disables path normalization, and inspects the returned capture Evidence's normalized_result.filesystem_effects entry for root_0:link.

Interface

CLI

Earliest failure stage

Capture or observation

Relevant output or evidence

The public CLI reproduction returned the following output, with the same failure in all three executed runs.

{
  "cli_status": 0,
  "before": { "target": "dir1/../target", "exists": true },
  "after": { "target": "dir2/../target", "exists": false },
  "effect": "unchanged",
  "snapshot_targets_equal": true,
  "truncated": false
}

A control that recreated dir1/../target stayed unchanged; a control that replaced it with other reported modified. Both controls completed successfully without truncation.

Typed outcome or recovery expected

Run the reproduction and require the link effect to be modified, with before/after evidence preserving the distinct observed targets. Keep the identical-target control unchanged and the ordinary retargeting control modified. Capturing link metadata must continue to work for broken links and must not require following their targets.

Analysis or suspected cause

Source-derived reasoning: src/process/capture/FilesystemSnapshot.ts:205 stores a lexically resolved target instead of the observed target spelling. src/process/capture/ProcessFilesystemEffects.ts:87 compares that stored value, mode, and size. The equal-length literal targets collapse to the same lexical path, so these comparisons lose the changed target distinction.

The executed CLI result and independent before/after readlink and existence checks establish the violation. The source locators explain the lost distinction separately from those observations.

Additional context

Related #59 requests broad owned-process and filesystem capture, including symlink target snapshots; this report concerns incorrect target preservation in the existing capture implementation.

Related #3238 fails capture when an observed file's parent becomes a regular file; this reproduction completes capture and misclassifies a changed symlink.

Related #1231, closed as completed, concerns false deletion under truncated snapshots; both snapshots here are complete and discard distinct observed target strings.

Related #2545, closed as completed, concerns snapshot failure on unlistable directories; no enumeration failure occurs here.

Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3303 ### 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 `capture-process` reports a symlink as `unchanged` after the captured command changes it from a working link to a broken link. Two different, equal-length target strings containing `..` collapse to the same stored `symlink_target`, so the complete before/after snapshots lose the target change. This hides an observable filesystem effect even with `normalization.paths: false` and `truncated: false`. The public CLI exits successfully. The failure occurred in all three executed runs of this target change. Both stored target fields contain the same absolute path ending in `target`, and the original target strings cannot be recovered from those fields. Independent before/after `readlink` and existence checks distinguish the working link from the replacement broken link. ### Reproduction 1. Start from a clean checkout at `8dde6370d0809ad04cda3342d4e6f290f48aa063`, install the locked dependencies, and build the runtime. These are preparation commands for a clean checkout; the observed reproduction used the built source checkout. ```sh git clone https://github.com/morluto/rea.git rea-repro cd rea-repro git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063 npm ci npm run build:cached ``` 2. From the checkout root, run the following script. It creates every input file inline in an isolated directory, supplies an isolated home to REA, invokes the public CLI, prints the relevant observations, and removes its files. ```sh node --input-type=module <<'JS' import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; const cli = path.resolve('scripts/rea.mjs'); const work = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-symlink-repro-')); try { const root = path.join(work, 'files'); const home = path.join(work, 'home'); fs.mkdirSync(path.join(root, 'dir1'), { recursive: true }); fs.mkdirSync(home); fs.writeFileSync(path.join(root, 'target'), 'data'); const link = path.join(root, 'link'); fs.symlinkSync('dir1/../target', link); const before = { target: fs.readlinkSync(link), exists: fs.existsSync(link) }; const scenario = { executable: process.execPath, arguments: ['-e', "const fs=require('node:fs');fs.unlinkSync('link');fs.symlinkSync('dir2/../target','link');"], working_directory: root, filesystem_observation_paths: ['.'], timeout_ms: 5000, normalization: { paths: false } }; const scenarioPath = path.join(work, 'scenario.json'); fs.writeFileSync(scenarioPath, JSON.stringify(scenario)); const run = spawnSync(process.execPath, [cli, 'capture-process', scenarioPath, '--format', 'json'], { encoding: 'utf8', env: { ...process.env, HOME: home, TMPDIR: work }, timeout: 20000, maxBuffer: 1024 * 1024 }); if (run.status !== 0) throw new Error(run.stderr || run.stdout); const result = JSON.parse(run.stdout).normalized_result; const effect = result.filesystem_effects.find(item => item.path === 'root_0:link'); console.log(JSON.stringify({ cli_status: run.status, before, after: { target: fs.readlinkSync(link), exists: fs.existsSync(link) }, effect: effect.status, snapshot_targets_equal: effect.before.symlink_target === effect.after.symlink_target, truncated: result.truncated }, null, 2)); } finally { fs.rmSync(work, { recursive: true, force: true }); } JS ``` 3. Observe that `before.target` is `dir1/../target` and resolves successfully, while `after.target` is `dir2/../target` and does not resolve. REA nevertheless reports `effect: unchanged`, equal snapshot targets, and no truncation. `dir1` exists and `dir2` does not. Both literal target strings have 14 bytes. The operating system must traverse the nonexistent `dir2` component before processing `..`, so the replacement cannot resolve to the existing file named `target`. ### Expected behavior The before/after evidence should distinguish the two observed symlink targets and report `root_0:link` as `modified`. Capturing the link's metadata should preserve its target without requiring traversal of the linked file. A lexical absolute path that removes meaningful components does not establish the link's operating-system target. `docs/process-capture.md:228` promises before/after filesystem snapshots and classification of observed entries. `AGENTS.md:13` requires preservation of local paths and metadata. `src/process/capture/FilesystemCoverage.test.ts:312` pins modified symlink targets and unchanged stable targets without requiring content hashes. ### Impact and scope Impact level: P2, supported. Complete filesystem Evidence falsely marks a persistent working-to-broken symlink change as unchanged and discards the original target distinction. This affects target spellings that collapse to the same lexical path with matching metadata; it does not establish general capture failure or data modification by REA. Workaround status: available. Record the literal target with `readlink` before and after the run. The reproduction directly observes both different target strings with Node's `readlinkSync`; those separate observations preserve the missing fact, although they are not included in REA's filesystem snapshots. ### REA version 6.4.0 (main at 8dde6370d0809ad04cda3342d4e6f290f48aa063) ### Runtime and platform Node.js v26.10.0; macOS 27.0.1; arm64; built source runtime. Runtime reproduction was executed on macOS; Linux behavior was not executed. ### Target and artifact context An isolated local filesystem fixture contains the regular file `target` with contents `data`, directory `dir1`, and symlink `link`. A Node child replaces `link` from `dir1/../target` to `dir2/../target`, with `dir2` absent. The scenario selects `filesystem_observation_paths: ['.']`, disables path normalization, and inspects the returned capture Evidence's `normalized_result.filesystem_effects` entry for `root_0:link`. ### Interface CLI ### Earliest failure stage Capture or observation ### Relevant output or evidence The public CLI reproduction returned the following output, with the same failure in all three executed runs. ```text { "cli_status": 0, "before": { "target": "dir1/../target", "exists": true }, "after": { "target": "dir2/../target", "exists": false }, "effect": "unchanged", "snapshot_targets_equal": true, "truncated": false } ``` A control that recreated `dir1/../target` stayed `unchanged`; a control that replaced it with `other` reported `modified`. Both controls completed successfully without truncation. ### Typed outcome or recovery expected Run the reproduction and require the link effect to be `modified`, with before/after evidence preserving the distinct observed targets. Keep the identical-target control `unchanged` and the ordinary retargeting control `modified`. Capturing link metadata must continue to work for broken links and must not require following their targets. ### Analysis or suspected cause Source-derived reasoning: `src/process/capture/FilesystemSnapshot.ts:205` stores a lexically resolved target instead of the observed target spelling. `src/process/capture/ProcessFilesystemEffects.ts:87` compares that stored value, mode, and size. The equal-length literal targets collapse to the same lexical path, so these comparisons lose the changed target distinction. The executed CLI result and independent before/after `readlink` and existence checks establish the violation. The source locators explain the lost distinction separately from those observations. ### Additional context Related [#59](https://github.com/morluto/rea/issues/59) requests broad owned-process and filesystem capture, including symlink target snapshots; this report concerns incorrect target preservation in the existing capture implementation. Related [#3238](https://github.com/morluto/rea/issues/3238) fails capture when an observed file's parent becomes a regular file; this reproduction completes capture and misclassifies a changed symlink. Related [#1231](https://github.com/morluto/rea/issues/1231), closed as completed, concerns false deletion under truncated snapshots; both snapshots here are complete and discard distinct observed target strings. Related [#2545](https://github.com/morluto/rea/issues/2545), closed as completed, concerns snapshot failure on unlistable directories; no enumeration failure occurs here.
Author
Owner

@Sriram-PR commented on GitHub (Oct 14, 2026):

I'll take this one, PR shortly.

<!-- gh-comment-id:6165789267 --> @Sriram-PR commented on GitHub (Oct 14, 2026): I'll take this one, PR shortly.
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#1178
No description provided.