fix(process): preserve sibling path identities

Keep aliases on selected paths at clear text delimiters so a longer sibling filename is not rewritten as the executable. Version the normalization contract and cover source and actual PTY behavior.

Fixes #6586
This commit is contained in:
morluto
2026-10-23 18:03:55 +08:00
parent 65168f77fb
commit c74bc2108c
5 changed files with 80 additions and 4 deletions
+7
View File
@@ -269,6 +269,13 @@ while comparisons and trace assertions use the normalized text. Retaining the
original adds at most `limits.output_bytes` of text, because it comes from the
same admitted PTY chunks rather than a second unbounded stream.
When path normalization is enabled, aliases apply to selected path identities
at clear text delimiters. A selected executable path does not alias a longer
sibling filename such as `tool-helper`; selected parent directories still
normalize within descendant paths. The comparison contract records these rules as
`path-component-boundaries-v2`, so captures made with the earlier prefix rules
cannot be compared as if the text had the same meaning.
Rendered frame `lines` hold the visible rows after normalization, with trailing
U+0020 spaces removed. Blank rows and internal spaces remain. `columns` records
terminal cell width; normalization and Unicode can make string lengths differ
+1 -1
View File
@@ -133,7 +133,7 @@ export const processComparisonContract = (
limits: scenario.limits,
normalization: scenario.normalization,
...(scenario.normalization.paths
? { path_normalization_version: "longest-original-paths-v1" }
? { path_normalization_version: "path-component-boundaries-v2" }
: {}),
...(scenario.normalization.ports
? { port_normalization_version: "contextual-endpoints-v2" }
@@ -520,3 +520,21 @@ it("treats path metacharacters literally while preferring nested observed roots"
"<executable> <filesystem-root-1>/item <filesystem-root-0>/file <working-directory>/log <working-directory>/toolll",
);
});
it("does not give a selected executable identity to a sibling filename", () => {
const declared = parseProcessScenario({
executable: "/tmp/process-paths/original/tool",
working_directory: "/tmp/process-paths",
normalization: { paths: true, pids: false, ports: false, patterns: [] },
});
expect(
normalizeProcessText(
"/tmp/process-paths/original/tool-helper /tmp/process-paths/original/tool, /tmp/process-paths/original/tool",
declared,
"/tmp/rea-private",
rootPid,
),
).toBe(
"<working-directory>/original/tool-helper <working-directory>/original/tool, <executable>",
);
});
+8 -2
View File
@@ -314,10 +314,16 @@ const createPathRule = (
const pattern = paths
.map((path) => path.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))
.join("|");
const matcher = new RegExp(pattern, "gu");
// A selected path is an identity, not an arbitrary text prefix. Only match
// it at a clear text delimiter; keep the parent-directory case (`/root` in
// `/root/file`) while leaving punctuation-adjacent names untouched.
const matcher = new RegExp(
`(^|[\\s"'=:(\\[{<])(${pattern})(?=$|/|[\\s"'])`,
"gu",
);
return {
matcher,
replace: (path) => aliases.get(path) ?? path,
replace: (_match, prefix, path) => `${prefix}${aliases.get(path) ?? path}`,
};
};
@@ -262,7 +262,7 @@ itWithCaptureCapability(
).toBe(original.replaceAll("\n", "\r\n"));
expect(capture.manifest.comparison_contract).toHaveProperty(
"path_normalization_version",
"longest-original-paths-v1",
"path-component-boundaries-v2",
);
const legacyContract = { ...capture.manifest.comparison_contract };
delete legacyContract.path_normalization_version;
@@ -281,6 +281,25 @@ itWithCaptureCapability(
],
}),
);
const formerPathSemantics = {
...capture,
manifest: {
...capture.manifest,
comparison_contract: {
...capture.manifest.comparison_contract,
path_normalization_version: "longest-original-paths-v1",
},
comparison_contract_sha256: digestProcessCommitment({
...capture.manifest.comparison_contract,
path_normalization_version: "longest-original-paths-v1",
}),
},
};
expect(() => compareProcessCaptures(formerPathSemantics, capture)).toThrow(
expect.objectContaining({
issues: [expect.objectContaining({ path: ["right"] })],
}),
);
expect(capture.exit).toMatchObject({ code: 0, reason: "exited" });
expect(capture.cleanup).toMatchObject({
owned_process_group: "verified",
@@ -289,3 +308,29 @@ itWithCaptureCapability(
},
20_000,
);
itWithCaptureCapability(
"keeps sibling executable names distinct in actual PTY Evidence",
async () => {
const root = await createTestTempDirectory("rea-process-sibling-path-");
const executable = join(root, "original", "tool");
await mkdir(join(root, "original"), { recursive: true });
await symlink(process.execPath, executable);
const sibling = `${executable}-helper`;
const scenario = parseProcessScenario({
executable,
working_directory: root,
arguments: ["-e", `process.stdout.write(${JSON.stringify(sibling)})`],
normalization: { paths: true, pids: false, ports: false, patterns: [] },
settle_ms: 100,
});
const result = await captureProcessScenario(scenario);
if (!result.ok) throw result.error;
const capture = parseProcessCapture(
createProcessCaptureEvidence(scenario, result.value).normalized_result,
);
expect(capture.frames.map(({ data }) => data).join("")).toBe(
`<working-directory>/original/tool-helper`,
);
},
);