From 637bcb5e4182b6c60e922b18688642d8e1008e90 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:16:57 +0000 Subject: [PATCH 1/4] fix(artifacts): report wrong target kinds as unsupported targets decode_interface_builder, inspect_asset_catalog, inspect_keyed_archive and trace_dylib_resolution refused a target of the wrong kind (for example a plain Mach-O for the asset catalog, or a plist for dyld tracing) as an `unavailable` artifact failure: "Artifact processing is unavailable for the current target or host. Check artifact support and required tools." Nothing was missing on the host; the operation does not support that kind of target. Raise AnalysisUnsupportedTargetError for these checks and let translateFailure keep it. The result is now `unsupported_target`, naming the operation, the target path and the requirement, with advice to pick a supported target or another operation. Co-Authored-By: Claude Opus 5.5 --- src/artifacts/ArtifactProvider.ts | 38 ++++++++++++++----- tests/boundary/mcp/dylibResolutionMcp.test.ts | 30 ++++++++++++++- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 941b75d0f..a711f266b 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -24,6 +24,7 @@ import type { BinaryTarget } from "../domain/binaryTarget.js"; import { AnalysisCapabilityUnavailableError, AnalysisInputError, + AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import { ArtifactOperationError } from "../domain/artifactOperationError.js"; import { type AnalysisError } from "../domain/analysisErrorBase.js"; @@ -89,8 +90,8 @@ class ArtifactClient implements AnalysisClient { this.target.sourcePath === undefined || !this.target.sourcePath.toLowerCase().endsWith(".app") ) - throw new ArtifactReaderFailure( - "unavailable", + throw this.unsupportedTarget( + operation, "decode_interface_builder requires an active .app bundle target", ); const limits = interfaceBuilderLimitsSchema.parse(parameters); @@ -135,8 +136,8 @@ class ArtifactClient implements AnalysisClient { bundlePath === undefined || (!standalone && !bundlePath.toLowerCase().endsWith(".app")) ) - throw new ArtifactReaderFailure( - "unavailable", + throw this.unsupportedTarget( + operation, "inspect_keyed_archive requires an active plist or .app bundle", ); const result = await inspectBundleKeyedArchive({ @@ -214,6 +215,18 @@ class ArtifactClient implements AnalysisClient { return Promise.resolve(); } + /** The active target's kind is outside this operation's supported targets. */ + private unsupportedTarget( + operation: string, + reason: string, + ): AnalysisUnsupportedTargetError { + return new AnalysisUnsupportedTargetError( + operation, + this.target.sourcePath ?? this.target.path, + reason, + ); + } + private async inspectAssetCatalog( parameters: Readonly>, options?: ExecutionOptions, @@ -224,8 +237,8 @@ class ArtifactClient implements AnalysisClient { bundlePath === undefined || !bundlePath.toLowerCase().endsWith(".app") ) - throw new ArtifactReaderFailure( - "unavailable", + throw this.unsupportedTarget( + "inspect_asset_catalog", "inspect_asset_catalog requires an active .app bundle target", ); const result = await analyzeAppleAssetCatalogs({ @@ -250,8 +263,8 @@ class ArtifactClient implements AnalysisClient { options?: ExecutionOptions, ) { if (this.target.kind !== "executable" || this.target.format !== "mach-o") - throw new ArtifactReaderFailure( - "unavailable", + throw this.unsupportedTarget( + "trace_dylib_resolution", "trace_dylib_resolution requires an active Mach-O or .app bundle target", ); // Only a target opened from an app bundle directory carries its Info.plist; @@ -360,8 +373,13 @@ const translateFailure = ( cause.details, cause.message, ); - // Caller-selection failures are already typed; keep their correction details. - if (cause instanceof AnalysisInputError) return cause; + // Caller-selection and unsupported-target failures are already typed; keep + // their correction details instead of reducing them to an I/O failure. + if ( + cause instanceof AnalysisInputError || + cause instanceof AnalysisUnsupportedTargetError + ) + return cause; return new ArtifactOperationError(operation, "io"); }; diff --git a/tests/boundary/mcp/dylibResolutionMcp.test.ts b/tests/boundary/mcp/dylibResolutionMcp.test.ts index 06e48e08f..02d75c45d 100644 --- a/tests/boundary/mcp/dylibResolutionMcp.test.ts +++ b/tests/boundary/mcp/dylibResolutionMcp.test.ts @@ -126,7 +126,35 @@ it("rejects targets that are not Mach-O", async () => { arguments: {}, }); expect(called.isError).toBe(true); - expect(JSON.stringify(called.structuredContent)).toContain("unavailable"); + expect(called.structuredContent).toMatchObject({ + error: { code: "unsupported_target", category: "unsupported_target" }, + }); + }); +}); + +it("reports target kinds that artifact operations cannot inspect as unsupported", async () => { + const directory = await createTestTempDirectory("rea-artifact-target-"); + const plist = join(directory, "Info.plist"); + await writeFile(plist, ""); + const tool = join(directory, "tool"); + await writeFile(tool, machoImage({ commands: [buildVersionCommand(1)] })); + await withClient(async (client) => { + for (const [path, operation, requirement] of [ + [plist, "decode_interface_builder", "requires an active .app bundle"], + [plist, "inspect_asset_catalog", "requires an active .app bundle"], + [tool, "inspect_keyed_archive", "requires an active plist or .app"], + ] as const) { + const open = { name: "open_binary", arguments: { path } }; + expect((await client.callTool(open)).isError, path).not.toBe(true); + const called = await client.callTool({ name: operation, arguments: {} }); + expect(called.structuredContent, operation).toMatchObject({ + error: { + code: "unsupported_target", + message: expect.stringContaining(`${operation} at ${path}`), + details: { reason: expect.stringContaining(requirement) }, + }, + }); + } }); }); From 44d5b4b25e0831769c1bd4e5426ef498d0bd3c74 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:21:21 +0000 Subject: [PATCH 2/4] test(artifacts): assert asset-catalog target refusal only on macOS Outside Darwin the session reports inspect_asset_catalog as unavailable on the host before the provider checks the target kind, so the Linux test shards would see capability_unavailable rather than unsupported_target. Co-Authored-By: Claude Opus 5.5 --- tests/boundary/mcp/dylibResolutionMcp.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/boundary/mcp/dylibResolutionMcp.test.ts b/tests/boundary/mcp/dylibResolutionMcp.test.ts index 02d75c45d..e07f9dde0 100644 --- a/tests/boundary/mcp/dylibResolutionMcp.test.ts +++ b/tests/boundary/mcp/dylibResolutionMcp.test.ts @@ -141,7 +141,13 @@ it("reports target kinds that artifact operations cannot inspect as unsupported" await withClient(async (client) => { for (const [path, operation, requirement] of [ [plist, "decode_interface_builder", "requires an active .app bundle"], - [plist, "inspect_asset_catalog", "requires an active .app bundle"], + // Elsewhere the session reports asset catalogs as unavailable on the + // host before the provider checks the target kind. + ...(process.platform === "darwin" + ? ([ + [plist, "inspect_asset_catalog", "requires an active .app bundle"], + ] as const) + : []), [tool, "inspect_keyed_archive", "requires an active plist or .app"], ] as const) { const open = { name: "open_binary", arguments: { path } }; From 8e74c9c62fb44556b90f20bb6c12771ac05a18f2 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:21:22 +0000 Subject: [PATCH 3/4] fix(artifacts): refuse unsupported extraction formats before scanning extract_artifact on a format without an extraction reader, such as a DMG, first ran the full inventory scan (mounting the image) and then failed with an `unavailable` artifact failure: "Check artifact support and required tools". The target format, not the host, is unsupported. Check the format before any inventory or output work and raise AnalysisUnsupportedTargetError, which the provider now keeps, as it does for the other target-kind checks in this PR. Co-Authored-By: Claude Opus 5.5 --- .../extraction/ArtifactExtraction.ts | 49 +++++++++++++------ .../artifactProvider.extraction.test.ts | 21 ++++++++ 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 57e8a0066..a309d35f1 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -22,6 +22,7 @@ import { type ArtifactNode, type ArtifactOccurrence, } from "../../domain/artifactGraph.js"; +import { AnalysisUnsupportedTargetError } from "../../domain/analysisErrorCore.js"; import type { BinaryTarget } from "../../domain/binaryTarget.js"; import { scanArtifactInventory } from "../inventory/ArtifactInventory.js"; @@ -38,6 +39,7 @@ export const extractArtifact = async ( signal?: AbortSignal, ): Promise => { const sourcePath = await realpath(input.inputPath); + await requireExtractableFormat(sourcePath, input.inputFormat); const snapshot = await scanArtifactInventory(sourcePath, { signal, }); @@ -240,26 +242,45 @@ const collectNodes = ( if (selected.has(item.artifact_id)) output.set(item.artifact_id, item); }; +const ZIP_FORMATS = ["ipa", "apk", "msix", "appx", "zip"] as const; + +const isZipFormat = ( + format: BinaryTarget["format"], +): format is (typeof ZIP_FORMATS)[number] => + (ZIP_FORMATS as readonly string[]).includes(format); + +/** + * Refuse a format without an extraction reader before inventory work starts. + * The target kind, not the host, is unsupported by extraction. + */ +const requireExtractableFormat = async ( + path: string, + format: BinaryTarget["format"], +): Promise => { + const directory = (await lstat(path)).isDirectory(); + if ( + !directory && + format !== "asar" && + format !== "mach-o" && + !isZipFormat(format) + ) + throw new AnalysisUnsupportedTargetError( + "extract_artifact", + path, + `Artifact format has no extraction reader: ${format}`, + ); + return directory; +}; + const createReader = async ( path: string, format: BinaryTarget["format"], ): Promise => { - if ((await lstat(path)).isDirectory()) + if (await requireExtractableFormat(path, format)) return new DirectoryArtifactReader(path); if (format === "asar") return new AsarArtifactReader(path); - if ( - format === "ipa" || - format === "apk" || - format === "msix" || - format === "appx" || - format === "zip" - ) - return new ZipArtifactReader(path, format); - if (format === "mach-o") return new MachOSliceArtifactReader(path); - throw new ArtifactReaderFailure( - "unavailable", - `Artifact format has no extraction reader: ${format}`, - ); + if (isZipFormat(format)) return new ZipArtifactReader(path, format); + return new MachOSliceArtifactReader(path); }; const preflight = (entry: ArtifactEntry): void => { diff --git a/tests/boundary/providers/native/artifactProvider.extraction.test.ts b/tests/boundary/providers/native/artifactProvider.extraction.test.ts index 534ff59d1..509a34798 100644 --- a/tests/boundary/providers/native/artifactProvider.extraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extraction.test.ts @@ -133,6 +133,27 @@ describe("artifact extraction", () => { "source", ]); }); + it("refuses a format without an extraction reader before inventory or output", async () => { + const root = await createTestTempDirectory("rea-extract-dmg-"); + const image = join(root, "Image.dmg"); + await writeFile(image, "not mounted"); + const output = join(root, "output"); + const result = await new ArtifactProvider() + .createClient(target(image, "dmg")) + .execute( + "extract_artifact", + artifactExtractionExecutionSchema.parse({ output_root: output }), + ); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisUnsupportedTargetError", + operation: "extract_artifact", + reason: "Artifact format has no extraction reader: dmg", + }, + }); + await expect(access(output)).rejects.toThrow(); + }); }); const inventory = async (targetValue: BinaryTarget) => { const result = await new ArtifactProvider() From de43da71ae982dd0f3d7b5ae49c914729db3a6ec Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:23:44 +0000 Subject: [PATCH 4/4] fix(artifacts): report encrypted archive entries as unsupported extraction extract_artifact on a ZIP containing a password-protected entry failed the whole extraction with "Artifact is invalid or has changed. Get a fresh copy" and named only an opaque occurrence ID. The archive was intact; REA does not decrypt entries, so complete extraction is unsupported for this target. Raise AnalysisUnsupportedTargetError naming the encrypted entry's path. Other unextractable occurrences keep their format failure but now name the logical path alongside the occurrence ID. Co-Authored-By: Claude Opus 5.5 --- src/artifacts/extraction/ArtifactExtraction.ts | 11 +++++++++-- .../filesystem/artifactZipIntegrity.test.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index a309d35f1..4cd226831 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -67,15 +67,22 @@ export const extractArtifact = async ( nodes, }; const selected = selectedOccurrences.map((occurrence) => { + // REA never decrypts archive entries, so an encrypted entry makes the + // complete extraction unsupported rather than the archive invalid. + if (occurrence.encrypted) + throw new AnalysisUnsupportedTargetError( + "extract_artifact", + sourcePath, + `Archive entry ${occurrence.logical_path} is encrypted; REA does not decrypt archive entries, so the archive cannot be extracted completely`, + ); if ( (occurrence.entry_kind !== "file" && occurrence.entry_kind !== "slice") || occurrence.artifact_id === null || - occurrence.encrypted || occurrence.logical_path === "." ) throw new ArtifactReaderFailure( "format", - `Selected occurrence is not an extractable regular child file: ${occurrence.occurrence_id}`, + `Selected occurrence is not an extractable regular child file: ${occurrence.logical_path} (${occurrence.occurrence_id})`, ); const node = inventory.nodes.get(occurrence.artifact_id); if (node === undefined) diff --git a/tests/boundary/filesystem/artifactZipIntegrity.test.ts b/tests/boundary/filesystem/artifactZipIntegrity.test.ts index 61b6b47c4..1796f1bc7 100644 --- a/tests/boundary/filesystem/artifactZipIntegrity.test.ts +++ b/tests/boundary/filesystem/artifactZipIntegrity.test.ts @@ -89,6 +89,22 @@ it("preserves encrypted-member and corrupt-CRC observations", async () => { hash_status: "unavailable", }), ); + // REA does not decrypt entries, so complete extraction is unsupported. + const extractionRoot = join(root, "encrypted-output"); + expect( + await new ArtifactProvider() + .createClient(encryptedTarget.value) + .execute("extract_artifact", { output_root: extractionRoot }), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisUnsupportedTargetError", + reason: expect.stringContaining("Archive entry secret.txt is encrypted"), + }, + }); + await expect(readFile(extractionRoot)).rejects.toMatchObject({ + code: "ENOENT", + }); const corruptPath = join(root, "corrupt-crc.zip"); const corruptWriter = new ZipWriter(new Uint8ArrayWriter());