diff --git a/docs/javascript-recovery.md b/docs/javascript-recovery.md index 31594f3f3d..529f0e13be 100644 --- a/docs/javascript-recovery.md +++ b/docs/javascript-recovery.md @@ -85,6 +85,11 @@ publication. REA writes verified bytes to an exclusively owned output tree and rolls it back on failure. It also checks that the original source and engine bytes remain unchanged before committing. The existing output is never overwritten. +A denied selected-input read reports `access_denied` with the observed OS code +and path. A source or engine identity change reports `artifact_changed`; +neither failure is a malformed recovery request. Cancellation retains the +caller's reason through admission, command launch and completion. + Multi-input relative filenames must be representable unchanged as artifact paths: NFC Unicode spelling and no literal backslashes. Unsupported layouts fail before invoking the engine rather than silently renaming inputs referenced by source maps. diff --git a/src/application/javascript/JavaScriptRecoveryService.test.ts b/src/application/javascript/JavaScriptRecoveryService.test.ts index 76fe66c04c..6594fbbd8e 100644 --- a/src/application/javascript/JavaScriptRecoveryService.test.ts +++ b/src/application/javascript/JavaScriptRecoveryService.test.ts @@ -123,11 +123,14 @@ describe("JavaScript recovery workflow", () => { const invalid = await service.recover({}); if (invalid.ok) throw new Error("expected malformed input"); expect(invalid.error._tag).toBe("AnalysisInputError"); - const cancelled = await service.recover(input, { - signal: AbortSignal.abort(), - }); - if (cancelled.ok) throw new Error("expected cancellation"); - expect(cancelled.error._tag).toBe("AnalysisCancelledError"); + for (const reason of [null, false, { request: "cancel recovery" }]) { + const cancelled = await service.recover(input, { + signal: AbortSignal.abort(reason), + }); + if (cancelled.ok) throw new Error("expected cancellation"); + expect(cancelled.error._tag).toBe("AnalysisCancelledError"); + expect(cancelled.error.cause).toBe(reason); + } }); it("rejects additional_paths repeating path or each other", async () => { const service = new JavaScriptRecoveryService({ diff --git a/src/application/javascript/JavaScriptRecoveryService.ts b/src/application/javascript/JavaScriptRecoveryService.ts index 6eb906401d..5fa5624a9c 100644 --- a/src/application/javascript/JavaScriptRecoveryService.ts +++ b/src/application/javascript/JavaScriptRecoveryService.ts @@ -34,7 +34,9 @@ export class JavaScriptRecoveryService { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted === true) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); const parsed = javascriptRecoveryInputSchema.safeParse(input); if (!parsed.success) return err( diff --git a/src/javascript/recovery/RecoveryFiles.test.ts b/src/javascript/recovery/RecoveryFiles.test.ts index b878dd192d..9323d6f692 100644 --- a/src/javascript/recovery/RecoveryFiles.test.ts +++ b/src/javascript/recovery/RecoveryFiles.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { expect, it, vi } from "vitest"; import { + AnalysisAccessDeniedError, AnalysisInputError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; @@ -113,6 +114,28 @@ it("keeps a failed admission close owned when cancellation is also requested", a expect(observed.fileCleanupFailure.cleanupCause).toBe(failure.cleanupCause); }); +it.each(["EACCES", "EPERM"] as const)( + "preserves %s while acquiring a selected recovery input", + async (code) => { + const root = await createTestTempDirectory("rea-recovery-access-"); + const selected = join(root, "selected.js"); + const snapshots = join(root, "snapshots"); + await mkdir(snapshots); + const denied = filesystemError(code); + vi.mocked(openRegularFile).mockRejectedValueOnce(denied); + const failure = await snapshotRecoveryInputs([selected], snapshots).catch( + (cause: unknown) => cause, + ); + expect(failure).toBeInstanceOf(AnalysisAccessDeniedError); + expect(failure).toMatchObject({ + operation: "recover_javascript_sources", + path: selected, + systemCode: code, + cause: denied, + }); + }, +); + it("reports one file selected under two letter cases as an input error", async (context) => { const root = await createTestTempDirectory("rea-recovery-case-"); const lower = join(root, "bundle.js"); diff --git a/src/javascript/recovery/RecoveryFiles.ts b/src/javascript/recovery/RecoveryFiles.ts index 1da3e3e7de..2f71c6aea2 100644 --- a/src/javascript/recovery/RecoveryFiles.ts +++ b/src/javascript/recovery/RecoveryFiles.ts @@ -13,6 +13,8 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { TextDecoder } from "node:util"; import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js"; import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, AnalysisOutputError, @@ -89,8 +91,9 @@ const scanRecoveryFile = async ( before.size !== bytes || [after, current].some((state) => !sameRegularFileState(before, state)) ) - throw new AnalysisOutputError( + throw new AnalysisArtifactChangedError( OPERATION, + path, `File identity changed while reading: ${path}`, ); return { sha256: hash.digest("hex"), bytes }; @@ -106,6 +109,14 @@ const scanRecoveryFile = async ( throw new AnalysisCancelledError(OPERATION, { cause: artifactFileCancellationCause(cause), }); + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "EACCES" || cause.code === "EPERM") + ) + throw new AnalysisAccessDeniedError(OPERATION, path, cause.code, { + cause, + }); if (cause instanceof NonRegularFileReadError) throw new AnalysisOutputError( OPERATION, @@ -169,6 +180,8 @@ const snapshotRecoveryInput = async ( } catch (cause: unknown) { if ( cause instanceof AnalysisCancelledError || + cause instanceof AnalysisAccessDeniedError || + cause instanceof AnalysisArtifactChangedError || cause instanceof ArtifactFileCleanupFailure || cause instanceof ArtifactFileCancelled ) diff --git a/src/javascript/recovery/WakaruCommand.test.ts b/src/javascript/recovery/WakaruCommand.test.ts index 460847d7fa..875913a2f9 100644 --- a/src/javascript/recovery/WakaruCommand.test.ts +++ b/src/javascript/recovery/WakaruCommand.test.ts @@ -92,6 +92,31 @@ it("preserves caller cancellation before acquiring an expired execution budget", ).rejects.toMatchObject({ _tag: "AnalysisCancelledError" }); }); +it.each([null, false, { request: "cancel launch" }])( + "preserves the opaque reason when cancellation interrupts launcher acquisition (%j)", + async (reason) => { + const controller = new AbortController(); + await expect( + runWakaruCommand({ + command: "/selected/wakaru", + limiter: "/usr/bin/prlimit", + arguments: ["--version"], + cwd: "/tmp/unused", + environment: {}, + deadline: performance.now() + 5_000, + signal: controller.signal, + launcher: async () => { + controller.abort(reason); + throw new Error("launcher interrupted"); + }, + }), + ).rejects.toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + }); + }, +); + const commandFixture = ( emit: (producer: ReturnType) => void, ) => { diff --git a/src/javascript/recovery/WakaruCommand.ts b/src/javascript/recovery/WakaruCommand.ts index 3227b9fb45..2d84fdb87e 100644 --- a/src/javascript/recovery/WakaruCommand.ts +++ b/src/javascript/recovery/WakaruCommand.ts @@ -3,6 +3,7 @@ import { constants } from "node:fs"; import { access, realpath, stat } from "node:fs/promises"; import { isAbsolute } from "node:path"; import { + AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisOutputError, @@ -104,8 +105,10 @@ export const resolveWakaruCommand = async ( return { command, limiter, ...fingerprint }; } catch (cause: unknown) { if ( - cause instanceof ArtifactReaderFailure && - (cause.cleanup !== undefined || cause.reason === "cancelled") + cause instanceof AnalysisCancelledError || + cause instanceof AnalysisArtifactChangedError || + (cause instanceof ArtifactReaderFailure && + (cause.cleanup !== undefined || cause.reason === "cancelled")) ) throw cause; const reason = `Configured Wakaru or util-linux prlimit is unavailable: ${recoveryFailureMessage(cause)}`; @@ -154,7 +157,9 @@ export const runWakaruCommand = async (context: WakaruCommandContext) => { env: { TMPDIR: context.cwd, RAYON_NUM_THREADS: "1", NO_COLOR: "1" }, }).catch((cause: unknown) => { if (context.signal?.aborted === true) - throw new AnalysisCancelledError(OPERATION); + throw new AnalysisCancelledError(OPERATION, { + cause: context.signal.reason, + }); throw new ProviderAdapterError("wakaru", OPERATION, { cause, diagnostics: { @@ -226,7 +231,9 @@ export const runWakaruCommand = async (context: WakaruCommandContext) => { ); if (failure !== undefined) throw failure; if (context.signal?.aborted === true) - throw new AnalysisCancelledError(OPERATION); + throw new AnalysisCancelledError(OPERATION, { + cause: context.signal.reason, + }); return { exit_code: snapshot.exitCode ?? null, signal: snapshot.signal ?? null, diff --git a/src/javascript/recovery/WakaruExecution.ts b/src/javascript/recovery/WakaruExecution.ts index 5da9f86359..8c0abfb2c9 100644 --- a/src/javascript/recovery/WakaruExecution.ts +++ b/src/javascript/recovery/WakaruExecution.ts @@ -6,8 +6,8 @@ import { import type { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { + AnalysisArtifactChangedError, AnalysisCapabilityUnavailableError, - AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import type { JavaScriptRecoveryInput } from "../../domain/javascript/javascriptRecovery.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; @@ -155,24 +155,37 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => { resources, ); if (current.sha256 !== selected.sha256 || current.bytes !== selected.bytes) - throw new AnalysisOutputError( + throw new AnalysisArtifactChangedError( OPERATION, + selected.path, `Original input changed during recovery: ${selected.path}`, + { + diagnostics: { + expected_sha256: selected.sha256, + observed_sha256: current.sha256, + expected_bytes: selected.bytes, + observed_bytes: current.bytes, + }, + }, ); } - if ( - ( - await fingerprintRecoveryFile( - engine.command, - RECOVERY_LIMITS.outputBytes, - options?.signal, - resources, - ) - ).sha256 !== engine.sha256 - ) - throw new AnalysisOutputError( + const currentEngine = await fingerprintRecoveryFile( + engine.command, + RECOVERY_LIMITS.outputBytes, + options?.signal, + resources, + ); + if (currentEngine.sha256 !== engine.sha256) + throw new AnalysisArtifactChangedError( OPERATION, + engine.command, "Configured engine bytes changed during recovery", + { + diagnostics: { + expected_sha256: engine.sha256, + observed_sha256: currentEngine.sha256, + }, + }, ); return createAnalysisExecution( result, diff --git a/src/javascript/recovery/WakaruProvider.ts b/src/javascript/recovery/WakaruProvider.ts index 626dd2b1cb..bd050349e0 100644 --- a/src/javascript/recovery/WakaruProvider.ts +++ b/src/javascript/recovery/WakaruProvider.ts @@ -16,6 +16,7 @@ import { AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { artifactFileCancellationCause } from "../../artifacts/ArtifactFileOperation.js"; import { javascriptRecoveryResultSchema, type JavaScriptRecoveryInput, @@ -166,7 +167,9 @@ export class WakaruProvider implements JavaScriptRecoveryPort { failed.cause instanceof ArtifactReaderFailure && failed.cause.reason === "cancelled" && failed.cause.cleanup === undefined - ? new AnalysisCancelledError(OPERATION, { cause: failed.cause }) + ? new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(failed.cause), + }) : failed.cause; const residuals: string[] = []; const cleanupFailures: { resource: string; reason: string }[] = []; diff --git a/tests/boundary/javascript-recovery/wakaruIntegration.test.ts b/tests/boundary/javascript-recovery/wakaruIntegration.test.ts index ce85c95c2d..ec3b11947d 100644 --- a/tests/boundary/javascript-recovery/wakaruIntegration.test.ts +++ b/tests/boundary/javascript-recovery/wakaruIntegration.test.ts @@ -80,10 +80,21 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")( }); if (result.ok) throw new Error("expected boundary failure"); const error = projectAnalysisError(result.error); - expect( - ["execution_failure", "unreadable_output"], - JSON.stringify(error), - ).toContain(error.code); + if (mode === "source-change" || mode === "engine-change") { + expect(error.code).toBe("artifact_changed"); + expect(error.details).toMatchObject({ + operation: "recover_javascript_sources", + path: + mode === "source-change" + ? fixture.path + : join(fixture.root, "fixture-wakaru"), + }); + } else { + expect( + ["execution_failure", "unreadable_output"], + JSON.stringify(error), + ).toContain(error.code); + } expect(error.details).toBeDefined(); await expect(access(fixture.output)).rejects.toMatchObject({ code: "ENOENT", @@ -495,9 +506,10 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")( output_directory: fixture.output, }); if (result.ok) throw new Error("expected per-source validation failure"); - expect(projectAnalysisError(result.error).code).toMatch( - /execution_failure|unreadable_output/u, - ); + const code = projectAnalysisError(result.error).code; + if (mode === "secondary-change" || mode === "redistribute") + expect(code).toBe("artifact_changed"); + else expect(code).toMatch(/execution_failure|unreadable_output/u); const reason = mode === "secondary-foreign" ? "does not bind the selected input snapshot" diff --git a/tests/boundary/mcp/javascriptRecoveryMcp.test.ts b/tests/boundary/mcp/javascriptRecoveryMcp.test.ts index 332b066bc2..60748e7c1a 100644 --- a/tests/boundary/mcp/javascriptRecoveryMcp.test.ts +++ b/tests/boundary/mcp/javascriptRecoveryMcp.test.ts @@ -1,5 +1,5 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { writeFile } from "node:fs/promises"; +import { chmod, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished } from "vitest"; @@ -7,6 +7,7 @@ import { toolContract } from "../../../src/contracts/toolContracts.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { assertRecoveryCleanup, recoveryFixture, @@ -83,3 +84,54 @@ it await assertRecoveryCleanup(fixture.launches); }, ); + +it.skipIf(process.platform !== "linux" || process.arch !== "x64")( + "reports denied selected-input access before launching recovery", + async (context) => { + const fixture = await recoveryFixture(); + onTestFinished(() => chmod(fixture.path, 0o600)); + await chmod(fixture.path, 0); + const denial = await readFile(fixture.path).then( + () => undefined, + (cause: unknown) => cause, + ); + if (denial === undefined) context.skip(); + expect(denial).toMatchObject({ code: "EACCES" }); + const session = createTestBinarySession(() => { + throw new Error("no binary provider should start"); + }); + const server = createServer( + { kind: "session", session }, + { + javascriptRecovery: fixture.provider, + }, + ); + const client = new Client({ name: "recovery-denied", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "recover_javascript_sources", + arguments: { path: fixture.path, output_directory: fixture.output }, + }); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "access_denied", + category: "unavailable", + details: { + operation: "recover_javascript_sources", + path: fixture.path, + system_code: "EACCES", + boundary: "filesystem-read", + }, + }, + }); + expect(fixture.launches).toEqual([]); + }, +);