From c4aaf813e05a503b79b059ee9f6933701117f915 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:37:45 +0800 Subject: [PATCH 1/3] fix(javascript): retain selected application paths in Evidence Record the admitted selected path separately from canonical artifact identity in successful and partial Evidence. Anchor relative inputs consistently across both CLI routes using the existing caller-path owner. Adapt shared client verifier assertions and boundary coverage from PR #5261, and the dedicated-route anchoring correction proposed in PR #5265. Historical parsing remains independent of live paths, including legacy records without the parameter. Validation: 118 focused tests in eight files, static and docs checks, discovery, and installed Gemini CLI 0.29.5 loopback verification of 207 schemas/241 examples passed. Synthetic static-analysis fixtures; no live Google API or native target execution. Fixes #5258 Co-authored-by: kwakhyun Co-authored-by: vishal-tambi --- docs/javascript-artifact-reconstruction.md | 6 + .../lib/javascript-application-paths.d.mts | 8 + scripts/lib/javascript-application-paths.mjs | 25 +++ scripts/verify-claude-client.mjs | 3 +- scripts/verify-commandcode-client.mjs | 3 +- scripts/verify-copilot-client.mjs | 3 +- scripts/verify-deepseek-client.mjs | 3 +- scripts/verify-gemini-client.mjs | 3 +- scripts/verify-grok-client.mjs | 9 +- scripts/verify-native-agent-client.mjs | 3 +- scripts/verify-omp-client.mjs | 3 +- scripts/verify-opencode-client.mjs | 3 +- scripts/verify-qwen-client.mjs | 3 +- .../JavaScriptApplicationEvidence.ts | 1 + src/cli/electronCommands.ts | 3 +- .../javascriptAnalysisLimits.test.ts | 3 + .../javascriptClientVerifier.test.ts | 55 +++++++ .../javascriptApplicationCancellation.test.ts | 24 ++- .../javascriptSelectedPathEvidence.test.ts | 142 ++++++++++++++++++ 19 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 scripts/lib/javascript-application-paths.d.mts create mode 100644 scripts/lib/javascript-application-paths.mjs create mode 100644 tests/boundary/filesystem/javascriptClientVerifier.test.ts create mode 100644 tests/boundary/javascript/javascriptSelectedPathEvidence.test.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index df14f67e43..9d61a7b1df 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -50,6 +50,12 @@ The equivalent MCP input is: `input_path` must be absolute. `format` accepts `auto`, `asar`, or `directory`. `integrity_policy` accepts `fail` or `record-and-continue`; both CLI routes and the MCP tool default to `fail`. +Evidence parameters retain the caller-selected `input_path`, including a +symlink alias. Both CLI routes anchor relative inputs to their working +directory without resolving symlinks. The subject and normalized result use +the resolved path for artifact identity; the selected path remains a separate +request fact. Older Evidence can omit this parameter; retained records are +validated without resolving their paths against the live filesystem. The result retains the canonical local path, root artifact digest, artifact manifest and graph commitments, JavaScript Application Graph, static Electron summary, reconstruction statistics, and explicit limitations. It does diff --git a/scripts/lib/javascript-application-paths.d.mts b/scripts/lib/javascript-application-paths.d.mts new file mode 100644 index 0000000000..4181c5b81e --- /dev/null +++ b/scripts/lib/javascript-application-paths.d.mts @@ -0,0 +1,8 @@ +export function assertJavaScriptApplicationInputPaths( + evidence: { + readonly parameters: Readonly>; + readonly subject: { readonly local_path: string } | null; + readonly normalized_result: { readonly input_path: string }; + }, + inputPath: string, +): Promise; diff --git a/scripts/lib/javascript-application-paths.mjs b/scripts/lib/javascript-application-paths.mjs new file mode 100644 index 0000000000..0a3926df5b --- /dev/null +++ b/scripts/lib/javascript-application-paths.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { realpath } from "node:fs/promises"; + +/** Verify caller provenance separately from the resolved artifact identity. */ +export const assertJavaScriptApplicationInputPaths = async ( + evidence, + inputPath, +) => { + const canonicalPath = await realpath(inputPath); + assert.equal( + evidence.parameters.input_path, + inputPath, + "JavaScript Evidence must retain the caller's input_path", + ); + assert.equal( + evidence.subject?.local_path, + canonicalPath, + "JavaScript Evidence subject must identify the resolved artifact", + ); + assert.equal( + evidence.normalized_result.input_path, + canonicalPath, + "JavaScript analysis result must identify the resolved artifact", + ); +}; diff --git a/scripts/verify-claude-client.mjs b/scripts/verify-claude-client.mjs index 2c0d79bc09..9a7298920d 100644 --- a/scripts/verify-claude-client.mjs +++ b/scripts/verify-claude-client.mjs @@ -11,6 +11,7 @@ import { toolContract, } from "../dist/contracts/toolContracts.js"; import { exec } from "./lib/verify-package-core.mjs"; +import { assertJavaScriptApplicationInputPaths } from "./lib/javascript-application-paths.mjs"; import { expandMcpSessionStatus, sortVerifierNames, @@ -210,7 +211,7 @@ const fixture = await createAnthropicModelFixture({ const evidence = toolContract( "analyze_javascript_application", ).outputSchema.parse(observed); - assert.equal(evidence.subject.local_path, target); + await assertJavaScriptApplicationInputPaths(evidence, target); const shape = evidence.normalized_result.graph.nodes .flatMap((n) => n.observations) .map((o) => o.properties) diff --git a/scripts/verify-commandcode-client.mjs b/scripts/verify-commandcode-client.mjs index 71e97deab5..50ba09eac3 100644 --- a/scripts/verify-commandcode-client.mjs +++ b/scripts/verify-commandcode-client.mjs @@ -10,6 +10,7 @@ import { toolContract, } from "../dist/contracts/toolContracts.js"; import { exec } from "./lib/verify-package-core.mjs"; +import { assertJavaScriptApplicationInputPaths } from "./lib/javascript-application-paths.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; @@ -228,7 +229,7 @@ const fixture = await createOpenAiModelFixture({ const evidence = toolContract( "analyze_javascript_application", ).outputSchema.parse(JSON.parse(last)); - assert.equal(evidence.subject.local_path, target); + await assertJavaScriptApplicationInputPaths(evidence, target); const shapes = evidence.normalized_result.graph.nodes .flatMap((node) => node.observations) .map((observation) => observation.properties) diff --git a/scripts/verify-copilot-client.mjs b/scripts/verify-copilot-client.mjs index 98ed62efbf..a2b0e5a62f 100644 --- a/scripts/verify-copilot-client.mjs +++ b/scripts/verify-copilot-client.mjs @@ -10,6 +10,7 @@ import { toolContract, } from "../dist/contracts/toolContracts.js"; import { exec } from "./lib/verify-package-core.mjs"; +import { assertJavaScriptApplicationInputPaths } from "./lib/javascript-application-paths.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; @@ -175,7 +176,7 @@ const fixture = await createOpenAiModelFixture({ const evidence = toolContract( "analyze_javascript_application", ).outputSchema.parse(observed); - assert.equal(evidence.subject.local_path, target); + await assertJavaScriptApplicationInputPaths(evidence, target); const shape = evidence.normalized_result.graph.nodes .flatMap((n) => n.observations) .map((o) => o.properties) diff --git a/scripts/verify-deepseek-client.mjs b/scripts/verify-deepseek-client.mjs index 42aae272bc..b8d8dd7409 100644 --- a/scripts/verify-deepseek-client.mjs +++ b/scripts/verify-deepseek-client.mjs @@ -11,6 +11,7 @@ import { toolContract, } from "../dist/contracts/toolContracts.js"; import { exec, pathExists } from "./lib/verify-package-core.mjs"; +import { assertJavaScriptApplicationInputPaths } from "./lib/javascript-application-paths.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; @@ -183,7 +184,7 @@ const fixture = await createOpenAiModelFixture({ const evidence = toolContract( "analyze_javascript_application", ).outputSchema.parse(found); - assert.equal(evidence.subject.local_path, target); + await assertJavaScriptApplicationInputPaths(evidence, target); const shape = evidence.normalized_result.graph.nodes .flatMap((node) => node.observations) .map((observation) => observation.properties) diff --git a/scripts/verify-gemini-client.mjs b/scripts/verify-gemini-client.mjs index 52c9aee08f..97b0995450 100644 --- a/scripts/verify-gemini-client.mjs +++ b/scripts/verify-gemini-client.mjs @@ -15,6 +15,7 @@ import { toolContract, } from "../dist/contracts/toolContracts.js"; import { exec } from "./lib/verify-package-core.mjs"; +import { assertJavaScriptApplicationInputPaths } from "./lib/javascript-application-paths.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { createGeminiModelFixture } from "./verify/clients/gemini-model-fixture.mjs"; @@ -307,7 +308,7 @@ const fixture = await createGeminiModelFixture({ const evidence = toolContract( "analyze_javascript_application", ).outputSchema.parse(observed); - assert.equal(evidence.subject.local_path, target); + await assertJavaScriptApplicationInputPaths(evidence, target); const shape = evidence.normalized_result.graph.nodes .flatMap((n) => n.observations) .map((o) => o.properties) diff --git a/scripts/verify-grok-client.mjs b/scripts/verify-grok-client.mjs index e1c066770b..befe2de8e9 100644 --- a/scripts/verify-grok-client.mjs +++ b/scripts/verify-grok-client.mjs @@ -10,6 +10,7 @@ import { toolContract, } from "../dist/contracts/toolContracts.js"; import { exec } from "./lib/verify-package-core.mjs"; +import { assertJavaScriptApplicationInputPaths } from "./lib/javascript-application-paths.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; @@ -121,11 +122,11 @@ const call = (name, args) => { ], }; }; -const verifyEvidence = (bytes) => { +const verifyEvidence = async (bytes) => { const evidence = toolContract( "analyze_javascript_application", ).outputSchema.parse(JSON.parse(bytes.toString("utf8"))); - assert.equal(evidence.subject.local_path, target); + await assertJavaScriptApplicationInputPaths(evidence, target); const shapes = evidence.normalized_result.graph.nodes .flatMap((node) => node.observations) .map((observation) => observation.properties) @@ -291,7 +292,7 @@ const fixture = await createOpenAiModelFixture({ const marker = "Full output written to: "; const start = last.indexOf(marker); if (start < 0) { - verifyEvidence(Buffer.from(last, "utf8")); + await verifyEvidence(Buffer.from(last, "utf8")); finalSeen = true; return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" }; } @@ -310,7 +311,7 @@ const fixture = await createOpenAiModelFixture({ "Unexpected native MCP spill path", ); retainedArtifact = file; - expectedProjection = verifyEvidence(await readFile(file)); + expectedProjection = await verifyEvidence(await readFile(file)); spillQueryRequested = true; return call("run_terminal_command", { command: [process.execPath, "-e", recoveryCode, file] diff --git a/scripts/verify-native-agent-client.mjs b/scripts/verify-native-agent-client.mjs index af2ab0ceca..d4ba0c8b43 100644 --- a/scripts/verify-native-agent-client.mjs +++ b/scripts/verify-native-agent-client.mjs @@ -9,6 +9,7 @@ import { toolContract, } from "../dist/contracts/toolContracts.js"; import { exec } from "./lib/verify-package-core.mjs"; +import { assertJavaScriptApplicationInputPaths } from "./lib/javascript-application-paths.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; @@ -169,7 +170,7 @@ const validateEvidence = async (value) => { const evidence = toolContract( "analyze_javascript_application", ).outputSchema.parse(value); - assert.equal(evidence.subject.local_path, target); + await assertJavaScriptApplicationInputPaths(evidence, target); const shape = evidence.normalized_result.graph.nodes .flatMap((n) => n.observations) .map((o) => o.properties) diff --git a/scripts/verify-omp-client.mjs b/scripts/verify-omp-client.mjs index ccf9f2ad0e..f107ce9b06 100644 --- a/scripts/verify-omp-client.mjs +++ b/scripts/verify-omp-client.mjs @@ -9,6 +9,7 @@ import { toolContract, } from "../dist/contracts/toolContracts.js"; import { exec } from "./lib/verify-package-core.mjs"; +import { assertJavaScriptApplicationInputPaths } from "./lib/javascript-application-paths.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; @@ -156,7 +157,7 @@ const fixture = await createOpenAiModelFixture({ const evidence = toolContract( "analyze_javascript_application", ).outputSchema.parse(JSON.parse(evidenceReply.content)); - assert.equal(evidence.subject.local_path, target); + await assertJavaScriptApplicationInputPaths(evidence, target); const shape = evidence.normalized_result.graph.nodes .flatMap((node) => node.observations) .map((observation) => observation.properties) diff --git a/scripts/verify-opencode-client.mjs b/scripts/verify-opencode-client.mjs index b1006f8ba2..2a3e4c0501 100644 --- a/scripts/verify-opencode-client.mjs +++ b/scripts/verify-opencode-client.mjs @@ -10,6 +10,7 @@ import { toolContract, } from "../dist/contracts/toolContracts.js"; import { exec } from "./lib/verify-package-core.mjs"; +import { assertJavaScriptApplicationInputPaths } from "./lib/javascript-application-paths.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; @@ -167,7 +168,7 @@ const fixture = await createOpenAiModelFixture({ const evidence = toolContract( "analyze_javascript_application", ).outputSchema.parse(observed); - assert.equal(evidence.subject.local_path, target); + await assertJavaScriptApplicationInputPaths(evidence, target); const shape = evidence.normalized_result.graph.nodes .flatMap((n) => n.observations) .map((o) => o.properties) diff --git a/scripts/verify-qwen-client.mjs b/scripts/verify-qwen-client.mjs index fff4338b7d..50001103c8 100644 --- a/scripts/verify-qwen-client.mjs +++ b/scripts/verify-qwen-client.mjs @@ -12,6 +12,7 @@ import { join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { createHash } from "node:crypto"; import { exec } from "./lib/verify-package-core.mjs"; +import { assertJavaScriptApplicationInputPaths } from "./lib/javascript-application-paths.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { TOOL_CONTRACTS, @@ -195,7 +196,7 @@ const fixture = await createOpenAiModelFixture({ "analyze_javascript_application", ).outputSchema.parse(JSON.parse(artifact.toString("utf8"))); assert(extractEvidence(evidence)); - assert.equal(evidence.subject.local_path, target); + await assertJavaScriptApplicationInputPaths(evidence, target); const shape = evidence.normalized_result.graph.nodes .flatMap((n) => n.observations) .map((o) => o.properties) diff --git a/src/application/javascript/JavaScriptApplicationEvidence.ts b/src/application/javascript/JavaScriptApplicationEvidence.ts index c604949ef3..f90c872bde 100644 --- a/src/application/javascript/JavaScriptApplicationEvidence.ts +++ b/src/application/javascript/JavaScriptApplicationEvidence.ts @@ -89,6 +89,7 @@ const applicationObservation = ( const parameters = ( input: AnalyzeJavaScriptApplicationInput, ): EvidenceObservation["parameters"] => ({ + input_path: input.input_path, format: input.format, integrity_policy: input.integrity_policy, ...(input.max_semantic_nodes === undefined diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index 8cc6550ba8..9995a4e8cc 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -22,6 +22,7 @@ import { } from "../cliObservationOptions.js"; import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js"; import { withCommandCancellation } from "./commandCancellation.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; /** Register CLI equivalents of the Electron MCP tools. */ @@ -279,7 +280,7 @@ const registerJavaScriptApplicationCommand = ( logCliCommand(logger, CLI_COMMANDS.analyzeJavaScriptApplication, () => runCliJavaScriptApplicationAnalysis( { - input_path: args.path, + input_path: absoluteCallerPath(args.path), format: options.artifactFormat, integrity_policy: options.integrityPolicy, ...(options.maxSemanticNodes === undefined diff --git a/tests/acceptance/applications/javascriptAnalysisLimits.test.ts b/tests/acceptance/applications/javascriptAnalysisLimits.test.ts index b0f180402e..593b89ae91 100644 --- a/tests/acceptance/applications/javascriptAnalysisLimits.test.ts +++ b/tests/acceptance/applications/javascriptAnalysisLimits.test.ts @@ -53,6 +53,7 @@ cliTest.for(cases)( expect(response.exitCode, response.stdout + response.stderr).toBe(0); const evidence = parseEvidence(response.json); expect(evidence.parameters).toEqual({ + input_path: root, format: "auto", integrity_policy: "fail", ...controls, @@ -81,6 +82,7 @@ it("preserves MCP analysis limits in retained Evidence and an exported bundle ro }) .parse(response.structuredContent).normalized_result; expected.set(parent_evidence_id, { + input_path: root, format: "auto", integrity_policy: "fail", ...controls, @@ -169,6 +171,7 @@ cliTest( (record) => record.operation === "analyze_javascript_application", ); expect(partial?.parameters).toEqual({ + input_path: root, format: "auto", integrity_policy: "fail", max_heap_mb: 128, diff --git a/tests/boundary/filesystem/javascriptClientVerifier.test.ts b/tests/boundary/filesystem/javascriptClientVerifier.test.ts new file mode 100644 index 0000000000..8fef4141d8 --- /dev/null +++ b/tests/boundary/filesystem/javascriptClientVerifier.test.ts @@ -0,0 +1,55 @@ +import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { assertJavaScriptApplicationInputPaths } from "../../../scripts/lib/javascript-application-paths.mjs"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; + +it("checks caller and canonical paths independently in real JavaScript analysis Evidence", async () => { + const root = await createTestTempDirectory("rea-js-client-paths-"); + const directory = join(root, "app α测试"); + const inputPath = join(root, "selected app"); + await mkdir(directory); + await writeFile(join(directory, "main.js"), "export const observed = 7;\n"); + await symlink( + directory, + inputPath, + process.platform === "win32" ? "junction" : "dir", + ); + const canonicalPath = await realpath(inputPath); + const result = await analyzeJavaScriptApplication({ input_path: inputPath }); + if (!result.ok) throw result.error; + const evidence = { + ...result.value, + normalized_result: javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ), + }; + + await expect( + assertJavaScriptApplicationInputPaths(evidence, inputPath), + ).resolves.toBeUndefined(); + + for (const parameters of [{}, { input_path: canonicalPath }]) + await expect( + assertJavaScriptApplicationInputPaths( + { ...evidence, parameters }, + inputPath, + ), + ).rejects.toThrow("must retain the caller's input_path"); + + await expect( + assertJavaScriptApplicationInputPaths( + { ...evidence, subject: { local_path: inputPath } }, + inputPath, + ), + ).rejects.toThrow("subject must identify the resolved artifact"); + await expect( + assertJavaScriptApplicationInputPaths( + { ...evidence, normalized_result: { input_path: inputPath } }, + inputPath, + ), + ).rejects.toThrow("result must identify the resolved artifact"); +}); diff --git a/tests/boundary/javascript/javascriptApplicationCancellation.test.ts b/tests/boundary/javascript/javascriptApplicationCancellation.test.ts index cecc0db287..2283006b1b 100644 --- a/tests/boundary/javascript/javascriptApplicationCancellation.test.ts +++ b/tests/boundary/javascript/javascriptApplicationCancellation.test.ts @@ -1,5 +1,5 @@ import { setImmediate } from "node:timers"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, onTestFinished } from "vitest"; import { z } from "zod"; @@ -16,7 +16,16 @@ import { analyzeJavaScriptApplication } from "../../support/javascriptApplicatio it.each(["during cleanup", "on the next control turn"] as const)( "retains the final source when cancellation arrives %s after analysis", async (timing) => { - const root = await createTestTempDirectory("rea-js-cancel-handoff-"); + const parent = await createTestTempDirectory("rea-js-cancel-handoff-"); + const root = join(parent, "physical"); + const selected = join(parent, "selected"); + await mkdir(root); + await symlink( + root, + selected, + process.platform === "win32" ? "junction" : "dir", + ); + const resolved = await realpath(root); await writeFile( join(root, "main.js"), 'import { readFile } from "node:fs/promises"; export { readFile };\n', @@ -35,13 +44,22 @@ it.each(["during cleanup", "on the next control turn"] as const)( const scope = new CancellingCleanupScope(); onTestFinished(() => scope.close()); const result = await analyzeOwnedApplication( - { input_path: root, format: "directory" }, + { input_path: selected, format: "directory" }, scope, { signal: controller.signal }, ); if (result.ok) throw new Error("Cancelled handoff must not succeed"); const projected = projectAnalysisError(result.error); expect(projected.code).toBe("cancelled"); + expect(projected).toMatchObject({ + details: { + partial_observation: { + parameters: { input_path: selected }, + subject: { local_path: resolved }, + normalized_result: { input_path: resolved }, + }, + }, + }); const partial = z .object({ details: z.object({ diff --git a/tests/boundary/javascript/javascriptSelectedPathEvidence.test.ts b/tests/boundary/javascript/javascriptSelectedPathEvidence.test.ts new file mode 100644 index 0000000000..d5372238ef --- /dev/null +++ b/tests/boundary/javascript/javascriptSelectedPathEvidence.test.ts @@ -0,0 +1,142 @@ +import { + mkdir, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js"; +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "retains selected JavaScript path separately from resolved identity", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-js-selected-path-"); + const physicalPath = join(root, "physical-app"); + const selectedPath = join(root, "selected-app"); + await mkdir(physicalPath); + await writeFile(join(physicalPath, "main.js"), "export const value = 7;\n"); + await symlink( + physicalPath, + selectedPath, + process.platform === "win32" ? "junction" : "dir", + ); + const resolvedPath = await realpath(physicalPath); + expect(await realpath(selectedPath)).toBe(resolvedPath); + + const cliResponse = await cli.run({ + arguments: [ + "analyze-javascript-application", + selectedPath, + "--artifact-format", + "directory", + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(cliResponse.exitCode, cliResponse.stderr).toBe(0); + const cliEvidence = parseEvidence(cliResponse.json); + + for (const command of ["analyze", "analyze-javascript-application"]) { + const relativeResponse = await cli.run({ + arguments: [command, "./selected-app", "--json"], + cwd: root, + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(relativeResponse.exitCode, relativeResponse.stderr).toBe(0); + const relativeEvidence = parseEvidence(relativeResponse.json); + expect(relativeEvidence.parameters.input_path, command).toBe( + selectedPath, + ); + expect(relativeEvidence.subject).toEqual(cliEvidence.subject); + expect(relativeEvidence.normalized_result).toEqual( + cliEvidence.normalized_result, + ); + } + + const { call } = await connectLocalToolsMcp({ REA_LOG_LEVEL: "silent" }); + const aliasResponse = await call("analyze_javascript_application", { + input_path: selectedPath, + format: "directory", + integrity_policy: "fail", + }); + expect( + aliasResponse.isError, + JSON.stringify(aliasResponse.content), + ).not.toBe(true); + const aliasEvidence = parseEvidence(aliasResponse.structuredContent); + const physicalResponse = await call("analyze_javascript_application", { + input_path: physicalPath, + format: "directory", + integrity_policy: "fail", + }); + expect( + physicalResponse.isError, + JSON.stringify(physicalResponse.content), + ).not.toBe(true); + const physicalEvidence = parseEvidence(physicalResponse.structuredContent); + + for (const evidence of [cliEvidence, aliasEvidence]) { + expect(evidence.parameters.input_path).toBe(selectedPath); + expect(evidence.subject?.local_path).toBe(resolvedPath); + expect(evidence.normalized_result).toMatchObject({ + input_path: resolvedPath, + }); + expect(parseApplicationGraphEvidence(evidence).ok).toBe(true); + } + expect(cliEvidence.normalized_result).toEqual( + aliasEvidence.normalized_result, + ); + expect(physicalEvidence.parameters.input_path).toBe(physicalPath); + expect(physicalEvidence.subject?.local_path).toBe(resolvedPath); + expect(physicalEvidence.normalized_result).toEqual( + aliasEvidence.normalized_result, + ); + expect(physicalEvidence.subject?.digest.sha256).toBe( + aliasEvidence.subject?.digest.sha256, + ); + expect(physicalEvidence.evidence_id).not.toBe(aliasEvidence.evidence_id); + const bundlePath = join(root, "retained-evidence.json"); + expect( + (await call("export_evidence_bundle", { path: bundlePath })).isError, + ).not.toBe(true); + await rm(selectedPath); + await rm(physicalPath, { recursive: true }); + const bundle = parseEvidenceBundle( + JSON.parse(await readFile(bundlePath, "utf8")), + ); + const retained = bundle.records.find( + (record) => record.evidence_id === aliasEvidence.evidence_id, + ); + expect(retained?.parameters.input_path).toBe(selectedPath); + for (const evidence of [ + cliEvidence, + aliasEvidence, + physicalEvidence, + retained, + ]) + expect( + parseApplicationGraphEvidence(JSON.parse(JSON.stringify(evidence))).ok, + ).toBe(true); + expect((await call("close_binary", {})).isError).not.toBe(true); + expect( + (await call("import_evidence_bundle", { path: bundlePath })).isError, + ).not.toBe(true); + + expect( + JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.parameters, + ).not.toHaveProperty("input_path"); + expect( + parseApplicationGraphEvidence(JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE).ok, + ).toBe(true); + }, +); From 5199c672ff2ea0d104dd569dad7d79e8dc9b9595 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:45:49 +0800 Subject: [PATCH 2/3] fix(process): keep lifecycle waits independent of application timers Use Node promise timers consistently for ownership retries, final verification and capture-journal quiet settlement. Preserve the existing elapsed-time budgets, ownership checks, cancellation and trailing-callback drain. Adapt the ownership timer correction from gensokyo-dev's PR #5249 and complete the same fix in the adjacent capture-journal owner. Three new controlled regressions fail before the fix; 93 source tests and 94 CLI/MCP/process boundary tests pass afterward, with static checks passing. Global performance replacement and ordinary native Jadx incidence remain unverified. Fixes #5267 Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- .../ProcessOwnership.finalSweep.test.ts | 103 ++++++++++++++++++ src/process/ProcessOwnership.ts | 20 +--- .../capture/ProcessCaptureLifecycle.ts | 2 +- src/process/capture/ProcessHarness.test.ts | 16 +++ 4 files changed, 126 insertions(+), 15 deletions(-) diff --git a/src/process/ProcessOwnership.finalSweep.test.ts b/src/process/ProcessOwnership.finalSweep.test.ts index b9c2cd1d5a..6ed2491fc4 100644 --- a/src/process/ProcessOwnership.finalSweep.test.ts +++ b/src/process/ProcessOwnership.finalSweep.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { cleanupOwnedProcessGroup, type ProcessOwnershipHost, + type ProcessRunTokenObservation, type ProcessTableEntry, } from "./ProcessOwnership.js"; import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; @@ -108,6 +109,108 @@ it("waits for native absence after one signal and does not signal again", async expect(fixture.signalGroups).toEqual([100]); }); +it("finishes the public cleanup sweep when the application timer is unavailable", async () => { + const unrelated: ProcessTableEntry = { + pid: 200, + parentPid: 1, + processGroupId: 200, + state: "S", + command: "transient-token-process", + }; + let signaled = false; + let postSignalSnapshots = 0; + let unrelatedTokenReads = 0; + const fixture = makeHost(() => { + signaled = true; + fixture.replaceRows([unrelated]); + }); + fixture.replaceRows([...initialRows, unrelated]); + const host: ProcessOwnershipHost = { + ...fixture.host, + listProcesses: async () => { + if (!signaled) return fixture.rows(); + postSignalSnapshots += 1; + if (postSignalSnapshots === 1) return fixture.rows(); + fixture.replaceRows([]); + return []; + }, + runTokens: async (entries) => + new Map( + entries.map(({ pid }) => { + if (pid !== unrelated.pid) + return [pid, { state: "readable", runId: "sweep-run" }] as const; + if (!signaled) + return [pid, { state: "readable", runId: undefined }] as const; + unrelatedTokenReads += 1; + return [pid, { state: "readable", runId: "sweep-run" }] as const; + }), + ), + }; + const applicationTimer = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(() => { + throw new Error("application timer is unavailable"); + }); + + try { + await expect(cleanupOwnedProcessGroup(ownership(), host)).resolves.toEqual({ + cleaned: true, + signaled: true, + }); + expect(unrelatedTokenReads).toBeGreaterThan(0); + expect(postSignalSnapshots).toBe(2); + expect(fixture.signalGroups).toEqual([100]); + expect(applicationTimer).not.toHaveBeenCalled(); + } finally { + applicationTimer.mockRestore(); + } +}); + +it("keeps an unreadable live group member fail-closed when the application timer is unavailable", async () => { + const fixture = makeHost(() => undefined); + let unreadableReads = 0; + const host: ProcessOwnershipHost = { + ...fixture.host, + runTokens: async (entries) => + new Map( + entries.map(({ pid }) => { + if (pid === 101) { + unreadableReads += 1; + return [ + pid, + { + state: "unavailable", + reason: "EACCES: fixture permission denied", + }, + ] as const; + } + return [pid, { state: "readable", runId: "sweep-run" }] as const; + }), + ), + }; + const applicationTimer = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(() => { + throw new Error("application timer is unavailable"); + }); + + try { + await expect( + cleanupOwnedProcessGroup(ownership(), host), + ).resolves.toMatchObject({ + cleaned: false, + reason: expect.stringContaining( + "process ownership token could not be read", + ), + }); + expect(unreadableReads).toBeGreaterThan(1); + expect(fixture.signalGroups).toEqual([]); + expect(applicationTimer).not.toHaveBeenCalled(); + } finally { + applicationTimer.mockRestore(); + } +}); + it("preserves a moved same-generation helper as unresolved through the final deadline", async () => { const child = initialRows[1]; if (child === undefined) throw new Error("fixture child is missing"); diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 307b20220c..a0c45fb571 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { performance } from "node:perf_hooks"; +import { setTimeout as delay } from "node:timers/promises"; import { promisify } from "node:util"; import { @@ -389,11 +390,8 @@ export const cleanupOwnedProcessGroup = async ( while (!verification.cleaned) { const remaining = deadline - performance.now(); if (remaining <= 0) break; - await new Promise((resolve) => - setTimeout( - resolve, - Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining), - ), + await delay( + Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining), ); verification = await verifyNoTokenOwnedProcesses( ownership.runId, @@ -1089,9 +1087,7 @@ const recheckOwnershipFailures = async ( ); attempt += 1 ) { - await new Promise((resolve) => - setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS), - ); + await delay(TOKEN_READ_RETRY_DELAY_MS); processes = await host.listProcesses(); } const live = new Set(liveProcesses(processes).map(({ pid }) => pid)); @@ -1336,9 +1332,7 @@ const scanWithOwnershipEvidence = async ( ) .map(({ process }) => process); if (retryRows.length === 0) break; - await new Promise((resolve) => - setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS), - ); + await delay(TOKEN_READ_RETRY_DELAY_MS); const retries = await readProcessOwnershipFacts(retryRows, host); const retryByPid = new Map(retries.map((fact) => [fact.process.pid, fact])); facts = facts.map((fact) => retryByPid.get(fact.process.pid) ?? fact); @@ -1509,9 +1503,7 @@ const settleUnreadableTokenCandidates = async ( attempt < TOKEN_READ_RETRY_ATTEMPTS && remaining.length > 0; attempt += 1 ) { - await new Promise((resolve) => - setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS), - ); + await delay(TOKEN_READ_RETRY_DELAY_MS); let live: ReadonlySet | undefined; try { live = new Set( diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index d20e693b28..a2b8e64a86 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -289,7 +289,7 @@ export const settleProcessCaptureJournal = async ( let observed = journal.length; let lastChangeAt = monotonicProcessCaptureClock(); while (monotonicProcessCaptureClock() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 4)); + await delay(4); // A stalled event loop can leave timer and poll callbacks that were already // due queued behind this wake-up. Drain them before judging silence so a // trailing observation is not dropped by an early settle. diff --git a/src/process/capture/ProcessHarness.test.ts b/src/process/capture/ProcessHarness.test.ts index bd7a3c0c77..a0833e380a 100644 --- a/src/process/capture/ProcessHarness.test.ts +++ b/src/process/capture/ProcessHarness.test.ts @@ -257,6 +257,22 @@ it("preserves the actionable Darwin ownership compiler prerequisite", () => { }); }); +it("settles the capture journal when the application timer is unavailable", async () => { + const applicationTimer = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(() => { + throw new Error("application timer is unavailable"); + }); + try { + await expect( + settleProcessCaptureJournal([], 0, 50), + ).resolves.toBeUndefined(); + expect(applicationTimer).not.toHaveBeenCalled(); + } finally { + applicationTimer.mockRestore(); + } +}); + it("waits for terminal observations delivered after the exit callback", async () => { const journal: Array<{ capture_order: number; From 0e62094b69b4b36c8855a8255558b4a9f65fdb95 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:53:52 +0800 Subject: [PATCH 3/3] test(setup): isolate registration checks from installed providers Select absent Hopper, Ghidra and Java locations consistently across registration environment acceptance fixtures. Host PATH Java was executing during IDA budget checks and exposing those checks to unrelated provider cleanup failures. Keep the process ownership failure policy and all registration assertions intact. --- .../registrationRuntimeEnvironmentCli.test.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts index 07a8b2bc71..9617e19b8b 100644 --- a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts +++ b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts @@ -11,6 +11,14 @@ import { import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +// Registration configuration tests must not discover installed providers or +// plan provider-environment edits based on the machine running the suite. +const missingHopperAndGhidraEnvironment = (home: string) => ({ + HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"), + GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"), + JAVA_HOME: join(home, "missing-java"), +}); + const scenarios = [ { client: "cursor", @@ -75,7 +83,7 @@ cliTest.for([ USERPROFILE: home, XDG_CONFIG_HOME: join(home, ".config"), DSH_HOME: join(home, ".dsh"), - HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"), + ...missingHopperAndGhidraEnvironment(home), }; const registration = (env: Readonly>) => { const entry = clientRegistrationEntry( @@ -194,6 +202,7 @@ for (const scenario of scenarios) USERPROFILE: home, XDG_CONFIG_HOME: join(home, ".config"), DSH_HOME: join(home, ".dsh"), + ...missingHopperAndGhidraEnvironment(home), }; const registration = (env: Readonly>) => { const server = { @@ -324,11 +333,7 @@ cliTest( HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home, ".config"), - // Select absent provider locations so installed tools cannot make this - // configuration merge test plan an unrelated provider-environment edit. - HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"), - GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"), - JAVA_HOME: join(home, "missing-java"), + ...missingHopperAndGhidraEnvironment(home), }; const run = (args: readonly string[]) => cli.run({ arguments: [...args, "--json"], cwd: home, environment }); @@ -405,7 +410,7 @@ cliTest( XDG_CONFIG_HOME: join(home, ".config"), CODEX_HOME: codexHome, KIMI_CODE_HOME: kimiHome, - HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"), + ...missingHopperAndGhidraEnvironment(home), REA_ANALYSIS_PROVIDER: "auto", REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700001", REA_HOPPER_STARTUP_TIMEOUT_MS: "2800002", @@ -503,7 +508,7 @@ for (const [client, provider] of [ XDG_CONFIG_HOME: join(home, ".config"), CODEX_HOME: clientHome, KIMI_CODE_HOME: clientHome, - HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"), + ...missingHopperAndGhidraEnvironment(home), }; const setupArguments = [ "setup", @@ -628,7 +633,7 @@ for (const invalidSetting of [ USERPROFILE: home, XDG_CONFIG_HOME: join(home, ".config"), KIMI_CODE_HOME: kimiHome, - HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"), + ...missingHopperAndGhidraEnvironment(home), [invalidSetting.setting]: invalidSetting.value, }; const result = await cli.run({