diff --git a/src/process/capture/ProcessHarness.ts b/src/process/capture/ProcessHarness.ts index 352b13ba9..1033e68cd 100644 --- a/src/process/capture/ProcessHarness.ts +++ b/src/process/capture/ProcessHarness.ts @@ -5,7 +5,7 @@ import type { ProcessScenario } from "../../domain/process/processScenario.js"; import { parseProcessCapture } from "../../domain/process/processCaptureParsing.js"; import { err, ok, type Result } from "../../domain/result.js"; import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; -import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { describeProcessCaptureExecutionFailure, ProcessCaptureError, @@ -864,7 +864,7 @@ export const captureProcessScenario = async ( const executionFailureReason = describeProcessCaptureExecutionFailure(cause); return err( - failure instanceof ProcessCaptureError + failure instanceof AnalysisError ? failure : new ProcessCaptureError("process capture failed", { cause, @@ -882,7 +882,7 @@ export const captureProcessScenario = async ( const failure = normalizeCaptureFailure(cause, signal); const executionFailure = describeProcessCaptureExecutionFailure(cause); return err( - failure instanceof ProcessCaptureError + failure instanceof AnalysisError ? failure : new ProcessCaptureError("process capture lifecycle failed", { cause, diff --git a/src/process/capture/ProcessScenarioRuntimeValidation.test.ts b/src/process/capture/ProcessScenarioRuntimeValidation.test.ts index fcf23e6ef..1277b753f 100644 --- a/src/process/capture/ProcessScenarioRuntimeValidation.test.ts +++ b/src/process/capture/ProcessScenarioRuntimeValidation.test.ts @@ -10,6 +10,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, it } from "vitest"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { resolveExecutable, resolveProcessScenarioRuntimePaths, @@ -51,7 +53,12 @@ it("resolves a Windows executable through the canonical PATH spelling", async () ); expect(resolved.executable).toBe(candidate); } catch (cause: unknown) { - const message = cause instanceof Error ? cause.message : String(cause); + const message = + cause instanceof AnalysisInputError + ? (cause.issues[0]?.message ?? cause.message) + : cause instanceof Error + ? cause.message + : String(cause); expect(message).toContain(candidate); expect(message).not.toContain(ambient); } @@ -60,14 +67,100 @@ it("resolves a Windows executable through the canonical PATH spelling", async () } }); -it("retains the selected missing working directory and OS reason", async () => { - const selected = join(tmpdir(), "rea-process-missing-cwd-unique"); - const scenario = parseProcessScenario({ - executable: process.execPath, - working_directory: selected, - }); - - await expect(resolveProcessScenarioRuntimePaths(scenario)).rejects.toThrow( - `working directory could not be resolved: ${selected} (ENOENT)`, - ); +it("reports a working-directory file as invalid input", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-process-invalid-cwd-")); + const selectedFile = join(root, "selected-file"); + await writeFile(selectedFile, "not a directory"); + try { + const failure = await runtimePathFailure({ + executable: process.execPath, + working_directory: selectedFile, + }); + expect(projectAnalysisError(failure)).toMatchObject({ + code: "invalid_request", + details: { + operation: "capture_process_scenario", + issues: [ + { + path: ["working_directory"], + reason: "invalid_value", + message: `working directory is not a directory: ${selectedFile}`, + }, + ], + }, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } }); + +it("reports an executable directory as invalid input", async () => { + const root = await mkdtemp( + join(tmpdir(), "rea-process-executable-directory-"), + ); + const directory = join(root, "tool-directory"); + try { + await mkdir(directory); + const directoryFailure = await runtimePathFailure({ + executable: directory, + working_directory: root, + }); + expect(projectAnalysisError(directoryFailure)).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["executable"], + message: `executable could not be resolved: ${directory}; checked ${directory} (not a regular file)`, + }, + ], + }, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it.skipIf(process.platform === "win32")( + "reports a non-executable regular file as invalid input", + async () => { + const root = await mkdtemp(join(tmpdir(), "rea-process-non-executable-")); + const selected = join(root, "not-executable"); + try { + await writeFile(selected, "not executable"); + await chmod(selected, 0o644); + const failure = await runtimePathFailure({ + executable: selected, + working_directory: root, + }); + expect(projectAnalysisError(failure)).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["executable"], + message: `executable could not be resolved: ${selected}; checked ${selected} (EACCES)`, + }, + ], + }, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +); + +const runtimePathFailure = async ( + scenario: Parameters[0], +): Promise => { + let failure: unknown; + try { + await resolveProcessScenarioRuntimePaths(parseProcessScenario(scenario)); + } catch (cause: unknown) { + failure = cause; + } + expect(failure).toBeInstanceOf(AnalysisInputError); + if (!(failure instanceof AnalysisInputError)) + throw new Error("Selected runtime path should be invalid input"); + return failure; +}; diff --git a/src/process/capture/ProcessScenarioRuntimeValidation.ts b/src/process/capture/ProcessScenarioRuntimeValidation.ts index 5196005b2..1ac9d2228 100644 --- a/src/process/capture/ProcessScenarioRuntimeValidation.ts +++ b/src/process/capture/ProcessScenarioRuntimeValidation.ts @@ -1,13 +1,11 @@ -import { access, realpath, stat } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; +import { access, realpath, stat } from "node:fs/promises"; import { delimiter, isAbsolute, join, resolve } from "node:path"; import type { ProcessScenario } from "../../domain/process/processScenario.js"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { snapshotEnvironment } from "../snapshotEnvironment.js"; -import { - ProcessCaptureError, - processCaptureCancelled, -} from "./ProcessCaptureError.js"; +import { processCaptureCancelled } from "./ProcessCaptureError.js"; /** Resolve caller-selected executable and observation paths for this execution. */ export const resolveProcessScenarioRuntimePaths = async ( @@ -20,22 +18,25 @@ export const resolveProcessScenarioRuntimePaths = async ( try { workingDirectory = await realpath(selectedWorkingDirectory); } catch (cause: unknown) { - throw new ProcessCaptureError( + throw invalidScenarioPath( + "working_directory", `working directory could not be resolved: ${selectedWorkingDirectory}${osFailure(cause)}`, - { cause }, + cause, ); } let workingDirectoryStat; try { workingDirectoryStat = await stat(workingDirectory); } catch (cause: unknown) { - throw new ProcessCaptureError( + throw invalidScenarioPath( + "working_directory", `working directory could not be inspected: ${workingDirectory}${osFailure(cause)}`, - { cause }, + cause, ); } if (!workingDirectoryStat.isDirectory()) - throw new ProcessCaptureError( + throw invalidScenarioPath( + "working_directory", `working directory is not a directory: ${workingDirectory}`, ); const executable = await resolveExecutable( @@ -80,19 +81,27 @@ export const resolveExecutable = async ( if ((await stat(candidate)).isFile()) return candidate; failures.push(`${candidate} (not a regular file)`); } catch (cause: unknown) { - const code = osErrorCode(cause); - if (code !== "ENOENT" && code !== "ENOTDIR") - failures.push(`${candidate}${osFailure(cause)}`); + failures.push(`${candidate}${osFailure(cause)}`); continue; } } - throw new ProcessCaptureError( - failures.length === 0 - ? `executable could not be resolved: ${command}` - : `executable could not be resolved: ${command}; checked ${failures.join("; ")}`, + throw invalidScenarioPath( + "executable", + `executable could not be resolved: ${command}; checked ${failures.join("; ")}`, ); }; +const invalidScenarioPath = ( + field: "executable" | "working_directory", + message: string, + cause?: unknown, +): AnalysisInputError => + new AnalysisInputError( + "capture_process_scenario", + cause === undefined ? undefined : { cause }, + [{ path: [field], reason: "invalid_value", message }], + ); + const osErrorCode = (cause: unknown): string | undefined => typeof cause === "object" && cause !== null && diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index dfd38344b..7b911bd27 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -19,6 +19,25 @@ const electron = { executable_path: "/tmp/Electron", application_path: "/tmp/main.js", }; + +const runCliExpectingInputError = async ( + scenarioPath: string, +): Promise => { + const failure = await execute(process.execPath, [ + cli, + "capture-process", + scenarioPath, + "--json", + ]).then( + () => undefined, + (cause: unknown) => cause, + ); + expect(failure).toMatchObject({ code: 1 }); + if (!(failure instanceof Error) || !("stdout" in failure)) + throw new Error("Expected the CLI to report an input error on stdout"); + return JSON.parse(String(failure.stdout)) as unknown; +}; + const pathRequests: readonly [string, Record][] = [ ["open_binary", { path: nul }], ["open_binary", { path: process.execPath, snapshot_path: nul }], @@ -235,25 +254,80 @@ captureTest( }); expect(parseMcpToolError(failed)).toMatchObject({ error: { - code: "process_capture_failed", - message: expect.stringContaining(missing), - details: { execution_failure: expect.stringContaining(missing) }, + code: "invalid_request", + details: { + operation: "capture_process_scenario", + issues: [ + { + path: ["executable"], + message: expect.stringContaining(`${missing} (ENOENT)`), + }, + ], + }, }, }); await writeFile( join(root, "scenario.json"), JSON.stringify({ executable: missing }), ); - await expect( - execute(process.execPath, [ - cli, - "capture-process", - join(root, "scenario.json"), - "--json", - ]), - ).rejects.toMatchObject({ - code: 1, - stdout: expect.stringContaining(missing), + const executableCliFailure = await runCliExpectingInputError( + join(root, "scenario.json"), + ); + expect(executableCliFailure).toMatchObject({ + code: "invalid_request", + details: { + operation: "capture_process_scenario", + issues: [ + { + path: ["executable"], + message: expect.stringContaining(`${missing} (ENOENT)`), + }, + ], + }, + }); + + const missingWorkingDirectory = join(root, "missing-working-directory"); + const cwdFailure = await call("capture_process_scenario", { + executable: process.execPath, + working_directory: missingWorkingDirectory, + }); + expect(parseMcpToolError(cwdFailure)).toMatchObject({ + error: { + code: "invalid_request", + details: { + operation: "capture_process_scenario", + issues: [ + { + path: ["working_directory"], + message: expect.stringContaining( + `${missingWorkingDirectory} (ENOENT)`, + ), + }, + ], + }, + }, + }); + const cwdScenario = join(root, "missing-working-directory.json"); + await writeFile( + cwdScenario, + JSON.stringify({ + executable: process.execPath, + working_directory: missingWorkingDirectory, + }), + ); + expect(await runCliExpectingInputError(cwdScenario)).toMatchObject({ + code: "invalid_request", + details: { + operation: "capture_process_scenario", + issues: [ + { + path: ["working_directory"], + message: expect.stringContaining( + `${missingWorkingDirectory} (ENOENT)`, + ), + }, + ], + }, }); }, 60_000,