feat(wasm): make command deadlines adjustable with recovery guidance (#5798)

Expose the existing WABT command deadline as an optional validated per-call CLI/MCP budget, preserving its 30000 ms default and fixed safety allowances.

Retain selected execution profiles in success and rejected-module Evidence. Keep typed timeout recovery through cleanup failures without duplicating command output or making incomplete cleanup retryable.

Verified focused tests, real WABT CLI/MCP workflows and actual Codex recovery/blocked controls; detailed measurements and qualification limits are recorded in issue #5797. Merge uses the explicitly authorized maintainer override with pending CI and unchecked human-review attestations disclosed on PR #5798.

Closes #5797

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-10-21 15:00:05 +08:00
1 parent ea24207b57
commit a5f982de8d
13 files changed
+375 -61

No files matched your search

+21 -1
View File
@@ -75,7 +75,27 @@ The tool profile preserves upstream release/source identity, resolved executable
paths, executable digests, exact banners and arguments. Executable digests are
checked again after each command. Caller-supplied tools are trusted programs;
version and digest recording are not publisher authentication or an OS sandbox.
Each subprocess has a 30-second deadline and 32 MiB combined output budget.
Each WABT version/analysis command defaults to a 30,000 ms deadline. Set optional
MCP `command_timeout_ms`, or CLI `--command-timeout-ms <milliseconds>`, to select a
positive integer up to 2,147,483,647 ms (the runtime timer range) for that call.
The selected deadline is retained as `tool_profile.timeout_ms` and, when supplied,
in Evidence parameters. It applies separately to each command, not the whole
inspection, file admission or cleanup. The MCP client must allow time for all
commands, file checks, cleanup and delivery. A provider timeout reports the
actual selected deadline and these recovery controls; increasing only the client
deadline does not change the provider budget. No server restart is required to
change this per-call setting. Resolve incomplete cleanup before another attempt.
For example, `rea inspect-wasm-artifact /artifacts/module.wasm
--command-timeout-ms 60000 --format json` uses a 60-second per-command deadline;
the equivalent MCP input is `{"path":"/artifacts/module.wasm",
"command_timeout_ms":60000,"detail":"summary"}`. Summary delivery retains the
complete Evidence but does not reduce WABT analysis work.
The 8 MiB input allowance and 32 MiB combined command-output budget remain fixed;
increasing `command_timeout_ms`, host memory or the client deadline does not raise
them. Do not truncate required module bytes to fit. Select another suitable
complete artifact only if it answers the question, otherwise report the limit.
Inspection output past the budget fails with `resource_constraint`; an oversized
`--version` probe still reports `unreadable_output`. Both streams share the
budget, so a stream that starts after the other has filled it retains nothing.
+23 -1
View File
@@ -118,6 +118,9 @@ try {
entrypoint,
"inspect-wasm-artifact",
input.path,
...(input.command_timeout_ms === undefined
? []
: ["--command-timeout-ms", String(input.command_timeout_ms)]),
...input.glue_paths.flatMap((path) => ["--glue_paths", path]),
...input.candidate_paths.flatMap((path) => [
"--candidate_paths",
@@ -238,7 +241,12 @@ try {
);
for (const [index, gluePath] of gluePaths.entries())
await writeFile(gluePath, glueSources[index]);
const input = { path, glue_paths: gluePaths, candidate_paths: [candidate] };
const input = {
path,
glue_paths: gluePaths,
candidate_paths: [candidate],
...(index === 0 ? { command_timeout_ms: 60_000 } : {}),
};
assert.ok(ajv.validate(advertised.inputSchema, input));
const cliValue = await inspectCli(input);
assert.equal(cliValue.category, undefined, JSON.stringify(cliValue));
@@ -256,6 +264,11 @@ try {
assert.equal(cli.evidence_id, mcp.evidence_id);
assert.deepEqual(cli, mcp);
const report = mcp.normalized_result;
assert.equal(
report.tool_profile.timeout_ms,
input.command_timeout_ms ?? 30_000,
);
assert.equal(mcp.parameters.command_timeout_ms, input.command_timeout_ms);
assert.equal(report.artifact.sha256, sha256(original));
assert.equal(report.artifact.bytes, original.length);
assert.equal(report.wat.text, wat);
@@ -359,6 +372,7 @@ try {
path: invalidPath,
glue_paths: [],
candidate_paths: [],
...(index === 0 ? { command_timeout_ms: 42_000 } : {}),
};
const cliError = await inspectCli(invalidInput);
assert.equal(
@@ -391,6 +405,14 @@ try {
),
);
assert.equal(failed.subject.digest.sha256, sha256(invalidBytes));
assert.equal(
failed.normalized_result.tool_profile.timeout_ms,
invalidInput.command_timeout_ms ?? 30_000,
);
assert.equal(
failed.normalized_result.tool_profile.output_budget_bytes,
32 * 1024 * 1024,
);
assert.equal(
failed.normalized_result.validation,
"rejected-by-selected-profile",
@@ -10,6 +10,10 @@ it.each([
{ path: "/module", glue_paths: ["relative"] },
{ path: "/module", candidate_paths: ["relative"] },
{ path: "/module", fetch: true },
...[0, -1, 1.5, 2_147_483_648, "1000", null].map((command_timeout_ms) => ({
path: "/module",
command_timeout_ms,
})),
])("rejects invalid selection before effects: %j", async (input) => {
const service = new WasmArtifactService({
close: () => Promise.resolve(),
@@ -22,6 +26,39 @@ it.each([
error: { _tag: "AnalysisInputError" },
});
});
it("preserves the selected command budget in provider input and Evidence", async () => {
const value = wasmArtifactFixture();
value.tool_profile.timeout_ms = 42_000;
const service = new WasmArtifactService({
close: () => Promise.resolve(),
inspect: async (input) => {
expect(input).toMatchObject({ command_timeout_ms: 42_000 });
return ok(wasmArtifactExecution(value));
},
});
const result = await service.inspect({
path: value.artifact.path,
command_timeout_ms: 42_000,
});
if (!result.ok) throw result.error;
expect(result.value.parameters).toMatchObject({ command_timeout_ms: 42_000 });
expect(result.value.normalized_result).toEqual(value);
});
it("rejects a provider report that silently changes the selected command budget", async () => {
const value = wasmArtifactFixture();
const service = new WasmArtifactService({
close: () => Promise.resolve(),
inspect: async () => ok(wasmArtifactExecution(value)),
});
expect(
await service.inspect({
path: value.artifact.path,
command_timeout_ms: 42_000,
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
it.each(["path", "digest", "wat", "candidates", "glue"])(
"rejects mismatched upstream observation: %s",
async (problem) => {
@@ -13,6 +13,7 @@ import {
inspectWasmArtifactInputSchema,
wasmArtifactSchema,
wasmArtifactMatchesSubject,
DEFAULT_WASM_COMMAND_TIMEOUT_MS,
} from "../../domain/wasm/wasmArtifact.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { err, type Result } from "../../domain/result.js";
@@ -73,6 +74,8 @@ export class WasmArtifactService {
if (
!report.success ||
report.data.artifact.path !== input.data.path ||
report.data.tool_profile.timeout_ms !==
(input.data.command_timeout_ms ?? DEFAULT_WASM_COMMAND_TIMEOUT_MS) ||
!wasmArtifactMatchesSubject(report.data.artifact, execution.subject) ||
JSON.stringify(report.data.glue.map((item) => item.artifact.path)) !==
JSON.stringify(input.data.glue_paths) ||
+5
View File
@@ -10,6 +10,7 @@ import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { inspectWasmArtifactInputSchema } from "../domain/wasm/wasmArtifact.js";
/** Resolve all caller-selected artifact paths through the shared workflow. */
export const registerWasmCommands = (
@@ -47,6 +48,7 @@ export const registerWasmCommands = (
path: z.string().describe("Local WASM artifact"),
}),
options: z.object({
commandTimeoutMs: inspectWasmArtifactInputSchema.shape.command_timeout_ms,
glue_paths: z
.array(z.string())
.optional()
@@ -64,6 +66,9 @@ export const registerWasmCommands = (
service.inspect(
{
path: absoluteCallerPath(args.path),
...(options.commandTimeoutMs === undefined
? {}
: { command_timeout_ms: options.commandTimeoutMs }),
glue_paths: options.glue_paths?.map((path) =>
absoluteCallerPath(path),
),
+1 -1
View File
@@ -22,7 +22,7 @@ export const INSPECT_WASM_ARTIFACT_CONTRACT = {
...toolContractMetadata("inspect_wasm_artifact"),
kind: "artifact-provider",
description:
'Validate an explicit local WASM artifact with caller-supplied WABT 1.0.42. Omitted or complete detail returns exact byte SHA-256, sections, upstream import/export WAT forms and decoded WAT inline as Evidence. For counts, validation and WAT identity without WAT text, pass detail: "summary" on this first call; it retains the complete original Evidence and returns the same typed summary as inspect_analysis_view, avoiding oversized complete delivery. Read exact imports/exports or WAT pages with inspect_analysis_view using normalized_result.parent_evidence_id. Delivery selection does not change analysis, provider inputs or source identity. Optionally associate selected JavaScript glue literals with explicit local candidates while preserving ambiguous URL/basename matches. Configure absolute REA_WABT_BIN_DIRECTORY. No installation, implicit fetch, WASM execution or original-source claim.',
'Validate an explicit local WASM artifact with caller-supplied WABT 1.0.42. Omitted or complete detail returns exact byte SHA-256, sections, upstream import/export WAT forms and decoded WAT inline as Evidence. For counts, validation and WAT identity without WAT text, pass detail: "summary" on this first call; it retains the complete original Evidence and returns the same typed summary as inspect_analysis_view, avoiding oversized complete delivery. Read exact imports/exports or WAT pages with inspect_analysis_view using normalized_result.parent_evidence_id. Delivery selection does not change analysis, provider inputs or source identity. Adjust command_timeout_ms for each WABT command (default 30000 ms), separately from the client deadline; input/output safety limits stay fixed. Optionally associate selected JavaScript glue literals with explicit local candidates while preserving ambiguous URL/basename matches. Configure absolute REA_WABT_BIN_DIRECTORY. No installation, implicit fetch, WASM execution or original-source claim.',
inputSchema: wasmArtifactInspectionInputSchema,
outputSchema: analysisDetailOutputSchemaOf(
wasmArtifactSchema,
+12
View File
@@ -11,6 +11,9 @@ const artifact = z.strictObject({
sha256: digest,
bytes: z.number().int().nonnegative(),
});
/** Default execution budget; upper bound is the runtime's signed 32-bit timer range. */
export const DEFAULT_WASM_COMMAND_TIMEOUT_MS = 30_000;
export const MAX_WASM_COMMAND_TIMEOUT_MS = 2_147_483_647;
/** All artifacts are explicitly selected local files; references never trigger acquisition. */
export const inspectWasmArtifactInputSchema = z.strictObject({
path: localPathStringSchema.describe(
@@ -18,6 +21,15 @@ export const inspectWasmArtifactInputSchema = z.strictObject({
),
glue_paths: z.array(localPathStringSchema).max(16).default([]),
candidate_paths: z.array(localPathStringSchema).max(16).default([]),
command_timeout_ms: z
.number()
.int()
.min(1)
.max(MAX_WASM_COMMAND_TIMEOUT_MS)
.exactOptional()
.describe(
`Deadline in milliseconds for each WABT version/analysis command (default ${String(DEFAULT_WASM_COMMAND_TIMEOUT_MS)}), not the whole inspection. Allow additional client time for all commands, file checks and cleanup. Input/output safety limits remain fixed.`,
),
});
/** Upstream output stays intact; import/export names are never split on ambiguous punctuation. */
export const wasmArtifactSchema = z
+95 -16
View File
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { access, chmod, readFile, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { expect, it, onTestFinished, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { WabtArtifactProvider } from "./WabtArtifactProvider.js";
import { wasmArtifactSchema } from "../domain/wasm/wasmArtifact.js";
@@ -194,6 +194,20 @@ const assertModeOutcome = (
result: Awaited<ReturnType<WabtArtifactProvider["inspect"]>>,
path: string,
): void => {
if (mode.startsWith("invalid"))
expect(result).toMatchObject({
error: {
partialObservation: {
parameters: { command_timeout_ms: 42_000 },
normalized_result: {
tool_profile: {
timeout_ms: 42_000,
output_budget_bytes: WABT_LIMITS.outputBytes,
},
},
},
},
});
if (mode === "success")
expect(result).toMatchObject({
ok: true,
@@ -228,6 +242,25 @@ const assertModeOutcome = (
});
};
const assertValidationCleanupRecovery = async (
provider: WabtArtifactProvider,
result: Awaited<ReturnType<WabtArtifactProvider["inspect"]>>,
cleanupAttempts: () => number,
): Promise<void> => {
expect(result).toMatchObject({ error: { cleanupIncomplete: true } });
await provider.close();
expect(cleanupAttempts()).toBe(2);
if (result.ok) throw new Error("Expected validation failure");
expect(result.error.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
details: {
captured_output: { stderr: "module.wasm: invalid bytes\n" },
cleanup_history: expect.any(Object),
},
});
};
it("returns actionable unavailable without creating a workspace when tools are absent", async () => {
const provider = new WabtArtifactProvider({}, undefined, () => {
throw new Error("Must not allocate");
@@ -537,6 +570,60 @@ const launchWabtOutcome = async (
return launched;
};
it("enforces a selected command deadline and completes a corrected retry after cleanup", async () => {
const fixture = await createWabtArtifactFixture();
let first = true;
let runtimePath = "";
const provider = new WabtArtifactProvider(
{ REA_WABT_BIN_DIRECTORY: fixture.selectedRoot },
async (input) => {
if (first) {
first = false;
return spawnOwnedProviderProcess({
...input,
command: process.execPath,
arguments: [
"-e",
'setTimeout(() => process.stdout.write("1.0.42\\n"), 250)',
],
expectedCommand: null,
});
}
return launchWabtOutcome(input, "success", {
...fixture,
controller: new AbortController(),
});
},
async () => {
const root = await PrivateRuntimeRoot.create({
prefix: "rea-wabt-budget-",
});
runtimePath = root.path;
return root;
},
);
onTestFinished(() => provider.close());
const input = { path: fixture.path, glue_paths: [], candidate_paths: [] };
const timed = await provider.inspect({ ...input, command_timeout_ms: 20 });
if (timed.ok) throw new Error("Selected 20 ms deadline was not enforced");
expect(projectAnalysisError(timed.error)).toMatchObject({
code: "provider_timeout",
details: { timeout_ms: 20 },
remediation: { action: expect.stringContaining("command_timeout_ms") },
});
expect(timed.error.cleanupIncomplete).toBe(false);
await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" });
const corrected = await provider.inspect({
...input,
command_timeout_ms: 2_000,
});
if (!corrected.ok) throw corrected.error;
const report = wasmArtifactSchema.parse(corrected.value.result);
expect(report.tool_profile.timeout_ms).toBe(2_000);
expect(report.limitations.join(" ")).not.toContain("30-second");
await expect(readFile(fixture.path)).resolves.toEqual(fixture.bytes);
});
const glueSourcesForOutcome = (mode: OutcomeMode) => {
const literal = 'fetch("./selected.wasm");';
return mode === "success"
@@ -636,6 +723,7 @@ const runOwnedArtifactOutcome = async (
path,
glue_paths: glueInputs.map(({ path }) => path),
candidate_paths: [],
...(mode.startsWith("invalid") ? { command_timeout_ms: 42_000 } : {}),
},
{ signal: controller.signal },
);
@@ -661,21 +749,12 @@ const runOwnedArtifactOutcome = async (
cleanupAttempts: () => cleanupAttempts,
cancellationReason: controller.signal.reason,
});
if (mode === "invalid-close-failure") {
expect(result).toMatchObject({ error: { cleanupIncomplete: true } });
await provider.close();
expect(cleanupAttempts).toBe(2);
if (!result.ok) {
expect(result.error.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
details: {
captured_output: { stderr: "module.wasm: invalid bytes\n" },
cleanup_history: expect.any(Object),
},
});
}
}
if (mode === "invalid-close-failure")
await assertValidationCleanupRecovery(
provider,
result,
() => cleanupAttempts,
);
if (
mode === "snapshot-close-failure" ||
mode === "snapshot-read-failure-late-abort"
+20 -17
View File
@@ -33,6 +33,7 @@ import { createEvidence } from "../domain/evidence.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
wasmArtifactSchema,
DEFAULT_WASM_COMMAND_TIMEOUT_MS,
type InspectWasmArtifactInput,
} from "../domain/wasm/wasmArtifact.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -111,6 +112,8 @@ export class WabtArtifactProvider implements WasmArtifactPort {
let result: Result<WasmArtifactExecution, AnalysisError>;
let capturedOutput: AnalysisCapturedOutput | undefined;
let versionProbe: WabtVersionProbe | undefined;
const commandTimeoutMs =
input.command_timeout_ms ?? DEFAULT_WASM_COMMAND_TIMEOUT_MS;
try {
if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION);
const directory = this.environment.REA_WABT_BIN_DIRECTORY ?? "";
@@ -188,7 +191,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
hostEnvironment: this.environment,
},
{
timeoutMs: WABT_LIMITS.timeoutMs,
timeoutMs: commandTimeoutMs,
diagnosticBytes: WABT_LIMITS.outputBytes,
acceptNonZeroExit,
},
@@ -246,6 +249,15 @@ export class WabtArtifactProvider implements WasmArtifactPort {
command.version_banner = version.stdout.text;
versionProbe = undefined;
}
// Success and validation failure report the same admitted tool/execution facts.
const toolProfile = {
source: "https://github.com/WebAssembly/wabt",
release: WABT_VERSION,
source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48",
commands,
timeout_ms: commandTimeoutMs,
output_budget_bytes: WABT_LIMITS.outputBytes,
};
phase = "artifact";
const selected = await readStableArtifact(
input.path,
@@ -328,12 +340,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
result: {
artifact,
validation: "rejected-by-selected-profile",
tool_profile: {
source: "https://github.com/WebAssembly/wabt",
release: WABT_VERSION,
source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48",
commands,
},
tool_profile: toolProfile,
exit_code: validation.exitCode ?? null,
stdout: validation.stdout.text,
stderr: validation.stderr.text,
@@ -405,7 +412,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
"Imports and exports retain exact top-level WABT WAT forms. Names remain escaped producer text; unescaped objdump display names are not parsed as unambiguous identifiers.",
"JavaScript references are AST-observed static literals only. Candidate matches are associations, not proven data flow, URL-to-file identity or runtime loading. Computed references and aliases are not resolved.",
"No target execution or implicit fetch. WABT tools are trusted caller-supplied executables; SHA-256 identifies their bytes, not publisher authenticity. No OS memory sandbox is claimed.",
"Each subprocess has a 30-second deadline and 32 MiB combined output budget. Oversized output fails instead of producing truncated success.",
`Each WABT command has a ${String(commandTimeoutMs)} ms deadline and a fixed 32 MiB combined output budget. The deadline is not a whole-inspection budget. Oversized output fails instead of producing truncated success.`,
];
const report = wasmArtifactSchema.parse({
artifact,
@@ -421,14 +428,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
sha256: digest(wat.stdout.text),
representation: "decoded-wat",
},
tool_profile: {
source: "https://github.com/WebAssembly/wabt",
release: WABT_VERSION,
source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48",
commands,
timeout_ms: WABT_LIMITS.timeoutMs,
output_budget_bytes: WABT_LIMITS.outputBytes,
},
tool_profile: toolProfile,
candidates,
glue,
runtime_execution: "not-performed",
@@ -447,7 +447,10 @@ export class WabtArtifactProvider implements WasmArtifactPort {
}),
);
} catch (cause: unknown) {
const failure = wabtFailure(cause, phase, input.path, versionProbe);
const failure = wabtFailure(cause, phase, input.path, {
commandTimeoutMs,
...(versionProbe === undefined ? {} : { versionProbe }),
});
runtime?.retainCommandFailure(cause, failure, this.#resources);
result = err(
failure instanceof AnalysisOutputError &&
+96 -6
View File
@@ -5,6 +5,98 @@ import { wabtFailure } from "./WabtFailures.js";
import { WABT_LIMITS } from "./WabtRelease.js";
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
it.each(["configuration", "producer"] as const)(
"retains timeout recovery under %s cleanup failure without making cleanup retryable",
(phase) => {
const cause = new OwnedCommandFailure(
"timeout",
"Command deadline elapsed.",
null,
"owned stop unconfirmed",
undefined,
["pid:owned-fixture"],
);
const failure = wabtFailure(cause, phase, "/module.wasm", {
commandTimeoutMs: 1234,
});
expect(failure.cause).toBe(cause);
const projected = projectAnalysisError(failure);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
retryable: false,
details: {
resources: ["pid:owned-fixture"],
diagnostics: {
previous_error: {
code: "provider_timeout",
details: { timeout_ms: 1234 },
recovery: { kind: "inspect_timeout_state", timeout_ms: 1234 },
remediation: {
action: expect.stringContaining("command_timeout_ms above 1234"),
},
},
},
},
});
expect(projected.remediation.action).not.toContain("Increase");
},
);
it("retains timeout command output once, including when cleanup wraps its recovery", () => {
for (const cleanup of [null, "stop unconfirmed"]) {
const cause = new OwnedCommandFailure(
"timeout",
"deadline",
{
stdout: { text: "partial-owned-output", bytes: 20, observedBytes: 20 },
stderr: { text: "", bytes: 0, observedBytes: 0 },
exitCode: null,
signal: null,
},
cleanup,
);
const projected = projectAnalysisError(
wabtFailure(cause, "producer", "/module.wasm", {
commandTimeoutMs: 1234,
}),
);
expect(
JSON.stringify(projected).split("partial-owned-output"),
).toHaveLength(2);
expect(projected.code).toBe(
cleanup === null ? "provider_timeout" : "cleanup_incomplete",
);
}
});
it.each([1234, 2_147_483_647])(
"reports the actual %i ms command budget without an unsupported higher timer",
(commandTimeoutMs) => {
const projected = projectAnalysisError(
wabtFailure(
new OwnedCommandFailure("timeout", "deadline"),
"producer",
"/module.wasm",
{ commandTimeoutMs },
),
);
expect(projected).toMatchObject({
code: "provider_timeout",
details: { timeout_ms: commandTimeoutMs },
recovery: { kind: "inspect_timeout_state", timeout_ms: commandTimeoutMs },
});
expect(projected.remediation.action).toContain("no server restart");
expect(projected.remediation.action).toContain("cleanup");
if (commandTimeoutMs === 2_147_483_647) {
expect(projected.remediation.action).toContain(
"no higher command deadline",
);
expect(projected.remediation.action).not.toContain("Increase");
} else
expect(projected.remediation.action).toContain("--command-timeout-ms");
},
);
it.each([
{ reason: "timeout" as const, cleanup: null, code: "provider_timeout" },
{ reason: "cancelled" as const, cleanup: null, code: "cancelled" },
@@ -28,12 +120,10 @@ it.each([
tool: "wasm2wat",
expected_version: "1.0.42" as const,
};
const failure = wabtFailure(
cause,
"configuration",
"/selected.wasm",
probe,
);
const failure = wabtFailure(cause, "configuration", "/selected.wasm", {
commandTimeoutMs: 1234,
versionProbe: probe,
});
const original = wabtFailure(cause, "configuration", "/selected.wasm");
expect(failure._tag).toBe(original._tag);
expect(failure.cleanupIncomplete).toBe(original.cleanupIncomplete);
+42 -16
View File
@@ -6,6 +6,7 @@ import {
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
AnalysisCancelledError,
AnalysisArtifactChangedError,
@@ -19,6 +20,10 @@ import {
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js";
import {
DEFAULT_WASM_COMMAND_TIMEOUT_MS,
MAX_WASM_COMMAND_TIMEOUT_MS,
} from "../domain/wasm/wasmArtifact.js";
import {
WABT_LIMITS,
WABT_PROVIDER_IDENTITY,
@@ -35,6 +40,11 @@ export type WabtVersionProbe = {
readonly expected_version: typeof WABT_VERSION;
};
type WabtFailureContext = {
readonly commandTimeoutMs: number;
readonly versionProbe?: WabtVersionProbe;
};
/** Configuration is captured by the server; fixing files and changing its environment are distinct repairs. */
export const wabtConfigurationRecoveryAction = (cause?: unknown): string => {
const code =
@@ -67,9 +77,9 @@ export const wabtFailure = (
cause: unknown,
phase: "configuration" | "artifact" | "producer",
path: string,
versionProbe?: WabtVersionProbe,
execution?: WabtFailureContext,
): AnalysisError => {
const failure = classifyWabtFailure(cause, phase, path, versionProbe);
const failure = classifyWabtFailure(cause, phase, path, execution);
bindArtifactFileCleanup(cause, failure);
return failure;
};
@@ -78,8 +88,9 @@ const classifyWabtFailure = (
cause: unknown,
phase: "configuration" | "artifact" | "producer",
path: string,
versionProbe: WabtVersionProbe | undefined,
execution: WabtFailureContext | undefined,
): AnalysisError => {
const versionProbe = execution?.versionProbe;
if (cause instanceof AnalysisError) return cause;
const artifactCleanup =
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
@@ -113,6 +124,24 @@ const classifyWabtFailure = (
...(capturedOutput === undefined ? {} : { capturedOutput }),
...(versionProbe === undefined ? {} : { diagnostics: versionProbe }),
};
// Classify once before cleanup takes precedence, so the primary remains actionable.
const timeoutMs =
execution?.commandTimeoutMs ?? DEFAULT_WASM_COMMAND_TIMEOUT_MS;
const timeout =
cause.reason === "timeout"
? new AnalysisTimeoutError("inspect_wasm_artifact", timeoutMs, {
...options,
// The cleanup wrapper retains output once; its nested primary needs only recovery facts.
capturedOutput:
cause.cleanupFailure === null ? capturedOutput : undefined,
userMessage: `A WABT command reached its ${String(timeoutMs)} ms server/provider deadline (command_timeout_ms), not the whole-inspection or MCP client deadline.`,
remediationAction: `${
timeoutMs < MAX_WASM_COMMAND_TIMEOUT_MS
? `Increase command_timeout_ms above ${String(timeoutMs)} (integer milliseconds, maximum ${String(MAX_WASM_COMMAND_TIMEOUT_MS)}), or use rea inspect-wasm-artifact <path> --command-timeout-ms <milliseconds> on the CLI.`
: `command_timeout_ms is already at the maximum supported ${String(MAX_WASM_COMMAND_TIMEOUT_MS)} ms; no higher command deadline is supported. Report this blocker or select another suitable complete artifact with caller approval.`
} This is a per-call setting; no server restart is needed. Allow the MCP client deadline to cover all version/analysis commands, file checks, cleanup and delivery; increasing only that client deadline does not change this budget. Input/output safety limits stay fixed, and detail: summary reduces delivery size, not analysis work. Resolve any incomplete cleanup before retrying; do not repeat the unchanged failed call.`,
})
: undefined;
if (cause.cleanupFailure !== null)
return new ProviderCleanupError(
WABT_PROVIDER_IDENTITY.id,
@@ -121,13 +150,15 @@ const classifyWabtFailure = (
...versionProbe,
reason: cause.cleanupFailure,
previous_error:
cause.reason === "output-limit"
? {
failure_kind: "output-limit",
message: OUTPUT_LIMIT_REASON,
max_output_bytes: WABT_LIMITS.outputBytes,
}
: reason,
timeout !== undefined
? projectAnalysisError(timeout)
: cause.reason === "output-limit"
? {
failure_kind: "output-limit",
message: OUTPUT_LIMIT_REASON,
max_output_bytes: WABT_LIMITS.outputBytes,
}
: reason,
...(capturedOutput === undefined
? {}
: { captured_output: capturedOutput }),
@@ -136,12 +167,7 @@ const classifyWabtFailure = (
);
if (cause.reason === "cancelled")
return new AnalysisCancelledError("inspect_wasm_artifact", options);
if (cause.reason === "timeout")
return new AnalysisTimeoutError(
"inspect_wasm_artifact",
WABT_LIMITS.timeoutMs,
options,
);
if (timeout !== undefined) return timeout;
if (phase !== "configuration" && cause.reason === "output-limit")
return new AnalysisResourceConstraintError(
"inspect_wasm_artifact",
-1
View File
@@ -9,5 +9,4 @@ export const WABT_LIMITS = {
inputBytes: 8 * 1024 * 1024,
toolBytes: 64 * 1024 * 1024,
outputBytes: 32 * 1024 * 1024,
timeoutMs: 30_000,
} as const;
+20 -2
View File
@@ -164,10 +164,14 @@ it.each(["bin", "hop"])(
it("advertises exact valid schemas and records inline WASM interface evidence without a binary target", async () => {
const value = wasmArtifactFixture();
value.tool_profile.timeout_ms = 42_000;
const close = vi.fn(() => Promise.resolve());
const service = new WasmArtifactService({
close,
inspect: () => Promise.resolve(ok(wasmArtifactExecution(value))),
inspect: (input) => {
expect(input.command_timeout_ms).toBe(42_000);
return Promise.resolve(ok(wasmArtifactExecution(value)));
},
});
const session = createTestBinarySession(() => {
throw new Error("deep provider must not start");
@@ -184,6 +188,20 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi
);
const { ajv, inputSchema } = schemas;
expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true);
for (const command_timeout_ms of [1, 42_000, 2_147_483_647])
expect(
ajv.validate(inputSchema, {
path: value.artifact.path,
command_timeout_ms,
}),
).toBe(true);
for (const command_timeout_ms of [0, -1, 1.5, 2_147_483_648, "1000", null])
expect(
ajv.validate(inputSchema, {
path: value.artifact.path,
command_timeout_ms,
}),
).toBe(false);
expect(
ajv.validate(inputSchema, {
path: value.artifact.path,
@@ -193,7 +211,7 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi
).toBe(false);
const response = await client.callTool({
name: "inspect_wasm_artifact",
arguments: { path: value.artifact.path },
arguments: { path: value.artifact.path, command_timeout_ms: 42_000 },
});
assertRecordedMcpEvidence(
response,