diff --git a/src/adb/AdbCommand.test.ts b/src/adb/AdbCommand.test.ts index 52882c8112..33123950a9 100644 --- a/src/adb/AdbCommand.test.ts +++ b/src/adb/AdbCommand.test.ts @@ -1,4 +1,5 @@ import { expect, it, vi } from "vitest"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { ExecFileOutputCancelledError, execFileBytes, @@ -183,6 +184,42 @@ it("does not infer cancellation from a foreign ABORT_ERR code", async () => { }); }); +it.each([true, false])( + "projects a truncated adb capture as retryable only for volatile output (%s)", + async (volatileOutput) => { + vi.mocked(execFileBytes).mockRejectedValueOnce( + Object.assign(new Error("too much"), { + code: 1, + stdout: "", + stderr: "", + signal: null, + killed: false, + outputTruncated: true, + }), + ); + const result = await runAdbCapture( + volatileOutput ? "read_adb_logcat" : "install_adb_package", + { device: "device" }, + "/host/adb", + volatileOutput ? ["logcat", "-d"] : ["install", "app.apk"], + { timeoutMs: 100, maxOutputBytes: 1024, volatileOutput }, + undefined, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected a capture allowance refusal"); + const projected = projectAnalysisError(result.error); + expect(projected.retryable).toBe(volatileOutput); + expect(projected.remediation.action).toContain( + "The adb output capture allowance", + ); + expect(projected.remediation.action).toContain( + volatileOutput + ? "live output may change between requests" + : "Repeating the unchanged request fails the same way.", + ); + }, +); + it("keeps a physical failure even when the later abort reason is that same error", async () => { const controller = new AbortController(); const failure = new Error("physical command failure"); diff --git a/src/adb/AdbCommand.ts b/src/adb/AdbCommand.ts index c494fd71fd..b1859e385c 100644 --- a/src/adb/AdbCommand.ts +++ b/src/adb/AdbCommand.ts @@ -9,6 +9,7 @@ import type { AnalysisCapturedOutput, AnalysisError, } from "../domain/analysisErrorBase.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { err, ok, type Result } from "../domain/result.js"; import { execFileBytes, @@ -86,6 +87,11 @@ export const adbArguments = ( export interface AdbRunLimits { readonly timeoutMs: number; readonly maxOutputBytes: number; + /** + * Live device observations can shrink between calls. Mutation commands omit + * this so a truncated result stays non-retryable. + */ + readonly volatileOutput?: boolean; } /** Keep the selected device separate from the file addressed by the command. */ @@ -139,7 +145,14 @@ export const runAdbCapture = async ( "memory", `adb output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`, { max_output_bytes: limits.maxOutputBytes }, - { cause }, + { + cause, + ...fixedAllowanceOptions( + "The adb output capture allowance", + limits.volatileOutput === true ? "live" : "capture", + ), + ...(limits.volatileOutput === true ? { retryable: true } : {}), + }, ), ); const stderr = failure.stderr.trim(); @@ -268,7 +281,7 @@ export const probeAdbPathExistence = async ( { device: serial, path }, binary, adbArguments(serial, ["shell", "test", "-e", path]), - limits, + { ...limits, volatileOutput: true }, signal, ); if (probed.ok) { diff --git a/src/adb/AdbConfiguration.ts b/src/adb/AdbConfiguration.ts index bf5187b499..1de39f9c09 100644 --- a/src/adb/AdbConfiguration.ts +++ b/src/adb/AdbConfiguration.ts @@ -12,6 +12,7 @@ import { } from "../process/ExecFileOutput.js"; import type { ProviderAvailability } from "../application/AnalysisProvider.js"; import type { ProviderRejectionCode } from "../contracts/providerSelection.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { adbCommandCapturedOutput } from "./AdbCommand.js"; @@ -146,7 +147,13 @@ export const inspectAdbClient = async ( "memory", `adb version output exceeded the ${String(VERSION_MAX_OUTPUT_BYTES)}-byte capture budget`, { max_output_bytes: VERSION_MAX_OUTPUT_BYTES }, - options, + { + ...options, + ...fixedAllowanceOptions( + "The adb version-output capture allowance", + "capture", + ), + }, ); if (failure?.code === "ETIMEDOUT") throw new AnalysisTimeoutError( diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index e56c0448ab..677aedf379 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -1326,7 +1326,7 @@ export class AdbProvider { "serial" in request.input ? request.input.serial : undefined, ["shell", ...arguments_], ), - limits, + { ...limits, volatileOutput: true }, signal, ); switch (operation) { @@ -1346,6 +1346,7 @@ export class AdbProvider { { timeoutMs: DEVICES_TIMEOUT_MS, maxOutputBytes: DEVICES_MAX_OUTPUT_BYTES, + volatileOutput: true, }, signal, ); @@ -1382,6 +1383,7 @@ export class AdbProvider { { timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: GETPROP_MAX_OUTPUT_BYTES, + volatileOutput: true, }, signal, ); @@ -1631,6 +1633,7 @@ export class AdbProvider { { timeoutMs: LISTING_TIMEOUT_MS, maxOutputBytes: LISTING_MAX_OUTPUT_BYTES, + volatileOutput: true, }, signal, ); @@ -1917,6 +1920,7 @@ export class AdbProvider { { timeoutMs: PM_PATH_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES, + volatileOutput: true, }, signal, ); @@ -2704,7 +2708,11 @@ export class AdbProvider { "android.intent.category.LAUNCHER", input.package, ]), - { timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES }, + { + timeoutMs: SHELL_TIMEOUT_MS, + maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES, + volatileOutput: true, + }, signal, ); if (!resolved.ok) return resolved; @@ -2918,7 +2926,11 @@ export class AdbProvider { { device: input.serial }, selection.binary, adbArguments(input.serial, ["exec-out", "screencap", "-p"]), - { timeoutMs: SCREEN_TIMEOUT_MS, maxOutputBytes: SCREEN_MAX_OUTPUT_BYTES }, + { + timeoutMs: SCREEN_TIMEOUT_MS, + maxOutputBytes: SCREEN_MAX_OUTPUT_BYTES, + volatileOutput: true, + }, signal, ); if (!captured.ok) return captured; diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index 1bccd050e8..97a3cef52e 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -45,6 +45,7 @@ import { AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { err, ok, type Result } from "../domain/result.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; @@ -194,6 +195,10 @@ const apktoolCommandFailure = ( { cause, ...(capturedOutput === undefined ? {} : { capturedOutput }), + ...fixedAllowanceOptions( + "The apktool output capture allowance", + "capture", + ), }, ); // A physical launcher/capture failure does not establish target refusal. diff --git a/src/application/wasm/WasmInterfaceComparisonService.test.ts b/src/application/wasm/WasmInterfaceComparisonService.test.ts index 9936c7530b..85fee9985f 100644 --- a/src/application/wasm/WasmInterfaceComparisonService.test.ts +++ b/src/application/wasm/WasmInterfaceComparisonService.test.ts @@ -1,8 +1,13 @@ import { expect, it } from "vitest"; import { createEvidence } from "../../domain/evidence.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { wasmInterfaceComparisonSchema } from "../../contracts/wasm/wasmComparisonContracts.js"; import { compareWasmInterfaces } from "./WasmInterfaceComparisonService.js"; -import type { WasmInterface } from "../../domain/wasm/wasmInterface.js"; +import { + WASM_INTERFACE_REPRESENTATION_BYTES, + wasmInterfaceEntryRepresentationBytes, + type WasmInterface, +} from "../../domain/wasm/wasmInterface.js"; import { wasmArtifactFixture } from "../../../tests/fixtures/wasm/artifact.js"; const facts = ( @@ -179,6 +184,72 @@ it("resolves exact retained references and permits cancellation between comparis }); }); +/** One inventory under the comparison cap; two of them exceed it together. */ +const admittedInventory = (label: string): WasmInterface => { + const base = facts(label).entries[0]!; + const minimum = Math.floor(WASM_INTERFACE_REPRESENTATION_BYTES / 2) + 4096; + let low = 0; + let high = 4_000_000; + let best = 0; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const name = `${label}-${"n".repeat(mid)}`; + const bytes = wasmInterfaceEntryRepresentationBytes({ + ...base, + name, + wat_form: `(export "${name}" (func 0))`, + }); + if (bytes >= minimum) { + best = mid; + high = mid - 1; + } else low = mid + 1; + } + const name = `${label}-${"n".repeat(best)}`; + const entry = { + ...base, + name, + wat_form: `(export "${name}" (func 0))`, + }; + const bytes = wasmInterfaceEntryRepresentationBytes(entry); + if ( + bytes >= WASM_INTERFACE_REPRESENTATION_BYTES || + bytes + 512 >= WASM_INTERFACE_REPRESENTATION_BYTES + ) + throw new Error("Sized inventory is not individually admitted"); + return { state: "complete", issues: [], entries: [entry] }; +}; + +it("refuses two admitted inventories that together exceed the comparison cap", async () => { + const left = admittedInventory("left"); + const right = admittedInventory("right"); + const empty: WasmInterface = { state: "complete", issues: [], entries: [] }; + const alone = await compareWasmInterfaces({ + left: evidence(left), + right: evidence(empty), + scope: "both", + }); + expect(alone.ok).toBe(true); + const combined = await compareWasmInterfaces({ + left: evidence(left), + right: evidence(right), + scope: "both", + }); + expect(combined.ok).toBe(false); + if (combined.ok) throw new Error("Expected comparison allowance refusal"); + const projected = projectAnalysisError(combined.error); + expect(projected.retryable).toBe(false); + expect(projected.code).toBe("resource_constraint"); + expect(combined.error).toMatchObject({ resource: "capacity" }); + expect(projected.remediation.action).toContain( + "The WASM interface comparison inventory", + ); + expect(projected.remediation.action).toContain("fixed byte allowance"); + expect(projected.remediation.action).not.toContain("fixed count"); + expect(projected.remediation.action).not.toMatch( + /Wait for an active analysis/u, + ); +}); + it.each([false, true])( "keeps a one-sided unsupported interface unknown with reverse=%s", async (reverse) => { diff --git a/src/application/wasm/WasmInterfaceComparisonService.ts b/src/application/wasm/WasmInterfaceComparisonService.ts index f920a5deaa..32e1c80865 100644 --- a/src/application/wasm/WasmInterfaceComparisonService.ts +++ b/src/application/wasm/WasmInterfaceComparisonService.ts @@ -22,6 +22,7 @@ import { AnalysisInputError, AnalysisResourceConstraintError, } from "../../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; @@ -133,6 +134,10 @@ export const compareWasmInterfaces = async ( "capacity", "WASM comparison exceeds typed interface representation capacity", { maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES }, + fixedAllowanceOptions( + "The WASM interface comparison inventory", + "bytes", + ), ); if (++work % 256 === 0) { await setImmediate(); diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index 8acada8e5a..110f0f5dd2 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -45,6 +45,7 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { createEvidence } from "../domain/evidence.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { type InspectPacketCaptureInput, @@ -480,6 +481,10 @@ export class TsharkCaptureProvider implements PacketCapturePort { ...(observedOutput === undefined ? {} : { capturedOutput: observedOutput }), + ...fixedAllowanceOptions( + "The packet-field discovery record allowance", + "capture", + ), }, ); const reason = cause instanceof Error ? cause.message : String(cause); @@ -561,7 +566,13 @@ export class TsharkCaptureProvider implements PacketCapturePort { "file-size", reason, { input_bytes: LIMITS.input_bytes }, - { cause }, + { + cause, + ...fixedAllowanceOptions( + "the fixed packet-capture input allowance", + "input", + ), + }, ); if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") return new AnalysisArtifactChangedError(operation, path, reason, { diff --git a/src/domain/analysisView/WebNetworkProjectionAccumulator.ts b/src/domain/analysisView/WebNetworkProjectionAccumulator.ts index e512ba4f69..2f9563f019 100644 --- a/src/domain/analysisView/WebNetworkProjectionAccumulator.ts +++ b/src/domain/analysisView/WebNetworkProjectionAccumulator.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { fixedAllowanceOptions } from "../fixedAllowanceRemediation.js"; import { AnalysisResourceConstraintError, AnalysisUnsupportedTargetError, @@ -58,6 +59,10 @@ export abstract class WebNetworkProjectionAccumulator { "memory", `${this.budgetSubject} retained state and temporary work exceed the projection representation budget.`, { output_bytes: this.maximumBytes }, + fixedAllowanceOptions( + "The analysis-view projection allowance", + "parser", + ), ), ); } diff --git a/src/domain/fixedAllowanceRemediation.test.ts b/src/domain/fixedAllowanceRemediation.test.ts new file mode 100644 index 0000000000..7691620689 --- /dev/null +++ b/src/domain/fixedAllowanceRemediation.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { AnalysisResourceConstraintError } from "./analysisErrorCore.js"; +import { projectAnalysisError } from "./analysisErrorProjection.js"; +import { + fixedAllowanceOptions, + fixedAllowanceRemediation, + type FixedAllowanceKind, +} from "./fixedAllowanceRemediation.js"; + +const forbidden = { + parser: [ + /available host memory\. Retry/u, + /worker memory limits and available/u, + ], + count: [ + /Wait for an active analysis/u, + /\bretry\b/iu, + /fixed byte allowance/u, + ], + bytes: [/Wait for an active analysis/u, /\bretry\b/iu, /fixed count/u], + report: [/get_evidence_bundle/u, /complete CLI JSON output/u], + input: [/evidence reply/u, /file-size allowance for the evidence/u], + capture: [/available host memory\. Retry/u, /live output may change/u], + live: [/available host memory\. Retry/u, /unchanged request/u], + work: [/worker CPU limits and observed signal/u], +} as const; + +describe("fixed allowance recovery", () => { + it.each([ + ["parser", "memory"], + ["count", "capacity"], + ["bytes", "capacity"], + ["report", "transport"], + ["input", "file-size"], + ["capture", "memory"], + ["live", "memory"], + ["work", "cpu"], + ] as const)( + "projects %s allowances without the generic %s fallback", + (kind: FixedAllowanceKind, resource) => { + const allowance = `The ${kind} allowance`; + const error = new AnalysisResourceConstraintError( + "inspect_fixture", + resource, + "allowance exceeded", + { maximum: 1 }, + fixedAllowanceOptions(allowance, kind), + ); + const projected = projectAnalysisError(error); + expect(projected.retryable).toBe(false); + expect(projected.remediation.action).toBe( + fixedAllowanceRemediation(allowance, kind), + ); + expect(projected.remediation.action).toContain(allowance); + for (const pattern of forbidden[kind]) + expect(projected.remediation.action).not.toMatch(pattern); + if (kind === "live") + expect(projected.remediation.action).toContain( + "live output may change between requests", + ); + if (kind === "capture") + expect(projected.remediation.action).toContain( + "Repeating the unchanged request fails the same way.", + ); + }, + ); +}); diff --git a/src/domain/fixedAllowanceRemediation.ts b/src/domain/fixedAllowanceRemediation.ts new file mode 100644 index 0000000000..a121a7f15d --- /dev/null +++ b/src/domain/fixedAllowanceRemediation.ts @@ -0,0 +1,49 @@ +/** + * Recovery for a deterministic allowance. Host memory, worker limits, waiting, + * and a different output channel do not raise it. + */ +export type FixedAllowanceKind = + | "parser" + | "count" + | "bytes" + | "report" + | "input" + | "capture" + | "live" + | "work"; + +const unchangedRequest = "Repeating the unchanged request fails the same way."; + +/** Name the fixed allowance and the recovery that cannot admit the same request. */ +export const fixedAllowanceRemediation = ( + allowance: string, + kind: FixedAllowanceKind, +): string => { + switch (kind) { + case "parser": + return `${allowance} is a fixed allowance. Worker memory limits, inherited limits, and additional host memory do not raise it. Select another complete input within the allowance when that preserves the needed observation. ${unchangedRequest}`; + case "count": + return `${allowance} is a fixed count. Waiting for an active analysis to finish does not raise it. ${unchangedRequest}`; + case "bytes": + return `${allowance} is a fixed byte allowance. Waiting for an active analysis to finish does not raise it. ${unchangedRequest}`; + case "report": + return `${allowance} is a fixed report allowance. This refusal does not leave a completed report or Evidence record to retrieve, and complete CLI JSON does not raise it. ${unchangedRequest}`; + case "input": + return `Select a suitable complete artifact within ${allowance}. Increasing an evidence-reply file-size allowance does not admit this input. Do not truncate required bytes. ${unchangedRequest}`; + case "capture": + return `${allowance} is a fixed capture allowance. Worker memory limits, inherited limits, and additional host memory do not raise it. ${unchangedRequest}`; + case "live": + return `${allowance} is a fixed capture allowance. Worker memory limits, inherited limits, and additional host memory do not raise it. Capturing the same output exceeds the allowance again; live output may change between requests.`; + case "work": + return `${allowance} is a fixed work allowance. Worker CPU limits and additional CPU time do not raise it. Select another complete artifact within the allowance. ${unchangedRequest}`; + } +}; + +/** Non-retryable options for a fixed allowance. Occupied capacity stays retryable. */ +export const fixedAllowanceOptions = ( + allowance: string, + kind: FixedAllowanceKind, +): { readonly retryable: false; readonly remediationAction: string } => ({ + retryable: false, + remediationAction: fixedAllowanceRemediation(allowance, kind), +}); diff --git a/src/dotnet/ManagedSnapshot.ts b/src/dotnet/ManagedSnapshot.ts index 45d1111cf3..4edfd89f84 100644 --- a/src/dotnet/ManagedSnapshot.ts +++ b/src/dotnet/ManagedSnapshot.ts @@ -17,6 +17,7 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { RegularFileChangedError } from "../filesystem/RegularFile.js"; import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; @@ -42,6 +43,7 @@ const managedSnapshotCapacityError = ( "memory", "Managed static inspection requires an eagerly retained PE snapshot within its input byte budget.", { max_snapshot_bytes: MAX_MANAGED_SNAPSHOT_BYTES }, + fixedAllowanceOptions("The managed PE snapshot allowance", "parser"), ); export const readManagedSnapshot = async ( diff --git a/src/electron/ElectronFuseFailures.ts b/src/electron/ElectronFuseFailures.ts index e7658c3a03..40d88a8dcd 100644 --- a/src/electron/ElectronFuseFailures.ts +++ b/src/electron/ElectronFuseFailures.ts @@ -17,6 +17,7 @@ import { AnalysisResourceConstraintError, AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ZodError } from "zod"; import { @@ -93,7 +94,15 @@ export const electronFuseFailure = ( cause.boundary === "file-size" ? { maximum_input_bytes: 1024 * 1024 * 1024 } : { maximum_sentinels_per_slice: 32 }, - { cause }, + { + cause, + ...fixedAllowanceOptions( + cause.boundary === "file-size" + ? "the 1 GiB Electron fuse input allowance" + : "The Electron fuse sentinel inventory", + cause.boundary === "file-size" ? "input" : "count", + ), + }, ); if (cause instanceof ElectronFuseFormatFailure) return cause.kind === "unsupported" diff --git a/src/electron/ElectronFuseProvider.test.ts b/src/electron/ElectronFuseProvider.test.ts index dd6227caaf..57c41ac773 100644 --- a/src/electron/ElectronFuseProvider.test.ts +++ b/src/electron/ElectronFuseProvider.test.ts @@ -575,8 +575,15 @@ it("enforces the 32 sentinel boundary with typed and public limit details", asyn expect(result.error._tag).toBe("AnalysisResourceConstraintError"); expect(projectAnalysisError(result.error)).toMatchObject({ code: "resource_constraint", + retryable: false, details: { reported_limits: { maximum_sentinels_per_slice: 32 } }, + remediation: { + action: expect.stringContaining("sentinel inventory"), + }, }); + expect( + projectAnalysisError(result.error).remediation.action, + ).not.toMatch(/Wait for an active analysis/u); } } } @@ -593,7 +600,11 @@ it("rejects a sparse artifact over 1 GiB before scanning", async () => { expect(result.error._tag).toBe("AnalysisResourceConstraintError"); expect(projectAnalysisError(result.error)).toMatchObject({ code: "resource_constraint", + retryable: false, details: { reported_limits: { maximum_input_bytes: 1024 * 1024 * 1024 } }, + remediation: { + action: expect.stringContaining("does not admit this input"), + }, }); } }); diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index ca37a58bc7..e67367e965 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -29,6 +29,7 @@ import { } from "../domain/analysisErrorCore.js"; import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -74,6 +75,8 @@ const APK_MAX_BYTES = 2 * 1024 * 1024 * 1024; // engine labels this keeps expanded result metadata below 512 KiB for ordinary // ABI names, independently of the archive's compressed-byte budget. const MAX_FLUTTER_ABIS = 32; +const flutterBudget = (allowance: string, kind: "parser" | "input") => + fixedAllowanceOptions(allowance, kind); const MAX_POOL_URIS = 500; const MAX_POOL_TOKENS = 2_000; /** A complete AOT image carries exactly one VM and one isolate section. */ @@ -190,7 +193,10 @@ const mapInputFailure = ( "memory", cause.message, { max_metadata_read_bytes: APK_METADATA_READ_MAX_BYTES }, - { cause }, + { + cause, + ...flutterBudget("The Flutter APK metadata read allowance", "parser"), + }, ); return new AnalysisInputError(operation, { cause }, [ { @@ -296,6 +302,7 @@ const collectFlutterLibraries = async ( max_flutter_abis: MAX_FLUTTER_ABIS, max_library_path_bytes: MAX_FLUTTER_LIBRARY_PATH_BYTES, }, + flutterBudget("The Flutter ABI inventory allowance", "parser"), ); libraries.push({ entry, @@ -331,6 +338,7 @@ const checkApkSize = (operation: string, bytes: number): void => { "file-size", `The selected APK exceeds the ${String(APK_MAX_BYTES)}-byte snapshot budget`, { max_apk_bytes: APK_MAX_BYTES, observed_bytes: bytes }, + flutterBudget("the Flutter APK snapshot allowance", "input"), ); }; @@ -659,6 +667,7 @@ export class FlutterBuildProvider { "file-size", `${entry.path} exceeds the library read budget`, { max_library_bytes: LIBRARY_MAX_BYTES, entry_path: entry.path }, + flutterBudget("the Flutter library read allowance", "input"), ), ); } @@ -679,6 +688,7 @@ export class FlutterBuildProvider { max_total_bytes: TOTAL_EXTRACTION_MAX_BYTES, declared_total: declaredTotal, }, + flutterBudget("the Flutter cumulative extraction allowance", "input"), ), ); @@ -730,6 +740,7 @@ export class FlutterBuildProvider { max_library_bytes: LIBRARY_MAX_BYTES, entry_path: entry.path, }, + flutterBudget("the Flutter library read allowance", "input"), ), ); hash.update(view); @@ -747,6 +758,10 @@ export class FlutterBuildProvider { "file-size", `cumulative library extraction across ${String(flutterEntries.length)} entries exceeds the ${String(TOTAL_EXTRACTION_MAX_BYTES)}-byte budget`, { max_total_bytes: TOTAL_EXTRACTION_MAX_BYTES }, + flutterBudget( + "the Flutter cumulative extraction allowance", + "input", + ), ), ); } @@ -765,6 +780,10 @@ export class FlutterBuildProvider { max_snapshot_hash_candidates: MAX_SNAPSHOT_HASH_CANDIDATES, entry_path: entry.path, }, + flutterBudget( + "The Flutter snapshot-hash candidate allowance", + "parser", + ), ), ); facts.set(abi, { @@ -965,6 +984,7 @@ export class FlutterBuildProvider { "file-size", `lib/${selected.abi}/libapp.so declares ${String(declared)} bytes, exceeding the ${String(LIBRARY_MAX_BYTES)}-byte read budget`, { max_library_bytes: LIBRARY_MAX_BYTES }, + flutterBudget("the Flutter library read allowance", "input"), ), ); const imagePath = join(snapshot.root, "libapp.so"); @@ -994,6 +1014,7 @@ export class FlutterBuildProvider { "file-size", `lib/${selected.abi}/libapp.so exceeds the ${String(LIBRARY_MAX_BYTES)}-byte read budget while streaming`, { max_library_bytes: LIBRARY_MAX_BYTES }, + flutterBudget("the Flutter library read allowance", "input"), ), ); imageHash.update(view); diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index 250e9a288d..7bbeb5c1d5 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -27,6 +27,7 @@ import { type InspectGoBinaryInput, } from "../domain/go/goBinary.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -62,7 +63,17 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => { cause.boundary === "search" ? "cpu" : "memory", `Selected artifact ${path}: ${cause.message}`, { boundary: cause.boundary, maximum_bytes: cause.maximum_bytes }, - { cause }, + { + cause, + ...fixedAllowanceOptions( + cause.boundary === "search" + ? "The Go build-info header-search allowance" + : cause.boundary === "build-info" + ? "The Go build-info string allowance" + : "The Go structural decoding allowance", + cause.boundary === "search" ? "work" : "parser", + ), + }, ); if (cause instanceof GoBinaryFormatFailure) return cause.kind === "unsupported" @@ -152,6 +163,7 @@ const outputLimit = (path: string) => "transport", `Complete Go metadata for ${path} exceeds the inline output budget; no partial success is returned.`, { maximum_output_bytes: OUTPUT_BYTES }, + fixedAllowanceOptions("The Go complete-report allowance", "report"), ); /** Fixed reader boundaries plus unknowns left by the observed metadata. */ diff --git a/src/il2cpp/Il2cppGhidraSeeds.ts b/src/il2cpp/Il2cppGhidraSeeds.ts index 2210068398..a19a987c76 100644 --- a/src/il2cpp/Il2cppGhidraSeeds.ts +++ b/src/il2cpp/Il2cppGhidraSeeds.ts @@ -7,12 +7,24 @@ import { AnalysisInputError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; const OPERATION = "inspect_il2cpp_metadata"; -const capacity = (message: string) => - new AnalysisResourceConstraintError(OPERATION, "capacity", message, { - maximum_seed_bytes: MAX_GHIDRA_SEED_BYTES, - maximum_seed_name_characters: MAX_GHIDRA_SEED_NAME_LENGTH, - }); +const capacity = ( + message: string, + allowance: string, + kind: "bytes" | "count", +) => + new AnalysisResourceConstraintError( + OPERATION, + "capacity", + message, + { + maximum_seed_bytes: MAX_GHIDRA_SEED_BYTES, + maximum_seed_entries: MAX_GHIDRA_SEED_ENTRIES, + maximum_seed_name_characters: MAX_GHIDRA_SEED_NAME_LENGTH, + }, + fixedAllowanceOptions(allowance, kind), + ); import type { Il2cppMetadata } from "../domain/il2cpp/il2cppMetadata.js"; /** A linked metadata report reduced to Ghidra function seeds. */ @@ -72,11 +84,19 @@ const typeName = (report: Il2cppMetadata, index: number): string => { ]); length += type.name.length + 1; if (length > MAX_GHIDRA_SEED_NAME_LENGTH) - throw capacity("Qualified seed type name exceeds the Ghidra name limit."); + throw capacity( + "Qualified seed type name exceeds the Ghidra name limit.", + "The IL2CPP Ghidra seed-name allowance", + "count", + ); parts.unshift(type.name); namespace = type.namespace; if (length + namespace.length > MAX_GHIDRA_SEED_NAME_LENGTH) - throw capacity("Qualified seed type name exceeds the Ghidra name limit."); + throw capacity( + "Qualified seed type name exceeds the Ghidra name limit.", + "The IL2CPP Ghidra seed-name allowance", + "count", + ); at = type.declaring_type; } return namespace === "" ? parts.join(".") : `${namespace}.${parts.join(".")}`; @@ -112,7 +132,11 @@ export const il2cppGhidraSeeds = ( const typeNames = new Map(); const reserve = (length: number) => { if (length > MAX_GHIDRA_SEED_BYTES - bytes) - throw capacity("Seed file exceeds Ghidra byte admission."); + throw capacity( + "Seed file exceeds Ghidra byte admission.", + "The IL2CPP Ghidra seed-file allowance", + "bytes", + ); bytes += length; }; const emit = (text: string) => { @@ -144,6 +168,8 @@ export const il2cppGhidraSeeds = ( if (ownerName.length + 2 + method.name.length > MAX_GHIDRA_SEED_NAME_LENGTH) throw capacity( "Qualified method seed name exceeds Ghidra character admission.", + "The IL2CPP Ghidra seed-name allowance", + "count", ); const name = `${ownerName}$$${method.name}`.replace(/\s/gu, "_"); if (name.includes("\0")) @@ -169,7 +195,11 @@ export const il2cppGhidraSeeds = ( } seeded.set(address, { kept: name, also: [] }); if (seeded.size > MAX_GHIDRA_SEED_ENTRIES) - throw capacity("Seed function count exceeds Ghidra entry admission."); + throw capacity( + "Seed function count exceeds Ghidra entry admission.", + "The IL2CPP Ghidra seed-entry allowance", + "count", + ); emit(`0x${address.toString(16)}\tfunction\t${name}\n`); }); const foldedGroups = [...seeded] diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index d3561d94d5..b215ec461a 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -27,6 +27,7 @@ import { AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, + AnalysisCapabilityUnavailableError, AnalysisInputError, AnalysisOutputError, AnalysisResourceConstraintError, @@ -37,6 +38,7 @@ import { type Il2cppMetadata, type Il2cppMetadataProviderInput, } from "../domain/il2cpp/il2cppMetadata.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -76,7 +78,13 @@ const failureForPrimary = ( cause.reason === "native-type-text-capacity" ? "memory" : "capacity", cause.message, {}, - { cause }, + { + cause, + ...fixedAllowanceOptions( + "The IL2CPP native-link allowance", + cause.reason === "native-type-text-capacity" ? "parser" : "count", + ), + }, ); if ( cause instanceof Il2cppBinaryFormatFailure || @@ -112,7 +120,10 @@ const failureForPrimary = ( "file-size", cause.message, { maximum_input_bytes: maximum }, - { cause }, + { + cause, + ...fixedAllowanceOptions("the fixed IL2CPP input allowance", "input"), + }, ); if (cause.reason === "path") return new AnalysisInputError(OPERATION, { cause }, [ @@ -220,12 +231,20 @@ const binaryReport = ( method_addresses: link.method_addresses, }); +/** + * Source facts stay on the refusal. The shared report sentence would deny an + * Evidence record that this reader attaches and MCP retains. + */ +const completeReportRecovery = + "The IL2CPP complete-report allowance is a fixed report allowance. No complete metadata inventory is retained. Source facts already observed stay with this refusal, and complete CLI JSON does not raise the allowance. Repeating the unchanged request fails the same way."; + const reportLimit = (path: string, maximumBytes: number) => new AnalysisResourceConstraintError( OPERATION, "memory", `Complete IL2CPP metadata for ${path} exceeds the retained report budget; no complete inventory is retained.`, { maximum_report_bytes: maximumBytes }, + { retryable: false, remediationAction: completeReportRecovery }, ); const sourceArtifact = (path: string, snapshot: StableArtifactSnapshot) => ({ @@ -316,6 +335,8 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort { readonly identity = IL2CPP_METADATA_PROVIDER_IDENTITY; readonly #resources = new ArtifactResourceScope(); #inspecting = false; + /** `close()` stops admission permanently, including while an inspection is active. */ + #admissionStopped = false; /** `outputBytes` exists so tests can reach the guard with small files. */ constructor( @@ -324,6 +345,7 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort { ) {} async close(): Promise { + this.#admissionStopped = true; try { await this.#resources.close(); } catch (cause: unknown) { @@ -352,13 +374,29 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort { return err( new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), ); + if (this.#admissionStopped) + return err( + new AnalysisCapabilityUnavailableError( + IL2CPP_METADATA_PROVIDER_IDENTITY.id, + OPERATION, + "IL2CPP metadata reader is closed", + { + remediationAction: + "This reader has stopped admitting inspections. Create a new IL2CPP metadata reader to inspect again. Repeating the request on this reader fails the same way.", + }, + ), + ); if (this.#inspecting) return err( new AnalysisResourceConstraintError( OPERATION, - "memory", + "capacity", "Only one IL2CPP metadata inspection is admitted per reader.", { maximum_concurrent_inspections: 1 }, + { + remediationAction: + "Retry after the active IL2CPP metadata inspection on this reader completes.", + }, ), ); this.#inspecting = true; diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index 3a455c839f..f5343bbfe2 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -24,6 +24,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { @@ -217,6 +218,10 @@ export const inspectAppleDispatchMetadata = async ( "memory", "Apple static metadata input exceeds its 64 MiB snapshot budget", { input_bytes: maximumBytes }, + fixedAllowanceOptions( + "The Apple static metadata snapshot allowance", + "parser", + ), ); const cancelled = () => new AnalysisCancelledError("inspect_native_dispatch_metadata", { diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 145c3d43a5..7541e84398 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -55,6 +55,7 @@ import { AnalysisResourceConstraintError, AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { @@ -1216,7 +1217,13 @@ const translateCommandFailure = ( "memory", `Native command output exceeded ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)} bytes; complete output is required for analysis.`, { max_output_bytes: NATIVE_COMMAND_OUTPUT_BUDGET_BYTES }, - errorOptions, + { + ...errorOptions, + ...fixedAllowanceOptions( + "The native command output capture allowance", + "capture", + ), + }, ), ); return bindCleanup( diff --git a/src/native/pe/PeResourceParser.ts b/src/native/pe/PeResourceParser.ts index 1f7829268a..1f266defb3 100644 --- a/src/native/pe/PeResourceParser.ts +++ b/src/native/pe/PeResourceParser.ts @@ -19,7 +19,9 @@ type Range = { offset: number; bytes: number }; type OccupiedRange = Range & { kind: "directory" | "name" | "data-entry" | "payload"; }; -const METADATA_BYTES = 8 * 1024 * 1024; +/** Complete PE resource result retained before a refusal. */ +export const PE_RESOURCE_METADATA_BYTES = 8 * 1024 * 1024; +const METADATA_BYTES = PE_RESOURCE_METADATA_BYTES; const limit = (message: string, boundary: ArtifactReaderBoundary): never => { throw new ArtifactReaderFailure("limit", message, { boundary }); }; diff --git a/src/native/pe/PeResourcesProvider.test.ts b/src/native/pe/PeResourcesProvider.test.ts index f54100c22e..62d7502d68 100644 --- a/src/native/pe/PeResourcesProvider.test.ts +++ b/src/native/pe/PeResourcesProvider.test.ts @@ -172,3 +172,32 @@ describe("PE resource provider budget reporting", () => { }); }); }); + +it("names the fixed PE metadata allowance instead of host memory", async () => { + const root = await createTestTempDirectory("rea-pe-metadata-budget-"); + const path = join(root, "resource.exe"); + const name = "n".repeat(200); + const resources = Array.from({ length: 5500 }, (_, index) => ({ + type: 10, + name: `${name}${String(index).padStart(4, "0")}`, + language: 1033, + payload: Buffer.alloc(0), + })); + await writeFile( + path, + peResourceFixture(false, resources, 12 * 1024 * 1024).bytes, + ); + const result = await inspect(path); + if (result.ok) throw new Error("Expected a metadata refusal"); + const error = result.error as AnalysisResourceConstraintError; + expect(error.resource).toBe("memory"); + expect(error.reportedLimits).toMatchObject({ + maximum_metadata_bytes: 8 * 1024 * 1024, + }); + const projected = projectAnalysisError(error); + expect(projected.retryable).toBe(false); + expect(projected.remediation.action).toContain( + "PE resource metadata allowance", + ); + expect(projected.remediation.action).not.toMatch(/available host memory/u); +}); diff --git a/src/native/pe/PeResourcesProvider.ts b/src/native/pe/PeResourcesProvider.ts index bd62e9e972..702cda02f5 100644 --- a/src/native/pe/PeResourcesProvider.ts +++ b/src/native/pe/PeResourcesProvider.ts @@ -1,3 +1,4 @@ +import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { @@ -25,7 +26,10 @@ import { import { err, ok } from "../../domain/result.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { PE_RESOURCES_PROVIDER } from "../../application/InvestigationProviders.js"; -import { parsePeResources } from "./PeResourceParser.js"; +import { + parsePeResources, + PE_RESOURCE_METADATA_BYTES, +} from "./PeResourceParser.js"; /** Portable parser over one bounded, identity-checked regular file. */ export class PeResourcesProvider implements PeResourcesPort { @@ -118,7 +122,8 @@ const peResourcesFailurePrimary = ( if (cause instanceof ArtifactReaderFailure) { // An input byte budget is a file-size bound, not a memory constraint, and // an entry budget is a capacity bound. Report what was actually exceeded. - if (cause.reason === "limit") + if (cause.reason === "limit") { + const metadata = cause.boundary === "memory"; return new AnalysisResourceConstraintError( operation, cause.boundary ?? "memory", @@ -126,22 +131,31 @@ const peResourcesFailurePrimary = ( { max_file_bytes: input.max_file_bytes, max_entries: input.max_entries, + ...(metadata + ? { maximum_metadata_bytes: PE_RESOURCE_METADATA_BYTES } + : {}), }, { cause, - // Both bounds are caller-selected and deterministic for the same - // input, so waiting for an active analysis cannot help. - ...(cause.boundary === undefined || cause.boundary === "memory" + // Caller-selected budgets and the fixed metadata allowance are + // deterministic for the same input, so waiting cannot help. + ...(cause.boundary === undefined ? {} - : { - retryable: false, - remediationAction: inputBudgetRemediation( - cause.boundary, - input, - ), - }), + : metadata + ? fixedAllowanceOptions( + "The PE resource metadata allowance", + "parser", + ) + : { + retryable: false as const, + remediationAction: inputBudgetRemediation( + cause.boundary, + input, + ), + }), }, ); + } if (cause.reason === "path" || cause.reason === "format") return new AnalysisInputError(operation, { cause }, [ { diff --git a/src/pcap/PcapCommand.ts b/src/pcap/PcapCommand.ts index be4ea4564f..8e16648e5b 100644 --- a/src/pcap/PcapCommand.ts +++ b/src/pcap/PcapCommand.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, @@ -223,7 +224,13 @@ export const runPcapCommand = async (options: { "memory", "Tool output exceeded its complete-output budget", { max_output_bytes: limits.maxOutputBytes }, - details, + { + ...details, + ...fixedAllowanceOptions( + "The packet-tool output capture allowance", + "capture", + ), + }, ); if ( cause instanceof WindowsBatchCommandError || diff --git a/src/pcap/PcapOutput.ts b/src/pcap/PcapOutput.ts index 3944d3871e..7226e46370 100644 --- a/src/pcap/PcapOutput.ts +++ b/src/pcap/PcapOutput.ts @@ -5,6 +5,7 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { parseRecordedTransportUrl } from "../domain/recordedTransportUrl.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; const OPERATION = "inspect_pcap_capture"; @@ -161,6 +162,7 @@ export const parseTsharkJsonOutput = async ( "memory", "tshark JSON exceeds the bounded decoding budget", { max_json_bytes: TSHARK_JSON_MAX_BYTES }, + fixedAllowanceOptions("The tshark JSON decoding allowance", "capture"), ); // The selected-field protocol has five container levels. Reject deeper // graphs before materializing them; quoted brackets are string data. @@ -192,6 +194,7 @@ export const parseTsharkJsonOutput = async ( "memory", "tshark JSON expansion exceeds the decoding budget", { max_decoded_bytes: MAX_DECODED_BYTES }, + fixedAllowanceOptions("The tshark JSON expansion allowance", "capture"), ); } let packets: unknown; @@ -220,6 +223,10 @@ export const parseTsharkJsonOutput = async ( "memory", "Packet inventory exceeds the retained-fact budget", { max_retained_bytes: MAX_RETAINED_BYTES }, + fixedAllowanceOptions( + "The packet-inventory retained-fact allowance", + "capture", + ), ); }; const add = (set: Set, value: string): void => { diff --git a/src/pcap/PcapSnapshot.ts b/src/pcap/PcapSnapshot.ts index fb1620d86e..b4e3d7919d 100644 --- a/src/pcap/PcapSnapshot.ts +++ b/src/pcap/PcapSnapshot.ts @@ -11,6 +11,7 @@ import { AnalysisInputError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { @@ -156,6 +157,10 @@ export class PcapSnapshot { "file-size", "Capture exceeds the owned-snapshot disk budget", { max_capture_bytes: MAX_CAPTURE_BYTES }, + fixedAllowanceOptions( + "the owned capture snapshot allowance", + "input", + ), ); const destination = this.own( new OwnedFileHandle(await open(this.path, "wx", 0o600)), diff --git a/src/process/ArtifactAnalysisResources.test.ts b/src/process/ArtifactAnalysisResources.test.ts index 1536c0a4ae..2e59422afb 100644 --- a/src/process/ArtifactAnalysisResources.test.ts +++ b/src/process/ArtifactAnalysisResources.test.ts @@ -491,6 +491,47 @@ it.each([false, true])( }, ); +it("treats a fixed command-output cap as a non-retryable capture allowance", async () => { + const resources = new ArtifactAnalysisResources( + "fixture", + "inspect_packet_fields", + readStableArtifact, + readLinuxResourceLimits, + { message: "busy", details: {} }, + ); + const failure = new OwnedCommandFailure( + "output-limit", + "command output exceeded the retained limit", + null, + ); + try { + const error = resources.commandFailure( + failure, + { command_timeout_ms: 100, output_bytes: 1024 }, + undefined, + ); + expect(error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + resource: "memory", + reportedLimits: { output_bytes: 1024 }, + retryable: false, + }); + const projected = projectAnalysisError(error); + expect(projected.retryable).toBe(false); + expect(projected.remediation.action).toContain( + "The retained command-output allowance", + ); + expect(projected.remediation.action).not.toMatch( + /Review the reported worker memory limits/u, + ); + expect(projected.remediation.action).not.toMatch( + /available host memory\. Retry/u, + ); + } finally { + await resources.close(); + } +}); + it("retains a physical command failure when the owner also observed late cancellation", async () => { const resources = new ArtifactAnalysisResources( "fixture", diff --git a/src/process/ArtifactAnalysisResources.ts b/src/process/ArtifactAnalysisResources.ts index 35f2d4703d..29168213a1 100644 --- a/src/process/ArtifactAnalysisResources.ts +++ b/src/process/ArtifactAnalysisResources.ts @@ -22,6 +22,7 @@ import { } from "../domain/analysisErrorCore.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -362,7 +363,13 @@ export class ArtifactAnalysisResources { "memory", cause.message, { output_bytes: limits.output_bytes }, - detail, + { + ...detail, + ...fixedAllowanceOptions( + "The retained command-output allowance", + "capture", + ), + }, ); if ( cause.reason === "process" && diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index 7e1b826677..1f88804b5b 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -26,6 +26,7 @@ import { type InspectRustBinaryInput, type RustBinary, } from "../domain/rust/rustBinary.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { err, ok, type Result } from "../domain/result.js"; import { readRustBinaryImage } from "./RustBinaryImage.js"; @@ -120,6 +121,7 @@ const outputLimit = (path: string) => "transport", `Complete Rust metadata for ${path} exceeds the inline output budget; no partial success is returned.`, { maximum_output_bytes: RUST_OUTPUT_BYTES }, + fixedAllowanceOptions("The Rust complete-report allowance", "report"), ); const limitationsFor = ( @@ -186,7 +188,21 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => { cause.constraint.resource, `Selected artifact ${path}: ${cause.message}`, cause.constraint.reported_limits, - { cause }, + { + cause, + ...fixedAllowanceOptions( + cause.constraint.resource === "capacity" + ? "The rustc commit-path inventory" + : cause.constraint.resource === "transport" + ? "The Rust complete-report allowance" + : "This Rust inspection allowance", + cause.constraint.resource === "capacity" + ? "count" + : cause.constraint.resource === "transport" + ? "report" + : "parser", + ), + }, ); if (cause instanceof RustAuditableFormatFailure) return cause.kind === "unsupported" @@ -274,7 +290,10 @@ const artifactReaderFailure = ( "file-size", cause.message, { maximum_input_bytes: RUST_BINARY_INPUT_BYTES }, - { cause }, + { + cause, + ...fixedAllowanceOptions("the fixed Rust input allowance", "input"), + }, ); if (cause.reason === "path") return new AnalysisInputError(OPERATION, { cause }, [ diff --git a/src/signals/SigrokRecordingProvider.ts b/src/signals/SigrokRecordingProvider.ts index e623e02985..6b8918d576 100644 --- a/src/signals/SigrokRecordingProvider.ts +++ b/src/signals/SigrokRecordingProvider.ts @@ -43,6 +43,7 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { createEvidence } from "../domain/evidence.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { windowAnnotationText } from "../domain/signals/annotationTextWindow.js"; import { @@ -439,6 +440,10 @@ export class SigrokRecordingProvider implements SignalRecordingPort { ...(observedOutput === undefined ? {} : { capturedOutput: observedOutput }), + ...fixedAllowanceOptions( + "The sigrok decoder-discovery line allowance", + "capture", + ), }, ); const reason = cause instanceof Error ? cause.message : String(cause); @@ -520,7 +525,10 @@ export class SigrokRecordingProvider implements SignalRecordingPort { "file-size", reason, { input_bytes: LIMITS.input_bytes }, - { cause }, + { + cause, + ...fixedAllowanceOptions("the fixed sigrok input allowance", "input"), + }, ); if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") return new AnalysisArtifactChangedError(operation, path, reason, { diff --git a/src/sqlite/SqliteDatabaseWorkerExecution.ts b/src/sqlite/SqliteDatabaseWorkerExecution.ts index 4d06f9c07a..d3fea33924 100644 --- a/src/sqlite/SqliteDatabaseWorkerExecution.ts +++ b/src/sqlite/SqliteDatabaseWorkerExecution.ts @@ -8,6 +8,7 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { z } from "zod"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, @@ -97,7 +98,10 @@ const rejectedReply = ( sqlite_value_or_record_bytes: SQLITE_DATABASE_LIMITS.outputBytes, worker_reply_bytes: SQLITE_DATABASE_LIMITS.outputBytes, }, - { capturedOutput }, + { + capturedOutput, + ...fixedAllowanceOptions("The SQLite worker reply allowance", "report"), + }, ); return new AnalysisInputError(operation, { capturedOutput }, [ { diff --git a/src/wasm/WasmInterfaceBudget.test.ts b/src/wasm/WasmInterfaceBudget.test.ts new file mode 100644 index 0000000000..9f4edd5fde --- /dev/null +++ b/src/wasm/WasmInterfaceBudget.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vitest"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { WASM_INTERFACE_REPRESENTATION_BYTES } from "../domain/wasm/wasmInterface.js"; +import { WasmInterfaceBudget } from "./WasmInterfaceBudget.js"; + +it("refuses typed WASM interface growth without asking the caller to wait", () => { + const budget = new WasmInterfaceBudget(); + expect(() => + budget.reserve(WASM_INTERFACE_REPRESENTATION_BYTES + 1), + ).toThrowError(); + try { + budget.reserve(WASM_INTERFACE_REPRESENTATION_BYTES + 1); + throw new Error("Expected a representation refusal"); + } catch (error) { + const projected = projectAnalysisError( + error as Parameters[0], + ); + expect(projected).toMatchObject({ + retryable: false, + details: { + resource: "capacity", + reported_limits: { + maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES, + }, + }, + }); + expect(projected.remediation.action).toContain( + "typed WASM interface inventory", + ); + expect(projected.remediation.action).toContain("fixed byte allowance"); + expect(projected.remediation.action).not.toContain("fixed count"); + expect(projected.remediation.action).not.toMatch( + /Wait for an active analysis/u, + ); + } +}); diff --git a/src/wasm/WasmInterfaceBudget.ts b/src/wasm/WasmInterfaceBudget.ts index 0d928b7ffb..dc910ea2b3 100644 --- a/src/wasm/WasmInterfaceBudget.ts +++ b/src/wasm/WasmInterfaceBudget.ts @@ -1,4 +1,5 @@ import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { WASM_INTERFACE_REPRESENTATION_BYTES } from "../domain/wasm/wasmInterface.js"; /** Eager typed observations share the existing WABT output capacity, not an agent token estimate. */ @@ -11,6 +12,7 @@ export class WasmInterfaceBudget { "capacity", "Typed WASM interface decoding exceeds the WABT output representation budget", { maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES }, + fixedAllowanceOptions("The typed WASM interface inventory", "bytes"), ); this.#remaining -= bytes; } diff --git a/tests/boundary/captures/captureProvider.test.ts b/tests/boundary/captures/captureProvider.test.ts index a7ec58b29f..9265e8120c 100644 --- a/tests/boundary/captures/captureProvider.test.ts +++ b/tests/boundary/captures/captureProvider.test.ts @@ -1,3 +1,4 @@ +import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; import { packetFieldsCatalogSchema } from "../../../src/domain/captures/packetFields.js"; import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js"; import { it, expect, onTestFinished } from "vitest"; @@ -613,11 +614,22 @@ it.each([ }); if (mode === "invalid-json") expect(result.error.capturedOutput?.stdout).toBe("not json\n"); - if (mode === "overflow") + if (mode === "overflow") { + expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); + if (!(result.error instanceof AnalysisResourceConstraintError)) + throw new Error("Expected command-output allowance refusal"); expect(result.error).toMatchObject({ resource: "memory", reportedLimits: { output_bytes: 32 * 1024 * 1024 }, + retryable: false, }); + expect(result.error.remediationAction).toContain( + "The retained command-output allowance", + ); + expect(result.error.remediationAction).not.toMatch( + /Review the reported worker memory limits/u, + ); + } }); it("retains completed discovery on late cancellation and cleanup failure", async () => { diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts index 289f159ba3..9bcc4ac1d7 100644 --- a/tests/boundary/filesystem/flutterBuildProvider.test.ts +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -928,7 +928,11 @@ it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( const result = await provider.execute({ operation, input: { path } }); if (result.ok) throw new Error("expected a size refusal"); expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); - expect(projectAnalysisError(result.error).code).toBe("resource_constraint"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("resource_constraint"); + expect(projected.retryable).toBe(false); + expect(projected.remediation.action).toContain("APK snapshot allowance"); + expect(projected.remediation.action).not.toMatch(/evidence reply/u); expect(result.error).toMatchObject({ operation, reportedLimits: { diff --git a/tests/boundary/go/goBinaryProvider.test.ts b/tests/boundary/go/goBinaryProvider.test.ts index 7c9c9a5475..e59484d03e 100644 --- a/tests/boundary/go/goBinaryProvider.test.ts +++ b/tests/boundary/go/goBinaryProvider.test.ts @@ -7,9 +7,11 @@ import { AnalysisResourceConstraintError, AnalysisUnsupportedTargetError, } from "../../../src/domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { createGoBinaryDiagnosticFixtures, createGoBinaryFixture, + createGoOverlappingElfFixture, } from "../../fixtures/go/image.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -67,6 +69,34 @@ it("preserves the actual build-info byte guard as a resource failure", async () boundary: "build-info", maximum_bytes: 1024 * 1024, }); + const projected = projectAnalysisError(result.error); + expect(projected.retryable).toBe(false); + expect(projected.remediation.action).toContain( + "Go build-info string allowance", + ); + expect(projected.remediation.action).not.toMatch(/available host memory/u); +}); + +it("keeps the header-search budget distinct from a worker CPU limit", async () => { + const root = await createTestTempDirectory("rea-go-search-"); + const path = join(root, "overlapping.elf"); + await writeFile(path, createGoOverlappingElfFixture()); + const result = await new GoBinaryProvider().inspect({ path }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Overlapping header search must be refused"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + retryable: false, + details: { + resource: "cpu", + reported_limits: { + boundary: "search", + maximum_bytes: 256 * 1024 * 1024, + }, + }, + }); + expect(projected.remediation.action).toContain("header-search allowance"); + expect(projected.remediation.action).not.toMatch(/observed signal/u); }); it.skipIf(process.platform === "win32")( diff --git a/tests/boundary/il2cpp/il2cppGhidraSeeds.test.ts b/tests/boundary/il2cpp/il2cppGhidraSeeds.test.ts index d063b37fbf..45b33ed455 100644 --- a/tests/boundary/il2cpp/il2cppGhidraSeeds.test.ts +++ b/tests/boundary/il2cpp/il2cppGhidraSeeds.test.ts @@ -1,4 +1,7 @@ -import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; +import { + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../../src/domain/analysisErrorCore.js"; import { expect, it } from "vitest"; import { parseGhidraSeeds } from "../../../src/ghidra/GhidraAnalysisSeeds.js"; import { @@ -175,7 +178,23 @@ it("bounds folded-name retention against the actual Ghidra file admission", () = })), method_addresses: Array.from({ length: count }, () => address("0x100")), }; - expect(() => il2cppGhidraSeeds(repeated, 0n)).toThrow(/byte admission/); + let caught: unknown; + try { + il2cppGhidraSeeds(repeated, 0n); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AnalysisResourceConstraintError); + if (!(caught instanceof AnalysisResourceConstraintError)) + throw new Error("Expected seed-file allowance refusal"); + expect(caught.message).toMatch(/byte admission/); + expect(caught.remediationAction).toContain( + "The IL2CPP Ghidra seed-file allowance", + ); + expect(caught.remediationAction).toContain("fixed byte allowance"); + expect(caught.remediationAction).not.toContain("fixed count"); + expect(caught.remediationAction).not.toContain("seed-name allowance"); + expect(caught.retryable).toBe(false); }); it("refuses cyclic type ancestry before emitting a seed", () => { const cyclic = { @@ -200,5 +219,20 @@ it("refuses names the real Ghidra seed parser cannot admit", () => { ...report, methods: [{ name: "M".repeat(2001), declaring_type: 0 }], }; - expect(() => il2cppGhidraSeeds(oversized, 0n)).toThrow(/character admission/); + let caught: unknown; + try { + il2cppGhidraSeeds(oversized, 0n); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AnalysisResourceConstraintError); + if (!(caught instanceof AnalysisResourceConstraintError)) + throw new Error("Expected seed-name allowance refusal"); + expect(caught.message).toMatch(/character admission/); + expect(caught.remediationAction).toContain( + "The IL2CPP Ghidra seed-name allowance", + ); + expect(caught.remediationAction).toContain("fixed count"); + expect(caught.remediationAction).not.toContain("fixed byte allowance"); + expect(caught.retryable).toBe(false); }); diff --git a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts index 0c28878fc5..c944b04355 100644 --- a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts +++ b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts @@ -5,8 +5,10 @@ import { join } from "node:path"; import { afterEach, expect, it, vi } from "vitest"; import { AnalysisCancelledError, + AnalysisCapabilityUnavailableError, AnalysisResourceConstraintError, } from "../../../src/domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { Il2cppMetadataProvider } from "../../../src/il2cpp/Il2cppMetadataProvider.js"; import { createElfFixture, @@ -188,6 +190,41 @@ it("admits a report whose escaped names fit the budget exactly", async () => { } }); +it("keeps observed source facts with a complete-report refusal", async () => { + const root = await createTestTempDirectory("rea-il2cpp-report-evidence-"); + const path = join(root, "global-metadata.dat"); + const bytes = createIl2cppMetadataFixture().bytes; + await writeFile(path, bytes); + const result = await inspectMetadata(path, 256); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected report allowance refusal"); + expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); + expect(result.error.partialObservation).toMatchObject({ + operation: "observe_il2cpp_metadata_source", + normalized_result: { + artifact: { + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }, + inventory_status: "not-returned-as-complete", + }, + }); + const projected = projectAnalysisError(result.error); + expect(projected.retryable).toBe(false); + expect(projected.code).toBe("resource_constraint"); + expect(projected.remediation.action).toContain( + "No complete metadata inventory is retained", + ); + expect(projected.remediation.action).toContain( + "Source facts already observed stay with this refusal", + ); + expect(projected.remediation.action).not.toMatch( + /does not leave a completed report or Evidence record/u, + ); + expect(projected.remediation.action).not.toMatch(/worker memory/u); +}); + it("refuses from row counts before decoding any row", async () => { const root = await createTestTempDirectory("rea-il2cpp-provider-counts-"); const path = join(root, "global-metadata.dat"); @@ -288,8 +325,17 @@ it("bounds concurrent snapshots and retains active decoding until close complete try { const refused = await provider.inspect({ path }); expect(refused.ok).toBe(false); - if (!refused.ok) - expect(refused.error).toBeInstanceOf(AnalysisResourceConstraintError); + if (!refused.ok) { + expect(refused.error).toBeInstanceOf(AnalysisCapabilityUnavailableError); + expect(projectAnalysisError(refused.error)).toMatchObject({ + code: "capability_unavailable", + retryable: false, + details: { reason: "IL2CPP metadata reader is closed" }, + }); + expect( + projectAnalysisError(refused.error).remediation.action, + ).not.toMatch(/retry/i); + } await setImmediate(); expect(closed).toBe(false); } finally { @@ -298,7 +344,53 @@ it("bounds concurrent snapshots and retains active decoding until close complete } expect((await active).ok).toBe(true); expect(closed).toBe(true); - expect((await provider.inspect({ path })).ok).toBe(false); + const afterClose = await provider.inspect({ path }); + expect(afterClose.ok).toBe(false); + if (!afterClose.ok) { + expect(afterClose.error).toBeInstanceOf(AnalysisCapabilityUnavailableError); + expect(projectAnalysisError(afterClose.error).retryable).toBe(false); + } +}); + +it("keeps occupied capacity retryable while the reader remains open", async () => { + const root = await createTestTempDirectory("rea-il2cpp-provider-occupied-"); + const path = join(root, "global-metadata.dat"); + await writeFile(path, createIl2cppMetadataFixture().bytes); + let markEntered!: () => void; + const entered = new Promise((resolve) => { + markEntered = resolve; + }); + let resume!: () => void; + const release = new Promise((resolve) => { + resume = resolve; + }); + const provider = new Il2cppMetadataProvider(undefined, () => { + markEntered(); + return release; + }); + const active = provider.inspect({ path }); + await entered; + try { + const refused = await provider.inspect({ path }); + expect(refused.ok).toBe(false); + if (!refused.ok) { + expect(refused.error).toBeInstanceOf(AnalysisResourceConstraintError); + expect(projectAnalysisError(refused.error)).toMatchObject({ + retryable: true, + details: { + resource: "capacity", + reported_limits: { maximum_concurrent_inspections: 1 }, + }, + remediation: { + action: expect.stringContaining("active IL2CPP metadata inspection"), + }, + }); + } + } finally { + resume(); + await active; + await provider.close(); + } }); it.each([false, true])( diff --git a/tests/boundary/rust/rustBinaryBudget.test.ts b/tests/boundary/rust/rustBinaryBudget.test.ts new file mode 100644 index 0000000000..8b498c62ab --- /dev/null +++ b/tests/boundary/rust/rustBinaryBudget.test.ts @@ -0,0 +1,51 @@ +import { truncate, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { RustBinaryProvider } from "../../../src/rust/RustBinaryProvider.js"; +import { RUST_BINARY_INPUT_BYTES } from "../../../src/rust/RustAuditableReader.js"; +import { createRustHashOverflowImage } from "../../fixtures/rust/image.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("refuses extra rustc paths without asking the caller to wait", async () => { + const root = await createTestTempDirectory("rea-rust-hash-budget-"); + const path = join(root, "many-hashes.elf"); + await writeFile(path, createRustHashOverflowImage()); + const result = await new RustBinaryProvider().inspect({ path }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected a hash-capacity refusal"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + retryable: false, + details: { + resource: "capacity", + reported_limits: { maximum_rustc_hashes: 64 }, + }, + }); + expect(projected.remediation.action).toContain("rustc commit-path inventory"); + expect(projected.remediation.action).not.toMatch( + /Wait for an active analysis/u, + ); +}); + +it("refuses an oversized Rust input without evidence-reply advice", async () => { + const root = await createTestTempDirectory("rea-rust-input-budget-"); + const path = join(root, "large.elf"); + await writeFile(path, Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + await truncate(path, RUST_BINARY_INPUT_BYTES + 1); + const result = await new RustBinaryProvider().inspect({ path }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected an input-size refusal"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + retryable: false, + details: { + resource: "file-size", + reported_limits: { maximum_input_bytes: RUST_BINARY_INPUT_BYTES }, + }, + }); + expect(projected.remediation.action).toContain("does not admit this input"); + expect(projected.remediation.action).not.toMatch( + /file-size allowance for the evidence reply/u, + ); +}); diff --git a/tests/boundary/signals/recordingProvider.test.ts b/tests/boundary/signals/recordingProvider.test.ts index 8b6c608e99..0a75aa5671 100644 --- a/tests/boundary/signals/recordingProvider.test.ts +++ b/tests/boundary/signals/recordingProvider.test.ts @@ -1,3 +1,4 @@ +import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; import { signalDecodersSchema } from "../../../src/domain/signals/signalDecoders.js"; import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js"; import { it, expect, onTestFinished } from "vitest"; @@ -568,6 +569,21 @@ it.each([ stdout: "sigrok-cli 0.7.1\n", stderr: "version warning\n", }); + if (mode === "overflow") { + expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); + if (!(result.error instanceof AnalysisResourceConstraintError)) + throw new Error("Expected command-output allowance refusal"); + expect(result.error).toMatchObject({ + resource: "memory", + retryable: false, + }); + expect(result.error.remediationAction).toContain( + "The retained command-output allowance", + ); + expect(result.error.remediationAction).not.toMatch( + /Review the reported worker memory limits/u, + ); + } }); it("retains completed discovery when cleanup fails, then retries before accepting work", async () => {