fix(browser): classify selected web trace file failures by cause (#1396)

trace_web_module_imports and trace_web_source_location reported a missing
or non-file manifest, source map or import map as artifact_operation_failed
("Artifact could not be read or written" / "unsafe or conflicting internal
path"). These are caller selections: report ENOENT/ENOTDIR and non-regular
files as invalid input on the selected field, and EACCES/EPERM as a host
access denial. A missing manifest-derived source file stays an artifact fault.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-09 19:29:31 -07:00
1 parent 498cec042a
commit 2b6e9ec03f
6 files changed
+138 -18

No files matched your search

@@ -25,6 +25,7 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
async load(input: SelectedWebScriptInput, options?: ExecutionOptions) {
let field: readonly (string | number)[] = ["manifest_path"];
let targetPath = input.manifest_path;
let callerSelected = true;
try {
const manifestBytes = await readWebArtifactJson(
input.manifest_path,
@@ -58,6 +59,7 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
const root = resolve(dirname(input.manifest_path));
const sourcePath = join(root, "files", portable);
targetPath = sourcePath;
callerSelected = false;
await assertContained(root, sourcePath);
const sourceBytes = await readStableArtifact(
sourcePath,
@@ -99,7 +101,7 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
return err(
webArtifactReadError(
cause,
{ operation: this.operation, field, targetPath },
{ operation: this.operation, field, targetPath, callerSelected },
options?.signal,
),
);
+37
View File
@@ -3,6 +3,7 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisCancelledError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
@@ -15,6 +16,8 @@ export interface WebArtifactReadContext {
readonly operation: "trace_web_module_imports" | "trace_web_source_location";
readonly field: readonly (string | number)[];
readonly targetPath: string;
/** Whether the caller named this file, rather than a manifest deriving it. */
readonly callerSelected: boolean;
}
/** A malformed producer document, distinct from host I/O failures. */
@@ -54,6 +57,10 @@ export const webArtifactReadError = (
): AnalysisError => {
if (signal?.aborted === true)
return new AnalysisCancelledError(context.operation);
const selection = context.callerSelected
? selectedFileError(cause, context)
: undefined;
if (selection !== undefined) return selection;
if (cause instanceof ArtifactReaderFailure)
return new ArtifactOperationError(
context.operation,
@@ -91,3 +98,33 @@ export const webArtifactReadError = (
},
});
};
/**
* Report a missing, non-regular or unreadable caller-selected file as the
* caller's selection or a host permission denial, not an artifact fault.
*/
const selectedFileError = (
cause: unknown,
context: WebArtifactReadContext,
): AnalysisError | undefined => {
const selected = (message: string) =>
new AnalysisInputError(context.operation, { cause }, [
{ path: context.field, reason: "invalid_value", message },
]);
if (cause instanceof ArtifactReaderFailure)
return cause.reason === "path" ? selected(cause.message) : undefined;
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
if (code === "EACCES" || code === "EPERM")
return new AnalysisAccessDeniedError(
context.operation,
context.targetPath,
code,
{ cause },
);
if (code === "ENOENT" || code === "ENOTDIR")
return selected(
`Selected file could not be read (${code}): ${context.targetPath}`,
);
return undefined;
};
@@ -48,6 +48,7 @@ export class LocalWebSourceLocationArtifacts implements WebSourceLocationArtifac
operation: "trace_web_source_location",
field: ["source_map", "path"],
targetPath: input.source_map.path,
callerSelected: true,
},
options?.signal,
),
@@ -52,6 +52,7 @@ export class LocalWebModuleArtifacts implements WebModuleArtifactPort {
operation: "trace_web_module_imports",
field: ["import_map", "path"],
targetPath: selected.path,
callerSelected: true,
},
options?.signal,
),
@@ -1,6 +1,14 @@
import { mkdir, readFile, rename, symlink, writeFile } from "node:fs/promises";
import {
chmod,
mkdir,
readFile,
rename,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, onTestFinished } from "vitest";
import { exportWebScripts } from "../../../src/application/WebScriptExportService.js";
import { LocalWebModuleArtifacts } from "../../../src/browser/modules/WebModuleArtifacts.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
@@ -163,20 +171,86 @@ describe("selected captured module artifact boundary", () => {
await writeFile(map, "[]");
expect((await new LocalWebModuleArtifacts().load(input)).ok).toBe(false);
});
it("preserves missing filesystem diagnostics separately from malformed input", async () => {
});
describe("caller-selected web module files", () => {
const selectionIssue = (field: readonly string[], message: string) => ({
code: "invalid_request",
details: {
issues: [
{
path: field,
reason: "invalid_value",
message: expect.stringContaining(message),
},
],
},
});
it("reports a missing or non-file manifest selection as invalid input", async () => {
const root = await createTestTempDirectory("rea-module-missing-");
await mkdir(join(root, "directory"));
const missing = join(root, "missing.json");
const directory = join(root, "directory");
await mkdir(directory);
for (const [manifest_path, message] of [
[missing, `(ENOENT): ${missing}`],
[join(missing, "manifest.json"), "(ENOENT)"],
[directory, `Expected a regular file without a symlink: ${directory}`],
] as const) {
const response = await new LocalWebModuleArtifacts().load({
manifest_path,
script_index: 0,
});
if (response.ok) throw new Error("expected selection failure");
expect(projectAnalysisError(response.error)).toMatchObject(
selectionIssue(["manifest_path"], message),
);
}
});
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports an unreadable manifest as a host access denial",
async () => {
const { manifestPath } = await fixture();
await chmod(manifestPath, 0o000);
onTestFinished(() => chmod(manifestPath, 0o600));
const response = await new LocalWebModuleArtifacts().load({
manifest_path: manifestPath,
script_index: 0,
});
if (response.ok) throw new Error("expected access denial");
expect(projectAnalysisError(response.error)).toMatchObject({
code: "access_denied",
details: { path: manifestPath, system_code: "EACCES" },
});
},
);
it("reports a missing import map selection as invalid input", async () => {
const { root, manifestPath } = await fixture();
const map = join(root, "missing-import-map.json");
const response = await new LocalWebModuleArtifacts().load({
manifest_path: join(root, "missing.json"),
manifest_path: manifestPath,
script_index: 0,
import_map: { path: map, base_url: "https://app.test/" },
});
if (response.ok) throw new Error("expected missing import map");
expect(projectAnalysisError(response.error)).toMatchObject(
selectionIssue(["import_map", "path"], `(ENOENT): ${map}`),
);
});
it("keeps a missing manifest-derived source as an artifact fault", async () => {
const { manifestPath, sourcePath } = await fixture();
await rm(sourcePath);
const response = await new LocalWebModuleArtifacts().load({
manifest_path: manifestPath,
script_index: 0,
});
if (response.ok) throw new Error("expected missing file");
if (response.ok) throw new Error("expected missing source");
expect(response.error).toMatchObject({
_tag: "ArtifactOperationError",
reason: "io",
});
expect(projectAnalysisError(response.error).details?.detail).toContain(
"ENOENT",
);
});
});
@@ -56,23 +56,28 @@ it.each(["unavailable", "invalid-utf8", "oversized"])(
});
if (response.ok) throw new Error("expected map failure");
const projection = projectAnalysisError(response.error);
if (variant === "invalid-utf8")
if (variant === "oversized")
expect(response.error).toMatchObject({
_tag: "ArtifactOperationError",
reason: "limit",
detail: expect.stringContaining(path),
});
else
expect(projection).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["source_map", "path"],
message: expect.stringContaining("UTF-8"),
...(variant === "unavailable"
? { reason: "invalid_value" }
: { reason: "invalid_format" }),
message: expect.stringContaining(
variant === "unavailable" ? `(ENOENT): ${path}` : "UTF-8",
),
},
],
},
});
else
expect(response.error).toMatchObject({
_tag: "ArtifactOperationError",
reason: variant === "oversized" ? "limit" : "io",
detail: expect.stringContaining(path),
});
},
);