mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(javascript): preserve recovery acquisition and cancellation failures
This commit is contained in:
1 parent
cb689fae96
commit
e00b2eda0b
11 files changed
+191
-33
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -34,7 +34,9 @@ export class JavaScriptRecoveryService {
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<typeof createProducer>) => void,
|
||||
) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }[] = [];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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([]);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user