diff --git a/src/application/javascript/ElectronActiveEvidence.ts b/src/application/javascript/ElectronActiveEvidence.ts index ebf1569cda..602ffe77b2 100644 --- a/src/application/javascript/ElectronActiveEvidence.ts +++ b/src/application/javascript/ElectronActiveEvidence.ts @@ -1,21 +1,25 @@ import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; import type { ProviderIdentity } from "../AnalysisProvider.js"; -import type { CanonicalElectronActiveObservationInput } from "./ElectronActiveObservationPort.js"; import type { Evidence, EvidenceObservation } from "../../domain/evidence.js"; import { createEvidence } from "../../domain/evidence.js"; -import type { ElectronActiveObservationResult } from "../../domain/javascript/electronActiveObservation.js"; +import type { + ElectronActiveObservationInput, + ElectronActiveObservationResult, +} from "../../domain/javascript/electronActiveObservation.js"; import { jsonObjectSchema } from "../../domain/jsonValue.js"; +import type { CanonicalElectronActiveObservationInput } from "./ElectronActiveObservationPort.js"; /** Commit the selected Electron experiment and its observed result. */ export const createElectronActiveEvidence = ( - input: CanonicalElectronActiveObservationInput, + input: ElectronActiveObservationInput, result: ElectronActiveObservationResult, provider: ProviderIdentity, + resolvedInput?: CanonicalElectronActiveObservationInput, ): Evidence => createEvidence(undefined, provider, { predicateType: "rea.electron-active-scenario", operation: "capture_electron_scenario", - parameters: parameters(input), + parameters: parameters(input, resolvedInput), result, confidence: "observed", authority: "controlled-replay", @@ -32,11 +36,21 @@ export const createElectronActiveEvidence = ( }); const parameters = ( - input: CanonicalElectronActiveObservationInput, + input: ElectronActiveObservationInput, + resolvedInput?: CanonicalElectronActiveObservationInput, ): EvidenceObservation["parameters"] => { const scenario = jsonObjectSchema.parse(input); return { ...scenario, scenario_sha256: digestCanonicalValue(scenario, "Electron scenario"), + ...(resolvedInput === undefined + ? {} + : { + resolved_paths: { + executable_path: resolvedInput.executable_path, + application_path: resolvedInput.application_path, + application_root: resolvedInput.application_root, + }, + }), }; }; diff --git a/src/application/javascript/ElectronActiveObservationService.test.ts b/src/application/javascript/ElectronActiveObservationService.test.ts index 0882db0a39..a54ed55de5 100644 --- a/src/application/javascript/ElectronActiveObservationService.test.ts +++ b/src/application/javascript/ElectronActiveObservationService.test.ts @@ -1,11 +1,13 @@ -import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { electronActiveObservationInputSchema } from "../../domain/javascript/electronActiveObservation.js"; -import { err } from "../../domain/result.js"; +import { err, ok } from "../../domain/result.js"; +import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; +import { createElectronActiveObservationFixtureResult } from "../../domain/javascript/electronActiveObservation.fixture.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js"; import { captureElectronScenario } from "./ElectronActiveObservationService.js"; @@ -188,3 +190,46 @@ it("admits readable selections and hands canonical paths to the provider", async application_root: readable, }); }); + +it.skipIf(process.platform === "win32")( + "records selected scenario paths and omission while executing canonical paths", + async () => { + const { root, readable } = await fixture(); + const alias = join(root, "selected-application"); + await symlink(readable, alias); + const input = electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: join(alias, "main.js"), + }); + const canonicalApplication = await realpath(input.application_path); + const capture = vi.fn(() => + Promise.resolve( + ok(createElectronActiveObservationFixtureResult(canonicalApplication)), + ), + ); + const result = await captureElectronScenario( + { identity: () => ({ id: "test", name: "test", version: "1" }), capture }, + input, + ); + expect(capture.mock.calls[0]?.[0]).toMatchObject({ + executable_path: await realpath(process.execPath), + application_path: canonicalApplication, + application_root: await realpath(readable), + }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.parameters).toEqual({ + ...input, + scenario_sha256: digestCanonicalValue(input, "Electron scenario"), + resolved_paths: { + executable_path: await realpath(process.execPath), + application_path: canonicalApplication, + application_root: await realpath(readable), + }, + }); + expect(result.value.parameters).not.toHaveProperty("application_root"); + expect(result.value.normalized_result).toMatchObject({ + application: { application_path: canonicalApplication }, + }); + }, +); diff --git a/src/application/javascript/ElectronActiveObservationService.ts b/src/application/javascript/ElectronActiveObservationService.ts index 81ccd3c734..220d846bf4 100644 --- a/src/application/javascript/ElectronActiveObservationService.ts +++ b/src/application/javascript/ElectronActiveObservationService.ts @@ -189,9 +189,10 @@ export const captureElectronScenario = async ( ? { ok: true, value: createElectronActiveEvidence( - canonical.value, + input, captured.value, provider.identity(), + canonical.value, ), } : captured; diff --git a/src/domain/javascript/javascriptRuntimeReconciliationActive.ts b/src/domain/javascript/javascriptRuntimeReconciliationActive.ts index ea8cde18da..8baea0ce6d 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationActive.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationActive.ts @@ -44,16 +44,30 @@ export const parseActiveElectronCapture = ( z .object({ application_path: absolutePathSchema, - application_root: absolutePathSchema, + application_root: absolutePathSchema.optional(), + resolved_paths: z + .strictObject({ + executable_path: absolutePathSchema, + application_path: absolutePathSchema, + application_root: absolutePathSchema, + }) + .optional(), }) .passthrough(), evidence.parameters, [...path, "parameters"], ); + // Older captures committed canonical paths directly in parameters. New + // captures keep request spelling separate from the admitted execution paths. + const executionPaths = parameters.resolved_paths ?? parameters; if ( - result.application.application_path !== parameters.application_path || + executionPaths.application_root === undefined || + result.application.application_path !== executionPaths.application_path || + (parameters.resolved_paths !== undefined && + result.application.executable_path !== + parameters.resolved_paths.executable_path) || !isPathWithinRoot( - parameters.application_root, + executionPaths.application_root, result.application.application_path, ) ) diff --git a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts index cb0a393695..a1cf7ec974 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; @@ -21,7 +21,10 @@ import { reconcileJavaScriptRuntimeInputSchema, javascriptRuntimeReconciliationResultSchema, } from "../../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; -import { electronActiveObservationInputSchema } from "../../../../src/domain/javascript/electronActiveObservation.js"; +import { + electronActiveObservationInputSchema, + type ElectronActiveObservationInput, +} from "../../../../src/domain/javascript/electronActiveObservation.js"; import { createElectronActiveObservationFixtureResult } from "../../../../src/domain/javascript/electronActiveObservation.fixture.js"; import { @@ -354,48 +357,69 @@ it("preserves unknown Electron frame provenance without linking it to an authori ).toBe(false); }); -it("reconciles active Electron as a partial target-only runtime capture", async () => { - const fixture = await applicationFixture(); - const staticEvidence = await analyzeFixture(fixture); - const applicationPath = join(fixture, "main.js"); - const input = { - ...electronActiveObservationInputSchema.parse({ - executable_path: process.execPath, - application_path: applicationPath, +it.each(["historical", "selected-paths"] as const)( + "reconciles %s active Electron as a partial target-only runtime capture", + async (representation) => { + const fixture = await applicationFixture(); + const staticEvidence = await analyzeFixture(fixture); + const applicationPath = join(fixture, "main.js"); + const input = { + ...electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: applicationPath, + application_root: fixture, + actions: [], + }), application_root: fixture, - actions: [], - }), - application_root: fixture, - }; - const runtimeEvidence = createElectronActiveEvidence( - input, - createElectronActiveObservationFixtureResult(applicationPath), - { - id: "rea-playwright-electron-active", - name: "REA Playwright active Electron observation provider", - version: "1", - }, - ); + }; + const capture = + createElectronActiveObservationFixtureResult(applicationPath); + let selectedInput: ElectronActiveObservationInput = input; + let resolvedInput: typeof input | undefined; + if (representation === "selected-paths") { + const alias = join(fixture, "selected-main.js"); + await symlink(applicationPath, alias); + selectedInput = electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: alias, + }); + resolvedInput = { + ...input, + executable_path: await realpath(process.execPath), + }; + capture.application.executable_path = resolvedInput.executable_path; + } + const runtimeEvidence = createElectronActiveEvidence( + selectedInput, + capture, + { + id: "rea-playwright-electron-active", + name: "REA Playwright active Electron observation provider", + version: "1", + }, + resolvedInput, + ); - const result = reconcileInput({ - static_layers: [{ role: "application", analysis: staticEvidence }], - runtime_observations: [runtimeEvidence], - }); + const result = reconcileInput({ + static_layers: [{ role: "application", analysis: staticEvidence }], + runtime_observations: [runtimeEvidence], + }); - expect(result.runtime_captures).toMatchObject([ - { - kind: "electron-active", - scripts: 0, - frames: 0, - workers: 0, - scripts_complete_within_scope: false, - }, - ]); - expect(result.coverage.status).toBe("partial"); - expect(result.limitations.join(" ")).toMatch( - /incomplete|not reported as executed/u, - ); -}); + expect(result.runtime_captures).toMatchObject([ + { + kind: "electron-active", + scripts: 0, + frames: 0, + workers: 0, + scripts_complete_within_scope: false, + }, + ]); + expect(result.coverage.status).toBe("partial"); + expect(result.limitations.join(" ")).toMatch( + /incomplete|not reported as executed/u, + ); + }, +); it("commits complete Electron actions independently from selected arguments", async () => { const fixture = await applicationFixture(); diff --git a/tests/boundary/mcp/electronMcp.test.ts b/tests/boundary/mcp/electronMcp.test.ts index c9f416fe86..15dd86f751 100644 --- a/tests/boundary/mcp/electronMcp.test.ts +++ b/tests/boundary/mcp/electronMcp.test.ts @@ -1,4 +1,6 @@ import { parseEvidence } from "../../../src/domain/evidence.js"; +import { digestCanonicalValue } from "../../../src/domain/canonicalDigest.js"; +import { electronActiveObservationInputSchema } from "../../../src/domain/javascript/electronActiveObservation.js"; import { ok as resultOk, err } from "../../../src/domain/result.js"; import { mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -419,7 +421,8 @@ it("exposes endpoint-scoped Electron discovery and inspection as Evidence", asyn }); }, 20_000); -it("runs active Electron scenarios with selected paths and inferred working directory", async () => { +/** Create the selected-path aliases and retained IPC-gap capture for the public workflow. */ +const activeElectronScenarioFixture = async () => { const root = await createTestTempDirectory("rea-electron-active-mcp-"); temporary.push(root); const applicationPath = join(root, "main.js"); @@ -436,7 +439,6 @@ it("runs active Electron scenarios with selected paths and inferred working dire "rea-electron-working-directory-", ); temporary.push(workingDirectory); - const capturedInputs: unknown[] = []; const activeResult = createElectronActiveObservationFixtureResult(applicationPath); const capturedEvent = activeResult.timeline.events[0]; @@ -459,6 +461,13 @@ it("runs active Electron scenarios with selected paths and inferred working dire activeResult.retention.estimated_retained_bytes = 1_536; activeResult.retention.observed_runtime = 2; activeResult.coverage.observed_event_families.push("ipc-observation-gap"); + return { root, applicationPath, aliasedRoot, workingDirectory, activeResult }; +}; + +it("runs active Electron scenarios with selected paths and inferred working directory", async () => { + const { root, applicationPath, aliasedRoot, workingDirectory, activeResult } = + await activeElectronScenarioFixture(); + const capturedInputs: unknown[] = []; const provider: ElectronActiveObservationPort = { identity: () => ({ id: "test-electron-active", @@ -539,6 +548,24 @@ it("runs active Electron scenarios with selected paths and inferred working dire application_path: applicationPath, application_root: workingDirectory, }); + const capturedEvidence = parseEvidence(captured.structuredContent); + expect(capturedEvidence.parameters).toMatchObject({ + executable_path: process.execPath, + application_path: join(aliasedRoot, "main.js"), + application_root: workingDirectory, + }); + expect(capturedEvidence.normalized_result).toMatchObject({ + application: { application_path: applicationPath }, + }); + const { scenario_sha256, resolved_paths, ...selectedScenario } = + capturedEvidence.parameters; + expect(resolved_paths).toMatchObject({ + application_path: applicationPath, + application_root: workingDirectory, + }); + expect(scenario_sha256).toBe( + digestCanonicalValue(selectedScenario, "Electron scenario"), + ); const inferredRoot = await client.callTool({ name: "capture_electron_scenario", arguments: { @@ -552,6 +579,18 @@ it("runs active Electron scenarios with selected paths and inferred working dire application_path: applicationPath, application_root: root, }); + const inferredEvidence = parseEvidence(inferredRoot.structuredContent); + expect(inferredEvidence.parameters).not.toHaveProperty("application_root"); + expect(inferredEvidence.parameters.scenario_sha256).toBe( + digestCanonicalValue( + electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: applicationPath, + actions: [], + }), + "Electron scenario", + ), + ); expect(JSON.stringify(captured.structuredContent)).toContain("submit-secret"); expect(JSON.stringify(captured.structuredContent)).toContain("super-secret"); expect(captured.structuredContent).toMatchObject({