diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 6583fb15a2..f27a38db40 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -167,7 +167,9 @@ explicit path or endpoint and do not need it. ambiguity. No implicit fetch or target execution; WAT is not original source. See the [WASM guide](https://github.com/morluto/rea/blob/main/docs/wasm-artifacts.md). - Explicit local EVM bytecode carrier: `inspect_evm_interface` with caller-selected - `raw` or `hex` encoding. Preserve carrier/decoded digests and treat selectors, + `raw` or `hex` encoding. The owned worker deadline is `timeout_ms` (default + 30000 ms, range 1..2147483647, next call). It does not raise the fixed byte, + address-space, or CPU allowances. Preserve carrier/decoded digests and treat selectors, argument strings and mutability as inferred candidates; this performs no chain lookup or target execution. For the complete inferred interface without hex or duplicate stdout, discover support and select `detail: "summary"`; complete diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md index 1e28542fb7..2b9141f933 100644 --- a/docs/evm-bytecode.md +++ b/docs/evm-bytecode.md @@ -106,7 +106,11 @@ CLI keeps its existing complete result. Inputs are limited to 4 MiB, complete replies to 16 MiB and combined diagnostic output to 1 MiB. An owned worker has a 256 MiB JavaScript heap, 3 GiB virtual -address-space limit, 30 CPU seconds and a 30-second command deadline. Virtual +address-space limit and 30 CPU seconds. The worker command deadline is +`timeout_ms` (default 30000 ms, integer 1 through 2147483647) on the next call, +or `--timeout-ms` on `rea inspect-evm-interface`. Omission keeps 30000 ms. +Those byte, address-space, and CPU ceilings have no runtime override, and +`timeout_ms` does not raise them. Virtual address space differs from resident memory. The worker retains inherited tighter soft/hard limits, reports its configured soft limits inline and leaves hard limits unchanged. WASM trap reservations are disabled diff --git a/src/application/evm/EvmInterfaceEvidence.ts b/src/application/evm/EvmInterfaceEvidence.ts index 38fe9ea70b..c4bd738cbc 100644 --- a/src/application/evm/EvmInterfaceEvidence.ts +++ b/src/application/evm/EvmInterfaceEvidence.ts @@ -9,7 +9,13 @@ export const evmInterfaceEvidence = ( ) => createEvidence(execution.subject, execution.provider, { operation: "inspect_evm_interface", - parameters: input, + parameters: { + path: input.path, + encoding: input.encoding, + ...(input.timeout_ms === undefined + ? {} + : { timeout_ms: input.timeout_ms }), + }, result: execution.result, ...(execution.rawResult === null ? {} : { rawResult: execution.rawResult }), confidence: "inferred", diff --git a/src/application/evm/EvmInterfaceService.test.ts b/src/application/evm/EvmInterfaceService.test.ts index 45133089b5..70adf734a5 100644 --- a/src/application/evm/EvmInterfaceService.test.ts +++ b/src/application/evm/EvmInterfaceService.test.ts @@ -18,6 +18,8 @@ it.each([ { path: "/selected", encoding: "auto" }, { path: "/selected" }, { path: "/selected", encoding: "raw", approval: true }, + { path: "/selected", encoding: "hex", timeout_ms: 0 }, + { path: "/selected", encoding: "hex", timeout_ms: 2_147_483_648 }, ])("rejects invalid selection before effects: %j", async (input) => { const service = new EvmInterfaceService({ inspect: () => { @@ -140,6 +142,42 @@ it("keeps malformed interface output ahead of a late cancellation", async () => if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); }); +it("keeps an omitted timeout at the default and records a selected one", async () => { + const value = evmInterfaceFixture(); + let seen: unknown; + const service = new EvmInterfaceService({ + inspect: (input) => { + seen = input; + return Promise.resolve(ok(evmInterfaceExecution(value))); + }, + }); + const omitted = await service.inspect({ + path: value.artifact.path, + encoding: "hex", + }); + expect(seen).toEqual({ path: value.artifact.path, encoding: "hex" }); + expect(omitted.ok).toBe(true); + if (omitted.ok) + expect(parseEvidence(omitted.value).parameters).not.toHaveProperty( + "timeout_ms", + ); + const selected = await service.inspect({ + path: value.artifact.path, + encoding: "hex", + timeout_ms: 45_000, + }); + expect(seen).toEqual({ + path: value.artifact.path, + encoding: "hex", + timeout_ms: 45_000, + }); + expect(selected.ok).toBe(true); + if (selected.ok) + expect(parseEvidence(selected.value).parameters).toMatchObject({ + timeout_ms: 45_000, + }); +}); + it.each([null, "caller stopped", { requested: "stop" }])( "preserves the exact admission reason without invoking the provider: %j", async (reason) => { diff --git a/src/cli/evmCommands.ts b/src/cli/evmCommands.ts index a9cbe82cfb..f5ac166cb6 100644 --- a/src/cli/evmCommands.ts +++ b/src/cli/evmCommands.ts @@ -6,6 +6,7 @@ import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { inspectEvmInterfaceInputSchema } from "../domain/evm/evmInterface.js"; /** Resolve operator paths and preserve explicit encoding through the shared workflow. */ export const registerEvmCommands = ( @@ -22,7 +23,10 @@ export const registerEvmCommands = ( .enum(["raw", "hex"]) .describe("Selected carrier representation"), }), - run: ({ args }) => + options: z.object({ + timeoutMs: inspectEvmInterfaceInputSchema.shape.timeout_ms, + }), + run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, evmInterfaceModule.cliCommand, () => finishAnalysisCommand( @@ -31,6 +35,9 @@ export const registerEvmCommands = ( { path: absoluteCallerPath(args.path), encoding: args.encoding, + ...(options.timeoutMs === undefined + ? {} + : { timeout_ms: options.timeoutMs }), }, { signal }, ), diff --git a/src/contracts/evm/evmToolContracts.ts b/src/contracts/evm/evmToolContracts.ts index 197fee77bc..9b7d4e3c53 100644 --- a/src/contracts/evm/evmToolContracts.ts +++ b/src/contracts/evm/evmToolContracts.ts @@ -23,7 +23,7 @@ export const INSPECT_EVM_INTERFACE_CONTRACT = { ...toolContractMetadata("inspect_evm_interface"), kind: "artifact-provider", description: - 'Inspect explicit local flat EVM bytecode without target execution or chain/RPC access. Select raw or hex encoding explicitly. Omitted or complete detail returns original carrier and decoded identities, full bytecode and all inferred interface candidates inline. For all inferred selectors/offsets/arguments/mutability and identities without bytecode hex or duplicated stdout, select detail: "summary" on this first call: it retains unchanged complete Evidence and returns a distinct linked inspect_analysis_view summary. Read original function rows or bytecode_hex/stdout/stderr text pages using view kind evm and normalized_result.parent_evidence_id; original complete Evidence remains accessible. Bytecode kind, hardfork, deployed authenticity and discovery completeness stay unknown; summary coverage covers only producer-returned candidates. Unchanged bundled EVMole 0.9.3/WASM in an owned bounded worker is initially real-verified on Linux x64; EOF-style EF00 containers are unsupported. No signature lookup, wallet action or transaction broadcast.', + 'Inspect explicit local flat EVM bytecode without target execution or chain/RPC access. Select raw or hex encoding explicitly. Omitted or complete detail returns original carrier and decoded identities, full bytecode and all inferred interface candidates inline. For all inferred selectors/offsets/arguments/mutability and identities without bytecode hex or duplicated stdout, select detail: "summary" on this first call: it retains unchanged complete Evidence and returns a distinct linked inspect_analysis_view summary. Read original function rows or bytecode_hex/stdout/stderr text pages using view kind evm and normalized_result.parent_evidence_id; original complete Evidence remains accessible. Bytecode kind, hardfork, deployed authenticity and discovery completeness stay unknown; summary coverage covers only producer-returned candidates. Unchanged bundled EVMole 0.9.3/WASM in an owned bounded worker is initially real-verified on Linux x64; EOF-style EF00 containers are unsupported. Adjust timeout_ms (default 30000 ms, integer 1..2147483647) for the owned worker command on the next call; omitted timeout_ms keeps 30000 ms. Fixed byte, address-space, and CPU allowances stay unchanged. No signature lookup, wallet action or transaction broadcast.', inputSchema: evmInterfaceInspectionInputSchema, outputSchema: analysisDetailOutputSchemaOf( evmInterfaceSchema, diff --git a/src/domain/evm/evmInterface.ts b/src/domain/evm/evmInterface.ts index bde6ecba7c..d1c7ab16bf 100644 --- a/src/domain/evm/evmInterface.ts +++ b/src/domain/evm/evmInterface.ts @@ -5,6 +5,10 @@ import { solidityMetadataSchema } from "./solidityMetadata.js"; const byteOffset = z.number().int().nonnegative(); const sha256 = z.string().regex(/^[a-f0-9]{64}$/); +/** Owned EVMole worker deadline. Omission keeps this value. */ +export const DEFAULT_EVM_TIMEOUT_MS = 30_000; +/** Largest delay supported without overflowing Node timers. */ +export const MAX_EVM_TIMEOUT_MS = 2_147_483_647; /** Select the carrier representation explicitly; no RPC or deployed-contract lookup is implied. */ export const inspectEvmInterfaceInputSchema = z.strictObject({ @@ -16,6 +20,15 @@ export const inspectEvmInterfaceInputSchema = z.strictObject({ .describe( "Raw bytes or UTF-8 hex with optional 0x prefix and outer ASCII whitespace", ), + timeout_ms: z + .number() + .int() + .min(1) + .max(MAX_EVM_TIMEOUT_MS) + .optional() + .describe( + `Deadline in milliseconds for the owned EVMole worker command (default ${String(DEFAULT_EVM_TIMEOUT_MS)}), not carrier acquisition or final reply reads. An integer from 1 to ${String(MAX_EVM_TIMEOUT_MS)} applies on the next call. Fixed input, reply, diagnostic, address-space, and CPU allowances stay unchanged.`, + ), }); /** Recovered interface candidates are inferences, independently of observed byte identity. */ diff --git a/src/domain/native/nativeAotPe.budget.test.ts b/src/domain/native/nativeAotPe.budget.test.ts index 7e5c02af3b..74a86a7428 100644 --- a/src/domain/native/nativeAotPe.budget.test.ts +++ b/src/domain/native/nativeAotPe.budget.test.ts @@ -104,6 +104,39 @@ describe("NativeAOT parser report admission", () => { /no unique System\.Object.*fully examined/iu, ); }); + + it("explains a hydration work budget on the partial report", () => { + const bytes = nativeAotPeFixture(); + // Extend DEHYDRATED_DATA with a zero-fill larger than the work left after + // the directory is found, so hydrate throws into the partial report. + const end = bytes.readBigUInt64LE(0x620); + bytes.writeBigUInt64LE(end + 3n, 0x620); + bytes.writeUInt8(0xf9, 0x704); + Buffer.from([0x3c, 0x42, 0x0f]).copy(bytes, 0x705); + const summary = parseNativeAotPe( + bytes, + nativeAotPeDigest(bytes), + REPORT_BUDGET, + undefined, + { + sourceBytes: bytes.length, + workUnits: 5000, + workingMemoryBytes: 1024 * 1024, + }, + ).summary; + const visible = [summary.reason ?? "", ...summary.diagnostics].join("\n"); + expect(summary.status).toBe("partial"); + expect(visible).toContain("NativeAOT parser work budget is exhausted"); + expect(visible).toContain( + "This release has no runtime override for that NativeAOT parser budget", + ); + expect(visible).toContain( + "Repeating the unchanged request fails the same way", + ); + expect(summary.diagnostics.join("\n")).toContain( + "Read-only hydration stopped:", + ); + }); }); describe("NativeAOT detail and summary byte accounting", () => { diff --git a/src/domain/native/nativeAotPe.ts b/src/domain/native/nativeAotPe.ts index 7edac548a5..90f5399432 100644 --- a/src/domain/native/nativeAotPe.ts +++ b/src/domain/native/nativeAotPe.ts @@ -73,6 +73,34 @@ class NativeAotParserBudget { } } +/** Hydration stops by throwing; other scans record truncation and continue. */ +class NativeAotParserBudgetError extends Error { + constructor(readonly budget: "work" | "working-memory") { + super( + budget === "work" + ? "NativeAOT parser work budget is exhausted" + : "NativeAOT working-memory budget is exhausted", + ); + this.name = "NativeAotParserBudgetError"; + } +} + +const parserBudgetExhausted = ( + budget: NativeAotParserBudgetError["budget"], +): never => { + throw new NativeAotParserBudgetError(budget); +}; + +const NATIVE_AOT_PARSER_BUDGET_RECOVERY = + "This release has no runtime override for that NativeAOT parser budget. Repeating the unchanged request fails the same way."; + +const nativeAotHydrationFailure = (cause: unknown): string => + cause instanceof NativeAotParserBudgetError + ? `${cause.message}. ${NATIVE_AOT_PARSER_BUDGET_RECOVERY}` + : cause instanceof Error + ? cause.message + : "DEHYDRATED_DATA could not be hydrated"; + type Section = { readonly name: string; readonly rva: number; @@ -301,8 +329,7 @@ export const nativeAotSnapshotOverCapacity = ( { format: "dotnet-nativeaot", status: "partial", - reason: - "The captured PE exceeds the read-only NativeAOT snapshot byte budget.", + reason: `The captured PE exceeds the read-only NativeAOT snapshot byte budget of ${String(limits.sourceBytes)} bytes. This release has no runtime override.`, analysis_artifact_sha256: sha256, analysis_mode: "read-only-derived-overlay", header_address: null, @@ -832,7 +859,7 @@ const hydrate = ( ); let output = Buffer.alloc(Math.min(outputLimit, 64 * 1024)); if (!budget.reserveWorkingMemory(output.byteLength)) - throw new Error("NativeAOT working-memory budget is exhausted"); + parserBudgetExhausted("working-memory"); let outputLength = 0; let cursor = 4; const ensureOutput = (length: number): number => { @@ -842,7 +869,7 @@ const hydrate = ( length > outputLimit - outputLength ) { if (length > budget.limits.workUnits - budget.workUnits) - throw new Error("NativeAOT parser work budget is exhausted"); + parserBudgetExhausted("work"); throw new Error("Hydrated metadata exceeds its mapped PE section"); } const start = outputLength; @@ -856,7 +883,7 @@ const hydrate = ( ), ); if (!budget.reserveWorkingMemory(capacity)) - throw new Error("NativeAOT working-memory budget is exhausted"); + parserBudgetExhausted("working-memory"); const expanded = Buffer.allocUnsafe(capacity); output.copy(expanded, 0, 0, outputLength); budget.releaseWorkingMemory(output.byteLength); @@ -866,19 +893,16 @@ const hydrate = ( return start; }; const append = (chunk: Buffer): void => { - if (!budget.consumeWork(chunk.length)) - throw new Error("NativeAOT parser work budget is exhausted"); + if (!budget.consumeWork(chunk.length)) parserBudgetExhausted("work"); chunk.copy(output, ensureOutput(chunk.length)); }; const appendZero = (length: number): void => { - if (!budget.consumeWork(length)) - throw new Error("NativeAOT parser work budget is exhausted"); + if (!budget.consumeWork(length)) parserBudgetExhausted("work"); const start = ensureOutput(length); output.fill(0, start, outputLength); }; const appendRel = (target: bigint): void => { - if (!budget.consumeWork(4)) - throw new Error("NativeAOT parser work budget is exhausted"); + if (!budget.consumeWork(4)) parserBudgetExhausted("work"); const delta = target - (base + BigInt(outputLength)); if (delta < -0x80000000n || delta > 0x7fffffffn) throw new Error("Hydrated relative pointer does not fit int32"); @@ -886,8 +910,7 @@ const hydrate = ( output.writeInt32LE(Number(delta), offset); }; const appendPtr = (target: bigint): void => { - if (!budget.consumeWork(8)) - throw new Error("NativeAOT parser work budget is exhausted"); + if (!budget.consumeWork(8)) parserBudgetExhausted("work"); const offset = ensureOutput(8); output.writeBigUInt64LE(target, offset); }; @@ -900,8 +923,7 @@ const hydrate = ( }; try { while (cursor < first.length) { - if (!budget.consumeWork(1)) - throw new Error("NativeAOT parser work budget is exhausted"); + if (!budget.consumeWork(1)) parserBudgetExhausted("work"); const encoded = first[cursor]; if (encoded === undefined) break; cursor++; @@ -934,8 +956,7 @@ const hydrate = ( appendPtr(readFixup(payload)); break; case 4: - if (!budget.consumeWork(payload * 4)) - throw new Error("NativeAOT parser work budget is exhausted"); + if (!budget.consumeWork(payload * 4)) parserBudgetExhausted("work"); if ( payload > Math.floor((first.length - cursor) / 4) || payload > Math.floor((outputLimit - outputLength) / 4) @@ -953,8 +974,7 @@ const hydrate = ( } break; case 5: - if (!budget.consumeWork(payload * 4)) - throw new Error("NativeAOT parser work budget is exhausted"); + if (!budget.consumeWork(payload * 4)) parserBudgetExhausted("work"); if ( payload > Math.floor((first.length - cursor) / 4) || payload > Math.floor((outputLimit - outputLength) / 8) @@ -1354,13 +1374,12 @@ export const parseNativeAotPe = ( ); let overlay: ReturnType = null; let hydrationError: string | null = null; + let hydrationBudgetLimited = false; try { overlay = hydrate(image, dehydrated[0] as RtrSection, budget); } catch (cause: unknown) { - hydrationError = - cause instanceof Error - ? cause.message - : "DEHYDRATED_DATA could not be hydrated"; + hydrationBudgetLimited = cause instanceof NativeAotParserBudgetError; + hydrationError = nativeAotHydrationFailure(cause); } const discovered = discoverTables(image, overlay, budget); const frozen = directory.sections.find( @@ -1399,12 +1418,18 @@ export const parseNativeAotPe = ( frozen === undefined || frozen.end < frozenFirstCandidate + 14n ? 0 : Number((frozen.end - 14n - frozenFirstCandidate) / 8n + 1n); - const recoveryReason = + const discoveryReason = discovered.truncated && discovered.tables.includedCount === 0 ? "RTR 9.1 was found, but method-table discovery reached its resource budget before determining whether a unique System.Object root exists." : discovered.tables.includedCount === 0 ? "RTR 9.1 was found, but no unique System.Object method-table root could be recovered from the fully examined image pointers." : hydrationError; + const recoveryReason = + hydrationBudgetLimited && + hydrationError !== null && + discoveryReason !== hydrationError + ? `${discoveryReason} ${hydrationError}` + : discoveryReason; const reportLimitations = [ "Only x64 PE32+ with RTR 9.1 is supported.", "No Ghidra database types, labels, functions, or bytes are created or changed.", diff --git a/src/evm/EvmWorkerLimits.ts b/src/evm/EvmWorkerLimits.ts index 069152e2d3..235c0a651c 100644 --- a/src/evm/EvmWorkerLimits.ts +++ b/src/evm/EvmWorkerLimits.ts @@ -1,5 +1,5 @@ import { err, ok, type Result } from "../domain/result.js"; -import { EVM_INTERFACE_LIMITS } from "./EvmoleRelease.js"; +import { EVM_CPU_SECONDS, EVM_INTERFACE_LIMITS } from "./EvmoleRelease.js"; /** Selected worker soft limits to configure; inherited hard limits are left unchanged. */ export interface EvmWorkerLimits { @@ -19,7 +19,7 @@ export const selectEvmWorkerLimits = ( const selected: number[] = []; for (const [name, units, maximum] of [ ["Max address space", "bytes", EVM_INTERFACE_LIMITS.addressSpaceBytes], - ["Max cpu time", "seconds", 30], + ["Max cpu time", "seconds", EVM_CPU_SECONDS], ["Max file size", "bytes", EVM_INTERFACE_LIMITS.outputBytes], ] as const) { const entries = raw diff --git a/src/evm/EvmoleFailures.test.ts b/src/evm/EvmoleFailures.test.ts index 0a1f44fb2b..af800e1fa1 100644 --- a/src/evm/EvmoleFailures.test.ts +++ b/src/evm/EvmoleFailures.test.ts @@ -2,6 +2,11 @@ import { expect, it } from "vitest"; import { evmInterfaceFailure } from "./EvmoleFailures.js"; import { OwnedCommandFailure } from "../process/OwnedCommand.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + DEFAULT_EVM_TIMEOUT_MS, + MAX_EVM_TIMEOUT_MS, +} from "../domain/evm/evmInterface.js"; import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; const snapshot: ProviderProcessSnapshot = { @@ -43,6 +48,64 @@ it.each([ }, ); +it("names timeout_ms, the omitted default, and the caps it does not raise", () => { + const source = new OwnedCommandFailure( + "timeout", + "Command deadline elapsed.", + snapshot, + null, + ); + const projected = projectAnalysisError( + evmInterfaceFailure( + source, + "worker", + "/selected.hex", + undefined, + undefined, + undefined, + 45_000, + ), + ); + expect(projected).toMatchObject({ + code: "provider_timeout", + details: { timeout_ms: 45_000 }, + }); + expect(projected.message).toContain("timeout_ms"); + expect(projected.message).toContain(String(DEFAULT_EVM_TIMEOUT_MS)); + expect(projected.remediation.action).toContain("--timeout-ms"); + expect(projected.remediation.action).toContain(String(MAX_EVM_TIMEOUT_MS)); + expect(projected.remediation.action).toContain("does not raise"); + expect(projected.remediation.action).toContain("no server restart"); +}); + +it("says a CPU signal has no runtime override and timeout_ms does not raise it", () => { + const source = new OwnedCommandFailure( + "process", + "signaled", + { + ...snapshot, + exitCode: null, + signal: "SIGXCPU", + }, + null, + ); + const projected = projectAnalysisError( + evmInterfaceFailure(source, "worker", "/selected.hex", { + addressSpaceBytes: 1024, + cpuSeconds: 30, + fileSizeBytes: 1024, + }), + ); + expect(projected).toMatchObject({ + code: "resource_constraint", + details: { resource: "cpu" }, + }); + expect(projected.remediation.action).toContain("no runtime override"); + expect(projected.remediation.action).toContain( + "timeout_ms does not raise it", + ); +}); + it.each([ { failure: "process" as const, cleanup: null, tag: "ProviderAdapterError" }, { failure: "timeout" as const, cleanup: null, tag: "AnalysisTimeoutError" }, diff --git a/src/evm/EvmoleFailures.ts b/src/evm/EvmoleFailures.ts index f0bef3b9bf..f2cc0021ef 100644 --- a/src/evm/EvmoleFailures.ts +++ b/src/evm/EvmoleFailures.ts @@ -19,12 +19,27 @@ import { import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { OwnedCommandFailure } from "../process/OwnedCommand.js"; +import { + DEFAULT_EVM_TIMEOUT_MS, + MAX_EVM_TIMEOUT_MS, +} from "../domain/evm/evmInterface.js"; import { EVMOLE_PROVIDER_IDENTITY, + EVM_CPU_SECONDS, EVM_INTERFACE_LIMITS, EVM_FILE_SIZE_FAILURE_EXIT, } from "./EvmoleRelease.js"; +const fixedEvmRecovery = (allowance: string): string => + `${allowance}. This release has no runtime override. timeout_ms does not raise it. Repeating the unchanged request fails the same way.`; + +const evmTimeoutRecovery = (timeoutMs: number): string => + `${ + timeoutMs < MAX_EVM_TIMEOUT_MS + ? `Increase timeout_ms above ${String(timeoutMs)} (integer milliseconds, maximum ${String(MAX_EVM_TIMEOUT_MS)}), or use rea inspect-evm-interface --timeout-ms on the CLI.` + : `timeout_ms is already at the maximum supported ${String(MAX_EVM_TIMEOUT_MS)} ms; no higher worker deadline is supported. Report this blocker or select another suitable complete carrier with caller approval.` + } This is a per-call setting; no server restart is needed. It covers the owned EVMole worker command, not carrier acquisition or final reply reads. Allow the MCP client deadline to cover acquisition, the worker, reply reads, cleanup and delivery; increasing only that client deadline does not change this budget. Fixed input (${String(EVM_INTERFACE_LIMITS.inputBytes)} bytes), reply (${String(EVM_INTERFACE_LIMITS.outputBytes)} bytes), diagnostic (${String(EVM_INTERFACE_LIMITS.diagnosticBytes)} bytes), address-space (${String(EVM_INTERFACE_LIMITS.addressSpaceBytes)} bytes), and CPU (${String(EVM_CPU_SECONDS)} seconds) allowances stay unchanged, and timeout_ms does not raise them. Resolve any incomplete cleanup before retrying; do not repeat the unchanged failed call.`; + /** Correlate a reserved exit with the owned worker's actual failure branch. */ export interface EvmFileSizeFailureEvidence { readonly verified: boolean; @@ -39,6 +54,7 @@ export const evmInterfaceFailure = ( workerLimits?: EvmWorkerLimits, limiter = "/usr/bin/prlimit", fileFailure?: EvmFileSizeFailureEvidence, + timeoutMs = DEFAULT_EVM_TIMEOUT_MS, ): AnalysisError => { const cleanup = cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; @@ -74,17 +90,20 @@ export const evmInterfaceFailure = ( ...cleanupOptions, }); if (cause.reason === "timeout") - return new AnalysisTimeoutError( - "inspect_evm_interface", - EVM_INTERFACE_LIMITS.timeoutMs, - { ...outputOptions, ...cleanupOptions }, - ); + return new AnalysisTimeoutError("inspect_evm_interface", timeoutMs, { + ...outputOptions, + ...cleanupOptions, + userMessage: `The EVMole worker reached its ${String(timeoutMs)} ms server/provider deadline (timeout_ms). This is not a carrier-read or MCP client deadline. Omitted timeout_ms uses ${String(DEFAULT_EVM_TIMEOUT_MS)} ms.`, + remediationAction: evmTimeoutRecovery(timeoutMs), + }); if (cause.reason === "output-limit") - return new AnalysisOutputError( - "inspect_evm_interface", - cause.message, - outputOptions, - ); + return new AnalysisOutputError("inspect_evm_interface", cause.message, { + ...outputOptions, + userMessage: `EVMole worker diagnostics exceeded the ${String(EVM_INTERFACE_LIMITS.diagnosticBytes)}-byte capture allowance. This release has no runtime override.`, + remediationAction: fixedEvmRecovery( + `The EVMole diagnostic capture allowance is ${String(EVM_INTERFACE_LIMITS.diagnosticBytes)} bytes`, + ), + }); if ( cause.reason === "process" && (cause.snapshot?.signal === "SIGXCPU" || @@ -110,7 +129,18 @@ export const evmInterfaceFailure = ( }, effective_soft_limits: null, }, - outputOptions, + { + ...outputOptions, + retryable: false, + remediationAction: + cause.snapshot.signal === "SIGXCPU" + ? fixedEvmRecovery( + `The EVMole CPU ceiling is ${String(EVM_CPU_SECONDS)} seconds, or a tighter inherited limit when one applies`, + ) + : fixedEvmRecovery( + `The EVMole file-size ceiling is ${String(EVM_INTERFACE_LIMITS.outputBytes)} bytes, or a tighter inherited limit when one applies`, + ), + }, ); } if ( @@ -144,19 +174,27 @@ export const evmInterfaceFailure = ( if ( phase === "artifact-read" && ["limit", "path", "format"].includes(cause.reason) - ) + ) { + const inputLimited = cause.reason === "limit"; + const message = inputLimited + ? `${cause.message} The input allowance is ${String(EVM_INTERFACE_LIMITS.inputBytes)} bytes. This release has no runtime override. timeout_ms does not raise it.` + : cause.message; return new AnalysisInputError( "inspect_evm_interface", - { cause, ...cleanupOptions }, + { + cause, + ...cleanupOptions, + ...(inputLimited ? { userMessage: message } : {}), + }, [ { path: ["path"], - reason: - cause.reason === "limit" ? "out_of_range" : "invalid_format", - message: cause.message, + reason: inputLimited ? "out_of_range" : "invalid_format", + message, }, ], ); + } } if (phase === "artifact-read" && cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 05eaaa0b9d..9559718f60 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -38,7 +38,10 @@ import { } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { evmInterfaceEvidence } from "../application/evm/EvmInterfaceEvidence.js"; -import type { InspectEvmInterfaceInput } from "../domain/evm/evmInterface.js"; +import { + DEFAULT_EVM_TIMEOUT_MS, + type InspectEvmInterfaceInput, +} from "../domain/evm/evmInterface.js"; import { evmInterfaceSchema } from "../domain/evm/evmInterface.js"; import { solidityMetadataSchema } from "../domain/evm/solidityMetadata.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -58,6 +61,7 @@ import { } from "./EvmoleInterfaceAdapter.js"; import { EVMOLE_PROVIDER_IDENTITY, + EVM_CPU_SECONDS, EVM_INTERFACE_LIMITS, EVM_FILE_SIZE_FAILURE_EXIT, } from "./EvmoleRelease.js"; @@ -337,7 +341,16 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { try { return await this.#resources.run(() => this.#inspect(input, options)); } catch (cause: unknown) { - const failure = evmInterfaceFailure(cause, "artifact-read", input.path); + const failure = evmInterfaceFailure( + cause, + "artifact-read", + input.path, + undefined, + this.environment.REA_EVM_PRLIMIT_COMMAND ?? + EVM_PRLIMIT_COMMAND_DEFAULT, + undefined, + input.timeout_ms ?? DEFAULT_EVM_TIMEOUT_MS, + ); if (cause instanceof ArtifactReaderFailure) bindArtifactFileCleanup(cause, failure); return err(failure); @@ -399,6 +412,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { rootLease ??= AnalysisCleanupLease.forCloseOwner(owner, owner.path); return rootLease; }; + const timeoutMs = input.timeout_ms ?? DEFAULT_EVM_TIMEOUT_MS; let retainedOutput: AnalysisCapturedOutput | undefined; let phase: "configuration" | "artifact-read" | "worker" = "configuration"; let workerLimits: EvmWorkerLimits | undefined; @@ -518,7 +532,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { hostEnvironment: environment, }, { - timeoutMs: EVM_INTERFACE_LIMITS.timeoutMs, + timeoutMs, diagnosticBytes: EVM_INTERFACE_LIMITS.diagnosticBytes, }, { @@ -594,6 +608,8 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { if (reply.reason === "output-limit") throw new AnalysisOutputError(OPERATION, reply.message, { capturedOutput, + userMessage: `${reply.message} The reply allowance is ${String(EVM_INTERFACE_LIMITS.outputBytes)} bytes. This release has no runtime override.`, + remediationAction: `The EVMole reply allowance is ${String(EVM_INTERFACE_LIMITS.outputBytes)} bytes. This release has no runtime override. timeout_ms does not raise it. Repeating the unchanged request fails the same way.`, }); throw new ProviderAdapterError(EVMOLE_PROVIDER_IDENTITY.id, OPERATION, { diagnostics: { @@ -631,7 +647,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { "Bytecode kind, hardfork and deployed-contract authenticity are unknown. This inspection performs no target runtime execution, chain/RPC lookup or network request.", "Original carrier and decoded bytecode SHA-256 are distinct identities; SHA-256 is not the Ethereum Keccak code hash.", "Terminal CBOR metadata is an independently decoded candidate, not proof of Solidity origin, source identity or deployment. Nested or unsupported values retain explicit unavailable interpretation and original byte ranges.", - "The owned worker has a 256 MiB JavaScript heap, 3 GiB virtual address-space limit and 30-second deadline. Virtual address space is distinct from resident memory; limits fail without partial success.", + `The owned worker has a 256 MiB JavaScript heap, an address-space ceiling of ${String(EVM_INTERFACE_LIMITS.addressSpaceBytes)} bytes, and a CPU ceiling of ${String(EVM_CPU_SECONDS)} seconds. The command deadline is timeout_ms at ${String(timeoutMs)} ms (default ${String(DEFAULT_EVM_TIMEOUT_MS)}). Those byte, address-space, and CPU ceilings have no runtime override, and timeout_ms does not raise them. Virtual address space is distinct from resident memory; limits fail without partial success.`, ]; const validated = evmInterfaceSchema.safeParse({ artifact: { @@ -729,6 +745,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { workerLimits, limiter, fileFailure, + timeoutMs, ) : options?.signal?.aborted && (cause === options.signal.reason || @@ -750,6 +767,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { workerLimits, limiter, fileFailure, + timeoutMs, ); const artifactFailure = markerCleanupFailure ?? diff --git a/src/evm/EvmoleRelease.ts b/src/evm/EvmoleRelease.ts index 8e8ea10fc2..7dd3a181e6 100644 --- a/src/evm/EvmoleRelease.ts +++ b/src/evm/EvmoleRelease.ts @@ -1,3 +1,5 @@ +import { DEFAULT_EVM_TIMEOUT_MS } from "../domain/evm/evmInterface.js"; + /** Unchanged locked npm/WASM profile; provenance is independent of chain deployment authenticity. */ export const EVMOLE_PROVIDER_IDENTITY = { id: "evmole-interface", @@ -10,7 +12,9 @@ export const EVM_FILE_SIZE_FAILURE_EXIT = 76; export const EVM_INTERFACE_LIMITS = { inputBytes: 4 * 1024 * 1024, outputBytes: 16 * 1024 * 1024, - timeoutMs: 30_000, + timeoutMs: DEFAULT_EVM_TIMEOUT_MS, diagnosticBytes: 1024 * 1024, addressSpaceBytes: 3 * 1024 * 1024 * 1024, } as const; +/** Process CPU ceiling. A longer timeout_ms does not raise it. */ +export const EVM_CPU_SECONDS = 30; diff --git a/src/firmware/FirmwareCommand.test.ts b/src/firmware/FirmwareCommand.test.ts index 6d8027d6f9..a71ad1259f 100644 --- a/src/firmware/FirmwareCommand.test.ts +++ b/src/firmware/FirmwareCommand.test.ts @@ -1,5 +1,7 @@ +import { EventEmitter } from "node:events"; import { chmod, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { PassThrough } from "node:stream"; import { expect, it as test, vi } from "vitest"; @@ -11,7 +13,10 @@ import { resolveFirmwareCommand, runFirmwareCommand, } from "./FirmwareCommand.js"; -import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js"; +import { + spawnOwnedProviderProcess, + type ProviderProcessHandle, +} from "../process/ProviderProcess.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { ExecFileOutputCancelledError } from "../process/ExecFileOutput.js"; @@ -193,6 +198,80 @@ it("rejects a directory configured as the resource limiter executable", async () }); }); +it("keeps captured tool output when firmware stops on SIGXCPU", async () => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const emitter = new EventEmitter(); + const child: ProviderProcessHandle = { + on: emitter.on.bind(emitter), + once: emitter.once.bind(emitter), + off: emitter.off.bind(emitter), + pid: 4242, + stdout, + stderr, + exitCode: null, + signalCode: "SIGXCPU", + kill: () => false, + }; + let ended = 0; + const close = () => { + ended += 1; + if (ended === 2) emitter.emit("close", null, "SIGXCPU"); + }; + stdout.once("end", close); + stderr.once("end", close); + setTimeout(() => { + stdout.end("partial scan\n"); + stderr.end("cpu limit\n"); + }, 15); + const analysis = new AbortController(); + const failure: unknown = await runFirmwareCommand({ + engine: "binwalk", + operation: "inspect_firmware_regions", + command: "/opt/binwalk", + limiter: "/usr/bin/prlimit", + args: ["--quiet"], + cwd: await createTestTempDirectory("rea-firmware-signal-"), + environment: process.env, + signal: analysis.signal, + deadline: { + phase: "analysis", + signal: analysis.signal, + timeoutMs: 30_000, + expiresAtMs: performance.now() + 30_000, + }, + launcher: () => + Promise.resolve({ + process: child, + ownership: { + runId: "rea-firmware-signal", + leaderPid: 4242, + processGroupId: 4242, + }, + cleanup: () => Promise.resolve({ cleaned: true, signaled: false }), + }), + retainCleanup: vi.fn(), + }).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + resource: "cpu", + capturedOutput: { + stdout: "partial scan\n", + stderr: "cpu limit\n", + signal: "SIGXCPU", + exit_code: null, + truncated: false, + }, + }); + if (!(failure instanceof AnalysisError)) + throw new Error("Expected a firmware resource constraint"); + expect(projectAnalysisError(failure).details?.captured_output).toMatchObject({ + stdout: "partial scan\n", + stderr: "cpu limit\n", + signal: "SIGXCPU", + }); +}); + const executableFixture = async (root: string, name: string) => { const path = join(root, name); await writeFile(path, "#!/bin/sh\n"); diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index b677f79535..a3ca7ff68a 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -9,18 +9,25 @@ import { } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { + AnalysisCapturedOutput, + AnalysisError, +} from "../domain/analysisErrorBase.js"; import { projectAnalysisErrorWithoutCapturedOutput } from "../domain/analysisErrorProjection.js"; import type { FirmwareDeadline } from "./FirmwareDeadline.js"; import { - firmwareFailure, firmwareCancellation, + firmwareFailure, + firmwareLimitRecovery, firmwarePhaseInterruption, + firmwareProcessLimitRecovery, + firmwareSignalLimitError, } from "./FirmwareFailures.js"; import { spawnOwnedProviderProcess, ProviderProcessSupervisor, type OwnedProviderProcessSpawnOptions, + type ProviderProcessSnapshot, type SpawnedOwnedProviderProcess, } from "../process/ProviderProcess.js"; import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; @@ -35,6 +42,19 @@ export type FirmwareLauncher = ( options: OwnedProviderProcessSpawnOptions, ) => Promise; +/** Captured tool output for one finished firmware command. */ +const capturedFirmwareOutput = ( + snapshot: ProviderProcessSnapshot, +): AnalysisCapturedOutput => ({ + stdout: snapshot.stdout.text, + stderr: snapshot.stderr.text, + stdout_bytes: snapshot.stdout.bytes, + stderr_bytes: snapshot.stderr.bytes, + exit_code: snapshot.exitCode ?? null, + signal: snapshot.signal ?? null, + truncated: snapshot.diagnosticTruncated === true, +}); + /** Refuse an unmet host or tool requirement, which another target cannot satisfy. */ export const firmwareToolUnavailable = ( engine: string, @@ -201,6 +221,10 @@ export const runFirmwareCommand = async (context: { stderr: snapshot.stderr.text, truncated: true, }, + userMessage: `Firmware diagnostics exceeded the ${String(FIRMWARE_LIMITS.diagnosticBytes)}-byte capture allowance. This release has no runtime override.`, + remediationAction: firmwareLimitRecovery( + `The firmware diagnostic capture allowance is ${String(FIRMWARE_LIMITS.diagnosticBytes)} bytes`, + ), }, ); } @@ -248,15 +272,7 @@ export const runFirmwareCommand = async (context: { { operation, cleanupPrimary: failure, - capturedOutput: { - stdout: snapshot.stdout.text, - stderr: snapshot.stderr.text, - stdout_bytes: snapshot.stdout.bytes, - stderr_bytes: snapshot.stderr.bytes, - exit_code: snapshot.exitCode ?? null, - signal: snapshot.signal ?? null, - truncated: snapshot.diagnosticTruncated === true, - }, + capturedOutput: capturedFirmwareOutput(snapshot), }, ); } @@ -267,7 +283,18 @@ export const runFirmwareCommand = async (context: { context.acceptReportedExtractionFailure === true && snapshot.exitCode === 1 ) - ) + ) { + const fileSizeBytes = + context.outputBudget?.bytes ?? FIRMWARE_LIMITS.reportBytes; + const fileSizeAdjustable = context.outputBudget !== undefined; + const signalLimit = firmwareSignalLimitError( + operation, + snapshot.signal, + fileSizeBytes, + fileSizeAdjustable, + capturedFirmwareOutput(snapshot), + ); + if (signalLimit !== undefined) throw signalLimit; throw new ProviderAdapterError(engine, operation, { diagnostics: { reason: @@ -279,7 +306,9 @@ export const runFirmwareCommand = async (context: { stdout_bytes: snapshot.stdout.bytes, stderr_bytes: snapshot.stderr.bytes, }, + remediationAction: `Inspect exit status and stderr for format, dependency, sandbox, or resource-limit failures. ${firmwareProcessLimitRecovery(fileSizeBytes, fileSizeAdjustable)} A format or dependency failure is not repaired by changing analysis_timeout_ms.`, }); + } return { stdout: snapshot.stdout, stderr: snapshot.stderr, @@ -341,14 +370,25 @@ export const readFirmwareCommandReport = async (context: { ...diagnostics, }, }); + const oversized = + cause instanceof AnalysisOutputError && + cause.diagnostics?.report_state === "oversized"; + const reportFacts = + "Review diagnostics.report_state, the exit status and captured stdout/stderr. REA did not retry or substitute an empty result. diagnostics.command and diagnostics.args record what ran; their workspace paths are REA-private and removed after the attempt, so they cannot be replayed as-is."; + const reportAllowance = firmwareLimitRecovery( + `The firmware report allowance is ${String(FIRMWARE_LIMITS.reportBytes)} bytes`, + ); + const observed = `${engine} exited successfully but left no usable report (${reportFailure}), so ${operation} produced no Evidence. A successful exit status is not an empty or complete result.`; throw new AnalysisOutputError( operation, `${engine} exited ${String(execution.exit_code)} without a usable report: ${reportFailure}`, { cause, diagnostics, - userMessage: `${engine} exited successfully but left no usable report (${reportFailure}), so ${operation} produced no Evidence. A successful exit status is not an empty or complete result.`, - remediationAction: `Review diagnostics.report_state, the exit status and captured stdout/stderr. REA did not retry or substitute an empty result. diagnostics.command and diagnostics.args record what ran; their workspace paths are REA-private and removed after the attempt, so they cannot be replayed as-is. Rerun the same REA request to check whether this is intermittent; each attempt uses a fresh workspace. If it recurs, copy the selected input bytes into a new directory and run diagnostics.command with the same flags, replacing every private path with your copy and a new report path, under the documented firmware resource limits; confirm that it writes the requested report. \`rea doctor\` checks configuration only and cannot recover this report.`, + userMessage: oversized ? `${observed} ${reportAllowance}` : observed, + remediationAction: oversized + ? `${reportFacts} ${reportAllowance} \`rea doctor\` checks configuration only and cannot recover this report.` + : `${reportFacts} Rerun the same REA request to check whether this is intermittent; each attempt uses a fresh workspace. If it recurs, copy the selected input bytes into a new directory and run diagnostics.command with the same flags, replacing every private path with your copy and a new report path, under the documented firmware resource limits; confirm that it writes the requested report. \`rea doctor\` checks configuration only and cannot recover this report.`, }, ); } diff --git a/src/firmware/FirmwareDeadline.test.ts b/src/firmware/FirmwareDeadline.test.ts index 6e4f568136..7eed7fdce4 100644 --- a/src/firmware/FirmwareDeadline.test.ts +++ b/src/firmware/FirmwareDeadline.test.ts @@ -4,8 +4,12 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { firmwareRequestSchema } from "../domain/firmware/firmwareAnalysis.js"; import { firmwareFailure, + firmwareLimitRecovery, firmwarePhaseInterruption, + firmwareProcessLimitRecovery, + firmwareSignalLimitError, } from "./FirmwareFailures.js"; +import { FIRMWARE_LIMITS } from "./FirmwareRelease.js"; import { startFirmwareDeadline } from "./FirmwareDeadline.js"; afterEach(() => vi.restoreAllMocks()); @@ -165,3 +169,66 @@ it.each([1234, 2_147_483_647])( expect(projected.remediation.action).toContain("maximum"); }, ); + +it("says a CPU signal is fixed and analysis_timeout_ms does not raise it", () => { + const error = firmwareSignalLimitError( + "inspect_firmware_regions", + "SIGXCPU", + FIRMWARE_LIMITS.reportBytes, + false, + ); + const projected = projectAnalysisError(error!); + expect(projected).toMatchObject({ + code: "resource_constraint", + details: { + resource: "cpu", + reported_limits: { cpu_seconds: FIRMWARE_LIMITS.cpuSeconds }, + }, + }); + expect(projected.message).toContain(String(FIRMWARE_LIMITS.cpuSeconds)); + expect(projected.remediation.action).toBe( + firmwareLimitRecovery( + `cpuSeconds is ${String(FIRMWARE_LIMITS.cpuSeconds)}`, + ), + ); + expect(projected.remediation.action).toContain("no runtime override"); + expect(projected.remediation.action).toContain( + "analysis_timeout_ms (--analysis-timeout-ms in the CLI) does not raise it", + ); +}); + +it("retains captured tool output on a CPU or file-size signal", () => { + const captured = { + stdout: "partial scan\n", + stderr: "stopped by prlimit\n", + stdout_bytes: 13, + stderr_bytes: 19, + exit_code: null, + signal: "SIGXCPU", + truncated: false, + }; + for (const signal of ["SIGXCPU", "SIGXFSZ"] as const) { + const error = firmwareSignalLimitError( + "inspect_firmware_regions", + signal, + FIRMWARE_LIMITS.reportBytes, + false, + { ...captured, signal }, + ); + expect(projectAnalysisError(error!).details?.captured_output).toEqual({ + ...captured, + signal, + }); + } +}); + +it("names the fixed address-space allowance on an unclassified tool failure", () => { + const recovery = firmwareProcessLimitRecovery( + FIRMWARE_LIMITS.reportBytes, + false, + ); + expect(recovery).toContain(String(FIRMWARE_LIMITS.addressSpaceBytes)); + expect(recovery).toContain(String(FIRMWARE_LIMITS.cpuSeconds)); + expect(recovery).toContain("no runtime override"); + expect(recovery).toContain("analysis_timeout_ms does not raise"); +}); diff --git a/src/firmware/FirmwareFailures.ts b/src/firmware/FirmwareFailures.ts index 4a80be6bc3..8ff7bfdec9 100644 --- a/src/firmware/FirmwareFailures.ts +++ b/src/firmware/FirmwareFailures.ts @@ -5,20 +5,82 @@ import { artifactFileCancellationCause, bindArtifactFileCleanup, } from "../artifacts/ArtifactFileOperation.js"; -import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisError, + type AnalysisCapturedOutput, + type AnalysisErrorOptions, +} from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, - AnalysisTimeoutError, AnalysisOutputError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import type { FirmwareDeadline } from "./FirmwareDeadline.js"; import { firmwareDeadlineElapsed } from "./FirmwareDeadline.js"; -import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js"; import { z } from "zod"; import { MAX_FIRMWARE_TIMEOUT_MS } from "../domain/firmware/firmwareAnalysis.js"; +import { FIRMWARE_LIMITS } from "./FirmwareRelease.js"; + +/** Fixed firmware allowance. analysis_timeout_ms does not raise it. */ +export const firmwareLimitRecovery = (allowance: string): string => + `${allowance}. This release has no runtime override. analysis_timeout_ms (--analysis-timeout-ms in the CLI) does not raise it. Repeating the unchanged request fails the same way.`; + +/** Classify a prlimit signal that is not the analysis wall clock. */ +export const firmwareSignalLimitError = ( + operation: string, + signal: NodeJS.Signals | null | undefined, + fileSizeBytes: number, + fileSizeAdjustable: boolean, + capturedOutput?: AnalysisCapturedOutput, +): AnalysisResourceConstraintError | undefined => { + const recovery = ( + remediationAction: string, + ): AnalysisResourceConstraintError => + new AnalysisResourceConstraintError( + operation, + signal === "SIGXCPU" ? "cpu" : "file-size", + signal === "SIGXCPU" + ? `Firmware analysis reached its ${String(FIRMWARE_LIMITS.cpuSeconds)}-second CPU allowance (cpuSeconds).` + : `Firmware analysis reached its ${String(fileSizeBytes)}-byte file-size allowance.`, + signal === "SIGXCPU" + ? { cpu_seconds: FIRMWARE_LIMITS.cpuSeconds } + : { file_size_bytes: fileSizeBytes }, + { + retryable: false, + remediationAction, + ...(capturedOutput === undefined ? {} : { capturedOutput }), + }, + ); + if (signal === "SIGXCPU") + return recovery( + firmwareLimitRecovery( + `cpuSeconds is ${String(FIRMWARE_LIMITS.cpuSeconds)}`, + ), + ); + if (signal !== "SIGXFSZ") return undefined; + return recovery( + fileSizeAdjustable + ? `The file-size cap for this extraction is max_output_bytes at ${String(fileSizeBytes)} bytes. analysis_timeout_ms does not raise it. Increase max_output_bytes when it is below its schema maximum and the larger staging budget is still required. Repeating the unchanged request fails the same way.` + : firmwareLimitRecovery( + `The firmware file-size allowance is ${String(fileSizeBytes)} bytes`, + ), + ); +}; + +/** Address-space and CPU ceilings that a wall-clock change cannot raise. */ +export const firmwareProcessLimitRecovery = ( + fileSizeBytes: number, + fileSizeAdjustable: boolean, +): string => + `cpuSeconds is ${String(FIRMWARE_LIMITS.cpuSeconds)}. The address-space allowance is ${String(FIRMWARE_LIMITS.addressSpaceBytes)} bytes. This release has no runtime override for those per-process allowances, and analysis_timeout_ms does not raise them. ${ + fileSizeAdjustable + ? `The file-size cap for this call is max_output_bytes at ${String(fileSizeBytes)} bytes. analysis_timeout_ms does not raise it.` + : `The file-size allowance is ${String(fileSizeBytes)} bytes. This release has no runtime override for that allowance, and analysis_timeout_ms does not raise it.` + }`; type FirmwarePublicationProgress = { readonly output_directory: string; diff --git a/src/firmware/FirmwareFiles.ts b/src/firmware/FirmwareFiles.ts index 699f276e34..14a10ffafd 100644 --- a/src/firmware/FirmwareFiles.ts +++ b/src/firmware/FirmwareFiles.ts @@ -13,6 +13,7 @@ import type { FirmwareRequest } from "../domain/firmware/firmwareAnalysis.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; import { FIRMWARE_LIMITS } from "./FirmwareRelease.js"; +import { firmwareLimitRecovery } from "./FirmwareFailures.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import type { SafeOutputTree } from "../artifacts/SafeOutputTree.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; @@ -282,14 +283,16 @@ const firmwareAdmissionFailure = ( ); }; -const firmwareInputFailure = (operation: string, cause?: unknown) => - new AnalysisInputError(operation, { cause }, [ +const firmwareInputFailure = (operation: string, cause?: unknown) => { + const message = `Firmware must be a nonempty regular file no larger than ${String(FIRMWARE_LIMITS.inputBytes)} bytes. Select a stable copy within this allowance. ${firmwareLimitRecovery(`The firmware input allowance is ${String(FIRMWARE_LIMITS.inputBytes)} bytes`)}`; + return new AnalysisInputError(operation, { cause, userMessage: message }, [ { path: ["path"], reason: "out_of_range", - message: `Firmware must be a nonempty regular file no larger than ${FIRMWARE_LIMITS.inputBytes} bytes. Select a stable copy within this allowance.`, + message, }, ]); +}; /** Inventory private output without following links, enforcing aggregate staging budgets. */ export const inventoryFirmwareOutput = async ( diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index 9488f07d14..25457d1c21 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -96,8 +96,7 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => { { maximum_input_bytes: INPUT_BYTES }, { cause, - remediationAction: - "Select a suitable stable artifact within the reported input byte allowance. Do not truncate required bytes; retrying the unchanged oversized input will fail again.", + remediationAction: `Select a suitable stable artifact within the reported ${String(INPUT_BYTES)}-byte input byte allowance. This release has no runtime override. Do not truncate required bytes; retrying the unchanged oversized input will fail again.`, }, ); if (cause.reason === "path") diff --git a/src/gpu/GpuCodeObjectsProvider.ts b/src/gpu/GpuCodeObjectsProvider.ts index b2107a281a..e4b531a190 100644 --- a/src/gpu/GpuCodeObjectsProvider.ts +++ b/src/gpu/GpuCodeObjectsProvider.ts @@ -120,7 +120,11 @@ export class GpuCodeObjectsProvider implements GpuCodeObjectsPort { maximum_object_bytes: GPU_OBJECT_BYTES, maximum_report_bytes: GPU_REPORT_BYTES, }, - { cause, retryable: false }, + { + cause, + retryable: false, + remediationAction: `${primary.message} This release has no runtime override for that GPU byte allowance. The object allowance is ${String(GPU_OBJECT_BYTES)} bytes and the report allowance is ${String(GPU_REPORT_BYTES)} bytes. Additional host memory does not raise them. Repeating the unchanged request fails the same way.`, + }, ); else if (primary instanceof BinaryFormatFailure) { error = @@ -175,8 +179,12 @@ export class GpuCodeObjectsProvider implements GpuCodeObjectsPort { OPERATION, "memory", primary.message, - {}, - { cause: primary }, + { maximum_object_bytes: GPU_OBJECT_BYTES }, + { + cause: primary, + retryable: false, + remediationAction: `${primary.message} The GPU object allowance is ${String(GPU_OBJECT_BYTES)} bytes. This release has no runtime override. Additional host memory does not raise it. Repeating the unchanged request fails the same way.`, + }, ); else if (primary instanceof Error && "code" in primary) error = new BinaryTargetError(input.path, primary.message, { cause }); diff --git a/src/model/GgufHeader.ts b/src/model/GgufHeader.ts index 1f2899f82c..f1b7646651 100644 --- a/src/model/GgufHeader.ts +++ b/src/model/GgufHeader.ts @@ -10,6 +10,7 @@ import type { import { ModelHeaderReader } from "./ModelHeaderReader.js"; import { MODEL_ARRAY_PREVIEW, + MODEL_METADATA_NESTING, ModelFormatFailure, ModelReportBudget, rejectModel, @@ -64,6 +65,7 @@ export class GgufHeader { throw new ModelFormatFailure( "capacity", "Header length/count exceeds exact host offset precision", + "exact-offset", ); return Number(value); } @@ -114,10 +116,11 @@ export class GgufHeader { }> { if (NAMES[type] === undefined) rejectModel(`Unknown GGUF metadata type ${String(type)}`); - if (depth > 32) + if (depth > MODEL_METADATA_NESTING) throw new ModelFormatFailure( "capacity", - "GGUF metadata nesting exceeds 32 array frames", + `GGUF metadata nesting exceeds ${String(MODEL_METADATA_NESTING)} array frames`, + "metadata-nesting", ); if (type === 8) return { diff --git a/src/model/ModelArtifactProvider.ts b/src/model/ModelArtifactProvider.ts index 9265a140b0..30113bfcfa 100644 --- a/src/model/ModelArtifactProvider.ts +++ b/src/model/ModelArtifactProvider.ts @@ -43,7 +43,8 @@ import { ModelFormatFailure, rejectModel, MODEL_REPORT_BYTES, - MODEL_HEADER_BYTES, + modelCapacityRemediation, + modelCapacityReportedLimits, } from "./ModelFormat.js"; import { safetensorsBits } from "./ModelTensorTypes.js"; import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; @@ -113,18 +114,19 @@ export class ModelArtifactProvider implements ModelArtifactPort { let error: AnalysisError; if (primary instanceof AnalysisError) error = primary; else if (primary instanceof ModelFormatFailure) { + const limit = primary.kind === "capacity" ? primary.limit : undefined; error = - primary.kind === "capacity" + limit !== undefined ? new AnalysisResourceConstraintError( OPERATION, "memory", primary.message, + modelCapacityReportedLimits(limit), { - maximum_report_bytes: MODEL_REPORT_BYTES, - maximum_header_bytes: MODEL_HEADER_BYTES, - maximum_exact_offset: Number.MAX_SAFE_INTEGER, + cause, + retryable: false, + remediationAction: modelCapacityRemediation(limit), }, - { cause, retryable: false }, ) : primary.kind === "unsupported" ? new AnalysisUnsupportedTargetError( @@ -253,6 +255,7 @@ export class ModelArtifactProvider implements ModelArtifactPort { throw new ModelFormatFailure( "capacity", "Model file exceeds exact host offset precision", + "exact-offset", ); const reader = new ModelHeaderReader(file, Number(initial.size), signal); const first = await reader.take(4); @@ -458,7 +461,11 @@ export class ModelArtifactProvider implements ModelArtifactPort { ], }; if (jsonValueByteLength(report, MODEL_REPORT_BYTES) > MODEL_REPORT_BYTES) - throw new ModelFormatFailure("capacity", "Model report exceeds 32 MiB"); + throw new ModelFormatFailure( + "capacity", + "Model report exceeds 32 MiB", + "report-bytes", + ); return modelArtifactSchema.parse(report); } } diff --git a/src/model/ModelFormat.ts b/src/model/ModelFormat.ts index e6c0f5a7e8..2e8d36956a 100644 --- a/src/model/ModelFormat.ts +++ b/src/model/ModelFormat.ts @@ -1,11 +1,33 @@ +/** Producing constraint for a model capacity refusal. Recovery uses this, not message text. */ +export const MODEL_CAPACITY_LIMITS = [ + "header-bytes", + "report-bytes", + "representation-bytes", + "exact-offset", + "metadata-nesting", +] as const; + +/** Which fixed model allowance refused the request. */ +export type ModelCapacityLimit = (typeof MODEL_CAPACITY_LIMITS)[number]; + /** Format failures stay separate from filesystem, capacity and cancellation failures. */ export class ModelFormatFailure extends Error { + /** Set only for `kind: "capacity"`. */ + readonly limit: ModelCapacityLimit | undefined; + constructor(kind: "format" | "unsupported", message: string); + constructor(kind: "capacity", message: string, limit: ModelCapacityLimit); constructor( readonly kind: "format" | "unsupported" | "capacity", message: string, + limit?: ModelCapacityLimit, ) { super(message); this.name = "ModelFormatFailure"; + if (kind === "capacity" && limit === undefined) + throw new TypeError("Model capacity failures require a limit kind"); + if (kind !== "capacity" && limit !== undefined) + throw new TypeError("Only model capacity failures carry a limit kind"); + this.limit = limit; } } @@ -13,11 +35,58 @@ export function rejectModel(message: string): never { throw new ModelFormatFailure("format", message); } -// Safetensors' format ceiling is 100,000,000 bytes. The reader additionally -// bounds retained rows/text independently of that input ceiling. +// Shared header ceiling for GGUF and Safetensors. Safetensors counts declared +// JSON bytes after the eight-byte length prefix; GGUF counts the complete +// header. Retained rows and the serialized report have separate allowances. export const MODEL_HEADER_BYTES = 100_000_000; export const MODEL_REPORT_BYTES = 32 * 1024 * 1024; export const MODEL_ARRAY_PREVIEW = 8; +export const MODEL_METADATA_NESTING = 32; + +const fixedModelLimit = (allowance: string): string => + `${allowance}. This release has no runtime override. Additional host memory does not raise it. Repeating the unchanged request fails the same way.`; + +/** Recovery for the allowance that actually refused the model. Host memory does not raise it. */ +export const modelCapacityRemediation = (limit: ModelCapacityLimit): string => { + switch (limit) { + case "header-bytes": + return fixedModelLimit("The model header allowance is 100,000,000 bytes"); + case "report-bytes": + return fixedModelLimit( + `The retained model report allowance is ${String(MODEL_REPORT_BYTES)} bytes`, + ); + case "representation-bytes": + return fixedModelLimit( + `The retained model representation allowance is ${String(MODEL_REPORT_BYTES)} bytes`, + ); + case "exact-offset": + return fixedModelLimit( + `Exact host offset precision stops at ${String(Number.MAX_SAFE_INTEGER)}`, + ); + case "metadata-nesting": + return fixedModelLimit( + `GGUF metadata nesting is limited to ${String(MODEL_METADATA_NESTING)} array frames`, + ); + } +}; + +/** Limits that belong to the producing constraint, without unrelated ceilings. */ +export const modelCapacityReportedLimits = ( + limit: ModelCapacityLimit, +): Readonly> => { + switch (limit) { + case "header-bytes": + return { maximum_header_bytes: MODEL_HEADER_BYTES }; + case "report-bytes": + return { maximum_report_bytes: MODEL_REPORT_BYTES }; + case "representation-bytes": + return { maximum_representation_bytes: MODEL_REPORT_BYTES }; + case "exact-offset": + return { maximum_exact_offset: Number.MAX_SAFE_INTEGER }; + case "metadata-nesting": + return { maximum_array_frames: MODEL_METADATA_NESTING }; + } +}; /** Charge representation growth before retaining rows or decoding long strings. */ export class ModelReportBudget { @@ -26,7 +95,8 @@ export class ModelReportBudget { if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining) throw new ModelFormatFailure( "capacity", - "Model facts exceed the 32 MiB retained representation budget", + "Model facts exceed the 32 MiB retained representation budget. This release has no runtime override.", + "representation-bytes", ); this.#remaining -= bytes; } diff --git a/src/model/ModelHeaderReader.ts b/src/model/ModelHeaderReader.ts index cd67776e98..36ff03e2ef 100644 --- a/src/model/ModelHeaderReader.ts +++ b/src/model/ModelHeaderReader.ts @@ -45,7 +45,8 @@ export class ModelHeaderReader { if (end - this.#headerPrefixBytes > MODEL_HEADER_BYTES) throw new ModelFormatFailure( "capacity", - "Model header exceeds 100,000,000 bytes", + "Model header exceeds 100,000,000 bytes. This release has no runtime override.", + "header-bytes", ); } diff --git a/src/model/SafetensorsHeader.ts b/src/model/SafetensorsHeader.ts index f9fbe0c1e9..601bdd1b8d 100644 --- a/src/model/SafetensorsHeader.ts +++ b/src/model/SafetensorsHeader.ts @@ -109,6 +109,7 @@ class HeaderJson { throw new ModelFormatFailure( "capacity", "Safetensors integer exceeds exact host offset precision", + "exact-offset", ); return number; } @@ -163,6 +164,7 @@ export const readSafetensorsHeader = async ( throw new ModelFormatFailure( "capacity", "Safetensors header length exceeds exact host offset precision", + "exact-offset", ); const size = Number(length); reader.guarantee(size - 1); diff --git a/src/native/pe/PeResourcesProvider.ts b/src/native/pe/PeResourcesProvider.ts index b86a294638..668b2b2035 100644 --- a/src/native/pe/PeResourcesProvider.ts +++ b/src/native/pe/PeResourcesProvider.ts @@ -96,10 +96,10 @@ export const inputBudgetRemediation = ( ): string => boundary === "file-size" ? input.max_file_bytes >= MAX_INSPECT_PE_FILE_BYTES - ? `The image exceeds the ${String(MAX_INSPECT_PE_FILE_BYTES)}-byte input ceiling this operation accepts; select a smaller artifact.` + ? `The image exceeds the ${String(MAX_INSPECT_PE_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.` : input.max_entries >= MAX_INSPECT_PE_DIRECTORY_ENTRIES - ? `The image exceeds the ${String(MAX_INSPECT_PE_DIRECTORY_ENTRIES)}-entry resource-directory ceiling this operation accepts; select a smaller artifact.` + ? `The image exceeds the ${String(MAX_INSPECT_PE_DIRECTORY_ENTRIES)}-entry resource-directory ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` : `Raise max_entries above ${String(input.max_entries)} or select a smaller artifact; the limit is the caller-selected resource directory entry budget.`; const peResourcesFailurePrimary = ( diff --git a/src/sqlite/SqliteDatabaseFailures.ts b/src/sqlite/SqliteDatabaseFailures.ts index 2b26822ae3..140f53c294 100644 --- a/src/sqlite/SqliteDatabaseFailures.ts +++ b/src/sqlite/SqliteDatabaseFailures.ts @@ -33,6 +33,7 @@ import { DEFAULT_SQLITE_WORKER_TIMEOUT_MS, MAX_SQLITE_WORKER_TIMEOUT_MS, } from "../domain/sqlite/sqliteDatabase.js"; +import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; /** Preserve diagnostics independently of worker exit and private-root cleanup. */ export const capturedSqliteOutput = ( @@ -274,7 +275,11 @@ const ownedCommandFailure = ( }); if (timeout !== undefined) return timeout; if (cause.reason === "output-limit") - return new AnalysisOutputError(operation, cause.message, options); + return new AnalysisOutputError(operation, cause.message, { + ...options, + userMessage: `SQLite worker diagnostics exceeded the ${String(SQLITE_DATABASE_LIMITS.diagnosticBytes)}-byte capture allowance. This release has no runtime override.`, + remediationAction: `The SQLite diagnostic capture allowance is ${String(SQLITE_DATABASE_LIMITS.diagnosticBytes)} bytes. This release has no runtime override. worker_timeout_ms does not raise it. Repeating the unchanged request fails the same way.`, + }); if ( cause.snapshot?.stderr.text.includes("JavaScript heap out of memory") === true diff --git a/src/sqlite/SqliteDatabaseProvider.ts b/src/sqlite/SqliteDatabaseProvider.ts index 50a8a4f68b..612d97afef 100644 --- a/src/sqlite/SqliteDatabaseProvider.ts +++ b/src/sqlite/SqliteDatabaseProvider.ts @@ -107,8 +107,7 @@ export class SqliteDatabaseProvider implements SqliteDatabasePort { input_bytes_per_inspection: SQLITE_DATABASE_LIMITS.inputBytes, }, { - remediationAction: - "Retry after an active SQLite inspection completes.", + remediationAction: `The concurrent SQLite inspection allowance is ${String(SQLITE_DATABASE_LIMITS.concurrentInspections)}. This release has no runtime override. Waiting for an active inspection to finish can free a slot. worker_timeout_ms does not raise it. Retry after an active SQLite inspection completes.`, }, ), ); diff --git a/src/sqlite/SqliteDatabaseSnapshot.ts b/src/sqlite/SqliteDatabaseSnapshot.ts index 799fd276d4..1589ee963c 100644 --- a/src/sqlite/SqliteDatabaseSnapshot.ts +++ b/src/sqlite/SqliteDatabaseSnapshot.ts @@ -280,8 +280,7 @@ export const captureSqliteDatabaseSnapshot = async ( observed_sqlite_snapshot_input_bytes: selectedSize.toString(), }, { - remediationAction: - "Use the application's supported export workflow to create a consistent, smaller offline SQLite snapshot containing only the relevant data, then inspect that snapshot. REA will not truncate or modify the selected source.", + remediationAction: `Use the application's supported export workflow to create a consistent, smaller offline SQLite snapshot containing only the relevant data, then inspect that snapshot. REA will not truncate or modify the selected source. The combined input allowance is ${String(SQLITE_DATABASE_LIMITS.inputBytes)} bytes. This release has no runtime override. worker_timeout_ms does not raise it.`, }, ); if (journal !== null) diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts index 9c9979f2f5..b2429b54b2 100644 --- a/src/wasm/WabtFailures.ts +++ b/src/wasm/WabtFailures.ts @@ -176,8 +176,7 @@ const classifyWabtFailure = ( { max_output_bytes: WABT_LIMITS.outputBytes }, { ...options, - remediationAction: - "The WABT output budget is fixed, so retrying with the same input will fail the same way.", + remediationAction: `The WABT output allowance is ${String(WABT_LIMITS.outputBytes)} bytes. This release has no runtime override. command_timeout_ms does not raise it. The WABT output budget is fixed, so retrying with the same input will fail the same way.`, }, ); if (phase === "configuration" && cause.reason === "process") @@ -200,6 +199,23 @@ const classifyWabtFailure = ( reason, { cause, retryable: cause.retryable }, ); + if ( + phase === "configuration" && + cause instanceof ArtifactReaderFailure && + cause.reason === "limit" && + cause.boundary === "file-size" + ) + return new AnalysisCapabilityUnavailableError( + WABT_PROVIDER_IDENTITY.id, + "inspect_wasm_artifact", + cause.message, + { + cause, + ...cleanupOptions, + userMessage: `Configured WABT tools exceed the ${String(WABT_LIMITS.toolBytes)}-byte tool allowance. This release has no runtime override.`, + remediationAction: `The WABT tool allowance is ${String(WABT_LIMITS.toolBytes)} bytes. This release has no runtime override. command_timeout_ms and REA_WABT_BIN_DIRECTORY do not raise it. Select tools within the allowance. Do not repeat the unchanged call.`, + }, + ); if (phase === "configuration") return new AnalysisCapabilityUnavailableError( WABT_PROVIDER_IDENTITY.id, @@ -227,8 +243,7 @@ const classifyWabtFailure = ( cause, ...cleanupOptions, retryable: false, - remediationAction: - "Select a suitable stable artifact within the reported input byte allowance. Do not truncate required bytes; retrying the unchanged oversized input will fail again.", + remediationAction: `Select a suitable stable artifact within the reported ${String(WABT_LIMITS.inputBytes)}-byte input byte allowance. This release has no runtime override. command_timeout_ms does not raise it. Do not truncate required bytes; retrying the unchanged oversized input will fail again.`, }, ); if ( diff --git a/tests/boundary/firmware/firmwareReportFiles.test.ts b/tests/boundary/firmware/firmwareReportFiles.test.ts index 7415856728..83bc9d1973 100644 --- a/tests/boundary/firmware/firmwareReportFiles.test.ts +++ b/tests/boundary/firmware/firmwareReportFiles.test.ts @@ -105,9 +105,29 @@ it.each([ expect(projected.message).not.toContain("rea doctor"); // Recorded arguments name a removed private workspace; recovery must not // present them as a replayable command. - expect(projected.remediation.action).toMatch( - /diagnostics\.report_state.*cannot be replayed as-is.*replacing every private path/, - ); + expect(projected.remediation.action).toContain("diagnostics.report_state"); + expect(projected.remediation.action).toContain("cannot be replayed as-is"); + if (state === "oversized") { + expect(projected.remediation.action).toContain("no runtime override"); + expect(projected.remediation.action).toContain( + "Repeating the unchanged request fails the same way", + ); + expect(projected.remediation.action).not.toContain( + "Rerun the same REA request", + ); + expect(projected.message).toContain("no runtime override"); + expect(projected.message).not.toContain("Rerun the same REA request"); + } else { + expect(projected.remediation.action).toContain( + "replacing every private path", + ); + expect(projected.remediation.action).toContain( + "Rerun the same REA request", + ); + expect(projected.remediation.action).not.toContain( + "Repeating the unchanged request fails the same way", + ); + } }, ); diff --git a/tests/boundary/model/modelArtifact.test.ts b/tests/boundary/model/modelArtifact.test.ts index dd7d9d8683..72896a7996 100644 --- a/tests/boundary/model/modelArtifact.test.ts +++ b/tests/boundary/model/modelArtifact.test.ts @@ -4,6 +4,13 @@ import { join } from "node:path"; import { expect, it, onTestFinished } from "vitest"; import { ModelArtifactProvider } from "../../../src/model/ModelArtifactProvider.js"; import { ModelHeaderReader } from "../../../src/model/ModelHeaderReader.js"; +import { + MODEL_METADATA_NESTING, + MODEL_REPORT_BYTES, + ModelFormatFailure, + modelCapacityRemediation, + modelCapacityReportedLimits, +} from "../../../src/model/ModelFormat.js"; import { readSafetensorsHeader } from "../../../src/model/SafetensorsHeader.js"; import { GgufHeader } from "../../../src/model/GgufHeader.js"; import { createModelArtifactService } from "../../../src/composition/model.js"; @@ -309,7 +316,64 @@ it("retains the absolute header budget for GGUF without allocating that extent", await file.truncate(100_000_009); const reader = new ModelHeaderReader(file, 100_000_009); expect(() => reader.guarantee(100_000_000)).not.toThrow(); - expect(() => reader.guarantee(100_000_001)).toThrow("100,000,000 bytes"); + expect(() => reader.guarantee(100_000_001)).toThrow(ModelFormatFailure); + try { + reader.guarantee(100_000_001); + } catch (cause) { + expect(cause).toBeInstanceOf(ModelFormatFailure); + if (!(cause instanceof ModelFormatFailure)) + throw new Error("Expected a model header capacity failure"); + expect(cause.limit).toBe("header-bytes"); + expect(cause.message).toContain("100,000,000 bytes"); + expect(cause.message).not.toContain("Safetensors"); + } +}); + +it("names exact offset and nesting limits instead of the retained report allowance", async () => { + const base = ggufFixture({ tensors: [] }); + const oversized = ggufFixture({ + metadata: [ + { + key: "custom.length", + type: 8, + value: base.u64(BigInt(Number.MAX_SAFE_INTEGER) + 1n), + }, + ], + tensors: [], + }); + let nested = Buffer.from([1]); + for (let frame = 0; frame < 33; frame += 1) + nested = Buffer.concat([ + base.u32(frame === 0 ? 7 : 9), + base.u64(1n), + nested, + ]); + const deep = ggufFixture({ + metadata: [{ key: "custom.nested", type: 9, value: nested }], + tensors: [], + }); + for (const [label, bytes, allowance] of [ + ["offset", oversized.bytes, String(Number.MAX_SAFE_INTEGER)], + ["nesting", deep.bytes, "32 array frames"], + ] as const) { + const { path, provider } = await providerAt(bytes); + const result = await provider.inspect({ path, hash: "header" }); + if (result.ok) throw new Error(`${label} capacity was accepted`); + expect(result.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + retryable: false, + remediationAction: expect.stringContaining(allowance), + }); + const remediation = + "remediationAction" in result.error ? result.error.remediationAction : ""; + expect(remediation).not.toContain(String(MODEL_REPORT_BYTES)); + expect(remediation).not.toContain("100,000,000"); + expect(result.error).toMatchObject({ + reportedLimits: expect.not.objectContaining({ + maximum_report_bytes: MODEL_REPORT_BYTES, + }), + }); + } }); it("preserves safetensors scalar, empty and packed tensor declarations and raw string metadata", async () => { @@ -504,6 +568,31 @@ it.each([ }, ); +it("chooses model recovery from the producing limit", () => { + const header = modelCapacityRemediation("header-bytes"); + expect(header).toContain("100,000,000 bytes"); + expect(header).not.toContain("Safetensors"); + expect(header).not.toContain(String(MODEL_REPORT_BYTES)); + const report = modelCapacityRemediation("report-bytes"); + expect(report).toContain(`report allowance is ${String(MODEL_REPORT_BYTES)}`); + expect(report).not.toContain("representation"); + const representation = modelCapacityRemediation("representation-bytes"); + expect(representation).toContain("representation allowance"); + expect(representation).not.toContain("report allowance"); + const offset = modelCapacityRemediation("exact-offset"); + expect(offset).toContain(String(Number.MAX_SAFE_INTEGER)); + expect(offset).not.toContain(String(MODEL_REPORT_BYTES)); + const nesting = modelCapacityRemediation("metadata-nesting"); + expect(nesting).toContain(`${String(MODEL_METADATA_NESTING)} array frames`); + expect(nesting).not.toContain(String(MODEL_REPORT_BYTES)); + expect(modelCapacityReportedLimits("report-bytes")).toEqual({ + maximum_report_bytes: MODEL_REPORT_BYTES, + }); + expect(modelCapacityReportedLimits("exact-offset")).toEqual({ + maximum_exact_offset: Number.MAX_SAFE_INTEGER, + }); +}); + it("refuses an oversized declared header before reading or expanding it", async () => { const bytes = Buffer.alloc(9); bytes.writeBigUInt64LE(100_000_001n); @@ -521,7 +610,15 @@ it("refuses an oversized declared header before reading or expanding it", async _tag: "AnalysisResourceConstraintError", retryable: false, reportedLimits: { maximum_header_bytes: 100_000_000 }, + remediationAction: expect.stringContaining("100,000,000"), }); + expect(result.error.message).not.toContain("Safetensors"); + expect( + "remediationAction" in result.error ? result.error.remediationAction : "", + ).not.toContain("Safetensors"); + expect( + "reportedLimits" in result.error ? result.error.reportedLimits : {}, + ).not.toHaveProperty("maximum_report_bytes"); }); const malformed = [ diff --git a/tests/boundary/sqlite/sqliteDatabaseFailures.test.ts b/tests/boundary/sqlite/sqliteDatabaseFailures.test.ts index 927151d0bf..cf41ca2c1f 100644 --- a/tests/boundary/sqlite/sqliteDatabaseFailures.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseFailures.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "vitest"; import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { sqliteDatabaseFailure } from "../../../src/sqlite/SqliteDatabaseFailures.js"; +import { SQLITE_DATABASE_LIMITS } from "../../../src/sqlite/SqliteDatabaseLimits.js"; it.each([null, "owned stop unconfirmed"])( "preserves a selected worker deadline with cleanup=%s", @@ -86,3 +87,20 @@ it("does not recommend a timer above the supported maximum", () => { expect(failure.remediation.action).toContain("no higher worker deadline"); expect(failure.remediation.action).not.toContain("Increase"); }); + +it("says diagnostic capture has no runtime override", () => { + const projected = projectAnalysisError( + sqliteDatabaseFailure( + new OwnedCommandFailure("output-limit", "diagnostics exceeded"), + "/selected.db", + "worker", + ), + ); + expect(projected.remediation.action).toContain( + String(SQLITE_DATABASE_LIMITS.diagnosticBytes), + ); + expect(projected.remediation.action).toContain("no runtime override"); + expect(projected.remediation.action).toContain( + "worker_timeout_ms does not raise it", + ); +});