diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index b8cd0582ff..12bedbd379 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -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 `, 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. diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index 7b9c08071b..38ee229bd4 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -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", diff --git a/src/application/wasm/WasmArtifactService.test.ts b/src/application/wasm/WasmArtifactService.test.ts index afb1e80101..4933fa9168 100644 --- a/src/application/wasm/WasmArtifactService.test.ts +++ b/src/application/wasm/WasmArtifactService.test.ts @@ -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) => { diff --git a/src/application/wasm/WasmArtifactService.ts b/src/application/wasm/WasmArtifactService.ts index 07aed7bd0d..10927acf0e 100644 --- a/src/application/wasm/WasmArtifactService.ts +++ b/src/application/wasm/WasmArtifactService.ts @@ -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) || diff --git a/src/cli/wasmCommands.ts b/src/cli/wasmCommands.ts index 1a3217c6ac..cee9d5d571 100644 --- a/src/cli/wasmCommands.ts +++ b/src/cli/wasmCommands.ts @@ -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), ), diff --git a/src/contracts/wasm/wasmToolContracts.ts b/src/contracts/wasm/wasmToolContracts.ts index cf05166d29..06abd7d784 100644 --- a/src/contracts/wasm/wasmToolContracts.ts +++ b/src/contracts/wasm/wasmToolContracts.ts @@ -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, diff --git a/src/domain/wasm/wasmArtifact.ts b/src/domain/wasm/wasmArtifact.ts index a15735125d..52abf38db2 100644 --- a/src/domain/wasm/wasmArtifact.ts +++ b/src/domain/wasm/wasmArtifact.ts @@ -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 diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts index 16462cc0fc..546ce0844e 100644 --- a/src/wasm/WabtArtifactProvider.test.ts +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -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>, 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>, + cleanupAttempts: () => number, +): Promise => { + 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" diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index c9901d4c3c..95b30b77dc 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -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; 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 && diff --git a/src/wasm/WabtFailures.test.ts b/src/wasm/WabtFailures.test.ts index e58363036d..648d8fc999 100644 --- a/src/wasm/WabtFailures.test.ts +++ b/src/wasm/WabtFailures.test.ts @@ -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); diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts index 5a089befeb..9c9979f2f5 100644 --- a/src/wasm/WabtFailures.ts +++ b/src/wasm/WabtFailures.ts @@ -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 --command-timeout-ms 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", diff --git a/src/wasm/WabtRelease.ts b/src/wasm/WabtRelease.ts index 9e553bd4a0..2280106fa9 100644 --- a/src/wasm/WabtRelease.ts +++ b/src/wasm/WabtRelease.ts @@ -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; diff --git a/tests/boundary/mcp/wasmArtifactMcp.test.ts b/tests/boundary/mcp/wasmArtifactMcp.test.ts index 1be804f61c..a2d394a2d7 100644 --- a/tests/boundary/mcp/wasmArtifactMcp.test.ts +++ b/tests/boundary/mcp/wasmArtifactMcp.test.ts @@ -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,