diff --git a/docs/client-configuration.md b/docs/client-configuration.md index d5fa7c2f24..1c61c4787b 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -153,8 +153,8 @@ headroom. The default is 450000 ms, covering the default 330-second Ghidra startup; an explicit Ghidra startup timeout of 2700000 ms produces a 2820000 ms tool timeout. The startup allowance follows an explicitly selected Ghidra or Hopper provider, and otherwise uses the larger known Ghidra/Hopper deadline. -An explicit `REA_JEB_MCP_TIMEOUT_MS` request budget also contributes to that -maximum. For `ida` or `auto` selection, a valid IDA registration's parsed +An explicit `REA_JEB_MCP_TIMEOUT_MS` deadline, shared by catalog discovery and +each tools/call, also contributes to that maximum. For `ida` or `auto` selection, a valid IDA registration's parsed `timeoutMs` also contributes to that maximum; its default is 300000 ms. Setup forwards these explicitly selected REA deadlines and the input schema profile into the registration; unrelated process values are omitted. An diff --git a/docs/frida-instrumentation.md b/docs/frida-instrumentation.md index b08ee99618..4ebb974060 100644 --- a/docs/frida-instrumentation.md +++ b/docs/frida-instrumentation.md @@ -24,6 +24,6 @@ Use `get_evidence_bundle` with `detail="summary"` and `filters={"evidence_id":"P Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. Server shutdown stops admitting new Frida operations and waits for admitted startup, discovery, and queued work before releasing their resources. A failed cleanup remains reported and can be retried by closing the server again, rather than treating an unregistered session as fully released. -Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. For a file, `source_sha256` identifies the original selected bytes. Invalid UTF-8 continues to use replacement decoding; Evidence then records that limitation and `raw_result.source_representation` with the encoding, decoding mode, and `loaded_source_sha256` of the UTF-8 string passed to Frida. Valid UTF-8 files and inline scripts do not need this additional representation fact. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. A cancelled script load on an existing session releases only its new script before queued operations can run; the session and previously loaded scripts remain available. If cancellation is observed when the native call returns, partial Evidence retains the source identity and captured messages even if that call rejects, including messages emitted during awaited cleanup. The caller's cancellation reason remains primary and a native rejection is retained as a sanitized diagnostic. A failure already observed before cleanup stays primary if cancellation arrives during cleanup. The cancellation cutoff is REA's observation of native completion, rather than a claim about unobservable native event order. `script_id` is REA's load-attempt identity. When creation rejects without returning a handle, `script_cleanup: "no_handle"` means REA acquired no handle to unload; it does not prove that the engine performed no work. Once the provider commits a successful load, later request cancellation does not unload that script; use `unload_frida_script` to release it. If script cleanup fails, the script remains owned and a paused target cannot resume until the pending instrumentation is released. Queued script loads, resumes, and unloads check cancellation before dispatching native effects. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed. +Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Local device lookup waits 1000 ms. This release has no runtime override for that lookup; another REA timeout does not raise it. Remote endpoints do not use that lookup. Message capture is bounded to 16 MiB and reports truncation. Metadata, type, and text field caps, and the JSON node cap, are also fixed. Evidence names a cap when that observation actually dropped data. This release has no runtime override for those capture caps. Script source is not stored in Evidence; its selected path and SHA-256 digest are. For a file, `source_sha256` identifies the original selected bytes. Invalid UTF-8 continues to use replacement decoding; Evidence then records that limitation and `raw_result.source_representation` with the encoding, decoding mode, and `loaded_source_sha256` of the UTF-8 string passed to Frida. Valid UTF-8 files and inline scripts do not need this additional representation fact. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. A cancelled script load on an existing session releases only its new script before queued operations can run; the session and previously loaded scripts remain available. If cancellation is observed when the native call returns, partial Evidence retains the source identity and captured messages even if that call rejects, including messages emitted during awaited cleanup. The caller's cancellation reason remains primary and a native rejection is retained as a sanitized diagnostic. A failure already observed before cleanup stays primary if cancellation arrives during cleanup. The cancellation cutoff is REA's observation of native completion, rather than a claim about unobservable native event order. `script_id` is REA's load-attempt identity. When creation rejects without returning a handle, `script_cleanup: "no_handle"` means REA acquired no handle to unload; it does not prove that the engine performed no work. Once the provider commits a successful load, later request cancellation does not unload that script; use `unload_frida_script` to release it. If script cleanup fails, the script remains owned and a paused target cannot resume until the pending instrumentation is released. Queued script loads, resumes, and unloads check cancellation before dispatching native effects. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed. The [Frida Core API](https://frida.re/docs/frida-core-api/) documents device management, sessions, and script loading; the [JavaScript API](https://frida.re/docs/javascript-api/) documents target-side instrumentation. [Luma](https://luma.frida.re/) is Frida's standalone GUI; REA does not automate or control it. `npm run verify:frida` requires a Linux host with the optional Frida binding and uses a short-lived local Node.js fixture created by the verifier. Add `-- --address HOST:PORT --pid PID` to run the separate remote attach lane against a caller-selected target. Mock tests do not establish real device/server compatibility. diff --git a/docs/ida-provider.md b/docs/ida-provider.md index ecaa2c66d8..a9f0eb5064 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -142,7 +142,7 @@ Use a package version that actually includes this adapter; repository main can b {"name":"close_binary","arguments":{}} ``` -Opening an MCP target starts its selected provider session; headless opening waits for auto-analysis. Set the agent's MCP request deadline long enough for the target. The registration's optional `timeoutMs` defaults to 300000 for upstream requests. An upstream SDK request that reaches this limit returns `provider_timeout` with its operation and actual request budget. A freshly opened headless worker can briefly report `status: "busy"` from `server_health`; REA re-reads health every 100 ms until the worker answers, the caller cancels, or `timeoutMs` has elapsed since the first read. Each health request is capped by the time remaining in that overall wait, so a stalled read cannot extend the readiness deadline by another full request timeout. Expiry while the worker is still busy produces a retryable `provider_timeout`; no further read is started after the deadline. That error names the busy tool from the last observation, or `unknown` when the worker reported none or a value that is not tool-name-shaped. If a health request itself consumes the remaining wait budget, the same last-busy timeout is returned with the stalled request's captured output. Cancellation during the wait produces `cancelled`. Any other unexpected health payload, including a busy payload with an invalid shape, remains `unreadable_output`. Cancellation drains the current upstream request before cleanup; it does not force-stop GUI analysis. An open request that times out without a completed response retains the private workspace because worker inventory alone cannot prove the request has stopped. `binary_session` reports actual tool availability. Repeated doctor/setup calls are unnecessary when the registration already works. +Opening an MCP target starts its selected provider session; headless opening waits for auto-analysis. Set the agent's MCP request deadline long enough for the target. The registration JSON named by `REA_IDA_MCP_CONFIG` has an optional `timeoutMs` integer from 1 to 2147483647. Omitted means 300000. REA applies an edited value on the next IDA connection after it re-reads that file; an already open session keeps the timeout it connected with. Changing the `REA_IDA_MCP_CONFIG` path itself is read at process start. An upstream SDK request that reaches this limit returns `provider_timeout` naming the effective milliseconds and `timeoutMs`. A shorter inner request is the time remaining inside that ceiling, and the error names both when they differ. A freshly opened headless worker can briefly report `status: "busy"` from `server_health`; REA re-reads health every 100 ms until the worker answers, the caller cancels, or `timeoutMs` has elapsed since the first read. Each health request is capped by the time remaining in that overall wait, so a stalled read cannot extend the readiness deadline by another full request timeout. Expiry while the worker is still busy produces a retryable `provider_timeout`; no further read is started after the deadline. That error names the busy tool from the last observation, or `unknown` when the worker reported none or a value that is not tool-name-shaped. If a health request itself consumes the remaining wait budget, the same last-busy timeout is returned with the stalled request's captured output. Cancellation during the wait produces `cancelled`. Any other unexpected health payload, including a busy payload with an invalid shape, remains `unreadable_output`. Cancellation drains the current upstream request before cleanup; it does not force-stop GUI analysis. An open request that times out without a completed response retains the private workspace because worker inventory alone cannot prove the request has stopped. `binary_session` reports actual tool availability. Repeated doctor/setup calls are unnecessary when the registration already works. For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client `. Setup includes that file reference in its reviewed plan and preserves it after approval; it does not copy upstream credentials into the agent configuration. For `ida` or `auto` selection, Codex/Kimi setup and doctor use the effective client registration's parsed `timeoutMs` (default 300000 ms) when recommending the agent's per-tool deadline. Existing positive client deadlines remain caller choices. `rea doctor --provider ida --json` validates the registration without starting IDA and explicitly leaves runtime compatibility and target binding for analysis. diff --git a/docs/jeb-analysis.md b/docs/jeb-analysis.md index dec049fe4a..25b1ef3de5 100644 --- a/docs/jeb-analysis.md +++ b/docs/jeb-analysis.md @@ -28,17 +28,19 @@ JEB service is reachable or that a project is open; use the existing MCP `binary_session` probes the live JEB service and its advertised tool catalog. Availability is reported per operation: a reachable headless client can support inspection and decompilation while refusing `open_jeb_project`. Catalog discovery -uses one 60-second deadline across connection and pagination; a responsive server -cannot keep discovery running indefinitely by returning more pages. +is one deadline across connection and pagination; a responsive server cannot keep +discovery running indefinitely by returning more pages. -Each upstream tool call has an absolute 60-second request deadline by default. -Set `REA_JEB_MCP_TIMEOUT_MS` to a positive decimal number of milliseconds to -select a different per-request budget, for example `REA_JEB_MCP_TIMEOUT_MS=300000` -for a five-minute decompilation. This budget starts after discovery and applies -to each individual `tools/call`; it does not extend the separate catalog deadline -or establish one end-to-end deadline across a whole CLI or MCP operation. A -request that reaches its budget returns a typed timeout, while caller -cancellation remains a cancellation. +Catalog discovery and each `tools/call` share `REA_JEB_MCP_TIMEOUT_MS`. Set it +in REA's launch environment to an integer from 1 to 2147483647 milliseconds, +for example `REA_JEB_MCP_TIMEOUT_MS=300000` for a five-minute budget. Omitted +means the pinned MCP client SDK default of 60000 ms (`@modelcontextprotocol/client` +2.3.1). Restart REA and reconnect to apply a change. Raising the setting raises +both phases. It does not make one end-to-end deadline across a whole CLI or MCP +operation: discovery and each `tools/call` each get that budget. A request that +reaches its budget returns a typed timeout naming the phase and the effective +milliseconds, while caller cancellation remains a cancellation. Allow execution, +cleanup, and delivery headroom in the outer MCP client deadline. Earlier verification used JEB 5.48.0 (Linux x64, licensed build) serving `jeb-mcp-server` 1.3.0 over streamable HTTP. The official Linux x64 CE build `5.48.0.202610071539` was also verified through real CLI and production stdio MCP native-code workflows; see the [Linux CE audit](../experiments/analysis-efficiency/jeb-linux-native.md) for measurements and limits. CE verification does not establish licensed/Demo Android decompilation or GUI behavior. diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index a54a035e91..0a27030846 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -850,7 +850,8 @@ operation headroom: 450 seconds with the default Ghidra allowance, or 2820 seconds with `REA_GHIDRA_STARTUP_TIMEOUT_MS=2700000`. Positive caller-selected deadlines remain unchanged. Doctor reports an informational recommendation when a configured deadline is shorter than the known provider allowance. An -explicit JEB upstream request budget also contributes to that allowance. +explicit JEB upstream deadline (`REA_JEB_MCP_TIMEOUT_MS`, shared by catalog +discovery and each tools/call) also contributes to that allowance. For an already connected client using the pinned SDK, request options are the **second** argument of `callTool`: diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index 5b0793aca6..0bd27ef2bb 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -27,11 +27,13 @@ rea inspect-with-objdump ./program dwarf rea inspect-with-objdump ./program dwarf --follow-debug-links ``` -Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_truncated` marks output beyond that limit. +Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_truncated` marks output beyond that limit. That cap has no runtime override. + +`inspect_with_objdump`, `rea list-objdump-architectures`, and one-shot `execute_rizin_command` accept `command_timeout_ms` (CLI: `--command-timeout-ms`). Omitted means 120000. The integer range is 1 to 2147483647. Target preflight shares one budget across stat and digest. The one-shot command receives that budget again. It applies on the next call; no restart is required. It does not raise the 16 MiB cap, a Ghidra or Hopper startup deadline, or a debugger session deadline. ## GDB -Persistent GDB sessions, including the MCP workflow and one-shot `rea debug-with-gdb` CLI, are available only on Linux. REA verifies a live inferior's output descriptors through `/proc` before detaching it; it does not claim equivalent descriptor verification on macOS. Windows is also unsupported because owned Job Object cleanup can terminate debugger inferiors. On supported Linux hosts, `start_gdb_session`, `gdb_console`, `gdb_session_status`, and `close_gdb_session` manage a persistent GDB/MI session in MCP. The one-shot CLI requires a caller-selected durable TTY: `rea debug-with-gdb --inferior-tty /dev/pts/7 'file ./program'`. The TTY must remain available after GDB exits so a detached inferior can continue writing. MCP starts GDB with `--nx --quiet --interpreter=mi3`, retries initialization with MI2 if MI3 initialization returns an error and cleanup succeeds, waits for its prompt, and disables auto-loading. Pass optional `inferior_tty` to select the terminal that future inferior runs use; REA configures it before the session becomes ready and checks the exact value with GDB/MI. `gdb_session_status` reports the selected path, whether REA still considers the configuration verified, and whether invalid UTF-8 has been observed in MI bytes (`mi_invalid_utf8`) or stderr (`stderr_invalid_utf8`). Commands that can change the inferior TTY or redirect a run revoke that verification permanently for the session, so close retains GDB ownership if a live inferior remains. Closing a session with one live inferior and a verified TTY automatically detaches the target, which can resume execution; MCP clients receive destructive-operation annotations for this behavior. The console remains unrestricted and can run shell commands, change target state, access files, and use network in the REA process user's security context. Raw MI records and console/target/log streams are returned with Evidence; command output is retained up to 16 MiB and explicitly marked when truncated. +Persistent GDB sessions, including the MCP workflow and one-shot `rea debug-with-gdb` CLI, are available only on Linux. REA verifies a live inferior's output descriptors through `/proc` before detaching it; it does not claim equivalent descriptor verification on macOS. Windows is also unsupported because owned Job Object cleanup can terminate debugger inferiors. On supported Linux hosts, `start_gdb_session`, `gdb_console`, `gdb_session_status`, and `close_gdb_session` manage a persistent GDB/MI session in MCP. The one-shot CLI requires a caller-selected durable TTY: `rea debug-with-gdb --inferior-tty /dev/pts/7 'file ./program'`. The TTY must remain available after GDB exits so a detached inferior can continue writing. MCP starts GDB with `--nx --quiet --interpreter=mi3`, retries initialization with MI2 if MI3 initialization returns an error and cleanup succeeds, waits for its prompt, and disables auto-loading. Pass optional `inferior_tty` to select the terminal that future inferior runs use; REA configures it before the session becomes ready and checks the exact value with GDB/MI. `gdb_session_status` reports the selected path, whether REA still considers the configuration verified, and whether invalid UTF-8 has been observed in MI bytes (`mi_invalid_utf8`) or stderr (`stderr_invalid_utf8`). Commands that can change the inferior TTY or redirect a run revoke that verification permanently for the session, so close retains GDB ownership if a live inferior remains. Closing a session with one live inferior and a verified TTY automatically detaches the target, which can resume execution; MCP clients receive destructive-operation annotations for this behavior. The console remains unrestricted and can run shell commands, change target state, access files, and use network in the REA process user's security context. Raw MI records and console/target/log streams are returned with Evidence; command output is retained up to 16 MiB and explicitly marked when truncated. GDB startup waits 10000 ms and each MI command waits 120000 ms. This release has no runtime override for those deadlines, the 16 MiB command output budget, session capacity, or the pending-command queue. `command_timeout_ms` does not change them. Before a normal close, REA checks for live GDB thread groups. If one remains and a durable `inferior_tty` is verified, REA asks GDB to detach it and confirms the detach before stopping the debugger. Without that sink, select and apply a disposition to each inferior through `gdb_console` (for example, detach or kill as appropriate), then retry close. REA never infers target ownership from a PID or path. When the MCP server shuts down, REA terminates only its directly owned GDB process after confirming no inferior or a sink-backed detach; otherwise it retains the process and its output streams for retry. @@ -49,7 +51,7 @@ close_gdb_session {"session_id":"…"} Persistent command Evidence retains the caller-selected and resolved startup paths in `parameters.launch_provenance`. These paths describe launch provenance, not the current target after unrestricted commands. `rizin_debug_session_status.output_invalid_utf8` reports whether lossy frame decoding has been observed during the session. Command Evidence sets `output_invalid_utf8` only when loss was observed in frames collected for that command, including retained partial output; earlier startup or command frames do not mark later valid output as lossy. -Session status retains up to 64 recent protocol frames. Each history entry keeps a complete-character UTF-8 prefix of at most 16 KiB, plus a truncation marker when needed; `recent_output_truncated` reports omitted history. This history budget also accounts for replacement characters from invalid UTF-8. It does not shorten command Evidence, which has its own 16 MiB output budget. +Session status retains up to 64 recent protocol frames. Each history entry keeps a complete-character UTF-8 prefix of at most 16 KiB, plus a truncation marker when needed; `recent_output_truncated` reports omitted history. This history budget also accounts for replacement characters from invalid UTF-8. It does not shorten command Evidence, which has its own 16 MiB output budget. Rizin debug startup waits 30000 ms and each command frame waits 120000 ms. This release has no runtime override for those deadlines, the 16777216 byte frame cap, the 16777216 byte command-output cap, the 4096 frame command cap, session capacity, or the pending-command queue. Evidence names the cap that stopped the command. The one-shot `command_timeout_ms` setting does not change a debug session. ```sh rea debug-with-rizin --inferior-tty /dev/pts/7 ./program dr @@ -61,7 +63,7 @@ rea debug-with-rizin --inferior-tty /dev/pts/7 ./program dr rea execute-rizin-command ./program iI ``` -Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_truncated` marks output beyond that limit. +Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_truncated` marks output beyond that limit. One-shot commands use the `command_timeout_ms` budget described above. That 16 MiB cap has no runtime override. ## Cutter bridge @@ -70,8 +72,11 @@ The client and plugin must support the current v2 handshake; older peers are rejected before dispatch because they cannot preserve cross-caller recovery proof. The client checks the final encoded request against the bridge's 16 MiB frame limit before dispatch; an oversized command does not retain a cleanup owner. -Discovery admits at most 256 descriptor candidates and reads and probes at most -eight concurrently. A larger directory reports `bridge_directory_capacity_exceeded`. +Each bridge request waits 25000 ms. Discovery admits at most 256 descriptor +candidates and reads and probes at most eight concurrently. A larger directory +reports `bridge_directory_capacity_exceeded`. This release has no runtime +override for the 25000 ms request deadline, the 16777216 byte frame, or the +256-candidate discovery budget. Terminal receipts are recovered before another command, including from a new CLI invocation. A signed status response also confirms completion of older bridge diff --git a/src/application/frida/FridaInstrumentationManager.test.ts b/src/application/frida/FridaInstrumentationManager.test.ts index 0d178a8400..df76dbbf51 100644 --- a/src/application/frida/FridaInstrumentationManager.test.ts +++ b/src/application/frida/FridaInstrumentationManager.test.ts @@ -15,8 +15,10 @@ import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js"; import { AnalysisCapabilityUnavailableError, AnalysisInputError, + AnalysisTimeoutError, } from "../../domain/analysisErrorCore.js"; import { FridaInstrumentationManager } from "../../frida/FridaInstrumentationManager.js"; +import { fridaCaptureLimitations } from "../../frida/fridaCaptureLimits.js"; const signal = void>() => { let connected: Handler | undefined; @@ -215,6 +217,78 @@ const startLocalAttachedSession = async ( // eslint-disable-next-line max-lines-per-function -- related manager lifecycle cases share the same focused fake provider. describe("FridaInstrumentationManager", () => { + it("reports a fixed device-lookup deadline when Frida's lookup times out", async () => { + const bindings = new FakeDeviceManager(); + bindings.getDeviceById = async () => { + throw new Error("Device not found"); + }; + const manager = new FridaInstrumentationManager({ + deviceManager: bindings, + }); + const listed = await manager.listProcesses({ deviceId: "local" }); + expect(listed.ok).toBe(false); + if (listed.ok) return; + expect(listed.error).toBeInstanceOf(AnalysisTimeoutError); + expect(listed.error).toMatchObject({ + operation: "list_frida_processes", + timeoutMs: 1_000, + }); + if (!(listed.error instanceof AnalysisTimeoutError)) return; + expect(listed.error.userMessage).toContain("1000 ms"); + expect(listed.error.userMessage).toContain("no runtime override"); + expect(listed.error.remediationAction).toContain("no runtime override"); + }); + + it("preserves a Frida device-manager failure instead of claiming the lookup deadline expired", async () => { + const bindings = new FakeDeviceManager(); + bindings.getDeviceById = async () => { + throw new Error("Device manager is closed"); + }; + const manager = new FridaInstrumentationManager({ + deviceManager: bindings, + }); + const listed = await manager.listProcesses({ deviceId: "local" }); + expect(listed.ok).toBe(false); + if (listed.ok) return; + expect(listed.error).toBeInstanceOf(AnalysisCapabilityUnavailableError); + expect(listed.error).not.toBeInstanceOf(AnalysisTimeoutError); + expect(listed.error).toMatchObject({ + providerId: "frida", + operation: "list_frida_processes", + reason: "Device manager is closed", + }); + expect(listed.error.message).toContain("Device manager is closed"); + expect(listed.error.message).not.toContain("deadlineExpired"); + }); + + it("names a capture cap only after that observation dropped data", () => { + expect(fridaCaptureLimitations({ messages_truncated: false })).toEqual([]); + expect( + fridaCaptureLimitations({ messages_truncated: true }).join("\n"), + ).toContain("16777216"); + const field = fridaCaptureLimitations({ + messages: [{ type_truncated: true }], + }).join("\n"); + expect(field).toContain("4096"); + expect(field).not.toContain("16777216"); + const text = fridaCaptureLimitations({ + messages: [ + { + description_truncated: true, + payload: { messages_truncated: true, type_truncated: true }, + }, + ], + }).join("\n"); + expect(text).toContain("32768"); + expect(text).not.toContain("16777216"); + expect(text).not.toContain("4096"); + expect( + fridaCaptureLimitations({ + messages: [{ payload: { messages_truncated: true } }], + }), + ).toEqual([]); + }); + it("attaches by PID when Frida omits the target from process lookup", async () => { const bindings = new FakeDeviceManager(); bindings.device.getProcessByPid = async () => { @@ -1242,7 +1316,7 @@ it("bounds oversized Frida message metadata before retaining it", async () => { }); expect(loaded.ok).toBe(true); if (loaded.ok) { - expect(loaded.value.messagesTruncated).toBe(true); + expect(loaded.value.messagesTruncated).toBe(false); const message = loaded.value.messages[0]; expect(message).toMatchObject({ description_truncated: true, @@ -1309,10 +1383,10 @@ it.each([ expect(message[field]).toBe(retained); expect(message[`${field}_truncated`] ?? false).toBe(truncated); } - expect(loaded.value.messagesTruncated).toBe(truncated); + expect(loaded.value.messagesTruncated).toBe(false); expect(manager.status(started.value.sessionId)).toMatchObject({ messages: [message], - messagesTruncated: truncated, + messagesTruncated: false, }); }, ); @@ -1460,7 +1534,10 @@ it.each([false, true])( expect(loaded.error.script.messages).toContainEqual( expect.objectContaining({ payload: "during cleanup" }), ); - expect(loaded.error.script.messagesTruncated).toBe(true); + expect(loaded.error.script.messagesTruncated).toBe(false); + expect(loaded.error.script.messages).toContainEqual( + expect.objectContaining({ description_truncated: true }), + ); expect(loaded.error.diagnostics).toEqual( rejectNative ? { native_failure: nativeFailure.message } : undefined, ); diff --git a/src/application/frida/FridaInstrumentationService.test.ts b/src/application/frida/FridaInstrumentationService.test.ts index 4ce961f4c4..6f28a834a0 100644 --- a/src/application/frida/FridaInstrumentationService.test.ts +++ b/src/application/frida/FridaInstrumentationService.test.ts @@ -99,6 +99,9 @@ it("keeps default Frida status identity and messages and refreshes every summary messages: [{ observed: 1 }], messages_truncated: false, }); + expect(complete.value.limitations.join("\n")).not.toContain( + "no runtime override", + ); const summary = service.inspectStatus( { ...input, detail: "summary" }, retention, @@ -121,11 +124,46 @@ it("keeps default Frida status identity and messages and refreshes every summary expect(parent?.normalized_result).toMatchObject({ messages: [{ observed: 2 }], }); + expect(parent?.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("16777216"), + expect.stringContaining("no runtime override"), + ]), + ); expect(parent?.authority).toBe("external-service"); expect(parent?.environment).toBeNull(); expect(parent?.raw_result).toBeNull(); }); +it("names the Frida field cap and ignores flags inside a caller payload", () => { + const service = new FridaInstrumentationService( + createProvider({ + status() { + return { + ...createSession(), + targetResumeObserved: false, + scripts: [], + messages: [ + { + description_truncated: true, + payload: { messages_truncated: true, type_truncated: true }, + }, + ], + messagesTruncated: false, + }; + }, + }), + ); + const result = service.inspectStatus({ + session_id: createSession().sessionId, + }); + if (!result.ok) throw result.error; + const text = result.value.limitations.join("\n"); + expect(text).toContain("32768"); + expect(text).not.toContain("16777216"); + expect(text).not.toContain("4096"); +}); + it.each([ undefined, { recordEvidence: undefined, evidenceById: () => undefined }, diff --git a/src/application/frida/FridaInstrumentationService.ts b/src/application/frida/FridaInstrumentationService.ts index 0aac571ade..4f21b054f2 100644 --- a/src/application/frida/FridaInstrumentationService.ts +++ b/src/application/frida/FridaInstrumentationService.ts @@ -21,6 +21,7 @@ import { summarizeRetainedAnalysis, type AnalysisViewRetention, } from "../analysisView/AnalysisViewService.js"; +import { fridaCaptureLimitations } from "../../frida/fridaCaptureLimits.js"; import { unknownFridaSession } from "./fridaInputErrors.js"; import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js"; import type { @@ -476,6 +477,7 @@ const fridaEvidence = ( environment: null, limitations: [ "The target operating system and architecture were not reported by this observation.", + ...fridaCaptureLimitations(result), ...(script?.sourceRepresentation === undefined ? [] : [ diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index b082ac9d14..022586879a 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -26,6 +26,8 @@ import { createEvidence, type Evidence } from "../../domain/evidence.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { err, ok, type Result } from "../../domain/result.js"; import { + DEFAULT_REVERSE_COMMAND_TIMEOUT_MS, + MAX_REVERSE_COMMAND_TIMEOUT_MS, objdumpInputSchema, rizinInputSchema, } from "../../domain/reverseEngineering.js"; @@ -48,7 +50,6 @@ export type ReverseEngineeringOperation = | "inspect_with_objdump" | "execute_rizin_command"; -const DEFAULT_TIMEOUT_MS = 120_000; const MAX_COMBINED_OUTPUT_BYTES = 16 * 1024 * 1024; // Eight concurrent captures bound retained provider output to 128 MiB. const MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS = 8; @@ -196,7 +197,11 @@ export class ReverseEngineeringService { const parsed = objdumpInputSchema.safeParse(rawInput); if (!parsed.success) return err(new AnalysisInputError("inspect_with_objdump")); - const { detail, ...input } = parsed.data; + const { + detail, + command_timeout_ms: commandTimeoutMs, + ...input + } = parsed.data; if (detail === "summary") { const ready = requireAnalysisViewRetention( "inspect_with_objdump", @@ -210,12 +215,15 @@ export class ReverseEngineeringService { const { symbol, ...base } = input; parameters = symbol === undefined ? base : { ...base, symbol }; } else parameters = input; + if (commandTimeoutMs !== undefined) + parameters = { ...parameters, command_timeout_ms: commandTimeoutMs }; const observed = await this.#observe( "inspect_with_objdump", command, input.operation === "architectures" ? undefined : input.path, parameters, options.signal, + commandTimeoutMs ?? DEFAULT_REVERSE_COMMAND_TIMEOUT_MS, ); return observed.ok && detail === "summary" ? summarizeRetainedAnalysis(observed.value, retention) @@ -231,14 +239,15 @@ export class ReverseEngineeringService { return Promise.resolve( err(new AnalysisInputError("execute_rizin_command")), ); - const input = parsed.data; + const { command_timeout_ms: commandTimeoutMs, ...input } = parsed.data; const command = this.#commands.rizin(input); return this.#observe( "execute_rizin_command", command, input.path, - input, + commandTimeoutMs === undefined ? input : parsed.data, options.signal, + commandTimeoutMs ?? DEFAULT_REVERSE_COMMAND_TIMEOUT_MS, ); } @@ -247,7 +256,8 @@ export class ReverseEngineeringService { profile: ReverseEngineeringProviderCommand, path: string | undefined, parameters: Readonly>, - signal?: AbortSignal, + signal: AbortSignal | undefined, + timeoutMs: number, ): Promise> { if (signal?.aborted) return Promise.resolve( @@ -271,12 +281,15 @@ export class ReverseEngineeringService { new AnalysisResourceConstraintError( operation, "transport", - "Concurrent reverse-engineering operation capacity reached.", + `Concurrent reverse-engineering operation capacity reached (${String(MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS)} operations). This release has no runtime override.`, { max_concurrent_operations: MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS, active_operations: this.#activeOperations.size, }, + { + remediationAction: `Wait for an active objdump or rizin command to finish, then retry. This capacity of ${String(MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS)} operations has no runtime override, and command_timeout_ms does not raise it.`, + }, ), ), ); @@ -296,6 +309,7 @@ export class ReverseEngineeringService { path, parameters, operationSignal, + timeoutMs, ); const tracked = running.finally(() => { signal?.removeEventListener("abort", relayAbort); @@ -310,7 +324,8 @@ export class ReverseEngineeringService { profile: ReverseEngineeringProviderCommand, path: string | undefined, parameters: Readonly>, - signal?: AbortSignal, + signal: AbortSignal | undefined, + timeoutMs: number, ): Promise> { const { executable: command, arguments: args } = profile; if (signal?.aborted) @@ -324,7 +339,12 @@ export class ReverseEngineeringService { let digest: string | undefined; try { if (absolutePath !== undefined) - digest = await this.#digest(absolutePath, signal, this.#stat); + digest = await this.#digest( + absolutePath, + signal, + this.#stat, + timeoutMs, + ); } catch (cause) { const code = commandFailureCode(cause); if (preflightCancelled(signal, cause, code)) @@ -343,7 +363,10 @@ export class ReverseEngineeringService { } if (code === "ETIMEDOUT") return err( - new AnalysisTimeoutError(operation, DEFAULT_TIMEOUT_MS, { cause }), + new AnalysisTimeoutError(operation, timeoutMs, { + cause, + ...reverseCommandTimeoutGuidance(timeoutMs), + }), ); if ( code !== "ENOENT" && @@ -410,7 +433,7 @@ export class ReverseEngineeringService { ...profile.environmentOverrides, }, signal, - timeout: DEFAULT_TIMEOUT_MS, + timeout: timeoutMs, stopSignal: "SIGTERM", maxBuffer: MAX_COMBINED_OUTPUT_BYTES, }); @@ -479,14 +502,11 @@ export class ReverseEngineeringService { ...ownedCleanupOptions(cause), }); else if (code === "ETIMEDOUT") - earlyFailure = new AnalysisTimeoutError( - operation, - DEFAULT_TIMEOUT_MS, - { - cause, - ...ownedCleanupOptions(cause), - }, - ); + earlyFailure = new AnalysisTimeoutError(operation, timeoutMs, { + cause, + ...ownedCleanupOptions(cause), + ...reverseCommandTimeoutGuidance(timeoutMs), + }); else if (code === "EACCES" || code === "EPERM") earlyFailure = new AnalysisAccessDeniedError( operation, @@ -536,10 +556,11 @@ export class ReverseEngineeringService { ...ownedCleanupOptions(cause), }); else if (code === "ETIMEDOUT") - failure = new AnalysisTimeoutError(operation, DEFAULT_TIMEOUT_MS, { + failure = new AnalysisTimeoutError(operation, timeoutMs, { cause, capturedOutput, ...ownedCleanupOptions(cause), + ...reverseCommandTimeoutGuidance(timeoutMs), }); else if (code === "EACCES" || code === "EPERM") failure = new AnalysisAccessDeniedError(operation, command, code, { @@ -607,7 +628,12 @@ export class ReverseEngineeringService { if (!cleanupOnlyFailure) completionStatus = "unknown"; } else { try { - digestAfter = await this.#digest(absolutePath, signal, this.#stat); + digestAfter = await this.#digest( + absolutePath, + signal, + this.#stat, + timeoutMs, + ); } catch { // Preserve the provider's collected observations if the artifact vanished or became unreadable. } @@ -729,7 +755,12 @@ export class ReverseEngineeringService { operation, parameters: { ...(absolutePath === undefined - ? { operation: "architectures" } + ? { + operation: "architectures", + ...(typeof parameters.command_timeout_ms === "number" + ? { command_timeout_ms: parameters.command_timeout_ms } + : {}), + } : parameters), provider_executable: command, command_arguments: [...args], @@ -835,7 +866,7 @@ const runOwnedCommandOutput = sweepTokenOwnedProcesses: true, }, { - timeoutMs: DEFAULT_TIMEOUT_MS, + timeoutMs: options.timeout ?? DEFAULT_REVERSE_COMMAND_TIMEOUT_MS, diagnosticBytes: MAX_COMBINED_OUTPUT_BYTES, acceptNonZeroExit: true, }, @@ -934,19 +965,43 @@ const ownedCleanupOptions = ( ? { cleanup: cause.cleanup } : {}; -const digestFile = async ( +const reverseCommandTimeoutGuidance = ( + timeoutMs: number, +): { readonly userMessage: string; readonly remediationAction: string } => ({ + userMessage: `This objdump or rizin command reached its ${String(timeoutMs)} ms server/provider deadline (command_timeout_ms) during target preflight or the one-shot command (deadlineExpired). This is not a Ghidra or Hopper startup deadline and not a debugger session deadline.`, + remediationAction: `${ + timeoutMs < MAX_REVERSE_COMMAND_TIMEOUT_MS + ? `Increase command_timeout_ms above ${String(timeoutMs)} (integer milliseconds from 1 to ${String(MAX_REVERSE_COMMAND_TIMEOUT_MS)}; omitted means ${String(DEFAULT_REVERSE_COMMAND_TIMEOUT_MS)}), or use --command-timeout-ms on the CLI.` + : `command_timeout_ms is already at the maximum ${String(MAX_REVERSE_COMMAND_TIMEOUT_MS)} ms; no higher deadline is supported.` + } The new value applies on the next call; no restart is required. The ${String(MAX_COMBINED_OUTPUT_BYTES)} byte (16 MiB) combined output cap has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, +}); + +export const digestFile = async ( path: string, signal?: AbortSignal, stat: (path: string) => Promise = statPath, + timeoutMs: number = DEFAULT_REVERSE_COMMAND_TIMEOUT_MS, + open: ( + path: string, + ) => ReturnType = createReadStream, ): Promise => { - const info = await statWithDeadline(path, signal, stat); + const startedAt = performance.now(); + const info = await statWithDeadline(path, signal, stat, timeoutMs); if (!info.isFile()) throw Object.assign(new Error("Artifact digest requires a regular file"), { code: "EINVAL", }); + const remainingMs = Math.max( + 0, + Math.ceil(timeoutMs - (performance.now() - startedAt)), + ); + if (remainingMs === 0) + throw Object.assign(new Error("Artifact digest timed out"), { + code: "ETIMEDOUT", + }); return new Promise((resolveDigest, rejectDigest) => { const hash = createHash("sha256"); - const stream = createReadStream(path); + const stream = open(path); const timer = setTimeout( () => stream.destroy( @@ -954,7 +1009,7 @@ const digestFile = async ( code: "ETIMEDOUT", }), ), - DEFAULT_TIMEOUT_MS, + remainingMs, ); const abort = (): void => { stream.destroy(new AnalysisCancelledError("artifact_digest")); @@ -1036,6 +1091,7 @@ const statWithDeadline = ( path: string, signal: AbortSignal | undefined, stat: (path: string) => Promise, + timeoutMs: number, ): Promise => { if (signal?.aborted) return Promise.reject(new AnalysisCancelledError("artifact_digest")); @@ -1060,7 +1116,7 @@ const statWithDeadline = ( }), ), ), - DEFAULT_TIMEOUT_MS, + timeoutMs, ); signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted) { diff --git a/src/cli/reverseEngineeringCommands.ts b/src/cli/reverseEngineeringCommands.ts index a027246f16..561e801caa 100644 --- a/src/cli/reverseEngineeringCommands.ts +++ b/src/cli/reverseEngineeringCommands.ts @@ -11,7 +11,10 @@ import { evidenceSchema, type Evidence } from "../domain/evidence.js"; import type { Result } from "../domain/result.js"; import { err } from "../domain/result.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { OBJDUMP_ARTIFACT_OPERATIONS } from "../domain/reverseEngineering.js"; +import { + OBJDUMP_ARTIFACT_OPERATIONS, + reverseCommandTimeoutSchema, +} from "../domain/reverseEngineering.js"; import { logCliCommand } from "../cliLogging.js"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; @@ -135,6 +138,7 @@ export const registerReverseEngineeringCommands = ( .describe( "Follow local DWARF debug links. External debug-file identity is not bound to Evidence.", ), + commandTimeoutMs: reverseCommandTimeoutSchema, }), run: ({ args, options }) => withCommandCancellation((signal) => @@ -156,6 +160,9 @@ export const registerReverseEngineeringCommands = ( ...(options.symbol === undefined ? {} : { symbol: options.symbol }), + ...(options.commandTimeoutMs === undefined + ? {} + : { command_timeout_ms: options.commandTimeoutMs }), }, { signal }, ), @@ -169,7 +176,10 @@ export const registerReverseEngineeringCommands = ( cli.command(CLI_COMMANDS.listObjdumpArchitectures, { description: "List BFD targets advertised by the installed GNU objdump", args: z.object({}), - options: z.object(analysisEvidenceDeliveryOptions), + options: z.object({ + ...analysisEvidenceDeliveryOptions, + commandTimeoutMs: reverseCommandTimeoutSchema, + }), run: ({ options }) => withCommandCancellation((signal) => logCliCommand( @@ -188,7 +198,12 @@ export const registerReverseEngineeringCommands = ( return projectAnalysisError(inputError); const result = await executeWithCleanup(service, () => service.inspectWithObjdump( - { operation: "architectures" }, + { + operation: "architectures", + ...(options.commandTimeoutMs === undefined + ? {} + : { command_timeout_ms: options.commandTimeoutMs }), + }, { signal }, ), ); @@ -208,11 +223,22 @@ export const registerReverseEngineeringCommands = ( .describe("Path to the local artifact to analyze."), command: z.string().min(1).describe("One caller-selected Rizin command."), }), - run: ({ args }) => + options: z.object({ + commandTimeoutMs: reverseCommandTimeoutSchema, + }), + run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.executeRizinCommand, async () => { const result = await executeWithCleanup(service, () => - service.executeRizinCommand(args, { signal }), + service.executeRizinCommand( + { + ...args, + ...(options.commandTimeoutMs === undefined + ? {} + : { command_timeout_ms: options.commandTimeoutMs }), + }, + { signal }, + ), ); return result.ok ? result.value : projectAnalysisError(result.error); }), diff --git a/src/composition/jeb.ts b/src/composition/jeb.ts index e0c26551e4..cc6cc0438c 100644 --- a/src/composition/jeb.ts +++ b/src/composition/jeb.ts @@ -24,7 +24,10 @@ export const createJebAnalysisProvider = ( environment, ...(requestTimeout.value === undefined ? {} - : { requestTimeoutMs: requestTimeout.value }), + : { + requestTimeoutMs: requestTimeout.value, + discoveryTimeoutMs: requestTimeout.value, + }), ...(connectionFactory === undefined ? {} : { connectionFactory }), }); } catch (cause: unknown) { diff --git a/src/contracts/reverseEngineeringToolContracts.ts b/src/contracts/reverseEngineeringToolContracts.ts index c194ad7cb0..a0aa37f50e 100644 --- a/src/contracts/reverseEngineeringToolContracts.ts +++ b/src/contracts/reverseEngineeringToolContracts.ts @@ -17,7 +17,7 @@ export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_with_objdump"), kind: "native-provider", description: - "Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. For operation=disassemble, optional symbol selects native symbol-scoped disassembly before output collection; omission preserves all code sections. Matching and symbol boundaries follow GNU objdump. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Default detail complete returns original Evidence. Explicit detail summary retains the complete parent and returns its command observation once, without the raw mirror; parent_evidence_id supports later inspect_analysis_view summary queries and complete Evidence export. Architecture output is subjectless and does not establish support for any artifact.", + "Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. For operation=disassemble, optional symbol selects native symbol-scoped disassembly before output collection; omission preserves all code sections. Matching and symbol boundaries follow GNU objdump. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Default detail complete returns original Evidence. Explicit detail summary retains the complete parent and returns its command observation once, without the raw mirror; parent_evidence_id supports later inspect_analysis_view summary queries and complete Evidence export. Architecture output is subjectless and does not establish support for any artifact. Optional command_timeout_ms is the per-phase wall clock in milliseconds (default 120000, range 1..2147483647); omitted means 120000 and the value applies on the next call. It does not raise the 16 MiB output cap.", inputSchema: objdumpInputSchema, outputSchema: analysisDetailOutputSchemaOf( commandObservationSchema, @@ -39,7 +39,7 @@ export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [ ...toolContractMetadata("execute_rizin_command"), kind: "native-provider", description: - "Execute one caller-selected Rizin command against a local artifact using the Rizin CLI profile that suppresses user settings and scripts while retaining plugins. Preserves decoded stdout and stderr inline with SHA-256 Evidence, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. Rizin commands and retained plugins can execute foreign code, mutate files, access the network, and read files outside the selected artifact; identities and contents of those external inputs are not bound to Evidence. Command effects depend on the Rizin command and loaded plugins; JSON is command-specific and raw output remains authoritative. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.", + "Execute one caller-selected Rizin command against a local artifact using the Rizin CLI profile that suppresses user settings and scripts while retaining plugins. Preserves decoded stdout and stderr inline with SHA-256 Evidence, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. Rizin commands and retained plugins can execute foreign code, mutate files, access the network, and read files outside the selected artifact; identities and contents of those external inputs are not bound to Evidence. Command effects depend on the Rizin command and loaded plugins; JSON is command-specific and raw output remains authoritative. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first. Optional command_timeout_ms is the per-phase wall clock in milliseconds (default 120000, range 1..2147483647); omitted means 120000 and the value applies on the next call. It does not raise the 16 MiB output cap or a Rizin debugger session deadline.", inputSchema: rizinInputSchema, outputSchema: evidenceResultOf(commandObservationSchema), examples: [ diff --git a/src/contracts/rizinDebugToolContracts.ts b/src/contracts/rizinDebugToolContracts.ts index 12ffbf896b..839248aad3 100644 --- a/src/contracts/rizinDebugToolContracts.ts +++ b/src/contracts/rizinDebugToolContracts.ts @@ -45,7 +45,7 @@ export const RIZIN_DEBUG_TOOL_CONTRACTS = [ ...toolContractMetadata("rizin_debug_command"), kind: "native-provider", description: - "Execute one unrestricted command in a persistent Rizin debugger session. Returns raw output inline with Evidence, including backend output interleaved before the completion marker. If the command exceeds the output or frame limit, `output_truncated` is true and `completion_status` is `unknown`; retained partial output is returned and the owned debugger session is stopped. Unsolicited frames already queued before the command make the session unavailable because correlation is ambiguous. Commands can access local files, shell, network, plugins, and target state; effects depend on the active backend and target. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.", + "Execute one unrestricted command in a persistent Rizin debugger session. Returns raw output inline with Evidence, including backend output interleaved before the completion marker. If the command exceeds the output-byte, frame-byte, or frame-count limit, `output_truncated` is true and `completion_status` is `unknown`; retained partial output is returned and the owned debugger session is stopped. Evidence names the cap that stopped capture. Unsolicited frames already queued before the command make the session unavailable because correlation is ambiguous. Commands can access local files, shell, network, plugins, and target state; effects depend on the active backend and target. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.", inputSchema: z.strictObject({ session_id: sessionId, command: z diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts index 9e76f3c509..cc737108cf 100644 --- a/src/cutter/CutterBridgeClient.ts +++ b/src/cutter/CutterBridgeClient.ts @@ -82,6 +82,7 @@ const acknowledgementSchema = z.object({ const MAX_DESCRIPTOR_BYTES = 64 * 1024; const MAX_FRAME_BYTES = 16 * 1024 * 1024; const FRAME_PAGE_BYTES = 64 * 1024; +const REQUEST_TIMEOUT_MS = 25_000; // Bound admitted descriptor input bytes and concurrent file/socket handles. const MAX_DESCRIPTOR_CANDIDATES = MAX_FRAME_BYTES / MAX_DESCRIPTOR_BYTES; const DISCOVERY_CONCURRENCY = 8; @@ -106,8 +107,11 @@ const serializeRequest = ( throw new AnalysisResourceConstraintError( "cutter_command", "transport", - "Cutter request exceeds its authenticated frame limit; no command was sent.", + `Cutter request exceeds its authenticated frame limit of ${String(MAX_FRAME_BYTES)} bytes (16 MiB); no command was sent. This release has no runtime override.`, { frame_bytes: frameBytes, max_frame_bytes: MAX_FRAME_BYTES }, + { + remediationAction: `Shorten the Cutter command. This ${String(MAX_FRAME_BYTES)} byte frame limit has no runtime override.`, + }, ); return requestBytes as AuthenticatedRequestBytes; }; @@ -130,13 +134,13 @@ class CutterBridgeResponseFrames { MAX_FRAME_BYTES ) throw new Error( - "Cutter bridge response exceeded its framed transport limit", + `Cutter bridge response exceeded its framed transport limit of ${String(MAX_FRAME_BYTES)} bytes (16 MiB). This release has no runtime override`, ); this.#append(segment); if (delimiter < 0) return; if (++this.#contentBytes > MAX_FRAME_BYTES) throw new Error( - "Cutter bridge response exceeded its framed transport limit", + `Cutter bridge response exceeded its framed transport limit of ${String(MAX_FRAME_BYTES)} bytes (16 MiB). This release has no runtime override`, ); if (this.#pageLength > 0) this.#parts.push(this.#page.subarray(0, this.#pageLength)); @@ -665,11 +669,14 @@ export class CutterBridgeClient { throw new AnalysisResourceConstraintError( "cutter_command", "transport", - "Cutter descriptor discovery exceeded its candidate budget; no command was sent.", + `Cutter descriptor discovery exceeded its candidate budget of ${String(MAX_DESCRIPTOR_CANDIDATES)} descriptors; no command was sent. This release has no runtime override.`, { max_descriptor_candidates: MAX_DESCRIPTOR_CANDIDATES, bridge_directory: this.#directory, }, + { + remediationAction: `Reduce the number of Cutter bridge descriptors in the bridge directory. This budget of ${String(MAX_DESCRIPTOR_CANDIDATES)} descriptors has no runtime override. Descriptor files above ${String(MAX_DESCRIPTOR_BYTES)} bytes are skipped, and at most ${String(DISCOVERY_CONCURRENCY)} descriptors are probed at once; those caps also have no runtime override.`, + }, ); if (loaded.error !== undefined) throw new AnalysisCapabilityUnavailableError( @@ -901,8 +908,10 @@ export class CutterBridgeClient { reject(new CutterBridgeRequestFailure(message, requestSent)), ); const timer = setTimeout(() => { - rejectRequest("Cutter bridge request timed out"); - }, 25_000); + rejectRequest( + `Cutter bridge request timed out after ${String(REQUEST_TIMEOUT_MS)} ms (deadlineExpired). This release has no runtime override`, + ); + }, REQUEST_TIMEOUT_MS); const onAbort = (): void => { rejectRequest("Cutter bridge request was cancelled"); }; diff --git a/src/domain/reverseEngineering.ts b/src/domain/reverseEngineering.ts index a011ea3d45..90b435db0c 100644 --- a/src/domain/reverseEngineering.ts +++ b/src/domain/reverseEngineering.ts @@ -1,6 +1,26 @@ import { z } from "zod"; import { commandEvidenceDetailSchema } from "./commandObservation.js"; +/** Omitted one-shot objdump/rizin wall clock. Preflight shares it; the command gets it again. */ +export const DEFAULT_REVERSE_COMMAND_TIMEOUT_MS = 120_000; +/** Node timer ceiling. Larger deadlines would be clamped instead of honored. */ +export const MAX_REVERSE_COMMAND_TIMEOUT_MS = 2_147_483_647; + +/** Per-call deadline for one-shot objdump and rizin commands. */ +export const reverseCommandTimeoutSchema = z + .number() + .int() + .min(1) + .max(MAX_REVERSE_COMMAND_TIMEOUT_MS) + .exactOptional() + .describe( + "Wall-clock budget in milliseconds. Target preflight shares one budget across stat and digest, and the one-shot command receives that budget again (default 120000 each). Omitted means 120000. Applies on the next call. Does not raise the 16 MiB combined output cap, Ghidra or Hopper startup deadlines, or debugger session deadlines.", + ); + +const commandTimeoutMsField = { + command_timeout_ms: reverseCommandTimeoutSchema, +}; + export const OBJDUMP_ARTIFACT_OPERATIONS = [ "file_headers", "section_headers", @@ -16,6 +36,7 @@ const objdumpArtifactInputSchema = z path: z.string().min(1), operation: z.enum(OBJDUMP_ARTIFACT_OPERATIONS).exclude(["disassemble"]), follow_debug_links: z.boolean().default(false), + ...commandTimeoutMsField, }) .strict(); @@ -38,6 +59,7 @@ const objdumpDisassemblyInputSchema = objdumpArtifactInputSchema.extend({ const objdumpArchitecturesInputSchema = z.strictObject({ operation: z.literal("architectures"), + ...commandTimeoutMsField, }); /** Caller-selected native selection and independent Evidence delivery. */ @@ -60,6 +82,7 @@ export const rizinInputSchema = z (value) => !/[\r\n\u0000]/u.test(value), "A single command line is required", ), + ...commandTimeoutMsField, }) .strict(); diff --git a/src/frida/FridaInstrumentationManager.ts b/src/frida/FridaInstrumentationManager.ts index 40cba0136f..fd0a3c641c 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -30,6 +30,7 @@ import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisInputError, + AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -39,18 +40,30 @@ import { unknownFridaScript, unknownFridaSession, } from "../application/frida/fridaInputErrors.js"; +import { + MAX_BINARY_MESSAGE_BYTES, + MAX_CAPTURE_JSON_NODES, + MAX_JSON_DEPTH, + MAX_MESSAGE_METADATA_BYTES, + MAX_MESSAGE_TEXT_BYTES, + MAX_MESSAGE_TYPE_BYTES, + MAX_SESSION_CAPTURE_BYTES, + type FridaOwnedCaptureLimit, +} from "./fridaCaptureLimits.js"; -const MAX_SESSION_CAPTURE_BYTES = 16 * 1024 * 1024; -const MAX_MESSAGE_METADATA_BYTES = 128 * 1024; -const MAX_MESSAGE_TYPE_BYTES = 4 * 1024; -const MAX_MESSAGE_TEXT_BYTES = 32 * 1024; -// Bound the in-memory object graph as well as its serialized JSON byte size. -const MAX_CAPTURE_JSON_NODES = 250_000; -const MAX_BINARY_MESSAGE_BYTES = Math.floor( - ((MAX_SESSION_CAPTURE_BYTES - 1024 * 1024) * 3) / 4, -); const DEVICE_LOOKUP_TIMEOUT_MS = 1_000; +/** + * Frida Core 17.23.2 raises this from `getDeviceById` only after the supplied + * timeout elapses without a match. A closed manager or backend failure uses a + * different message and is not this deadline. + */ +const FRIDA_DEVICE_LOOKUP_TIMEOUT_MESSAGE = "Device not found"; + +const isFridaDeviceLookupTimeout = (cause: unknown): boolean => + cause instanceof Error && + cause.message === FRIDA_DEVICE_LOOKUP_TIMEOUT_MESSAGE; + export const FRIDA_PROVIDER_IDENTITY = { id: "frida", name: "Frida", @@ -948,6 +961,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { readonly value: JsonValue; readonly truncated: boolean; readonly bytes: number; + readonly limit?: FridaOwnedCaptureLimit; } | undefined; if (payload !== undefined) { @@ -962,9 +976,9 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { value: { payload_unavailable: true }, truncated: true, bytes: Buffer.byteLength('{"payload_unavailable":true}'), + limit: "unrepresentable", }; } - if (payloadResult.truncated) markMessageTruncated(managed, scriptId); } let binaryResult: Record = {}; if (data !== null) { @@ -983,11 +997,16 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { if (secrets.some((secret) => data.includes(secret, 0, "utf8"))) { binaryResult = { data_redacted: true }; } else if (data.byteLength > messageBinaryLimit) { + const sessionConstrained = + messageBinaryLimit < MAX_BINARY_MESSAGE_BYTES; binaryResult = { data_truncated: true, data_bytes: data.byteLength, + data_capture_limit: sessionConstrained + ? "session_bytes" + : "binary_bytes", }; - markMessageTruncated(managed, scriptId); + if (sessionConstrained) markMessageTruncated(managed, scriptId); } else { binaryResult = { data_base64: data.toString("base64") }; } @@ -1017,13 +1036,6 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { MAX_MESSAGE_TEXT_BYTES, secrets, ); - if ( - type.truncated || - description?.truncated === true || - stack?.truncated === true || - fileName?.truncated === true - ) - markMessageTruncated(managed, scriptId); const observation: JsonValue = { script_id: scriptId, type: type.value, @@ -1032,7 +1044,14 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { ? {} : { payload: payloadResult.value, - ...(payloadResult.truncated ? { payload_truncated: true } : {}), + ...(payloadResult.truncated + ? { + payload_truncated: true, + ...(payloadResult.limit === undefined + ? {} + : { payload_capture_limit: payloadResult.limit }), + } + : {}), }), ...(description === undefined ? {} @@ -1120,10 +1139,28 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { ): Promise { if (selector.remote !== undefined) return this.#retainRemote(manager, selector.remote); - return manager.getDeviceById( - selector.deviceId ?? "local", - DEVICE_LOOKUP_TIMEOUT_MS, - ); + try { + return await manager.getDeviceById( + selector.deviceId ?? "local", + DEVICE_LOOKUP_TIMEOUT_MS, + ); + } catch (cause: unknown) { + if ( + cause instanceof AnalysisCancelledError || + cause instanceof AnalysisTimeoutError + ) + throw cause; + if (!isFridaDeviceLookupTimeout(cause)) throw cause; + throw new AnalysisTimeoutError( + "frida_device_lookup", + DEVICE_LOOKUP_TIMEOUT_MS, + { + cause, + userMessage: `Frida device lookup reached its ${String(DEVICE_LOOKUP_TIMEOUT_MS)} ms deadline (deadlineExpired). This release has no runtime override.`, + remediationAction: `The ${String(DEVICE_LOOKUP_TIMEOUT_MS)} ms Frida device lookup has no runtime override. Confirm the selected device is attached, then retry. Another REA timeout does not raise this lookup.`, + }, + ); + } } async #retainRemote( @@ -1265,93 +1302,107 @@ const boundedRedactedString = ( return { value: chunks.join(""), truncated }; }; +type BoundedJson = { + readonly value: JsonValue; + readonly truncated: boolean; + readonly limit?: FridaOwnedCaptureLimit; +}; + +const clipped = (limit: FridaOwnedCaptureLimit): BoundedJson => ({ + value: null, + truncated: true, + limit, +}); + const boundedJsonValue = ( input: unknown, maximumBytes: number, secrets: readonly string[], -): { - readonly value: JsonValue; - readonly truncated: boolean; - readonly bytes: number; -} => { +): BoundedJson & { readonly bytes: number } => { const seen = new WeakSet(); let remainingNodes = MAX_CAPTURE_JSON_NODES; const visit = ( value: unknown, budget: number, depth: number, - ): { readonly value: JsonValue; readonly truncated: boolean } => { + ): BoundedJson => { remainingNodes -= 1; - if (remainingNodes < 0) return { value: null, truncated: true }; - if (depth > 64 || budget < 8) return { value: null, truncated: true }; + if (remainingNodes < 0) return clipped("json_nodes"); + if (depth > MAX_JSON_DEPTH) return clipped("json_depth"); + if (budget < 8) return clipped("payload_bytes"); if (value === null) return { value: null, truncated: false }; - if (typeof value === "boolean" || typeof value === "number") - return { - value: - typeof value === "number" && !Number.isFinite(value) ? null : value, - truncated: typeof value === "number" && !Number.isFinite(value), - }; + if (typeof value === "boolean" || typeof value === "number") { + if (typeof value === "number" && !Number.isFinite(value)) + return clipped("unrepresentable"); + return { value, truncated: false }; + } if (typeof value === "string") { const bounded = boundedRedactedString( value, Math.max(0, budget - 2), secrets, ); - return { value: bounded.value, truncated: bounded.truncated }; + return bounded.truncated + ? { value: bounded.value, truncated: true, limit: "payload_bytes" } + : { value: bounded.value, truncated: false }; } - if (typeof value !== "object") return { value: null, truncated: true }; - if (seen.has(value)) return { value: null, truncated: true }; + if (typeof value !== "object") return clipped("unrepresentable"); + if (seen.has(value)) return clipped("unrepresentable"); seen.add(value); try { if (Array.isArray(value)) { const output: JsonValue[] = []; let usedBytes = 2; - let truncated = false; + let limit: FridaOwnedCaptureLimit | undefined; for (let index = 0; index < value.length; index += 1) { const separatorBytes = output.length === 0 ? 0 : 1; const remaining = budget - usedBytes - separatorBytes - 1; if (remaining < 8) { - truncated = true; + limit ??= "payload_bytes"; break; } const item = visit(value[index] ?? null, remaining, depth + 1); const itemBytes = Buffer.byteLength(JSON.stringify(item.value)); if (itemBytes > remaining) { - truncated = true; + limit ??= "payload_bytes"; break; } output.push(item.value); usedBytes += separatorBytes + itemBytes; - truncated ||= item.truncated; - if (item.truncated) break; + if (item.truncated) { + limit ??= item.limit; + break; + } } - if (output.length < value.length) truncated = true; - return { value: output, truncated }; + if (output.length < value.length) limit ??= "payload_bytes"; + return limit === undefined + ? { value: output, truncated: false } + : { value: output, truncated: true, limit }; } const output: Record = {}; let usedBytes = 2; let entries = 0; - let truncated = false; + let limit: FridaOwnedCaptureLimit | undefined; for (const rawKey in value) { if (!Object.hasOwn(value, rawKey)) continue; const separatorBytes = entries === 0 ? 0 : 1; const keyLimit = Math.max(0, budget - usedBytes - separatorBytes - 10); const boundedKey = boundedRedactedString(rawKey, keyLimit, secrets); if (boundedKey.truncated) { - truncated = true; + limit ??= "payload_bytes"; break; } const key = boundedKey.value; const keyBytes = jsonStringByteLength(key) + 1; const remaining = budget - usedBytes - separatorBytes - keyBytes - 1; if (remaining < 8) { - truncated = true; + limit ??= "payload_bytes"; break; } const entry = visit(Reflect.get(value, rawKey), remaining, depth + 1); const entryBytes = Buffer.byteLength(JSON.stringify(entry.value)); if (entryBytes > remaining) { - truncated = true; + limit ??= "payload_bytes"; break; } Object.defineProperty(output, key, { @@ -1362,10 +1413,14 @@ const boundedJsonValue = ( }); entries += 1; usedBytes += separatorBytes + keyBytes + entryBytes; - truncated ||= entry.truncated; - if (entry.truncated) break; + if (entry.truncated) { + limit ??= entry.limit; + break; + } } - return { value: output, truncated }; + return limit === undefined + ? { value: output, truncated: false } + : { value: output, truncated: true, limit }; } finally { seen.delete(value); } @@ -1373,7 +1428,12 @@ const boundedJsonValue = ( const result = visit(input, maximumBytes, 0); const bytes = Buffer.byteLength(JSON.stringify(result.value)); return bytes > maximumBytes - ? { value: null, truncated: true, bytes: Buffer.byteLength("null") } + ? { + value: null, + truncated: true, + bytes: Buffer.byteLength("null"), + limit: result.limit ?? "payload_bytes", + } : { ...result, bytes }; }; @@ -1430,6 +1490,24 @@ const providerError = ( ): AnalysisError => { if (cause instanceof AnalysisInputError) return new AnalysisInputError(operation, { cause }, cause.issues); + if (cause instanceof AnalysisTimeoutError) + return new AnalysisTimeoutError(operation, cause.timeoutMs, { + cause, + ...(cause.userMessage === undefined + ? {} + : { userMessage: cause.userMessage }), + ...(cause.remediationAction === undefined + ? {} + : { remediationAction: cause.remediationAction }), + ...(cleanupReason === undefined + ? {} + : { + cleanup: { + reason: cleanupReason, + resources: ["Frida provider operation"], + }, + }), + }); if (cause instanceof AnalysisCancelledError) return new AnalysisCancelledError(operation, { cause: cause.cause, diff --git a/src/frida/fridaCaptureLimits.ts b/src/frida/fridaCaptureLimits.ts new file mode 100644 index 0000000000..a8cbc31d5d --- /dev/null +++ b/src/frida/fridaCaptureLimits.ts @@ -0,0 +1,97 @@ +/** Fixed Frida capture caps. This release has no runtime override. */ +export const MAX_SESSION_CAPTURE_BYTES = 16 * 1024 * 1024; +export const MAX_MESSAGE_METADATA_BYTES = 128 * 1024; +export const MAX_MESSAGE_TYPE_BYTES = 4 * 1024; +export const MAX_MESSAGE_TEXT_BYTES = 32 * 1024; +/** Bound the in-memory object graph as well as its serialized JSON byte size. */ +export const MAX_CAPTURE_JSON_NODES = 250_000; +export const MAX_JSON_DEPTH = 64; +export const MAX_BINARY_MESSAGE_BYTES = Math.floor( + ((MAX_SESSION_CAPTURE_BYTES - 1024 * 1024) * 3) / 4, +); + +/** Why a payload or binary field was shortened. Caller payloads cannot set these. */ +export const FRIDA_OWNED_CAPTURE_LIMITS = [ + "session_bytes", + "message_type", + "message_text", + "payload_bytes", + "json_nodes", + "json_depth", + "binary_bytes", + "unrepresentable", +] as const; + +export type FridaOwnedCaptureLimit = + (typeof FRIDA_OWNED_CAPTURE_LIMITS)[number]; + +const OWNED_CAPTURE_LIMITS = new Set(FRIDA_OWNED_CAPTURE_LIMITS); + +const namedLimit = (value: unknown): FridaOwnedCaptureLimit | undefined => + typeof value === "string" && OWNED_CAPTURE_LIMITS.has(value) + ? (value as FridaOwnedCaptureLimit) + : undefined; + +const noOverride = "This release has no runtime override."; + +const limitationFor = (limit: FridaOwnedCaptureLimit): string => { + switch (limit) { + case "session_bytes": + return `Frida session message capture reached its ${String(MAX_SESSION_CAPTURE_BYTES)} byte budget. ${noOverride}`; + case "message_type": + return `A Frida message type reached its ${String(MAX_MESSAGE_TYPE_BYTES)} byte cap. ${noOverride}`; + case "message_text": + return `A Frida message text field reached its ${String(MAX_MESSAGE_TEXT_BYTES)} byte cap. ${noOverride}`; + case "payload_bytes": + return `A Frida message payload reached its ${String(MAX_SESSION_CAPTURE_BYTES - MAX_MESSAGE_METADATA_BYTES)} byte cap. ${noOverride}`; + case "json_nodes": + return `A Frida message payload reached its ${String(MAX_CAPTURE_JSON_NODES)} JSON node cap. ${noOverride}`; + case "json_depth": + return `A Frida message payload reached its ${String(MAX_JSON_DEPTH)} level nesting cap. ${noOverride}`; + case "binary_bytes": + return `A Frida binary message reached its ${String(MAX_BINARY_MESSAGE_BYTES)} byte cap. ${noOverride}`; + case "unrepresentable": + return `A Frida message value could not be retained in full. ${noOverride}`; + } +}; + +/** + * Read capture caps from REA-owned observation fields. `payload` is caller + * data, so flags inside it are not REA dropping bytes. + */ +const collectOwnedCaps = ( + value: unknown, + caps: Set, +): void => { + if (typeof value !== "object" || value === null) return; + if (Array.isArray(value)) { + for (const item of value) collectOwnedCaps(item, caps); + return; + } + const record = value as Record; + if (record.messages_truncated === true) caps.add("session_bytes"); + if (record.type_truncated === true) caps.add("message_type"); + if ( + record.description_truncated === true || + record.stack_truncated === true || + record.file_name_truncated === true + ) + caps.add("message_text"); + if (record.payload_truncated === true) + caps.add(namedLimit(record.payload_capture_limit) ?? "payload_bytes"); + if (record.data_truncated === true) + caps.add(namedLimit(record.data_capture_limit) ?? "binary_bytes"); + for (const [key, child] of Object.entries(record)) { + if (key === "payload") continue; + collectOwnedCaps(child, caps); + } +}; + +/** Name only the caps that REA itself recorded while dropping data. */ +export const fridaCaptureLimitations = (result: unknown): readonly string[] => { + const caps = new Set(); + collectOwnedCaps(result, caps); + return FRIDA_OWNED_CAPTURE_LIMITS.flatMap((limit) => + caps.has(limit) ? [limitationFor(limit)] : [], + ); +}; diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index c714ee7535..eaefcb914c 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -35,6 +35,29 @@ import { parseLinuxProcessStat } from "../process/LinuxProcessStat.js"; const STARTUP_TIMEOUT_MS = 10_000; const COMMAND_TIMEOUT_MS = 120_000; + +/** Fixed debugger deadline. Callers cannot raise it in this release. */ +const fixedDeadlineGuidance = ( + what: string, + timeoutMs: number, +): { readonly userMessage: string; readonly remediationAction: string } => ({ + userMessage: `${what} reached its ${String(timeoutMs)} ms deadline (deadlineExpired). This release has no runtime override.`, + remediationAction: `This ${String(timeoutMs)} ms deadline has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, +}); + +const copiedTimeoutGuidance = ( + cause: AnalysisTimeoutError, +): { + readonly userMessage?: string; + readonly remediationAction?: string; +} => ({ + ...(cause.userMessage === undefined + ? {} + : { userMessage: cause.userMessage }), + ...(cause.remediationAction === undefined + ? {} + : { remediationAction: cause.remediationAction }), +}); const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024; const MAX_COMMAND_OUTPUT_RECORDS = 16_384; const MAX_MI_LINE_BYTES = 16 * 1024 * 1024; @@ -96,7 +119,9 @@ class GdbCommandTimeoutError extends AnalysisTimeoutError { readonly evidence: Evidence, timeoutMs: number, ) { - super("gdb_console", timeoutMs); + super("gdb_console", timeoutMs, { + ...fixedDeadlineGuidance("The GDB MI command", timeoutMs), + }); } } @@ -646,11 +671,14 @@ export class GdbSessionManager { new AnalysisResourceConstraintError( "start_gdb_session", "memory", - "GDB session capacity reached.", + `GDB session capacity reached (${String(this.#maxSessions)} sessions). This release has no runtime override.`, { max_sessions: this.#maxSessions, active_or_reserved_sessions: occupied, }, + { + remediationAction: `Close a GDB session before starting another. This capacity of ${String(this.#maxSessions)} sessions has no runtime override.`, + }, ), ); this.#sessionReservations += 1; @@ -836,6 +864,10 @@ export class GdbSessionManager { cause: new Error( "GDB did not emit the MI prompt before the startup deadline", ), + ...fixedDeadlineGuidance( + "GDB startup", + this.#startupTimeoutMs, + ), }, ), ), @@ -1040,11 +1072,10 @@ export class GdbSessionManager { if (cause instanceof AnalysisTimeoutError) return err( retainCleanup( - new AnalysisTimeoutError( - cause.operation, - cause.timeoutMs, - details, - ), + new AnalysisTimeoutError(cause.operation, cause.timeoutMs, { + ...details, + ...copiedTimeoutGuidance(cause), + }), ), ); if (cause instanceof AnalysisCancelledError) @@ -1079,7 +1110,10 @@ export class GdbSessionManager { ? "start_gdb_session" : cause.operation, cause.timeoutMs, - errorDetails, + { + ...errorDetails, + ...copiedTimeoutGuidance(cause), + }, ) : cause instanceof AnalysisCapabilityUnavailableError ? new AnalysisCapabilityUnavailableError( @@ -1192,6 +1226,11 @@ export class GdbSessionManager { "The active GDB inferior identity is not established by the debugger session identifier.", limitations: [ "MI records are returned as decoded text; their semantics depend on GDB version and target.", + ...(result.output_truncated + ? [ + `Retained MI records reached the ${String(this.#maxCommandOutputBytes)} byte or ${String(MAX_COMMAND_OUTPUT_RECORDS)} record cap. This release has no runtime override. Output completeness is unknown.`, + ] + : []), ...(result.output_invalid_utf8 ? [ "MI stream bytes were not valid UTF-8; returned text contains replacement characters.", @@ -1233,7 +1272,7 @@ export class GdbSessionManager { "GDB exited before returning a correlated MI result record; only records retained for the active command are returned raw.", ...(result.output_truncated ? [ - "Retained MI records are a truncated prefix; output completeness is unknown.", + `Retained MI records reached the ${String(this.#maxCommandOutputBytes)} byte or ${String(MAX_COMMAND_OUTPUT_RECORDS)} record cap. This release has no runtime override. Output completeness is unknown.`, ] : []), ], @@ -1254,6 +1293,10 @@ export class GdbSessionManager { return err( new AnalysisTimeoutError("gdb_console", this.#commandTimeoutMs, { cause, + ...fixedDeadlineGuidance( + "The GDB MI command", + this.#commandTimeoutMs, + ), }), ); } @@ -1481,11 +1524,14 @@ export class GdbSessionManager { throw new AnalysisResourceConstraintError( operationName, "transport", - "GDB command queue capacity reached.", + `GDB command queue capacity reached (${String(MAX_PENDING_GDB_COMMANDS)} pending commands). This release has no runtime override.`, { max_pending_commands: MAX_PENDING_GDB_COMMANDS, active_or_queued_commands: session.pendingCommandCount, }, + { + remediationAction: `Wait for a GDB command on this session to finish, then retry. This queue of ${String(MAX_PENDING_GDB_COMMANDS)} commands has no runtime override.`, + }, ); session.pendingCommandCount += 1; const previous = session.commandTail; @@ -1539,7 +1585,7 @@ export class GdbSessionManager { if (lineBytes > MAX_MI_LINE_BYTES) { this.#failProtocol( session, - "GDB emitted an MI line exceeding the 16 MiB protocol limit.", + `GDB emitted an MI line exceeding the ${String(MAX_MI_LINE_BYTES)} byte (16 MiB) protocol limit. This release has no runtime override.`, ); return; } @@ -1962,7 +2008,13 @@ export class GdbSessionManager { const timeout = new AnalysisTimeoutError( operation, this.#commandTimeoutMs, - { cause: new Error(`GDB MI command timed out: ${command}`) }, + { + cause: new Error(`GDB MI command timed out: ${command}`), + ...fixedDeadlineGuidance( + "The GDB MI command", + this.#commandTimeoutMs, + ), + }, ); const evidence = createEvidence(undefined, GDB_PROVIDER_IDENTITY, { predicateType: @@ -1979,7 +2031,7 @@ export class GdbSessionManager { subjectUnavailableReason: "The active GDB inferior identity is not established by the debugger session identifier.", limitations: [ - "The GDB command exceeded its deadline; retained MI and stream records are partial and command completion is unknown.", + `The GDB command exceeded its ${String(this.#commandTimeoutMs)} ms deadline (deadlineExpired). This release has no runtime override. Retained MI and stream records are partial and command completion is unknown.`, "Cleanup of the owned GDB process was requested because output correlation could no longer be guaranteed.", "The unrestricted GDB console can access local shell, filesystem, network, and target controls.", ], @@ -1995,6 +2047,7 @@ export class GdbSessionManager { cause: timeout, cleanup: { reason: stopped.reason, resources: [session.id] }, partialObservation: evidence, + ...copiedTimeoutGuidance(timeout), }), ); return; diff --git a/src/ida/IdaBusyHealth.test.ts b/src/ida/IdaBusyHealth.test.ts index 47d59883e4..e19bcfa3d1 100644 --- a/src/ida/IdaBusyHealth.test.ts +++ b/src/ida/IdaBusyHealth.test.ts @@ -106,6 +106,8 @@ it("clamps the last pause to the remaining budget and stops without another read expect(failure, "expiry while busy is a timeout").toMatchObject({ _tag: "AnalysisTimeoutError", timeoutMs: 350, + userMessage: expect.stringContaining("350 ms"), + remediationAction: expect.stringContaining("REA_IDA_MCP_CONFIG"), }); }); diff --git a/src/ida/IdaBusyHealth.ts b/src/ida/IdaBusyHealth.ts index fdb02da474..25b8a9d849 100644 --- a/src/ida/IdaBusyHealth.ts +++ b/src/ida/IdaBusyHealth.ts @@ -1,5 +1,6 @@ import { setTimeout as sleep } from "node:timers/promises"; import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js"; +import { idaRegistrationTimeoutGuidance } from "./IdaConfiguration.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { IdaMcpConnection } from "./IdaMcpConnection.js"; import { busyHealthSchema } from "./IdaProtocolValues.js"; @@ -63,7 +64,9 @@ export const readSettledHealth = async ( const budgetMs = remaining(); if (budgetMs <= 0) throw lastBusy === undefined - ? new AnalysisTimeoutError("server_health", timeoutMs) + ? new AnalysisTimeoutError("server_health", timeoutMs, { + ...idaRegistrationTimeoutGuidance(timeoutMs, "readiness"), + }) : busyTimeout(lastBusy, timeoutMs); const requestTimeoutMs = Math.min(timeoutMs, Math.ceil(budgetMs)); let raw: JsonValue; @@ -83,6 +86,7 @@ export const readSettledHealth = async ( throw new AnalysisTimeoutError(lastBusy.operation, timeoutMs, { cause: lastBusy.raw, capturedOutput: cause.capturedOutput, + ...idaRegistrationTimeoutGuidance(timeoutMs, "readiness"), diagnostics: { health_request_operation: cause.operation, health_request_timeout_ms: cause.timeoutMs, @@ -116,4 +120,5 @@ const busyTimeout = ( ): AnalysisTimeoutError => new AnalysisTimeoutError(lastBusy.operation, timeoutMs, { cause: lastBusy.raw, + ...idaRegistrationTimeoutGuidance(timeoutMs, "readiness"), }); diff --git a/src/ida/IdaConfiguration.ts b/src/ida/IdaConfiguration.ts index d254667ad4..5ebbf01af1 100644 --- a/src/ida/IdaConfiguration.ts +++ b/src/ida/IdaConfiguration.ts @@ -15,10 +15,20 @@ import { NonRegularFileReadError, } from "../filesystem/RegularFile.js"; +/** Omitted registration `timeoutMs` for each upstream IDA MCP request. */ +export const DEFAULT_IDA_REQUEST_TIMEOUT_MS = 300_000; +/** Node timer ceiling. Larger registration values would be clamped instead of honored. */ +export const MAX_IDA_REQUEST_TIMEOUT_MS = 2_147_483_647; + const common = { mode: z.enum(["attached", "headless"]).default("attached"), protocol: z.literal("native").optional(), - timeoutMs: z.number().int().positive().max(2_147_483_647).default(300_000), + timeoutMs: z + .number() + .int() + .positive() + .max(MAX_IDA_REQUEST_TIMEOUT_MS) + .default(DEFAULT_IDA_REQUEST_TIMEOUT_MS), workspaceRoot: z .string() .refine(isAbsolute, "workspaceRoot must be absolute on the REA host") @@ -211,6 +221,25 @@ const registrationTooLarge = (): ConfigurationError => "REA_IDA_MCP_CONFIG exceeds the 64 KiB registration size.", ); +/** + * Name the registration key that sets an IDA request or readiness deadline. + * `ceilingMs` is the parsed `timeoutMs`; a shorter effective value is the time + * remaining inside that ceiling. + */ +export const idaRegistrationTimeoutGuidance = ( + timeoutMs: number, + phase: "request" | "readiness", + ceilingMs: number = timeoutMs, +): { readonly userMessage: string; readonly remediationAction: string } => ({ + userMessage: + phase === "readiness" + ? `IDA readiness reached its ${String(timeoutMs)} ms deadline while the headless worker was still busy (deadlineExpired).` + : ceilingMs === timeoutMs + ? `This IDA request reached the registration timeoutMs deadline of ${String(timeoutMs)} ms (deadlineExpired).` + : `This IDA request reached its ${String(timeoutMs)} ms deadline (deadlineExpired), within the registration timeoutMs ceiling of ${String(ceilingMs)} ms.`, + remediationAction: `Set timeoutMs in the JSON file named by REA_IDA_MCP_CONFIG to an integer from 1 to ${String(MAX_IDA_REQUEST_TIMEOUT_MS)} (omitted means ${String(DEFAULT_IDA_REQUEST_TIMEOUT_MS)}). REA applies the edited file on the next IDA connection after it re-reads the registration; an already open session keeps the timeout it connected with. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, +}); + /** A REA_IDA_MCP_CONFIG rejection that names the setting it concerns. */ export const idaConfigurationError = ( constraint: string, diff --git a/src/ida/IdaMcpConnection.test.ts b/src/ida/IdaMcpConnection.test.ts index f7e7a93755..24ad83691d 100644 --- a/src/ida/IdaMcpConnection.test.ts +++ b/src/ida/IdaMcpConnection.test.ts @@ -246,7 +246,12 @@ it("preserves the SDK request timeout as a typed IDA timeout", async () => { expect(failure).toMatchObject({ operation: "decompile", timeoutMs: requestTimeoutMs, + userMessage: expect.stringContaining("40 ms"), + remediationAction: expect.stringContaining("REA_IDA_MCP_CONFIG"), }); + expect( + failure instanceof AnalysisTimeoutError ? failure.userMessage : "", + ).toContain("1000 ms"); if (!(failure instanceof AnalysisTimeoutError)) throw new Error("Expected a typed IDA request timeout"); expect(failure.cause).toBeInstanceOf(Error); @@ -311,6 +316,8 @@ it("bounds a stalled health poll by the remaining readiness deadline", async () expect(failure).toMatchObject({ operation: "server_health: headless worker still busy in config_json_get", timeoutMs, + userMessage: expect.stringContaining("800 ms"), + remediationAction: expect.stringContaining("timeoutMs"), cause: busy, diagnostics: { health_request_operation: "server_health", diff --git a/src/ida/IdaMcpConnection.ts b/src/ida/IdaMcpConnection.ts index d427d1fe45..57f7bc32be 100644 --- a/src/ida/IdaMcpConnection.ts +++ b/src/ida/IdaMcpConnection.ts @@ -19,7 +19,10 @@ import { StdioClientTransport, } from "@modelcontextprotocol/client/stdio"; import { z } from "zod"; -import type { IdaConfiguration } from "./IdaConfiguration.js"; +import { + idaRegistrationTimeoutGuidance, + type IdaConfiguration, +} from "./IdaConfiguration.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -397,6 +400,11 @@ export const createIdaMcpConnection = ( throw new AnalysisTimeoutError(operation, requestTimeoutMs, { cause: failure, capturedOutput, + ...idaRegistrationTimeoutGuidance( + requestTimeoutMs, + "request", + config.timeoutMs, + ), }); if (bufferLimit !== null) throw new AnalysisResourceConstraintError( diff --git a/src/jeb/JebMcpConnection.test.ts b/src/jeb/JebMcpConnection.test.ts index b5bc163d46..59706d5449 100644 --- a/src/jeb/JebMcpConnection.test.ts +++ b/src/jeb/JebMcpConnection.test.ts @@ -1,5 +1,6 @@ import { createServer } from "node:http"; -import { describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/client"; +import { describe, expect, it, vi } from "vitest"; import { decodeJebToolResult, @@ -30,9 +31,31 @@ const waitForRequestCount = async ( throw new Error(`Expected ${expected} ${method} requests`); }; +const jebCatalogResult = ( + paginateCatalog: boolean, + cursor: string | undefined, +) => { + const pageTools = + paginateCatalog && cursor === undefined + ? jebTools.slice(0, 1) + : paginateCatalog + ? jebTools.slice(1) + : jebTools; + return { + tools: pageTools.map((name) => ({ + name, + inputSchema: { type: "object" as const, properties: {} }, + })), + ...(paginateCatalog && cursor === undefined + ? { nextCursor: "page-2" } + : {}), + }; +}; + const startJebHttpFixture = ( responseDelayMs: number, catalogRefreshDelayMs = 0, + options: { readonly paginateCatalog?: boolean } = {}, ) => { let callCount = 0; let catalogCount = 0; @@ -78,18 +101,20 @@ const startJebHttpFixture = ( response.on("close", () => { if (!response.writableEnded) responseClosed = true; }); + const cursor = + typeof message.params?.cursor === "string" + ? message.params.cursor + : undefined; const sendCatalog = () => { if (responseClosed) return; response.writeHead(200, { "Content-Type": "application/json" }).end( JSON.stringify({ jsonrpc: "2.0", id: message.id, - result: { - tools: jebTools.map((name) => ({ - name, - inputSchema: { type: "object", properties: {} }, - })), - }, + result: jebCatalogResult( + options.paginateCatalog === true, + cursor, + ), }), ); }; @@ -372,3 +397,52 @@ it("keeps caller abort and connection close as cancellations during catalog refr } } }); + +it("passes a discovery budget above the SDK default to connect and every catalog page", async () => { + const discoveryTimeoutMs = 120_000; + const connect = vi.spyOn(Client.prototype, "connect"); + const request = vi.spyOn(Client.prototype, "request"); + const fixture = await startJebHttpFixture(0, 0, { paginateCatalog: true }); + const connection = createStreamableHttpJebMcpConnection(fixture.endpoint, { + discoveryTimeoutMs, + requestTimeoutMs: discoveryTimeoutMs, + }); + try { + await expect(connection.connect()).resolves.toEqual([...jebTools]); + const connectTimeout = connect.mock.calls[0]?.[1]?.timeout; + expect(connectTimeout).toBeGreaterThan(60_000); + expect(connectTimeout).toBeLessThanOrEqual(discoveryTimeoutMs); + const listTimeouts = request.mock.calls.flatMap((call) => { + const message = call[0]; + if ( + typeof message !== "object" || + message === null || + !("method" in message) || + message.method !== "tools/list" + ) + return []; + const options = call[1]; + return [ + typeof options === "object" && + options !== null && + "timeout" in options && + typeof options.timeout === "number" + ? options.timeout + : -1, + ]; + }); + expect(listTimeouts.length).toBeGreaterThanOrEqual(2); + for (const timeout of listTimeouts) { + expect(timeout).toBeGreaterThan(60_000); + expect(timeout).toBeLessThanOrEqual(discoveryTimeoutMs); + } + } finally { + connect.mockRestore(); + request.mockRestore(); + try { + await connection.close(); + } finally { + await fixture.close(); + } + } +}); diff --git a/src/jeb/JebMcpConnection.ts b/src/jeb/JebMcpConnection.ts index c2e15cfad0..353e1a2195 100644 --- a/src/jeb/JebMcpConnection.ts +++ b/src/jeb/JebMcpConnection.ts @@ -163,12 +163,17 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( const acquisition = new SharedStartupAttempt< Result >(async (startupSignal) => { - const deadline = new JebMcpDeadline(discoveryTimeoutMs, startupSignal); + const deadline = new JebMcpDeadline( + discoveryTimeoutMs, + startupSignal, + "discovery", + ); try { await owner.client.connect(owner.transport, { signal: deadline.signal, + timeout: deadline.remainingMs(), }); - const tools = await readToolNames(owner.client, deadline.signal); + const tools = await readToolNames(owner.client, deadline); deadline.signal.throwIfAborted(); owner.tools = tools; return ok(tools); @@ -220,9 +225,13 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( signal === undefined ? owner.signal.signal : AbortSignal.any([signal, owner.signal.signal]); - const deadline = new JebMcpDeadline(discoveryTimeoutMs, discoverySignal); + const deadline = new JebMcpDeadline( + discoveryTimeoutMs, + discoverySignal, + "discovery", + ); try { - const refreshed = await readToolNames(owner.client, deadline.signal); + const refreshed = await readToolNames(owner.client, deadline); owner.tools = refreshed; return refreshed; } catch (cause: unknown) { @@ -281,27 +290,28 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( const readToolNames = async ( client: Client, - signal: AbortSignal, + deadline: JebMcpDeadline, ): Promise => { const names = new Set(); let cursor: string | undefined; do { - signal.throwIfAborted(); + deadline.signal.throwIfAborted(); // listTools without a cursor aggregates and caches every page in SDK v2. // This owner needs single pages to bound retained names and the whole walk. + // Each page gets the discovery budget still remaining, not the SDK default. const page = await client.request( { method: "tools/list", ...(cursor === undefined ? {} : { params: { cursor } }), }, - { signal }, + { signal: deadline.signal, timeout: deadline.remainingMs() }, ); for (const tool of page.tools) if (RELEVANT_ENGINE_TOOLS.has(tool.name)) names.add(tool.name); const next = page.nextCursor; if (next !== undefined && next === cursor) throw new AnalysisProtocolError("JEB MCP repeated a tool catalog cursor"); - signal.throwIfAborted(); + deadline.signal.throwIfAborted(); if (names.size === RELEVANT_ENGINE_TOOLS.size) return [...names]; cursor = next; } while (cursor !== undefined); diff --git a/src/jeb/JebMcpDeadline.ts b/src/jeb/JebMcpDeadline.ts index 1ddb0c3d8a..7faa52e4be 100644 --- a/src/jeb/JebMcpDeadline.ts +++ b/src/jeb/JebMcpDeadline.ts @@ -1,10 +1,27 @@ -import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; +import { + DEFAULT_REQUEST_TIMEOUT_MSEC, + SdkError, + SdkErrorCode, +} from "@modelcontextprotocol/client"; import { AnalysisCancelledError, AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; +import { MAX_PROVIDER_STARTUP_TIMEOUT_MS } from "../config/providerStartupTimeout.js"; import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; +/** Which JEB MCP phase consumed `REA_JEB_MCP_TIMEOUT_MS`. */ +export type JebMcpDeadlinePhase = "discovery" | "tools/call"; + +/** Name the launch-environment deadline that both JEB phases share. */ +export const jebMcpTimeoutGuidance = ( + phase: JebMcpDeadlinePhase, + timeoutMs: number, +): { readonly userMessage: string; readonly remediationAction: string } => ({ + userMessage: `This request reached REA's configured JEB ${phase === "discovery" ? "catalog discovery" : phase} deadline of ${String(timeoutMs)} ms (deadlineExpired).`, + remediationAction: `Increase REA_JEB_MCP_TIMEOUT_MS in REA's launch environment to an integer from 1 to ${String(MAX_PROVIDER_STARTUP_TIMEOUT_MS)} milliseconds (omitted means ${String(DEFAULT_REQUEST_TIMEOUT_MSEC)} ms) and restart and reconnect REA to apply it. The same setting raises catalog discovery and each tools/call. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, +}); + /** SDK 2.3.1 preserves an SdkError abort reason by identity. */ export class JebMcpDeadline { readonly #deadline: ProviderStartupDeadline; @@ -18,6 +35,7 @@ export class JebMcpDeadline { constructor( readonly timeoutMs: number, signal?: AbortSignal, + readonly phase: JebMcpDeadlinePhase = "tools/call", ) { this.#deadline = new ProviderStartupDeadline(timeoutMs, signal); if (this.#deadline.signal.aborted) this.#onAbort(); @@ -40,13 +58,17 @@ export class JebMcpDeadline { return this.#deadline.interruption === "timeout" ? new AnalysisTimeoutError(operation, this.timeoutMs, { cause: this.#deadline.signal.reason, + ...jebMcpTimeoutGuidance(this.phase, this.timeoutMs), }) : new AnalysisCancelledError(operation, { cause: this.#deadline.signal.reason, }); } if (cause instanceof SdkError && cause.code === SdkErrorCode.RequestTimeout) - return new AnalysisTimeoutError(operation, this.timeoutMs, { cause }); + return new AnalysisTimeoutError(operation, this.timeoutMs, { + cause, + ...jebMcpTimeoutGuidance(this.phase, this.timeoutMs), + }); return cause; } diff --git a/src/jeb/JebProvider.test.ts b/src/jeb/JebProvider.test.ts index 553e5773bd..3bd7d973d6 100644 --- a/src/jeb/JebProvider.test.ts +++ b/src/jeb/JebProvider.test.ts @@ -56,7 +56,10 @@ describe("JebProvider live availability", () => { return scriptedConnection({}); }, ); - expect(configured).toEqual({ requestTimeoutMs: 900_000 }); + expect(configured).toEqual({ + requestTimeoutMs: 900_000, + discoveryTimeoutMs: 900_000, + }); await provider.close(); }); diff --git a/src/jeb/JebProvider.ts b/src/jeb/JebProvider.ts index 4d64ba7c4a..69aa4ea57b 100644 --- a/src/jeb/JebProvider.ts +++ b/src/jeb/JebProvider.ts @@ -47,6 +47,8 @@ export interface JebProviderOptions { readonly environment?: Readonly>; /** Per-tools/call budget selected by REA_JEB_MCP_TIMEOUT_MS. */ readonly requestTimeoutMs?: number; + /** Catalog discovery budget. Production uses the same REA_JEB_MCP_TIMEOUT_MS value. */ + readonly discoveryTimeoutMs?: number; /** Injectable MCP boundary; defaults to the streamable HTTP client. */ readonly connectionFactory?: JebMcpConnectionFactory; } @@ -129,12 +131,18 @@ export class JebProvider implements JebAnalysisPort { this.#endpoint = endpoint.value; const createConnection = options.connectionFactory ?? createStreamableHttpJebMcpConnection; + const timeouts = { + ...(options.discoveryTimeoutMs === undefined + ? {} + : { discoveryTimeoutMs: options.discoveryTimeoutMs }), + ...(options.requestTimeoutMs === undefined + ? {} + : { requestTimeoutMs: options.requestTimeoutMs }), + }; this.#connection = - options.requestTimeoutMs === undefined + Object.keys(timeouts).length === 0 ? createConnection(this.#endpoint) - : createConnection(this.#endpoint, { - requestTimeoutMs: options.requestTimeoutMs, - }); + : createConnection(this.#endpoint, timeouts); } async inspectAvailability( diff --git a/src/rizin/RizinDebugSessionManager.ts b/src/rizin/RizinDebugSessionManager.ts index cadb6284df..83752b9892 100644 --- a/src/rizin/RizinDebugSessionManager.ts +++ b/src/rizin/RizinDebugSessionManager.ts @@ -37,6 +37,15 @@ const MAX_PENDING_COMMANDS = 16; const FRAME_TIMEOUT_MS = 120_000; const CLEANUP_COMMAND_TIMEOUT_MS = 1_000; const STARTUP_TIMEOUT_MS = 30_000; + +/** Fixed debugger deadline. Callers cannot raise it in this release. */ +const fixedDeadlineGuidance = ( + what: string, + timeoutMs: number, +): { readonly userMessage: string; readonly remediationAction: string } => ({ + userMessage: `${what} reached its ${String(timeoutMs)} ms deadline (deadlineExpired). This release has no runtime override.`, + remediationAction: `This ${String(timeoutMs)} ms deadline has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, +}); const MAX_FRAME_BYTES = 16 * 1024 * 1024; const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024; const MAX_QUEUED_FRAME_BYTES = 4 * 1024 * 1024; @@ -106,10 +115,18 @@ const createRizinCommandEvidence = ({ ], }); +type RizinOutputLimit = + | "command_output_bytes" + | "frame_bytes" + | "command_frames" + | "queued_frames"; + interface RizinCommandCapture { readonly output: string; readonly outputTruncated: boolean; readonly outputInvalidUtf8: boolean; + /** Cap that first stopped this capture. Absent when output is complete. */ + readonly outputLimit?: RizinOutputLimit; } interface RizinCommandInterruption { @@ -128,6 +145,7 @@ interface RizinCommandTimeoutEvidenceInput { readonly inferiorTty: string; readonly cleanupFailure: string | undefined; readonly launchProvenance: RizinLaunchProvenance; + readonly timeoutMs?: number; } export const createRizinCommandTimeoutEvidence = ({ @@ -137,6 +155,7 @@ export const createRizinCommandTimeoutEvidence = ({ inferiorTty, cleanupFailure, launchProvenance, + timeoutMs = FRAME_TIMEOUT_MS, }: RizinCommandTimeoutEvidenceInput): Evidence => createRizinCommandEvidence({ sessionId, @@ -145,7 +164,7 @@ export const createRizinCommandTimeoutEvidence = ({ inferiorTty, launchProvenance, limitations: [ - "The command timed out before its completion marker; retained output is partial and completion is unknown.", + `The command timed out before its completion marker at ${String(timeoutMs)} ms (deadlineExpired). This release has no runtime override. Retained output is partial and completion is unknown.`, ...(result.output_invalid_utf8 ? [ "Collected command frame bytes did not form valid UTF-8; displayed output contains replacement characters.", @@ -283,8 +302,11 @@ export class RizinDebugSessionManager { new AnalysisResourceConstraintError( "start_rizin_debug_session", "memory", - "Rizin session capacity reached.", + `Rizin session capacity reached (${String(MAX_SESSIONS)} sessions). This release has no runtime override.`, { max_sessions: MAX_SESSIONS, active_or_reserved_sessions: occupied }, + { + remediationAction: `Close a Rizin debug session before starting another. This capacity of ${String(MAX_SESSIONS)} sessions has no runtime override.`, + }, ), ); this.#activeStarts += 1; @@ -536,7 +558,14 @@ export class RizinDebugSessionManager { new AnalysisTimeoutError( "start_rizin_debug_session", this.#startupTimeoutMs, - { cause, capturedOutput }, + { + cause, + capturedOutput, + ...fixedDeadlineGuidance( + "Rizin debug session startup", + this.#startupTimeoutMs, + ), + }, ), ); } @@ -569,11 +598,14 @@ export class RizinDebugSessionManager { new AnalysisResourceConstraintError( "rizin_debug_command", "transport", - "Rizin command queue capacity reached.", + `Rizin command queue capacity reached (${String(MAX_PENDING_COMMANDS)} pending commands). This release has no runtime override.`, { max_pending_commands: MAX_PENDING_COMMANDS, active_or_queued_commands: session.pendingCommandCount, }, + { + remediationAction: `Wait for a Rizin debug command on this session to finish, then retry. This queue of ${String(MAX_PENDING_COMMANDS)} commands has no runtime override.`, + }, ), ); session.pendingCommandCount += 1; @@ -695,7 +727,11 @@ export class RizinDebugSessionManager { "Output contains all NUL frames through an explicit command-completion marker; backend output interleaved before that marker cannot be distinguished from command output.", ...(captured.outputTruncated ? [ - `The Rizin command exceeded the output or frame limit. Retained output is partial and command completion is unknown.${cleanupFailure === undefined ? " The owned debugger session was stopped." : ` The owned debugger session remains retained because cleanup could not be verified: ${cleanupFailure}`}`, + rizinCaptureStoppedSentence( + captured.outputLimit, + this.#maxCommandOutputBytes, + cleanupFailure, + ), ] : []), "The command is unrestricted and may access local files, the shell, network, or mutate the target.", @@ -793,8 +829,7 @@ export class RizinDebugSessionManager { ); } if (interruption?.kind === "timeout") { - session.protocolError = - "Rizin command timed out before its completion marker; output correlation is no longer safe."; + session.protocolError = `Rizin command timed out before its completion marker at ${String(this.#frameTimeoutMs)} ms (deadlineExpired). This release has no runtime override. Output correlation is no longer safe.`; const stopped = await this.#stopDebuggerOnly(session, true); const result = { ...commandResult(interruption.capture, "unknown"), @@ -808,6 +843,7 @@ export class RizinDebugSessionManager { cleanupFailure: stopped.status === "incomplete" ? stopped.reason : undefined, launchProvenance: session.launchProvenance, + timeoutMs: this.#frameTimeoutMs, }); if (stopped.status === "incomplete") return err( @@ -869,11 +905,13 @@ export class RizinDebugSessionManager { ), ); return err( - new AnalysisTimeoutError( - "rizin_debug_command", - this.#frameTimeoutMs, - errorOptions, - ), + new AnalysisTimeoutError("rizin_debug_command", this.#frameTimeoutMs, { + ...errorOptions, + ...fixedDeadlineGuidance( + "The Rizin debug command", + this.#frameTimeoutMs, + ), + }), ); } finally { if (acquired) { @@ -995,8 +1033,7 @@ export class RizinDebugSessionManager { continue; } if (session.bufferedFrameBytes + piece.length > MAX_FRAME_BYTES) { - const message = - "Rizin emitted an NUL frame exceeding the 16 MiB protocol limit."; + const message = `Rizin emitted a NUL frame exceeding the ${String(MAX_FRAME_BYTES)} byte (16 MiB) protocol limit. This release has no runtime override.`; if (!session.commandCaptureActive) { this.#failProtocol(session, message); return; @@ -1089,7 +1126,7 @@ export class RizinDebugSessionManager { session.commandOutputDropped ||= session.commandCaptureActive; this.#failProtocol( session, - "Rizin produced unsolicited frames exceeding the bounded queue limit.", + `Rizin produced unsolicited frames exceeding the bounded queue limit of ${String(MAX_QUEUED_FRAMES)} frames or ${String(MAX_QUEUED_FRAME_BYTES)} bytes. This release has no runtime override.`, ); return; } @@ -1403,6 +1440,28 @@ const bufferedRizinFrame = (session: RizinDebugSession, truncated: boolean) => { }; }; +const rizinCaptureStoppedSentence = ( + limit: RizinOutputLimit | undefined, + maxCommandOutputBytes: number, + cleanupFailure: string | undefined, +): string => { + const cap = + limit === "command_frames" + ? `the ${String(MAX_COMMAND_FRAMES)} frame command limit` + : limit === "frame_bytes" + ? `the ${String(MAX_FRAME_BYTES)} byte frame limit` + : limit === "queued_frames" + ? `the queued frame limit of ${String(MAX_QUEUED_FRAMES)} frames or ${String(MAX_QUEUED_FRAME_BYTES)} bytes` + : limit === "command_output_bytes" + ? `the ${String(maxCommandOutputBytes)} byte output limit` + : "a capture limit"; + const cleanup = + cleanupFailure === undefined + ? " The owned debugger session was stopped." + : ` The owned debugger session remains retained because cleanup could not be verified: ${cleanupFailure}`; + return `The Rizin command exceeded ${cap}. This release has no runtime override. Retained output is partial and command completion is unknown.${cleanup}`; +}; + const waitForCommandFrames = async ( session: RizinDebugSession, marker: string, @@ -1415,15 +1474,25 @@ const waitForCommandFrames = async ( let outputBytes = 0; let outputInvalidUtf8 = false; let outputTruncated = false; + let outputLimit: RizinOutputLimit | undefined; let frameCount = 0; - const snapshot = () => ({ - output: output.join("\n"), - outputTruncated: outputTruncated || session.commandOutputDropped, - outputInvalidUtf8, - }); + const noteLimit = (limit: RizinOutputLimit): void => { + outputTruncated = true; + outputLimit ??= limit; + }; + const snapshot = (): RizinCommandCapture => { + const dropped = session.commandOutputDropped; + const limit = outputLimit ?? (dropped ? "queued_frames" : undefined); + return { + output: output.join("\n"), + outputTruncated: outputTruncated || dropped, + outputInvalidUtf8, + ...(limit === undefined ? {} : { outputLimit: limit }), + }; + }; const append = (frame: RizinFrame): boolean => { outputInvalidUtf8 ||= frame.invalidUtf8; - outputTruncated ||= frame.truncated; + if (frame.truncated) noteLimit("frame_bytes"); const markerIndex = frame.text.indexOf(marker); const text = markerIndex < 0 ? frame.text : frame.text.slice(0, markerIndex); @@ -1432,7 +1501,7 @@ const waitForCommandFrames = async ( const remaining = maxCommandOutputBytes - outputBytes - separatorBytes; const bytes = Buffer.byteLength(text, "utf8"); if (bytes > remaining) { - outputTruncated = true; + noteLimit("command_output_bytes"); const retained = truncateUtf8(text, remaining); if (retained.length > 0) output.push(retained); outputBytes = maxCommandOutputBytes; @@ -1453,7 +1522,7 @@ const waitForCommandFrames = async ( session.queuedFrameBytes -= queued.queueChargeBytes; frameCount += 1; if (frameCount > MAX_COMMAND_FRAMES) { - outputTruncated = true; + noteLimit("command_frames"); break; } markerSeen = append(queued.frame); @@ -1520,15 +1589,16 @@ const waitForCommandFrames = async ( } frameCount += 1; if (frameCount > MAX_COMMAND_FRAMES) { - session.protocolError = - "Rizin emitted more than 4096 frames for one command; the session was stopped because output correlation is ambiguous."; - outputTruncated = true; + session.protocolError = `Rizin emitted more than ${String(MAX_COMMAND_FRAMES)} frames for one command. This release has no runtime override. The session was stopped because output correlation is ambiguous.`; + noteLimit("command_frames"); return ok(snapshot()); } const markerSeen = append(received); if (outputTruncated) { - session.protocolError ??= - "Rizin command output exceeded the 16 MiB command limit."; + if (outputLimit === "command_output_bytes") + session.protocolError ??= `Rizin command output exceeded the ${String(maxCommandOutputBytes)} byte (16 MiB) command limit. This release has no runtime override.`; + else if (outputLimit === "frame_bytes") + session.protocolError ??= `Rizin emitted a NUL frame exceeding the ${String(MAX_FRAME_BYTES)} byte (16 MiB) protocol limit. This release has no runtime override.`; return ok(snapshot()); } if (markerSeen) return ok(snapshot()); diff --git a/tests/boundary/cli/reverseEngineeringCommands.test.ts b/tests/boundary/cli/reverseEngineeringCommands.test.ts index 29a61acf0e..c6af908a37 100644 --- a/tests/boundary/cli/reverseEngineeringCommands.test.ts +++ b/tests/boundary/cli/reverseEngineeringCommands.test.ts @@ -50,14 +50,22 @@ const observation = (completionStatus: "complete" | "unknown") => const runObjdump = async ( service: Partial, path = "/tmp/sample", - selection: { operation?: string; symbol?: string } = {}, + selection: { + operation?: string; + symbol?: string; + commandTimeoutMs?: number; + } = {}, ) => { const commands = new Map< string, { run(input: { args: { path: string; operation: string }; - options: { followDebugLinks: boolean; symbol?: string }; + options: { + followDebugLinks: boolean; + symbol?: string; + commandTimeoutMs?: number; + }; }): Promise; } >(); @@ -67,7 +75,11 @@ const runObjdump = async ( definition: { run(input: { args: { path: string; operation: string }; - options: { followDebugLinks: boolean; symbol?: string }; + options: { + followDebugLinks: boolean; + symbol?: string; + commandTimeoutMs?: number; + }; }): Promise; }, ) => { @@ -90,6 +102,9 @@ const runObjdump = async ( options: { followDebugLinks: false, ...(selection.symbol === undefined ? {} : { symbol: selection.symbol }), + ...(selection.commandTimeoutMs === undefined + ? {} + : { commandTimeoutMs: selection.commandTimeoutMs }), }, }); }; @@ -111,6 +126,26 @@ it("forwards the selected native symbol to the shared workflow", async () => { }); }); +it("forwards a selected objdump command deadline and omits the field otherwise", async () => { + const inspectWithObjdump = vi.fn(async (_input: unknown) => + ok(observation("complete")), + ); + const service = { inspectWithObjdump, close: async () => undefined }; + await runObjdump(service, "/tmp/sample", { commandTimeoutMs: 5_000 }); + expect(inspectWithObjdump.mock.calls[0]?.[0]).toEqual({ + path: "/tmp/sample", + operation: "file_headers", + follow_debug_links: false, + command_timeout_ms: 5_000, + }); + await runObjdump(service, "/tmp/sample"); + expect(inspectWithObjdump.mock.calls[1]?.[0]).toEqual({ + path: "/tmp/sample", + operation: "file_headers", + follow_debug_links: false, + }); +}); + it("lists objdump architectures without inventing an artifact path", async () => { const evidence = observation("complete"); const inspectWithObjdump = vi.fn(async (_input: unknown) => ok(evidence)); diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts index 7fa88f259a..ac6229e5fa 100644 --- a/tests/boundary/mcp/fridaLifecycleMcp.test.ts +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -476,7 +476,7 @@ it.each([ response.structuredContent, ); const result = evidence.normalized_result; - expect(result.messages_truncated).toBe(true); + expect(result.messages_truncated).toBe(false); const retained = jsonObjectSchema.parse(result.messages[0]); expect(retained.description_truncated).toBe(true); expect( @@ -492,7 +492,7 @@ it.each([ status.structuredContent, ), ).toMatchObject({ - normalized_result: { messages: [retained], messages_truncated: true }, + normalized_result: { messages: [retained], messages_truncated: false }, }); }, ); diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index 53233ce137..636dfd8305 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -2879,7 +2879,7 @@ process.stdin.on('data', (chunk) => { }); expect(exited.error.partialObservation).toMatchObject({ limitations: expect.arrayContaining([ - expect.stringContaining("truncated prefix"), + expect.stringContaining("16777216 byte or 16384 record cap"), ]), }); } diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts index 5a4546e1f8..7fd3c9539c 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts @@ -31,7 +31,7 @@ it.skipIf(process.platform === "win32")( }); expect(result.value.evidence.limitations).toEqual([ "The current debugger target identity is not established by the Rizin session identifier.", - "The command timed out before its completion marker; retained output is partial and completion is unknown.", + "The command timed out before its completion marker at 10 ms (deadlineExpired). This release has no runtime override. Retained output is partial and completion is unknown.", "The owned debugger session was stopped because output correlation could no longer be guaranteed.", "The command is unrestricted and may access local files, the shell, network, or mutate the target.", "Interrupted output may include an incomplete completion-marker prefix; it is retained because it cannot be distinguished from command diagnostics.", diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts index af043e12e5..e8d19ba72c 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts @@ -27,11 +27,15 @@ process.stdin.on("data", (chunk) => { }); const result = await manager.execute(sessionId, "many"); expect(result.ok).toBe(true); - if (result.ok) + if (result.ok) { expect(result.value.result).toMatchObject({ output_truncated: true, completion_status: "unknown", }); + const limitations = result.value.evidence.limitations.join("\n"); + expect(limitations).toContain("4096 frame command limit"); + expect(limitations).not.toContain("16777216"); + } }, ); diff --git a/tests/boundary/providers/RizinTimeoutEvidence.test.ts b/tests/boundary/providers/RizinTimeoutEvidence.test.ts index b83b402eb6..2f383b8b16 100644 --- a/tests/boundary/providers/RizinTimeoutEvidence.test.ts +++ b/tests/boundary/providers/RizinTimeoutEvidence.test.ts @@ -37,7 +37,7 @@ it("preserves every field of partial Rizin timeout Evidence", () => { expect(evidence.normalized_result).toEqual(result); expect(evidence.limitations).toEqual([ "The current debugger target identity is not established by the Rizin session identifier.", - "The command timed out before its completion marker; retained output is partial and completion is unknown.", + "The command timed out before its completion marker at 120000 ms (deadlineExpired). This release has no runtime override. Retained output is partial and completion is unknown.", "The owned debugger session was stopped because output correlation could no longer be guaranteed.", "The command is unrestricted and may access local files, the shell, network, or mutate the target.", "Interrupted output may include an incomplete completion-marker prefix; it is retained because it cannot be distinguished from command diagnostics.", diff --git a/tests/boundary/providers/jebMcpTimeout.test.ts b/tests/boundary/providers/jebMcpTimeout.test.ts index 41e783759e..f9ef5caab5 100644 --- a/tests/boundary/providers/jebMcpTimeout.test.ts +++ b/tests/boundary/providers/jebMcpTimeout.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "vitest"; import { JebProvider } from "../../../src/jeb/JebProvider.js"; import { createStreamableHttpJebMcpConnection } from "../../../src/jeb/JebMcpConnection.js"; import { AnalysisTimeoutError } from "../../../src/domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { startJebMcpFixture, jebPrimaryResponses, @@ -48,6 +49,15 @@ it.each([ if (result.ok) throw new Error("Timed-out MCP request passed"); expect(result.error).toBeInstanceOf(AnalysisTimeoutError); expect(result.error).toMatchObject({ operation, timeoutMs: 100 }); + const projected = projectAnalysisError(result.error); + expect(projected.message).toContain( + stage === "catalog" ? "catalog discovery" : "tools/call", + ); + expect(projected.message).toContain("100 ms"); + expect(projected.remediation.action).toContain("REA_JEB_MCP_TIMEOUT_MS"); + expect(projected.remediation.action).toContain("2147483647"); + expect(projected.remediation.action).toContain("60000"); + expect(projected.remediation.action).toContain("restart"); expect(result.error.cause).toBeDefined(); expect(result.error.cleanupIncomplete).toBe(false); } finally { diff --git a/tests/composition/reverseEngineering.test.ts b/tests/composition/reverseEngineering.test.ts index b17865c499..f703d3d91d 100644 --- a/tests/composition/reverseEngineering.test.ts +++ b/tests/composition/reverseEngineering.test.ts @@ -1,11 +1,15 @@ import { createHash } from "node:crypto"; +import type { Stats } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative } from "node:path"; +import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; +import { digestFile } from "../../src/application/reverse/ReverseEngineeringService.js"; import { TestReverseEngineeringService as ReverseEngineeringService } from "../support/reverseEngineeringService.js"; +import { providerSnapshot } from "../support/reverseEngineeringCompositionFixtures.js"; import { createReverseEngineeringService } from "../../src/composition/reverseEngineering.js"; import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../src/contracts/reverseEngineeringToolContracts.js"; @@ -17,6 +21,8 @@ describe("reverse engineering command services", () => { expect(REVERSE_ENGINEERING_TOOL_CONTRACTS[0]?.description).toContain( "does not follow local debug links by default", ); + for (const contract of REVERSE_ENGINEERING_TOOL_CONTRACTS) + expect(contract.description).toContain("command_timeout_ms"); }); it("uses compatible local DWARF link flags and disables debuginfod through the environment", async () => { @@ -500,6 +506,9 @@ it("retains partial Evidence when a provider command times out", async () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.error._tag).toBe("AnalysisTimeoutError"); + expect(JSON.stringify(result.error.partialObservation)).not.toContain( + "command_timeout_ms", + ); expect(result.error.partialObservation).toMatchObject({ operation: "execute_rizin_command", raw_result: { @@ -513,6 +522,130 @@ it("retains partial Evidence when a provider command times out", async () => { expect(JSON.stringify(projectAnalysisError(result.error))).toContain( "captured before timeout", ); + const projected = projectAnalysisError(result.error); + expect(projected.message).toContain("command_timeout_ms"); + expect(projected.message).toContain("120000 ms"); + expect(projected.remediation.action).toContain("120000"); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("keeps a selected architecture deadline on Evidence", async () => { + const service = new ReverseEngineeringService({ + run: async () => ({ stdout: "elf64-x86-64\n", stderr: "" }), + }); + const selected = await service.inspectWithObjdump({ + operation: "architectures", + command_timeout_ms: 5_000, + }); + expect(selected.ok).toBe(true); + if (!selected.ok) return; + expect(selected.value.parameters).toMatchObject({ + operation: "architectures", + command_timeout_ms: 5_000, + }); + expect(selected.value.parameters).not.toHaveProperty("path"); + + const omitted = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(omitted.ok).toBe(true); + if (!omitted.ok) return; + expect(omitted.value.parameters).not.toHaveProperty("command_timeout_ms"); +}); + +it("shares one preflight deadline across stat and digest", async () => { + const stat = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 150)); + return { isFile: () => true } as Stats; + }; + const open = () => { + const stream = new PassThrough(); + return stream as unknown as ReturnType< + typeof import("node:fs").createReadStream + >; + }; + const started = performance.now(); + await expect( + digestFile("/tmp/rea-preflight-deadline", undefined, stat, 200, open), + ).rejects.toMatchObject({ code: "ETIMEDOUT" }); + expect(performance.now() - started).toBeLessThan(280); +}); + +it("forwards selected and omitted deadlines through the production command runner", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-owned-timeout-")); + const path = join(directory, "fixture.bin"); + await writeFile(path, "before"); + const timeouts: number[] = []; + const service = new ReverseEngineeringService({ + runOwned: async (_spawn, limits) => { + timeouts.push(limits.timeoutMs); + return providerSnapshot(); + }, + }); + try { + const selected = await service.executeRizinCommand({ + path, + command: "i", + command_timeout_ms: 5_000, + }); + expect(selected.ok).toBe(true); + const omitted = await service.executeRizinCommand({ + path, + command: "i", + }); + expect(omitted.ok).toBe(true); + expect(timeouts).toEqual([5_000, 120_000]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("applies a caller-selected one-shot deadline on the next call", async () => { + const directory = await mkdtemp( + join(tmpdir(), "rea-rizin-timeout-selected-"), + ); + const path = join(directory, "fixture.bin"); + await writeFile(path, "before"); + const timeouts: number[] = []; + const service = new ReverseEngineeringService({ + run: async (_command, _args, options) => { + timeouts.push(options?.timeout ?? -1); + throw Object.assign(new Error("timeout"), { + stdout: "", + stderr: "", + code: "ETIMEDOUT", + signal: "SIGTERM", + killed: true, + }); + }, + }); + + try { + const selected = await service.executeRizinCommand({ + path, + command: "i", + command_timeout_ms: 5_000, + }); + expect(selected.ok).toBe(false); + if (!selected.ok) { + expect(selected.error).toMatchObject({ timeoutMs: 5_000 }); + expect(projectAnalysisError(selected.error).message).toContain("5000 ms"); + expect(selected.error.partialObservation).toMatchObject({ + parameters: { command_timeout_ms: 5_000 }, + }); + } + expect(timeouts).toEqual([5_000]); + for (const command_timeout_ms of [0, 2_147_483_648]) { + const rejected = await service.executeRizinCommand({ + path, + command: "i", + command_timeout_ms, + }); + expect(rejected.ok).toBe(false); + if (!rejected.ok) expect(rejected.error._tag).toBe("AnalysisInputError"); } } finally { await rm(directory, { recursive: true, force: true }); diff --git a/tests/composition/reverseEngineeringEdgeCases.test.ts b/tests/composition/reverseEngineeringEdgeCases.test.ts index 3b748fff09..e29fbad97c 100644 --- a/tests/composition/reverseEngineeringEdgeCases.test.ts +++ b/tests/composition/reverseEngineeringEdgeCases.test.ts @@ -386,7 +386,7 @@ it("rejects work after reaching the concurrent provider-operation limit", async active_operations: 8, }); expect(rejected.error.message).toContain( - "Concurrent reverse-engineering operation capacity reached.", + "Concurrent reverse-engineering operation capacity reached (8 operations).", ); } expect(providerCalls).toBe(0);