diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index 5289976a71..21e2735a06 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -4,6 +4,15 @@ local WASM bytes using caller-supplied WABT 1.0.42. No active binary target is required. REA does not install tools or fetch artifacts and does not execute WASM. +`open_binary` is not the WASM inspection route. A recognized WASM v1 header +returns `target_unavailable` with category `unsupported_target` and constraint +`wasm_requires_static_inspection`, including the selected path in a valid +`inspect_wasm_artifact` call. Use that target-free tool or +`rea inspect-wasm-artifact --format json` with the toolchain below. +Check `binary_session` for configured capability availability. A recognized +header is not validation of the complete module; missing WABT is reported +separately as an unavailable capability, not as missing native engine support. + Set `REA_WABT_BIN_DIRECTORY` to an absolute directory containing executable `wasm-validate`, `wasm-objdump` and `wasm2wat` (with `.exe` on Windows). Missing executables, unsupported banners and inaccessible tools produce actionable diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index ca8a4ab737..9e0f5274cb 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -247,6 +247,15 @@ const detectArtifactFormat = async ( const magic = Buffer.alloc(8); const observed = await handle.read(magic, 0, magic.length, 0); throwIfTargetResolutionCancelled(signal); + if ( + observed.bytesRead === 8 && + magic.equals(Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])) + ) + throw new BinaryTargetError( + path, + "WebAssembly v1 artifacts are not supported by the native opening route", + { constraint: "wasm_requires_static_inspection" }, + ); if (hasZipSignature(magic.subarray(0, observed.bytesRead))) { // The HarmonyOS App Pack suffix `.app` is a root-input concept; a nested // ZIP member ending in `.app` keeps the generic zip classification. diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 5ffc9f340d..3e8659dcaa 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -421,7 +421,7 @@ export const systemDoctorHost = ( name: "target", ok: false, classification: - targetError?.constraint === "directory_requires_file" + targetError?.constraint !== undefined ? "unsupported_target" : "config_drift", detail: path, diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 0c433e7891..c28f926b57 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -76,6 +76,11 @@ export const analysisErrorRemediationAction = ( error.constraint === "directory_requires_file" ) return "For a JavaScript/Electron application directory, call analyze_javascript_application with input_path or run `rea analyze `. For binary analysis, select its executable file."; + if ( + error instanceof BinaryTargetError && + error.constraint === "wasm_requires_static_inspection" + ) + return `For static inspection, call inspect_wasm_artifact with ${JSON.stringify({ path: error.path })} or run \`rea inspect-wasm-artifact --format json\`. This separate lane requires caller-supplied WABT 1.0.42 via absolute REA_WABT_BIN_DIRECTORY; check its availability in binary_session. REA does not execute WASM or install tools, and the header alone does not establish that the complete module is valid.`; if ( error instanceof AnalysisAccessDeniedError || (error instanceof BinaryTargetError && error.systemCode !== undefined) @@ -95,6 +100,11 @@ export const analysisErrorRemediationAction = ( export const analysisErrorCategory = ( error: AnalysisError, ): AnalysisErrorProjection["category"] => { + if ( + error instanceof BinaryTargetError && + error.constraint === "wasm_requires_static_inspection" + ) + return "unsupported_target"; if ( error instanceof ProviderSelectionError && error.reason === "provider_unavailable" @@ -213,6 +223,11 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => { ) .join("; ")}.`; if (error instanceof NoBinaryOpenError) return error.message; + if ( + error instanceof BinaryTargetError && + error.constraint === "wasm_requires_static_inspection" + ) + return `${error.message}. Static WebAssembly inspection uses a separate optional WABT lane, not a native analysis engine.`; if (error instanceof BinaryTargetError) return error.constraint === "directory_requires_file" ? "open_binary accepts files and macOS app bundles. This target is a directory; JavaScript/Electron application directories can be analyzed directly with analyze_javascript_application or `rea analyze `." diff --git a/src/domain/configurationErrors.ts b/src/domain/configurationErrors.ts index 88078d0be7..803a21a652 100644 --- a/src/domain/configurationErrors.ts +++ b/src/domain/configurationErrors.ts @@ -38,7 +38,9 @@ export class NoBinaryOpenError extends AnalysisError { /** Additional constraints identified while admitting a filesystem target. */ export interface BinaryTargetErrorOptions extends AnalysisErrorOptions { - readonly constraint?: "directory_requires_file"; + readonly constraint?: + | "directory_requires_file" + | "wasm_requires_static_inspection"; } /** A supplied target path could not be safely opened as a supported app or binary. */ diff --git a/tests/boundary/filesystem/mipsAbiFlags.test.ts b/tests/boundary/filesystem/mipsAbiFlags.test.ts index 4cab9b7029..01a2196579 100644 --- a/tests/boundary/filesystem/mipsAbiFlags.test.ts +++ b/tests/boundary/filesystem/mipsAbiFlags.test.ts @@ -131,6 +131,8 @@ for (const little of [true, false]) { const fp64 = await inspect(bytes, little); assert.ok(fp64.ok); const changed = target({ ...metadata(little), abiFlags: fp64.value }); + assert.equal(fp64.value?.cpr1Size, 2); + assert.equal(fp64.value?.fpAbi, 6); assert.match(ghidraMipsUnsupportedReason(changed) ?? "", /FP64/u); assert.notDeepEqual(ghidraMipsProfileParameters(changed), before); }); @@ -271,6 +273,13 @@ const unsupported: [string, (bytes: Buffer) => void, RegExp][] = [ }, /MIPS32r2/u, ], + [ + "soft-float with declared CPR1", + (b) => { + b[abiOffset + 7] = 3; + }, + /soft-float without CPR1/u, + ], [ "unspecified FP ABI", (b) => { @@ -301,19 +310,22 @@ for (const [name, change, reason] of unsupported) { ); }); } -it("resolves soft-float ABI metadata and commits its integer-register storage policy", async () => { - const bytes = fixture(); - bytes[abiOffset + 5] = 0; - bytes[abiOffset + 7] = 3; - const resolved = await resolveFixture(bytes); - assert.equal(resolved.mips.abiFlags?.cpr1Size, 0); - assert.equal(resolved.mips.abiFlags?.fpAbi, 3); - assert.equal(ghidraMipsUnsupportedReason(resolved), null); - assert.equal( - ghidraMipsProfileParameters(resolved).mips_soft_float_storage, - "o32-soft-float-prototype-v1", - ); -}); +it.each([true, false])( + "resolves soft-float ABI metadata and commits its integer-register storage policy (little endian: %s)", + async (little) => { + const bytes = fixture(little); + bytes[abiOffset + 5] = 0; + bytes[abiOffset + 7] = 3; + const resolved = await resolveFixture(bytes); + assert.equal(resolved.mips.abiFlags?.cpr1Size, 0); + assert.equal(resolved.mips.abiFlags?.fpAbi, 3); + assert.equal(ghidraMipsUnsupportedReason(resolved), null); + assert.equal( + ghidraMipsProfileParameters(resolved).mips_soft_float_storage, + "o32-soft-float-prototype-v1", + ); + }, +); it("preserves the caller's cancellation error during table inspection", async () => { const cancelled = new Error("caller cancellation sentinel"); let checks = 0; diff --git a/tests/boundary/filesystem/targetDiagnostics.test.ts b/tests/boundary/filesystem/targetDiagnostics.test.ts index 645846fe5b..32b86bbceb 100644 --- a/tests/boundary/filesystem/targetDiagnostics.test.ts +++ b/tests/boundary/filesystem/targetDiagnostics.test.ts @@ -7,6 +7,81 @@ import { projectAnalysisError } from "../../../src/domain/analysisErrorProjectio import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; describe("target route diagnostics", () => { + it.each(["module.wasm", "module.bin", "misleading.js"])( + "identifies a WASM v1 header in %s without suggesting native provider installation", + async (name) => { + const directory = await createTestTempDirectory("rea-wasm-target-"); + const path = join(directory, name); + const bytes = Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]); + await writeFile(path, bytes); + const result = await parseBinaryTarget(path); + if (result.ok) throw new Error("WASM was admitted to native opening"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "target_unavailable", + category: "unsupported_target", + retryable: false, + details: { + path: await realpath(path), + constraint: "wasm_requires_static_inspection", + }, + }); + expect(projected.message).toContain("WebAssembly"); + expect(projected.remediation.action).toContain("inspect_wasm_artifact"); + expect(projected.remediation.action).toContain("REA_WABT_BIN_DIRECTORY"); + expect(projected.remediation.action).toContain("WABT 1.0.42"); + expect(projected.remediation.action).toContain("does not execute"); + expect(await systemDoctorHost().inspectTarget?.(path)).toMatchObject({ + ok: false, + classification: "unsupported_target", + details: { constraint: "wasm_requires_static_inspection" }, + remediation: projected.remediation.action, + }); + }, + ); + + it.each([ + [0, 0x61, 0x73, 0x6d], + [0, 0x61, 0x73, 0x6d, 2, 0, 0, 0], + [1, 2, 3, 4, 5, 6, 7, 8], + ])( + "does not infer supported WASM encoding from a suffix or incomplete header", + async (...bytes) => { + const directory = await createTestTempDirectory("rea-wasm-unknown-"); + const path = join(directory, "module.wasm"); + await writeFile(path, Buffer.from(bytes)); + const result = await parseBinaryTarget(path); + if (result.ok) throw new Error("unknown header was admitted"); + const projected = projectAnalysisError(result.error); + expect(projected.details).not.toHaveProperty("constraint"); + expect(projected.remediation.action).not.toContain( + "inspect_wasm_artifact", + ); + }, + ); + + it("preserves an explicit raw-binary selection even when the bytes have a WASM header", async () => { + const directory = await createTestTempDirectory("rea-wasm-explicit-raw-"); + const path = join(directory, "module.wasm"); + await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])); + expect( + await parseBinaryTarget(path, { + formatHint: "raw-binary", + architecture: "x86", + entryPoint: "0x001000", + }), + ).toMatchObject({ + ok: true, + value: { + path: await realpath(path), + format: "raw-binary", + architecture: "x86", + entryPoint: "0x1000", + entryPointSource: "0x001000", + }, + }); + }); + it("identifies the directory opening constraint and gives the direct analysis route", async () => { const directory = await createTestTempDirectory("rea-js-target-"); await writeFile(join(directory, "index.js"), "export const answer = 42;\n"); diff --git a/tests/boundary/mcp/wasmArtifactMcp.test.ts b/tests/boundary/mcp/wasmArtifactMcp.test.ts index 62b85b6e11..0a4a2610c7 100644 --- a/tests/boundary/mcp/wasmArtifactMcp.test.ts +++ b/tests/boundary/mcp/wasmArtifactMcp.test.ts @@ -1,17 +1,93 @@ -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { Client } from "@modelcontextprotocol/client"; import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished, vi } from "vitest"; +import { readFile, realpath, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { WasmArtifactService } from "../../../src/application/wasm/WasmArtifactService.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { ok } from "../../../src/domain/result.js"; -import { createServer } from "../../../src/server/createServer.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { wasmArtifactFixture, wasmArtifactExecution, } from "../../fixtures/wasm/artifact.js"; +const wasmToolSchemas = async (client: Client) => { + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_wasm_artifact", + ); + if (advertised?.outputSchema === undefined) + throw new Error("WASM recovery must name an advertised static tool"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + expect(ajv.validateSchema(inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + return { name: advertised.name, ajv, inputSchema, outputSchema }; +}; + +it("directs rejected native WASM opening to the advertised static tool while preserving missing-WABT diagnostics", async () => { + const directory = await createTestTempDirectory("rea-wasm-recovery-"); + const path = join( + await realpath(directory), + process.platform === "win32" ? "module space.bin" : 'module "quoted".bin', + ); + await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])); + const createClient = vi.fn(() => { + throw new Error("native provider must not start for WASM"); + }); + const session = createTestBinarySession(createClient); + const harness = await createApplicationMcpHarness({ + session, + serverOptions: { environment: {} }, + }); + onTestFinished(harness.close); + const { client } = harness; + const { name, ajv, inputSchema } = await wasmToolSchemas(client); + const input = { path }; + expect( + toolContract("inspect_wasm_artifact").inputSchema.safeParse(input).success, + ).toBe(true); + const opening = await client.callTool({ + name: "open_binary", + arguments: input, + }); + expect(opening.isError).toBe(true); + const failure = parseMcpToolError(opening); + expect(failure).toMatchObject({ + error: { + code: "target_unavailable", + category: "unsupported_target", + details: { constraint: "wasm_requires_static_inspection" }, + }, + }); + expect(failure.error.remediation.action).toContain(JSON.stringify({ path })); + expect(ajv.validate(inputSchema, input)).toBe(true); + const inspection = await client.callTool({ + name, + arguments: input, + }); + expect(inspection.isError).toBe(true); + expect(parseMcpToolError(inspection)).toMatchObject({ + error: { + code: "capability_unavailable", + details: { + operation: "inspect_wasm_artifact", + reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY"), + }, + }, + }); + await client.ping(); + expect(createClient).not.toHaveBeenCalled(); + expect(await readFile(path)).toEqual( + Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]), + ); +}); + it("advertises exact valid schemas and records inline WASM interface evidence without a binary target", async () => { const value = wasmArtifactFixture(); const close = vi.fn(() => Promise.resolve()); @@ -22,30 +98,13 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi const session = createTestBinarySession(() => { throw new Error("deep provider must not start"); }); - const server = createServer( - { kind: "session", session }, - { wasmArtifact: service }, - ); - const client = new Client({ name: "wasm-artifact-contract", version: "1" }); - onTestFinished(async () => { - await client.close(); - await server.close(); - await session.close(); + const harness = await createApplicationMcpHarness({ + session, + serverOptions: { wasmArtifact: service }, }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - const advertised = (await client.listTools()).tools.find( - (tool) => tool.name === "inspect_wasm_artifact", - ); - if (advertised?.outputSchema === undefined) - throw new Error("WASM interface inspection must publish both schemas"); - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - const inputSchema: Record = advertised.inputSchema; - const outputSchema: Record = advertised.outputSchema; - expect(ajv.validateSchema(inputSchema)).toBe(true); - expect(ajv.validateSchema(outputSchema)).toBe(true); + onTestFinished(harness.close); + const { client } = harness; + const { ajv, inputSchema, outputSchema } = await wasmToolSchemas(client); expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true); expect( ajv.validate(inputSchema, { @@ -79,6 +138,6 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi }) ).isError, ).toBe(true); - await server.close(); + await harness.close(); expect(close).toHaveBeenCalled(); }); diff --git a/tests/fixtures/applicationMcpHarness.ts b/tests/fixtures/applicationMcpHarness.ts index f9e8f38b11..95196e6218 100644 --- a/tests/fixtures/applicationMcpHarness.ts +++ b/tests/fixtures/applicationMcpHarness.ts @@ -14,12 +14,22 @@ export interface ApplicationMcpHarness { } /** Connect application workflows through the production MCP registration. */ -export async function createApplicationMcpHarness(): Promise { - const session = createTestBinarySession(() => ({ - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(resultOk(null)), - })); - const server = createServer({ kind: "session", session }); +export async function createApplicationMcpHarness( + options: { + readonly session?: BinarySession; + readonly serverOptions?: Parameters[1]; + } = {}, +): Promise { + const session = + options.session ?? + createTestBinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(resultOk(null)), + })); + const server = createServer( + { kind: "session", session }, + options.serverOptions, + ); const client = new Client({ name: "application-workflow-test", version: "1",