diff --git a/src/application/javascript/ElectronActiveObservationService.test.ts b/src/application/javascript/ElectronActiveObservationService.test.ts index a54ed55de5..1f551e4fbf 100644 --- a/src/application/javascript/ElectronActiveObservationService.test.ts +++ b/src/application/javascript/ElectronActiveObservationService.test.ts @@ -172,25 +172,58 @@ it.skipIf(SKIP_PERMISSIONS).each([ }, ); -it("admits readable selections and hands canonical paths to the provider", async () => { +it("rejects a regular-file application root before provider capture", async () => { const { readable } = await fixture(); const { provider, capture } = recordingProvider(); - const application = join(readable, "main.js"); - await captureElectronScenario( + const result = await captureElectronScenario( provider, electronActiveObservationInputSchema.parse({ executable_path: process.execPath, - application_path: application, + application_path: join(readable, "main.js"), + application_root: join(readable, "main.js"), actions: [], }), ); - expect(capture).toHaveBeenCalledOnce(); - expect(capture.mock.calls[0]?.[0]).toMatchObject({ - application_path: application, - application_root: readable, + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["application_root"], + reason: "invalid_value", + message: expect.stringContaining("not a directory"), + }, + ], + }, }); + expect(capture).not.toHaveBeenCalled(); }); +it.each([false, true])( + "admits readable selections with explicit root %s and hands canonical paths to the provider", + async (explicitRoot) => { + const { readable } = await fixture(); + const { provider, capture } = recordingProvider(); + const application = join(readable, "main.js"); + await captureElectronScenario( + provider, + electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: application, + ...(explicitRoot ? { application_root: readable } : {}), + actions: [], + }), + ); + expect(capture).toHaveBeenCalledOnce(); + expect(capture.mock.calls[0]?.[0]).toMatchObject({ + application_path: application, + application_root: readable, + }); + }, +); + it.skipIf(process.platform === "win32")( "records selected scenario paths and omission while executing canonical paths", async () => { diff --git a/src/application/javascript/ElectronActiveObservationService.ts b/src/application/javascript/ElectronActiveObservationService.ts index 220d846bf4..6bfeb57709 100644 --- a/src/application/javascript/ElectronActiveObservationService.ts +++ b/src/application/javascript/ElectronActiveObservationService.ts @@ -62,7 +62,19 @@ const resolveInputPath = async ( try { // Darwin realpath can collapse file/.. even though native lookup rejects // that traversal. Admit the selected path before canonicalizing it. - await stat(selected); + const metadata = await stat(selected); + if (field === "application_root" && !metadata.isDirectory()) { + const cause = new Error("not a directory"); + return err({ + kind: "invalid", + issue: { + path: [field], + reason: "invalid_value", + message: `application_root ${selected} is not a directory`, + }, + cause, + }); + } return ok(await realpath(selected)); } catch (cause: unknown) { return err( diff --git a/tests/acceptance/applications/electronInvalidPaths.test.ts b/tests/acceptance/applications/electronInvalidPaths.test.ts index 040be4f735..41031746c3 100644 --- a/tests/acceptance/applications/electronInvalidPaths.test.ts +++ b/tests/acceptance/applications/electronInvalidPaths.test.ts @@ -7,6 +7,46 @@ import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +cliTest( + "rejects a regular-file Electron application root through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-file-root-"); + const app = join(root, "main.js"); + await writeFile(app, "module.exports = {};"); + const input = { + executable_path: process.execPath, + application_path: app, + application_root: app, + }; + const request = join(root, "request.json"); + await writeFile(request, JSON.stringify(input)); + const result = await cli.run({ + arguments: ["capture-electron-scenario", request, "--format", "json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + const error = { + code: "invalid_request", + details: { + issues: [ + { + path: ["application_root"], + reason: "invalid_value", + message: expect.stringContaining("not a directory"), + }, + ], + }, + }; + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject(error); + const { call } = await connectLocalToolsMcp(); + const reply = await call("capture_electron_scenario", input); + expect(reply.isError).toBe(true); + const content = reply.content.find((item) => item.type === "text"); + if (content?.type !== "text") throw new Error("Missing error response"); + expect(JSON.parse(content.text)).toMatchObject({ error }); + }, +); + cliTest( "reports every unresolved Electron path through CLI and MCP without launching", async ({ cli }) => {