diff --git a/docs/tool-design.md b/docs/tool-design.md index 14424a5b48..d3c0f9fe1f 100644 --- a/docs/tool-design.md +++ b/docs/tool-design.md @@ -256,6 +256,10 @@ File-error wrappers retain the format owner's specific diagnostic and recovery text. Strict JSON file input, for example, keeps the byte-order-mark explanation and UTF-8 recovery through CLI/MCP projection rather than replacing it with generic invalid-file advice. Consumers do not classify parser message text. +When an artifact boundary falls back to an I/O error, retain the original cause +on the typed error so provider callers can inspect its filesystem evidence. +Keep public error fields under the canonical projector's ownership; retaining +a cause does not require exposing it in transport output. Update canonical contracts, output schemas, examples, generated artifacts, and relevant docs together. Verify converted advertised schemas and success, diff --git a/src/artifacts/ArtifactOperationFailure.ts b/src/artifacts/ArtifactOperationFailure.ts index e484f8afb2..b67f74e048 100644 --- a/src/artifacts/ArtifactOperationFailure.ts +++ b/src/artifacts/ArtifactOperationFailure.ts @@ -37,5 +37,7 @@ export const translateArtifactFailure = ( } // Keep typed Evidence and other analysis failures instead of reducing them to I/O. if (cause instanceof AnalysisError) return cause; - return new ArtifactOperationError(operation, "io"); + return new ArtifactOperationError(operation, "io", undefined, undefined, { + cause, + }); }; diff --git a/tests/process-global/dylibFileLifetime.test.ts b/tests/process-global/dylibFileLifetime.test.ts index 1cb37d37f2..91d3bf6894 100644 --- a/tests/process-global/dylibFileLifetime.test.ts +++ b/tests/process-global/dylibFileLifetime.test.ts @@ -125,6 +125,23 @@ it("preserves a dyld read failure when the same read observes cancellation", asy expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); }); +it("retains the original dyld I/O cause through provider translation", async () => { + const { client } = await fixture(); + const cause = Object.assign(new Error("controlled dyld EIO"), { + code: "EIO", + path: fault.path, + }); + fault.readFailure = cause; + const result = await client.execute("trace_dylib_resolution", { + roots: ["Main"], + }); + if (result.ok) throw new Error("Expected failed dyld read"); + expect(result.error).toMatchObject({ reason: "io" }); + expect(result.error.cause).toBe(cause); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + await expect(client.close()).resolves.toMatchObject({ ok: true }); +}); + it("observes cancellation during the final dyld digest close", async () => { const { trace } = await fixture(); fault.abortClose = 2; @@ -382,15 +399,31 @@ it("waits for an in-flight final dyld close before completing provider close", a } }); -it("keeps post-admission pathname disappearance as an integrity failure through the provider", async () => { - const { client } = await fixture(); - fault.onRead = async () => { - fault.onRead = undefined; - await rename(fault.path, `${fault.path}.old`); - }; - const result = await client.execute("trace_dylib_resolution", { - roots: ["Main"], - }); - expect(result).toMatchObject({ ok: false, error: { reason: "integrity" } }); - expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); -}); +it.each(["image", "digest"] as const)( + "retains the path and filesystem cause when the pathname disappears during the dyld %s read", + async (stage) => { + const { client } = await fixture(); + fault.onRead = async () => { + if (fault.handles.length !== (stage === "image" ? 1 : 2)) return; + fault.onRead = undefined; + await rename(fault.path, `${fault.path}.old`); + }; + const result = await client.execute("trace_dylib_resolution", { + roots: ["Main"], + }); + expect(result).toMatchObject({ + ok: false, + error: { + reason: "integrity", + detail: expect.stringContaining(fault.path), + cause: { + reason: "integrity", + cause: { code: "ENOENT", path: fault.path }, + }, + }, + }); + expect(fault.handles).toHaveLength(stage === "image" ? 1 : 2); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + await expect(client.close()).resolves.toMatchObject({ ok: true }); + }, +);