From f1126ff7bfef9d8a928ed0edb9978b4440b9121d Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:25:28 +0800 Subject: [PATCH] fix(wasm): retain actionable provider configuration diagnostics Fixes #5767. Retain exact WABT version-probe output and tool identity, including nonzero command failures, and provide condition-specific configuration recovery. Verified with 54 focused tests, the final 51-case real WABT CLI/MCP lane, actual Codex recovery trials, and independent source reviews. No provider installation, target execution or admission relaxation. Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) --- docs/wasm-artifacts.md | 11 +++++ src/wasm/WabtArtifactProvider.test.ts | 71 ++++++++++++++++++++++++++- src/wasm/WabtArtifactProvider.ts | 48 ++++++++++++++---- src/wasm/WabtFailures.test.ts | 68 +++++++++++++++++++++++++ src/wasm/WabtFailures.ts | 48 +++++++++++++++--- src/wasm/WabtRelease.ts | 3 +- 6 files changed, 231 insertions(+), 18 deletions(-) diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index 7093df0a06..b8cd0582ff 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -26,6 +26,17 @@ execution validates tool files and version banners. Host process-ownership and private-workspace controls must also be available. The real-tool profile has been exercised on macOS arm64; other hosts require their own verification. +Configuration failures do not establish anything about the selected WASM bytes. +For a rejected version probe, `details.diagnostics` identifies the executable, +tool, expected version and configuration key; `details.captured_output` preserves +its actual stdout and stderr, including empty banners or warnings. Fix the +reported directory, executable access or release rather than changing the module +or repeating an unchanged call. All three tools must report 1.0.42 on stdout with +empty stderr. For CLI calls, set the environment before the next invocation. For +MCP, change the server's launch environment and restart/reconnect that server; +exporting a variable in a different shell does not update a running server. +REA never installs tools or relaxes version admission to recover. + ```bash REA_WABT_BIN_DIRECTORY=/absolute/wabt/bin rea inspect-wasm-artifact \ /artifacts/module.wasm --glue_paths /artifacts/glue.js \ diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts index b09d6ea2be..16462cc0fc 100644 --- a/src/wasm/WabtArtifactProvider.test.ts +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { access, chmod, readFile, writeFile } from "node:fs/promises"; +import { access, chmod, readFile, realpath, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; @@ -247,6 +247,75 @@ it("returns actionable unavailable without creating a workspace when tools are a }); }); +it.each([ + { tool: "wasm-validate", stdout: "1.0.41\n", stderr: "" }, + { tool: "wasm-objdump", stdout: "", stderr: "" }, + { tool: "wasm2wat", stdout: "1.0.42\n", stderr: "launcher warning\n" }, + { + tool: "wasm-objdump", + stdout: "", + stderr: "version launcher failed\n", + exitCode: 2, + }, +])( + "retains the exact rejected $tool version probe and configuration recovery", + async ({ tool, stdout, stderr, exitCode = 0 }) => { + const root = await realpath( + await createTestTempDirectory("rea-wabt-version-recovery-"), + ); + const suffix = process.platform === "win32" ? ".exe" : ""; + for (const name of ["wasm-validate", "wasm-objdump", "wasm2wat"]) + await writeFile(join(root, name + suffix), "owned executable seam", { + mode: 0o700, + }); + const commands: string[] = []; + const provider = new WabtArtifactProvider( + { REA_WABT_BIN_DIRECTORY: root }, + (input) => { + expect(input.arguments).toEqual(["--version"]); + commands.push(input.command); + const selected = input.command === join(root, tool + suffix); + return spawnOwnedProviderProcess({ + ...input, + command: process.execPath, + arguments: [ + "-e", + `process.stdout.write(${JSON.stringify(selected ? stdout : "1.0.42\n")}); process.stderr.write(${JSON.stringify(selected ? stderr : "")}); process.exit(${String(selected ? exitCode : 0)});`, + ], + expectedCommand: null, + }); + }, + ); + try { + const result = await provider.inspect({ + path: "/must-not-read-before-configuration.wasm", + glue_paths: [], + candidate_paths: [], + }); + if (result.ok) throw new Error("Expected WABT configuration rejection"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "capability_unavailable", + retryable: false, + details: { + diagnostics: { + configuration_key: "REA_WABT_BIN_DIRECTORY", + executable_path: join(root, tool + suffix), + tool, + expected_version: "1.0.42", + }, + captured_output: { stdout, stderr, truncated: false }, + }, + }); + expect(projected.remediation.action).toContain("--version"); + expect(projected.remediation.action).toContain("restart"); + expect(commands.at(-1)).toBe(join(root, tool + suffix)); + } finally { + await provider.close(); + } + }, +); + it("retries a retained input descriptor before admitting the next WABT operation", async () => { const root = await createTestTempDirectory("rea-wabt-read-cleanup-"); const path = join(root, "selected.wasm"); diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index 2436a387f0..c9901d4c3c 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -39,8 +39,16 @@ import { err, ok, type Result } from "../domain/result.js"; import { runOwnedCommand } from "../process/OwnedCommand.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; import { WabtRuntime } from "./WabtRuntime.js"; -import { WABT_PROVIDER_IDENTITY, WABT_LIMITS } from "./WabtRelease.js"; -import { wabtFailure } from "./WabtFailures.js"; +import { + WABT_PROVIDER_IDENTITY, + WABT_LIMITS, + WABT_VERSION, +} from "./WabtRelease.js"; +import { + wabtFailure, + wabtConfigurationRecoveryAction, + type WabtVersionProbe, +} from "./WabtFailures.js"; import { parseWabtOutput } from "./WabtOutput.js"; import { parseWabtWat } from "./WabtWat.js"; import { associateWasmGlue } from "./WasmGlueAdapter.js"; @@ -102,6 +110,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { let phase: "configuration" | "artifact" | "producer" = "configuration"; let result: Result; let capturedOutput: AnalysisCapturedOutput | undefined; + let versionProbe: WabtVersionProbe | undefined; try { if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION); const directory = this.environment.REA_WABT_BIN_DIRECTORY ?? ""; @@ -206,15 +215,36 @@ export class WabtArtifactProvider implements WasmArtifactPort { return snapshot; }; for (const command of commands) { + versionProbe = { + configuration_key: "REA_WABT_BIN_DIRECTORY", + executable_path: command.path, + tool: command.tool, + expected_version: WABT_VERSION, + }; const version = await execute(command, ["--version"]); if ( - version.stdout.text.trim() !== "1.0.42" || + version.stdout.text.trim() !== WABT_VERSION || version.stderr.text !== "" - ) - throw new Error( - `Unsupported ${command.tool} version banner; expected 1.0.42.`, + ) { + const reason = `Unsupported ${command.tool} version banner; expected ${WABT_VERSION}.`; + throw new AnalysisCapabilityUnavailableError( + WABT_PROVIDER_IDENTITY.id, + OPERATION, + reason, + { + userMessage: reason, + diagnostics: versionProbe, + capturedOutput: { + stdout: version.stdout.text, + stderr: version.stderr.text, + truncated: version.diagnosticTruncated === true, + }, + remediationAction: wabtConfigurationRecoveryAction(), + }, ); + } command.version_banner = version.stdout.text; + versionProbe = undefined; } phase = "artifact"; const selected = await readStableArtifact( @@ -300,7 +330,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { validation: "rejected-by-selected-profile", tool_profile: { source: "https://github.com/WebAssembly/wabt", - release: "1.0.42", + release: WABT_VERSION, source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48", commands, }, @@ -393,7 +423,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { }, tool_profile: { source: "https://github.com/WebAssembly/wabt", - release: "1.0.42", + release: WABT_VERSION, source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48", commands, timeout_ms: WABT_LIMITS.timeoutMs, @@ -417,7 +447,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { }), ); } catch (cause: unknown) { - const failure = wabtFailure(cause, phase, input.path); + const failure = wabtFailure(cause, phase, input.path, versionProbe); runtime?.retainCommandFailure(cause, failure, this.#resources); result = err( failure instanceof AnalysisOutputError && diff --git a/src/wasm/WabtFailures.test.ts b/src/wasm/WabtFailures.test.ts index e7c8b09cd2..e58363036d 100644 --- a/src/wasm/WabtFailures.test.ts +++ b/src/wasm/WabtFailures.test.ts @@ -3,6 +3,74 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { wabtFailure } from "./WabtFailures.js"; import { WABT_LIMITS } from "./WabtRelease.js"; +import { OwnedCommandFailure } from "../process/OwnedCommand.js"; + +it.each([ + { reason: "timeout" as const, cleanup: null, code: "provider_timeout" }, + { reason: "cancelled" as const, cleanup: null, code: "cancelled" }, + { + reason: "process" as const, + cleanup: "owned stop failed", + code: "cleanup_incomplete", + }, +])( + "retains version-probe facts without changing $reason cleanup=$cleanup", + ({ reason, cleanup, code }) => { + const cause = new OwnedCommandFailure( + reason, + "version command failed", + null, + cleanup, + ); + const probe = { + configuration_key: "REA_WABT_BIN_DIRECTORY" as const, + executable_path: "/tools/wasm2wat", + tool: "wasm2wat", + expected_version: "1.0.42" as const, + }; + const failure = wabtFailure( + cause, + "configuration", + "/selected.wasm", + probe, + ); + const original = wabtFailure(cause, "configuration", "/selected.wasm"); + expect(failure._tag).toBe(original._tag); + expect(failure.cleanupIncomplete).toBe(original.cleanupIncomplete); + expect(failure.cause).toBe(original.cause); + expect(projectAnalysisError(failure)).toMatchObject({ + code, + details: { diagnostics: probe }, + }); + }, +); + +it.each([ + { code: "EACCES", action: "read and execute access" }, + { + code: "ENOENT", + action: "contains wasm-validate, wasm-objdump and wasm2wat", + }, + { code: "ENOTDIR", action: "directory, not an individual executable" }, +])( + "explains $code configuration recovery without blaming the WASM input", + ({ code, action }) => { + const failure = wabtFailure( + Object.assign(new Error(`tool admission failed: ${code}`), { code }), + "configuration", + "/selected.wasm", + ); + const projected = projectAnalysisError(failure); + expect(projected).toMatchObject({ + code: "capability_unavailable", + retryable: false, + remediation: { action: expect.stringContaining(action) }, + }); + expect(projected.remediation.action).toContain("REA_WABT_BIN_DIRECTORY"); + expect(projected.remediation.action).toContain("restart"); + expect(projected.remediation.action).toContain("Do not repeat"); + }, +); it.each([false, true])( "preserves the file-size boundary with cleanup=%s when an input exceeds its fixed allowance", diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts index 8dfb9b774e..5a089befeb 100644 --- a/src/wasm/WabtFailures.ts +++ b/src/wasm/WabtFailures.ts @@ -19,9 +19,36 @@ import { import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { OwnedCommandFailure } from "../process/OwnedCommand.js"; import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js"; -import { WABT_LIMITS, WABT_PROVIDER_IDENTITY } from "./WabtRelease.js"; +import { + WABT_LIMITS, + WABT_PROVIDER_IDENTITY, + WABT_VERSION, +} from "./WabtRelease.js"; const OUTPUT_LIMIT_EXCERPT_BYTES = 4096; const OUTPUT_LIMIT_REASON = `WABT output exceeded the ${String(WABT_LIMITS.outputBytes)}-byte output budget`; + +/** Facts owned before dispatch, even when a version command does not return successfully. */ +export type WabtVersionProbe = { + readonly configuration_key: "REA_WABT_BIN_DIRECTORY"; + readonly executable_path: string; + readonly tool: string; + readonly expected_version: typeof WABT_VERSION; +}; + +/** Configuration is captured by the server; fixing files and changing its environment are distinct repairs. */ +export const wabtConfigurationRecoveryAction = (cause?: unknown): string => { + const code = + cause instanceof Error && "code" in cause ? cause.code : undefined; + const action = + code === "EACCES" || code === "EPERM" + ? "Check this REA process has read and execute access to the reported caller-supplied tools, or select an accessible trusted copy. " + : code === "ENOTDIR" + ? "Select the tools' containing directory, not an individual executable. " + : code === "ENOENT" + ? "The reported configured tool is missing. " + : ""; + return `${action}Set absolute REA_WABT_BIN_DIRECTORY to a directory that contains wasm-validate, wasm-objdump and wasm2wat from WABT ${WABT_VERSION}; each --version must report ${WABT_VERSION} on stdout with empty stderr. Correct the reported tool path, access or release before retrying, not the WASM input. Set this in the shell for the next CLI call, or in the MCP server launch environment and restart that server; a shell export does not update a running MCP server. Do not repeat the unchanged call or install tools implicitly.`; +}; /** Past the output budget, retained text is a prefix of an oversized stream; keep a bounded excerpt of each. */ const outputLimitExcerpt = (snapshot: ProviderProcessSnapshot) => ({ stdout: boundedUtf8Text(snapshot.stdout.text, OUTPUT_LIMIT_EXCERPT_BYTES) @@ -40,8 +67,9 @@ export const wabtFailure = ( cause: unknown, phase: "configuration" | "artifact" | "producer", path: string, + versionProbe?: WabtVersionProbe, ): AnalysisError => { - const failure = classifyWabtFailure(cause, phase, path); + const failure = classifyWabtFailure(cause, phase, path, versionProbe); bindArtifactFileCleanup(cause, failure); return failure; }; @@ -50,6 +78,7 @@ const classifyWabtFailure = ( cause: unknown, phase: "configuration" | "artifact" | "producer", path: string, + versionProbe: WabtVersionProbe | undefined, ): AnalysisError => { if (cause instanceof AnalysisError) return cause; const artifactCleanup = @@ -79,13 +108,17 @@ const classifyWabtFailure = ( stderr: cause.snapshot.stderr.text, truncated: cause.snapshot.diagnosticTruncated ?? false, }; - const options = - capturedOutput === undefined ? { cause } : { cause, capturedOutput }; + const options = { + cause, + ...(capturedOutput === undefined ? {} : { capturedOutput }), + ...(versionProbe === undefined ? {} : { diagnostics: versionProbe }), + }; if (cause.cleanupFailure !== null) return new ProviderCleanupError( WABT_PROVIDER_IDENTITY.id, cause.resources, { + ...versionProbe, reason: cause.cleanupFailure, previous_error: cause.reason === "output-limit" @@ -128,8 +161,8 @@ const classifyWabtFailure = ( reason, { ...options, - userMessage: - "Configured WABT could not start. Check executable/interpreter access and WABT 1.0.42 profile; inspect retained diagnostics.", + userMessage: `Configured WABT could not start. Check executable/interpreter access and WABT ${WABT_VERSION} profile; inspect retained diagnostics.`, + remediationAction: wabtConfigurationRecoveryAction(cause), }, ); return new AnalysisOutputError("inspect_wasm_artifact", reason, options); @@ -149,7 +182,8 @@ const classifyWabtFailure = ( { cause, ...cleanupOptions, - userMessage: `${reason} Supply WABT 1.0.42 via absolute REA_WABT_BIN_DIRECTORY; REA does not install tools.`, + userMessage: reason, + remediationAction: wabtConfigurationRecoveryAction(cause), }, ); if ( diff --git a/src/wasm/WabtRelease.ts b/src/wasm/WabtRelease.ts index 5920d95235..9e553bd4a0 100644 --- a/src/wasm/WabtRelease.ts +++ b/src/wasm/WabtRelease.ts @@ -1,8 +1,9 @@ /** Exact upstream release whose producer output is admitted by this adapter. */ +export const WABT_VERSION = "1.0.42"; export const WABT_PROVIDER_IDENTITY = { id: "wabt-artifact", name: "REA WABT artifact adapter", - version: "wabt@1.0.42", + version: `wabt@${WABT_VERSION}`, } as const; export const WABT_LIMITS = { inputBytes: 8 * 1024 * 1024,