mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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)
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<WasmArtifactExecution, AnalysisError>;
|
||||
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 &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user