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:
1 parent
ea24207b57
commit
a5f982de8d
13 files changed
+375
-61
No files matched your search
+21
-1
@@ -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.
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user