diff --git a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md index 32a5962d40..0a83d37e79 100644 --- a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md +++ b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md @@ -73,6 +73,14 @@ Parent Evidence ID plus source index preserves same-address alternatives. Follow `coverage.next_offset` only as far as the question needs; exhausting retained rows does not establish complete program discovery. +`batch_decompile` returns every body unless you select `detail: "summary"` when +the connected tool advertises it. Use its parent Evidence ID with +`view: {kind: "batch-decompile", collection: "items"}` for body-free rows and +exact `where.indices`, `addresses`, `procedure_addresses` or `statuses`. Add +`include_pseudocode: true` for several chosen bodies, or use +`collection: "pseudocode"` with one row's `index` to page a large body. Batch +limitations aggregate every item, so do not attribute them to one successful row. + Use `record_unknown` to track unresolved questions in the user's investigation and name the authority or environment still required. Supply supporting and contradicting evidence IDs. It updates the session registry directly. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 91063384cc..5420b790ad 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -106,7 +106,8 @@ limits, security/privacy/containment implications, and meaningful proof gaps. -- ## Author readiness - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 55221d1261..744cb9361f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,7 +93,8 @@ Before requesting maintainer review, the human owner must: passing. Follow the [verification requirements](#development-feedback-and-pr-verification) for required checks and real-provider coverage. - Complete the **Author readiness** declaration in the PR template, explicitly - stating that the PR is ready for initial review. + stating that the PR is ready for initial review, unless the PR author is + exempt from the declaration as listed below. Keep the PR description concise and current. Explain the problem, observable outcome, approach, and useful evidence; link long logs or put them in optional @@ -114,9 +115,12 @@ validation current as work changes. AI agents must leave human declarations to the human owner and must not attest to a human review on their behalf. The `PR readiness` check reports pending for drafts and requires a named human -owner and all three template declarations for non-drafts. It checks that the -declaration is filled, not whether the code or evidence is correct. Existing PRs -can copy the **Author readiness** section from the current template. +owner and all three template declarations for non-drafts. PRs authored by +@morluto, @kaoru0822-kitauji, @N0zoM1z0, or @gensokyo-dev are exempt from +the declaration; their human ownership, review, and evidence responsibilities +above still apply. The check identifies authors by GitHub account ID. It checks +that the declaration is filled, not whether the code or evidence is correct. +Existing PRs can copy the **Author readiness** section from the current template. Open PRs must have distinct head commits because GitHub shares commit statuses. Maintainers enable merge enforcement after the workflow is merged: run diff --git a/README.md b/README.md index 474ecfbec3..a6343e167a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ **[Website](https://rea.tools/) · [Guides](https://rea.tools/guides/) · [Showcases](https://rea.tools/showcase/)** +Join the REA community on Discord + [Quick start](#quick-start) · [How REA works](#how-rea-works) · [What you can analyze](#what-you-can-analyze) · [Showcases](#showcases) · [Build RL environments](#build-rl-environments) · [FAQ](#faq) · [Documentation](#documentation) npx rea-agents setup diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e58c4d94f2..61426ff708 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -60,7 +60,7 @@ pin inspection sources; they are not included in the npm package. | ghidra-nativeaot | [MIT](https://github.com/Washi1337/ghidra-nativeaot/blob/effeb734fc570c32650f88b159608979dc7b423e/LICENSE.md) | Pinned inspection source; derived parser attribution above. | | JADX | [Apache-2.0](https://github.com/skylot/jadx/blob/v1.5.6/LICENSE) | The audited jadx-headless-mcp source uses JADX 1.5.6 for Android decompilation; the engine JAR is separately supplied. | | jadx-headless-mcp | [Apache-2.0](https://github.com/1013503897/jadx-headless-mcp/blob/5844800a486d2248fa949b74c9896285f5b54de2/LICENSE) | v0.7.1; pinned submodule and user-supplied JAR. | -| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.988/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.988/NOTICE) | Bring your own `kuna`; REA verified 1.988 and never downloads, installs or bundles it. REA invokes it as a separate process. | +| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.989/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.989/NOTICE) | Bring your own `kuna`; REA verified 1.989 and never downloads, installs or bundles it. REA invokes it as a separate process. | | Apktool | [Apache-2.0](https://github.com/iBotPeaches/Apktool/blob/master/LICENSE.md) | User-supplied Android resource decoder. | | Binwalk | [MIT](https://github.com/ReFirmLabs/binwalk/blob/4fdab3d464d97b68e0af9088df3f9e2e1545b21c/LICENSE) | v3.1.0; pinned submodule and user-supplied firmware engine. | | Unblob | [MIT](https://github.com/onekey-sec/unblob/blob/1fcc7a0a584a70a96c31f5a276c20944d199a089/LICENSE) | 26.6.4; pinned submodule and user-supplied firmware engine. | diff --git a/bridge/native/ReaNativeUI.swift b/bridge/native/ReaNativeUI.swift index b123a57f24..2c7bd73992 100644 --- a/bridge/native/ReaNativeUI.swift +++ b/bridge/native/ReaNativeUI.swift @@ -65,7 +65,10 @@ func observe(_ request: Request) async throws -> [String: Any] { guard SHA256.hash(data: executableBytes).map({ String(format: "%02x", $0) }).joined() == request.sha256 else { try fail("target-mismatch", "Running executable bytes differ from the active target digest") } - guard let windows = CGWindowListCopyWindowInfo([.optionIncludingWindow], request.window_id) as? [[String: Any]], + // .optionIncludingWindow omits a selected window while it is off screen, which + // is not absence: select from every window, and let capture report what it can. + guard let windows = (CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]])? + .filter({ ($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value == request.window_id }), windows.count == 1, let window = windows.first, (window[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == request.pid, (window[kCGWindowLayer as String] as? NSNumber)?.intValue == 0, diff --git a/bridge/process/ProcessRunTokenReader.swift b/bridge/process/ProcessRunTokenReader.swift index d5152ab50c..931eb48750 100644 --- a/bridge/process/ProcessRunTokenReader.swift +++ b/bridge/process/ProcessRunTokenReader.swift @@ -15,6 +15,8 @@ struct ProcessIdentityObservation: Encodable { let effective_uid: UInt32? let lifecycle: String? let relation: ProcessRelationObservation? + /** Display-only kernel accounting name (p_comm, at most MAXCOMLEN bytes). */ + let name: String? let reason: String? } @@ -31,6 +33,7 @@ struct ProcessMetadata { let parentPid: Int32 let processGroupId: Int32 let pointerSize: Int + let name: String } struct ProcessSnapshot { @@ -92,7 +95,8 @@ func readProcessSnapshot( lifecycle: process.kp_proc.p_stat == SZOMB ? "zombie" : "non-zombie", parentPid: process.kp_eproc.e_ppid, processGroupId: process.kp_eproc.e_pgid, - pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4 + pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4, + name: processName(process) ) ) }, @@ -103,6 +107,13 @@ func readProcessSnapshot( return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(ENOMEM)") } +/** Decode the NUL-terminated accounting name; invalid UTF-8 is replaced for display. */ +private func processName(_ process: kinfo_proc) -> String { + withUnsafeBytes(of: process.kp_proc.p_comm) { bytes in + String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self) + } +} + func readProcessIdentities(pids: [Int32]) -> [ProcessIdentityObservation] { readProcessIdentities(pids: pids, snapshot: readProcessSnapshot()) } @@ -130,6 +141,7 @@ func readProcessIdentities( effective_uid: nil, lifecycle: nil, relation: nil, + name: nil, reason: "not-in-snapshot" ) } @@ -144,6 +156,7 @@ func readProcessIdentities( parent_pid: metadata.parentPid, process_group_id: metadata.processGroupId ), + name: metadata.name, reason: nil ) } @@ -157,6 +170,7 @@ private func unavailableIdentity(pid: Int32, reason: String) -> ProcessIdentityO effective_uid: nil, lifecycle: nil, relation: nil, + name: nil, reason: reason ) } @@ -214,6 +228,22 @@ func processRunTokenObservation( parse: Result, codeSigningFlags: () -> UInt32? ) -> RunTokenObservation { + // Native inspection restrictions apply even when argv is incomplete or + // overwritten, before parsing reaches the environment. Keep this fact at + // the native boundary; cleanup still decides whether the process is related. + func withheldEnvironment() -> RunTokenObservation? { + guard let flags = codeSigningFlags() else { return nil } + if (flags & csPlatformBinary) != 0 { + return unavailable(pid: pid, reason: "platform_binary_environment_withheld") + } + if (flags & csRestrict) != 0 { + return unavailable(pid: pid, reason: "restricted_process_environment_withheld") + } + return nil + } + func unavailableEnvironment(_ reason: String) -> RunTokenObservation { + withheldEnvironment() ?? unavailable(pid: pid, reason: reason) + } switch parse { case .success(let parsed): if let runId = parsed.runId { @@ -224,40 +254,25 @@ func processRunTokenObservation( if parsed.environmentEntryCount > 0 { return RunTokenObservation(pid: pid, state: "readable", run_id: nil, reason: nil) } - let flags = codeSigningFlags() - if let flags, (flags & csPlatformBinary) != 0 { - return unavailable(pid: pid, reason: "platform_binary_environment_withheld") - } - if let flags, (flags & csRestrict) != 0 { - return unavailable(pid: pid, reason: "restricted_process_environment_withheld") - } + if let withheld = withheldEnvironment() { return withheld } return RunTokenObservation(pid: pid, state: "readable", run_id: nil, reason: nil) case .failure(let failure): switch failure { case .environmentUnavailable: - let flags = codeSigningFlags() - if let flags, (flags & csPlatformBinary) != 0 { - return unavailable(pid: pid, reason: "platform_binary_environment_withheld") - } - if let flags, (flags & csRestrict) != 0 { - return unavailable(pid: pid, reason: "restricted_process_environment_withheld") - } - return unavailable(pid: pid, reason: "environment_unavailable") + return unavailableEnvironment("environment_unavailable") case .ambiguousEnvironmentBoundary: - if let flags = codeSigningFlags(), (flags & csRestrict) != 0 { - return unavailable(pid: pid, reason: "restricted_process_environment_withheld") - } - return unavailable(pid: pid, reason: "ambiguous_environment_boundary") + return unavailableEnvironment("ambiguous_environment_boundary") + // These failures already establish that token bytes were available. case .duplicateToken: return unavailable(pid: pid, reason: "duplicate_run_token") case .invalidTokenEncoding: return unavailable(pid: pid, reason: "invalid_run_token_encoding") case .appleVectorUnavailable: - return unavailable(pid: pid, reason: "apple_vector_unavailable") + return unavailableEnvironment("apple_vector_unavailable") case .invalidBuffer: - return unavailable(pid: pid, reason: "malformed_procargs") + return unavailableEnvironment("malformed_procargs") case .invalidBufferAt(let stage): - return unavailable(pid: pid, reason: "malformed_procargs_\(stage.rawValue)") + return unavailableEnvironment("malformed_procargs_\(stage.rawValue)") } } } diff --git a/bridge/pwntools/layout_impl.py b/bridge/pwntools/layout_impl.py index 86ec3f623b..a9c112fb70 100644 --- a/bridge/pwntools/layout_impl.py +++ b/bridge/pwntools/layout_impl.py @@ -16,10 +16,10 @@ PROFILE = "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2" PACKAGES = {"pwntools": "4.15.0", "pyelftools": "0.33", "unicorn": "2.1.2"} OUTPUT_BYTES = 64 * 1024 * 1024 # (e_machine, ELF class bits, byte order) -> architecture pwntools must also report. -PROFILES = {(62, 64, "little"): "amd64", (3, 32, "little"): "i386", (40, 32, "little"): "arm", - (8, 32, "little"): "mips", (8, 32, "big"): "mips"} -PROFILE_SCOPE = ("Layout profile requires ELF64 x86-64, ELF32 i386, ELF32 ARM little-endian " - "or ELF32 MIPS little- or big-endian objects.") +PROFILES = {(62, 64, "little"): "amd64", (183, 64, "little"): "aarch64", (3, 32, "little"): "i386", + (40, 32, "little"): "arm", (8, 32, "little"): "mips", (8, 32, "big"): "mips"} +PROFILE_SCOPE = ("Layout profile requires ELF64 x86-64, ELF64 AArch64 little-endian, ELF32 i386, " + "ELF32 ARM little-endian or ELF32 MIPS little- or big-endian objects.") class LayoutFailure(Exception): diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 8bcc62be86..71fd35bd65 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -1149,7 +1149,12 @@ global conflict involving an equal pair is not used to normalize either pair; th A step agrees when the statuses and the normalised bodies are equal. The report gives agreement overall, per probe and per tool, `agreement_ignoring_order` for steps that differ only in item order, and each step's differences, at most 20, -with a JSON pointer and both values cut short: +with a JSON pointer and both values cut short. Each step reports `differences_total` before that cap, +`differences_omitted`, and `differences_truncated` (true when entries were omitted). Agreement and categories use the +complete comparison. Omitted entries are not retained, so `--full-output` cannot recover them; inspect the original +capture and probe Evidence to investigate beyond the returned pointers. + +The difference categories are: | Category | Meaning | | --------------- | ------------------------------------------------------------------------------------------------------------ | diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index b58b8d04dc..cf074b10c3 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -9,9 +9,11 @@ ordinary binary inspection use the same modular capability. ## Bring your own engine Real verification covers **Linux x64** hosts and **EXEC/DYN/REL** objects in -these profiles: ELF64 x86-64, ELF32 i386, ELF32 little-endian ARM and ELF32 -little- or big-endian MIPS. Other machines, classes and byte orders, including -AArch64 and big-endian ARM, return `unsupported_target`. Provide an absolute +these profiles: ELF64 x86-64, ELF64 little-endian AArch64, ELF32 i386, ELF32 +little-endian ARM and ELF32 little- or big-endian MIPS. Other machines, classes +and byte orders, including big-endian ARM, return `unsupported_target`. Other +hosts, including Linux arm64, return `capability_unavailable` naming the +detected host before any target is read. Provide an absolute Python executable whose environment already contains unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2: @@ -424,8 +426,10 @@ rea inspect-native-exports ./libexample.so --detail summary --evidence_output ./ without an export directory is a complete empty inventory. - ELF rows list defined `.dynsym` symbols with global, weak or GNU-unique binding and default or protected visibility. Each row keeps `value` - (`st_value` as stored; bit 0 marks Thumb code on 32-bit ARM), `size`, `type` - and `binding`. ELF string tables do not declare a text encoding. Names that + (`st_value` as stored; bit 0 marks Thumb code on 32-bit ARM), `size`, `type`, + `binding` and `visibility` (`"default"` or `"protected"`; `"unknown"` in older + historical Evidence where visibility was not recorded). ELF string tables do + not declare a text encoding. Names that contain non-ASCII bytes are decoded as UTF-8 when those bytes form valid UTF-8; `name_utf8_decodable` records whether the non-ASCII name bytes are valid UTF-8 and `name_bytes_base64` retains those exact bytes (`null` for diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 22265bd53f..c2830a91a0 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -500,7 +500,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex connection/frame inventories; matching direction/opcode sequences alone do not prove alignment after drops. Redirect resource types use only shared known metadata: an omitted type leaves equality unknown rather than proving a modification. -- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. Argument types are listed by CDP argument position. An absent or non-array argument inventory is `null`, and a malformed argument keeps its position as `unknown`; either makes `console_events` incomplete, while an observed empty inventory remains `[]`. A malformed stack or top stack frame is not replaced by its caller: the console event is excluded, and a request initiator location or WebMCP `registration_source` is `null`; each is reported as `invalid_protocol_value`. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim with their original `argument_index`; objects, getters, and remote properties are never expanded. Text capture is `unavailable` when the argument inventory is unknown. +- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. Argument types are listed by CDP argument position. An absent or non-array argument inventory is `null`, and a malformed argument keeps its position as `unknown`; either makes `console_events` incomplete, while an observed empty inventory remains `[]`. A malformed stack or top stack frame is not replaced by its caller: the console event is excluded, and a request initiator location or WebMCP `registration_source` is `null`; each is reported as `invalid_protocol_value`. Source locations are CDP's zero-based lines and UTF-16 columns. A negative column, which Chrome reports on the first line of some inline scripts, is kept as reported and does not locate a column in the source URL; an invalid line or a non-integer column is `null` (WebMCP `registration_source` is `null`) and is reported as `invalid_protocol_value`. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim with their original `argument_index`; objects, getters, and remote properties are never expanded. Text capture is `unavailable` when the argument inventory is unknown. - WebSocket observations retain direction, opcode, and payload byte length. A missing or malformed CDP opcode is returned as `null`, with opcode-dependent payload byte length and shape also `null`, and marks `websocket_frames` incomplete. Missing or wrong-typed payload data is not treated as empty; its byte length is `null`, and a text payload shape is unknown. Numeric opcode `0` and an observed empty payload remain distinct from missing data. Opcode is nullable in the public inspection schema, so clients must handle unknown protocol values. When shape capture is selected, text messages are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw payloads are never retained. - Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache fingerprints hash all decoded response bytes, including bodies above 64 KiB, subject to the [64 MiB per-message CDP limit](#request-boundary). Missing or undecodable cache bodies and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. - Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers. diff --git a/docs/cli.md b/docs/cli.md index e1575faa0e..76a3e5ef5c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -569,6 +569,17 @@ prints a failure, so it omits the same-session reuse and `parameter`) for a new invocation and rerun. For example, a Ghidra decompile deadline names `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS`. +`rea debug-with-gdb` and `rea debug-with-rizin` hold their debugger only for the +command's own process. If cleanup cannot confirm the debugger stopped, they print +`cleanup_incomplete` and exit nonzero without stopping it. `remediation.action` +names the process, as does `retained_debugger_pids` in `details.diagnostics`, or +in `details.diagnostics.cleanup_error.details.diagnostics` when the command also +failed or was interrupted. Exiting closes the debugger's command and output pipes. +REA does not establish what the debugger or its target does next, so check that +process. An MCP server that shuts down likewise exits with status 1 instead of +waiting on a debugger it could not stop. It names the process on stderr and as +`retained_debugger_pids` in its error log. + If one-shot analysis fails and closing its session also fails, the original error details remain inline and `details.cleanup_error` describes the later close failure. Each diagnostic retains its own reported resources; the later @@ -1294,6 +1305,18 @@ new file. A publication that has committed can win a later cancellation; the after commit, the `cleanup_incomplete` error reports the same file as `details.published_path` and `details.bytes`. +When a failure retains a completed observation and no bundle committed, +`details.source_closure` states what that observation cites. `publication` is +`not_published`. `records` holds authentic source Evidence this invocation +captured, within 64 KiB. `omitted` lists up to 64 other identities and +`omitted_count` the total: `not_captured` means the invocation holds no such +record, so nothing is shown for it or its ancestors; `not_inlined` means it was +captured but is not in this response. `complete` is true only when every cited +record is in `records` or embedded in the observation. If examining the closure +itself fails, `collection_failure` names the error type and the closure state is +unknown; the original failure is still returned. Nothing is retained after exit, +and running the command again is a new observation. + When the reader of piped output closes early, as `rea ... | head` does, the CLI stops writing, prints no error and exits `141` (`128 + 13`, the status of a SIGPIPE-terminated writer). The operation itself may have completed; only its diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 3461bd6855..87f55ed530 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -45,6 +45,10 @@ the client's configured command or reproduce the client's environment. If doctor passes but the client disconnects, inspect the client's startup error and registered launcher; see [verification scope](client-verification.md#launch-environment-verification). +`rea doctor` has the same limit for provider checks, and reports a provider setting +the registration's file does not carry as a +[non-blocking unknown](installation.md#check-readiness-for-your-task). + ## Runtime settings and client deadlines `update_analysis_settings` changes curated defaults inside the running REA diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md index 0b4d97bd65..e215f0028e 100644 --- a/docs/evm-bytecode.md +++ b/docs/evm-bytecode.md @@ -28,6 +28,15 @@ The host must already provide util-linux `prlimit`, normally `/usr/bin/prlimit`; no system tools and changes no user configuration. [Upstream provenance and license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) are packaged. +Limiter discovery validates the selected path without launching it. A relative +path or directory is reported as `not_configured`, a missing file as +`executable_missing`, and denied access or another filesystem-probe failure as +`runtime_unavailable`. Recovery names the selected path and +`REA_EVM_PRLIMIT_COMMAND`; denied access asks for execute permission and +filesystem access. The original admission or system code stays in diagnostics. +An available filesystem probe does not verify the selected tool's version or +util-linux compatibility, and REA does not substitute another command. + ## Evidence semantics - `encoding` is required: raw bytes or UTF-8 hex, with optional `0x` prefix and diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 28d70d4f27..de61db9794 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -26,6 +26,9 @@ REA does not install these tools, their dependencies or system packages. It checks the tool's `--version` banner when executing a request. Binwalk must print `binwalk ` and Unblob must print the version alone. Inspection and extraction have independent prerequisites; one does not require the other. +Capability discovery recommends `REA_BINWALK_COMMAND` for missing inspection +and `REA_UNBLOB_COMMAND` for missing extraction. Configuring the other engine +does not enable the unavailable operation. Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed. `rea doctor` does not currently inspect firmware readiness. Engine selection/version checks run diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md index 2d18753d63..7ffb31d2d6 100644 --- a/docs/flutter-analysis.md +++ b/docs/flutter-analysis.md @@ -109,9 +109,13 @@ server; older servers keep the complete request after schema discovery. header check does not establish valid table contents or exported symbols. - **`libflutter` facts** are per-ABI observations: byte size, SHA-256 digest, GNU build IDs from ELF-declared `PT_NOTE` / `SHT_NOTE` ranges, - and any _labeled_ strings the build - carries. Release engine builds often strip the `Dart VM version` string — - absence is reported as `null`, not guessed. Android clang/LLVM toolchain + and Dart/toolchain strings the build carries. `dart_version` accepts the + labeled `Dart VM version:` form and the unlabeled Dart + `Version::String()` form (` () ()` with an + optional ` on "_"` suffix) that release engines embed. An + unlabeled match is a candidate based on its shape; it is not bound to a + Dart symbol and another string can match. `null` means no supported pattern + was found, not proof that no version exists. Android clang/LLVM toolchain lines are reported verbatim as build provenance. `build_id_candidates` preserves distinct IDs. `build_id` is populated only when all declared note ranges were examined and one ID was found; @@ -122,10 +126,13 @@ server; older servers keep the complete request after schema discovery. Compressed `SHT_NOTE` sections are skipped with an unsupported-compression limitation and partial coverage; IDs from other note ranges remain candidates. Encoded note storage is never interpreted as note headers or inflated. -- Labeled strings must fit their complete grammar. `dart_version_capped` - and `toolchain_capped` independently report labels omitted by grammar or - projection bounds; either makes identification coverage partial. Values - split across read chunks are retained without publishing truncated prefixes. +- Version and toolchain strings must fit their complete grammar. + `dart_version_capped` and `toolchain_capped` independently report labels + omitted by grammar or projection bounds; either makes identification + coverage partial. Values split across read chunks are retained without + publishing truncated prefixes. Unlabeled runs that miss the + `Version::String()` grammar are ignored and do not set + `dart_version_capped`. - **`coverage: "partial"`** also appears when an ABI carries `libapp.so` without `libflutter.so` (or vice versa). - The APK itself is digested before parsing, so `target.sha256` binds the diff --git a/docs/index.md b/docs/index.md index 09a8845890..bd9cbc36a5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -51,7 +51,6 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | | Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Linux kernel modules](kernel-modules.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | -| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | | Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) | | Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | | Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) | diff --git a/docs/installation.md b/docs/installation.md index 1378665340..a9ff7d110a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -390,6 +390,20 @@ A scoped report has `scope.mode: "explicit"`. Its `scope_checks` determine `environment_healthy` summarizes the full audit. `--target` adds a target check, but the report remains audit-wide unless a scope option is also supplied. +Provider checks read the terminal running doctor. A client-started server may +receive a different environment, and doctor cannot observe it. When an available +provider relies on a setting that a registration's file does not record, or +records with other text, doctor adds a non-blocking +`registration_environment:` check with `classification: "unknown"`. The +setting names appear in that registration's `provider_settings`. Selecting the +client or an affected provider places the check in `scope_checks`; it never +changes `healthy`, because the client may still pass the setting on. Preview +`rea setup --client --skill=false --dry-run` from that terminal and +approve it to record the settings, then restart the client. To see what the +running server can use, call `binary_session` in the client and read +`result.tool_availability`. For Hopper, only an explicit `HOPPER_LAUNCHER_PATH` +is compared. + An invalid `REA_LOG_LEVEL` fails every scope, including one client or the installed skill, because the executable rejects it before any command. A selected client also fails for every other setting that MCP startup rejects, @@ -666,7 +680,8 @@ Hopper is separate commercial software with its own license. Its free demo has vendor-defined limits, and a paid license is optional. REA reuses any detected installation and preserves Hopper during uninstall. -The automated Hopper installer supports macOS 12+ and x86_64 hosts running +The automated Hopper installer supports macOS 12+, subject to the minimum +macOS version of the current Hopper release, and x86_64 hosts running Ubuntu 24.04+, Fedora 41+, Nobara 44+, Arch Linux, CachyOS, or Omarchy. The official Linux packages are x86_64-only, so setup does not offer Hopper on other Linux architectures; configure [Ghidra](#ghidra) there instead. REA itself runs on any @@ -709,6 +724,15 @@ and digest, validates the application bundle, and atomically installs it to operator can choose its demo mode or activate an existing license. Homebrew and administrator access are not used. +Each Hopper release declares its own minimum macOS version, so the setup plan +cannot confirm support before approval. Setup compares this Mac's version with +the release metadata's `deployment_target` before downloading the DMG, and with +the signed application's `LSMinimumSystemVersion` before copying it. If this Mac +is older, setup fails with `incompatible_host_version`, names both versions, and +installs nothing. Update macOS, or install a Hopper release that supports this +macOS version yourself and set `HOPPER_LAUNCHER_PATH`. An existing Hopper +installation is not checked against the current release's minimum. + The Hopper launcher action used by REA creates a document from an executable; its supported command-line interface does not attach to an already-open document. While another live REA session owns the same target and loader diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 88d97864a9..1dd3c99d58 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -876,8 +876,22 @@ and Evidence. If that expansion does not fit, the file node retains `static_analysis` and any admitted `semantic_module_analysis` observations, including their exact values and source locations. Use the public modules item view to read them. The graph marks those unexpanded relationships as unknown. -If cumulative result capacity is exhausted, analysis stops; inventoried but -unexamined files remain present with explicit unknown scopes. + +The parent charges everything it retains against the heap that was free when +analysis started, less a transfer reserve. Semantic facts and expanded +relationships stop at half of that capacity, and a source refused there keeps +its static facts with the refusal reported. Static facts can continue to three +quarters of it while the live heap also has room for another copy of what it +holds, so results charged for earlier sources do not cost later sources their +static facts when the heap can still hold them. When that share runs short, +relationships that were reserved but not yet expanded are given up before any +static facts, and each affected file reports the omission. + +If static facts themselves exhaust that share, or the parent's live heap cannot +hold the next checkpoint, analysis stops; inventoried but unexamined files remain +present with explicit unknown scopes. The failure reports the limit that applied +in bytes, what was still available, the live heap in use and what the checkpoint +needed. ### Large local JSON inputs @@ -1002,7 +1016,12 @@ reason in the renderer observations. HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR line endings, preserving UTF-16 columns. Inline JavaScript in local HTML, meaning classic or module `''') + fixture = site / "examples/notes-web/index.html" + fixture.parent.mkdir(parents=True) + fixture.write_text("
Target application
") + fallback = site / "zh-CN/fallback/index.html" + fallback.parent.mkdir(parents=True) + fallback.write_text('
English fallback notice.
') + prepare_markdown(site) + markdown = page.with_suffix(".md").read_text() + self.assertIn('# Inspect a program', markdown) + self.assertIn('const value = a < b ? "x_y" : "&";\n keepIndent();', markdown) + self.assertIn("Hidden evidence remains available.", markdown) + self.assertIn("Shortened display paths.", markdown) + self.assertNotIn("Read`input.exe`", markdown) + self.assertIn("https://rea.tools/zh-CN/guides/other/?q=a%2Fb#code", markdown) + self.assertIn("https://rea.tools/assets/diagram.svg", markdown) + for text in ("Outside navigation", "Contents navigation", "Copy code", "doNotPublish"): + self.assertNotIn(text, markdown) + self.assertFalse(fixture.with_suffix(".md").exists()) + self.assertFalse(fallback.with_suffix(".md").exists()) + html = BeautifulSoup(page.read_text(), "html.parser") + self.assertEqual(html.find("link", rel="alternate")["href"], "https://rea.tools/zh-CN/guides/test/index.md") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-adb.mjs b/scripts/verify-adb.mjs index 8d0685c908..fd4ed01285 100644 --- a/scripts/verify-adb.mjs +++ b/scripts/verify-adb.mjs @@ -358,7 +358,6 @@ try { `versionCode=${String(details.version_code)}`, ); } - } finally { await rm(transferRoot, { recursive: true, force: true }); } diff --git a/scripts/verify-binary-layout.mjs b/scripts/verify-binary-layout.mjs index 64a1b430f4..428daf53f5 100644 --- a/scripts/verify-binary-layout.mjs +++ b/scripts/verify-binary-layout.mjs @@ -38,15 +38,16 @@ if ( !isAbsolute(strace ?? "") ) throw new Error( - "verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld, strip and the i686/ARM/MIPS/MIPSel linux-gnu cross gcc and readelf must be available. No target is executed.", + "verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld, strip and the AArch64/i686/ARM/MIPS/MIPSel linux-gnu cross gcc and readelf must be available. No target is executed.", ); const run = createVerifierRun(); const execute = promisify(execFile); -const ELF32_TOOLCHAINS = [ - ["i686-linux-gnu", "EM_386", "little"], - ["arm-linux-gnueabi", "EM_ARM", "little"], - ["mips-linux-gnu", "EM_MIPS", "big"], - ["mipsel-linux-gnu", "EM_MIPS", "little"], +const CROSS_TOOLCHAINS = [ + ["aarch64-linux-gnu", "EM_AARCH64", 64, "little"], + ["i686-linux-gnu", "EM_386", 32, "little"], + ["arm-linux-gnueabi", "EM_ARM", 32, "little"], + ["mips-linux-gnu", "EM_MIPS", 32, "big"], + ["mipsel-linux-gnu", "EM_MIPS", 32, "little"], ]; for (const command of [ "gcc", @@ -54,7 +55,7 @@ for (const command of [ "strip", "/usr/bin/prlimit", strace, - ...ELF32_TOOLCHAINS.flatMap(([prefix]) => [ + ...CROSS_TOOLCHAINS.flatMap(([prefix]) => [ `${prefix}-gcc`, `${prefix}-readelf`, ]), @@ -347,14 +348,15 @@ try { cases++; } } - // ELF32 profiles: cross-compiled, never executed, checked against GNU readelf. + // Cross profiles: cross-compiled, never executed, checked against GNU readelf. for (const [ prefix, machine, + bits, byteOrder, kind, flags, - ] of ELF32_TOOLCHAINS.flatMap((toolchain) => [ + ] of CROSS_TOOLCHAINS.flatMap((toolchain) => [ [...toolchain, "executable", ["-fPIE", "-pie"]], [...toolchain, "relocatable", ["-c", "-fPIC"]], ])) { @@ -423,16 +425,18 @@ try { `readelf relocation count: ${name}`, ); for (const [entryIndex, [, offset, info]] of rows.entries()) { - // ELF32 r_info uses the low byte for type and the upper 24 bits for - // the symbol index. readelf has already decoded the file's byte order. + // ELF32 r_info packs an 8-bit type under a 24-bit symbol index; + // ELF64 packs a 32-bit type under a 32-bit index. readelf has already + // decoded the file's byte order. const decodedInfo = BigInt(`0x${info}`); + const typeBits = bits === 32 ? 8n : 32n; expectedRelocations.push({ section_index: section.index, entry_index: entryIndex, reported_offset: BigInt(`0x${offset}`), - type: Number(decodedInfo & 0xffn), + type: Number(decodedInfo & ((1n << typeBits) - 1n)), symbol_table_index: section.link, - symbol_index: Number(decodedInfo >> 8n), + symbol_index: Number(decodedInfo >> typeBits), }); } } @@ -445,7 +449,7 @@ try { const value = await inspect(mode, path); assert.deepEqual(value.architecture, { machine, - bits: 32, + bits, byte_order: byteOrder, }); assert.equal( @@ -1312,7 +1316,8 @@ try { } } } - unsupported.writeUInt16LE(183, 18); + // EM_RISCV: AArch64 (183) is now an admitted profile. + unsupported.writeUInt16LE(243, 18); const core = Buffer.from(object); core.writeUInt16LE(4, 16); for (const [name, bytes, category] of [ @@ -1337,7 +1342,7 @@ try { "invalid_input", ], ["undersized-program-header", undersizedProgramHeader, "invalid_input"], - ["arm64", unsupported, "unsupported_target"], + ["riscv64", unsupported, "unsupported_target"], ["core", core, "unsupported_target"], ]) { const path = join(root.path, name); diff --git a/scripts/verify-http3-network-captures.mjs b/scripts/verify-http3-network-captures.mjs index 130a55802b..dbf9dedba0 100644 --- a/scripts/verify-http3-network-captures.mjs +++ b/scripts/verify-http3-network-captures.mjs @@ -390,7 +390,9 @@ const report = { "Linux reverse HTTP/3 capture verification for mitmproxy 12.2.3, aioquic 1.2.0 and guest cURL", limitations: [ ...(inspectExisting - ? ["Existing capture producer provenance was not authenticated by this attempt."] + ? [ + "Existing capture producer provenance was not authenticated by this attempt.", + ] : []), "No local/WireGuard, browser, QUIC v2, migration, 0-RTT, mTLS or replay claim.", "No HAR, interruption, certificate-failure or body-completeness coverage in this profile.", diff --git a/scripts/verify-native-ui.mjs b/scripts/verify-native-ui.mjs index 91a9f6a169..b7bfb43536 100644 --- a/scripts/verify-native-ui.mjs +++ b/scripts/verify-native-ui.mjs @@ -245,7 +245,13 @@ try { scope, { environment: process.env }, ); - if (mismatch.ok || !mismatch.error.message.includes("target-mismatch")) + // A selection failure carries its helper code in the issue, not in its message. + if ( + mismatch.ok || + !mismatch.error.issues?.some(({ message }) => + message.startsWith("target-mismatch:"), + ) + ) throw new Error("Real helper did not reject changed target bytes"); report = { ok: true, diff --git a/scripts/verify-real-kuna.mjs b/scripts/verify-real-kuna.mjs index 13659746b8..de799e3ddd 100644 --- a/scripts/verify-real-kuna.mjs +++ b/scripts/verify-real-kuna.mjs @@ -28,7 +28,10 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { inspectKunaInstallation } from "../dist/kuna/KunaInstallation.js"; import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js"; import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js"; -import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; +import { + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; import { verifyKunaReferences } from "./lib/kuna-reference-verifier.mjs"; import { nativeKunaStrings, @@ -172,7 +175,7 @@ const sampleTree = async (root) => { }; /** One MCP server with a selected provider, sampled while it runs. */ -const openServer = async (provider) => { +const openServer = async (provider, kunaSettings = {}) => { const owner = Symbol(provider.id); pendingServers.add(owner); const transport = new StdioClientTransport({ @@ -185,6 +188,7 @@ const openServer = async (provider) => { REA_PROCESS_RUN_ID: run.run_id, REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cacheRoot, + ...kunaSettings, ...(process.env.GHIDRA_INSTALL_DIR === undefined ? {} : { GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR }), @@ -229,6 +233,13 @@ const openServer = async (provider) => { structured: result.structuredContent, }; }; + const callError = async (name, arguments_ = {}) => { + const result = await client.callTool( + { name, arguments: arguments_ }, + { timeout: 3_600_000 }, + ); + return requireMcpToolError(result); + }; const close = async (priorFailure) => { clearInterval(timer); await sample(); @@ -270,7 +281,59 @@ const openServer = async (provider) => { // A rejected connection/target still owns its transport and workspace. await close(cause); } - return { call, close }; + return { call, callError, close }; +}; + +/** Verify a forced inventory timeout can be retried without changing jobs. */ +const verifyProjectTimeoutRecovery = async (path, address) => { + const before = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + const settings = { + REA_KUNA_JOBS: "2", + REA_KUNA_TIMEOUT_MS: "60000", + REA_KUNA_PROJECT_TIMEOUT_MS: "1", + }; + const timed = await openServer({ path, id: "kuna" }, settings); + let timedFailure; + try { + const failure = await timed.callError("procedure_assembly", { + procedure: address, + }); + assert.equal(failure.code, "capability_unavailable"); + assert.match(failure.message, /kuna functions/u); + assert.match(failure.remediation.action, /REA_KUNA_PROJECT_TIMEOUT_MS/u); + assert.doesNotMatch(failure.remediation.action, /REA_KUNA_JOBS/u); + } catch (cause) { + timedFailure = cause; + } finally { + await timed.close(timedFailure); + } + const retry = await openServer( + { path, id: "kuna" }, + { ...settings, REA_KUNA_PROJECT_TIMEOUT_MS: "60000" }, + ); + let retryFailure; + try { + const assembly = await retry.call("procedure_assembly", { + procedure: address, + }); + assert.equal(typeof assembly.value, "string"); + assert.ok(assembly.value.length > 0); + assert.equal(assembly.structured.analysis_profile.parameters.jobs, 2); + } catch (cause) { + retryFailure = cause; + } finally { + await retry.close(retryFailure); + } + assert.equal( + createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + before, + "Kuna timeout recovery changed the target", + ); + return { forced_deadline_ms: 1, retry_deadline_ms: 60000, jobs: 2 }; }; /** Count structuring and diagnostic markers in provider pseudocode. */ @@ -535,6 +598,7 @@ const verifyVariant = async ({ flavor, directory, symbols }, variant) => { }; const results = []; +let timeoutRecovery; const failures = []; let inspectionResourcesClean = true; try { @@ -596,6 +660,11 @@ try { }), }); + timeoutRecovery = await verifyProjectTimeoutRecovery( + built, + fixtureFunctions.get("rea_checksum"), + ); + // Caller-built matrix. for (const pair of callerPairs) for (const variant of [pair.file, `${pair.file}.stripped`]) @@ -650,6 +719,9 @@ const report = { platform: `${process.platform}/${process.arch}`, verifier_run: lifecycle, results, + ...(timeoutRecovery === undefined + ? {} + : { timeout_recovery: timeoutRecovery }), limitations: [ "Targets are analyzed statically; they are never executed.", "Memory is the sampled peak resident set of the MCP server process tree; CPU is its sampled cumulative time. A short-lived peak between 200 ms samples is not observed.", diff --git a/scripts/website.py b/scripts/website.py index 4891347ff9..b6b3a39f19 100644 --- a/scripts/website.py +++ b/scripts/website.py @@ -327,8 +327,9 @@ def main(): refresh_contributors(root) elif args.command == "check": sync_pages(root, check=True) - for name in ("prepare-website.py", "verify-website.py", "test-website.py"): + for name in ("prepare-website.py", "verify-website.py", "test-website.py", "test-website-agents.py"): run_script(root, name) + subprocess.run(["node", "--test", "website/worker.test.mjs"], cwd=root, check=True) elif args.command == "links": run_script(root, "audit-website-links.py", ["--report", args.report] if args.report else []) else: diff --git a/scripts/website_agents.py b/scripts/website_agents.py new file mode 100644 index 0000000000..74a3e0a5d5 --- /dev/null +++ b/scripts/website_agents.py @@ -0,0 +1,116 @@ +"""Build public agent discovery and Markdown from their maintained sources.""" + +import hashlib +import json +from urllib.parse import urljoin +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + +from bs4 import BeautifulSoup +from markdownify import MarkdownConverter +import yaml + +from website import NOTES_FIXTURES, PageMetadata, SITE_ORIGIN, page_url + + +def write_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def prepare_discovery(root, site): + """Publish the complete skill, including its relative reference documents.""" + source = root / ".agents/skills/reverse-engineer-anything" + skill = (source / "SKILL.md").read_text(encoding="utf-8") + if not skill.startswith("---\n") or "\n---\n" not in skill[4:]: + raise ValueError("Public REA skill needs YAML frontmatter") + metadata = yaml.safe_load(skill[4:].split("\n---\n", 1)[0]) + if not isinstance(metadata, dict): + raise ValueError("Public REA skill needs YAML metadata") + name, description = metadata["name"], metadata["description"] + if name != source.name or not isinstance(description, str) or not description.strip(): + raise ValueError("Invalid public REA skill metadata") + discovery = site / ".well-known/agent-skills" + discovery.mkdir(parents=True, exist_ok=True) + artifact = discovery / f"{name}.zip" + with ZipFile(artifact, "w") as archive: + for path in [source / "SKILL.md", *sorted(source.glob("references/**/*.md"))]: + relative = path.relative_to(source) + entry = ZipInfo(f"{name}/{relative.as_posix()}", (1980, 1, 1, 0, 0, 0)) + entry.create_system = 3 + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, path.read_bytes(), compress_type=ZIP_DEFLATED) + artifact_url = f"{SITE_ORIGIN}/.well-known/agent-skills/{artifact.name}" + write_json(discovery / "index.json", { + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [{ + "name": name, "type": "archive", "description": description, + "url": artifact_url, + "digest": "sha256:" + hashlib.sha256(artifact.read_bytes()).hexdigest(), + }], + }) + entry = { + "identifier": f"urn:air:rea.tools:skill:{name}", + "displayName": "REA reverse engineering skill", + "description": description, + "type": "application/zip", + "url": artifact_url, + } + write_json(site / ".well-known/ai-catalog.json", { + "specVersion": "1.0", + "host": {"displayName": "REA", "documentationUrl": f"{SITE_ORIGIN}/get-started/"}, + "entries": [entry], + }) + write_json(site / ".well-known/ard.json", {"entries": [{ + **entry, + "representativeQueries": [ + "Explain how a feature works in a shipped executable using REA.", + "Trace an Electron application's behavior back to its packaged JavaScript.", + "Build an AgentEnv bundle from a web application capture.", + ], + }]}) + print("Prepared agent skill archive, discovery index, AI Catalog and ARD manifest.") + + +def prepare_markdown(site): + """Convert rendered reading pages, keeping code and resolving resource URLs.""" + converter = MarkdownConverter(heading_style="ATX", bullets="-", escape_underscores=False) + count = 0 + for path in sorted(site.rglob("*.html")): + if path.relative_to(site).as_posix() in NOTES_FIXTURES: + continue + source = path.read_text(encoding="utf-8") + metadata = PageMetadata() + metadata.feed(source) + # Drafts and English fallbacks keep their HTML indexing policy intact. + if metadata.noindex: + continue + soup = BeautifulSoup(source, "html.parser") + main = soup.find("main") + if main is None: + continue + # Preserve fallback notices and closed disclosures; remove interface chrome. + for node in main.select('script, style, nav, button, svg, .home-page-nav, [aria-hidden="true"]'): + if node.parent is not None: + node.decompose() + # These spans are separate lines in the shared flow component's CSS. + for node in main.select("span.flow-label, span.flow-node, span.flow-note"): + node.name = "div" + canonical = page_url(site, path) + for node in main.select("[href], [src]"): + for attribute in ("href", "src"): + if node.has_attr(attribute): + node[attribute] = urljoin(canonical, node[attribute]) + title = metadata.titles[0] if metadata.titles else "REA" + header = yaml.safe_dump({"title": title, "url": canonical}, allow_unicode=True, sort_keys=False) + markdown = converter.convert_soup(main).strip() + path.with_suffix(".md").write_text(f"---\n{header}---\n\n{markdown}\n", encoding="utf-8") + alternate = page_url(site, path.with_suffix(".md")) + links = ( + f'\n' + f'\n' + f'\n' + f'\n' + ) + path.write_text(source.replace("", links + "", 1), encoding="utf-8") + count += 1 + print(f"Prepared Markdown for {count} reading pages.") diff --git a/src/application/AnalysisEvidenceSources.ts b/src/application/AnalysisEvidenceSources.ts index ff43a6a8b0..b7795ba283 100644 --- a/src/application/AnalysisEvidenceSources.ts +++ b/src/application/AnalysisEvidenceSources.ts @@ -1,6 +1,7 @@ import { readArtifactInspectionSources } from "../domain/artifactInspection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import type { Evidence } from "../domain/evidence.js"; +import { tryParseEvidence, type Evidence } from "../domain/evidence.js"; +import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; import { err, ok, type Result } from "../domain/result.js"; /** Resolve the embedded publication closure without reinterpreting it in delivery adapters. */ @@ -15,3 +16,128 @@ export const analysisEvidenceSources = ( return err(embedded.error.retainPartialObservation(complete)); return ok([...embedded.value, complete]); }; + +/** Matches the diagnostics allowance retained for other finalized one-shot work. */ +const SOURCE_CLOSURE_INLINE_BYTES = 64 * 1024; +/** Identities beyond this stay counted; direct links remain on the observation. */ +const SOURCE_CLOSURE_LISTED_OMISSIONS = 64; +const SOURCE_CLOSURE_RECOVERY = + "No bundle was written and this invocation retains nothing after exit. Keep any records shown here, or run the operation again after following remediation; that is a new observation and may differ."; + +/** What a failed one-shot delivery holds for one record its observation cites. */ +export type SourceClosureOmission = { + readonly evidence_id: string; + /** + * `not_captured`: this invocation holds no such record, so none can be shown. + * `not_inlined`: captured, but not part of this bounded failure response. + */ + readonly reason: "not_captured" | "not_inlined"; +}; + +/** Bounded failure handoff for a completed observation whose bundle never committed. */ +export type UndeliveredSourceClosure = { + readonly publication: "not_published"; + /** Every cited record is in `records` or embedded in the observation itself. */ + readonly complete: boolean; + readonly records: Evidence[]; + readonly omitted_count: number; + readonly omitted: SourceClosureOmission[]; + readonly recovery: string; +}; + +/** The closure state is unknown because examining it failed. */ +export type UnavailableSourceClosure = { + readonly publication: "not_published"; + readonly complete: false; + readonly collection_failure: string; + readonly recovery: string; +}; + +const collectSourceClosure = ( + observation: Evidence, + captured: readonly Evidence[], + options: { readonly inlineRecords: boolean }, +): UndeliveredSourceClosure | undefined => { + const inspection = analysisEvidenceSources(observation); + const embedded = new Map( + (inspection.ok ? inspection.value : []).map((record) => [ + record.evidence_id, + record, + ]), + ); + const held = new Map(captured.map((record) => [record.evidence_id, record])); + // Set iteration reaches entries added while it runs, which makes this a + // deduplicating breadth-first queue: directly cited records spend the + // allowance first, and each link costs one lookup however often it repeats. + const closure = new Set([observation.evidence_id]); + for (const evidenceId of observation.evidence_links) closure.add(evidenceId); + for (const evidenceId of held.keys()) closure.add(evidenceId); + const records: Evidence[] = []; + const omitted: SourceClosureOmission[] = []; + let omittedCount = 0; + let inlineBytes = options.inlineRecords ? SOURCE_CLOSURE_INLINE_BYTES : 0; + const omit = ( + evidenceId: string, + reason: SourceClosureOmission["reason"], + ) => { + omittedCount += 1; + if (omitted.length < SOURCE_CLOSURE_LISTED_OMISSIONS) + omitted.push({ evidence_id: evidenceId, reason }); + }; + for (const evidenceId of closure) { + if (evidenceId === observation.evidence_id) continue; + const inObservation = embedded.get(evidenceId); + const record = inObservation ?? held.get(evidenceId); + if (record === undefined) { + omit(evidenceId, "not_captured"); + continue; + } + for (const link of record.evidence_links) closure.add(link); + if (inObservation !== undefined) continue; + const bytes = jsonValueByteLength(record, inlineBytes); + if (bytes > inlineBytes) { + omit(evidenceId, "not_inlined"); + continue; + } + records.push(record); + inlineBytes -= bytes; + } + if (closure.size === 1) return undefined; + return { + publication: "not_published", + complete: omittedCount === 0, + records, + omitted_count: omittedCount, + omitted, + recovery: SOURCE_CLOSURE_RECOVERY, + }; +}; + +/** + * Describe the source closure of a completed observation after delivery failed + * before commit. Only already captured records are reported, so an unresolved + * link stays an explicit omission and its own ancestors remain unknown. + * + * This annotates a failure that must reach the caller, so it never throws. If + * examining the closure fails, the state is reported as unknown, naming only + * the bounded error type rather than a message that could quote analyzed data. + */ +export const undeliveredSourceClosure = ( + partialObservation: unknown, + captured: readonly Evidence[], + options: { readonly inlineRecords: boolean }, +): UndeliveredSourceClosure | UnavailableSourceClosure | undefined => { + try { + const observation = tryParseEvidence(partialObservation); + return observation === undefined + ? undefined + : collectSourceClosure(observation, captured, options); + } catch (cause: unknown) { + return { + publication: "not_published", + complete: false, + collection_failure: cause instanceof Error ? cause.name : "unknown", + recovery: SOURCE_CLOSURE_RECOVERY, + }; + } +}; diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts index 58761256a1..e8209cc166 100644 --- a/src/application/AppBundleExecutable.ts +++ b/src/application/AppBundleExecutable.ts @@ -461,10 +461,11 @@ const resolveLayoutExecutable = async ( let name: string; try { - name = hasBinaryPlistSignature(plist) - ? await (fileSystem.decodeBinaryPlist?.(plistPath) ?? - parseBinaryPlistExecutable(plist)) - : parseXmlPlistExecutable(decodeXmlPlistText(plist)); + name = + hasBinaryPlistSignature(plist) && + fileSystem.decodeBinaryPlist !== undefined + ? await fileSystem.decodeBinaryPlist(plistPath) + : executableName(decodeInfoPlistValue(plist)); } catch (cause: unknown) { return err( new BinaryTargetError( @@ -546,12 +547,18 @@ const resolveLayoutExecutable = async ( } }; -/** Decode the top-level executable name with an XML parser, not a pattern. */ -const parseXmlPlistExecutable = (plist: string): string => { - // An unrelated `__proto__` entry must not make the bundle unreadable. - const { value } = parseXmlPropertyList(plist); - return executableName(value); -}; +/** + * Decode one Info.plist's top-level value in-process, binary or XML, so no + * host plist tool is required. Values are read with format-aware parsers, not + * patterns; an unrelated `__proto__` entry must not make the bundle unreadable. + */ +export const decodeInfoPlistValue = (plist: Buffer): unknown => + hasBinaryPlistSignature(plist) + ? decodeBinaryPlistValue( + plist, + preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES), + ) + : parseXmlPropertyList(decodeXmlPlistText(plist)).value; /** The Info.plist decoded but declares no string CFBundleExecutable. */ class UndeclaredBundleExecutable extends Error {} @@ -565,12 +572,6 @@ const executableName = (value: unknown): string => { const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() }); -/** Decode a binary plist in-process, so no host plist tool is required. */ -const parseBinaryPlistExecutable = (plist: Buffer): string => { - const plan = preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES); - return executableName(decodeBinaryPlistValue(plist, plan)); -}; - /** Names are one local path component; backslash is literal on POSIX hosts. */ const isSafeExecutableName = (name: string): boolean => name.length > 0 && diff --git a/src/application/CallRelationshipTraversal.ts b/src/application/CallRelationshipTraversal.ts index 4b68e5bd3b..6c53ad2da0 100644 --- a/src/application/CallRelationshipTraversal.ts +++ b/src/application/CallRelationshipTraversal.ts @@ -46,6 +46,15 @@ export type CallRelationshipRead = | { readonly status: "unreadable"; readonly error: AnalysisOutputError } | { readonly status: "stopped"; readonly error: AnalysisError }; +/** + * Whether a failed child read must end a traversal rather than become one of + * its unknowns. A cancelled read ends it; so does a read whose provider cannot + * confirm release of resources it owns, because that read has not finished and + * dispatching more reads would build on that state. + */ +export const stopsTraversal = (error: AnalysisError): boolean => + error._tag === "AnalysisCancelledError" || error.cleanupIncomplete; + /** Read and parse one procedure's direct relationships for a traversal. */ export const readCallRelationships = async ( observe: CallRelationshipObserver, @@ -57,13 +66,7 @@ export const readCallRelationships = async ( const execution = await observe(operation, { procedure }, signal, "provider"); if (!execution.ok) return { - // A provider that cannot confirm release of resources it owns has not - // finished this read; dispatching more reads would build on that state. - status: - execution.error._tag === "AnalysisCancelledError" || - execution.error.cleanupIncomplete - ? "stopped" - : "unavailable", + status: stopsTraversal(execution.error) ? "stopped" : "unavailable", error: execution.error, }; const related = parseRelatedAddresses( diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 024e94b6c4..8425bd1ab9 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -8,6 +8,7 @@ import { compactToolAvailability, } from "./CapabilityInventory.js"; import { TOOL_CONTRACTS } from "../contracts/toolContracts.js"; +import { cleanupRetryRemediation } from "../domain/analysisErrorRecovery.js"; import { expandToolAvailability, toolAvailabilityInventory, @@ -52,6 +53,29 @@ const enabledPolicy: Parameters[1] = { v8InspectorObservationEnabled: true, }; +it("adds the retaining close owner's prerequisite without guessing from a pending flag", () => { + const retry = { operation: "close_binary" } as const; + const input = { open: false, capabilities: [], cleanup_pending: true }; + const unavailable = buildCapabilityInventory( + { ...input, cleanup_retry: retry }, + enabledPolicy, + ).find(({ name }) => name === "procedure_assembly"); + expect(unavailable).toMatchObject({ + available: false, + reason: "target_required", + remediation: expect.stringContaining(cleanupRetryRemediation(retry)), + }); + const unknownOwner = buildCapabilityInventory(input, enabledPolicy).find( + ({ name }) => name === "procedure_assembly", + ); + const original = buildCapabilityInventory( + { open: false, capabilities: [] }, + enabledPolicy, + ).find(({ name }) => name === "procedure_assembly"); + expect(unknownOwner).toEqual(original); + expect(unknownOwner?.reason).toBe("target_required"); +}); + const status = ( options: { readonly open?: boolean; @@ -856,6 +880,62 @@ describe("trace_feature requirements", () => { }); }); +describe("firmware operation recovery", () => { + it.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])( + "keeps inspection=%s and extraction=%s independent", + (inspection, extraction) => { + const inventory = buildCapabilityInventory(status(), { + processCaptureEnabled: false, + firmwareInspectionEnabled: inspection, + firmwareExtractionEnabled: extraction, + }); + for (const [name, enabled, command, otherCommand] of [ + [ + "inspect_firmware_regions", + inspection, + "REA_BINWALK_COMMAND", + "REA_UNBLOB_COMMAND", + ], + [ + "extract_firmware", + extraction, + "REA_UNBLOB_COMMAND", + "REA_BINWALK_COMMAND", + ], + ] as const) { + const tool = inventory.find((candidate) => candidate.name === name); + expect(tool).toMatchObject({ + available: enabled, + reason: enabled ? "available" : "provider_missing", + }); + if (enabled) { + expect(tool?.remediation).toBeNull(); + } else { + expect(tool?.remediation).toContain(command); + expect(tool?.remediation).not.toContain(otherCommand); + expect(tool?.remediation).toContain("prlimit"); + expect(tool?.remediation).toContain("procps ps"); + expect(tool?.remediation?.includes("external extractor")).toBe( + name === "extract_firmware", + ); + } + } + const byName = (left: ToolAvailability, right: ToolAvailability) => + left.name.localeCompare(right.name); + expect( + expandToolAvailability(compactToolAvailability(inventory)).toSorted( + byName, + ), + ).toEqual(inventory.toSorted(byName)); + }, + ); +}); + const HOST_RESTRICTED_TOOLS = [ ["inspect_packet_capture", "packetCapture", "packetCaptureEnabled"], ["inspect_packet_fields", "packetCapture", "packetCaptureEnabled"], diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 2ae0fb4ab7..b1316b5d6f 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -1,6 +1,9 @@ import { PCAP_CAPTURE_UNSUPPORTED_HOST_REASON } from "../domain/pcap/pcapAnalysis.js"; import { DEFAULT_CALL_RELATIONSHIP_DIRECTION } from "../contracts/enhancedInputs.js"; -import { FIRMWARE_TOOL_REQUIREMENTS } from "../contracts/firmware/firmwareToolRequirements.js"; +import { + FIRMWARE_OPERATION_ENGINES, + FIRMWARE_TOOL_REQUIREMENTS, +} from "../contracts/firmware/firmwareToolRequirements.js"; import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js"; import { NATIVE_DISPATCH_METADATA_SOURCES } from "./native/NativeDispatchMetadataInspection.js"; import { NATIVE_UI_ACTION_OPERATIONS } from "./native/NativeUiActionTrace.js"; @@ -42,6 +45,11 @@ import type { JebAnalysisAvailability } from "./jeb/JebAnalysisPort.js"; import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js"; import { TOOL_CONTRACTS } from "../contracts/toolContracts.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { + analysisCleanupRetrySchema, + cleanupRetryRemediation, + type AnalysisCleanupRetry, +} from "../domain/analysisErrorRecovery.js"; import { isBundleResourceOperation } from "./binary/BundleResourceSession.js"; const FRIDA_OPERATION_NAMES: ReadonlySet = new Set( @@ -50,6 +58,7 @@ const FRIDA_OPERATION_NAMES: ReadonlySet = new Set( const statusSchema = z.object({ open: z.boolean(), + cleanup_retry: analysisCleanupRetrySchema.optional(), session_kind: z.literal("bundle_resources").optional(), kind: z.enum(["executable", "database", "archive", "artifact"]).optional(), format: z.string().optional(), @@ -135,6 +144,7 @@ type AvailabilityContext = { readonly name: string; readonly kind: ToolKind; readonly targetOpen: boolean; + readonly cleanupRetry: AnalysisCleanupRetry | undefined; readonly resourceSession: boolean; readonly requiresActiveTarget: boolean; readonly targetKind: @@ -263,6 +273,7 @@ export const buildCapabilityInventory = ( name: contract.name, kind: contract.kind, targetOpen: status.open, + cleanupRetry: status.cleanup_retry, resourceSession: status.session_kind === "bundle_resources", requiresActiveTarget: "requiresActiveTarget" in contract && @@ -587,15 +598,19 @@ const workflowAvailabilityFor = ({ "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", ); if (kind === "firmware-provider") { - const { binwalk, unblob } = FIRMWARE_TOOL_REQUIREMENTS; const inspection = name === "inspect_firmware_regions"; + const operation = inspection + ? "inspect_firmware_regions" + : "extract_firmware"; + const requirement = + FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES[operation]]; return hostRestrictedAvailability( policy, inspection ? "firmwareInspection" : "firmwareExtraction", inspection ? policy.firmwareInspectionEnabled : policy.firmwareExtractionEnabled, - `On Linux, provide an absolute ${binwalk.commandEnvironment} (${binwalk.name} ${binwalk.line}, verified with ${binwalk.version}) or ${unblob.commandEnvironment} (${unblob.name} ${unblob.line}, verified with ${unblob.version}) path and util-linux prlimit. Extraction also requires the selected format’s external extractor.`, + `On Linux, provide an absolute ${requirement.commandEnvironment} (${requirement.requirement}) path, util-linux prlimit and procps ps.${inspection ? "" : " Extraction also requires the selected format’s external extractor."}`, ); } if (kind === "android-provider") @@ -779,13 +794,17 @@ const targetAvailability = ({ targetKind, targetFormat, targetOpen, + cleanupRetry, requiresActiveTarget, descriptors, }: AvailabilityContext): Availability | null => { if (!targetOpen && requiresActiveTarget) return { reason: "target_required", - remediation: "Call open_binary with a supported local target.", + remediation: + cleanupRetry === undefined + ? "Call open_binary with a supported local target." + : `${cleanupRetryRemediation(cleanupRetry)} After cleanup succeeds, call open_binary with a supported local target.`, }; const descriptor = descriptors.get(name); if ( diff --git a/src/application/ClientRegistrationEnvironment.ts b/src/application/ClientRegistrationEnvironment.ts index 8a1b84afa7..1173106394 100644 --- a/src/application/ClientRegistrationEnvironment.ts +++ b/src/application/ClientRegistrationEnvironment.ts @@ -127,6 +127,62 @@ export const clientRegistrationEnvironmentDetails = ( return validatedDetails(environment, validation, platform); }; +/** Setting names a registration does not carry as the value selected here. */ +export interface RecordedProviderSettings { + /** Selected here and absent from the registration's recorded environment. */ + readonly unrecorded: readonly string[]; + /** Recorded with text other than the value selected here. */ + readonly differing: readonly string[]; + /** Recorded as an expression, or in a map REA could not read. */ + readonly unresolved: readonly string[]; +} + +/** + * Compare settings selected in this process with a registration's own file. + * The server's environment also depends on what its client passes on, which is + * not observable here, so a gap is never a claim about the effective value. + * Returns only names: recorded values stay in the file that owns them. + */ +export const recordedProviderSettings = ( + parsed: ClientConfigurationDocument, + selected: Readonly>, + platform: NodeJS.Platform = process.platform, +): RecordedProviderSettings | undefined => { + const names = Object.keys(selected).sort(compareUnicodeCodePoints); + if (names.length === 0) return undefined; + const details = clientRegistrationEnvironmentDetails( + parsed, + {}, + "fragment", + platform, + ); + if (!details.ok) return { unrecorded: [], differing: [], unresolved: names }; + const recorded = snapshotEnvironment(details.value.values, platform); + // Expression names fold like every other environment name on this platform. + const expressions = snapshotEnvironment( + Object.fromEntries( + [...details.value.unresolvedSettings].map((name) => [name, ""]), + ), + platform, + ); + const facts = { + unrecorded: names.filter( + (name) => recorded[name] === undefined && expressions[name] === undefined, + ), + differing: names.filter( + (name) => + recorded[name] !== undefined && recorded[name] !== selected[name], + ), + unresolved: names.filter((name) => expressions[name] !== undefined), + }; + return facts.unrecorded.length + + facts.differing.length + + facts.unresolved.length === + 0 + ? undefined + : facts; +}; + /** Resolve the async provider-specific budget through its retained reader owner. */ export const clientRegistrationEnvironmentDetailsWithBudget = async ( parsed: ClientConfigurationDocument | undefined, diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 24266348a9..f1396eefa8 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -34,8 +34,14 @@ import { import type { SetupClient } from "./SupportedClients.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { dshDocument } from "./DshCordisPatch.js"; -import { clientRegistrationEnvironmentDetailsWithBudget } from "./ClientRegistrationEnvironment.js"; -import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js"; +import { + clientRegistrationEnvironmentDetailsWithBudget, + recordedProviderSettings, +} from "./ClientRegistrationEnvironment.js"; +import type { + ProviderRequestBudgetResolver, + RecordedProviderSettings, +} from "./ClientRegistrationEnvironment.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { @@ -52,6 +58,8 @@ interface ClientRegistrationStatusBase { readonly package_reference?: string; /** Non-blocking timeout guidance; caller-selected values remain aligned. */ readonly advisory?: string; + /** Provider settings this process selected that the registration's file does not carry. */ + readonly provider_settings?: RecordedProviderSettings; } type RegistrationCommand = readonly [string, ...string[]]; @@ -99,6 +107,8 @@ interface ClientRegistrationReadOptions { */ readonly evidence?: "readiness" | "ownership"; readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver; + /** Settings to compare with each registration's file; omitted skips that read. */ + readonly providerSettings?: Readonly>; } /** Registration facts shared by diagnostics and update maintenance. */ @@ -362,6 +372,13 @@ export const readClientRegistrationInspections = async ( registrationEnvironment.value.timeoutInputs, ) : undefined, + options.providerSettings === undefined + ? undefined + : recordedProviderSettings( + parsed, + options.providerSettings, + options.platform, + ), ), activation, commandFacts.identity, @@ -494,6 +511,7 @@ const configuredStatus = ( policyRemediation?: string, packageReference?: string, advisory?: string, + providerSettings?: RecordedProviderSettings, ): ClientRegistrationStatus => state === "aligned" ? { @@ -506,6 +524,9 @@ const configuredStatus = ( state, remediation: null, ...(advisory === undefined ? {} : { advisory }), + ...(providerSettings === undefined + ? {} + : { provider_settings: providerSettings }), } : { client: client.name, @@ -516,6 +537,9 @@ const configuredStatus = ( : { package_reference: packageReference }), state, remediation: policyRemediation ?? remediation(client), + ...(providerSettings === undefined + ? {} + : { provider_settings: providerSettings }), }; const timeoutAdvisory = ( diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index b09b0f332a..70935557c5 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -59,6 +59,7 @@ import { composedLimitationBudget, executeComposedWorkflow, createWorkflowEvidence, + retainPartialWorkflowEvidence, workflowSnapshotRecord, recordWorkflowUnknowns, } from "./WorkflowEvidence.js"; @@ -627,6 +628,12 @@ const executeAnalysisTool = async (input: { new EnhancedTools(observed).execute(tool, input.arguments, signal), composedLimitationBudget(tool), ); + retainPartialWorkflowEvidence(result, { + target: input.openedTarget, + operation: tool, + parameters: input.evidenceParameters, + upstreamProfile: evidenceProfile, + }); result = retainAnalysisResultSources( tool, (source) => session.recordEvidence(source), diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 4d7092c5e9..3b096e53ca 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -57,6 +57,7 @@ import { type UnhealthyClientRegistrationStatus, } from "./ClientRegistrationStatus.js"; import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js"; +import { registrationSpelledSettings } from "./SetupRegistrationEnvironment.js"; import { collectDoctorDiagnostics, doctorHealthy, @@ -146,6 +147,7 @@ export interface DoctorHost { close?(): Promise; clientRegistrations?( selectedClientIds?: readonly string[], + providerSettings?: Readonly>, ): Promise; ilspyCmdVersion?(path: string): Promise; } @@ -276,18 +278,26 @@ const inspectDoctor = async ( hopperPath, providerInspections, } = await collectDoctorDiagnostics(target, host); - const identity = await collectDoctorIdentity(host, scope); + const hopperAvailable = + hopperPath !== undefined && + diagnosticChecks + .filter(({ name }) => name.startsWith("hopper")) + .every(({ ok }) => ok); + const selections = providerSettingSelections( + providerInspections, + hopperAvailable && hopperPath === host.configuredHopperPath + ? hopperPath + : undefined, + ); + const identity = await collectDoctorIdentity(host, scope, selections); const checks = [ ...diagnosticChecks, ...identity.checks, ...registrationAdvisoryChecks(identity.value.registrations), + ...registrationEnvironmentChecks(identity.value.registrations, selections), ]; const providers = { - hopperAvailable: - hopperPath !== undefined && - checks - .filter(({ name }) => name.startsWith("hopper")) - .every(({ ok }) => ok), + hopperAvailable, hopperConfigured: host.configuredHopperPath !== undefined, providerInspections, }; @@ -329,6 +339,77 @@ const registrationAdvisoryChecks = ( ], ); +/** Settings an available provider's checks in this report relied on. */ +interface ProviderSettingSelection { + readonly provider: string; + readonly settings: Readonly>; +} + +const providerSettingSelections = ( + inspections: readonly DoctorProviderInspection[] | undefined, + configuredHopperPath: string | undefined, +): readonly ProviderSettingSelection[] => [ + ...(inspections ?? []) + .filter(({ available }) => available) + .map(({ id, registrationEnvironment }) => ({ + provider: id, + settings: registrationEnvironment, + })), + // Startup probes the fixed and renamed locations on its own, so only an + // explicit path is something this terminal selected for the server. + ...(configuredHopperPath === undefined + ? [] + : [ + { + provider: "hopper", + settings: { HOPPER_LAUNCHER_PATH: configuredHopperPath }, + }, + ]), +]; + +/** + * A healthy provider check describes this terminal, not the server a client + * starts. Report what the registration's file leaves out without gating health: + * the client may still pass the setting on, which doctor cannot observe. + */ +const registrationEnvironmentChecks = ( + registrations: readonly ClientRegistrationStatus[], + selections: readonly ProviderSettingSelection[], +): readonly DoctorCheck[] => + registrations.flatMap(({ client, provider_settings: settings }) => { + if (settings === undefined) return []; + const { unrecorded, differing, unresolved } = settings; + const names = [...unrecorded, ...differing, ...unresolved]; + return [ + { + name: `registration_environment:${client}`, + ok: true, + classification: "unknown" as const, + detail: [ + `Provider checks used this terminal's environment; what the server ${client} starts receives is unknown.`, + ...(unrecorded.length === 0 + ? [] + : [`Its registration does not record ${unrecorded.join(", ")}.`]), + ...(differing.length === 0 + ? [] + : [`It records other values for ${differing.join(", ")}.`]), + ...(unresolved.length === 0 + ? [] + : [`REA could not resolve its ${unresolved.join(", ")}.`]), + ].join(" "), + details: { + effective_environment: "unknown", + providers: selections + .filter(({ settings: selected }) => + names.some((name) => selected[name] !== undefined), + ) + .map(({ provider }) => provider), + }, + remediation: `Preview rea setup --client ${client} --skill=false --dry-run from this terminal, apply it to record these settings and restart the client, or read binary_session result.tool_availability in the client.`, + }, + ]; + }); + const closeDoctorHost = async ( host: DoctorHost, ): Promise => { @@ -369,12 +450,19 @@ const doctorReportWithCleanup = ( const collectDoctorIdentity = async ( host: DoctorHost, scope: DoctorScopeReport, + selections: readonly ProviderSettingSelection[], ): Promise<{ readonly checks: readonly DoctorCheck[]; readonly value: DoctorIdentity; }> => { const installationPaths = (await host.installationPaths?.()) ?? []; - const registrations = (await host.clientRegistrations?.(scope.clients)) ?? []; + const registrations = + (await host.clientRegistrations?.( + scope.clients, + Object.fromEntries( + selections.flatMap(({ settings }) => Object.entries(settings)), + ), + )) ?? []; const skillClientIds = scope.skill_clients ?? (scope.clients.length > 0 ? scope.clients : undefined); @@ -786,12 +874,20 @@ export const systemDoctorHost = ( throw failure; } }, - clientRegistrations: (selectedClientIds) => + clientRegistrations: (selectedClientIds, providerSettings) => readClientRegistrationStatuses(homeDirectory, undefined, { platform, environment, resources, ...(selectedClientIds === undefined ? {} : { selectedClientIds }), + ...(providerSettings === undefined + ? {} + : { + providerSettings: registrationSpelledSettings( + providerSettings, + environment, + ), + }), ...(resolveProviderRequestBudget === undefined ? {} : { diff --git a/src/application/DoctorRegistrationEnvironment.test.ts b/src/application/DoctorRegistrationEnvironment.test.ts new file mode 100644 index 0000000000..58844fafbb --- /dev/null +++ b/src/application/DoctorRegistrationEnvironment.test.ts @@ -0,0 +1,184 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { createDoctorHostFixture as host } from "./Doctor.fixture.js"; +import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js"; + +const inspection = ( + id: string, + registrationEnvironment: Readonly>, + available = true, +) => ({ + id, + configured: true, + available, + providerVersion: null, + registrationEnvironment, + checks: [], +}); +const recordingHost = (overrides: Partial) => { + const requested: Readonly>[] = []; + return { + requested, + host: host({ + clientRegistrations: (_clients, providerSettings) => { + requested.push(providerSettings ?? {}); + return Promise.resolve([ + { + client: "cursor", + config_path: "/home/user/.cursor/mcp.json", + command: ["/current/rea", "mcp"], + state: "aligned", + remediation: null, + provider_settings: { + unrecorded: ["GHIDRA_INSTALL_DIR"], + differing: [], + unresolved: [], + }, + }, + ]); + }, + ...overrides, + }), + }; +}; +const selectedProviders = { + providerInspections: () => + Promise.resolve([ + inspection("ghidra", { GHIDRA_INSTALL_DIR: "/opt/ghidra" }), + inspection("jadx", { REA_JADX_MCP_JAR: "/opt/jadx.jar" }), + inspection("kuna", { REA_KUNA_COMMAND: "/opt/kuna" }, false), + ]), +}; +const CHECK = "registration_environment:cursor"; + +it("compares only settings an available provider relied on", async () => { + const { host: selectedHost, requested } = recordingHost(selectedProviders); + await runDoctor(undefined, selectedHost); + expect(requested).toEqual([ + { + GHIDRA_INSTALL_DIR: "/opt/ghidra", + REA_JADX_MCP_JAR: "/opt/jadx.jar", + }, + ]); +}); + +it.each([ + [{ providers: ["ghidra"] }, true], + [{ clients: ["cursor"] }, true], + [{ clients: ["codex"] }, false], + [{ providers: ["jadx"] }, false], + [{ skill: true }, false], +])("follows the selected scope %j", async (scope, selected) => { + const report = await runDoctor( + undefined, + recordingHost(selectedProviders).host, + scope, + ); + const inScope = report.scope_checks.some(({ name }) => name === CHECK); + expect(inScope).toBe(selected); + expect(report.informational_checks.some(({ name }) => name === CHECK)).toBe( + !selected, + ); +}); + +it("does not gate health or name unaffected providers", async () => { + const report = await runDoctor( + undefined, + recordingHost(selectedProviders).host, + { providers: ["ghidra"] }, + ); + expect(report.healthy).toBe(true); + expect(report.checks).toContainEqual( + expect.objectContaining({ + name: CHECK, + ok: true, + classification: "unknown", + details: { effective_environment: "unknown", providers: ["ghidra"] }, + }), + ); +}); + +it.each([ + ["/custom/Hopper", { HOPPER_LAUNCHER_PATH: "/custom/Hopper" }], + // A default location is found again without this terminal's environment. + [undefined, {}], +])( + "includes Hopper only for an explicit available path (%s)", + async (configuredHopperPath, expected) => { + const { host: selectedHost, requested } = recordingHost( + configuredHopperPath === undefined ? {} : { configuredHopperPath }, + ); + await runDoctor(undefined, selectedHost); + expect(requested).toEqual([expected]); + }, +); + +it("does not include an explicit Hopper path that fails its checks", async () => { + const { host: selectedHost, requested } = recordingHost({ + configuredHopperPath: "/custom/missing", + executable: () => Promise.resolve(false), + }); + await runDoctor(undefined, selectedHost); + expect(requested).toEqual([{}]); +}); + +it("compares a caller selection as setup spells it", async () => { + const home = await createTestTempDirectory("rea-doctor-spelling-"); + await writeFile( + join(home, ".claude.json"), + JSON.stringify({ + mcpServers: { + rea: { + command: "/current/rea", + args: ["mcp"], + env: { + GHIDRA_INSTALL_DIR: "/opt/ghidra", + JAVA_HOME: "/selected/alias", + }, + }, + }, + }), + ); + const system = systemDoctorHost({ + environment: { HOME: home, JAVA_HOME: "/selected/alias" }, + }); + const statuses = await system.clientRegistrations?.(["claude_code"], { + GHIDRA_INSTALL_DIR: "/opt/ghidra", + // The provider's observed home; setup records the caller's spelling. + JAVA_HOME: "/observed/jdk", + }); + await system.close?.(); + expect(statuses?.[0]?.client).toBe("claude_code"); + expect(statuses?.[0]).not.toHaveProperty("provider_settings"); +}); + +it("states each comparison result without a claim it did not establish", async () => { + const report = await runDoctor( + undefined, + host({ + clientRegistrations: () => + Promise.resolve([ + { + client: "cursor", + config_path: "/cursor.json", + command: ["/current/rea", "mcp"], + state: "aligned", + remediation: null, + provider_settings: { + unrecorded: [], + differing: ["JAVA_HOME"], + unresolved: ["GHIDRA_INSTALL_DIR"], + }, + }, + ]), + }), + ); + const detail = report.checks.find(({ name }) => name === CHECK)?.detail; + expect(detail).toContain("what the server cursor starts receives is unknown"); + expect(detail).toContain("records other values for JAVA_HOME"); + expect(detail).toContain("could not resolve its GHIDRA_INSTALL_DIR"); + expect(detail).not.toContain("does not record"); +}); diff --git a/src/application/DoctorScope.ts b/src/application/DoctorScope.ts index 1c8b18d836..870562b8c9 100644 --- a/src/application/DoctorScope.ts +++ b/src/application/DoctorScope.ts @@ -72,6 +72,9 @@ export const scopeDoctorChecks = ( input.registrations.find((candidate) => candidate.client === client), ), ); + const environmentChecks = input.checks.filter((check) => + registrationEnvironmentInScope(check, input.scope), + ); const skillChecks = input.scope.skill ? [selectedSkillCheck(input.skillState, input.checks)] : []; @@ -111,6 +114,7 @@ export const scopeDoctorChecks = ( ...hopperHostChecks, ...selectedProviderChecks, ...clientChecks, + ...environmentChecks, ...skillChecks, ]; const selectedNames = new Set(scopeChecks.map(({ name }) => name)); @@ -129,6 +133,22 @@ export const scopeDoctorChecks = ( }; }; +/** Follow the caller's question: a named client, or a provider it relies on. */ +const registrationEnvironmentInScope = ( + { name, details }: DoctorCheck, + scope: DoctorScopeReport, +): boolean => { + const [family, client] = name.split(":"); + if (family !== "registration_environment" || client === undefined) + return false; + const providers = details?.providers; + return ( + scope.clients.includes(client) || + (Array.isArray(providers) && + scope.providers.some((provider) => providers.includes(provider))) + ); +}; + const isProviderCheck = ( name: string, inspections: readonly DoctorProviderInspection[] | undefined, diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 08b001b16f..a01fb6546b 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -1,6 +1,10 @@ import { permitsCapabilityFallback } from "./CapabilityFallback.js"; import { withOverviewInventoryCoverage } from "../domain/native/inventoryCoverage.js"; import type { NativeCallGraph } from "../domain/native/nativeCallGraph.js"; +import type { + BatchDecompileItem, + BatchProcedureIdentity, +} from "../domain/native/batchDecompile.js"; import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js"; import { cleanupStoppedTraversal, @@ -76,18 +80,6 @@ import { import { traceLiteralFeature } from "./EnhancedLiteralTracing.js"; import { projectNativeApiInspection } from "./native/NativeApiInspection.js"; -type BatchProcedureIdentity = - | { - readonly status: "resolved"; - readonly address: string; - readonly name: string | null; - readonly name_error?: ReturnType; - } - | { - readonly status: "unknown"; - readonly error: ReturnType; - }; - type BatchResolution = { readonly address: string; readonly result: Result; @@ -99,16 +91,6 @@ type BatchReadyItem = { readonly providerProcedure: string; }; -type BatchDecompileItem = { - readonly address: string; - readonly procedure: BatchProcedureIdentity; - readonly status: "ok" | "error"; - readonly pseudocode?: string; - readonly error?: ReturnType; - /** Caveats of this item's own observation when it supplied no pseudocode. */ - readonly limitations?: string[]; -}; - /** * Guidance for a batch item whose provider returned null pseudocode, by * whether that observation reported caveats for the item to carry. diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index 43b76ceabb..26bf80e437 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -212,6 +212,11 @@ export const publishEvidenceDelivery = async (input: { return ok({ delivered: input.delivered, evidenceBundle: written.value }); }; +/** A delivery failure can follow commit; its receipt then owns the source closure. */ +export const evidenceDeliveryCommitted = (error: AnalysisError): boolean => + error.diagnostics?.evidence_bundle !== undefined || + (error instanceof EvidenceFileError && error.published !== undefined); + /** Publish a completed bundle and retry its exact staged-output cleanup owner. */ const persistEvidenceBundleCommand = async ( bundle: EvidenceBundle, @@ -292,10 +297,12 @@ const projectWrite = ( export const inspectEvidenceBundleCommand = async ( input: { readonly path: string; - readonly detail: "complete" | "summary" | "records"; + readonly detail: "complete" | "summary" | "records" | "links"; readonly filters?: EvidenceRecordFilters | undefined; readonly offset?: number | undefined; readonly limit?: number | undefined; + readonly traversal?: "direct" | "retained-closure" | undefined; + readonly link_status?: "missing" | "retained" | "all" | undefined; }, signal?: AbortSignal, ) => { diff --git a/src/application/HopperReleaseFeed.ts b/src/application/HopperReleaseFeed.ts index 8612982431..e6da3d70d5 100644 --- a/src/application/HopperReleaseFeed.ts +++ b/src/application/HopperReleaseFeed.ts @@ -90,6 +90,18 @@ export const parseMacHopperReleases = ( return ok(retained); }; +/** + * The minimum macOS version the selected release declares (`deployment_target`). + * Undefined when the row declares none as text; the signed application's own + * requirement is still checked before it is copied. + */ +export const macHopperReleaseMinimumSystemVersion = ( + release: MacHopperRelease, +): string | undefined => + typeof release.deployment_target === "string" + ? release.deployment_target + : undefined; + /** Stable publisher identity; signing subkeys are admitted through this primary. */ export const HOPPER_LINUX_PRIMARY_FINGERPRINT = "7C2D73BAFAF5B9A710A69DA1A26DF815099B1BCD"; diff --git a/src/application/MacHopper.test.ts b/src/application/MacHopper.test.ts index 800b120960..f6ad8d2ef3 100644 --- a/src/application/MacHopper.test.ts +++ b/src/application/MacHopper.test.ts @@ -9,6 +9,8 @@ import type { } from "./HopperDownload.js"; import { installMacHopper, + macHopperHostVersionDetail, + macHopperIncomparableMinimumDetail, type MacHopperBundleVerification, type MacHopperBundleInstallResult, type MacHopperInstallHost, @@ -77,6 +79,17 @@ class FakeMacHopperHost implements MacHopperInstallHost { this.verificationFailure === undefined ? Promise.resolve(this.verification) : Promise.reject(this.verificationFailure); + hostVersion: string | undefined = "14.5"; + appMinimum: string | undefined = "12.4"; + appMinimumFailure: Error | undefined; + appMinimumReads = 0; + macosVersion = () => Promise.resolve(this.hostVersion); + minimumSystemVersion = () => { + this.appMinimumReads += 1; + return this.appMinimumFailure === undefined + ? Promise.resolve(this.appMinimum) + : Promise.reject(this.appMinimumFailure); + }; destinationExists = () => Promise.resolve(this.existing); installBundle = ( _source: string, @@ -237,6 +250,182 @@ describe("macOS Hopper installation", () => { }); }); +const PACKAGE_URL = + "https://www.hopperapp.com:443/downloader/public/Hopper-6.6.1-demo.dmg"; + +/** The refusal detail carries the observed facts, independent of its wording. */ +const expectNamed = ( + result: Awaited>, + facts: readonly string[], +): void => { + const diagnostic = result.status === "failed" ? result.diagnostic : undefined; + for (const fact of facts) expect(diagnostic).toContain(fact); +}; + +/** The fixture's macOS row with vendor fields such as `deployment_target`. */ +const macFeed = ( + host: FakeMacHopperHost, + fields: Readonly>, +): Uint8Array => + new TextEncoder().encode( + JSON.stringify({ + "OS X": { + filename: PACKAGE_URL, + file_length: String(host.archive.byteLength), + file_hash: createHash("sha1").update(host.archive).digest("hex"), + version: "6.6.1", + ...fields, + }, + }), + ); + +describe("macOS Hopper release host requirements", () => { + it.each(["12", "12.0", "12.3", "12.3.9"])( + "refuses macOS %s below the release deployment target before downloading the package", + async (hostVersion) => { + const host = new FakeMacHopperHost(); + host.hostVersion = hostVersion; + host.feedBytes = macFeed(host, { deployment_target: "12.4" }); + + const result = await installMacHopper({}, host); + expect(result).toEqual({ + status: "failed", + reason: "incompatible_host_version", + diagnostic: macHopperHostVersionDetail({ + source: "release_feed", + packageUrl: PACKAGE_URL, + minimumVersion: "12.4", + hostVersion, + }), + }); + expectNamed(result, [PACKAGE_URL, "12.4", hostVersion]); + expect(host.packageDownloads).toBe(0); + expect(host.mounts).toBe(0); + expect(host.installs).toBe(0); + }, + ); + + it.each(["12.4", "12.4.0", "12.4.1", "12.10", "13", "26.1"])( + "installs on macOS %s at or above both declared minimums", + async (hostVersion) => { + const host = new FakeMacHopperHost(); + host.hostVersion = hostVersion; + host.feedBytes = macFeed(host, { deployment_target: "12.4" }); + + expect(await installMacHopper({}, host)).toMatchObject({ + status: "installed", + }); + expect(host.installs).toBe(1); + }, + ); + + it.each([ + { label: "an undeclared", fields: {} }, + { label: "an unrecognized", fields: { deployment_target: "twelve" } }, + { label: "a non-text", fields: { deployment_target: 12.4 } }, + { label: "a lower", fields: { deployment_target: "11.0" } }, + ])( + "lets the signed app refuse the host after $label feed deployment target", + async ({ fields }) => { + const host = new FakeMacHopperHost(); + host.hostVersion = "12.0"; + host.feedBytes = macFeed(host, fields); + + const result = await installMacHopper({}, host); + expect(result).toEqual({ + status: "failed", + reason: "incompatible_host_version", + diagnostic: macHopperHostVersionDetail({ + source: "signed_app", + minimumVersion: "12.4", + hostVersion: "12.0", + }), + }); + expectNamed(result, ["LSMinimumSystemVersion", "12.4", "12.0"]); + expect(host.packageDownloads).toBe(1); + expect(host.installs).toBe(0); + expect(host.opens).toBe(0); + expect(host.unmounts).toBe(1); + expect(host.cleanups).toBe(1); + }, + ); + + it.each(["invalid_signature", "unverified_publisher"] as const)( + "reads the app's requirement only after its signature checks pass: %s", + async (verification) => { + const host = new FakeMacHopperHost(); + host.verification = verification; + + expect(await installMacHopper({}, host)).toMatchObject({ + status: "failed", + }); + expect(host.appMinimumReads).toBe(0); + expect(host.installs).toBe(0); + }, + ); + + it("installs when neither the feed nor the signed app declares a minimum", async () => { + const host = new FakeMacHopperHost(); + host.hostVersion = "12.0"; + host.appMinimum = undefined; + + expect(await installMacHopper({}, host)).toMatchObject({ + status: "installed", + }); + }); + + it("reports an incomparable signed-app minimum as a bundle failure without copying", async () => { + const host = new FakeMacHopperHost(); + host.appMinimum = "12.x"; + + const result = await installMacHopper({}, host); + expect(result).toEqual({ + status: "failed", + reason: "bundle", + diagnostic: macHopperIncomparableMinimumDetail("12.x", "14.5"), + }); + expectNamed(result, ['"12.x"', "14.5"]); + expect(host.installs).toBe(0); + expect(host.unmounts).toBe(1); + expect(host.cleanups).toBe(1); + }); + + it("preserves an unreadable signed-app requirement as a bundle diagnostic", async () => { + const host = new FakeMacHopperHost(); + host.appMinimumFailure = new Error("Info.plist: permission denied"); + + expect(await installMacHopper({}, host)).toMatchObject({ + status: "failed", + reason: "bundle", + diagnostic: "Info.plist: permission denied", + }); + expect(host.installs).toBe(0); + expect(host.unmounts).toBe(1); + expect(host.cleanups).toBe(1); + }); + + it.each([undefined, "", "14beta", "14.0\n"])( + "refuses an incomparable host version %j before any vendor request", + async (hostVersion) => { + const host = new FakeMacHopperHost(); + host.hostVersion = hostVersion; + const download = host.download; + let requests = 0; + host.download = (url) => { + requests += 1; + return download(url); + }; + + expect(await installMacHopper({}, host)).toEqual({ + status: "failed", + reason: "unsupported_host", + }); + expect(requests).toBe(0); + expect(host.mounts).toBe(0); + }, + ); +}); + it("reports an unresolved destination probe without claiming an existing installation", async () => { const host = new FakeMacHopperHost(); host.destinationExists = async () => { diff --git a/src/application/MacHopper.ts b/src/application/MacHopper.ts index 3f0b28cc12..834311bc20 100644 --- a/src/application/MacHopper.ts +++ b/src/application/MacHopper.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readdir, + readFile, rename, rm, writeFile, @@ -12,8 +13,9 @@ import { tmpdir } from "node:os"; import { join, posix } from "node:path"; import { promisify } from "node:util"; +import { z } from "zod"; + import { appendCallerPath } from "../filesystem/callerPath.js"; -import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js"; import { macDeveloperIdRequirement } from "../process/MacCodeSignature.js"; @@ -29,8 +31,14 @@ import { parseHopperReleaseJson, parseMacHopperReleases, hopperArchiveIntegrityMatches, + macHopperReleaseMinimumSystemVersion, } from "./HopperReleaseFeed.js"; import type { HopperInstallFailureReason } from "./SetupInstallFailure.js"; +import { decodeInfoPlistValue } from "./AppBundleExecutable.js"; +import { + macosMajorVersion, + macosVersionMeetsMinimum, +} from "../domain/macosVersion.js"; const execFileAsync = promisify(execFile); const HOPPER_PUBLISHER_REQUIREMENT = macDeveloperIdRequirement( @@ -46,6 +54,8 @@ export type MacHopperBundleVerification = type MacHopperInstallFailureReason = Extract< HopperInstallFailureReason, + | "unsupported_host" + | "incompatible_host_version" | "release_metadata" | "download" | "integrity" @@ -77,8 +87,10 @@ export interface MacHopperBundleInstallOperations { removeStagingRoot(path: string): Promise; } -/** Stable network and command effects disclosed before macOS Hopper setup. */ +/** Stable effects and checks disclosed before macOS Hopper setup. */ export const macHopperInstallDisclosure = { + detail: + "Download the official Hopper package, verify it, and install it. Before the download, and again from the signed app before copying, setup compares the release's minimum macOS version with this Mac's and stops if this Mac is older. Hopper may show its demo or license prompt when first opened.", networkOrigins: [HOPPER_RELEASES_URL, HOPPER_DOWNLOAD_PREFIX] as const, commands: [ "hdiutil attach -readonly -nobrowse -mountpoint ", @@ -116,6 +128,10 @@ export interface MacHopperInstallHost { unmount(mountPath: string): Promise; appBundle(mountPath: string): Promise; validBundle(appPath: string): Promise; + /** This Mac's version, read through the probe setup planning used. */ + macosVersion(): Promise; + /** The mounted app's declared LSMinimumSystemVersion; undefined when it declares none. */ + minimumSystemVersion(appPath: string): Promise; destinationExists(path: string): Promise; installBundle( source: string, @@ -132,14 +148,20 @@ export interface MacHopperInstallHost { destination(): string; } -/** Download, verify, and install Hopper into the current user's Applications directory. */ +/** + * Download, verify, and install Hopper into the current user's Applications + * directory. The selected release's own macOS minimum gates installation: the + * feed's `deployment_target` before the package is downloaded, and the signed + * app's `LSMinimumSystemVersion` before it is copied. + */ export const installMacHopper = async ( - options: { readonly replaceExisting?: boolean } = {}, - host: MacHopperInstallHost = systemMacHopperInstallHost( - homeDirectoryFromEnvironment(process.env, process.platform), - process.env, - ), + options: { readonly replaceExisting?: boolean }, + host: MacHopperInstallHost, ): Promise => { + const hostVersion = await host.macosVersion(); + // Without a comparable host version no declared minimum can be checked. + if (hostVersion === undefined || macosMajorVersion(hostVersion) === undefined) + return { status: "failed", reason: "unsupported_host" }; let temporary: string | undefined; let mounted = false; let bundleCleanupFailure: string | undefined; @@ -159,6 +181,22 @@ export const installMacHopper = async ( const parsed = parseMacHopperReleases(decoded.value); if (!parsed.ok) return { status: "failed", reason: "release_metadata" }; const release = parsed.value; + const releaseMinimum = macHopperReleaseMinimumSystemVersion(release); + // An unrecognized feed value defers to the signed app checked below. + if ( + releaseMinimum !== undefined && + macosVersionMeetsMinimum(hostVersion, releaseMinimum) === false + ) + return { + status: "failed", + reason: "incompatible_host_version", + diagnostic: macHopperHostVersionDetail({ + source: "release_feed", + packageUrl: release.filename, + minimumVersion: releaseMinimum, + hostVersion, + }), + }; installStage = "download"; const archive = await host.download(release.filename); if (!archive.ok) @@ -193,6 +231,30 @@ export const installMacHopper = async ( return { status: "failed", reason: "bundle" }; if (verification === "unverified_publisher") return { status: "failed", reason: "publisher_verification_failed" }; + // An app that declares no minimum is accepted, as macOS does. + const appMinimum = await host.minimumSystemVersion(source); + if (appMinimum !== undefined) { + const compatible = macosVersionMeetsMinimum(hostVersion, appMinimum); + if (compatible === false) + return { + status: "failed", + reason: "incompatible_host_version", + diagnostic: macHopperHostVersionDetail({ + source: "signed_app", + minimumVersion: appMinimum, + hostVersion, + }), + }; + if (compatible === undefined) + return { + status: "failed", + reason: "bundle", + diagnostic: macHopperIncomparableMinimumDetail( + appMinimum, + hostVersion, + ), + }; + } installStage = "copy"; const installation = await host.installBundle( source, @@ -374,6 +436,7 @@ export const installMacHopperBundle = async ( export const systemMacHopperInstallHost = ( homeDirectory: string, environment: Readonly, + macosVersion: () => Promise, ): MacHopperInstallHost & { close(): Promise; } => { @@ -434,6 +497,18 @@ export const systemMacHopperInstallHost = ( return "unverified_publisher"; return "verified"; }, + macosVersion, + async minimumSystemVersion(appPath) { + const plist = posix.join(appPath, "Contents", "Info.plist"); + const declared = minimumSystemVersionSchema.safeParse( + decodeInfoPlistValue(await readFile(plist)), + ); + if (!declared.success) + throw new TypeError( + `${plist} does not declare LSMinimumSystemVersion as text`, + ); + return declared.data.LSMinimumSystemVersion; + }, async destinationExists(path) { try { await access(path); @@ -494,6 +569,28 @@ export const systemMacHopperInstallHost = ( }; }; +/** Installer detail naming the declared minimum this Mac's version does not meet. */ +export const macHopperHostVersionDetail = ( + refusal: ( + | { readonly source: "release_feed"; readonly packageUrl: string } + | { readonly source: "signed_app" } + ) & { readonly minimumVersion: string; readonly hostVersion: string }, +): string => + refusal.source === "release_feed" + ? `The current Hopper release (${refusal.packageUrl}) declares deployment_target ${refusal.minimumVersion}; this Mac runs macOS ${refusal.hostVersion}. The package was not downloaded.` + : `The signed Hopper application declares LSMinimumSystemVersion ${refusal.minimumVersion}; this Mac runs macOS ${refusal.hostVersion}. Nothing was copied.`; + +/** Installer detail for a signed-app minimum REA cannot compare with this Mac's version. */ +export const macHopperIncomparableMinimumDetail = ( + declared: string, + hostVersion: string, +): string => + `The signed Hopper application declares LSMinimumSystemVersion ${JSON.stringify(declared)}, which REA cannot compare with macOS ${hostVersion}. Nothing was copied.`; + +const minimumSystemVersionSchema = z.looseObject({ + LSMinimumSystemVersion: z.string().optional(), +}); + const commandSucceeds = async ( command: string, args: readonly string[], diff --git a/src/application/ReferenceSourceImportTypes.ts b/src/application/ReferenceSourceImportTypes.ts index d9c2324b74..34ccdbd24e 100644 --- a/src/application/ReferenceSourceImportTypes.ts +++ b/src/application/ReferenceSourceImportTypes.ts @@ -125,6 +125,8 @@ export interface ReferenceSourceImportOptions { export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [ ".git/", ".git/hooks/", + // Match pointer files after directory rules to preserve their provenance. + ".git", "node_modules/", "dist/", "build/", diff --git a/src/application/Setup.planning.test.ts b/src/application/Setup.planning.test.ts index 527f213856..2b7dd3a07c 100644 --- a/src/application/Setup.planning.test.ts +++ b/src/application/Setup.planning.test.ts @@ -301,10 +301,13 @@ describe("setup client selection", () => { configPath: "/codex.toml", format: "toml" as const, }; - host.clients = [codex]; + host.clients = [ + codex, + { name: "grok_bot", configPath: "/grokbot", format: "unsupported" }, + ]; host.availableClients = [ { name: "cursor", configPath: "/cursor.json" }, - codex, + ...host.clients, ]; const result = await runSetup( @@ -331,6 +334,31 @@ describe("setup client selection", () => { expect(host.skillInstalls).toBe(0); }); + it.each([undefined, false])( + "omits manual clients from automatic readiness while preserving detection (skill: %s)", + async (installSkill) => { + const host = new FakeSetupHost(); + host.clients = [ + { name: "grok_bot", configPath: "/grokbot", format: "unsupported" }, + ]; + const result = await runSetup( + { + ...options(true), + allDetectedClients: true, + ...(installSkill === undefined ? {} : { installSkill }), + }, + host, + ); + expect(result.status).toBe("ready"); + expect(result.doctor.scope.clients).toEqual([]); + expect(result.clientStates).toMatchObject([ + { client: { name: "grok_bot" }, detected: true }, + ]); + expect(result.clients).toEqual({}); + expect(host.configurations).toBe(0); + }, + ); + it.each([true, false])( "handles repeated clients once in first-selected order (dry run: %s)", async (dryRun) => { diff --git a/src/application/Setup.recovery.test.ts b/src/application/Setup.recovery.test.ts index edf0be2768..0071617240 100644 --- a/src/application/Setup.recovery.test.ts +++ b/src/application/Setup.recovery.test.ts @@ -6,6 +6,8 @@ import { ConfigurationError } from "../domain/configurationErrors.js"; import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js"; import { FakeSetupHost, options } from "./Setup.fixture.js"; import { runSetup } from "./Setup.js"; +import { discoverSetupState } from "./SetupPlan.js"; +import { macHopperInstallDisclosure } from "./MacHopper.js"; import { parseLinuxDistribution, type LinuxDistribution, @@ -690,6 +692,8 @@ describe("setup Hopper install eligibility", () => { { version: "14.", installs: false }, { version: "14.0\n", installs: false }, { version: "99999999999999999", installs: false }, + // REA's own floor admits these; the selected release's minimum is + // compared by the installer, which this recording host replaces. { version: "12", installs: true }, { version: "12.0", installs: true }, ])( @@ -709,6 +713,35 @@ describe("setup Hopper install eligibility", () => { }); }, ); + + it.each(["12.0", "12.3", "12.4", "14.5"])( + "leaves Hopper support on macOS %s unknown until the release minimum is read", + async (version) => { + const host = new FakeSetupHost("darwin"); + host.version = version; + + const discovery = await discoverSetupState({ + host, + providerConfigured: false, + forceHopperInstall: false, + proposeHopper: true, + initialDoctor: await host.doctor(), + }); + expect(discovery).toMatchObject({ + hopperInstall: { supported: "unknown" }, + installHopper: true, + }); + const plan = await runSetup( + { ...options(false, true), dryRun: true }, + host, + ); + // The installer owns the disclosed check; its refusal is tested there. + expect( + plan.plannedActions.find(({ kind }) => kind === "install_hopper") + ?.detail, + ).toBe(macHopperInstallDisclosure.detail); + }, + ); }); it("leaves ownerless caller-host failures unconfirmed after an unobserved direct retry", async () => { diff --git a/src/application/Setup.ts b/src/application/Setup.ts index e74debd0b9..754b902013 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -395,7 +395,8 @@ const runSetupWorkflow = async ( appliedActions.includes("installed_hopper"), doctor.healthy, // Offer installation only where the explicit gate accepts it. - hopperPath === undefined && planDiscovery.hopperInstall.supported, + hopperPath === undefined && + planDiscovery.hopperInstall.supported !== false, ), hopperWarning, manualRemediation, diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 3a73594d72..d59260c3b3 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -130,7 +130,9 @@ export const systemSetupHost = ( const { homeDirectory } = doctorHost; const macHopperHost = platform === "darwin" - ? systemMacHopperInstallHost(homeDirectory, environment) + ? systemMacHopperInstallHost(homeDirectory, environment, () => + doctorHost.macosVersion(), + ) : undefined; const resources = new ArtifactResourceScope(); const linuxDistribution = () => doctorHost.linuxDistribution(); diff --git a/src/application/SetupInstallFailure.test.ts b/src/application/SetupInstallFailure.test.ts index b503409b1f..afdd3494f9 100644 --- a/src/application/SetupInstallFailure.test.ts +++ b/src/application/SetupInstallFailure.test.ts @@ -20,6 +20,18 @@ describe("macOS Hopper setup failure projection", () => { "The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.", }); }); + + it("keeps a host below the release minimum distinct from an unsupported host", () => { + const incompatible = setupInstallFailure("incompatible_host_version"); + expect(incompatible).toMatchObject({ + status: "failed", + code: "incompatible_host_version", + }); + expect(incompatible.remediation).toContain("HOPPER_LAUNCHER_PATH"); + const unsupported = setupInstallFailure("unsupported_host"); + expect(unsupported.code).toBe("unsupported_host"); + expect(incompatible.remediation).not.toBe(unsupported.remediation); + }); }); describe("Linux package-manager setup failure projection", () => { diff --git a/src/application/SetupInstallFailure.ts b/src/application/SetupInstallFailure.ts index 5fc9695e24..c815f0223a 100644 --- a/src/application/SetupInstallFailure.ts +++ b/src/application/SetupInstallFailure.ts @@ -9,6 +9,7 @@ import type { LinuxPackageInstallFailure } from "./LinuxHopper.js"; export type SetupFailureCode = | "unsupported_host" + | "incompatible_host_version" | "download_failed" | "integrity_mismatch" | "authorization_or_package_manager_failed" @@ -49,6 +50,7 @@ export type SetupHopperInstallFailure = Extract< export type HopperInstallFailureReason = | "unsupported_host" + | "incompatible_host_version" | "release_metadata" | "download" | "integrity" @@ -150,6 +152,13 @@ const stageFailure = ( code: "unsupported_host", remediation: "Use a supported host, then rerun rea setup.", }; + case "incompatible_host_version": + return { + status: "failed", + code: "incompatible_host_version", + remediation: + "The current Hopper release requires a newer macOS version than this Mac runs, so REA did not install it. Update macOS to at least the version the installer detail names, or install a Hopper release that supports this macOS version yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.", + }; case "integrity": return { status: "failed", diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index d92d545cc0..5cc3762841 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -26,9 +26,15 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; import { macosMajorVersion } from "../domain/macosVersion.js"; -/** Whether automated Hopper installation can run on this host, and why not. */ +/** + * Whether automated Hopper installation can run on this host, and why not. + * `unknown`: the host meets REA's own prerequisites, but the selected release + * declares its minimum macOS version only in metadata the installer reads + * after approval, so planning cannot claim the release supports this host. + */ export type HopperInstallEligibility = | { readonly supported: true } + | { readonly supported: "unknown" } | { readonly supported: false; readonly remediation: string }; /** Read-only setup facts shared by client selection and preflight. */ @@ -74,7 +80,7 @@ export const discoverSetupState = async (input: { ); const installHopper = input.forceHopperInstall || - (hopperInstall.supported && + (hopperInstall.supported !== false && input.proposeHopper && (!input.providerConfigured || linuxHopperRepairNeeded)); const [clients, detectedClients] = await Promise.all([ @@ -133,7 +139,9 @@ export const discoverSetupState = async (input: { installHopper, hopperInstall, clients, - detectedClientIds: [...detectedIds], + detectedClientIds: detectedClients + .filter(({ format }) => format !== "unsupported") + .map(({ name }) => name), defaultClientIds, clientStates, ...(linuxDistribution?.packageFamily === undefined @@ -327,7 +335,7 @@ const setupPlan = (input: { detail: input.platform === "linux" ? `Download the current official Hopper demo, verify its PGP publisher and launcher bytes, and retain archive, public-key.asc and release.json under ${input.hopperCacheDirectory ?? `${input.homeDirectory}/.cache/rea/hopper-releases`}/ for offline verification. Preserve replaced damaged evidence beside it as .backup-. Install Hopper plus its Xvfb demo-session dependencies${input.linuxPackageFamily === "deb" ? ", including the shared library matching the distribution Python interpreter" : ""}. REA uses a private display and selects Hopper's offered demo mode for each analysis session.` - : "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.", + : macHopperInstallDisclosure.detail, external: true, operation: "install" as const, ...(input.platform === "linux" @@ -393,7 +401,7 @@ const linuxDisclosure = (family: LinuxPackageFamily) => { export const hopperInstallRefusal = ( discovery: Pick, ): string | undefined => - discovery.installHopper && !discovery.hopperInstall.supported + discovery.installHopper && discovery.hopperInstall.supported === false ? discovery.hopperInstall.remediation : undefined; @@ -401,7 +409,8 @@ export const hopperInstallRefusal = ( * Decide automated Hopper installation once from the host facts read during * discovery, so proposals, explicit requests, and recovery advice agree. An * unknown or unparseable macOS version and an unidentified Linux distribution - * are not eligible. + * are not eligible. macOS 12 is REA's own floor; the selected Hopper release's + * minimum is unknown until the installer reads it. */ const hopperInstallEligibility = ( platform: NodeJS.Platform, @@ -411,7 +420,7 @@ const hopperInstallEligibility = ( if (platform === "darwin") { const major = macosMajorVersion(macosVersion); return major !== undefined && major >= 12 - ? { supported: true } + ? { supported: "unknown" } : { supported: false, remediation: "Upgrade to macOS 12 or newer." }; } if (platform === "linux") diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index 401e18806d..335c86488a 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -107,3 +107,17 @@ export const providerRegistrationEnvironment = ( ) .sort(([left], [right]) => compareUnicodeCodePoints(left, right)), ); + +/** Spell selected settings as setup records them: an explicit selection wins. */ +export const registrationSpelledSettings = ( + settings: Readonly>, + environment: Readonly>, +): Readonly> => { + const explicit = explicitSetupRegistrationEnvironment(environment); + return Object.fromEntries( + Object.entries(settings).map(([key, value]) => [ + key, + explicit[key] ?? value, + ]), + ); +}; diff --git a/src/application/analysisView/NativeObservationViews.test.ts b/src/application/analysisView/NativeObservationViews.test.ts index c3e2de8d8d..be9092a4e0 100644 --- a/src/application/analysisView/NativeObservationViews.test.ts +++ b/src/application/analysisView/NativeObservationViews.test.ts @@ -121,53 +121,56 @@ const graph = (): NativeCallGraph => limitations: ["Graph caveat", "Graph caveat"], }); -it("returns useful caller/callee facts with exact source context identity and partial coverage", async () => { - const source = parent("procedure_callees", [ - "ram:0x0010", - "ram:0x0010", - "ram:0x0020", - ]); - const before = JSON.stringify(source); - const summary = await project(source, { kind: "summary" }); - expect(summary.value).toMatchObject({ - kind: "summary", - parent_evidence_id: source.evidence_id, - source_provider: source.provider, - source_parameters: source.parameters, - source_analysis_profile_digest: source.analysis_profile?.digest, - source_context: { - evidence_limitation_count: 1, - result_limitation_count: null, - }, - summary: { observations: 3 }, - items: { - type: "addresses", - rows: [ - { source_index: 0, address: "ram:0x0010" }, - { source_index: 1, address: "ram:0x0010" }, - { source_index: 2, address: "ram:0x0020" }, - ], - }, - }); - expect(summary.evidence.relationship_coverage).toEqual( - source.relationship_coverage, - ); - const page = await project(source, { - kind: "native-observation", - offset: 1, - limit: 1, - }); - expect(page.value).toMatchObject({ - items: { rows: [{ source_index: 1, address: "ram:0x0010" }] }, - coverage: { total: 3, next_offset: 2, exhausted: false }, - }); - expect(page.value).toHaveProperty( - "source_context.digest", - contextDigest(summary.value), - ); - expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":'); - expect(JSON.stringify(source)).toBe(before); -}); +it.each(["procedure_callees", "xrefs"])( + "pages %s addresses with exact source context and duplicate identity", + async (operation) => { + const source = parent(operation, [ + "ram:0x0010", + "ram:0x0010", + "ram:0x0020", + ]); + const before = JSON.stringify(source); + const summary = await project(source, { kind: "summary" }); + expect(summary.value).toMatchObject({ + kind: "summary", + parent_evidence_id: source.evidence_id, + source_provider: source.provider, + source_parameters: source.parameters, + source_analysis_profile_digest: source.analysis_profile?.digest, + source_context: { + evidence_limitation_count: 1, + result_limitation_count: null, + }, + summary: { observations: 3 }, + items: { + type: "addresses", + rows: [ + { source_index: 0, address: "ram:0x0010" }, + { source_index: 1, address: "ram:0x0010" }, + { source_index: 2, address: "ram:0x0020" }, + ], + }, + }); + expect(summary.evidence.relationship_coverage).toEqual( + source.relationship_coverage, + ); + const page = await project(source, { + kind: "native-observation", + offset: 1, + limit: 1, + }); + expect(page.value).toMatchObject({ + items: { rows: [{ source_index: 1, address: "ram:0x0010" }] }, + coverage: { total: 3, next_offset: 2, exhausted: false }, + }); + expect(page.value).toHaveProperty( + "source_context.digest", + contextDigest(summary.value), + ); + expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":'); + expect(JSON.stringify(source)).toBe(before); + }, +); it("pages complete context fields and changes its digest when source profile or caveats change", async () => { const source = parent("procedure_callers", []); diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 75fccdc330..84369c5253 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -180,6 +180,7 @@ export class BinarySession { #active: SessionBinding | undefined; #externalMutationCleanupFailure: AnalysisError | undefined; + #lastCloseCleanupFailure: AnalysisError | undefined; #pendingCleanup: | { readonly run: SessionRun; @@ -1010,16 +1011,31 @@ export class BinarySession closed: Result, retry: AnalysisCleanupRetry | undefined, ): Result { + this.#lastCloseCleanupFailure = undefined; if ( !closed.ok && retry !== undefined && closed.error.cleanupIncomplete && this.#retainsCloseCleanup() - ) + ) { closed.error.bindCleanupRetry(retry); + this.#lastCloseCleanupFailure = closed.error; + } return closed; } + /** Only a retired target's known close owner can offer this status retry. */ + #statusCleanupRetry(): AnalysisCleanupRetry | undefined { + if ( + this.#active !== undefined || + this.#activeBundleResources !== undefined || + this.#externalMutationCleanupFailure?.cleanupIncomplete === true || + !this.#retainsCloseCleanup() + ) + return undefined; + return this.#lastCloseCleanupFailure?.cleanupRetry; + } + /** Owners the next close retries; external mutation owners retry elsewhere. */ #retainsCloseCleanup(): boolean { return ( @@ -1238,6 +1254,7 @@ export class BinarySession this.#pendingBundleResourceCleanup[0] ?? this.#pendingCleanup?.run; const client = run?.client; + const cleanupRetry = this.#statusCleanupRetry(); return binarySessionStatus({ ...options, investigation: this.investigationStatistics(), @@ -1255,6 +1272,7 @@ export class BinarySession runtimeLineageSnapshots: client?.runtimeLineageSnapshots?.() ?? [], requestActivitySnapshots: client?.requestActivitySnapshots?.() ?? [], providerOperationHealth: client?.operationHealthSnapshot?.() ?? null, + ...(cleanupRetry === undefined ? {} : { cleanupRetry }), cleanupPending: this.#externalMutationCleanupFailure?.cleanupIncomplete === true || this.#retainsCloseCleanup(), diff --git a/src/application/binary/BinarySessionInvestigation.test.ts b/src/application/binary/BinarySessionInvestigation.test.ts index abad3a7ea6..81039e084c 100644 --- a/src/application/binary/BinarySessionInvestigation.test.ts +++ b/src/application/binary/BinarySessionInvestigation.test.ts @@ -99,8 +99,10 @@ it("publishes engine retirement with its pending cleanup owner already visible", pending.push(closing); await awaitAcknowledgement(entered.promise, closing, "engine close entry"); expect(notifications.length).toBeGreaterThan(0); - for (const status of await Promise.all(notifications)) + for (const status of await Promise.all(notifications)) { expect(status).toMatchObject({ open: false, cleanup_pending: true }); + expect(status).not.toHaveProperty("cleanup_retry"); + } release.resolve(); expect((await closing).ok).toBe(true); expect(await session.status()).toMatchObject({ diff --git a/src/application/binary/BinarySessionStatus.ts b/src/application/binary/BinarySessionStatus.ts index 958897ed22..f17413f91e 100644 --- a/src/application/binary/BinarySessionStatus.ts +++ b/src/application/binary/BinarySessionStatus.ts @@ -26,6 +26,7 @@ import type { } from "./SessionProviderRouter.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import type { BinarySessionDetail } from "../../contracts/sessionStatusContract.js"; +import type { AnalysisCleanupRetry } from "../../domain/analysisErrorRecovery.js"; interface BinarySessionStatusInput { readonly investigation: InvestigationStatistics; @@ -43,6 +44,7 @@ interface BinarySessionStatusInput { readonly requestActivitySnapshots: readonly ProviderRequestActivitySnapshot[]; readonly providerOperationHealth?: ProviderOperationHealth | null; readonly cleanupPending?: boolean; + readonly cleanupRetry?: AnalysisCleanupRetry; readonly detail?: BinarySessionDetail; readonly signal?: AbortSignal; } @@ -61,6 +63,7 @@ export const binarySessionStatus = async ({ requestActivitySnapshots, providerOperationHealth, cleanupPending = false, + cleanupRetry, detail = "complete", signal, }: BinarySessionStatusInput): Promise => { @@ -137,6 +140,9 @@ export const binarySessionStatus = async ({ const common = { investigation, cleanup_pending: cleanupPending, + ...(cleanupRetry === undefined + ? {} + : { cleanup_retry: { ...cleanupRetry } }), capabilities, analysis_provider_binding: analysisProviderBinding, analysis_provider_candidates: analysisProviderCandidates, diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts index f8a4c6b878..c087a83a64 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts @@ -58,6 +58,7 @@ it("records the decoded layout once in its Evidence", async () => { it.each([ [{ machine: "EM_X86_64", bits: 64, byte_order: "little" }, "x86_64"], + [{ machine: "EM_AARCH64", bits: 64, byte_order: "little" }, "arm64"], [{ machine: "EM_386", bits: 32, byte_order: "little" }, "x86"], [{ machine: "EM_ARM", bits: 32, byte_order: "little" }, "arm"], [{ machine: "EM_MIPS", bits: 32, byte_order: "big" }, "mips"], diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.ts b/src/application/binaryDiagnostics/BinaryLayoutService.ts index 87dd9723b6..757ab7eb6b 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.ts @@ -14,6 +14,7 @@ import { parseAnalysisInput } from "../AnalysisInput.js"; const OPERATION = "inspect_binary_layout"; const EVIDENCE_ARCHITECTURES = { EM_X86_64: "x86_64", + EM_AARCH64: "arm64", EM_386: "x86", EM_ARM: "arm", EM_MIPS: "mips", diff --git a/src/application/electron/ElectronFuseService.test.ts b/src/application/electron/ElectronFuseService.test.ts index aba6d285ac..16c693f509 100644 --- a/src/application/electron/ElectronFuseService.test.ts +++ b/src/application/electron/ElectronFuseService.test.ts @@ -57,7 +57,9 @@ it("retains completed fuse Evidence when the provider completes after abort", as parameters: { path: report.artifact.selected_path }, confidence: "observed", limitations: expect.arrayContaining(report.limitations), - locations: [{ kind: "artifact-path", path: report.artifact.selected_path }], + locations: [ + { kind: "artifact-path", path: report.artifact.selected_path }, + ], normalized_result: report, }); } diff --git a/src/application/environment/environmentCompare.test.ts b/src/application/environment/environmentCompare.test.ts index c69917b786..fea061cf5f 100644 --- a/src/application/environment/environmentCompare.test.ts +++ b/src/application/environment/environmentCompare.test.ts @@ -64,6 +64,56 @@ const compare = (...probes: (readonly TimedCaptureRow[])[]) => { return report.value; }; +describe("step difference coverage", () => { + it("keeps categories from differences beyond the returned prefix", () => { + const fields = Object.fromEntries( + Array.from({ length: 20 }, (_, index) => [`field${String(index)}`, 0]), + ); + const capture = timedCaptureEvidence([["GET", "/api/config", fields]]); + const served = environmentStateServeFixture(capture); + const report = compareEnvironment({ + source: environmentServeSource(served), + captureValue: capture, + probes: [ + { + label: "changed", + value: timedCaptureEvidence([ + [ + "GET", + "/api/config", + { + ...Object.fromEntries( + Object.keys(fields).map((key) => [key, 1]), + ), + z_missing: true, + }, + ], + ]), + }, + ], + operation: "environment-compare", + }); + if (!report.ok) throw report.error; + const step = report.value.probes[0]!.results[0]!; + expect(step).toMatchObject({ + equal: false, + equal_ignoring_order: false, + categories: ["value", "missing-field"], + differences_total: 21, + differences_omitted: 1, + differences_truncated: true, + }); + expect(step.differences).toHaveLength(20); + expect(step.differences.every(({ category }) => category === "value")).toBe( + true, + ); + expect(report.value.summary.categories).toMatchObject({ + value: 1, + "missing-field": 1, + }); + }); +}); + describe("matchProbeRequest", () => { it("compares a repeated-query probe against the matching recorded response", () => { const recorded = timedCaptureEvidence([ diff --git a/src/application/environment/environmentCompare.ts b/src/application/environment/environmentCompare.ts index 1fa4eb3cf7..63c7254bc6 100644 --- a/src/application/environment/environmentCompare.ts +++ b/src/application/environment/environmentCompare.ts @@ -81,6 +81,10 @@ export interface CompareStep { readonly equal_ignoring_order: boolean; readonly categories: DifferenceCategory[]; readonly differences: Difference[]; + /** All differences computed for this step, before the returned list is capped. */ + readonly differences_total: number; + readonly differences_omitted: number; + readonly differences_truncated: boolean; readonly ignored_query: string[]; } @@ -287,6 +291,8 @@ const stepOf = ( outcome: { env_status: number | null; differences: Difference[] }, ): CompareStep => { const categories = [...new Set(outcome.differences.map((d) => d.category))]; + const differences = outcome.differences.slice(0, STEP_DIFFERENCE_LIMIT); + const omitted = outcome.differences.length - differences.length; return { ...base, tool: call?.tool.name ?? null, @@ -296,7 +302,10 @@ const stepOf = ( (difference) => difference.category === "order", ), categories, - differences: outcome.differences.slice(0, STEP_DIFFERENCE_LIMIT), + differences, + differences_total: outcome.differences.length, + differences_omitted: omitted, + differences_truncated: omitted > 0, ignored_query: [...(call?.ignoredQuery ?? [])], }; }; diff --git a/src/application/investigation/InspectEvidenceBundle.ts b/src/application/investigation/InspectEvidenceBundle.ts index 8831becc2f..1a186bb02c 100644 --- a/src/application/investigation/InspectEvidenceBundle.ts +++ b/src/application/investigation/InspectEvidenceBundle.ts @@ -2,24 +2,41 @@ import type { EvidenceBundle } from "../../domain/evidenceBundle.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; import { + inspectEvidenceLinks, selectEvidenceRecords, summarizeEvidenceBundle, type EvidenceBundleSummary, + type EvidenceLinkInspection, type EvidenceRecordFilters, type EvidenceRecordSelection, } from "../../domain/evidenceRecordSummary.js"; -/** Select complete bundle delivery, discovery metadata, or a page of complete selected records. */ +type InspectionDetail = "complete" | "summary" | "records" | "links"; + +/** Fields owned by particular deliveries, refused elsewhere rather than ignored. */ +const DELIVERY_FIELDS = [ + ["offset", ["records", "links"]], + ["limit", ["records", "links"]], + ["traversal", ["links"]], + ["link_status", ["links"]], +] as const; + +/** Select complete delivery, discovery metadata, complete records, or link membership. */ export const inspectEvidenceBundle = ( bundle: EvidenceBundle, options: { - readonly detail: "complete" | "summary" | "records"; + readonly detail: InspectionDetail; readonly filters?: EvidenceRecordFilters | undefined; readonly offset?: number | undefined; readonly limit?: number | undefined; + readonly traversal?: "direct" | "retained-closure" | undefined; + readonly link_status?: "missing" | "retained" | "all" | undefined; }, ): Result< - EvidenceBundle | EvidenceBundleSummary | EvidenceRecordSelection, + | EvidenceBundle + | EvidenceBundleSummary + | EvidenceRecordSelection + | EvidenceLinkInspection, AnalysisInputError > => { if (options.detail === "complete" && options.filters !== undefined) @@ -29,29 +46,34 @@ export const inspectEvidenceBundle = ( path: ["filters"], reason: "invalid_value", message: - "Record filters require detail: summary or records; complete returns the canonical bundle.", - expected: "detail: summary or records", + "Record filters require detail: summary, records or links; complete returns the canonical bundle.", + expected: "detail: summary, records or links", }, ]), ); - if (options.detail !== "records") { - const paging = (["offset", "limit"] as const).filter( - (field) => options[field] !== undefined, - ); - if (paging.length > 0) - return err( - new AnalysisInputError( - "get_evidence_bundle", - undefined, - paging.map((field) => ({ + const misplaced = DELIVERY_FIELDS.filter( + ([field, details]) => + options[field] !== undefined && + !(details as readonly InspectionDetail[]).includes(options.detail), + ); + if (misplaced.length > 0) + return err( + new AnalysisInputError( + "get_evidence_bundle", + undefined, + misplaced.map(([field, details]) => { + const expected = details + .map((detail) => `detail: ${detail}`) + .join(" or "); + return { path: [field], reason: "invalid_value" as const, - message: `${field} pages complete records and requires detail: records.`, - expected: "detail: records", - })), - ), - ); - } + message: `${field} requires ${expected}.`, + expected, + }; + }), + ), + ); if (options.detail === "records") return ok( selectEvidenceRecords(bundle, options.filters, { @@ -59,6 +81,15 @@ export const inspectEvidenceBundle = ( limit: options.limit, }), ); + if (options.detail === "links") + return ok( + inspectEvidenceLinks(bundle, options.filters, { + traversal: options.traversal, + linkStatus: options.link_status, + offset: options.offset, + limit: options.limit, + }), + ); return ok( options.detail === "summary" ? summarizeEvidenceBundle(bundle, options.filters) diff --git a/src/application/native/NativeUiActionTrace.ts b/src/application/native/NativeUiActionTrace.ts index 50e3d8e9f0..edd111ee5b 100644 --- a/src/application/native/NativeUiActionTrace.ts +++ b/src/application/native/NativeUiActionTrace.ts @@ -1,5 +1,6 @@ import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { permitsCapabilityFallback } from "../CapabilityFallback.js"; +import { stopsTraversal } from "../CallRelationshipTraversal.js"; import { z } from "zod"; import type { AnalysisOperationPort } from "../AnalysisProvider.js"; @@ -459,11 +460,7 @@ const expandNativeCalls = async (input: { ); examined += 1; sourceLimitations.push(...execution.limitations); - if ( - !execution.outcome.ok && - (execution.outcome.error._tag === "AnalysisCancelledError" || - execution.outcome.error.cleanupIncomplete) - ) + if (!execution.outcome.ok && stopsTraversal(execution.outcome.error)) return { error: execution.outcome.error }; const unknowns = execution.unknowns; for (const [index, unknown] of unknowns.entries()) { diff --git a/src/application/native/NativeValueTrace.fixture.ts b/src/application/native/NativeValueTrace.fixture.ts index 725a01fad2..a11b11897f 100644 --- a/src/application/native/NativeValueTrace.fixture.ts +++ b/src/application/native/NativeValueTrace.fixture.ts @@ -5,7 +5,8 @@ import { import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js"; import { functionDossierSchema } from "../../domain/native/functionDossier.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; -import { ok } from "../../domain/result.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { err, ok } from "../../domain/result.js"; /** Simulated provider seam for value traces; not a real decompiler observation. */ export const valueTraceSubject = { @@ -215,3 +216,42 @@ export const valueTraceDiamond = (options: GraphOptions = {}) => }, options, ); + +/** + * The diamond's reads with max_depth 1 when nothing fails. 0x2000's call to + * 0x3000 is resolved but not followed, because 0x3000 is already queued. + */ +export const VALUE_TRACE_DIAMOND_READS = [ + "analyze_function:0x1000", + "resolve_native_call_targets:0x1004", + "resolve_native_call_targets:0x1008", + "analyze_function:0x2000", + "resolve_native_call_targets:0x2004", + "analyze_function:0x3000", +] as const; +export type ValueTraceDiamondRead = (typeof VALUE_TRACE_DIAMOND_READS)[number]; + +/** Fail each listed `operation:selector` trace read and record every trace read. */ +export const failingValueTraceReads = ( + port: AnalysisOperationPort, + failures: Partial>, +) => { + const reads: string[] = []; + const failed = new Map(Object.entries(failures)); + const failing: AnalysisOperationPort = { + execute: async (operation, parameters, options) => { + if ( + operation !== "analyze_function" && + operation !== "resolve_native_call_targets" + ) + return ok(createAnalysisExecution(null, valueTraceProvider)); + const read = `${operation}:${String(parameters.procedure ?? parameters.address)}`; + reads.push(read); + const failure = failed.get(read); + return failure === undefined + ? port.execute(operation, parameters, options) + : err(failure); + }, + }; + return { port: failing, reads }; +}; diff --git a/src/application/native/NativeValueTrace.ts b/src/application/native/NativeValueTrace.ts index 39e06465ff..de1e2efb68 100644 --- a/src/application/native/NativeValueTrace.ts +++ b/src/application/native/NativeValueTrace.ts @@ -11,13 +11,22 @@ import { } from "../../domain/analysisErrorCore.js"; import { err, ok } from "../../domain/result.js"; import type { AnalysisOperationPort } from "../AnalysisProvider.js"; -import type { EnhancedResult } from "../EnhancedToolTypes.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { stopsTraversal } from "../CallRelationshipTraversal.js"; +import { + failedWithPartialResult, + type EnhancedResult, +} from "../EnhancedToolTypes.js"; import { renderSourceLimitations, type SourceLimitation, } from "../SourceLimitations.js"; import { createValueTraceBudget } from "./NativeValueTraceBudget.js"; +/** The located unknown where unconfirmed provider cleanup stopped a trace. */ +export const CLEANUP_STOPPED_VALUE_TRACE = + "Trace stopped because the provider did not confirm cleanup of resources it owns"; + /** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */ export const traceNativeValues = async ( analysis: AnalysisOperationPort, @@ -52,6 +61,27 @@ export const traceNativeValues = async ( let targetSha256: string | undefined; let decompilations = 0; let callSites = 0; + // A provider that cannot confirm cleanup of resources it owns stops the + // trace; its own failure becomes the outcome, holding the admitted work. + let stopped: AnalysisError | undefined; + /** Record a failed read after the validated seed; true when it ends the trace. */ + const endsTrace = ( + error: AnalysisError, + procedure: string, + address: string | null, + ): boolean => { + if (!stopsTraversal(error)) { + unknowns.push({ procedure, address, reason: error.message }); + return false; + } + if (error.cleanupIncomplete) + budget.narrow("cleanup_incomplete", { + procedure, + address, + reason: CLEANUP_STOPPED_VALUE_TRACE, + }); + return true; + }; while (pending.length > 0) { if (signal?.aborted) return err( @@ -91,14 +121,12 @@ export const traceNativeValues = async ( decompilations++; if (!execution.ok) { // A seed failure has no trace to qualify. Preserve the provider's typed - // refusal; failures after a validated seed remain partial observations. - if (next.depth === 0 || execution.error._tag === "AnalysisCancelledError") - return err(execution.error); - unknowns.push({ - procedure: next.procedure, - address: null, - reason: execution.error.message, - }); + // refusal; later failures are unknowns unless they end the trace. + if (next.depth === 0) return err(execution.error); + if (endsTrace(execution.error, next.procedure, null)) { + stopped = execution.error; + break; + } continue; } const evidence = createAnalysisExecutionEvidence( @@ -252,13 +280,10 @@ export const traceNativeValues = async ( signal === undefined ? {} : { signal }, ); if (!resolved.ok) { - if (resolved.error._tag === "AnalysisCancelledError") - return err(resolved.error); - unknowns.push({ - procedure: address, - address: operation.address, - reason: resolved.error.message, - }); + if (endsTrace(resolved.error, address, operation.address)) { + stopped = resolved.error; + break; + } continue; } const targets = nativeCallTargetsSchema.safeParse(resolved.value.result); @@ -330,8 +355,10 @@ export const traceNativeValues = async ( } } // Remaining work is attributed to the rejecting budget recorded above. - if (budget.exhausted()) break; + if (stopped !== undefined || budget.exhausted()) break; } + // Cancellation leaves no outcome to qualify; unconfirmed cleanup keeps one. + if (stopped !== undefined && !stopped.cleanupIncomplete) return err(stopped); if (targetSha256 === undefined) return err( new AnalysisOutputError( @@ -456,7 +483,12 @@ export const traceNativeValues = async ( ...renderSourceLimitations(sourceLimitations), ], }); - if (Buffer.byteLength(JSON.stringify(result)) > 32 * 1024 * 1024) + const oversized = + Buffer.byteLength(JSON.stringify(result)) > 32 * 1024 * 1024; + // The stopping failure stays primary over output size and a later abort. + if (stopped !== undefined) + return oversized ? err(stopped) : failedWithPartialResult(stopped, result); + if (oversized) return err( new AnalysisOutputError( "trace_native_values", diff --git a/src/application/native/NativeValueTraceStops.test.ts b/src/application/native/NativeValueTraceStops.test.ts new file mode 100644 index 0000000000..fae841232e --- /dev/null +++ b/src/application/native/NativeValueTraceStops.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import type { AnalysisOperationPort } from "../AnalysisProvider.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, +} from "../../domain/analysisErrorCore.js"; +import { nativeValueTraceSchema } from "../../domain/native/nativeValueTrace.js"; +import { + CLEANUP_STOPPED_VALUE_TRACE, + traceNativeValues, +} from "./NativeValueTrace.js"; +import { + failingValueTraceReads, + valueTraceDiamond, + valueTraceProvider as provider, + VALUE_TRACE_DIAMOND_READS, + type ValueTraceDiamondRead, +} from "./NativeValueTrace.fixture.js"; + +describe("native value trace child stops", () => { + const cleanup = { + cleanup: { reason: "cleanup proof refused", resources: ["owned worker"] }, + }; + const operationOf = (read: ValueTraceDiamondRead) => + read.startsWith("analyze_function") + ? "analyze_function" + : "resolve_native_call_targets"; + const childReads = [ + "resolve_native_call_targets:0x1004", + "analyze_function:0x2000", + ] as const; + const readsThrough = (read: ValueTraceDiamondRead) => + VALUE_TRACE_DIAMOND_READS.slice( + 0, + VALUE_TRACE_DIAMOND_READS.indexOf(read) + 1, + ); + + it.each(childReads)( + "returns a cancelled %s read without a partial trace", + async (read) => { + const cancelled = new AnalysisCancelledError(operationOf(read)); + const { port, reads } = failingValueTraceReads(valueTraceDiamond().port, { + [read]: cancelled, + }); + + const result = await traceNativeValues(port, { + procedure: "0x1000", + max_depth: 1, + }); + + expect(result).toStrictEqual({ ok: false, error: cancelled }); + expect(reads).toEqual(readsThrough(read)); + }, + ); + + it.each( + childReads.flatMap((read) => + (["caller abort", "cancelled read"] as const).map((race) => ({ + read, + race, + })), + ), + )( + "keeps unconfirmed cleanup primary over a $race at $read", + async ({ read, race }) => { + const controller = new AbortController(); + const failure = + race === "cancelled read" + ? new AnalysisCancelledError(operationOf(read), cleanup) + : new AnalysisCapabilityUnavailableError( + provider.id, + operationOf(read), + "controlled child failure", + cleanup, + ); + const { port, reads } = failingValueTraceReads(valueTraceDiamond().port, { + [read]: failure, + }); + // The caller aborts while the failing read settles. + const aborting: AnalysisOperationPort = { + execute: async (operation, parameters, options) => { + const selector = `${operation}:${String(parameters.procedure ?? parameters.address)}`; + if (selector === read) controller.abort("caller stopped the trace"); + return port.execute(operation, parameters, options); + }, + }; + + const result = await traceNativeValues( + aborting, + { procedure: "0x1000", max_depth: 1 }, + controller.signal, + ); + + expect(reads).toEqual(readsThrough(read)); + if (result.ok || !("partialResult" in result)) + throw new Error("Expected a stopped trace with its admitted result"); + expect(result.error).toBe(failure); + expect(nativeValueTraceSchema.parse(result.partialResult)).toMatchObject({ + procedures: [{ address: "0x1000" }], + traversal: { status: "truncated", reasons: ["cleanup_incomplete"] }, + truncated: true, + }); + }, + ); + + it("records the stop as a located unknown in the admitted trace", async () => { + const failure = new AnalysisCapabilityUnavailableError( + provider.id, + "resolve_native_call_targets", + "controlled child failure", + cleanup, + ); + const { port } = failingValueTraceReads(valueTraceDiamond().port, { + "resolve_native_call_targets:0x1008": failure, + }); + + const result = await traceNativeValues(port, { + procedure: "0x1000", + max_depth: 1, + }); + + if (result.ok || !("partialResult" in result)) + throw new Error("Expected a stopped trace with its admitted result"); + const admitted = nativeValueTraceSchema.parse(result.partialResult); + // 0x2000 was queued from 0x1004 but never analyzed; the stop, not a depth + // limit, accounts for it, and the admitted call edge to it remains. + expect(admitted.traversal).toEqual({ + status: "truncated", + reasons: ["cleanup_incomplete"], + }); + expect(admitted.unknowns.filter(({ address }) => address !== null)).toEqual( + [ + { + procedure: "0x1000", + address: "0x1008", + reason: CLEANUP_STOPPED_VALUE_TRACE, + }, + ], + ); + expect(admitted.edges).toContainEqual( + expect.objectContaining({ kind: "call", target: "0x2000" }), + ); + }); +}); diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index e4117b31a8..02fe3b53c3 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -549,7 +549,7 @@ export class ReverseEngineeringService { ? "REA_OBJDUMP_COMMAND" : "REA_RIZIN_COMMAND"; earlyFailure = new AnalysisCapabilityUnavailableError( - provider, + profile.identity.id, operation, `Unable to run configured command (${String(code ?? "unknown error")})`, { @@ -708,10 +708,10 @@ export class ReverseEngineeringService { const limitations = [ ...(absolutePath === undefined ? [ - "The objdump version is not reported; the output lists BFD targets advertised by the installed tool and does not establish support for the selected artifact.", + "The objdump implementation and version are unknown. The captured output is the selected command's response and does not establish support for an artifact.", ] : [ - "The executable version and supported formats are reported as unknown because no reliable version probe was performed.", + "The executable implementation, version and supported formats are unknown because no reliable identity probe was performed.", ]), "A non-zero process exit is preserved as an observed command result.", ...(operation === "inspect_with_objdump" && @@ -759,9 +759,7 @@ export class ReverseEngineeringService { `Command completion is unknown because the operation failed with ${failure._tag}: ${failure.message}`, ]), ...(absolutePath === undefined - ? [ - "This output lists BFD targets advertised by the installed objdump and is independent of the selected artifact.", - ] + ? [] : [ "When both are available, pre-run and post-run digests detect artifact changes across the provider call but cannot prove the exact bytes consumed during the call.", ]), diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index a904b49e0f..b5b5f931ef 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -36,7 +36,8 @@ export const DMG_EXISTING_ATTACHMENT_RECOVERY = "hdiutil refused a private read-only DMG attachment after listing an existing attachment of this image path. If it is still attached, ask its owner to release it when safe, then retry. REA does not borrow or detach existing attachments."; const DMG_CHECKSUM_ABSENT_LIMITATION = "hdiutil verify found no stored checksum in this disk image, so its integrity was not verified; the root SHA-256 identifies the inventoried bytes."; -const VERIFY_SETTLE_DELAYS_MS = [250, 500] as const; +const VERIFY_TRANSIENT_DELAY_MS = 250; +const VERIFY_UNKNOWN_SETTLE_DELAYS_MS = [250, 500] as const; // APFS ejects and busy reporting can lag a detach attempt. const DETACH_SETTLE_DELAYS_MS = [250, 500, 1000] as const; const delay = (milliseconds: number): Promise => @@ -545,7 +546,13 @@ export class NativeDmgArtifactReader implements ArtifactReader { }, ); } - const delayMs = VERIFY_SETTLE_DELAYS_MS[attempt]; + // An explicit resource refusal can outlast the initial settle window + // after image creation. Its retries share the existing command deadline; + // an ambiguous localized refusal keeps only the short diagnostic probe. + const delayMs = + cause.verifyDiagnostic === "Resource temporarily unavailable" + ? VERIFY_TRANSIENT_DELAY_MS + : VERIFY_UNKNOWN_SETTLE_DELAYS_MS[attempt]; // Retry only the producer's explicit transient diagnostic, or a localized // refusal of a readable target that may be the same, before any mount. if ( diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts index 3075f65f91..f23e26223c 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts @@ -14,6 +14,10 @@ import { resolveOccurrenceParents, } from "./ArtifactGraphConstruction.js"; import { createArtifactClassificationProbe } from "../ArtifactClassificationProbe.js"; +import { createAsarHeaderSignatureProbe } from "../AsarHeader.js"; +import { ArtifactReaderFailure } from "../ArtifactReader.js"; +import { translateArtifactFailure } from "../ArtifactOperationFailure.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { artifactIdForContent } from "../../domain/artifactIdentity.js"; import { pe } from "../../domain/binaryTarget.fixture.js"; import type { ArtifactEntry } from "../ArtifactReader.js"; @@ -26,6 +30,7 @@ import { } from "../../domain/firmware/uf2.fixture.js"; import { classifyArtifactBytesAtPath } from "../../../tests/fixtures/artifactClassification.js"; +import { asarHeaderBytes } from "../../../tests/fixtures/asarHeader.js"; import { ESP32C3_APP_SEGMENTS, esp8266Image, @@ -73,6 +78,42 @@ it("classifies original DOS header padding as an executable", () => { }); }); +it("projects the parsed ASAR header size when admission rejects an archive", () => { + const bytes = asarHeaderBytes("{}", { padding: Buffer.alloc(8, 0x20) }); + const headerBytes = bytes.readUInt32LE(4); + const probe = createAsarHeaderSignatureProbe({ + maximumBytes: headerBytes - 1, + source: "setting", + }); + probe.write(bytes); + const asarHeader = probe.finish(bytes.length); + expect(asarHeader).toMatchObject({ + state: "limit", + extents: { headerBytes }, + }); + + let failure: unknown; + try { + classifyArtifactContent( + "app.asar", + { prefix: bytes, asarHeader }, + bytes.length, + ); + } catch (cause) { + failure = cause; + } + expect(failure).toBeInstanceOf(ArtifactReaderFailure); + if (!(failure instanceof ArtifactReaderFailure)) + throw new Error("ASAR admission failure was not classified"); + + const projected = projectAnalysisError( + translateArtifactFailure("inspect_artifact", failure), + ); + expect(projected.details?.limit).toMatchObject({ + required_value: headerBytes, + }); +}); + const entry = (path: string, kind: ArtifactEntry["kind"]): ArtifactEntry => ({ path, kind, diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 75614bcb2c..b0c77906f8 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -557,7 +557,10 @@ export const classifyArtifactContent = ( asarHeader.state !== "rejected" ) { if (asarHeader.state === "limit") - throw asarHeaderAdmissionFailure(asarHeader.admission); + throw asarHeaderAdmissionFailure( + asarHeader.admission, + asarHeader.extents?.headerBytes, + ); if (asarHeader.state === "matched") role = { kind: "container", format: "asar" }; else role = artifactRoleForFormat(path, contentFormat); diff --git a/src/browser/CdpCaptureEventHelpers.ts b/src/browser/CdpCaptureEventHelpers.ts index e3e6621e41..f328862c7a 100644 --- a/src/browser/CdpCaptureEventHelpers.ts +++ b/src/browser/CdpCaptureEventHelpers.ts @@ -1,5 +1,6 @@ import { err, ok, type Result } from "../domain/result.js"; import { mediaTypeEssence } from "../domain/analysisView/recordedMediaType.js"; +import { cdpResourceColumnSchema } from "../domain/cdpObservationSchemas.js"; import { admitRecords, isHttpUrl, @@ -57,18 +58,22 @@ export const exclusionReasonForUrl = ( ? "disallowed_origin" : "unsupported_url"; -/** Preserve omitted and invalid protocol offsets as unknown, never invented coordinates. */ +/** + * Preserve omitted and invalid protocol offsets as unknown, never invented + * coordinates. Columns follow the shared CDP resource-column policy, which + * keeps a negative column Chrome reports. + */ export const cdpSourceCoordinates = (frame: UnknownRecord | undefined) => { - const valid = (value: unknown): value is number => + const validLine = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; const line = frame?.lineNumber; - const column = frame?.columnNumber; + const column = cdpResourceColumnSchema.safeParse(frame?.columnNumber); return { - line: valid(line) ? line : null, - column: valid(column) ? column : null, + line: validLine(line) ? line : null, + column: column.success ? column.data : null, invalid: - (line !== undefined && !valid(line)) || - (column !== undefined && !valid(column)), + (line !== undefined && !validLine(line)) || + (frame?.columnNumber !== undefined && !column.success), }; }; diff --git a/src/browser/CdpNetworkEventFacts.ts b/src/browser/CdpNetworkEventFacts.ts index 54f3f26632..ae7fa548d3 100644 --- a/src/browser/CdpNetworkEventFacts.ts +++ b/src/browser/CdpNetworkEventFacts.ts @@ -11,6 +11,7 @@ import { recordValue, type UnknownRecord, } from "./CdpCaptureValues.js"; +import { cdpResourceColumnSchema } from "../domain/cdpObservationSchemas.js"; const integer = z.int().min(0); /** Active runtime admission remains stricter than passive request observations. */ @@ -25,7 +26,7 @@ export const runtimeRequestSchema = z.object({ stack: z.unknown().optional(), url: z.string().optional(), lineNumber: integer.optional(), - columnNumber: integer.optional(), + columnNumber: cdpResourceColumnSchema.optional(), }) .passthrough(), }); diff --git a/src/browser/CdpScriptContextFacts.ts b/src/browser/CdpScriptContextFacts.ts index 0ba1ae0cf9..a95d1db86a 100644 --- a/src/browser/CdpScriptContextFacts.ts +++ b/src/browser/CdpScriptContextFacts.ts @@ -4,6 +4,7 @@ import { numberValue, recordValue, } from "./CdpCaptureValues.js"; +import { cdpResourceColumnSchema } from "../domain/cdpObservationSchemas.js"; const integer = z.int().min(0); const contextAuxDataSchema = z @@ -31,9 +32,9 @@ export const runtimeScriptParsedSchema = scriptDeclaration.extend({ scriptId: z.string().min(1), executionContextId: integer, startLine: integer, - startColumn: integer, + startColumn: cdpResourceColumnSchema, endLine: integer, - endColumn: integer, + endColumn: cdpResourceColumnSchema, hash: z.string().optional(), sourceMapURL: z.string().optional(), hasSourceURL: z.boolean().optional(), diff --git a/src/browser/execution/CdpRuntimeProtocol.ts b/src/browser/execution/CdpRuntimeProtocol.ts index a9fdb5bff2..2b90e09d8c 100644 --- a/src/browser/execution/CdpRuntimeProtocol.ts +++ b/src/browser/execution/CdpRuntimeProtocol.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { cdpResourceColumnSchema } from "../../domain/cdpObservationSchemas.js"; const integer = z.int().min(0); const range = z.object({ @@ -30,7 +31,7 @@ export const runtimeStackSchema = z.object({ scriptId: z.string(), url: z.string(), lineNumber: integer, - columnNumber: integer, + columnNumber: cdpResourceColumnSchema, functionName: z.string(), }), ), @@ -49,7 +50,7 @@ export const runtimeListenersSchema = z.object({ once: z.boolean(), scriptId: z.string(), lineNumber: integer, - columnNumber: integer, + columnNumber: cdpResourceColumnSchema, }), ), }); diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts index 6e58f32367..19e7b68aa3 100644 --- a/src/browser/history/HarCapture.test.ts +++ b/src/browser/history/HarCapture.test.ts @@ -805,12 +805,46 @@ it.each(["[2001:db8::1]", "[::1]", "[::ffff:192.0.2.1]"])( }, ); +it.each(["[2620:0:860:ed1a::1]", "[::1]"])( + "retains Chromium DevTools bracketed IPv6 spelling in reported Evidence: %s", + (address) => { + const fixture = historicalHar(); + const first = fixture.log.entries[0]; + if (first === undefined) throw new Error("fixture missing"); + const input = { + log: { + ...fixture.log, + creator: { name: "WebInspector", version: "537.36" }, + entries: [ + { ...first, serverIPAddress: address }, + structuredClone(first), + ], + }, + }; + const decoded = decodeHarCapture(JSON.stringify(input), []); + expect(decoded.records[0]).toMatchObject({ + reported: { serverIPAddress: address }, + }); + expect(decoded.records[0]?.limitations).toContain( + "This capture declares Chromium DevTools (WebInspector 537.36) and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.", + ); + expect( + decoded.records[1]?.limitations.some((limitation) => + limitation.includes("bracketed IPv6 serverIPAddress"), + ), + ).toBe(false); + }, +); + it.each([ { name: "other", version: "1.58.2", address: "[2001:db8::1]" }, { name: "Playwright", version: "1.58.1", address: "[2001:db8::1]" }, { name: "Playwright", version: "1.58.2", address: "[192.0.2.1]" }, { name: "Playwright", version: "1.58.2", address: "[2001:db8::1]:443" }, { name: "Playwright", version: "1.58.2", address: "[fe80::1%eth0]" }, + { name: "WebInspector", version: "605.1.15", address: "[2001:db8::1]" }, + { name: "WebInspector", version: "537.36", address: "[192.0.2.1]" }, + { name: "WebInspector", version: "537.36", address: "[2001:db8::1]:443" }, ])( "rejects unrecognized bracketed HAR address input: $name $version $address", ({ name, version, address }) => { diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts index 91c67f9efe..4353e9b6de 100644 --- a/src/browser/history/HarCapture.ts +++ b/src/browser/history/HarCapture.ts @@ -33,9 +33,14 @@ export const decodeHarCapture = ( ): DecodedCapture => { const raw = parseCaptureJson(text, "HAR"); const validation = projectHar(raw, [], "validation"); - const adaptedPlaywrightEntries = new Set( - createHarValidator()(validation.reported), - ); + const ipv6Adaptation = createHarValidator()(validation.reported); + const adaptedIpv6Entries = new Set(ipv6Adaptation?.entries); + const ipv6Limitations = + ipv6Adaptation === null + ? [] + : [ + `This capture declares ${ipv6Adaptation.producer} and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.`, + ]; if ( !captureObject(raw) || !captureObject(raw.log) || @@ -76,11 +81,7 @@ export const decodeHarCapture = ( ...projection, limitations: [ "HAR text without an encoding declaration is producer-decoded Unicode. Unsupported declared body encodings remain opaque; original body bytes remain unknown unless content explicitly uses valid base64. Declared sizes are preserved independently of retained bytes. mitmproxy 12.2.3 SaveHar may report null optional postData.text; that null is retained and omitted only from the upstream validation copy.", - ...(adaptedPlaywrightEntries.has(ordinal) - ? [ - "This capture declares Playwright 1.58.2 and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.", - ] - : []), + ...(adaptedIpv6Entries.has(ordinal) ? ipv6Limitations : []), ...(recordsExcluded ? [ "This record's payload and sidecars are excluded because its structural ancestor property was explicitly marked sensitive.", diff --git a/src/browser/history/HarSchema.ts b/src/browser/history/HarSchema.ts index f50d91d392..43d5422a0d 100644 --- a/src/browser/history/HarSchema.ts +++ b/src/browser/history/HarSchema.ts @@ -30,10 +30,28 @@ const timingsSchema = { }, }; +// Observed producers that copy Chromium's bracketed IPv6 remote address into +// serverIPAddress. Chromium DevTools declares WebInspector with the frozen +// AppleWebKit version from its user agent. +const BRACKETED_IPV6_PRODUCERS = [ + { name: "Playwright", version: "1.58.2", label: "Playwright 1.58.2" }, + { + name: "WebInspector", + version: "537.36", + label: "Chromium DevTools (WebInspector 537.36)", + }, +] as const; + +/** Entries whose bracketed IPv6 address was adapted, and the declared producer. */ +interface BracketedIpv6Adaptation { + readonly producer: string; + readonly entries: readonly number[]; +} + /** Validate with pinned HAR 1.2 draft-06 schemas and the owned schema corrections. */ export const createHarValidator = (): (( value: unknown, -) => readonly number[]) => { +) => BracketedIpv6Adaptation | null) => { const require = createRequire(import.meta.url); const schemas = z .record(z.string(), z.record(z.string(), z.unknown())) @@ -78,8 +96,7 @@ export const createHarValidator = (): (( if (validate === undefined) throw new Error("Pinned HAR root schema is unavailable"); return (value) => { - const adaptedPlaywrightEntries = - allowObservedProducerRepresentations(value); + const adaptations = allowObservedProducerRepresentations(value); if (!validate(value)) { const error = validate.errors?.[0]; // AJV messages name the violated constraint, such as the missing @@ -92,36 +109,43 @@ export const createHarValidator = (): (( error?.instancePath ?? "", ); } - return adaptedPlaywrightEntries; + return adaptations; }; }; +/** Name the declared producer when it is observed to bracket IPv6 addresses. */ +const bracketedIpv6ProducerOf = ( + creator: Record, +): string | null => + BRACKETED_IPV6_PRODUCERS.find( + ({ name, version }) => creator.name === name && creator.version === version, + )?.label ?? null; + /** Adapt only observed producer representations in the disposable validation copy. */ const allowObservedProducerRepresentations = ( value: unknown, -): readonly number[] => { - if (!captureObject(value) || !captureObject(value.log)) return []; +): BracketedIpv6Adaptation | null => { + if (!captureObject(value) || !captureObject(value.log)) return null; const log = value.log; - if (!captureObject(log.creator) || !Array.isArray(log.entries)) return []; + if (!captureObject(log.creator) || !Array.isArray(log.entries)) return null; const mitmproxy = log.creator.name === "mitmproxy" && log.creator.version === "12.2.3"; - const playwright = - log.creator.name === "Playwright" && log.creator.version === "1.58.2"; - if (!mitmproxy && !playwright) return []; - const adaptedPlaywrightEntries: number[] = []; + const bracketedIpv6Producer = bracketedIpv6ProducerOf(log.creator); + if (!mitmproxy && bracketedIpv6Producer === null) return null; + const adaptedEntries: number[] = []; for (const [ordinal, entry] of log.entries.entries()) { const candidate: unknown = entry; if (!captureObject(candidate)) continue; const address = candidate.serverIPAddress; if ( - playwright && + bracketedIpv6Producer !== null && typeof address === "string" && address.startsWith("[") && address.endsWith("]") && isIP(address.slice(1, -1)) === 6 ) { candidate.serverIPAddress = address.slice(1, -1); - adaptedPlaywrightEntries.push(ordinal); + adaptedEntries.push(ordinal); } if (!mitmproxy) continue; const request = candidate.request; @@ -129,7 +153,9 @@ const allowObservedProducerRepresentations = ( const body = request.postData; if (captureObject(body) && body.text === null) delete body.text; } - return adaptedPlaywrightEntries; + return bracketedIpv6Producer === null + ? null + : { producer: bracketedIpv6Producer, entries: adaptedEntries }; }; const HAR_REQUEST_URL_FORMAT = "absolute-url"; diff --git a/src/cli.ts b/src/cli.ts index b9829e68dd..84b7b13522 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -252,7 +252,7 @@ export const createCli = ( ); cli.serve = (arguments_ = process.argv.slice(2), options) => { if (complete(arguments_, options ?? {})) return Promise.resolve(); - const normalized = normalizeCliArguments(arguments_); + let normalized = normalizeCliArguments(arguments_); const { command, flags } = splitIncurBuiltinArguments(normalized); const builtin = command[0] === PRODUCT_IDENTITY.cliBinary ? 1 : 0; if ( @@ -320,10 +320,34 @@ export const createCli = ( // prevents a save. Guard that path: reject a settings file REA cannot // update, back up the file, preserve the caller's environment, and verify // the registration landed. + // SyncMcp.register also writes Amp when no agents are selected. Mode + // switches return before registration and must not touch its settings. + // Use the same normalized arguments for selection and dispatch, including + // non-Amp equals forms. Incur consumes command values before agent flags. + normalized = serveArguments; + const registration = splitIncurBuiltinArguments(serveArguments); + const rest = registration.command.slice(builtin + 2); + const agents: string[] = []; + for (let index = 0; index < rest.length; index += 1) { + if ( + (rest[index] === "--command" || rest[index] === "-c") && + rest[index + 1] + ) + index += 1; + else if (rest[index] === "--agent" && rest[index + 1]) + agents.push(rest[++index]!); + } const ampRequested = - command.some( - (token, index) => token === "--agent" && command[index + 1] === "amp", - ) || command.includes("--agent=amp"); + ![ + "--help", + "-h", + "--llms", + "--llms-full", + "--mcp", + "--update", + "--incur-update-check", + ].some((flag) => registration.flags.has(flag)) && + (agents.length === 0 || agents.includes("amp")); if (ampRequested) { const settingsPath = join(homedir(), ".config", "amp", "settings.json"); let priorEnv: unknown; @@ -409,9 +433,17 @@ export const createCli = ( } const runRegistration = (backupPath?: string): Promise => { + let failed = false; const result = serveStrict( serveArguments, - options ?? {}, + { + ...options, + exit: (code) => { + failed ||= code !== 0; + if (options?.exit !== undefined) options.exit(code); + else process.exitCode = code; + }, + }, (tokens, selected) => serveDeclared(tokens, selected, (declared, declaredOptions) => serve( @@ -421,6 +453,7 @@ export const createCli = ( ), ); return result.then(() => { + if (failed) return; let failure: string | undefined; try { if (!existsSync(settingsPath)) diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts index 0550210b15..1858bd3fa8 100644 --- a/src/cli/analysisEvidenceDelivery.ts +++ b/src/cli/analysisEvidenceDelivery.ts @@ -1,5 +1,8 @@ import { recordCliEvidencePublication } from "./cliEvidencePublication.js"; -import { analysisEvidenceSources } from "../application/AnalysisEvidenceSources.js"; +import { + analysisEvidenceSources, + undeliveredSourceClosure, +} from "../application/AnalysisEvidenceSources.js"; import type { AnalysisViewRequest } from "../domain/analysisView/analysisView.js"; import { z } from "incur"; import { @@ -14,12 +17,18 @@ import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js"; import { err, ok, type Result } from "../domain/result.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { createEvidenceBundle } from "../domain/evidenceBundle.js"; -import { publishEvidenceDelivery } from "../application/EvidenceBundleCommands.js"; +import { + evidenceDeliveryCommitted, + publishEvidenceDelivery, +} from "../application/EvidenceBundleCommands.js"; import { inspectAnalysisViewValidated } from "../application/analysisView/AnalysisViewService.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; -import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; +import { + isObject, + projectCliAnalysisResult, +} from "./projectCliAnalysisResult.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import type { ZodSafeParseResult } from "zod"; import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js"; @@ -169,6 +178,29 @@ export const deliverCliAnalysisEvidence = async ( }); }; +/** + * A requested bundle that never committed cannot silently drop what a completed + * observation cites. Absent delivery or observation leaves the failure untouched. + */ +const withUndeliveredSourceClosure = ( + failure: JsonValue, + input: CliEvidenceDelivery, +): JsonValue => { + if (input.evidenceOutput === undefined || !isObject(failure)) return failure; + const { details } = failure; + if (!isObject(details)) return failure; + const closure = undeliveredSourceClosure( + details.partial_observation, + input.sourceEvidence ?? [], + // Error output is not the caller's selected Evidence sink. Keep records + // out of it when this invocation carries explicit exclusions. + { inlineRecords: (input.sensitiveValues?.length ?? 0) === 0 }, + ); + return closure === undefined + ? failure + : { ...failure, details: { ...details, source_closure: closure } }; +}; + /** Publish already finalized CLI analysis without opening a second provider/session. */ export const publishCliAnalysisEvidence = async ( result: Result, @@ -179,7 +211,9 @@ export const publishCliAnalysisEvidence = async ( projectAnalysisError( redactExplicitFailure(error, input.sensitiveValues ?? []), ); - if (!result.ok) return result.error; + const undelivered = (error: AnalysisError) => + withUndeliveredSourceClosure(projectFailure(error), input); + if (!result.ok) return withUndeliveredSourceClosure(result.error, input); const complete = parseEvidence(result.value); const inputError = cliEvidenceDeliveryInputError(input); if (inputError !== undefined) { @@ -197,7 +231,7 @@ export const publishCliAnalysisEvidence = async ( }) : ok(complete); if (!delivered.ok) - return projectFailure(delivered.error.retainPartialObservation(complete)); + return undelivered(delivered.error.retainPartialObservation(complete)); // This projection answers the same direct invocation as its parent. Later // inspect_analysis_view calls own their own input and do not inherit it. const selected = @@ -205,7 +239,7 @@ export const publishCliAnalysisEvidence = async ( ? withEvidenceCallerInputPath(delivered.value, complete.caller_input_path) : delivered.value; const inspectionSources = analysisEvidenceSources(complete); - if (!inspectionSources.ok) return projectFailure(inspectionSources.error); + if (!inspectionSources.ok) return undelivered(inspectionSources.error); const published = await publishEvidenceDelivery({ bundle: createEvidenceBundle([ ...new Map( @@ -220,7 +254,10 @@ export const publishCliAnalysisEvidence = async ( operation, signal, }); - if (!published.ok) return projectFailure(published.error); + if (!published.ok) + return evidenceDeliveryCommitted(published.error) + ? projectFailure(published.error) + : undelivered(published.error); const receipt = { ...(input.sensitiveValues?.some( (literal) => diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts index a6fea44e69..427ab6709b 100644 --- a/src/cli/analysisViewCommands.ts +++ b/src/cli/analysisViewCommands.ts @@ -26,12 +26,12 @@ export const registerAnalysisViewCommands = ( ): void => { cli.command(CLI_COMMANDS.inspectEvidenceBundle, { description: - "Inspect a local Evidence bundle completely, as discovery metadata, or as a page of selected complete records", + "Inspect a local Evidence bundle completely, as discovery metadata, as a page of selected complete records, or as direct-link and retained-closure membership.", args: z.object({ inputJson: z .string() .describe( - "JSON or JSON file with absolute path, detail, optional record filters, and records offset/limit", + "JSON or JSON file with absolute path, detail, optional record filters, records or links offset/limit, and links traversal/link_status", ), }), run: ({ args }) => diff --git a/src/cli/firmwareCommands.ts b/src/cli/firmwareCommands.ts index 43223a58cd..564b563c3e 100644 --- a/src/cli/firmwareCommands.ts +++ b/src/cli/firmwareCommands.ts @@ -1,3 +1,4 @@ +import { FIRMWARE_OPERATION_ENGINES } from "../contracts/firmware/firmwareToolRequirements.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysisService.js"; @@ -36,7 +37,7 @@ export const registerFirmwareCommands = ( callerPath, () => service.execute(operation, input, { signal }), service, - operation === "inspect_firmware_regions" ? "binwalk" : "unblob", + FIRMWARE_OPERATION_ENGINES[operation], operation, signal, ).then(projectCliAnalysisResult), diff --git a/src/cli/projectCliAnalysisResult.ts b/src/cli/projectCliAnalysisResult.ts index 3b541c7aa8..61bbc03208 100644 --- a/src/cli/projectCliAnalysisResult.ts +++ b/src/cli/projectCliAnalysisResult.ts @@ -16,7 +16,7 @@ export const projectCliAnalysisResult = ( return projectInputFields(result.error, inputBinding); }; -const isObject = ( +export const isObject = ( value: JsonValue | undefined, ): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); diff --git a/src/cli/reverseEngineeringCommands.ts b/src/cli/reverseEngineeringCommands.ts index b07da0176b..59e715fb6d 100644 --- a/src/cli/reverseEngineeringCommands.ts +++ b/src/cli/reverseEngineeringCommands.ts @@ -1,5 +1,6 @@ import { z } from "incur"; import type { Logger } from "pino"; +import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js"; import { createReverseEngineeringService } from "../composition/reverseEngineering.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -28,6 +29,11 @@ import { createRizinDebugSessionManager, } from "../composition/debuggerSessions.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { + retainedDebuggerDiagnostics, + retainedDebuggerProcessIds, + retainedDebuggerRemediation, +} from "../process/ownedDebuggerCleanupError.js"; import { analysisEvidenceDeliveryOptions, cliEvidenceDeliveryInputError, @@ -144,6 +150,9 @@ const closeDebuggerCommandSession = async ( try { await manager.closeAll(); } catch (cause) { + // This process is the only owner, so a caller gets the debugger to check + // rather than a retry that no later command could perform. + const retainedPids = retainedDebuggerProcessIds(cause); throw new ProviderCleanupError( providerId, sessionId === undefined ? [] : [sessionId], @@ -158,8 +167,15 @@ const closeDebuggerCommandSession = async ( : cause instanceof Error ? cause.message : String(cause), + ...retainedDebuggerDiagnostics(cause), + }, + { + cause, + operation, + ...(retainedPids.length === 0 + ? {} + : { remediationAction: retainedDebuggerRemediation(retainedPids) }), }, - { cause, operation }, ); } }; @@ -177,7 +193,7 @@ export const registerReverseEngineeringCommands = ( const service = createService(environment); cli.command(CLI_COMMANDS.inspectWithObjdump, { description: - "Inspect a local artifact with GNU objdump and retain its raw output as Evidence", + "Inspect a local artifact using GNU-style objdump arguments and retain the selected command output as Evidence", args: z.object({ path: z .string() @@ -185,7 +201,7 @@ export const registerReverseEngineeringCommands = ( .describe("Path to the local artifact to inspect."), operation: z .enum(OBJDUMP_ARTIFACT_OPERATIONS) - .describe("GNU objdump inspection operation to run."), + .describe("GNU-style objdump operation to run."), }), options: z.object({ ...analysisEvidenceDeliveryOptions, @@ -193,7 +209,7 @@ export const registerReverseEngineeringCommands = ( .string() .optional() .describe( - "Native symbol to select with the disassemble operation; GNU objdump controls matching and boundaries.", + "Native symbol to select with the disassemble operation; the selected executable controls matching and boundaries.", ), followDebugLinks: z .boolean() @@ -207,7 +223,7 @@ export const registerReverseEngineeringCommands = ( withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectWithObjdump, async () => { const delivery = { - providerId: "gnu.objdump", + providerId: OBJDUMP_PROVIDER_IDENTITY.id, operation: "inspect_with_objdump", signal, detail: options.detail, @@ -240,7 +256,7 @@ export const registerReverseEngineeringCommands = ( ), }); cli.command(CLI_COMMANDS.listObjdumpArchitectures, { - description: "List BFD targets advertised by the installed GNU objdump", + description: "Capture the selected objdump command response to -i", args: z.object({}), options: z.object({ ...analysisEvidenceDeliveryOptions, @@ -253,7 +269,7 @@ export const registerReverseEngineeringCommands = ( CLI_COMMANDS.listObjdumpArchitectures, async () => { const delivery = { - providerId: "gnu.objdump", + providerId: OBJDUMP_PROVIDER_IDENTITY.id, operation: "inspect_with_objdump", signal, detail: options.detail, diff --git a/src/cliBuiltinArguments.ts b/src/cliBuiltinArguments.ts index a75495a56b..2dafd941ae 100644 --- a/src/cliBuiltinArguments.ts +++ b/src/cliBuiltinArguments.ts @@ -46,8 +46,8 @@ export interface IncurBuiltinArgumentSplit { /** * Mirror Incur's builtin pass over normalized arguments without stopping at a - * rejected value, so callers can still see every later token. REA's boolean - * timing global follows the same valueless pass; no config flag is registered. + * rejected value, so callers can still see every later token. Then mirror the + * boolean timing global pass over the remaining tokens; no config is registered. */ export const splitIncurBuiltinArguments = ( arguments_: readonly string[], @@ -59,7 +59,7 @@ export const splitIncurBuiltinArguments = ( const argument = arguments_[index]!; const next = arguments_[index + 1]; if ( - INCUR_BUILTIN_FLAGS.has(argument) || + (INCUR_BUILTIN_FLAGS.has(argument) && argument !== "--timings") || // A following value belongs to a command-local `--version` option. (argument === "--version" && (next === undefined || next.startsWith("-"))) ) { @@ -73,7 +73,24 @@ export const splitIncurBuiltinArguments = ( } command.push(argument); } - return { command, flags, outputValues }; + // Incur extracts builtin output values first, then parses globals until `--`. + // A timing-shaped output value is data; a command option's value is still + // subject to this global pass before the registration builtin consumes it. + let globalsEnded = false; + const remaining = command.filter((token) => { + if (token === "--") globalsEnded = true; + if ( + !globalsEnded && + (token === "--timings" || + token === "--no-timings" || + token.startsWith("--timings=")) + ) { + flags.add(token); + return false; + } + return true; + }); + return { command: remaining, flags, outputValues }; }; /** diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 14ffdf63bb..87fdfec068 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings. Exact names and declaring_types filters intersect before paging where supported; fields selects columns (distinct from collection: "fields"). Identity/source_index, module MVID, source coverage and unresolved filter counts remain; omit body to avoid IL anchors. Omitted fields preserves full rows. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native load-image Evidence uses kind native-load-image with collection source_files/mappings/relocations/entry_points/entry_context/checks/sections, original indices, collection-specific fields and offset/limit paging. Null collections remain unrecorded or inapplicable; pages preserve unsupported verification. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings. Exact names and declaring_types filters intersect before paging where supported; fields selects columns (distinct from collection: "fields"). Identity/source_index, module MVID, source coverage and unresolved filter counts remain; omit body to avoid IL anchors. Omitted fields preserves full rows. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native load-image Evidence uses kind native-load-image with collection source_files/mappings/relocations/entry_points/entry_context/checks/sections, original indices, collection-specific fields and offset/limit paging. Null collections remain unrecorded or inapplicable; pages preserve unsupported verification. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Retained batch_decompile Evidence accepts kind summary for counts and sizes, or view: {kind: "batch-decompile", collection: "items", where: {statuses: ["error"]}} for body-free rows with exact indices, addresses, procedure_addresses or statuses (include_pseudocode adds bodies), or collection "pseudocode" with an index to page one body. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/enhancedToolContracts.ts b/src/contracts/enhancedToolContracts.ts index 1ba7c45068..b4f7010703 100644 --- a/src/contracts/enhancedToolContracts.ts +++ b/src/contracts/enhancedToolContracts.ts @@ -47,8 +47,10 @@ export const ENHANCED_TOOL_CONTRACTS = [ ), enhanced( "batch_decompile", - "Decompile each explicit procedure symbol or address, with one provider read active per batch. Preserves each input selector in address and reports the observed canonical entry and entry label in procedure; identity or label failures remain explicit without discarding usable pseudocode. Returns ordered per-item success or error results and aggregate counts. An item whose provider answer has no pseudocode keeps that observation's limitations, including any reason the provider reported. The complete reply retains every pseudocode body; use selected small batches to bound retained output. A per-function limit is not a batch deadline; allow for every item or split calls.", - enhancedInputSchemas.batch_decompile, + "Decompile each explicit procedure symbol or address, with one provider read active per batch. Preserves each input selector in address and reports the observed canonical entry and entry label in procedure; identity or label failures remain explicit without discarding usable pseudocode. Returns ordered per-item success or error results and aggregate counts. An item whose provider answer has no pseudocode keeps that observation's limitations, including any reason the provider reported. The complete reply carries every pseudocode body. Select detail summary to retain the batch and receive only counts, then read rows or one body by index through inspect_analysis_view. A per-function limit is not a batch deadline; allow for every item or split calls.", + enhancedInputSchemas.batch_decompile.extend({ + detail: analysisDetailSchema, + }), ), enhanced( "decompile_all", diff --git a/src/contracts/firmware/firmwareToolContracts.ts b/src/contracts/firmware/firmwareToolContracts.ts index 2bb8e19980..c210212271 100644 --- a/src/contracts/firmware/firmwareToolContracts.ts +++ b/src/contracts/firmware/firmwareToolContracts.ts @@ -5,7 +5,10 @@ import { import type { ToolContract } from "../toolContractTypes.js"; import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; import { toolContractMetadata } from "../toolEffects.js"; -import { FIRMWARE_TOOL_REQUIREMENTS } from "./firmwareToolRequirements.js"; +import { + FIRMWARE_OPERATION_ENGINES, + FIRMWARE_TOOL_REQUIREMENTS, +} from "./firmwareToolRequirements.js"; /** Explicit local firmware observations and extraction, backed by caller-supplied tools. */ export const FIRMWARE_TOOL_CONTRACTS = [ @@ -13,7 +16,7 @@ export const FIRMWARE_TOOL_CONTRACTS = [ name: "inspect_firmware_regions", ...toolContractMetadata("inspect_firmware_regions"), kind: "firmware-provider", - description: `Inspect signature candidates in a local firmware file with ${FIRMWARE_TOOL_REQUIREMENTS.binwalk.requirement}. Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool, util-linux prlimit and procps ps required; 128 MiB input limit, one worker. Adjust analysis_timeout_ms for the analysis wall-clock budget (default 120000); fixed per-process limits are unchanged.`, + description: `Inspect signature candidates in a local firmware file with ${FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES.inspect_firmware_regions].requirement}. Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool, util-linux prlimit and procps ps required; 128 MiB input limit, one worker. Adjust analysis_timeout_ms for the analysis wall-clock budget (default 120000); fixed per-process limits are unchanged.`, inputSchema: firmwareInputSchemas.inspect_firmware_regions, outputSchema: evidenceResultOf( firmwareResultSchemas.inspect_firmware_regions, @@ -29,7 +32,7 @@ export const FIRMWARE_TOOL_CONTRACTS = [ name: "extract_firmware", ...toolContractMetadata("extract_firmware"), kind: "firmware-provider", - description: `Extract caller-selected firmware bytes with ${FIRMWARE_TOOL_REQUIREMENTS.unblob.requirement} into an absent output directory. Returns verified child paths/digests, task derivations, unknown chunks and uncovered selected input ranges, extraction diagnostics and explicit extraction_gaps rows naming unextracted regions, missing extractor dependencies and a continuation_id referencing shared extraction_continuations recovery instructions. Preserves the default sandbox; publishes regular files only. Linux, caller-supplied tool, format-specific extractors, util-linux prlimit and procps ps required. No mounts or extracted-code execution. Select a returned native file with open_binary for Ghidra analysis; decompressed offsets are not original-file addresses.`, + description: `Extract caller-selected firmware bytes with ${FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES.extract_firmware].requirement} into an absent output directory. Returns verified child paths/digests, task derivations, unknown chunks and uncovered selected input ranges, extraction diagnostics and explicit extraction_gaps rows naming unextracted regions, missing extractor dependencies and a continuation_id referencing shared extraction_continuations recovery instructions. Preserves the default sandbox; publishes regular files only. Linux, caller-supplied tool, format-specific extractors, util-linux prlimit and procps ps required. No mounts or extracted-code execution. Select a returned native file with open_binary for Ghidra analysis; decompressed offsets are not original-file addresses.`, inputSchema: firmwareInputSchemas.extract_firmware, outputSchema: evidenceResultOf(firmwareResultSchemas.extract_firmware), examples: [ diff --git a/src/contracts/firmware/firmwareToolRequirements.ts b/src/contracts/firmware/firmwareToolRequirements.ts index fb0816fe97..824a43816a 100644 --- a/src/contracts/firmware/firmwareToolRequirements.ts +++ b/src/contracts/firmware/firmwareToolRequirements.ts @@ -1,3 +1,5 @@ +import type { FirmwareOperation } from "../../domain/firmware/firmwareAnalysis.js"; + /** Derive public support facts from one audited, stable release number. */ const defineToolRequirement = (release: { readonly name: string; @@ -39,3 +41,9 @@ export const FIRMWARE_TOOL_REQUIREMENTS = { /** Caller-supplied firmware engine selected by the operation. */ export type FirmwareEngineName = keyof typeof FIRMWARE_TOOL_REQUIREMENTS; + +/** The engine required by each existing firmware operation, shared by discovery and execution. */ +export const FIRMWARE_OPERATION_ENGINES = { + inspect_firmware_regions: "binwalk", + extract_firmware: "unblob", +} as const satisfies Readonly>; diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index eba10d04fa..7bc97ecbc4 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -245,7 +245,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_binary_layout"), kind: "native-provider", description: - "Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Profile: EXEC/DYN/REL ELF64 x86-64, ELF32 i386, ELF32 little-endian ARM and ELF32 little- or big-endian MIPS on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success. Select detail summary to retain the complete layout Evidence in this session and receive only its summary view, then inspect sections, symbols, mitigations or linkage with inspect_analysis_view. pwntools_timeout_ms is each owned pwntools command's deadline (omitted means 30000 ms, range 1..2147483647, next call). cpuSeconds 30 and the output, diagnostic, and address-space byte allowances have no runtime override.", + "Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Profile: EXEC/DYN/REL ELF64 x86-64, ELF64 little-endian AArch64, ELF32 i386, ELF32 little-endian ARM and ELF32 little- or big-endian MIPS on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success. Select detail summary to retain the complete layout Evidence in this session and receive only its summary view, then inspect sections, symbols, mitigations or linkage with inspect_analysis_view. pwntools_timeout_ms is each owned pwntools command's deadline (omitted means 30000 ms, range 1..2147483647, next call). cpuSeconds 30 and the output, diagnostic, and address-space byte allowances have no runtime override.", inputSchema: inspectBinaryLayoutRequestSchema, outputSchema: analysisDetailOutputSchemaOf( binaryLayoutSchema, diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 9d1ab1a899..16ba5d0b66 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -224,7 +224,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "search_strings", - `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, + `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, xrefs can look for references; read_bytes can inspect bytes where supported (not available with Kuna). Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, z.object({ ...nativeSearchInput, detail: analysisDetailSchema }), { evidenceParameters: "omit-detail" }, ), @@ -260,7 +260,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "xrefs", - "Return analyzed references to a code or data address. Hopper may default to its current cursor; headless providers such as Ghidra require an explicit address. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete. Use inspect_native_instruction at the target address for typed incoming references where supported.", + "Return analyzed references to a code or data address. Hopper may default to its current cursor; headless providers such as Ghidra require an explicit address. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete. Page a retained result with inspect_analysis_view and native-observation; use native-context for source caveats. Use inspect_native_instruction at the target address for typed incoming references where supported.", z.object({ document, address: optionalAddress }), ), ] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/reverseEngineeringToolContracts.ts b/src/contracts/reverseEngineeringToolContracts.ts index a0aa37f50e..d2aacf238b 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. 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.", + "Run a caller-selected objdump command using GNU-style arguments against a local artifact, or request operation=architectures without a path to capture its response to -i. The executable implementation and version are not verified; other implementations may differ in options and output. For operation=disassemble, optional symbol selects native symbol-scoped disassembly before output collection; omission preserves all code sections. Matching and symbol boundaries follow the selected executable. 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, diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 87af0a7368..44d5655bf6 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -173,7 +173,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "get_evidence_bundle", - "Return every retained Evidence record and residual unknown as a complete canonical bundle by default. detail=summary discovers retained records without copying or transferring their payloads; optionally filter by exact Evidence ID, operation, target SHA-256, analysis-profile digest or canonical native procedure address. Metadata distinguishes complete record retention from native value-flow truncation or unavailable/unknown coverage. detail=records returns a page (offset, limit) of the matching complete records with unchanged IDs and their missing direct evidence_links; it is not an importable bundle. Use a discovered evidence_id with inspect_analysis_view without restarting a provider. Filters apply to summary and records delivery.", + "Return every retained Evidence record and residual unknown as a complete canonical bundle by default. detail=summary discovers retained records without copying or transferring their payloads; optionally filter by exact Evidence ID, operation, target SHA-256, analysis-profile digest or canonical native procedure address. Metadata distinguishes complete record retention from native value-flow truncation or unavailable/unknown coverage. detail=records returns a page (offset, limit) of the matching complete records with unchanged IDs and their missing direct evidence_links; it is not an importable bundle. detail=links reads which evidence_links of the selected records this session or bundle retains, without their payloads: traversal retained-closure also follows retained links, the walk composed workflows require, and rows default to missing links. Use a discovered evidence_id with inspect_analysis_view without restarting a provider. Filters apply to summary, records and links delivery.", getEvidenceBundleInputSchema, ), { diff --git a/src/contracts/sessionToolSchemas.ts b/src/contracts/sessionToolSchemas.ts index a8a0f31ada..25c344d790 100644 --- a/src/contracts/sessionToolSchemas.ts +++ b/src/contracts/sessionToolSchemas.ts @@ -5,26 +5,41 @@ import { bundleComparisonInputSchema } from "../domain/bundleComparison.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { + EVIDENCE_LINK_INSPECTION_LIMIT, EVIDENCE_RECORD_SELECTION_LIMIT, + evidenceLinkStatusFilterSchema, + evidenceLinkTraversalSchema, evidenceRecordFiltersSchema, } from "../domain/evidenceRecordSummary.js"; import { residualUnknownSchema } from "../domain/residualUnknown.js"; -/** Complete retained Evidence, selected discovery metadata, or a page of selected complete records. */ +/** Complete retained Evidence, discovery metadata, a page of complete records, or link membership. */ export const getEvidenceBundleInputSchema = z.strictObject({ - detail: z.enum(["complete", "summary", "records"]).default("complete"), + detail: z + .enum(["complete", "summary", "records", "links"]) + .default("complete"), filters: evidenceRecordFiltersSchema.optional(), offset: z .int() .nonnegative() .optional() - .describe("records only: first matching record to return; default 0."), + .describe("records or links: first matching row to return; default 0."), limit: z .int() .positive() .optional() .describe( - `records only: maximum complete records to return; default ${String(EVIDENCE_RECORD_SELECTION_LIMIT)}.`, + `records: maximum complete records, default ${String(EVIDENCE_RECORD_SELECTION_LIMIT)}. links: maximum link rows, default ${String(EVIDENCE_LINK_INSPECTION_LIMIT)}.`, + ), + traversal: evidenceLinkTraversalSchema + .optional() + .describe( + "links only: direct (default) reads the selected records' own links; retained-closure also follows retained links.", + ), + link_status: evidenceLinkStatusFilterSchema + .optional() + .describe( + "links only: rows to return, default missing. Counts always cover every inspected link.", ), }); diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 1d017445df..1ac80877e4 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -10,6 +10,7 @@ import { nativeFunctionMatchingSummaryViewResultSchema, nativeFunctionSummaryViewResultSchema, nativeInventorySummaryViewResultSchema, + batchDecompileSummaryViewResultSchema, nativeLoadImageSummaryViewResultSchema, artifactInventorySummaryViewResultSchema, interfaceBuilderSummaryViewResultSchema, @@ -22,6 +23,7 @@ import { byteEntropySchema } from "../domain/native/byteEntropy.js"; import { nativeFunctionMatchingSchema } from "../domain/native/functionMatching.js"; import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js"; import { analysisDetailOutputSchemaOf } from "./analysisDetail.js"; +import { batchDecompileResultSchema } from "../domain/native/batchDecompile.js"; import { functionDossierSchema, functionInstructionWindowSchema, @@ -44,6 +46,7 @@ import { jsonRecordSchema } from "../domain/jsonRecord.js"; import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; import { evidenceBundleSummarySchema, + evidenceLinkInspectionSchema, evidenceRecordSelectionSchema, } from "../domain/evidenceRecordSummary.js"; import { liveBinaryTargetSummaryVariants } from "../domain/liveBinaryTargetSummary.js"; @@ -323,23 +326,6 @@ const callPathTraceOutput = evidenceResultOf( }), ); -/** Identity facts attached to one requested batch selector. */ -const batchProcedureIdentity = z.discriminatedUnion("status", [ - z.object({ - status: z.literal("resolved"), - address: z.string().min(1).describe("Observed canonical procedure entry."), - name: z - .string() - .nullable() - .describe("Provider's entry label, or null when unavailable."), - name_error: analysisErrorProjectionSchema.optional(), - }), - z.object({ - status: z.literal("unknown"), - error: analysisErrorProjectionSchema, - }), -]); - /** Exact structured-content schemas for composed analysis workflows. */ export const enhancedOutputSchemas = { inspect_native_dispatch_metadata: evidenceResultOf( @@ -350,38 +336,9 @@ export const enhancedOutputSchemas = { get_objc_classes: objcClassDiscoveryOutput, get_objc_protocols: symbolDiscoveryOutput("protocols"), decompile_all: evidenceResultOf(decompileDirectoryManifestSchema), - batch_decompile: evidenceResultOf( - z.object({ - items: z.array( - z.discriminatedUnion("status", [ - z.object({ - address: z - .string() - .describe("Original caller-selected symbol or address."), - procedure: batchProcedureIdentity, - status: z.literal("ok"), - pseudocode: z.string().min(1), - }), - z.object({ - address: z - .string() - .describe("Original caller-selected symbol or address."), - procedure: batchProcedureIdentity, - status: z.literal("error"), - error: analysisErrorProjectionSchema, - limitations: z - .array(z.string()) - .optional() - .describe( - "Caveats of this item's own provider observation when it supplied no pseudocode, including any reason the provider reported.", - ), - }), - ]), - ), - total: z.int().min(0), - succeeded: z.int().min(0), - failed: z.int().min(0), - }), + batch_decompile: analysisDetailOutputSchemaOf( + batchDecompileResultSchema, + batchDecompileSummaryViewResultSchema, ), get_call_graph: analysisDetailOutputSchemaOf( nativeObservationSourceSchemas.get_call_graph, @@ -591,6 +548,7 @@ export const sessionOutputSchemas = { evidenceBundleSchema, evidenceBundleSummarySchema, evidenceRecordSelectionSchema, + evidenceLinkInspectionSchema, ]), ), get_navigation_context: lifecycleResultOf( diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index 0d7d5e923e..dad89d6607 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -2,6 +2,7 @@ import { procedureInfoSchema } from "../domain/native/procedureInfo.js"; import { z } from "zod"; import { investigationStatisticsSchema } from "../domain/investigationLifecycle.js"; import { utcDateTimeSchema } from "../domain/offsetDateTime.js"; +import { analysisCleanupRetrySchema } from "../domain/analysisErrorRecovery.js"; import { functionDossierSchema } from "../domain/native/functionDossier.js"; import { analysisProfileSchema } from "../domain/analysisProfile.js"; @@ -544,6 +545,7 @@ export const sessionProviderFacts = z .object({ investigation: investigationStatisticsSchema, cleanup_pending: z.boolean(), + cleanup_retry: analysisCleanupRetrySchema.optional(), capabilities: z.array(providerCapabilitySet), analysis_run: analysisRun, analysis_activity: analysisActivity, diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 76a4e0ff40..2285303699 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -56,6 +56,10 @@ export const MCP_TRANSPORT_EVIDENCE_DISCOVERY = `Discover retained records with /** Next step when one MCP frame cannot carry a selected page of complete records. */ export const MCP_TRANSPORT_EVIDENCE_RECORDS = `Request fewer records with get_evidence_bundle detail: records, a smaller limit or an exact evidence_id filter; inspect compatible records in pages through inspect_analysis_view. A single record larger than one response needs export. ${MCP_TRANSPORT_EVIDENCE_EXPORT}`; +/** Next step when one MCP frame cannot carry a selected page of link rows. */ +export const MCP_TRANSPORT_EVIDENCE_LINKS = + "Request fewer rows with get_evidence_bundle detail: links, a smaller limit or an exact evidence_id filter, then follow next_offset."; + /** Name the next step that is true for how long the caller keeps REA's runtime and session. */ export const analysisErrorRemediationAction = ( error: AnalysisError, diff --git a/src/domain/analysisErrorRecovery.ts b/src/domain/analysisErrorRecovery.ts index 419759a15d..301863363a 100644 --- a/src/domain/analysisErrorRecovery.ts +++ b/src/domain/analysisErrorRecovery.ts @@ -31,6 +31,11 @@ export interface AnalysisCleanupRetry { readonly operation: "close_binary"; } +/** The retained owner's same-connection retry, also exposed in session status. */ +export const analysisCleanupRetrySchema = z + .object({ operation: z.literal("close_binary") }) + .strict(); + export const cleanupRetryRecovery = ( retry: AnalysisCleanupRetry, ): AnalysisErrorRecovery => ({ diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index cc69a3505f..91937ee723 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -18,6 +18,7 @@ import { projectJavaScriptRoutesView } from "./javascriptRoutesView.js"; import { projectWebBundleView } from "./webBundleView.js"; import { projectSourceToBundleView } from "./sourceToBundleView.js"; import { projectFeatureTraceView } from "./featureTraceView.js"; +import { projectBatchDecompileView } from "./batchDecompileView.js"; import { projectGpuCodeObjectsView } from "./gpuCodeObjectsView.js"; import { projectModelArtifactView } from "./modelArtifactView.js"; import { projectCommandObservationView } from "./commandObservationView.js"; @@ -149,6 +150,7 @@ import { sourceToBundleSummaryViewResultSchema, sourceToBundlePageViewResultSchema, featureTraceSummaryViewResultSchema, + batchDecompileSummaryViewResultSchema, featureTraceViewResultSchema, analysisViewResultSchema, type AnalysisViewResult, @@ -215,6 +217,7 @@ export { sourceToBundleSummaryViewResultSchema, sourceToBundlePageViewResultSchema, featureTraceSummaryViewResultSchema, + batchDecompileSummaryViewResultSchema, featureTraceViewResultSchema, analysisViewResultSchema, type AnalysisViewResult, @@ -333,6 +336,7 @@ const retainedViewProjectors = new Map([ retain("match_native_functions", projectNativeFunctionMatchingView), retain("compare_source_to_bundle", projectSourceToBundleView), retain("trace_application_feature", projectFeatureTraceView), + retain("batch_decompile", projectBatchDecompileView), retain("inspect_artifact", projectArtifactInventoryView), retain("inventory_artifact", projectArtifactInventoryView), retain("analyze_web_bundle", projectWebBundleView), @@ -432,6 +436,7 @@ export const projectAnalysisView = ( parent.operation === "match_native_functions" ? NATIVE_FUNCTION_MATCHING_PREDICATE : parent.operation === "analyze_function" || + parent.operation === "batch_decompile" || isNativeObservationOperation(parent.operation) || isNativeInventoryOperation(parent.operation) ? ANALYSIS_EVIDENCE_PREDICATE diff --git a/src/domain/analysisView/analysisViewRequest.ts b/src/domain/analysisView/analysisViewRequest.ts index 8375b84dd9..cfa24c93f6 100644 --- a/src/domain/analysisView/analysisViewRequest.ts +++ b/src/domain/analysisView/analysisViewRequest.ts @@ -11,6 +11,7 @@ import { javaScriptRoutesViewRequestSchema } from "./javascriptRoutesView.js"; import { webBundleViewRequestSchema } from "./webBundleView.js"; import { sourceToBundleViewRequestSchema } from "./sourceToBundleView.js"; import { featureTraceViewRequestSchema } from "./featureTraceView.js"; +import { batchDecompileViewRequestSchema } from "./batchDecompileView.js"; import { gpuCodeObjectsViewRequestSchema } from "./gpuCodeObjectsView.js"; import { modelArtifactViewRequestSchema } from "../model/modelArtifactQuery.js"; import { rustBinaryViewRequestSchema } from "../rust/rustBinaryQuery.js"; @@ -68,6 +69,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ nativeFunctionMatchingViewRequestSchema, sourceToBundleViewRequestSchema, featureTraceViewRequestSchema, + batchDecompileViewRequestSchema, summaryViewSchema, goBinaryViewRequestSchema, rustBinaryViewRequestSchema, diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index 7bd99d111b..2b5e9d3cd3 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -68,6 +68,11 @@ import { featureTraceViewCollections, featureTraceViewContextSchema, } from "./featureTraceView.js"; +import { + batchDecompileSummarySchema, + batchDecompileViewCollections, + batchDecompileViewContextSchema, +} from "./batchDecompileView.js"; import { gpuCodeObjectsViewRequestSchema, gpuCodeObjectsSummarySchema, @@ -377,6 +382,7 @@ export const nativeObservationSummaryViewResultSchemas = { procedure_address: nativeObservationSummaryFor("procedure_address"), procedure_callees: nativeObservationSummaryFor("procedure_callees"), procedure_callers: nativeObservationSummaryFor("procedure_callers"), + xrefs: nativeObservationSummaryFor("xrefs"), resolve_containing_procedure: nativeObservationSummaryFor( "resolve_containing_procedure", ), @@ -388,6 +394,7 @@ const nativeObservationSummaryViewResultSchema = z.discriminatedUnion( nativeObservationSummaryViewResultSchemas.procedure_address, nativeObservationSummaryViewResultSchemas.procedure_callees, nativeObservationSummaryViewResultSchemas.procedure_callers, + nativeObservationSummaryViewResultSchemas.xrefs, nativeObservationSummaryViewResultSchemas.resolve_containing_procedure, nativeObservationSummaryViewResultSchemas.get_call_graph, ], @@ -407,6 +414,7 @@ const nativeObservationPageViewResultSchema = z.discriminatedUnion( nativeObservationPageFor("procedure_address"), nativeObservationPageFor("procedure_callees"), nativeObservationPageFor("procedure_callers"), + nativeObservationPageFor("xrefs"), nativeObservationPageFor("resolve_containing_procedure"), nativeObservationPageFor("get_call_graph"), ], @@ -987,6 +995,39 @@ export const featureTraceViewResultSchema = z ]), ); +/** Counts and identities of a retained batch without any pseudocode body. */ +export const batchDecompileSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + artifact: artifactSchema.nullable(), + parent_operation: z.literal("batch_decompile"), + summary: batchDecompileSummarySchema, +}); +const batchDecompilePage = { + ...viewResultBase, + ...batchDecompileViewContextSchema.shape, + kind: z.literal("batch-decompile"), + artifact: artifactSchema.nullable(), + parent_operation: z.literal("batch_decompile"), +}; +// The kind tag keeps dispatch while each collection keeps exactly its own shape. +export const batchDecompileViewResultSchema = z + .object({ kind: z.literal("batch-decompile") }) + .passthrough() + .pipe( + z.union([ + z.strictObject({ + ...batchDecompilePage, + view: batchDecompileViewCollections.items.request, + items: batchDecompileViewCollections.items.rows, + }), + z.strictObject({ + ...batchDecompilePage, + view: batchDecompileViewCollections.pseudocode.request, + item: batchDecompileViewCollections.pseudocode.item, + }), + ]), + ); + /** Bind the existing nested facet selection to its producer-owned rows. */ const machoPage = (facet: Facet) => z.strictObject({ @@ -1026,6 +1067,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ webBundlePageViewResultSchema, sourceToBundlePageViewResultSchema, featureTraceViewResultSchema, + batchDecompileViewResultSchema, z.strictObject({ ...viewResultBase, ...nativeFunctionMatchingContextSchema.shape, @@ -1116,6 +1158,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ nativeFunctionSummaryViewResultSchema, sourceToBundleSummaryViewResultSchema, featureTraceSummaryViewResultSchema, + batchDecompileSummaryViewResultSchema, webBundleSummaryViewResultSchema, ]), z.strictObject({ diff --git a/src/domain/analysisView/batchDecompileView.ts b/src/domain/analysisView/batchDecompileView.ts new file mode 100644 index 0000000000..19aae24827 --- /dev/null +++ b/src/domain/analysisView/batchDecompileView.ts @@ -0,0 +1,432 @@ +import { z } from "zod"; +import { + committedProviderSchema, + type CommittedProviderIdentity, +} from "../analysisProfile.js"; +import { digestSchema } from "../digests.js"; +import { providerSchema } from "../evidence.js"; +import { + batchDecompileResultSchema, + batchItemLimitationsSchema, + batchProcedureIdentitySchema, + type BatchDecompileItem, + type BatchDecompileResult, +} from "../native/batchDecompile.js"; +import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js"; +import { err, ok } from "../result.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { + createAnalysisViewResultReader, + type AnalysisViewParent, +} from "./analysisViewParent.js"; +import { + ownedView, + type AdmittedView, + type SummaryAnalysisView, +} from "./analysisViewShape.js"; +import { + analysisTextPage, + analysisTextPageItemSchema, +} from "./analysisViewText.js"; +import { selectViewRows } from "./selectViewRows.js"; + +const OPERATION = "batch_decompile" as const; + +const offset = z.int().nonnegative().default(0); + +const itemsRequestSchema = z.strictObject({ + kind: z.literal("batch-decompile"), + collection: z.literal("items"), + offset, + limit: z.int().positive().default(64), + where: z + .strictObject({ + indices: z + .array(z.int().nonnegative()) + .optional() + .describe( + "Exact zero-based positions in the original batch, which keep duplicate selectors distinct. Omitted selects all; [] selects none.", + ), + addresses: z + .array(z.string()) + .optional() + .describe( + "Exact caller-selected symbols or addresses as submitted. Omitted selects all; [] selects none.", + ), + procedure_addresses: z + .array(z.string()) + .optional() + .describe( + "Exact observed canonical entries. An item with unknown procedure identity never matches. Omitted selects all; [] selects none.", + ), + statuses: z + .array(z.enum(["ok", "error"])) + .optional() + .describe("Item outcomes. Omitted selects all; [] selects none."), + }) + .default({}), + include_pseudocode: z + .boolean() + .default(false) + .describe( + "Opt into each selected successful item's complete pseudocode. Otherwise rows report pseudocode_code_units only.", + ), +}); + +const pseudocodeRequestSchema = z.strictObject({ + kind: z.literal("batch-decompile"), + collection: z.literal("pseudocode"), + index: z.int().nonnegative().describe("Exact item position from a row."), + offset: offset.describe("UTF-16 code-unit offset into this item's text."), + limit: z + .int() + .positive() + .default(16_384) + .describe( + "UTF-16 code units in this page. A page size only: coverage reports the complete body and the next offset.", + ), +}); + +/** Select rows of a completed batch or page one retained pseudocode body. */ +export const batchDecompileViewRequestSchema = z.discriminatedUnion( + "collection", + [itemsRequestSchema, pseudocodeRequestSchema], +); + +type BatchDecompileViewRequest = z.output< + typeof batchDecompileViewRequestSchema +>; + +const itemIdentity = { + index: z.int().nonnegative().describe("Position in the original batch."), + address: z.string().describe("Original caller-selected symbol or address."), + procedure: batchProcedureIdentitySchema, +}; + +const batchDecompileRowSchema = z.discriminatedUnion("status", [ + z.strictObject({ + ...itemIdentity, + status: z.literal("ok"), + pseudocode_code_units: z.int().positive(), + pseudocode: z.string().min(1).exactOptional(), + }), + z.strictObject({ + ...itemIdentity, + status: z.literal("error"), + error: analysisErrorProjectionSchema, + limitations: batchItemLimitationsSchema, + }), +]); + +/** + * Producer counts and identities repeated with every view, so one selected row + * stays traceable to its batch and to the provider that decompiled it. + */ +export const batchDecompileViewContextSchema = z.strictObject({ + batch: z.strictObject({ + total: z.int().nonnegative(), + succeeded: z.int().nonnegative(), + failed: z.int().nonnegative(), + }), + source_provider: providerSchema.nullable(), + source_analysis_profile_digest: digestSchema.nullable(), + decompiling_provider: committedProviderSchema + .nullable() + .describe( + "Provider committed by the batch's upstream analysis profile, or null when the parent records none.", + ), +}); + +/** Summary context adds the facts an analyst needs to choose what to read. */ +export const batchDecompileSummarySchema = + batchDecompileViewContextSchema.extend({ + procedures: z.strictObject({ + distinct_resolved: z.int().nonnegative(), + unknown_identity: z.int().nonnegative(), + label_unavailable: z + .int() + .nonnegative() + .describe( + "Resolved items whose entry label is null, whether the provider had none or its lookup failed.", + ), + }), + pseudocode: z.strictObject({ + code_units: z.int().nonnegative(), + largest: z + .strictObject({ + index: z.int().nonnegative(), + code_units: z.int().positive(), + }) + .nullable(), + }), + limitation_count: z.int().nonnegative(), + }); + +/** Each collection's request bound to its own rows, so a reply cannot mix them. */ +export const batchDecompileViewCollections = { + items: { + request: itemsRequestSchema, + rows: z.array(batchDecompileRowSchema), + }, + pseudocode: { + request: pseudocodeRequestSchema, + item: z.strictObject({ + ...itemIdentity, + ...analysisTextPageItemSchema.shape, + pseudocode_code_units: z.int().positive(), + }), + }, +} as const; + +const checkedBatch = createAnalysisViewResultReader(batchDecompileResultSchema); + +const upstreamProfileSchema = z.object({ + upstream_analysis_profile: z.object({ provider: committedProviderSchema }), +}); + +/** A composed batch's Evidence provider is the workflow; its profile names the decompiler. */ +const decompilingProvider = ( + parent: AnalysisViewParent, +): CommittedProviderIdentity | null => { + const parsed = upstreamProfileSchema.safeParse( + parent.analysisProfile?.parameters, + ); + return parsed.success ? parsed.data.upstream_analysis_profile.provider : null; +}; + +const batchContext = ( + parent: AnalysisViewParent, + { total, succeeded, failed }: BatchDecompileResult, +) => ({ + batch: { total, succeeded, failed }, + source_provider: parent.provider ?? null, + source_analysis_profile_digest: parent.analysisProfile?.digest ?? null, + decompiling_provider: decompilingProvider(parent), +}); + +const summarizeBatch = (items: readonly BatchDecompileItem[]) => { + const resolved = new Set(); + let unknownIdentity = 0; + let labelUnavailable = 0; + let codeUnits = 0; + let largest: { index: number; code_units: number } | null = null; + for (const [index, item] of items.entries()) { + if (item.procedure.status === "unknown") unknownIdentity++; + else { + resolved.add(item.procedure.address); + // Null covers an answer without a label and a failed label lookup alike. + if (item.procedure.name === null) labelUnavailable++; + } + if (item.status !== "ok") continue; + codeUnits += item.pseudocode.length; + if (largest === null || item.pseudocode.length > largest.code_units) + largest = { index, code_units: item.pseudocode.length }; + } + return { + procedures: { + distinct_resolved: resolved.size, + unknown_identity: unknownIdentity, + label_unavailable: labelUnavailable, + }, + pseudocode: { code_units: codeUnits, largest }, + }; +}; + +const batchUnknowns = (items: readonly BatchDecompileItem[]): string[] => { + const unknownIdentity = items.filter( + ({ procedure }) => procedure.status === "unknown", + ).length; + return [ + ...(unknownIdentity === 0 + ? [] + : [ + `${String(unknownIdentity)} items have unknown procedure identity; their address is the caller's selector, not an observed entry.`, + ]), + "Evidence limitations aggregate every item's provider observations and are not attributed to one successful item. A batch covers only its selected procedures, and exhausting a page establishes nothing outside it.", + ]; +}; + +const selected = (values: readonly Value[] | undefined) => + values === undefined ? undefined : new Set(values); + +const itemRow = ( + item: BatchDecompileItem, + index: number, + includePseudocode: boolean, +): z.output => { + const identity = { + index, + address: item.address, + procedure: item.procedure, + }; + if (item.status === "ok") + return { + ...identity, + status: "ok", + pseudocode_code_units: item.pseudocode.length, + ...(includePseudocode ? { pseudocode: item.pseudocode } : {}), + }; + return { + ...identity, + status: "error", + error: item.error, + ...(item.limitations === undefined + ? {} + : { limitations: item.limitations }), + }; +}; + +const pseudocodeRefusal = (message: string) => + err( + analysisViewInputError([ + { path: ["view", "index"], reason: "invalid_value", message }, + ]), + ); + +/** Select retained batch facts without repeating decompilation or changing its record. */ +export const projectBatchDecompileView = ( + parent: AnalysisViewParent, + admitted: AdmittedView, +) => { + const view = ownedView( + admitted, + "summary", + "batch-decompile", + ); + if (view === undefined) + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "batch_decompile Evidence supports summary and batch-decompile views of items or pseudocode.", + }, + ]), + ); + const parsed = checkedBatch(parent); + if (!parsed.ok) return parsed; + const batch = parsed.value; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: OPERATION, + parent_digest: parent.evidenceId.slice(3), + artifact: parent.artifact ?? null, + limitations: boundedViewMessages( + parent.limitations, + parent.evidenceId, + "limitations", + ), + unknowns: boundedViewMessages( + [], + parent.evidenceId, + "unknowns", + batchUnknowns(batch.items), + ), + }; + const context = batchContext(parent, batch); + if (view.kind === "summary") + return ok({ + kind: "summary" as const, + view, + ...shared, + summary: { + ...context, + ...summarizeBatch(batch.items), + limitation_count: new Set(parent.limitations).size, + }, + coverage: completeWithinViewCoverage(1, 1), + }); + if (view.collection === "items") { + const indices = selected(view.where.indices); + const addresses = selected(view.where.addresses); + const entries = selected(view.where.procedure_addresses); + const statuses = selected(view.where.statuses); + const { rows, total } = selectViewRows( + batch.items, + view, + (item, index) => + (indices === undefined || indices.has(index)) && + (addresses === undefined || addresses.has(item.address)) && + (statuses === undefined || statuses.has(item.status)) && + (entries === undefined || + (item.procedure.status === "resolved" && + entries.has(item.procedure.address))), + (item, index) => itemRow(item, index, view.include_pseudocode), + ); + return ok({ + kind: "batch-decompile" as const, + view, + ...shared, + ...context, + items: rows, + coverage: pageViewCoverage(view.offset, rows.length, total), + }); + } + const item = batch.items[view.index]; + if (item === undefined) + return pseudocodeRefusal( + `Item ${String(view.index)} is outside this batch of ${String(batch.items.length)}.`, + ); + if (item.status !== "ok") + return pseudocodeRefusal( + `Item ${String(view.index)} has status error and no pseudocode; read its error and limitations through the items collection.`, + ); + const page = analysisTextPage(item.pseudocode, view.offset, view.limit); + if (!page.ok) return page; + return ok({ + kind: "batch-decompile" as const, + view, + ...shared, + ...context, + item: { + index: view.index, + address: item.address, + procedure: item.procedure, + pseudocode_code_units: item.pseudocode.length, + ...page.value.item, + }, + coverage: page.value.coverage, + }); +}; + +const deliveredPageSchema = z.looseObject({ + view: batchDecompileViewRequestSchema, + items: z + .array(z.looseObject({ index: z.int(), status: z.string() })) + .optional(), +}); + +/** + * The next selection when a delivered page did not fit: fewer rows, then one + * body's text pages, which need no export. Other views are refused by kind + * first, because delivery asks about every view it returns. + */ +export const smallerBatchDecompileSelection = ( + delivered: unknown, +): z.input | undefined => { + if ( + typeof delivered !== "object" || + delivered === null || + !("kind" in delivered) || + delivered.kind !== "batch-decompile" + ) + return undefined; + const page = deliveredPageSchema.safeParse(delivered); + if (!page.success) return undefined; + const { view, items = [] } = page.data; + if (view.collection === "pseudocode") + return view.limit > 1 + ? { ...view, limit: Math.floor(view.limit / 2) } + : undefined; + if (items.length > 1) return { ...view, limit: Math.floor(items.length / 2) }; + const [row] = items; + return row?.status === "ok" && view.include_pseudocode + ? { kind: view.kind, collection: "pseudocode", index: row.index } + : undefined; +}; diff --git a/src/domain/analysisView/nativeExportsView.ts b/src/domain/analysisView/nativeExportsView.ts index 1d9be08030..2dad3ee5cd 100644 --- a/src/domain/analysisView/nativeExportsView.ts +++ b/src/domain/analysisView/nativeExportsView.ts @@ -67,6 +67,7 @@ export const nativeExportsViewItemsSchema = z.union([ name_utf8_decodable: elfRow.shape.name_utf8_decodable .removeDefault() .optional(), + visibility: elfRow.shape.visibility.removeDefault().optional(), }), ), z.array(issue.extend(sourceIndex)), diff --git a/src/domain/analysisView/nativeObservationSchemas.ts b/src/domain/analysisView/nativeObservationSchemas.ts index 50f42c6c99..bd526d2379 100644 --- a/src/domain/analysisView/nativeObservationSchemas.ts +++ b/src/domain/analysisView/nativeObservationSchemas.ts @@ -158,6 +158,7 @@ export const nativeObservationItemsSchemas = { procedure_address: addresses, procedure_callees: addresses, procedure_callers: addresses, + xrefs: addresses, resolve_containing_procedure: resolutions, get_call_graph: nativeGraphViewItemsSchema, }; diff --git a/src/domain/analysisView/nativeObservationView.ts b/src/domain/analysisView/nativeObservationView.ts index a532242c2d..48ef919da2 100644 --- a/src/domain/analysisView/nativeObservationView.ts +++ b/src/domain/analysisView/nativeObservationView.ts @@ -45,7 +45,8 @@ const readSource = ( ): Result => { switch (parent.operation) { case "procedure_callees": - case "procedure_callers": { + case "procedure_callers": + case "xrefs": { const read = readAddresses(parent); return read.ok ? ok({ operation: parent.operation, result: read.value }) @@ -138,6 +139,7 @@ const observationRows = ( }; case "procedure_callees": case "procedure_callers": + case "xrefs": return { parent_operation: source.operation, total: source.result.length, diff --git a/src/domain/browserObservationSchemas.ts b/src/domain/browserObservationSchemas.ts index bafa37e644..b020db89db 100644 --- a/src/domain/browserObservationSchemas.ts +++ b/src/domain/browserObservationSchemas.ts @@ -4,6 +4,7 @@ import { utcDateTimeSchema } from "./offsetDateTime.js"; import { browserCompletenessSchema } from "./browserCompleteness.js"; import { cdpDomSnapshotSchema, + cdpResourceColumnSchema, cdpScriptSourceSchema, cdpScriptMetadataSchema, } from "./cdpObservationSchemas.js"; @@ -125,7 +126,7 @@ const browserNetworkRequestSchema = z.object({ type: z.string(), url: z.string().nullable(), line: z.int().min(0).nullable(), - column: z.int().min(0).nullable(), + column: cdpResourceColumnSchema.nullable(), }), body_shapes: z.object({ status: z.enum(["not_approved", "included", "partial", "unavailable"]), @@ -149,7 +150,7 @@ const browserConsoleEventSchema = z.object({ ), url: z.string().nullable(), line: z.int().min(0).nullable(), - column: z.int().min(0).nullable(), + column: cdpResourceColumnSchema.nullable(), text_capture: z.object({ status: z.enum(["not_approved", "included", "unavailable"]), values: z.array( diff --git a/src/domain/cdpObservationSchemas.ts b/src/domain/cdpObservationSchemas.ts index 8af603df05..6e705d1192 100644 --- a/src/domain/cdpObservationSchemas.ts +++ b/src/domain/cdpObservationSchemas.ts @@ -1,6 +1,15 @@ import { z } from "zod"; import { webTextArtifactSchema } from "./webContentArtifact.js"; +/** + * Zero-based UTF-16 column in a resource, as CDP reports it. CDP declares + * columns as plain integers. Chrome reports a negative start column for some + * inline scripts, and positions on a script's first line include that start + * column, so a negative column is kept as reported. It does not locate a + * column in the resource. + */ +export const cdpResourceColumnSchema = z.int(); + /** Structural DOM facts shared by passive web and Electron observations. */ export const cdpDomNodeSchema = z.object({ index: z.number().int().min(0), diff --git a/src/domain/changedBehavior.test.ts b/src/domain/changedBehavior.test.ts index 07c31f4852..049f84da9a 100644 --- a/src/domain/changedBehavior.test.ts +++ b/src/domain/changedBehavior.test.ts @@ -228,7 +228,6 @@ describe("changed behavior", () => { }); expect(aggregate([changed, unknown]).result).toEqual(result); }); - }); describe("changed-behavior input and Evidence validation", () => { diff --git a/src/domain/evidenceRecordSummary.ts b/src/domain/evidenceRecordSummary.ts index 01c4ed2278..373d028fdd 100644 --- a/src/domain/evidenceRecordSummary.ts +++ b/src/domain/evidenceRecordSummary.ts @@ -53,6 +53,12 @@ export const evidenceBundleSummarySchema = z.strictObject({ total_retained_records: countSchema, matching_records: countSchema, unknown_revisions: countSchema, + direct_links: countSchema.describe( + "Distinct evidence_links of each matching record; detail links names them.", + ), + missing_direct_links: countSchema.describe( + "Those direct links whose records this session or bundle does not retain. Zero establishes no transitive closure or analysis coverage.", + ), records: z.array( z.strictObject({ evidence_id: evidenceSchema.shape.evidence_id, @@ -149,13 +155,24 @@ function* matchingEvidenceRecords( } } +/** A record's distinct direct links; the Evidence schema does not deduplicate them. */ +const directLinksOf = ({ evidence_links: links }: Evidence): string[] => + uniqueSorted(links); + /** Discover retained Evidence without reading or copying its complete payloads. */ export const summarizeEvidenceBundle = ( bundle: EvidenceBundle, filters: EvidenceRecordFilters = {}, ): EvidenceBundleSummary => { const records: EvidenceBundleSummary["records"] = []; - for (const { evidence, native } of matchingEvidenceRecords(bundle, filters)) + const retained = new Set(bundle.records.map(({ evidence_id: id }) => id)); + let directLinks = 0; + let missingDirectLinks = 0; + for (const { evidence, native } of matchingEvidenceRecords(bundle, filters)) { + for (const link of directLinksOf(evidence)) { + directLinks += 1; + if (!retained.has(link)) missingDirectLinks += 1; + } records.push({ evidence_id: evidence.evidence_id, subject: evidence.subject, @@ -168,12 +185,15 @@ export const summarizeEvidenceBundle = ( limitation_count: evidence.limitations.length, native_dossier: native, }); + } return { kind: "evidence-bundle-summary", filters: selectedFilters(filters), total_retained_records: bundle.records.length, matching_records: records.length, unknown_revisions: bundle.unknowns.length, + direct_links: directLinks, + missing_direct_links: missingDirectLinks, records, }; }; @@ -243,3 +263,142 @@ export const selectEvidenceRecords = ( limitations: [...EVIDENCE_RECORD_SELECTION_LIMITATIONS], }; }; + +/** How far link inspection reads from the selected records. */ +export const evidenceLinkTraversalSchema = z.enum([ + "direct", + "retained-closure", +]); +/** Which inspected links a page returns; counts always cover all of them. */ +export const evidenceLinkStatusFilterSchema = z.enum([ + "missing", + "retained", + "all", +]); + +/** Default page size for link rows, which carry two identifiers each. */ +export const EVIDENCE_LINK_INSPECTION_LIMIT = 64; + +/** Bounds what link membership establishes, in every inspection. */ +export const EVIDENCE_LINK_INSPECTION_LIMITATIONS = [ + "Link status is membership in this session or bundle: missing means absent here, not nonexistent, and neither status describes analysis coverage or a record's meaning.", +] as const; + +/** Direct links, or the retained closure, of selected records without their payloads. */ +export const evidenceLinkInspectionSchema = z.strictObject({ + kind: z.literal("evidence-link-inspection"), + filters: z.record(z.string(), z.string()), + traversal: evidenceLinkTraversalSchema, + link_status: evidenceLinkStatusFilterSchema, + total_retained_records: countSchema, + matching_records: countSchema, + examined_records: countSchema.describe( + "Retained records whose evidence_links were read: the matching records, plus those reached through retained links when traversal is retained-closure.", + ), + links: z + .strictObject({ + total: countSchema, + retained: countSchema, + missing: countSchema, + }) + .describe("Every link read from the examined records, before link_status."), + retained_closure: z + .enum(["complete", "incomplete"]) + .nullable() + .describe( + "With retained-closure traversal, incomplete means a link could not be followed, so what lies beyond it remains unknown. Null for direct traversal or an empty selection, which make no closure claim.", + ), + offset: countSchema, + limit: z.int().positive(), + matching_links: countSchema, + next_offset: countSchema.nullable(), + rows: z.array( + z.strictObject({ + evidence_id: prefixedDigestSchema("ev"), + linked_evidence_id: prefixedDigestSchema("ev"), + status: z.enum(["retained", "missing"]), + }), + ), + limitations: z.array(z.string()), +}); +export type EvidenceLinkInspection = z.infer< + typeof evidenceLinkInspectionSchema +>; + +/** Read link membership without copying or parsing any record payload. */ +export const inspectEvidenceLinks = ( + bundle: EvidenceBundle, + filters: EvidenceRecordFilters = {}, + options: { + readonly traversal?: + | z.infer + | undefined; + readonly linkStatus?: + | z.infer + | undefined; + readonly offset?: number | undefined; + readonly limit?: number | undefined; + } = {}, +): EvidenceLinkInspection => { + const traversal = options.traversal ?? "direct"; + const linkStatus = options.linkStatus ?? "missing"; + const offset = options.offset ?? 0; + const limit = options.limit ?? EVIDENCE_LINK_INSPECTION_LIMIT; + const retained = new Map( + bundle.records.map((record) => [record.evidence_id, record]), + ); + const examined = new Set(); + const pending: string[] = []; + for (const { evidence } of matchingEvidenceRecords(bundle, filters)) + pending.push(evidence.evidence_id); + const matching = pending.length; + while (pending.length > 0) { + const id = pending.pop(); + if (id === undefined || examined.has(id)) continue; + examined.add(id); + if (traversal === "direct") continue; + for (const link of retained.get(id)?.evidence_links ?? []) + if (retained.has(link) && !examined.has(link)) pending.push(link); + } + const rows: EvidenceLinkInspection["rows"] = []; + const counts = { total: 0, retained: 0, missing: 0 }; + let matchingLinks = 0; + // Bundle order is canonical, so a page stays stable across requests. + for (const evidence of bundle.records) { + const id = evidence.evidence_id; + if (!examined.has(id)) continue; + for (const link of directLinksOf(evidence)) { + const status = retained.has(link) ? "retained" : "missing"; + counts.total += 1; + counts[status] += 1; + if (linkStatus !== "all" && linkStatus !== status) continue; + if (matchingLinks >= offset && rows.length < limit) + rows.push({ evidence_id: id, linked_evidence_id: link, status }); + matchingLinks += 1; + } + } + const end = offset + rows.length; + return { + kind: "evidence-link-inspection", + filters: selectedFilters(filters), + traversal, + link_status: linkStatus, + total_retained_records: bundle.records.length, + matching_records: matching, + examined_records: examined.size, + links: counts, + // Nothing selected, or a direct read, supports no claim about a closure. + retained_closure: + traversal === "direct" || examined.size === 0 + ? null + : counts.missing === 0 + ? "complete" + : "incomplete", + offset, + limit, + matching_links: matchingLinks, + next_offset: end < matchingLinks ? end : null, + rows, + limitations: [...EVIDENCE_LINK_INSPECTION_LIMITATIONS], + }; +}; diff --git a/src/domain/flutter/flutterBuildAnalysis.ts b/src/domain/flutter/flutterBuildAnalysis.ts index d1f00ed5a1..0bd925fbab 100644 --- a/src/domain/flutter/flutterBuildAnalysis.ts +++ b/src/domain/flutter/flutterBuildAnalysis.ts @@ -142,7 +142,7 @@ export const flutterResultSchemas = { .string() .nullable() .describe( - "Labeled 'Dart VM version' string, when a build carries one", + "Observed string matching the labeled 'Dart VM version' or unlabeled Dart Version::String() pattern; unlabeled matches are candidates rather than verified source identities", ), toolchain_lines: z.array(z.string().min(1)), /** Dart labels omitted by grammar or single-version projection bounds. */ diff --git a/src/domain/javascript/electronStaticAnalysisProtocol.test.ts b/src/domain/javascript/electronStaticAnalysisProtocol.test.ts index 5df4809b64..a5aa5f2b2a 100644 --- a/src/domain/javascript/electronStaticAnalysisProtocol.test.ts +++ b/src/domain/javascript/electronStaticAnalysisProtocol.test.ts @@ -289,8 +289,18 @@ describe("Electron protocol registrations without a scheme", () => { describe("Electron protocol calls that register nothing", () => { it.each([ - ["a handler without a callback", 'protocol.handle("app");'], - ["a handler with a null callback", 'protocol.handle("app", null);'], + [ + "a legacy handler without a callback", + 'protocol.registerStringProtocol("app");', + ], + [ + "a legacy handler with a null callback", + 'protocol.registerStringProtocol("app", null);', + ], + [ + "a legacy handler with a void callback", + 'protocol.registerStringProtocol("app", void 0);', + ], [ "an interceptor without a callback", 'protocol.interceptHttpProtocol("https");', diff --git a/src/domain/javascript/electronStaticAnalysisProtocol.ts b/src/domain/javascript/electronStaticAnalysisProtocol.ts index 8667c9da2b..d6887e78ea 100644 --- a/src/domain/javascript/electronStaticAnalysisProtocol.ts +++ b/src/domain/javascript/electronStaticAnalysisProtocol.ts @@ -87,7 +87,12 @@ export const inspectElectronProtocolNode = ( : INTERCEPTORS.has(target.api) ? "interceptor" : undefined; - if (role !== undefined && !handlerKnownMissing(node)) + // handle installs its own native callback and invokes the supplied handler + // only when a request arrives; a missing handler does not prevent registration. + if ( + role !== undefined && + (target.api === "handle" || !handlerKnownMissing(node)) + ) addProtocol(context, { node, api: target.api, @@ -97,7 +102,7 @@ export const inspectElectronProtocolNode = ( }); }; -/** Electron requires the callback, so a call without one installs nothing. */ +/** Legacy native registration APIs require a callback before installing it. */ const handlerKnownMissing = ( node: t.CallExpression | t.OptionalCallExpression, ): boolean => { diff --git a/src/domain/javascript/htmlArtifactReferences.test.ts b/src/domain/javascript/htmlArtifactReferences.test.ts index 7dbf2f363f..e1e5b44d8e 100644 --- a/src/domain/javascript/htmlArtifactReferences.test.ts +++ b/src/domain/javascript/htmlArtifactReferences.test.ts @@ -106,7 +106,7 @@ it.each([ ['', []], ["", []], ["", []], - ["", []], + ["", ["classic"]], ])("classifies inline JavaScript in %s", (text, expected) => { expect( htmlArtifactReferences(text).inlineScripts.map( @@ -115,6 +115,65 @@ it.each([ ).toEqual(expected); }); +it.each([ + ['src="ignored.js"', "classic"], + ['language="vbscript"', "classic"], + ['xlink:type="simple"', "classic"], + ['type=" Text/JavaScript "', "classic"], + ['type="MODULE"', "module"], + ['type=" module "', null], + ['type="application/json"', null], + ['type="text/javascript; charset=utf-8"', null], + ['href="external.js"', null], + ['href=""', null], + ['xlink:href="external.js"', null], +] as const)("classifies SVG inline bodies using %s", (attributes, expected) => { + const result = htmlArtifactReferences( + ``, + ); + expect(result.inlineScripts.map(({ scriptType }) => scriptType)).toEqual( + expected === null ? [] : [expected], + ); + expect(result.inlineScriptCount).toBe(expected === null ? 0 : 1); +}); + +it.each([ + ['href="external.js"', ["external.js"]], + ['xlink:href="external.js"', ["external.js"]], + ['href="first.js" xlink:href="second.js"', ["first.js"]], + ['xlink:href="second.js" href="first.js"', ["first.js"]], + ['href="" xlink:href="ignored.js"', []], + ['other:href="ignored.js"', []], + ['src="ignored.js"', []], +])("extracts SVG script URL from %s", (attributes, paths) => { + const result = htmlArtifactReferences( + ``, + ); + expect(result.scripts.map(({ scriptPath }) => scriptPath)).toEqual(paths); + expect(result.inlineScriptCount).toBe(0); +}); + +it("shares precise body coordinates and location limits across HTML and SVG", () => { + const prefix = '\n`).inlineScripts, + ).toEqual([ + { + scriptType: "classic", + location: { start: { line: 2, column: 30 }, end: { line: 4, column: 0 } }, + }, + ]); + const repeated = "".repeat(60); + const result = htmlArtifactReferences(repeated); + expect(result.inlineScriptCount).toBe(120); + expect(result.inlineScripts).toHaveLength(HTML_INLINE_SCRIPT_LOCATION_LIMIT); + expect( + htmlArtifactReferences("") + .inlineScriptCount, + ).toBe(0); +}); + it("retains exact inline script body coordinates, including unclosed scripts", () => { const prefix = "\n`, + '', + '', + '', + '', + "", + ].join("\n"); + await writeFile(join(root, "index.html"), html); + await writeFile( + join(root, "main.js"), + 'fetch("https://example.test/external");', + ); + const response = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--source-path", + "index.html", + "--source-path", + "main.js", + "--format", + "json", + ], + }); + expect(response.exitCode, response.stderr).toBe(0); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + const observations = result.graph.nodes.flatMap( + ({ observations }) => observations, + ); + const scopes = observations.filter( + ({ properties }) => properties.operation === "parse-html-inline-scripts", + ); + expect(scopes).toHaveLength(1); + expect(scopes[0]).toMatchObject({ + properties: { + path: "index.html", + inline_script_count: 1, + inline_scripts: [ + { + script_type: "classic", + location: { + start: { line: 2, column: 45 }, + end: { line: 2, column: 86 }, + }, + }, + ], + }, + evidence: { + state: "unavailable", + confidence: "unknown", + coverage: { status: "partial" }, + }, + }); + expect( + result.graph.edges.filter( + ({ relation, properties }) => + relation === "loads" && properties.resolved_path === "main.js", + ), + ).toHaveLength(2); + const endpoints = result.graph.nodes + .filter(({ kind }) => kind === "endpoint") + .flatMap(({ observations }) => observations) + .map(({ properties }) => properties.value); + expect(endpoints).toContain("https://example.test/external"); + expect(endpoints).not.toContain(inlineUrl); + }, +); + cliTest.for([ "invalid-html", "empty-html", diff --git a/tests/acceptance/elfNotesAbiEndian.test.ts b/tests/acceptance/elfNotesAbiEndian.test.ts new file mode 100644 index 0000000000..819b11051b --- /dev/null +++ b/tests/acceptance/elfNotesAbiEndian.test.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { + elfNote, + elfNoteFixture, +} from "../../src/native/elf/ElfNotes.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +// Literal big-endian descriptors keep the expected values independent of the +// fixture's integer writers and the parser's byte-order branch. +const knownAbi = Buffer.from("00000000010203041020304080010203", "hex"); +const unknownAbi = Buffer.from("01000000010203041020304080010203", "hex"); +const extendedAbi = Buffer.concat([knownAbi, Buffer.from("01020304", "hex")]); +const shortAbi = knownAbi.subarray(0, 12); +const descriptorHash = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +for (const { bits, ownership, machine } of [ + { bits: 32, ownership: "program", machine: 20 }, + { bits: 64, ownership: "section", machine: 21 }, +] as const) + cliTest( + `preserves big-endian ELF${String(bits)} ABI facts through CLI and stdio MCP`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-elf-abi-endian-"); + const path = join(root, "selected.elf"); + const descriptors = [ + knownAbi, + unknownAbi, + shortAbi, + extendedAbi, + knownAbi, + ]; + const notes = Buffer.concat([ + ...descriptors + .slice(0, 4) + .map((bytes) => elfNote("GNU", 1, bytes, false)), + elfNote("OTHER", 1, knownAbi, false), + ]); + const bytes = elfNoteFixture({ + bits, + ownership, + machine, + little: false, + notes, + }); + await writeFile(path, bytes); + + const output = await cli.sharedCliRun!({ + arguments: ["inspect-elf-notes", path, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const call = await cli.sharedMcpCall!(); + const response = await call("inspect_elf_notes", { path }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + + const schema = toolContract("inspect_elf_notes").outputSchema; + const reports = [output.json, response.structuredContent].map((value) => { + const evidence = schema.parse(value); + expect(evidence.subject).toMatchObject({ + local_path: path, + digest: { sha256: descriptorHash(bytes) }, + }); + const report = evidence.normalized_result; + expect(report).toMatchObject({ + format: `elf${String(bits)}`, + byte_order: "big", + machine, + coverage: { + status: "complete", + examined_tables: 1, + examined_entries: 5, + }, + }); + expect(report.entries).toHaveLength(5); + expect(report.entries[0]?.gnu?.abi_tag).toEqual({ + os: 0, + os_name: "linux", + major: 0x01020304, + minor: 0x10203040, + subminor: 0x80010203, + }); + expect(report.entries[1]?.gnu?.abi_tag).toEqual({ + os: 0x01000000, + os_name: null, + major: 0x01020304, + minor: 0x10203040, + subminor: 0x80010203, + }); + // Unexpected lengths stay raw; no ABI values are invented or truncated. + for (const index of [2, 3]) + expect(report.entries[index]?.gnu?.abi_tag).toBeNull(); + expect(report.entries[4]).toMatchObject({ + owner: "OTHER", + type_name: "unknown", + gnu: null, + }); + for (const [index, descriptor] of descriptors.entries()) + expect(report.entries[index]?.descriptor).toMatchObject({ + bytes: descriptor.length, + sha256: descriptorHash(descriptor), + }); + return report; + }); + expect(reports[0]).toEqual(reports[1]); + expect((await readFile(path)).equals(bytes)).toBe(true); + }, + ); diff --git a/tests/acceptance/setup/automaticClientSetup.test.ts b/tests/acceptance/setup/automaticClientSetup.test.ts new file mode 100644 index 0000000000..ea93f2119c --- /dev/null +++ b/tests/acceptance/setup/automaticClientSetup.test.ts @@ -0,0 +1,95 @@ +import { access, mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for([ + { marker: true, skill: true }, + { marker: true, skill: false }, + { marker: false, skill: true }, +])( + "automatic setup excludes manual hosted connectors (marker: $marker, skill: $skill)", + async ({ marker, skill }, { cli }) => { + const home = await createTestTempDirectory("rea-automatic-clients-"); + if (marker) await mkdir(join(home, ".grokbot")); + const run = () => + cli.run({ + arguments: [ + "setup", + "--all-detected", + "--yes", + "--json", + ...(skill ? [] : ["--skill=false"]), + ], + cwd: home, + environment: { HOME: home, USERPROFILE: home, TMPDIR: home }, + }); + const first = await run(); + expect(first.exitCode, first.stdout).toBe(0); + expect(first.json).toMatchObject({ + status: "ready", + appliedActions: skill ? ["installed_skill"] : [], + clients: {}, + doctor: { healthy: true, scope: { clients: [], skill } }, + }); + if (marker) + expect(first.json).toMatchObject({ + clientStates: expect.arrayContaining([ + expect.objectContaining({ + client: expect.objectContaining({ name: "grok_bot" }), + detected: true, + }), + ]), + }); + const repeated = await run(); + expect(repeated.exitCode, repeated.stdout).toBe(0); + expect(repeated.json).toMatchObject({ + status: "ready", + plannedActions: [], + appliedActions: [], + doctor: { healthy: true, scope: { clients: [], skill } }, + }); + await expect( + access(join(home, ".grokbot", "mcp.json")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest.for([true, false])( + "explicit Grok Bot setup retains manual instructions (all detected: %s)", + async (allDetected, { cli }) => { + const home = await createTestTempDirectory("rea-explicit-manual-client-"); + await mkdir(join(home, ".grokbot")); + const result = await cli.run({ + arguments: [ + "setup", + "--client", + "grok_bot", + "--skill=false", + "--yes", + "--json", + ...(allDetected ? ["--all-detected"] : []), + ], + cwd: home, + environment: { HOME: home, USERPROFILE: home, TMPDIR: home }, + }); + expect(result.exitCode, result.stdout).toBe(1); + expect(result.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining("hosted computer"), + doctor: { scope: { clients: ["grok_bot"] } }, + }); + await expect( + access(join(home, ".grokbot", "mcp.json")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); diff --git a/tests/acceptance/setup/repeatedClientSetup.test.ts b/tests/acceptance/setup/repeatedClientSetup.test.ts index def26a75bb..143ed803d1 100644 --- a/tests/acceptance/setup/repeatedClientSetup.test.ts +++ b/tests/acceptance/setup/repeatedClientSetup.test.ts @@ -88,6 +88,7 @@ cliTest( "rea-explicit-detected-clients-", ); await mkdir(join(home, ".codex")); + await mkdir(join(home, ".grokbot")); const run = (selection: readonly string[], mode: string) => cli.run({ arguments: ["setup", ...selection, "--skill=false", "--json", mode], diff --git a/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts b/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts index e36bc6659a..a465ce02b8 100644 --- a/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts +++ b/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts @@ -1,3 +1,4 @@ +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { readFile, writeFile } from "node:fs/promises"; @@ -117,9 +118,11 @@ it("preserves file URL scheme spelling and script identity after real Inspector const retained = await call("get_evidence_bundle", {}); expect(retained.isError, JSON.stringify(retained.content)).not.toBe(true); - const records = toolContract("get_evidence_bundle").outputSchema.parse( - retained.structuredContent, - ).result.records; + const { records } = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + retained.structuredContent, + ).result, + ); const original = records.find( (record) => record.evidence_id === first.evidence_id, ); diff --git a/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts b/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts index 83977cfb92..e0a4426a02 100644 --- a/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts +++ b/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts @@ -1,3 +1,4 @@ +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { expect, it } from "vitest"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; @@ -171,9 +172,11 @@ it.each(["pseudocode", "overall"] as const)( claims: { items: [{ unknown_ids: [] }] }, }); const exported = await call("get_evidence_bundle", { detail: "complete" }); - const bundle = toolContract("get_evidence_bundle").outputSchema.parse( - exported.structuredContent, - ).result; + const bundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + exported.structuredContent, + ).result, + ); for (const source of [left, right, comparison]) expect( bundle.records.find( diff --git a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts index 1f2156ce19..b1e816758b 100644 --- a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts @@ -677,6 +677,11 @@ it.runIf(unsupportedHost)( const projected = projectAnalysisError(result.error); expect(projected.message).toContain("Linux x64"); expect(projected.remediation.action).toContain("Linux x64"); + // The refusal names this host, not the target, which was never read. + expect(projected.message).toContain( + `this host is ${process.platform}/${process.arch}`, + ); + expect(projected.message).toContain("The selected target was not read."); }, ); diff --git a/tests/boundary/browser/cdpWebRuntimeReview.test.ts b/tests/boundary/browser/cdpWebRuntimeReview.test.ts index f8243d36e4..f6338ee9d2 100644 --- a/tests/boundary/browser/cdpWebRuntimeReview.test.ts +++ b/tests/boundary/browser/cdpWebRuntimeReview.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js"; import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js"; import { inspectWebEventListenersInputSchema } from "../../../src/domain/webEventListeners.js"; -import { startRuntimeBrowser } from "../../fixtures/webRuntime.js"; +import { + negativeColumnScript, + negativeColumnScriptUrl, + startNegativeColumnBrowser, + startRuntimeBrowser, +} from "../../fixtures/webRuntime.js"; describe("runtime document identity across same-document URL changes", () => { it.each(["execution", "listeners"] as const)( @@ -217,3 +222,90 @@ describe("runtime collector failure and uncertain source metadata", () => { ).toBe(false); }); }); + +it("keeps negative columns Chrome reports for an inline script and its first-line callsites", async () => { + const browser = await startNegativeColumnBrowser(); + onTestFinished(() => browser.close()); + const scriptUrl = negativeColumnScriptUrl(browser.endpoint); + const result = await new CdpWebRuntimeProvider().observeExecution( + observeWebExecutionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + observation_ms: 30, + }), + { + progress: { + report: async (update) => { + if (update.completed !== 1) return; + for (const [requestId, initiator] of [ + [ + "script-request", + { + type: "script", + stack: { + callFrames: [ + { + scriptId: negativeColumnScript.scriptId, + url: scriptUrl, + functionName: "", + lineNumber: 21, + columnNumber: -40, + }, + ], + }, + }, + ], + [ + "parser-request", + { + type: "parser", + url: scriptUrl, + lineNumber: 21, + columnNumber: -30, + }, + ], + ] as const) + browser.emitEvent({ + method: "Network.requestWillBeSent", + sessionId: "session-1", + params: { + requestId, + frameId: "runtime-main", + timestamp: 12, + request: { + url: `${browser.endpoint}/${requestId}`, + method: "GET", + }, + initiator, + }, + }); + }, + }, + }, + ); + if (!result.ok) throw result.error; + expect( + result.value.requests.map(({ callsites }) => + callsites.map(({ script_id, line_number, column_number }) => ({ + script_id, + line_number, + column_number, + })), + ), + ).toEqual([ + [ + { + script_id: negativeColumnScript.scriptId, + line_number: 21, + column_number: -40, + }, + ], + [{ script_id: "", line_number: 21, column_number: -30 }], + ]); + expect(result.value.sources).toContainEqual( + expect.objectContaining({ + script_id: negativeColumnScript.scriptId, + resource_start: { line_number: 21, column_number: -60 }, + }), + ); +}); diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index 2f1d42a2a0..d4b0aac213 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -46,6 +46,72 @@ import { } from "../../../src/domain/evidenceBundle.js"; import { inspectEvidenceBundle } from "../../../src/application/investigation/InspectEvidenceBundle.js"; +cliTest( + "reads selected batch rows and one body from a saved bundle without a provider", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-batch-view-cli-"); + const path = join(root, "bundle.json"); + const body = "int main(void) { return 0; }"; + const parent = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "batch_decompile", + parameters: { addresses: ["main", "main"] }, + result: { + items: ["main", "main"].map((address) => ({ + address, + procedure: { status: "resolved", address: "0x1", name: "main" }, + status: "ok", + pseudocode: body, + })), + total: 2, + succeeded: 2, + failed: 0, + }, + confidence: "derived", + authority: "shipped-artifact", + }, + ); + await writeFile(path, JSON.stringify(createEvidenceBundle([parent]))); + const inspect = async (view: JsonValue) => { + const result = await cli.run({ + arguments: [ + "inspect-analysis-view", + JSON.stringify({ + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view, + }), + "--evidence-bundle", + path, + "--json", + ], + }); + expect(result.exitCode).toBe(0); + return parseEvidence(result.json).normalized_result; + }; + expect( + await inspect({ kind: "batch-decompile", collection: "items" }), + ).toMatchObject({ + batch: { total: 2 }, + items: [ + { index: 0, pseudocode_code_units: body.length }, + { index: 1, pseudocode_code_units: body.length }, + ], + }); + expect( + await inspect({ + kind: "batch-decompile", + collection: "pseudocode", + index: 1, + }), + ).toMatchObject({ item: { index: 1, text: body } }); + }, +); + cliTest( "authenticates an exact portable parent for non-managed views and rejects ambiguous or altered sources", async ({ cli }) => { @@ -653,6 +719,54 @@ cliTest( }, ); +cliTest( + "reads link membership from a deliberately partial portable bundle", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-evidence-links-cli-"); + const path = join(root, "partial.json"); + const absent = analysisViewLayoutEvidence(); + const linked = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "trace_feature", + parameters: {}, + result: {}, + evidenceLinks: [absent.evidence_id], + }, + ); + const bundle = createEvidenceBundle([linked]); + await writeFile(path, JSON.stringify(bundle)); + const options = { + detail: "links", + traversal: "retained-closure", + } as const; + const result = await cli.run({ + arguments: [ + "inspect-evidence-bundle", + JSON.stringify({ path, ...options }), + "--json", + ], + environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root }, + }); + expect(result.exitCode).toBe(0); + const expected = inspectEvidenceBundle(bundle, options); + if (!expected.ok) throw expected.error; + expect(result.json).toEqual(expected.value); + expect(result.json).toMatchObject({ + kind: "evidence-link-inspection", + retained_closure: "incomplete", + rows: [ + { + evidence_id: linked.evidence_id, + linked_evidence_id: absent.evidence_id, + status: "missing", + }, + ], + }); + }, +); + cliTest( "formats the full caller-admitted native inventory page as Markdown and JSON", async ({ cli }) => { diff --git a/tests/boundary/cli/cliWorkerCompletion.test.ts b/tests/boundary/cli/cliWorkerCompletion.test.ts index dc879f65a0..19a6a72f7b 100644 --- a/tests/boundary/cli/cliWorkerCompletion.test.ts +++ b/tests/boundary/cli/cliWorkerCompletion.test.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url"; import { expect } from "vitest"; import { z } from "zod"; +import { createDeferred } from "../../fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createCliSessionFrameReader, @@ -41,22 +43,45 @@ process.stdout.write = (chunk, ...arguments_) => { }; `, ); - const result = await processes.run( - process.execPath, - ["--import", preload, worker], - { - input: encodeCliSessionFrame({ - id: 1, - arguments: ["--help"], - cwd: root, - env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" }, - }), - timeoutMs: 15_000, - }, + const child = processes.spawn(process.execPath, [ + "--import", + preload, + worker, + ]); + const closed = new Promise((resolve) => { + child.once("close", () => resolve()); + }); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + const ready = createDeferred(); + child.stdout.on( + "data", + createCliSessionFrameReader((value: unknown) => { + const frame = z.object({ ready: z.boolean() }).parse(value); + if (frame.ready) ready.resolve(); + }), ); - expect(result.signal, result.stderr).toBeNull(); - expect(result.exitCode, result.stderr).toBe(1); - expect(result.stderr).toContain("selected CLI response frame write failed"); + await awaitAcknowledgement( + ready.promise, + closed, + "CLI worker startup", + 15_000, + ); + child.stdin.end( + encodeCliSessionFrame({ + id: 1, + arguments: ["--help"], + cwd: root, + env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" }, + }), + ); + expect(await waitForExit(child, 15_000), stderr).toBe(true); + await closed; + expect(child.signalCode, stderr).toBeNull(); + expect(child.exitCode, stderr).toBe(1); + expect(stderr).toContain("selected CLI response frame write failed"); }, ); diff --git a/tests/boundary/cli/debuggerOneShotExit.test.ts b/tests/boundary/cli/debuggerOneShotExit.test.ts new file mode 100644 index 0000000000..b5d8b949f4 --- /dev/null +++ b/tests/boundary/cli/debuggerOneShotExit.test.ts @@ -0,0 +1,423 @@ +import { chmod, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { describe, expect, onTestFinished } from "vitest"; + +import { MCP_SHUTDOWN_FAILED } from "../../../src/main/messages.js"; +import { retainedDebuggerRemediation } from "../../../src/process/ownedDebuggerCleanupError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + cliProcessEnvironment, + cliTest, +} from "../../support/cli/cliFixture.js"; +import type { + ProcessResult, + TestProcesses, +} from "../../support/process/processFixture.js"; +import { rizinTestInferiorTty } from "../providers/RizinTestInferiorTty.js"; + +/** + * Fake debuggers behind the compiled public entrypoint. Real Rizin and GDB are + * not exercised: what they do once REA's end of their pipes closes is unverified. + * Every retained mode stays alive after stdin ends, like a debugger that ignores + * EOF, so a passing test also shows that REA did not stop what it reports retained. + * A request neither fake models is noted, and that note fails the test. + */ +const fakeDebuggerPrelude = `#!${process.execPath} +const { appendFileSync } = require("node:fs"); +const note = (line) => appendFileSync(process.env.FAKE_DEBUGGER_LOG, line + "\\n"); +const mode = process.env.FAKE_DEBUGGER_MODE; +note("pid " + process.pid); +process.on("SIGTERM", () => { note("sigterm"); process.exit(0); }); +process.stdin.setEncoding("utf8"); +process.stdin.on("end", () => note("stdin-end")); +setInterval(() => {}, 1000); +const onLines = (receive) => { + let input = ""; + process.stdin.on("data", (chunk) => { + input += chunk; + for (let end = input.indexOf("\\n"); end >= 0; end = input.indexOf("\\n")) { + receive(input.slice(0, end)); + input = input.slice(end + 1); + } + }); +}; +`; + +const fakeRizin = `${fakeDebuggerPrelude} +process.stdout.write("\\0"); +let silent = false; +onLines((line) => { + if (line === "dr") process.stdout.write("rax=0x1234\\0"); + else if (line === "olj") { + if (silent) return; + note("inventory-request"); + if (mode === "unanswered") silent = true; + else process.stdout.write(mode === "clean" ? "[]\\0" : "not-json\\0"); + } else if (/^!echo __REA_(COMMAND|CLEANUP)_COMPLETE_[0-9a-f]+__$/u.test(line)) { + if (!silent) process.stdout.write(line.slice(6) + "\\n\\0"); + } else { + note("unmodeled " + line); + process.stdout.write("unmodeled\\0"); + } +}); +`; + +const fakeGdb = `${fakeDebuggerPrelude} +process.stdout.write("(gdb)\\n"); +let tty = ""; +let inventoryRequests = 0; +let ttyReads = 0; +const liveGroup = '^done,groups=[{id="i1",type="process",pid="4194305",threads=[{id="1",state="stopped"}]}]'; +onLines((line) => { + const [, token, request] = /^(\\d+)(.*)$/u.exec(line) ?? []; + const reply = (tail) => process.stdout.write(token + tail + "\\n(gdb)\\n"); + if (request === "-gdb-set auto-load off") reply("^done"); + else if (request?.startsWith("-inferior-tty-set ")) { + tty = JSON.parse(request.slice("-inferior-tty-set ".length)); + reply("^done"); + } else if (request === "-inferior-tty-show") { + ttyReads += 1; + reply('^done,inferior_tty_terminal="' + (ttyReads > 1 ? "/dev/null" : tty) + '"'); + } else if (request === '-interpreter-exec console "show version"') { + process.stdout.write('~"GNU gdb (fake)\\\\n"\\n'); + reply("^done"); + } else if (request === "-list-thread-groups --recurse 1") { + inventoryRequests += 1; + if (mode === "clean") reply("^done,groups=[]"); + else if (mode === "malformed") reply('^error,msg="unavailable"'); + else if (inventoryRequests <= 2) reply(liveGroup); + else note("inventory-request"); + } else if (request === "-gdb-exit") { + process.stdout.write(token + "^exit\\n"); + process.exit(0); + } else { + note("unmodeled " + line); + if (token !== undefined) reply('^error,msg="unmodeled request"'); + } +}); +`; + +const debuggers = { + rizin: { + environmentKey: "REA_RIZIN_COMMAND", + source: fakeRizin, + arguments: (tty: string) => [ + "debug-with-rizin", + process.execPath, + "dr", + "--inferior-tty", + tty, + "--format", + "json", + ], + operation: "rizin_debug_command", + startTool: { + name: "start_rizin_debug_session", + arguments: (tty: string) => ({ + path: process.execPath, + inferior_tty: tty, + }), + }, + }, + gdb: { + environmentKey: "REA_GDB_COMMAND", + source: fakeGdb, + arguments: (tty: string) => [ + "debug-with-gdb", + "show version", + "--inferior-tty", + tty, + "--format", + "json", + ], + operation: "gdb_console", + startTool: { + name: "start_gdb_session", + arguments: (tty: string) => ({ inferior_tty: tty }), + }, + }, +} as const; + +type DebuggerName = keyof typeof debuggers; +type FakeMode = "clean" | "malformed" | "unanswered"; + +const EXIT_DEADLINE_MS = 20_000; +const TEST_DEADLINE_MS = 2 * EXIT_DEADLINE_MS; + +const isAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +/** Launch the compiled CLI against one fake debugger, on any POSIX host. */ +const prepareLaunch = async ( + name: DebuggerName, + mode: FakeMode, + compileCache: string, +) => { + const root = await createTestTempDirectory("rea-debugger-one-shot-exit-"); + const log = join(root, "debugger.log"); + await writeFile(log, ""); + const provider = join(root, name); + await writeFile(provider, debuggers[name].source); + await chmod(provider, 0o755); + // Persistent debugger sessions are Linux-only behind this gate. These modes + // stop before any /proc read, so the same workflow also runs on macOS. + const linuxGate = join(root, "linux-gate.mjs"); + await writeFile( + linuxGate, + 'Object.defineProperty(process, "platform", { value: "linux" });\n', + ); + const notes = async (): Promise => + (await readFile(log, "utf8")).split("\n").filter(Boolean); + const debuggerPid = async (): Promise => { + const line = (await notes()).find((note) => note.startsWith("pid ")); + if (line === undefined) throw new Error("The fake debugger did not start"); + return Number(line.slice("pid ".length)); + }; + onTestFinished(async () => { + const pid = await debuggerPid().catch(() => undefined); + if (pid !== undefined && isAlive(pid)) process.kill(pid, "SIGKILL"); + expect( + (await notes()).filter((note) => note.startsWith("unmodeled ")), + ).toEqual([]); + }); + return { + notes, + debuggerPid, + command: [ + "--import", + linuxGate, + resolve("scripts/rea.mjs"), + ] as readonly string[], + env: cliProcessEnvironment( + { + [debuggers[name].environmentKey]: provider, + FAKE_DEBUGGER_LOG: log, + FAKE_DEBUGGER_MODE: mode, + }, + compileCache, + ), + }; +}; + +/** Comparing with the formatter alone would still pass if it dropped the process. */ +const expectNamesProcess = (guidance: string, pid: number): void => { + expect(guidance).toMatch(new RegExp(`\\b${String(pid)}\\b`, "u")); +}; + +const expectRetainedOutcome = ( + name: DebuggerName, + result: ProcessResult, + pid: number, +): void => { + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "cleanup_incomplete", + retryable: false, + remediation: { action: retainedDebuggerRemediation([pid]) }, + details: { + operation: debuggers[name].operation, + cleanup: "incomplete", + partial_observation: { + operation: debuggers[name].operation, + normalized_result: { completion_status: "complete" }, + }, + }, + }); + expectNamesProcess(retainedDebuggerRemediation([pid]), pid); + expect(result.stdout).toContain(`"retained_debugger_pids":[${String(pid)}]`); +}; + +/** Serve one MCP stdio client that has started a debugger session. */ +const startMcpSession = async ( + processes: TestProcesses, + name: DebuggerName, + launch: Awaited>, + tty: string, +) => { + const child = processes.spawn(process.execPath, [...launch.command, "mcp"], { + env: launch.env, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += String(chunk))); + child.stderr.on("data", (chunk: Buffer) => (stderr += String(chunk))); + const request = async ( + id: number, + method: string, + params: object, + ): Promise => { + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, + ); + await expect + .poll(() => stdout, { timeout: EXIT_DEADLINE_MS }) + .toContain(`"id":${String(id)}`); + }; + await request(1, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "debugger-one-shot-exit", version: "1" }, + }); + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`, + ); + const { startTool } = debuggers[name]; + await request(2, "tools/call", { + name: startTool.name, + arguments: startTool.arguments(tty), + }); + expect(stdout).toContain("session_id"); + return { child, stderr: () => stderr }; +}; + +describe.skipIf(process.platform === "win32")( + "debugger processes that outlive their cleanup", + () => { + cliTest.for([ + ["rizin", "malformed"], + ["rizin", "unanswered"], + ["gdb", "malformed"], + ["gdb", "unanswered"], + ] as const)( + "ends one-shot %s with a retained debugger whose cleanup is %s", + { timeout: TEST_DEADLINE_MS }, + async ([name, mode], { processes, cliCompileCache }) => { + const tty = await rizinTestInferiorTty(); + const launch = await prepareLaunch(name, mode, cliCompileCache); + + const result = await processes.run( + process.execPath, + [...launch.command, ...debuggers[name].arguments(tty)], + { env: launch.env, timeoutMs: EXIT_DEADLINE_MS }, + ); + + expect(result).toMatchObject({ exitCode: 1, signal: null }); + const pid = await launch.debuggerPid(); + expectRetainedOutcome(name, result, pid); + // REA reports the debugger as retained and did not stop it. Exiting + // closes its command stream; that is the only thing it sees from REA. + await expect + .poll(launch.notes, { timeout: EXIT_DEADLINE_MS }) + .toContain("stdin-end"); + expect(await launch.notes()).not.toContain("sigterm"); + expect(isAlive(pid)).toBe(true); + }, + ); + + cliTest.for(["rizin", "gdb"] as const)( + "keeps a one-shot %s success and its exit status when cleanup is confirmed", + { timeout: TEST_DEADLINE_MS }, + async (name, { processes, cliCompileCache }) => { + const tty = await rizinTestInferiorTty(); + const launch = await prepareLaunch(name, "clean", cliCompileCache); + + const result = await processes.run( + process.execPath, + [...launch.command, ...debuggers[name].arguments(tty)], + { env: launch.env, timeoutMs: EXIT_DEADLINE_MS }, + ); + + expect(result).toMatchObject({ exitCode: 0, signal: null }); + expect(JSON.parse(result.stdout)).toMatchObject({ + operation: debuggers[name].operation, + normalized_result: { completion_status: "complete" }, + }); + expect(result.stdout).not.toContain("retained_debugger_pids"); + const pid = await launch.debuggerPid(); + await expect + .poll(() => isAlive(pid), { timeout: EXIT_DEADLINE_MS }) + .toBe(false); + }, + ); + + cliTest.for(["rizin", "gdb"] as const)( + "ends one-shot %s with its signal status when interrupted during final cleanup", + { timeout: TEST_DEADLINE_MS }, + async (name, { processes, cliCompileCache }) => { + const tty = await rizinTestInferiorTty(); + const launch = await prepareLaunch(name, "unanswered", cliCompileCache); + + const child = processes.spawn( + process.execPath, + [...launch.command, ...debuggers[name].arguments(tty)], + { env: launch.env }, + ); + let stdout = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += String(chunk))); + child.stdin.end(); + // The unanswered request is issued by final cleanup, after the command. + await expect + .poll(launch.notes, { timeout: EXIT_DEADLINE_MS }) + .toContain("inventory-request"); + child.kill("SIGINT"); + await expect + .poll(() => child.exitCode, { timeout: EXIT_DEADLINE_MS }) + .toBe(130); + await expect + .poll(() => stdout, { timeout: EXIT_DEADLINE_MS }) + .toContain("retained_debugger_pids"); + + const pid = await launch.debuggerPid(); + expectRetainedOutcome( + name, + { exitCode: 130, signal: null, stdout, stderr: "" }, + pid, + ); + expect(await launch.notes()).not.toContain("sigterm"); + expect(isAlive(pid)).toBe(true); + }, + ); + + cliTest.for([ + ["rizin", "client disconnect"], + ["gdb", "client disconnect"], + ["rizin", "SIGTERM"], + ] as const)( + "ends the stdio MCP server holding a retained %s session on %s and names it", + { timeout: TEST_DEADLINE_MS }, + async ([name, trigger], { processes, cliCompileCache }) => { + const tty = await rizinTestInferiorTty(); + const launch = await prepareLaunch(name, "malformed", cliCompileCache); + + const { child, stderr } = await startMcpSession( + processes, + name, + launch, + tty, + ); + + if (trigger === "SIGTERM") child.kill("SIGTERM"); + else child.stdin.end(); + + await expect + .poll(() => child.exitCode, { timeout: EXIT_DEADLINE_MS }) + .toBe(1); + const pid = await launch.debuggerPid(); + // Nothing else reports these once the server has exited. + await expect + .poll(stderr, { timeout: EXIT_DEADLINE_MS }) + .toContain(`${retainedDebuggerRemediation([pid])}\n`); + expectNamesProcess(retainedDebuggerRemediation([pid]), pid); + const logs = stderr() + .split("\n") + .filter((line) => line.startsWith("{")) + .map((line): unknown => JSON.parse(line)); + for (const msg of [ + "Provider cleanup failed during server shutdown", + MCP_SHUTDOWN_FAILED, + ]) + expect(logs).toContainEqual( + expect.objectContaining({ msg, retained_debugger_pids: [pid] }), + ); + expect(await launch.notes()).not.toContain("sigterm"); + expect(isAlive(pid)).toBe(true); + }, + ); + }, +); diff --git a/tests/boundary/cli/defaultAmpRegistration.test.ts b/tests/boundary/cli/defaultAmpRegistration.test.ts new file mode 100644 index 0000000000..0531cbba8d --- /dev/null +++ b/tests/boundary/cli/defaultAmpRegistration.test.ts @@ -0,0 +1,362 @@ +import { + mkdir, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { delimiter, join } from "node:path"; +import { expect, it } from "vitest"; +import { Parser, z } from "incur"; + +import { splitIncurBuiltinArguments } from "../../../src/cliBuiltinArguments.js"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const fixture = async (initial: string) => { + const home = await createTestTempDirectory("rea-default-amp-"); + const bin = join(home, "bin"); + const log = join(home, "runner.jsonl"); + const script = join(home, "runner.cjs"); + const directory = join(home, ".config", "amp"); + const settings = join(directory, "settings.json"); + await mkdir(bin); + await mkdir(directory, { recursive: true }); + await writeFile(settings, initial); + await writeFile(log, ""); + await writeFile( + script, + `require("node:fs").appendFileSync(process.env.REA_TEST_RUNNER_LOG, +JSON.stringify(process.argv.slice(2)) + "\\n"); +if (process.env.REA_TEST_RUNNER_FAIL) { + console.error("fixture runner failure"); + process.exitCode = 23; +} else console.log("│ ✓ Fixture Client: fixture │"); +`, + ); + for (const runner of ["npx", "bun", "bunx", "pnpx"]) + await writeFile( + join(bin, process.platform === "win32" ? `${runner}.cmd` : runner), + process.platform === "win32" + ? `@echo off\r\n"%REA_TEST_NODE%" "%REA_TEST_RUNNER_SCRIPT%" %*\r\n` + : `#!/bin/sh\nexec "$REA_TEST_NODE" "$REA_TEST_RUNNER_SCRIPT" "$@"\n`, + { mode: 0o700 }, + ); + const environment: NodeJS.ProcessEnv = { + HOME: home, + USERPROFILE: home, + PATH: [bin, process.env.PATH ?? ""].join(delimiter), + REA_TEST_NODE: process.execPath, + REA_TEST_RUNNER_SCRIPT: script, + REA_TEST_RUNNER_LOG: log, + npm_config_user_agent: "npm/11 node/v24.22.1", + npm_execpath: "", + }; + for (const key of [ + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "TMPDIR", + ]) { + environment[key] = join(home, key); + await mkdir(environment[key]!); + } + return { home, log, directory, settings, environment }; +}; + +const original = `${JSON.stringify({ + "amp.mcpServers": { + rea: { command: "old", env: { KEEP_ME: "1" } }, + peer: { command: "peer" }, + }, + theme: "dark", +})}\n`; + +cliTest( + "default registration preserves Amp settings and recovery in both output modes", + async ({ cli }) => { + for (const output of [[], ["--json"]]) { + const f = await fixture(original); + for (let attempt = 0; attempt < 2; attempt += 1) { + const before = await readFile(f.settings, "utf8"); + const result = await cli.run({ + arguments: [ + "mcp", + "add", + "--command", + "fixture-server --mcp", + ...output, + ], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Amp"); + expect(JSON.parse(await readFile(f.settings, "utf8"))).toEqual({ + "amp.mcpServers": { + rea: { + command: "fixture-server", + args: ["--mcp"], + env: { KEEP_ME: "1" }, + }, + peer: { command: "peer" }, + }, + theme: "dark", + }); + const backup = result.stderr.trim().split(" retained at ")[1]!; + expect(await readFile(backup, "utf8")).toBe(before); + if (process.platform !== "win32") + expect((await stat(backup)).mode & 0o777).toBe(0o600); + expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe( + original, + ); + } + const calls = (await readFile(f.log, "utf8")).trim().split("\n"); + expect(calls.map((line) => JSON.parse(line))).toEqual( + Array.from({ length: 2 }, () => [ + "add-mcp", + "fixture-server --mcp", + "--name", + "rea", + "-y", + "-g", + ]), + ); + } + }, +); + +cliTest( + "default registration refuses invalid settings and unavailable backups before the runner", + async ({ cli }) => { + for (const initial of [ + "broken JSON\n", + "null\n", + '{"amp.mcpServers":[]}\n', + original, + ]) { + const f = await fixture(initial); + if (initial === original) await mkdir(`${f.settings}.rea.backup`); + for (const output of [[], ["--json"]]) { + const result = await cli.run({ + arguments: ["mcp", "add", ...output], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("VALIDATION_ERROR"); + if (output.length > 0) + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "VALIDATION_ERROR", + }); + expect(await readFile(f.settings, "utf8")).toBe(initial); + expect(await readFile(f.log, "utf8")).toBe(""); + expect(await readdir(f.directory)).toEqual( + initial === original + ? ["settings.json", "settings.json.rea.backup"] + : ["settings.json"], + ); + } + } + }, +); + +cliTest( + "non-Amp selections, command values and discovery do not touch Amp settings", + async ({ cli }) => { + const f = await fixture("broken JSON\n"); + for (const selection of [ + ["--agent", "claude-code"], + ["--agent=claude-code"], + ["--agent", "cursor", "--agent", "claude-code"], + ["--command", "--agent", "amp", "--agent", "claude-code"], + ["-c", "--agent", "amp", "--agent", "claude-code"], + ]) { + const result = await cli.run({ + arguments: ["mcp", "add", ...selection, "--json"], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readdir(f.directory)).toEqual(["settings.json"]); + } + const calls = (await readFile(f.log, "utf8")).trim().split("\n"); + expect(calls).toHaveLength(5); + for (const line of calls) expect(JSON.parse(line)).toContain("claude-code"); + await writeFile(f.log, ""); + for (const flag of [ + "--help", + "-h", + "--llms", + "--llms-full", + "--schema", + "--version", + ]) { + const result = await cli.run({ + arguments: ["mcp", "add", flag, "--json"], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode).toBe(flag === "--schema" ? 1 : 0); + expect(result.stdout).not.toContain("VALIDATION_ERROR"); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readdir(f.directory)).toEqual(["settings.json"]); + } + expect(await readFile(f.log, "utf8")).toBe(""); + }, +); + +cliTest( + "default runner failure retains the original settings and failure status", + async ({ cli }) => { + for (const output of [[], ["--json"]]) { + const f = await fixture(original); + const result = await cli.run({ + arguments: ["mcp", "add", ...output], + cwd: f.home, + environment: { ...f.environment, REA_TEST_RUNNER_FAIL: "1" }, + timeoutMs: 20_000, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("MCP_ADD_FAILED"); + expect(result.stdout).not.toContain("VALIDATION_ERROR"); + expect(result.stdout).not.toContain("Registered rea"); + expect(result.stderr).not.toContain("backup retained"); + expect(await readFile(f.settings, "utf8")).toBe(original); + expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe(original); + expect((await readFile(f.log, "utf8")).trim().split("\n")).toHaveLength( + 1, + ); + } + }, +); + +cliTest( + "timing globals cannot bypass Amp preservation or validation", + async ({ cli }) => { + const cases = [ + ["--no-timings", "mcp", "add"], + ["--timings=false", "mcp", "add", "--json"], + ["mcp", "--timings=true", "add"], + ["mcp", "add", "--agent", "--no-timings", "amp", "--json"], + ["mcp", "add", "--agent", "--timings=false", "amp"], + ]; + for (const arguments_ of cases) { + const f = await fixture(original); + const run = () => + cli.run({ + arguments: arguments_, + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + const registered = await run(); + expect(registered.exitCode, registered.stdout).toBe(0); + expect( + JSON.parse(await readFile(f.settings, "utf8"))["amp.mcpServers"].rea + .env, + ).toEqual({ KEEP_ME: "1" }); + expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe(original); + await writeFile(f.settings, "broken JSON\n"); + await writeFile(f.log, ""); + const rejected = await run(); + expect(rejected.exitCode).toBe(1); + expect(rejected.stdout).toContain("VALIDATION_ERROR"); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readFile(f.log, "utf8")).toBe(""); + expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe(original); + } + const f = await fixture("broken JSON\n"); + for (const arguments_ of [ + [ + "--no-timings", + "mcp", + "add", + "--agent", + "--timings=false", + "claude-code", + ], + ["--timings=false", "mcp", "add", "--help"], + ]) { + const result = await cli.run({ + arguments: arguments_, + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readdir(f.directory)).toEqual(["settings.json"]); + } + const calls = (await readFile(f.log, "utf8")).trim().split("\n"); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0]!)).toContain("claude-code"); + }, +); + +it("timing splitting matches Incur after output values and before the terminator", () => { + for (const flag of [ + "--timings", + "--no-timings", + "--timings=false", + "--timings=true", + "--timings=other", + ]) { + for (const command of [ + [flag, "mcp", "add"], + ["mcp", "add", "--agent", flag, "amp"], + ["mcp", "add", "--command", flag], + ["mcp", "add", "--", flag], + ]) { + const expected = Parser.parseGlobals( + command, + z.object({ timings: z.boolean().default(false) }), + undefined, + { validate: false }, + ).rest; + expect(splitIncurBuiltinArguments(command).command).toEqual(expected); + // The builtin output pass consumes this flag-shaped value before globals. + expect( + splitIncurBuiltinArguments(["--filter-output", flag, ...command]) + .command, + ).toEqual(expected); + } + } +}); + +cliTest( + "expanded discovery values never inspect or back up Amp settings", + async ({ cli }) => { + for (const initial of [undefined, "broken JSON\n", original]) { + const f = await fixture(initial ?? ""); + if (initial === undefined) await rm(f.settings); + for (const flag of ["--help", "--llms"]) { + const result = await cli.run({ + arguments: ["mcp", "add", `--command=${flag}`], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(result.stdout).not.toContain("VALIDATION_ERROR"); + expect(result.stdout).not.toContain("Registered rea"); + expect(result.stdout.length).toBeGreaterThan(0); + expect(result.stderr).not.toContain("backup retained"); + expect(await readdir(f.directory)).toEqual( + initial === undefined ? [] : ["settings.json"], + ); + if (initial !== undefined) + expect(await readFile(f.settings, "utf8")).toBe(initial); + expect(await readFile(f.log, "utf8")).toBe(""); + } + } + }, +); diff --git a/tests/boundary/cli/doctorRegistrationEnvironment.test.ts b/tests/boundary/cli/doctorRegistrationEnvironment.test.ts new file mode 100644 index 0000000000..888c6e186d --- /dev/null +++ b/tests/boundary/cli/doctorRegistrationEnvironment.test.ts @@ -0,0 +1,289 @@ +import { chmod, mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { elf, thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; + +/** An installation doctor admits; nothing here starts an analysis JVM. */ +const writeAdmittedGhidra = async (root: string) => { + const jdk = join(root, "jdk"); + const install = join(root, "ghidra"); + const native = join( + install, + "Ghidra/Features/Decompiler/os", + `${process.platform === "darwin" ? "mac" : "linux"}_${process.arch === "arm64" ? "arm_64" : "x86_64"}`, + "decompile", + ); + for (const path of [ + join(jdk, "bin"), + join(install, "support"), + join(native, ".."), + ]) + await mkdir(path, { recursive: true }); + await writeFile( + join(install, "Ghidra/application.properties"), + "application.version=12.1.4\napplication.java.min=21\n", + ); + await writeFile( + join(install, "support/analyzeHeadless"), + "#!/bin/sh\nexit 99\n", + { mode: 0o700 }, + ); + await writeFile( + native, + process.platform === "darwin" + ? thinMach(0xfeedfacf, process.arch === "arm64" ? 0x0100000c : 0x01000007) + : elf(2, 1, process.arch === "arm64" ? 183 : 62), + ); + await chmod(native, 0o700); + const properties = [ + "java.version = 21.0.11", + `java.home = ${jdk}`, + "sun.arch.data.model = 64", + `os.arch = ${process.arch === "arm64" ? "aarch64" : "amd64"}`, + `os.name = ${process.platform === "darwin" ? "Mac OS X" : "Linux"}`, + "", + ].join("\n"); + await writeFile( + join(jdk, "bin/java"), + `#!${process.execPath}\nprocess.stderr.write(${JSON.stringify(properties)});\n`, + { mode: 0o700 }, + ); + await writeFile(join(jdk, "bin/javac"), "#!/bin/sh\nexit 0\n", { + mode: 0o700, + }); + return { install, jdk }; +}; + +const reportSchema = z.object({ + healthy: z.boolean(), + scope_checks: z.array(z.object({ name: z.string() }).loose()), + informational_checks: z.array(z.object({ name: z.string() }).loose()), + identity: z.object({ + registrations: z.array(z.object({ client: z.string() }).loose()), + }), +}); + +const run = async ( + cli: TestCli, + arguments_: readonly string[], + environment: NodeJS.ProcessEnv, +) => { + const result = await cli.run({ + arguments: [...arguments_, "--json"], + environment, + timeoutMs: 30_000, + }); + return { result, report: reportSchema.safeParse(result.json) }; +}; + +const doctor = async ( + cli: TestCli, + scope: readonly string[], + environment: NodeJS.ProcessEnv, +) => { + const { result, report } = await run(cli, ["doctor", ...scope], environment); + if (!report.success) throw new Error(result.stdout + result.stderr); + return { exitCode: result.exitCode, report: report.data }; +}; + +const names = (checks: readonly { readonly name: string }[]) => + checks.map(({ name }) => name); + +const CHECK = "registration_environment:claude_code"; + +cliTest.skipIf(!["darwin", "linux"].includes(process.platform))( + "doctor does not let a configured terminal stand in for an unconfigured registration", + { timeout: 120_000 }, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-doctor-registration-env-"); + const home = join(root, "home"); + await mkdir(home); + const { install, jdk } = await writeAdmittedGhidra(root); + const clientEnvironment = { + HOME: home, + USERPROFILE: home, + // The host's own Ghidra or Java must not satisfy this fixture. + PATH: "/usr/bin:/bin", + HOPPER_LAUNCHER_PATH: join(root, "missing-hopper"), + REA_LOG_LEVEL: "silent", + }; + const terminal = { + ...clientEnvironment, + GHIDRA_INSTALL_DIR: install, + JAVA_HOME: jdk, + }; + const configPath = join(home, ".claude.json"); + const saved = async () => + z + .object({ + mcpServers: z.object({ + rea: z.object({ env: z.record(z.string(), z.string()) }), + }), + }) + .parse(JSON.parse(await readFile(configPath, "utf8"))).mcpServers.rea + .env; + + // Setup ran where the provider settings were not visible. + const unconfigured = await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--yes"], + clientEnvironment, + ); + expect(unconfigured.result.exitCode, unconfigured.result.stdout).toBe(0); + expect(await saved()).not.toHaveProperty("GHIDRA_INSTALL_DIR"); + + // The registration's own environment is what a client without these + // settings gives its server; it does not reach the terminal's result. + const served = await doctor(cli, ["--provider", "ghidra"], { + ...(await saved()), + HOME: home, + USERPROFILE: home, + PATH: clientEnvironment.PATH, + }); + expect(served.exitCode).toBe(1); + expect(served.report.scope_checks).toContainEqual( + expect.objectContaining({ + name: "ghidra", + ok: false, + classification: "missing_analysis_engine", + }), + ); + expect(names(served.report.scope_checks)).not.toContain(CHECK); + + for (const scope of [ + ["--provider", "ghidra"], + ["--client", "claude_code"], + ["--provider", "ghidra", "--client", "claude_code"], + ]) { + const scoped = await doctor(cli, scope, terminal); + expect(scoped.report.scope_checks, scope.join(" ")).toContainEqual( + expect.objectContaining({ + name: CHECK, + ok: true, + classification: "unknown", + detail: expect.stringContaining( + "does not record GHIDRA_INSTALL_DIR, JAVA_HOME", + ), + details: { + effective_environment: "unknown", + providers: ["ghidra"], + }, + remediation: expect.stringContaining( + "rea setup --client claude_code --skill=false --dry-run", + ), + }), + ); + // An unobserved environment is neither a failure nor a healthy match. + expect(scoped.report.healthy, scope.join(" ")).toBe(true); + expect(scoped.exitCode, scope.join(" ")).toBe(0); + expect(scoped.report.identity.registrations).toEqual([ + expect.objectContaining({ + client: "claude_code", + state: "aligned", + provider_settings: { + unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"], + differing: [], + unresolved: [], + }, + }), + ]); + } + + const unrelated = await doctor(cli, ["--skill"], terminal); + expect(names(unrelated.report.scope_checks)).not.toContain(CHECK); + expect(names(unrelated.report.informational_checks)).toContain(CHECK); + + // Doctor reads; the configuration is unchanged until setup is approved. + const before = await readFile(configPath, "utf8"); + await doctor(cli, [], terminal); + expect(await readFile(configPath, "utf8")).toBe(before); + + // The named remediation records exactly these settings and then clears it. + const planned = await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--dry-run"], + terminal, + ); + expect(planned.result.stdout).toContain(`GHIDRA_INSTALL_DIR=${install}`); + expect(planned.result.stdout).toContain(`JAVA_HOME=${jdk}`); + expect(await readFile(configPath, "utf8")).toBe(before); + const applied = await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--yes"], + terminal, + ); + expect(applied.result.exitCode, applied.result.stdout).toBe(0); + expect(await saved()).toMatchObject({ + GHIDRA_INSTALL_DIR: install, + JAVA_HOME: jdk, + }); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(before); + + const recorded = await doctor(cli, ["--provider", "ghidra"], terminal); + expect(recorded.exitCode).toBe(0); + expect(names(recorded.report.scope_checks)).not.toContain(CHECK); + expect(recorded.report.identity.registrations[0]).not.toHaveProperty( + "provider_settings", + ); + + // Another terminal selecting a different JDK spelling is reported as that. + const alias = join(root, "jdk-alias"); + await symlink(jdk, alias); + const otherTerminal = { ...terminal, JAVA_HOME: alias }; + const other = await doctor(cli, ["--provider", "ghidra"], otherTerminal); + expect(other.report.identity.registrations[0]).toMatchObject({ + provider_settings: { + unrecorded: [], + differing: ["JAVA_HOME"], + unresolved: [], + }, + }); + + // Setup's own spelling of a caller selection compares as recorded. + await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--yes"], + otherTerminal, + ); + expect(await saved()).toMatchObject({ JAVA_HOME: alias }); + const aliased = await doctor(cli, ["--provider", "ghidra"], otherTerminal); + expect(names(aliased.report.scope_checks)).not.toContain(CHECK); + }, +); + +cliTest.skipIf(!["darwin", "linux"].includes(process.platform))( + "doctor adds nothing when the terminal's provider is not available", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-doctor-registration-off-"); + const home = join(root, "home"); + await mkdir(home); + const environment = { + HOME: home, + USERPROFILE: home, + PATH: "/usr/bin:/bin", + HOPPER_LAUNCHER_PATH: join(root, "missing-hopper"), + REA_LOG_LEVEL: "silent", + }; + await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--yes"], + environment, + ); + // A provider setting that fails its own check has no green result to + // misread; that failure is the report. + const { report } = await doctor(cli, ["--provider", "ghidra"], { + ...environment, + GHIDRA_INSTALL_DIR: join(root, "missing-ghidra"), + }); + expect(report.healthy).toBe(false); + expect(names(report.scope_checks)).not.toContain(CHECK); + expect(names(report.informational_checks)).not.toContain(CHECK); + expect(report.identity.registrations[0]).not.toHaveProperty( + "provider_settings", + ); + }, +); diff --git a/tests/boundary/cli/environmentCompareCli.test.ts b/tests/boundary/cli/environmentCompareCli.test.ts index b7776f3ac9..0113e4b1a0 100644 --- a/tests/boundary/cli/environmentCompareCli.test.ts +++ b/tests/boundary/cli/environmentCompareCli.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { describe, expect } from "vitest"; +import type { CompareReport } from "../../../src/application/environment/environmentCompare.js"; import { analysisViewCaptureEvidence, environmentManifestCaptureFixture, @@ -28,6 +29,140 @@ import { import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +describe("rea environment-compare difference coverage", () => { + cliTest( + "discloses the bounded differences in ordinary and full output", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-environment-compare-coverage-", + ); + const counts = [0, 19, 20, 21, 25]; + const originals = new Map(); + for (const [name, value] of [ + ["capture", 0], + ["probe", 1], + ] as const) { + const entries = counts.map((count, index) => { + const text = JSON.stringify( + Object.fromEntries( + Array.from({ length: count }, (_, field) => [ + `field${String(field).padStart(2, "0")}`, + value, + ]), + ), + ); + return { + startedDateTime: new Date( + Date.parse("2026-10-27T20:00:00Z") + index * 1000, + ).toISOString(), + time: 0, + cache: {}, + timings: { send: 0, wait: 0, receive: 0 }, + request: { + method: "GET", + url: `https://app.example/api/config${String(count)}`, + httpVersion: "HTTP/1.1", + headers: [], + queryString: [], + cookies: [], + headersSize: -1, + bodySize: 0, + }, + response: { + status: 200, + statusText: "OK", + httpVersion: "HTTP/1.1", + headers: [], + cookies: [], + redirectURL: "", + headersSize: -1, + bodySize: -1, + content: { + mimeType: "application/json", + size: Buffer.byteLength(text), + text, + }, + }, + }; + }); + const har = join(root, `${name}.har`); + const text = JSON.stringify({ + log: { + version: "1.2", + creator: { name: "coverage-test", version: "1" }, + entries, + }, + }); + await writeFile(har, text); + originals.set(har, text); + const captured = await cli.run({ + arguments: ["inspect-web-network-capture", har, "har", "--json"], + }); + expect(captured.exitCode, captured.stderr).toBe(0); + const path = join(root, `${name}.json`); + const evidence = JSON.stringify(captured.json); + await writeFile(path, evidence); + originals.set(path, evidence); + } + const view = join(root, "view.json"); + await writeFile( + view, + JSON.stringify({ + source: { + kind: "inline", + evidence: JSON.parse(originals.get(join(root, "capture.json"))!), + }, + view: { kind: "environment-manifest" }, + }), + ); + const projected = await cli.run({ + arguments: ["inspect-analysis-view", view, "--json"], + }); + expect(projected.exitCode, projected.stderr).toBe(0); + const manifest = join(root, "manifest.json"); + const manifestText = JSON.stringify(projected.json); + await writeFile(manifest, manifestText); + originals.set(manifest, manifestText); + const args = [ + "environment-compare", + manifest, + "--evidence", + join(root, "capture.json"), + "--probe", + join(root, "probe.json"), + "--json", + ]; + const result = await cli.run({ arguments: args }); + expect(result.exitCode, result.stderr).toBe(0); + const report = result.json as CompareReport; + expect(report).toMatchObject({ + status: "complete", + summary: { steps: 5, equal: 1, categories: { value: 4 } }, + }); + expect(report.probes[0]!.results).toHaveLength(counts.length); + for (const [index, count] of counts.entries()) { + const step = report.probes[0]!.results[index]!; + expect(step).toMatchObject({ + equal: count === 0, + equal_ignoring_order: count === 0, + categories: count === 0 ? [] : ["value"], + differences_total: count, + differences_omitted: Math.max(0, count - 20), + differences_truncated: count > 20, + }); + expect(step.differences).toHaveLength(Math.min(count, 20)); + if (count >= 20) + expect(step.differences.at(-1)?.pointer).toBe("/field19"); + } + const full = await cli.run({ arguments: [...args, "--full-output"] }); + expect(full.exitCode, full.stderr).toBe(0); + expect(full.json).toMatchObject({ data: report }); + for (const [path, text] of originals) + expect(await readFile(path, "utf8")).toBe(text); + }, + ); +}); + /** The books environment and a probe of the application re-seeded under other ids, written as Evidence files. */ const writeCompareFiles = async (root: string) => { const fixture = environmentStateServeFixture(timedCaptureEvidence(BOOK_ROWS)); diff --git a/tests/boundary/cli/objdumpIdentity.test.ts b/tests/boundary/cli/objdumpIdentity.test.ts new file mode 100644 index 0000000000..82a342c3de --- /dev/null +++ b/tests/boundary/cli/objdumpIdentity.test.ts @@ -0,0 +1,71 @@ +import { expect } from "vitest"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "does not attribute a caller-selected command to GNU objdump", + async ({ cli }) => { + const result = await cli.run({ + arguments: [ + "inspect-with-objdump", + process.execPath, + "file_headers", + "--format", + "json", + ], + environment: { REA_OBJDUMP_COMMAND: process.execPath }, + }); + + expect(result.exitCode).toBe(0); + expect(result.json).toMatchObject({ + provider: { + id: "objdump.command", + name: "objdump command", + version: null, + }, + parameters: { provider_executable: process.execPath }, + normalized_result: { + exit_code: expect.any(Number), + completion_status: "complete", + }, + }); + + const architectures = await cli.run({ + arguments: ["list-objdump-architectures", "--format", "json"], + environment: { REA_OBJDUMP_COMMAND: process.execPath }, + input: "", + }); + expect(architectures.exitCode).toBe(0); + expect(architectures.json).toMatchObject({ + provider: { + id: "objdump.command", + name: "objdump command", + version: null, + }, + subject: null, + }); + + const missing = join(tmpdir(), `missing-objdump-${randomUUID()}`); + const failed = await cli.run({ + arguments: [ + "inspect-with-objdump", + process.execPath, + "file_headers", + "--format", + "json", + ], + environment: { REA_OBJDUMP_COMMAND: missing }, + }); + expect(failed.exitCode).toBe(1); + expect(failed.json).toMatchObject({ + code: "capability_unavailable", + details: { + provider_id: "objdump.command", + diagnostics: { command: missing }, + }, + }); + }, +); diff --git a/tests/boundary/cli/reverseEngineeringCommands.test.ts b/tests/boundary/cli/reverseEngineeringCommands.test.ts index 77a7ceef67..a3482c8814 100644 --- a/tests/boundary/cli/reverseEngineeringCommands.test.ts +++ b/tests/boundary/cli/reverseEngineeringCommands.test.ts @@ -250,7 +250,7 @@ it("promotes only complete production cleanup-only Evidence after CLI cleanup re const result = await runObjdump(service, path); expect(result).toMatchObject({ - provider: { id: "gnu.objdump" }, + provider: { id: "objdump.command" }, normalized_result: { completion_status: "complete" }, }); expect(producedCleanupFailure?.cleanupOnly).toBe(true); @@ -552,7 +552,16 @@ it.each(["incomplete", "interrupt", "control"] as const)( rawResult: { output: "registers" }, }, ); - const sessions = new Map([["fixture-session", { id: "fixture-session" }]]); + const sessions = new Map([ + [ + "fixture-session", + { + id: "fixture-session", + launched: { process: { pid: 4242 } }, + supervisor: { ref: vi.fn(), unref: vi.fn() }, + }, + ], + ]); vi.spyOn(RizinDebugSessionManager.prototype, "start").mockResolvedValue( ok({ session_id: "fixture-session", backend: null }), ); @@ -642,6 +651,7 @@ it.each(["incomplete", "interrupt", "control"] as const)( details: { cleanup: "incomplete", resources: ["fixture-session"], + diagnostics: { retained_debugger_pids: [4242] }, partial_observation: { operation: "rizin_debug_command", parameters: { session_id: "fixture-session" }, diff --git a/tests/boundary/cli/traceNativeValuesCompletenessCli.test.ts b/tests/boundary/cli/traceNativeValuesCompletenessCli.test.ts index ee76f1d2c8..787717e85c 100644 --- a/tests/boundary/cli/traceNativeValuesCompletenessCli.test.ts +++ b/tests/boundary/cli/traceNativeValuesCompletenessCli.test.ts @@ -3,23 +3,31 @@ import { join } from "node:path"; import { Cli } from "incur"; import pino from "pino"; import { expect, it } from "vitest"; +import { z } from "zod"; import { createAnalysisExecution, + type AnalysisClient, + type AnalysisOperationPort, type AnalysisProvider, } from "../../../src/application/AnalysisProvider.js"; import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js"; import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js"; +import { CLEANUP_STOPPED_VALUE_TRACE } from "../../../src/application/native/NativeValueTrace.js"; import { + failingValueTraceReads, valueTraceDiamond, valueTraceProvider, + VALUE_TRACE_DIAMOND_READS, } from "../../../src/application/native/NativeValueTrace.fixture.js"; import { registerCoreNativeCommands } from "../../../src/cli/coreNativeCommands.js"; import { parseConfig } from "../../../src/config/parseConfig.js"; +import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { nativeValueTraceSchema } from "../../../src/domain/native/nativeValueTrace.js"; -import { ok } from "../../../src/domain/result.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { err, ok } from "../../../src/domain/result.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -33,9 +41,11 @@ const effects = { requiresRoot: false, }; -/** One-shot CLI analysis over the shared trace with a simulated provider. */ -const traceCli = async () => { - const graph = valueTraceDiamond({ sessionSubject: true }); +/** One-shot CLI analysis over a simulated value-trace provider; returns parsed stdout. */ +const serveTraceCli = async ( + port: AnalysisOperationPort, + close: AnalysisClient["close"] = async () => ok(null), +) => { const directory = await createTestTempDirectory("rea-value-trace-cli-"); const path = join(directory, "fixture.hop"); await writeFile(path, "synthetic analysis database"); @@ -61,8 +71,8 @@ const traceCli = async () => { execute: async (operation, parameters, options) => operation === "health" ? ok(createAnalysisExecution(null, valueTraceProvider)) - : graph.port.execute(operation, parameters, options), - close: async () => ok(null), + : port.execute(operation, parameters, options), + close, }), }; const createSession = () => createTestBinarySession(provider); @@ -93,12 +103,30 @@ const traceCli = async () => { exit: () => undefined, }, ); - return nativeValueTraceSchema.parse( - parseEvidence(JSON.parse(stdout)).normalized_result, - ); + return { path, output: JSON.parse(stdout) as unknown }; }; }; +const traceCli = async () => { + const run = await serveTraceCli( + valueTraceDiamond({ sessionSubject: true }).port, + ); + return async (options: readonly string[]) => + nativeValueTraceSchema.parse( + parseEvidence((await run(options)).output).normalized_result, + ); +}; + +const cliFailureSchema = z.object({ + code: z.string(), + details: z + .object({ + resources: z.array(z.string()), + partial_observation: z.unknown(), + }) + .loose(), +}); + it("reports the CLI last page and its observed node budget separately", async () => { const trace = await traceCli(); expect( @@ -135,3 +163,66 @@ it("reports the CLI last page and its observed node budget separately", async () reason: "Node budget reached", }); }); + +it.each([ + { close: "confirmed", resources: ["owned worker"] }, + { close: "unconfirmed", resources: ["owned session", "owned worker"] }, +] as const)( + "stops a CLI trace at a callee's unconfirmed cleanup with $close final close", + async ({ close, resources }) => { + const failure = new AnalysisCapabilityUnavailableError( + valueTraceProvider.id, + "analyze_function", + "controlled child failure", + { + cleanup: { + reason: "cleanup proof refused", + resources: ["owned worker"], + }, + }, + ); + const { port, reads } = failingValueTraceReads( + valueTraceDiamond({ sessionSubject: true }).port, + { "analyze_function:0x2000": failure }, + ); + const run = await serveTraceCli( + port, + close === "confirmed" + ? async () => ok(null) + : async () => + err( + new ProviderCleanupError( + valueTraceProvider.id, + ["owned session"], + {}, + ), + ), + ); + + const { path, output } = await run(["--json"]); + + expect(reads).toEqual(VALUE_TRACE_DIAMOND_READS.slice(0, 4)); + const failed = cliFailureSchema.parse(output); + expect(failed.code).toBe("cleanup_incomplete"); + expect([...failed.details.resources].sort()).toEqual(resources); + const partial = parseEvidence(failed.details.partial_observation); + expect(partial).toMatchObject({ + operation: "trace_native_values", + caller_input_path: path, + parameters: { procedure: "0x1000", max_depth: 1 }, + }); + const admitted = nativeValueTraceSchema.parse(partial.normalized_result); + expect(admitted.procedures.map(({ address }) => address)).toEqual([ + "0x1000", + ]); + expect(admitted.traversal).toEqual({ + status: "truncated", + reasons: ["cleanup_incomplete"], + }); + expect(admitted.unknowns).toContainEqual({ + procedure: "0x2000", + address: null, + reason: CLEANUP_STOPPED_VALUE_TRACE, + }); + }, +); diff --git a/tests/boundary/evm/evmLimiterAvailability.test.ts b/tests/boundary/evm/evmLimiterAvailability.test.ts index d8ad789adf..7684146571 100644 --- a/tests/boundary/evm/evmLimiterAvailability.test.ts +++ b/tests/boundary/evm/evmLimiterAvailability.test.ts @@ -1,4 +1,7 @@ import { expect, it } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; import { buildCapabilityInventory } from "../../../src/application/CapabilityInventory.js"; @@ -41,33 +44,43 @@ it("probes the default limiter with executable and regular-file checks", async ( ]); }); -it("uses the exact absolute override and reports a failed executable check", async () => { - const limiter = "/fixture/util-linux/prlimit"; - const availability = await evmLimiterAvailability( - { REA_EVM_PRLIMIT_COMMAND: limiter }, - { - ...linux, - fs: { - access: async (path) => { - expect(path).toBe(limiter); - throw Object.assign(new Error("permission denied"), { - code: "EACCES", - }); - }, - stat: async () => { - throw new Error("stat must not run after access failure"); +it.each(["EACCES", "EPERM"])( + "uses the exact absolute override and reports denied access: %s", + async (code) => { + const limiter = "/fixture/util-linux/prlimit"; + const availability = await evmLimiterAvailability( + { REA_EVM_PRLIMIT_COMMAND: limiter }, + { + ...linux, + fs: { + access: async (path) => { + expect(path).toBe(limiter); + throw Object.assign(new Error("permission denied"), { + code, + }); + }, + stat: async () => { + throw new Error("stat must not run after access failure"); + }, }, }, - }, - ); + ); - expect(availability).toMatchObject({ - status: "unavailable", - code: "executable_missing", - reason: expect.stringContaining(limiter), - diagnostics: { limiter_path: limiter }, - }); -}); + expect(availability).toMatchObject({ + status: "unavailable", + code: "runtime_unavailable", + reason: expect.stringContaining(limiter), + diagnostics: { limiter_path: limiter, system_code: code }, + }); + if (availability === undefined) throw new Error("Expected limiter probe"); + const entry = buildCapabilityInventory(status, { + ...basePolicy, + evmLimiterAvailability: availability, + }).find(({ name }) => name === "inspect_evm_interface"); + expect(entry?.remediation).toContain("REA_EVM_PRLIMIT_COMMAND"); + expect(entry?.remediation).toContain("execute permission"); + }, +); it("rejects a relative override and non-file without probing an alternate command", async () => { const relative = await evmLimiterAvailability( @@ -84,7 +97,8 @@ it("rejects a relative override and non-file without probing an alternate comman ); expect(relative).toMatchObject({ status: "unavailable", - reason: "prlimit_not_absolute", + code: "not_configured", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: { limiter_path: "prlimit", code: "prlimit_not_absolute", @@ -104,11 +118,53 @@ it("rejects a relative override and non-file without probing an alternate comman ); expect(directory).toMatchObject({ status: "unavailable", - reason: "prlimit_not_file", + code: "not_configured", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: expect.objectContaining({ code: "prlimit_not_file" }), }); }); +it("delivers selected-path recovery from real filesystem probes to capability output", async () => { + const root = await createTestTempDirectory("rea-evm-limiter-recovery-"); + const executable = join(root, "caller-selected-limiter"); + await writeFile(executable, "not executed by the readiness probe", { + mode: 0o700, + }); + for (const [path, expectedCode, expectedReason] of [ + ["prlimit", "not_configured", "absolute executable regular file"], + [root, "not_configured", "absolute executable regular file"], + [ + join(root, "missing-limiter"), + "executable_missing", + "REA_EVM_PRLIMIT_COMMAND", + ], + [executable, null, null], + ] as const) { + const availability = await evmLimiterAvailability( + { REA_EVM_PRLIMIT_COMMAND: path }, + linux, + ); + expect(availability?.code).toBe(expectedCode); + expect(availability?.diagnostics.limiter_path).toBe(path); + if (availability === undefined) throw new Error("Expected limiter probe"); + const entry = buildCapabilityInventory(status, { + ...basePolicy, + evmLimiterAvailability: availability, + }).find(({ name }) => name === "inspect_evm_interface"); + if (expectedReason === null) { + expect(entry).toMatchObject({ available: true, remediation: null }); + } else { + expect(entry).toMatchObject({ + available: false, + reason: "provider_missing", + }); + expect(entry?.remediation).toContain(expectedReason); + expect(entry?.remediation).toContain(path); + expect(entry?.remediation).toContain("util-linux prlimit"); + } + } +}); + it("rechecks on each observation so inventory follows the selected executable's current state", async () => { let accessible = true; const fs = { diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts index 9edd10309e..1e98c38490 100644 --- a/tests/boundary/evm/evmoleInterfaceProvider.test.ts +++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts @@ -970,7 +970,7 @@ it.runIf(!unsupportedHost).each(["directory", "missing-interpreter"])( }); if (scenario === "directory") expect(result.error).toMatchObject({ - reason: "prlimit_not_file", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: { limiter_path: limiter, code: "prlimit_not_file", diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 1452dfb509..b25d603cd3 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -446,6 +446,34 @@ describe("binary target I/O: app plist permissions and decoding", () => { }); }); +describe("binary target I/O: OpenStep app plist escapes", () => { + it.each([ + '{ CFBundleExecutable = "M\\U0061in"; }', + '{ CFBundleExecutable = "M\\141in"; }', + '{ "CFBundle\\U0045xecutable" = "Main"; }', + ])( + "resolves an OpenStep bundle executable with escaped keys or values in %s", + async (plistContent) => { + const directory = await createTestTempDirectory( + "rea-app-openstep-escapes-", + ); + const app = join(directory, "Probe.app"); + const programs = join(app, "Contents", "MacOS"); + await mkdir(programs, { recursive: true }); + await writeFile(join(app, "Contents", "Info.plist"), plistContent); + await writeFile(join(programs, "Main"), "binary"); + const result = await resolveAppBundleExecutable(app); + expect(result).toEqual({ + ok: true, + value: { + executable: join(programs, "Main"), + infoPlist: join(app, "Contents", "Info.plist"), + }, + }); + }, + ); +}); + describe("binary target I/O: package classification", () => { it("classifies ZIP package families and text artifacts without Hopper", async () => { const directory = await createTestTempDirectory("rea-artifact-target-"); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 03b936b5d0..e5710daa25 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -346,6 +346,7 @@ it.each([ "tests/support/reverseEngineeringService.ts", "tests/support/ownedRizinCommandEvidenceTests.ts", "tests/support/reverseDebuggerMcpTestHelpers.ts", + "tests/boundary/cli/debuggerOneShotExit.test.ts", "tests/boundary/cli/gdbStartupCleanupCli.test.ts", "vitest.reverse-tools.config.ts", "vitest.reverse-tools-semantics.config.ts", @@ -878,7 +879,14 @@ it.each([ ["install.sh", { code: true, runtime: true, package: true, evm: false }], [ ".agents/skills/reverse-engineer-anything/SKILL.md", - { code: true, runtime: true, docs: true, package: false, evm: false }, + { + code: true, + runtime: true, + docs: true, + website: true, + package: false, + evm: false, + }, ], [ "scripts/verify-real-ghidra.mjs", @@ -896,6 +904,18 @@ it.each([ "tests/boundary/cli/nativePlistCli.test.ts", { code: true, apple: true, package: false, runtime: false }, ], + [ + "tests/boundary/process/nativeUiVerifierLane.test.ts", + { code: true, apple: true, package: false, runtime: false }, + ], + [ + "tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts", + { code: true, apple: true, package: false, runtime: false }, + ], + [ + "tests/conformance/native/ui-offscreen.swift", + { code: true, apple: true, package: false, runtime: false }, + ], [ "src/artifacts/InesArtifactReader.ts", { code: true, apple: true, package: false, runtime: true }, diff --git a/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts b/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts new file mode 100644 index 0000000000..25c3abf32c --- /dev/null +++ b/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts @@ -0,0 +1,290 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import { recordedProviderSettings } from "../../../src/application/ClientRegistrationEnvironment.js"; +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; +import { CURRENT_ENTRYPOINT_PATH } from "../../../src/process/CurrentEntrypointPath.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +beforeEach(clearClientLocationEnvironment); +afterEach(() => { + vi.unstubAllEnvs(); +}); + +const selected = { + GHIDRA_INSTALL_DIR: "/opt/ghidra", + JAVA_HOME: "/opt/jdk", +}; +const entry = (env: unknown) => + JSON.stringify({ + mcpServers: { + rea: { + command: process.execPath, + args: [CURRENT_ENTRYPOINT_PATH, "mcp"], + ...(env === undefined ? {} : { env }), + }, + }, + }); +const readClaudeCode = async (env: unknown) => { + const home = await createTestTempDirectory("rea-recorded-settings-"); + await writeFile(join(home, ".claude.json"), entry(env)); + const [status] = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {}, providerSettings: selected }, + ); + return status; +}; + +it.each([ + [ + "a registration with no environment map", + undefined, + { + unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"], + differing: [], + unresolved: [], + }, + ], + [ + "a registration recording only part of the selection", + { GHIDRA_INSTALL_DIR: "/opt/ghidra" }, + { unrecorded: ["JAVA_HOME"], differing: [], unresolved: [] }, + ], + [ + "a registration recording another value", + { GHIDRA_INSTALL_DIR: "/opt/other", JAVA_HOME: "/opt/jdk" }, + { unrecorded: [], differing: ["GHIDRA_INSTALL_DIR"], unresolved: [] }, + ], +])("reports names only for %s", async (_label, env, provider_settings) => { + expect(await readClaudeCode(env)).toMatchObject({ + client: "claude_code", + provider_settings, + }); +}); + +it.each([ + ["a non-string value", { GHIDRA_INSTALL_DIR: 1 }], + ["a peer setting the runtime parser rejects", { REA_LOG_LEVEL: "shout" }], +])( + "leaves a malformed map with %s to the invalid registration result", + async (_label, env) => { + const status = await readClaudeCode(env); + expect(status?.state).toBe("invalid"); + expect(status).not.toHaveProperty("provider_settings"); + }, +); + +it("omits the comparison when the registration records the selection", async () => { + expect(await readClaudeCode(selected)).not.toHaveProperty( + "provider_settings", + ); +}); + +it("keeps recorded values out of the status", async () => { + const status = await readClaudeCode({ + GHIDRA_INSTALL_DIR: "/recorded/secret-path", + }); + expect(status?.provider_settings?.differing).toEqual(["GHIDRA_INSTALL_DIR"]); + expect(JSON.stringify(status)).not.toContain("/recorded/secret-path"); +}); + +it("does not read the comparison for callers that did not ask", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-off-"); + await writeFile(join(home, ".claude.json"), entry(undefined)); + const [status] = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {} }, + ); + expect(status).not.toHaveProperty("provider_settings"); +}); + +it("retains the comparison on a stale launcher, which setup can still repair", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-stale-"); + await writeFile( + join(home, ".claude.json"), + JSON.stringify({ + mcpServers: { + rea: { + command: "npx", + args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"], + }, + }, + }), + ); + const [status] = await readClientRegistrationStatuses(home, "/current/rea", { + environment: {}, + providerSettings: selected, + }); + expect(status).toMatchObject({ + state: "stale", + provider_settings: { unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"] }, + }); +}); + +it("reads Codex's environment table", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-codex-"); + await mkdir(join(home, ".codex")); + await writeFile( + join(home, ".codex/config.toml"), + `[mcp_servers.rea]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(CURRENT_ENTRYPOINT_PATH)}, "mcp"]\nstartup_timeout_sec = 30\ntool_timeout_sec = 3600\n[mcp_servers.rea.env]\nGHIDRA_INSTALL_DIR = "/opt/ghidra"\n`, + ); + const [status] = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {}, providerSettings: selected }, + ); + expect(status).toMatchObject({ + client: "codex", + provider_settings: { + unrecorded: ["JAVA_HOME"], + differing: [], + unresolved: [], + }, + }); +}); + +it("reads OpenCode's differently named environment member", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-open-"); + await mkdir(join(home, ".config/opencode"), { recursive: true }); + await writeFile( + join(home, ".config/opencode/opencode.json"), + JSON.stringify({ + mcp: { + rea: { + type: "local", + command: [process.execPath, CURRENT_ENTRYPOINT_PATH, "mcp"], + environment: { JAVA_HOME: "/opt/jdk" }, + }, + }, + }), + ); + const [status] = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {}, providerSettings: selected }, + ); + expect(status).toMatchObject({ + client: "opencode", + provider_settings: { + unrecorded: ["GHIDRA_INSTALL_DIR"], + differing: [], + unresolved: [], + }, + }); +}); + +it("reads the effective view of layered OpenCode files", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-layer-"); + await mkdir(join(home, ".config/opencode"), { recursive: true }); + const layer = (environment: Readonly>) => + JSON.stringify({ + mcp: { + rea: { + type: "local", + command: [process.execPath, CURRENT_ENTRYPOINT_PATH, "mcp"], + environment, + }, + }, + }); + const override = join(home, "override.json"); + await writeFile( + join(home, ".config/opencode/opencode.json"), + layer({ JAVA_HOME: "/opt/jdk" }), + ); + await writeFile(override, layer({ GHIDRA_INSTALL_DIR: "/opt/ghidra" })); + const statuses = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { + environment: { OPENCODE_CONFIG: override }, + providerSettings: selected, + }, + ); + expect( + statuses.find(({ client }) => client === "opencode"), + ).not.toHaveProperty("provider_settings"); +}); + +it("keeps a Cordis expression unresolved instead of absent or different", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-dsh-"); + await mkdir(join(home, ".dsh")); + await writeFile( + join(home, ".dsh/cordis.patch.yml"), + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: ${JSON.stringify(process.execPath)} + args: [${JSON.stringify(CURRENT_ENTRYPOINT_PATH)}, mcp] + env: + GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR + JAVA_HOME: /opt/other +`, + ); + const statuses = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {}, platform: "linux", providerSettings: selected }, + ); + expect(statuses.find((status) => status.client === "dsh")).toMatchObject({ + provider_settings: { + unrecorded: [], + differing: ["JAVA_HOME"], + unresolved: ["GHIDRA_INSTALL_DIR"], + }, + }); +}); + +it("folds recorded environment names on Windows", () => { + const servers = { + rea: { env: { java_home: "/opt/jdk" } }, + }; + const parsed = { + document: { mcpServers: servers }, + servers, + serversPath: ["mcpServers"], + dialect: "json", + environmentKey: "env", + legacyServers: {}, + } as const; + expect(recordedProviderSettings(parsed, selected, "win32")).toEqual({ + unrecorded: ["GHIDRA_INSTALL_DIR"], + differing: [], + unresolved: [], + }); + expect(recordedProviderSettings(parsed, selected, "linux")).toEqual({ + unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"], + differing: [], + unresolved: [], + }); + expect(recordedProviderSettings(parsed, {}, "linux")).toBeUndefined(); +}); + +it("does not treat a map it could not read as absent", () => { + const servers = { rea: { env: { GHIDRA_INSTALL_DIR: 1 } } }; + expect( + recordedProviderSettings( + { + document: { mcpServers: servers }, + servers, + serversPath: ["mcpServers"], + dialect: "json", + environmentKey: "env", + legacyServers: {}, + }, + selected, + "linux", + ), + ).toEqual({ + unrecorded: [], + differing: [], + unresolved: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"], + }); +}); diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts index 40aaf9093c..3c437d9535 100644 --- a/tests/boundary/filesystem/flutterBuildProvider.test.ts +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -1237,6 +1237,31 @@ it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( }, ); +it("qualifies unlabeled Dart-shaped strings as candidates", async () => { + const candidate = "1.2.3 (stable) (unrelated string)"; + const path = await buildApk("dart-pattern.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { + path: "lib/arm64-v8a/libflutter.so", + bytes: Buffer.concat([libflutter(), Buffer.from(`\0${candidate}\0`)]), + }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (!result.ok) throw result.error; + const value = flutterResultSchemas.identify_flutter_build.parse( + result.value.result, + ); + expect(value.abis[0]?.libflutter.dart_version).toBe(candidate); + expect(result.value.limitations.join(" ")).toContain( + "a matching unrelated string may be reported", + ); +}); + it("reports omitted Dart labels independently of toolchain labels", async () => { const path = await buildApk("dart-label.apk", [ { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, diff --git a/tests/boundary/filesystem/macHopperInstallOwnership.test.ts b/tests/boundary/filesystem/macHopperInstallOwnership.test.ts index 41549a9c83..cbd60110c7 100644 --- a/tests/boundary/filesystem/macHopperInstallOwnership.test.ts +++ b/tests/boundary/filesystem/macHopperInstallOwnership.test.ts @@ -288,7 +288,9 @@ it("reports only retained Mac cleanup roots and clears the live report after exa const temporary = join(home, "owned-dmg"); const stage = join(home, "owned-stage"); await Promise.all([mkdir(temporary), mkdir(stage)]); - const host = systemMacHopperInstallHost(home, { HOME: home }); + const host = systemMacHopperInstallHost(home, { HOME: home }, () => + Promise.resolve("14.0"), + ); let allowTemporary = false; let allowStage = false; let temporaryCalls = 0; diff --git a/tests/boundary/filesystem/prReadiness.test.ts b/tests/boundary/filesystem/prReadiness.test.ts index 4d3e7273e2..8e136c9da4 100644 --- a/tests/boundary/filesystem/prReadiness.test.ts +++ b/tests/boundary/filesystem/prReadiness.test.ts @@ -30,6 +30,7 @@ const makePull = ( state: "open", draft: false, body: readyBody, + user: { id: 123456 }, head: { sha: "a".repeat(40) }, base: { ref: "main" }, ...changes, @@ -94,6 +95,30 @@ function fixture(changes: Partial = {}) { } describe("initial author declaration", () => { + it.each([76467478, 208281322, 161784452, 342580529])( + "exempts the configured author ID %s, but keeps drafts pending", + (id) => { + expect( + evaluateReadiness(makePull({ user: { id }, body: null }), template), + ).toMatchObject({ + state: "success", + description: expect.stringContaining("exempt"), + }); + expect( + evaluateReadiness( + makePull({ user: { id }, body: null, draft: true }), + template, + ).state, + ).toBe("pending"); + }, + ); + + it("does not exempt unknown or missing author identities", () => { + for (const user of [{ id: 123456 }, null]) + expect( + evaluateReadiness(makePull({ user, body: null }), template).state, + ).toBe("failure"); + }); it("accepts the filled current template and uppercase checkmarks", () => { expect(evaluateReadiness(makePull(), template).state).toBe("success"); expect( @@ -212,6 +237,35 @@ describe("initial author declaration", () => { }); describe("GitHub status boundary", () => { + it.each([76467478, 208281322, 161784452, 342580529])( + "publishes a labeled exemption for author ID %s after checking the head", + async (id) => { + const input = fixture({ user: { id }, body: null }); + expect(await runReadinessCheck(input)).toMatchObject({ + state: "success", + description: expect.stringContaining("exempt"), + }); + expect(input.paginate).toHaveBeenCalledOnce(); + expect(input.createCommitStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ state: "success", sha: input.pull.head.sha }), + ); + }, + ); + it("does not let an exempt author lend success to another PR sharing the head", async () => { + const input = fixture({ user: { id: 76467478 }, body: null }); + input.paginate.mockResolvedValue([input.pull, makePull({ number: 43 })]); + expect((await runReadinessCheck(input))?.state).toBe("failure"); + }); + + it("does not publish an exemption if the author changes during the check", async () => { + const input = fixture({ user: { id: 76467478 }, body: null }); + input.get + .mockResolvedValueOnce({ data: input.pull }) + .mockResolvedValueOnce({ + data: makePull({ user: { id: 123456 }, body: null }), + }); + expect((await runReadinessCheck(input))?.state).toBe("pending"); + }); it("publishes failure on the actual PR head when declarations are unchecked", async () => { const input = fixture({ body: readyBody.replaceAll("[x]", "[ ]") }); expect((await runReadinessCheck(input))?.state).toBe("failure"); diff --git a/tests/boundary/filesystem/referenceSourceGitMetadataExclusion.test.ts b/tests/boundary/filesystem/referenceSourceGitMetadataExclusion.test.ts new file mode 100644 index 0000000000..1f072bd554 --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceGitMetadataExclusion.test.ts @@ -0,0 +1,208 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import ignore from "ignore"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS } from "../../../src/application/ReferenceSourceImportTypes.js"; +import { createHistoricalSourceManifest } from "../../../src/domain/referenceSourceGraph.js"; +import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; + +const importTree = async ( + root: string, + secretPatterns: readonly string[] = [], + excludePaths: readonly string[] = [], +) => { + const result = await importReferenceSource({ + root, + caller: "git-metadata-test", + policy: { secretPatterns }, + excludePaths, + }); + if (!result.ok) throw result.error; + return result.value; +}; + +const fixtureGit = ( + directory: string, + configuration: string, + args: readonly string[], +) => + execFileOutput( + "git", + ["-c", `core.hooksPath=${join(configuration, "no-hooks")}`, ...args], + { + cwd: directory, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => !name.toUpperCase().startsWith("GIT_"), + ), + ), + GIT_CONFIG_GLOBAL: join(configuration, "gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + GIT_TEMPLATE_DIR: join(configuration, "no-template"), + }, + }, + ); + +it("excludes Git metadata files and directories without broadening directory-only defaults", () => { + const matcher = ignore().add([...DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS]); + for (const path of [".git", ".git/", "nested/.git", "nested/.git/"]) + expect(matcher.ignores(path), path).toBe(true); + for (const path of [".gitignore", ".github", ".gitkeep", "dist"]) + expect(matcher.ignores(path), path).toBe(false); + expect(matcher.ignores("dist/")).toBe(true); + expect(matcher.test(".git").rule?.pattern).toBe(".git"); + expect(matcher.test(".git/").rule?.pattern).toBe(".git/"); +}); + +describe.skipIf(process.platform === "win32")( + "reference source Git metadata exclusions", + () => { + it("keeps linked-worktree identities stable while retaining HEAD and ordinary-checkout provenance", async () => { + const root = await createTestTempDirectory("rea-reference-git-metadata-"); + const repository = join(root, "repository"); + await Promise.all([ + mkdir(join(root, "no-hooks")), + mkdir(join(root, "no-template")), + writeFile(join(root, "gitconfig"), ""), + ]); + await mkdir(join(repository, "src"), { recursive: true }); + await writeFile( + join(repository, "src", "main.js"), + "export const value = 7;\n", + ); + await writeFile(join(repository, ".gitignore"), "*.log\n"); + const git = (...args: string[]) => fixtureGit(repository, root, args); + await git("init", "--initial-branch=main"); + await git("add", ".gitignore", "src/main.js"); + await git( + "-c", + "user.name=REA fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "fixture", + ); + const head = (await git("rev-parse", "HEAD")).stdout.trim(); + const leftRoot = join(root, "left"); + const rightRoot = join(root, "right"); + for (const worktree of [leftRoot, rightRoot]) { + await git("worktree", "add", "--detach", worktree, head); + expect( + (await fixtureGit(worktree, root, ["status", "--porcelain"])).stdout, + ).toBe(""); + } + expect(await readFile(join(leftRoot, ".git"), "utf8")).not.toBe( + await readFile(join(rightRoot, ".git"), "utf8"), + ); + const importSelectedTree = async (source: string) => { + const graph = await importTree(source); + expect(graph.vcs).toEqual({ kind: "git", head, dirty: null }); + expect( + graph.entries + .filter(({ kind }) => kind === "file") + .map(({ path }) => path), + ).toEqual([".gitignore", "src/main.js"]); + expect( + graph.entries.some( + ({ path }) => path === ".git" || path.startsWith(".git/"), + ), + ).toBe(false); + return graph; + }; + const left = await importSelectedTree(leftRoot); + const right = await importSelectedTree(rightRoot); + const repeated = await importSelectedTree(leftRoot); + expect(createHistoricalSourceManifest(left)).toEqual( + createHistoricalSourceManifest(right), + ); + expect(createHistoricalSourceManifest(repeated)).toEqual( + createHistoricalSourceManifest(left), + ); + for (const graph of [left, right]) + expect(graph.exclusions).toEqual([ + { + path: ".git", + entry_kind: "file", + reason: "default-ignored", + pattern: ".git", + }, + ]); + const ordinary = await importSelectedTree(repository); + expect(ordinary.exclusions).toEqual([ + { + path: ".git", + entry_kind: "directory", + reason: "default-ignored", + pattern: ".git/", + }, + ]); + }); + + it.each(["file", "directory"] as const)( + "prunes nested .git %s metadata and preserves configured-secret precedence", + async (kind) => { + const root = await createTestTempDirectory("rea-reference-nested-git-"); + const nested = join(root, "nested"); + await mkdir(nested); + if (kind === "file") + await writeFile( + join(nested, ".git"), + "gitdir: /checkout-specific/metadata\n", + ); + else { + await mkdir(join(nested, ".git", "hooks"), { recursive: true }); + await writeFile( + join(nested, ".git", "hooks", "pre-commit"), + "metadata\n", + ); + } + for (const name of [ + ".gitignore", + ".github", + ".gitkeep", + "dist", + "source.js", + ]) + await writeFile(join(nested, name), ""); + for (const secretPatterns of [[], [".git"]]) { + const graph = await importTree( + root, + secretPatterns, + secretPatterns.length === 0 ? [] : ["!.git"], + ); + expect(graph.exclusions).toEqual([ + { + path: "nested/.git", + entry_kind: kind, + reason: + secretPatterns.length === 0 + ? "default-ignored" + : "configured-secret", + pattern: + secretPatterns.length === 0 && kind === "directory" + ? ".git/" + : ".git", + }, + ]); + expect( + graph.entries + .filter(({ kind }) => kind === "file") + .map(({ path }) => path), + ).toEqual([ + "nested/.github", + "nested/.gitignore", + "nested/.gitkeep", + "nested/dist", + "nested/source.js", + ]); + } + }, + ); + }, +); diff --git a/tests/boundary/filesystem/releaseNotes.test.ts b/tests/boundary/filesystem/releaseNotes.test.ts index 38a601a19d..4ec741a90c 100644 --- a/tests/boundary/filesystem/releaseNotes.test.ts +++ b/tests/boundary/filesystem/releaseNotes.test.ts @@ -528,11 +528,7 @@ it("accepts Release Please lockfile refreshes without allowing product changes", join(f.directory, "package-lock.json"), '{"lockfileVersion":3}', ); - await git(f.directory, [ - "add", - "CHANGELOG.md", - "package-lock.json", - ]); + await git(f.directory, ["add", "CHANGELOG.md", "package-lock.json"]); await git(f.directory, ["commit", "-m", "chore: release lockfiles"]); await f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]); }); diff --git a/tests/boundary/filesystem/releaseProposal.test.ts b/tests/boundary/filesystem/releaseProposal.test.ts index f952091603..234d4967d9 100644 --- a/tests/boundary/filesystem/releaseProposal.test.ts +++ b/tests/boundary/filesystem/releaseProposal.test.ts @@ -15,6 +15,7 @@ import { z } from "zod"; import { applyReleasePleasePatch, + describeReleaseProposalError, releaseProposalSearchDepth, } from "../../../scripts/lib/release-proposal.mjs"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -33,6 +34,16 @@ const silent = { trace() {}, }; +it("reports an HTTP status when a release proposal error has no message", () => { + const failure = Object.assign(new Error(""), { + name: "HttpError", + status: 504, + request: { headers: { authorization: "secret" } }, + }); + expect(describeReleaseProposalError(failure)).toBe("HttpError (HTTP 504)"); + expect(describeReleaseProposalError(new Error("broken"))).toBe("broken"); +}); + beforeAll(async () => { upstream = await import("release-please"); githubRuntime = require("release-please/build/src/github.js"); @@ -40,6 +51,84 @@ beforeAll(async () => { afterEach(() => vi.restoreAllMocks()); +it("preserves the existing overflow notes if their replacement fails", async () => { + const write = vi + .fn() + .mockRejectedValueOnce(new Error("upload failed")) + .mockResolvedValueOnce({ + data: { content: { html_url: "https://example.test/notes" } }, + }); + const getContent = vi.fn().mockResolvedValue({ + data: { type: "file", sha: "existing-blob" }, + }); + const createNewBranch = vi.fn(); + const forkBranch = vi.fn(); + const github = { + repository: { owner: "fixture", repo: "rea" }, + getBranchSha: vi.fn().mockResolvedValue("existing-branch"), + createNewBranch, + forkBranch, + octokit: { repos: { getContent, createOrUpdateFileContents: write } }, + } as unknown as Awaited>; + const update = () => + upstream.GitHub.prototype.createFileOnNewBranch.call( + github, + "release-notes.md", + "Notes: café", + "notes", + "main", + ); + await expect(update()).rejects.toThrow("upload failed"); + expect(forkBranch).not.toHaveBeenCalled(); + expect(createNewBranch).not.toHaveBeenCalled(); + await expect(update()).resolves.toBe("https://example.test/notes"); + expect(write).toHaveBeenLastCalledWith( + expect.objectContaining({ + branch: "notes", + sha: "existing-blob", + content: Buffer.from("Notes: café", "utf8").toString("base64"), + }), + ); +}); + +it("creates overflow notes on a new branch without requiring an existing file", async () => { + const write = vi.fn().mockResolvedValue({ + data: { content: { html_url: "https://example.test/notes" } }, + }); + const createNewBranch = vi.fn(); + const github = { + repository: { owner: "fixture", repo: "rea" }, + getBranchSha: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce("base-sha"), + createNewBranch, + octokit: { + repos: { + getContent: vi + .fn() + .mockRejectedValue( + Object.assign(new Error("missing"), { status: 404 }), + ), + createOrUpdateFileContents: write, + }, + }, + } as unknown as Awaited>; + await expect( + upstream.GitHub.prototype.createFileOnNewBranch.call( + github, + "release-notes.md", + "Notes", + "notes", + "main", + ), + ).resolves.toBe("https://example.test/notes"); + expect(createNewBranch).toHaveBeenCalledWith("notes", "base-sha"); + expect(write).toHaveBeenCalledWith( + expect.not.objectContaining({ sha: expect.anything() }), + ); +}); + function respond(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status, diff --git a/tests/boundary/filesystem/undeliveredSourceClosure.test.ts b/tests/boundary/filesystem/undeliveredSourceClosure.test.ts new file mode 100644 index 0000000000..1ce25fdbf2 --- /dev/null +++ b/tests/boundary/filesystem/undeliveredSourceClosure.test.ts @@ -0,0 +1,627 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it, vi } from "vitest"; + +import type { UndeliveredSourceClosure } from "../../../src/application/AnalysisEvidenceSources.js"; +import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js"; +import { + deliverCliAnalysisEvidence, + publishCliAnalysisEvidence, +} from "../../../src/cli/analysisEvidenceDelivery.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../../src/domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { createArtifactInspection } from "../../../src/domain/artifactInspection.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../../src/domain/evidence.js"; +import { jsonByteLength } from "../../../src/domain/jsonSerialization.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js"; +import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; +import { composedWorkflowFixture } from "../../fixtures/composedWorkflowEvidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const provider = { id: "fixture", name: "Fixture", version: "1" }; + +const source = (name: string, links: readonly string[] = []) => + createEvidence(undefined, provider, { + operation: "fixture_source", + parameters: { name }, + result: { name }, + evidenceLinks: [...links], + }); + +const observed = (links: readonly string[]) => + createEvidence(undefined, provider, { + operation: "fixture_observation", + parameters: {}, + result: { completed: true }, + evidenceLinks: [...links], + }); + +const cancelledWith = (observation: unknown): JsonValue => + projectAnalysisError( + new AnalysisCancelledError("fixture_observation", { + partialObservation: observation as Evidence, + }), + "invocation", + ); + +const delivery = (root: string, sources?: readonly Evidence[]) => ({ + providerId: "fixture", + operation: "fixture_observation", + signal: new AbortController().signal, + detail: "complete" as const, + evidenceOutput: join(root, "evidence.json"), + ...(sources === undefined ? {} : { sourceEvidence: sources }), +}); + +const closureOf = (output: unknown) => + (output as { details: { source_closure: UndeliveredSourceClosure } }).details + .source_closure; + +it("reports never-captured links as explicit omissions without fabricating records", async () => { + const root = await createTestTempDirectory("rea-closure-missing-"); + const held = source("held", ["ev_".padEnd(67, "a")]); + const neverCaptured = "ev_".padEnd(67, "b"); + const observation = observed([held.evidence_id, neverCaptured]); + const closure = closureOf( + await publishCliAnalysisEvidence( + err(cancelledWith(observation)), + delivery(root, [held]), + ), + ); + expect(closure.records.map((record) => record.evidence_id)).toEqual([ + held.evidence_id, + ]); + expect(parseEvidence(closure.records[0])).toEqual(held); + // The ancestor of a held record is unresolved here, not absent. + expect(closure.omitted).toEqual([ + { evidence_id: neverCaptured, reason: "not_captured" }, + { evidence_id: "ev_".padEnd(67, "a"), reason: "not_captured" }, + ]); + expect(closure).toMatchObject({ + publication: "not_published", + complete: false, + omitted_count: 2, + }); + expect(await readdir(root)).toEqual([]); +}); + +it("reports an unlinked captured source and stops at nothing linked or captured", async () => { + const root = await createTestTempDirectory("rea-closure-unlinked-"); + const selected = source("selected"); + const observation = observed([]); + expect( + closureOf( + await publishCliAnalysisEvidence( + err(cancelledWith(observation)), + delivery(root, [selected]), + ), + ).records, + ).toEqual([selected]); + + const failure = cancelledWith(observation); + expect( + await publishCliAnalysisEvidence(err(failure), delivery(root, [])), + ).toBe(failure); + // A self-reference is the observation, not one of its sources. + expect( + await publishCliAnalysisEvidence( + err(failure), + delivery(root, [observation]), + ), + ).toBe(failure); +}); + +it("leaves failures untouched unless a completed observation requested delivery", async () => { + const root = await createTestTempDirectory("rea-closure-untouched-"); + const held = source("held"); + const observation = observed([held.evidence_id]); + const requested = delivery(root, [held]); + + // Without a selected output no source closure was requested on success either. + const noOutput = cancelledWith(observation); + expect( + await publishCliAnalysisEvidence(err(noOutput), { + ...requested, + evidenceOutput: undefined, + }), + ).toBe(noOutput); + + const noObservation = projectAnalysisError( + new AnalysisCancelledError("fixture_observation"), + "invocation", + ); + expect(await publishCliAnalysisEvidence(err(noObservation), requested)).toBe( + noObservation, + ); + + const notEvidence = cancelledWith({ kind: "file-snapshot-read" }); + expect(await publishCliAnalysisEvidence(err(notEvidence), requested)).toBe( + notEvidence, + ); + + // A failed authentication cannot select which sources an observation cites. + const altered = cancelledWith({ + ...observation, + evidence_links: [], + normalized_result: { completed: false }, + }); + expect(await publishCliAnalysisEvidence(err(altered), requested)).toBe( + altered, + ); +}); + +it("bounds records and listed omissions while counting everything", async () => { + const root = await createTestTempDirectory("rea-closure-bounds-"); + const held = Array.from({ length: 200 }, (_, index) => + createEvidence(undefined, provider, { + operation: "fixture_source", + parameters: { index }, + result: { text: "x".repeat(4 * 1024) }, + }), + ); + const missing = Array.from({ length: 100 }, (_, index) => + `ev_${index.toString(16).padStart(4, "0")}`.padEnd(67, "c"), + ); + const observation = observed([ + ...held.map((record) => record.evidence_id), + ...missing, + ]); + const closure = closureOf( + await publishCliAnalysisEvidence( + err(cancelledWith(observation)), + delivery(root, held), + ), + ); + expect(closure.complete).toBe(false); + expect(closure.records.length).toBeGreaterThan(0); + expect(closure.records.length).toBeLessThan(held.length); + const expectedOmissions = held.length + missing.length; + expect(closure.omitted_count).toBe( + expectedOmissions - closure.records.length, + ); + expect(closure.omitted).toHaveLength(64); + expect(jsonByteLength(closure)).toBeLessThan(80 * 1024); +}); + +/** Above V8's default call-argument limit of about 125,000 on a forked main thread. */ +const DATA_SIZED_LINKS = 200_000; + +const digestLink = (index: number) => + `ev_${index.toString(16).padStart(64, "0")}`; + +const wideSource = (links: readonly string[]) => + createEvidence(undefined, provider, { + operation: "fixture_source", + parameters: {}, + result: { wide: true }, + evidenceLinks: links, + }); + +it.each(["final-close cancellation", "refused publication"] as const)( + "keeps the original failure when a captured source has a data-sized link set: %s", + async (failure) => { + const root = await createTestTempDirectory("rea-closure-wide-"); + const links = Array.from({ length: DATA_SIZED_LINKS }, (_, index) => + digestLink(index), + ); + const wide = wideSource(links); + const observation = observed([wide.evidence_id]); + const requested = delivery(root, [wide]); + if (failure === "refused publication") + await writeFile(requested.evidenceOutput, "original"); + const output = await publishCliAnalysisEvidence( + failure === "refused publication" + ? ok(observation) + : err(cancelledWith(observation)), + requested, + ); + expect(output).toMatchObject( + failure === "refused publication" + ? { details: { reason: "exists" } } + : { code: "cancelled" }, + ); + const closure = closureOf(output); + expect(closure.records).toEqual([]); + expect(closure.omitted_count).toBe(DATA_SIZED_LINKS + 1); + expect(closure.omitted).toHaveLength(64); + expect(closure.omitted[0]).toEqual({ + evidence_id: wide.evidence_id, + reason: "not_inlined", + }); + expect(jsonByteLength(closure)).toBeLessThan(16 * 1024); + }, + 60_000, +); + +it("reports a data-sized link set cited by the observation itself", async () => { + const root = await createTestTempDirectory("rea-closure-wide-observation-"); + const closure = closureOf( + await publishCliAnalysisEvidence( + err( + cancelledWith( + observed( + Array.from({ length: DATA_SIZED_LINKS }, (_, index) => + digestLink(index), + ), + ), + ), + ), + delivery(root), + ), + ); + expect(closure.omitted_count).toBe(DATA_SIZED_LINKS); + expect(closure.omitted).toHaveLength(64); +}, 60_000); + +it("examines repeated links once across every captured source", async () => { + const root = await createTestTempDirectory("rea-closure-dedupe-"); + const shared = source("shared"); + const repeated = Array.from( + { length: DATA_SIZED_LINKS }, + () => shared.evidence_id, + ); + const first = wideSource(repeated); + const second = createEvidence(undefined, provider, { + operation: "fixture_source", + parameters: { second: true }, + result: { wide: true }, + evidenceLinks: repeated, + }); + const closure = closureOf( + await publishCliAnalysisEvidence( + err(cancelledWith(observed([first.evidence_id, second.evidence_id]))), + delivery(root, [first, second, shared]), + ), + ); + expect(closure.records).toEqual([shared]); + expect(closure.omitted_count).toBe(2); +}, 60_000); + +it.each([ + ["a captured source", "captured"], + ["the completed observation", "observation"], +] as const)( + "keeps the original failure when examining %s throws", + async (_, thrower) => { + const root = await createTestTempDirectory("rea-closure-throws-"); + const held = source("held"); + const failing = (record: Evidence) => + Object.defineProperty({ ...record }, "evidence_links", { + enumerable: true, + get: () => { + throw new RangeError("quoted analyzed-value text"); + }, + }) as Evidence; + const observation = observed([held.evidence_id]); + const projected = cancelledWith(observation) as { + details: Record; + }; + const partial = + thrower === "observation" + ? (failing(observation) as unknown as JsonValue) + : projected.details.partial_observation!; + const failure = { + ...projected, + details: { ...projected.details, partial_observation: partial }, + } as JsonValue; + const output = (await publishCliAnalysisEvidence( + err(failure), + delivery(root, thrower === "captured" ? [failing(held)] : [held]), + )) as { + code: string; + message: string; + details: Record; + }; + expect(output.code).toBe("cancelled"); + expect(output.message).toBe((failure as { message: string }).message); + expect(output.details.partial_observation).toBe(partial); + expect(output.details.operation).toBe("fixture_observation"); + expect(output.details.source_closure).toEqual({ + publication: "not_published", + complete: false, + collection_failure: "RangeError", + recovery: expect.any(String), + }); + expect(JSON.stringify(output.details.source_closure)).not.toContain( + "quoted analyzed-value text", + ); + expect(await readdir(root)).toEqual([]); + }, +); + +it("spends the allowance on smaller later records after a record does not fit", async () => { + const root = await createTestTempDirectory("rea-closure-greedy-"); + const large = createEvidence(undefined, provider, { + operation: "fixture_source", + parameters: {}, + result: { text: "x".repeat(128 * 1024) }, + }); + const small = source("small"); + const closure = closureOf( + await publishCliAnalysisEvidence( + err( + cancelledWith(observed([large.evidence_id, small.evidence_id].sort())), + ), + delivery(root, [large, small]), + ), + ); + expect(closure.records.map((record) => record.evidence_id)).toEqual([ + small.evidence_id, + ]); + expect(closure.omitted).toEqual([ + { evidence_id: large.evidence_id, reason: "not_inlined" }, + ]); +}); + +it("keeps captured records out of error output under explicit exclusions", async () => { + const root = await createTestTempDirectory("rea-closure-sensitive-"); + const held = source("literal-secret-value"); + const output = await publishCliAnalysisEvidence( + err(cancelledWith(observed([held.evidence_id]))), + { ...delivery(root, [held]), sensitiveValues: ["literal-secret-value"] }, + ); + const closure = closureOf(output); + expect(closure.records).toEqual([]); + expect(closure.omitted).toEqual([ + { evidence_id: held.evidence_id, reason: "not_inlined" }, + ]); + expect(JSON.stringify(closure)).not.toContain("literal-secret-value"); +}); + +it("does not duplicate sources an artifact inspection already embeds", async () => { + const root = await createTestTempDirectory("rea-closure-embedded-"); + const input = join(root, "input.txt"); + await writeFile(input, "artifact contents"); + const embedded = createEvidence(undefined, provider, { + operation: "inventory_artifact", + parameters: {}, + result: await inventoryArtifact(input), + }); + const observation = createEvidence(undefined, provider, { + operation: "inspect_artifact", + parameters: {}, + result: createArtifactInspection(embedded), + evidenceLinks: [embedded.evidence_id], + }); + const output = await publishCliAnalysisEvidence( + err(cancelledWith(observation)), + { ...delivery(root), evidenceOutput: join(root, "bundle.json") }, + ); + expect(closureOf(output)).toMatchObject({ + publication: "not_published", + complete: true, + records: [], + omitted_count: 0, + }); +}); + +it("reports the closure when cancellation precedes bundle commit", async () => { + const root = await createTestTempDirectory("rea-closure-precommit-"); + const held = source("held"); + const observation = observed([held.evidence_id]); + const controller = new AbortController(); + controller.abort(new Error("cancelled before publication")); + const output = await publishCliAnalysisEvidence(ok(observation), { + ...delivery(root, [held]), + signal: controller.signal, + }); + expect(output).toMatchObject({ code: "cancelled" }); + expect(closureOf(output).records).toEqual([held]); + expect(await readdir(root)).toEqual([]); +}); + +it("reports the closure when an existing output refuses publication", async () => { + const root = await createTestTempDirectory("rea-closure-exists-"); + const held = source("held"); + const requested = delivery(root, [held]); + await writeFile(requested.evidenceOutput, "original"); + const output = await publishCliAnalysisEvidence( + ok(observed([held.evidence_id])), + requested, + ); + expect(output).toMatchObject({ details: { reason: "exists" } }); + expect(closureOf(output).records).toEqual([held]); + expect(await readFile(requested.evidenceOutput, "utf8")).toBe("original"); +}); + +it("defers to the committed bundle receipt after cancellation wins late", async () => { + const root = await createTestTempDirectory("rea-closure-committed-"); + const held = source("held"); + const controller = new AbortController(); + const close = OwnedStagedOutput.prototype.close; + const seam = vi + .spyOn(OwnedStagedOutput.prototype, "close") + .mockImplementationOnce(async function (this: OwnedStagedOutput) { + await close.call(this); + controller.abort(new Error("cancelled after commit")); + }); + const requested = { + ...delivery(root, [held]), + signal: controller.signal, + }; + let output: JsonValue; + try { + output = await publishCliAnalysisEvidence( + ok(observed([held.evidence_id])), + requested, + ); + } finally { + seam.mockRestore(); + } + expect(output).toMatchObject({ + code: "cancelled", + details: { + diagnostics: { evidence_bundle: { path: requested.evidenceOutput } }, + }, + }); + expect(output).not.toHaveProperty("details.source_closure"); +}); + +it("defers to the published file when staging cleanup stays incomplete after commit", async () => { + const root = await createTestTempDirectory("rea-closure-published-"); + const held = source("held"); + const requested = delivery(root, [held]); + const close = OwnedStagedOutput.prototype.close; + const owners = new Set(); + const seam = vi + .spyOn(OwnedStagedOutput.prototype, "close") + .mockImplementation(async function (this: OwnedStagedOutput) { + owners.add(this); + throw new Error("controlled staging cleanup failure"); + }); + let output: JsonValue; + try { + output = await publishCliAnalysisEvidence( + ok(observed([held.evidence_id])), + requested, + ); + } finally { + seam.mockRestore(); + for (const owner of owners) await close.call(owner); + } + expect(output).toMatchObject({ + code: "cleanup_incomplete", + details: { published_path: requested.evidenceOutput }, + }); + expect(output).not.toHaveProperty("details.source_closure"); +}); + +it("does not change ordinary successful delivery", async () => { + const root = await createTestTempDirectory("rea-closure-success-"); + const held = source("held"); + const observation = observed([held.evidence_id]); + const requested = delivery(root, [held]); + const delivered = await publishCliAnalysisEvidence( + ok(observation), + requested, + ); + expect(JSON.stringify(delivered)).toBe(JSON.stringify(observation)); + const withoutOutput = await publishCliAnalysisEvidence(ok(observation), { + ...requested, + evidenceOutput: undefined, + }); + expect(JSON.stringify(withoutOutput)).toBe(JSON.stringify(observation)); +}); + +it.each(["interrupt", "cleanup-failure", "interrupt-cleanup-failure"] as const)( + "states the sources a production direct analysis never captured: %s", + async (scenario) => { + const workflow = await composedWorkflowFixture(); + const controller = new AbortController(); + const close = workflow.session.close.bind(workflow.session); + workflow.session.close = async () => { + // The fixture's own final close retries this owner after the command. + workflow.session.close = close; + if (scenario !== "cleanup-failure") controller.abort(); + const closed = await close(); + if (scenario === "interrupt" || !closed.ok) return closed; + return err( + new ProviderCleanupError( + "fixture", + ["owned-root"], + { reason: "controlled session close failure" }, + { operation: "close_binary" }, + ), + ); + }; + const root = await createTestTempDirectory("rea-closure-direct-"); + const complete = await runDirectAnalysis( + workflow.dependencies, + workflow.path, + "binary_overview", + {}, + { signal: controller.signal }, + ); + const output = await publishCliAnalysisEvidence(complete, { + ...delivery(root), + signal: controller.signal, + }); + const failure = output as { + code: string; + details: { partial_observation: unknown }; + }; + expect(failure.code).toBe( + scenario === "interrupt" ? "cancelled" : "cleanup_incomplete", + ); + const links = parseEvidence( + failure.details.partial_observation, + ).evidence_links; + expect(links.length).toBeGreaterThan(0); + expect(closureOf(output)).toMatchObject({ + publication: "not_published", + complete: false, + records: [], + omitted_count: links.length, + omitted: links.map((evidence_id) => ({ + evidence_id, + reason: "not_captured", + })), + }); + expect(await readdir(root)).toEqual([]); + }, +); + +it("reports through the shared one-shot delivery when cancellation arrives during close", async () => { + const root = await createTestTempDirectory("rea-closure-deliver-"); + const held = source("held"); + const uncaptured = "ev_".padEnd(67, "d"); + const observation = observed([held.evidence_id, uncaptured]); + const controller = new AbortController(); + const output = await deliverCliAnalysisEvidence({ + ...delivery(root, [held]), + signal: controller.signal, + run: async () => ok(observation), + service: { + close: async () => { + controller.abort(new Error("cancelled during final close")); + }, + }, + }); + expect(output).toMatchObject({ + code: "cancelled", + details: { partial_observation: { evidence_id: observation.evidence_id } }, + }); + expect(closureOf(output)).toMatchObject({ + complete: false, + records: [held], + omitted: [{ evidence_id: uncaptured, reason: "not_captured" }], + }); + expect(await readdir(root)).toEqual([]); +}); + +it("reports the closure when delivered view projection refuses a completed observation", async () => { + const root = await createTestTempDirectory("rea-closure-view-"); + const held = source("held"); + const observation = observed([held.evidence_id]); + const output = await publishCliAnalysisEvidence(ok(observation), { + ...delivery(root, [held]), + detail: "summary", + // A view applying to another provider's facts cannot be selected here. + view: { kind: "native", facet: "pseudocode", offset: 0, limit: 1 }, + }); + expect(closureOf(output).records).toEqual([held]); + expect(await readdir(root)).toEqual([]); +}); + +it("leaves a primary failure without a completed observation untouched", async () => { + const root = await createTestTempDirectory("rea-closure-primary-"); + const held = source("held"); + const primary = projectAnalysisError( + new AnalysisOutputError("fixture_observation", "primary reply failure"), + "invocation", + ); + expect( + await publishCliAnalysisEvidence(err(primary), delivery(root, [held])), + ).toBe(primary); +}); diff --git a/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts b/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts new file mode 100644 index 0000000000..c56840195b --- /dev/null +++ b/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "vitest"; +import { reconcileJavaScriptRuntimeEvidence } from "../../../src/application/javascript/JavaScriptRuntimeReconciliationService.js"; +import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { nodeReconciliationFixture } from "../../fixtures/nodeRuntimeReconciliation.js"; + +test.each([false, true])( + "matches a synthetic Node entry target without inventing script presence: %s", + (scriptObserved) => { + const reconciled = reconcileJavaScriptRuntimeEvidence( + nodeReconciliationFixture(scriptObserved), + ); + if (!reconciled.ok) throw reconciled.error; + const result = javascriptRuntimeReconciliationResultSchema.parse( + reconciled.value.evidence.normalized_result, + ); + const target = result.reconciliations.find( + ({ entity_kind }) => entity_kind === "target", + ); + expect(target).toMatchObject({ + status: "matched", + basis: "artifact-path", + confidence: "medium", + reason: "unique-location-match", + candidate_static_count: 1, + }); + expect( + result.graph.nodes.find( + ({ node_id }) => node_id === target?.static_node_id, + )?.kind, + ).toBe("javascript-asset"); + expect(result.summary).toMatchObject({ + runtime_scripts: scriptObserved ? 1 : 0, + static_loaded: scriptObserved ? 1 : 0, + static_not_observed: 0, + }); + }, +); diff --git a/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts b/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts index ef1c99e458..11b78f9819 100644 --- a/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts +++ b/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts @@ -1,7 +1,9 @@ import { createHash } from "node:crypto"; import { expect, it, vi } from "vitest"; +import { z } from "zod"; const heap = vi.hoisted(() => ({ + usedBytes: 1024 * 1024, remainingBytes: 200 * 1024 * 1024, readings: [] as number[], })); @@ -13,9 +15,9 @@ vi.mock("node:v8", async (load) => { const current = actual.getHeapStatistics(); return { ...current, + used_heap_size: heap.usedBytes, heap_size_limit: - current.used_heap_size + - (heap.readings.shift() ?? heap.remainingBytes), + heap.usedBytes + (heap.readings.shift() ?? heap.remainingBytes), }; }, }; @@ -37,32 +39,56 @@ vi.mock( import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js"; import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; -const file = (path: string, count: number): JavaScriptArtifactFile => { +const sourceText = (text: string) => ({ + sha256: createHash("sha256").update(text).digest("hex"), + bytes: Buffer.byteLength(text), + text: { included: true as const, value: text }, +}); +const file = (path: string, count: number): JavaScriptArtifactFile => ({ + path, + container_sha256: "a".repeat(64), + inventory_artifact_id: path, + kind: "javascript", + unpacked: false, + ...sourceText( + Array.from( + { length: count }, + (_, i) => `localStorage.setItem('k${i}', 'v');`, + ).join("\n"), + ), +}); +const expectCapacityRemains = ( + result: Awaited>, +): void => { + if ( + !result.ok && + result.error.error instanceof AnalysisResourceConstraintError + ) + expect( + result.error.error.reportedLimits?.parent_result_budget_exhausted, + ).not.toBe(true); +}; +const functions = (count: number): JavaScriptArtifactFile => { const text = Array.from( { length: count }, - (_, i) => `localStorage.setItem('k${i}', 'v');`, + (_, i) => `export function f${i}(a) { const b = a + ${i}; return b; }`, ).join("\n"); - return { - path, - container_sha256: "a".repeat(64), - sha256: createHash("sha256").update(text).digest("hex"), - bytes: Buffer.byteLength(text), - inventory_artifact_id: path, - kind: "javascript", - unpacked: false, - text: { included: true, value: text }, - }; + return { ...file("functions.js", 0), ...sourceText(text) }; }; +// The mocked heap leaves a 4 MiB result budget and 6 MiB of static capacity. +// A 50-line source reserves about 1.1 MiB of relationships; each later 500-line +// source is charged about 0.5 MiB, so reclamation starts at the eleventh. +const SOURCES_UNTIL_RECLAMATION = 12; it("uses one live heap observation for revocation and static admission", async () => { const run = async (changeHeapAt?: number) => { const worker = new JavaScriptAnalysisWorker( { heapMb: 512, timeoutMs: 30_000 }, undefined, - 8, + SOURCES_UNTIL_RECLAMATION, ); const results = []; try { - for (let i = 0; i < 8; i++) { + for (let i = 0; i < SOURCES_UNTIL_RECLAMATION; i++) { const index = i; results.push( await worker.analyze( @@ -108,15 +134,15 @@ it("uses one live heap observation for revocation and static admission", async ( ).not.toBe(true); }); -it("reclaims prior unmaterialized projections only when a complete static checkpoint would otherwise exhaust the result budget", async () => { +it("reclaims prior unmaterialized projections only when a complete static checkpoint would otherwise exhaust the static capacity", async () => { const worker = new JavaScriptAnalysisWorker( { heapMb: 512, timeoutMs: 30_000 }, undefined, - 8, + SOURCES_UNTIL_RECLAMATION, ); const results = []; try { - for (let i = 0; i < 8; i++) + for (let i = 0; i < SOURCES_UNTIL_RECLAMATION; i++) results.push( await worker.analyze(file(`${i}.js`, i === 0 ? 50 : 500), { nodes: 1, @@ -149,13 +175,13 @@ it("keeps earlier projections when reclaiming them cannot admit the checkpoint", const worker = new JavaScriptAnalysisWorker( { heapMb: 512, timeoutMs: 30_000 }, undefined, - 5, + 6, ); try { const results = []; - for (let i = 0; i < 5; i++) + for (let i = 0; i < 6; i++) results.push( - await worker.analyze(file(`${i}.js`, i === 0 ? 10 : 1000), { + await worker.analyze(file(`${i}.js`, i === 0 ? 10 : 1400), { nodes: 1, unknowns: 1, }), @@ -173,9 +199,136 @@ it("keeps earlier projections when reclaiming them cannot admit the checkpoint", if (last === undefined || last.ok) throw new Error("Expected capacity refusal"); expect(last.error.error).toMatchObject({ - reportedLimits: { parent_result_budget_exhausted: true }, + reportedLimits: { + parent_result_budget_bytes: 4 * 1024 * 1024, + parent_static_capacity_bytes: 6 * 1024 * 1024, + parent_result_budget_exhausted: true, + }, + }); + const { + static_retention_estimate_bytes: required, + parent_available_static_capacity_bytes: available, + } = z + .object({ + static_retention_estimate_bytes: z.number(), + parent_available_static_capacity_bytes: z.number(), + }) + .parse( + last.error.error instanceof AnalysisResourceConstraintError + ? last.error.error.reportedLimits + : null, + ); + expect(required).toBeGreaterThan(available); + } finally { + await worker.close(); + } +}); + +it("retains static facts past the result budget, keeping earlier relationships while the static capacity lasts", async () => { + const sources = 9; + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + sources, + ); + try { + const details = []; + for (let i = 0; i < sources; i++) { + const result = await worker.analyze(file(`${i}.js`, i === 0 ? 50 : 500), { + nodes: 1, + unknowns: 1, + }); + expectCapacityRemains(result); + details.push(result.ok ? result.value : result.error); + } + expect(details.map((detail) => detail.javascript?.storage.length)).toEqual( + details.map((_, i) => (i === 0 ? 50 : 500)), + ); + expect( + details.flatMap((detail) => detail.revokedStaticProjections ?? []), + ).toEqual([]); + // Relationships that do not fit the budget are refused, explicitly. + expect(details[0]?.applicationProjectionFailure).toBeUndefined(); + for (const detail of details.slice(1)) + expect(detail.applicationProjectionFailure).toMatchObject({ + reason: expect.stringContaining("exceed the parent's result budget"), + }); + } finally { + await worker.close(); + } +}); + +it("retains static facts for later sources after semantic results use the result budget", async () => { + const sources = 4; + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + sources, + ); + try { + // Semantic results are already adopted, so nothing can be reclaimed from them. + const first = await worker.analyze(functions(10), { + nodes: 20_000, + unknowns: 20_000, + }); + if (!first.ok) throw first.error.error; + expect(first.value.module?.callables).toHaveLength(10); + expect(first.value.projection).not.toBeNull(); + const later = []; + for (let i = 1; i < sources; i++) { + const result = await worker.analyze(file(`${i}.js`, 1000), { + nodes: 20_000, + unknowns: 20_000, + }); + expectCapacityRemains(result); + later.push(result.ok ? result.value : result.error); + } + expect(later.map((detail) => detail.javascript?.storage.length)).toEqual( + later.map(() => 1000), + ); + expect( + later.flatMap((detail) => detail.revokedStaticProjections ?? []), + ).toEqual([]); + } finally { + await worker.close(); + } +}); + +it("stays within the result budget when the live heap has no room for another copy", async () => { + // Each later checkpoint is charged about 1 MiB, against 8 MiB of live room. + heap.usedBytes = 7.5 * 1024 * 1024; + const sources = 9; + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + sources, + ); + try { + const results = []; + for (let i = 0; i < sources; i++) + results.push( + await worker.analyze(file(`${i}.js`, i === 0 ? 10 : 1000), { + nodes: 1, + unknowns: 1, + }), + ); + const stopped = results + .flatMap((result) => (result.ok ? [] : [result.error.error])) + .find( + (error) => + error instanceof AnalysisResourceConstraintError && + error.reportedLimits?.parent_result_budget_exhausted === true, + ); + expect(stopped).toMatchObject({ + reportedLimits: { + parent_result_budget_bytes: 4 * 1024 * 1024, + parent_static_capacity_bytes: 4 * 1024 * 1024, + parent_used_heap_bytes: 7.5 * 1024 * 1024, + parent_result_budget_exhausted: true, + }, }); } finally { + heap.usedBytes = 1024 * 1024; await worker.close(); } }); @@ -192,9 +345,11 @@ it("resets projection reservations and source indices when an idle worker is reu unknowns: 1, }); expect(first.ok).toBe(true); - expect(worker.prepareForAnotherAnalysis(8)).toBe(true); + expect(worker.prepareForAnotherAnalysis(SOURCES_UNTIL_RECLAMATION)).toBe( + true, + ); const results = []; - for (let i = 0; i < 7; i++) + for (let i = 0; i < SOURCES_UNTIL_RECLAMATION; i++) results.push( await worker.analyze(file(`new-${i}.js`, i === 0 ? 50 : 500), { nodes: 1, diff --git a/tests/boundary/mcp/batchDecompileViewsMcp.test.ts b/tests/boundary/mcp/batchDecompileViewsMcp.test.ts new file mode 100644 index 0000000000..ecbe90b383 --- /dev/null +++ b/tests/boundary/mcp/batchDecompileViewsMcp.test.ts @@ -0,0 +1,870 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { afterAll, expect, it, onTestFinished, vi } from "vitest"; +import { z } from "zod"; +import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "../../../src/contracts/analysisViewToolContracts.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { jsonObjectSchema } from "../../../src/domain/jsonValue.js"; +import { batchDecompileResultSchema } from "../../../src/domain/native/batchDecompile.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { GhidraSessionError } from "../../../src/ghidra/GhidraSessionError.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + closeEnhancedToolResources, + connect as connectFixedAnalysis, +} from "./enhancedToolsHarness.js"; +import { connectGhidraMcp, ghidraFixtureResult } from "./ghidraMcpHarness.js"; + +const MAIN_BODY = "int main(void) { return 0; }"; +const EMOJI_BODY = `int emoji(void) { return "${"\u{1F600}".repeat(4)}"; }`; +const NO_BODY_REASON = "Thunk has no decompilable body."; + +/** Selectors exercising a duplicate, an interior address, no body and no identity. */ +const SELECTORS = ["main", "0x401005", "main", "emoji", "bodyless", "missing"]; +const ENTRIES: Readonly> = { + main: "0x401000", + "0x401005": "0x401000", + emoji: "0x402000", + bodyless: "0x403000", + unlabeled: "0x404000", + "label-failure": "0x405000", +}; +const LABELS: Readonly> = { + "0x401000": "main", + "0x402000": "emoji", + "0x403000": "bodyless", +}; +const BODIES: Readonly> = { + "0x401000": MAIN_BODY, + "0x402000": EMOJI_BODY, +}; + +const provider = (calls: { count: number }, bodies: typeof BODIES) => + ((operation, input) => { + calls.count++; + if (operation === "procedure_address") { + const selector = String(input.procedure); + const entry = ENTRIES[selector]; + return Promise.resolve( + entry === undefined + ? err( + new GhidraSessionError( + "remote", + `no function: ${selector}`, + {}, + { remoteCode: "not_found" }, + ), + ) + : ok(entry), + ); + } + if (operation === "address_name") + return Promise.resolve( + input.address === "0x405000" + ? err(new GhidraSessionError("remote", "label lookup failed")) + : ok(LABELS[String(input.address)] ?? null), + ); + if (operation === "procedure_pseudo_code") { + const body = bodies[String(input.procedure)]; + return Promise.resolve( + ok( + body === undefined + ? { value: null, limitations: [NO_BODY_REASON] } + : { value: body, limitations: [] }, + ), + ); + } + return Promise.resolve(ok(ghidraFixtureResult(operation, input))); + }) satisfies NonNullable[1]>; + +const [inspectAnalysisViewContract] = ANALYSIS_VIEW_TOOL_CONTRACTS.filter( + ({ name }) => name === "inspect_analysis_view", +); +if (inspectAnalysisViewContract === undefined) + throw new Error("Missing inspect_analysis_view contract"); + +const schemaValidation = createMcpSchemaValidation(); +afterAll(() => schemaValidation.clear()); + +/** Replies these tools deliver must validate against what they advertise. */ +const ADVERTISED_OUTPUTS = ["batch_decompile", "inspect_analysis_view"]; + +const connect = async ( + profile: "full" | "compact", + options: { + readonly budget?: number; + readonly bodies?: typeof BODIES; + readonly execute?: Parameters[1]; + } = {}, +) => { + const calls = { count: 0 }; + const harness = await connectGhidraMcp( + `batch-decompile-views-${profile}`, + options.execute ?? provider(calls, options.bodies ?? BODIES), + { + environment: { + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + REA_MCP_OUTPUT_SCHEMA_PROFILE: profile, + }, + schemaValidator: schemaValidation, + ...(options.budget === undefined + ? {} + : { delivery: createToolResultDelivery(options.budget) }), + }, + ); + onTestFinished(() => harness.close()); + const { tools } = await harness.mcp.listTools(); + const outputSchemas = new Map( + ADVERTISED_OUTPUTS.map((name) => { + const advertised = tools.find((tool) => tool.name === name)?.outputSchema; + if (advertised === undefined) + throw new Error(`${name} advertises no output schema`); + const schema: Record = advertised; + return [name, schema] as const; + }), + ); + const reply = async (name: string, args: Record) => + z + .custom() + .parse(await harness.mcp.callTool({ name, arguments: args })); + const call = async (name: string, args: Record) => { + const result = await reply(name, args); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + const schema = outputSchemas.get(name); + if (schema !== undefined) + expect( + schemaValidation.assertions.validate(schema, result.structuredContent), + JSON.stringify(schemaValidation.assertions.errors), + ).toBe(true); + return { evidence: parseEvidence(result.structuredContent), result }; + }; + return { ...harness, calls, reply, call }; +}; + +type Harness = Awaited>; + +const viewOf = (evidence: Evidence) => + z.looseObject({}).parse(evidence.normalized_result); + +const retained = (evidenceId: string) => ({ + kind: "retained-evidence", + evidence_id: evidenceId, +}); + +const view = async ( + { call }: Harness, + parentId: string, + selected: Record, +) => + viewOf( + ( + await call("inspect_analysis_view", { + source: retained(parentId), + view: { kind: "batch-decompile", ...selected }, + }) + ).evidence, + ); + +const rowIndices = (page: Record) => + z + .array(z.looseObject({ index: z.number() })) + .parse(page.items) + .map(({ index }) => index); + +/** Read every text page of one retained body in order. */ +const readBody = async ( + harness: Harness, + parentId: string, + index: number, + limit?: number, +) => { + let text = ""; + let pages = 0; + let offset: number | null = 0; + while (offset !== null) { + const page = await view(harness, parentId, { + collection: "pseudocode", + index, + offset, + ...(limit === undefined ? {} : { limit }), + }); + text += z.looseObject({ text: z.string() }).parse(page.item).text; + offset = z + .looseObject({ next_offset: z.number().nullable() }) + .parse(page.coverage).next_offset; + pages++; + } + return { text, pages }; +}; + +/** A typed refusal whose issue names the rejected selection. */ +const invalidIssue = (message: string) => ({ + code: "invalid_request", + details: { + issues: expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining(message) }), + ]), + }, +}); + +it.each(["full", "compact"] as const)( + "selects retained batch rows and bodies from a summary without repeating analysis (%s)", + async (profile) => { + const harness = await connect(profile); + const reply = await harness.call("batch_decompile", { + addresses: SELECTORS, + detail: "summary", + }); + const summary = viewOf(reply.evidence); + const parentId = z.string().parse(summary.parent_evidence_id); + const parent = parseEvidence(harness.session.evidenceById(parentId)); + + // The selection never reaches the record, so the parent is the same analysis. + expect(parent.parameters).toEqual({ addresses: SELECTORS }); + expect( + batchDecompileResultSchema.parse(parent.normalized_result), + ).toMatchObject({ total: 6, succeeded: 4, failed: 2 }); + expect(JSON.stringify(reply.result)).not.toContain("int main"); + expect(reply.evidence.evidence_links).toEqual([parentId]); + expect(reply.evidence.limitations).toEqual( + expect.arrayContaining(parent.limitations), + ); + expect(summary).toMatchObject({ + kind: "summary", + parent_operation: "batch_decompile", + summary: { + batch: { total: 6, succeeded: 4, failed: 2 }, + source_provider: parent.provider, + source_analysis_profile_digest: parent.analysis_profile?.digest, + decompiling_provider: { id: "ghidra", version: "12.1.4" }, + procedures: { + distinct_resolved: 3, + unknown_identity: 1, + label_unavailable: 0, + }, + pseudocode: { + code_units: MAIN_BODY.length * 3 + EMOJI_BODY.length, + largest: { index: 3, code_units: EMOJI_BODY.length }, + }, + }, + }); + expect(summary.unknowns).toEqual( + expect.arrayContaining([ + expect.stringContaining("1 items have unknown procedure identity"), + expect.stringContaining("not attributed to one successful item"), + ]), + ); + const callsAfterBatch = harness.calls.count; + + // The same summary is reachable later through the retained parent. + expect( + viewOf( + ( + await harness.call("inspect_analysis_view", { + source: retained(parentId), + view: { kind: "summary" }, + }) + ).evidence, + ).summary, + ).toEqual(summary.summary); + + // The default page is an index without bodies. + const index = await view(harness, parentId, { collection: "items" }); + expect(JSON.stringify(index.items)).not.toContain("int main"); + expect(index).toMatchObject({ + batch: { total: 6, succeeded: 4, failed: 2 }, + coverage: { total: 6, exhausted: true }, + }); + expect(index.items).toMatchObject([ + { + index: 0, + address: "main", + procedure: { status: "resolved", address: "0x401000", name: "main" }, + status: "ok", + pseudocode_code_units: MAIN_BODY.length, + }, + { index: 1, address: "0x401005" }, + { index: 2, address: "main" }, + { index: 3, address: "emoji" }, + { + index: 4, + status: "error", + error: { code: "capability_unavailable" }, + limitations: expect.arrayContaining([NO_BODY_REASON]), + }, + { index: 5, procedure: { status: "unknown" } }, + ]); + + // Filters are exact, compose, and keep original indices. + for (const [where, indices] of [ + [{ addresses: ["main"] }, [0, 2]], + [{ procedure_addresses: ["0x401000"] }, [0, 1, 2]], + // A selector never matches as an observed entry, including an unknown one. + [{ procedure_addresses: ["missing"] }, []], + [{ statuses: ["error"] }, [4, 5]], + [{ indices: [5, 3, 3] }, [3, 5]], + [{ statuses: ["ok"], procedure_addresses: ["0x402000"] }, [3]], + [{ indices: [] }, []], + [{ addresses: [] }, []], + [{ procedure_addresses: [] }, []], + [{ statuses: [] }, []], + ] as const) + expect( + rowIndices( + await view(harness, parentId, { collection: "items", where }), + ), + JSON.stringify(where), + ).toEqual(indices); + + // Coverage counts matches rather than the whole batch. + const paged = await view(harness, parentId, { + collection: "items", + where: { statuses: ["ok"] }, + offset: 1, + limit: 2, + }); + expect(rowIndices(paged)).toEqual([1, 2]); + expect(paged.coverage).toMatchObject({ + status: "page", + examined: 2, + total: 4, + next_offset: 3, + exhausted: false, + }); + + // Bodies are opt-in, and only successful items have one. + const selected = await view(harness, parentId, { + collection: "items", + where: { indices: [3, 4] }, + include_pseudocode: true, + }); + expect(selected.items).toMatchObject([ + { index: 3, pseudocode: EMOJI_BODY }, + { index: 4, status: "error" }, + ]); + expect(JSON.stringify(selected.items)).not.toContain('"pseudocode":null'); + + expect(harness.calls.count).toBe(callsAfterBatch); + expect(harness.session.evidenceById(parentId)).toEqual(parent); + }, +); + +it("counts both forms of an unavailable entry label", async () => { + const harness = await connect("full"); + const parent = ( + await harness.call("batch_decompile", { + addresses: ["main", "unlabeled", "label-failure"], + }) + ).evidence.evidence_id; + const summary = viewOf( + ( + await harness.call("inspect_analysis_view", { + source: retained(parent), + view: { kind: "summary" }, + }) + ).evidence, + ); + expect(summary.summary).toMatchObject({ + procedures: { + distinct_resolved: 3, + unknown_identity: 0, + label_unavailable: 2, + }, + }); + const { items } = await view(harness, parent, { collection: "items" }); + expect(items).toMatchObject([ + { procedure: { name: "main" } }, + { procedure: { name: null } }, + { procedure: { name: null, name_error: { code: expect.any(String) } } }, + ]); + expect(JSON.stringify(z.array(z.unknown()).parse(items)[1])).not.toContain( + "name_error", + ); +}); + +it("pages one body exactly without splitting a Unicode character", async () => { + const harness = await connect("full"); + const { evidence } = await harness.call("batch_decompile", { + addresses: SELECTORS, + }); + const parent = evidence.evidence_id; + // The complete delivery still carries every body by default. + expect(JSON.stringify(evidence.normalized_result)).toContain("int main"); + + // A limit ending inside a surrogate pair stops before it, so pages rebuild the text. + const { text, pages } = await readBody(harness, parent, 3, 30); + expect(text).toBe(EMOJI_BODY); + expect(pages).toBeGreaterThan(1); + expect( + await view(harness, parent, { collection: "pseudocode", index: 3 }), + ).toMatchObject({ + item: { + index: 3, + address: "emoji", + procedure: { status: "resolved", address: "0x402000" }, + pseudocode_code_units: EMOJI_BODY.length, + unit: "utf16-code-units", + }, + coverage: { total: EMOJI_BODY.length, exhausted: true }, + }); + + const refused = async (selected: Record) => + parseMcpToolError( + await harness.reply("inspect_analysis_view", { + source: retained(parent), + view: { + kind: "batch-decompile", + collection: "pseudocode", + ...selected, + }, + }), + ).error; + expect(await refused({ index: 4 })).toMatchObject( + invalidIssue("Item 4 has status error"), + ); + expect(await refused({ index: 6 })).toMatchObject( + invalidIssue("outside this batch of 6"), + ); + expect( + await refused({ index: 3, offset: EMOJI_BODY.indexOf("\u{1F600}") + 1 }), + ).toMatchObject(invalidIssue("splits a UTF-16 surrogate pair")); +}); + +it("recovers oversized batches and pages through smaller selections of the parent", async () => { + const budget = 64 * 1024; + const large = `int main(void) { /*${"x".repeat(budget * 2)}*/ }`; + const harness = await connect("full", { + budget, + bodies: { "0x401000": large, "0x402000": large }, + }); + const constraint = (result: CallToolResult) => { + const { error } = parseMcpToolError(result); + expect(error.code).toBe("resource_constraint"); + const limits = z + .object({ details: z.object({ reported_limits: jsonObjectSchema }) }) + .parse(error).details.reported_limits; + return z + .object({ + name: z.literal("inspect_analysis_view"), + arguments: inspectAnalysisViewContract.inputSchema, + }) + .parse(limits.continuation); + }; + + // The complete batch names a body-free first page of its retained parent. + const first = constraint( + await harness.reply("batch_decompile", { + addresses: ["main", "emoji", "bodyless"], + }), + ); + const source = first.arguments.source; + if (source.kind !== "retained-evidence") throw new Error("Expected parent"); + expect(first.arguments.view).toEqual({ + kind: "batch-decompile", + collection: "items", + where: {}, + include_pseudocode: false, + offset: 0, + limit: 64, + }); + const rows = await harness.call(first.name, first.arguments); + expect(rowIndices(viewOf(rows.evidence))).toEqual([0, 1, 2]); + expect(Buffer.byteLength(JSON.stringify(rows.result))).toBeLessThan(budget); + + // Several bodies that cannot fit continue with fewer rows from the same parent. + const withBodies = { + kind: "batch-decompile", + collection: "items", + include_pseudocode: true, + }; + const fewer = constraint( + await harness.reply("inspect_analysis_view", { + source, + view: withBodies, + }), + ); + expect(fewer.arguments).toMatchObject({ + source, + view: { ...withBodies, limit: 1 }, + }); + + // One body that cannot fit continues with its text pages rather than an export. + const text = constraint(await harness.reply(fewer.name, fewer.arguments)); + expect(text.arguments).toMatchObject({ + source, + view: { kind: "batch-decompile", collection: "pseudocode", index: 0 }, + }); + const oversizedPage = constraint( + await harness.reply("inspect_analysis_view", { + source, + view: { ...text.arguments.view, limit: budget * 2 }, + }), + ); + expect(oversizedPage.arguments).toMatchObject({ + source, + view: { collection: "pseudocode", index: 0, limit: budget }, + }); + expect((await readBody(harness, source.evidence_id, 0)).text).toBe(large); +}); + +it("reads the same rows from a bundle and an Evidence file without a provider", async () => { + const harness = await connect("full"); + const parent = ( + await harness.call("batch_decompile", { addresses: SELECTORS }) + ).evidence; + const selected = { + kind: "batch-decompile", + collection: "items", + where: { addresses: ["emoji"] }, + include_pseudocode: true, + }; + const { items } = viewOf( + ( + await harness.call("inspect_analysis_view", { + source: retained(parent.evidence_id), + view: selected, + }) + ).evidence, + ); + expect(items).toMatchObject([{ index: 3, pseudocode: EMOJI_BODY }]); + + const root = await createTestTempDirectory("rea-batch-decompile-view-"); + const bundlePath = join(root, "bundle.json"); + const filePath = join(root, "batch.json"); + await writeFile(bundlePath, JSON.stringify(createEvidenceBundle([parent]))); + await writeFile(filePath, JSON.stringify(parent)); + const { client, close } = await createApplicationMcpHarness({ + session: createTestBinarySession(() => { + throw new Error("Retained batch views must not open a provider"); + }), + clientIdentity: { name: "batch-decompile-offline", version: "1.0.0" }, + schemaValidator: schemaValidation, + }); + onTestFinished(close); + const imported = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path: bundlePath }, + }); + expect(imported.isError).not.toBe(true); + for (const source of [ + retained(parent.evidence_id), + { kind: "evidence-file", path: filePath }, + ]) { + const reply = await client.callTool({ + name: "inspect_analysis_view", + arguments: { source, view: selected }, + }); + expect(viewOf(parseEvidence(reply.structuredContent)).items).toEqual(items); + } +}); + +it("keeps the caller's own source when a page cannot fit, without retaining its parent", async () => { + const budget = 64 * 1024; + const body = `int f(void) { /*${"x".repeat(budget * 2)}*/ }`; + const row = { + procedure: { status: "resolved", address: "0x1", name: null }, + status: "ok", + pseudocode: body, + }; + const parent = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "batch_decompile", + parameters: {}, + result: { + items: [ + { address: "a", ...row }, + { address: "b", ...row }, + ], + total: 2, + succeeded: 2, + failed: 0, + }, + predicateType: "rea.analysis", + confidence: "derived", + authority: "shipped-artifact", + }, + ); + const root = await createTestTempDirectory("rea-batch-decompile-source-"); + const path = join(root, "batch.json"); + await writeFile(path, JSON.stringify(parent)); + const session = createTestBinarySession(() => { + throw new Error("Retained batch views must not open a provider"); + }); + const { client, close } = await createApplicationMcpHarness({ + session, + clientIdentity: { name: "batch-decompile-source", version: "1.0.0" }, + schemaValidator: schemaValidation, + serverOptions: { delivery: createToolResultDelivery(budget) }, + }); + onTestFinished(close); + const inspect = (source: unknown, selected: Record) => + client.callTool({ + name: "inspect_analysis_view", + arguments: { source, view: { kind: "batch-decompile", ...selected } }, + }); + const continuationOf = (result: Parameters[0]) => + z + .object({ details: z.object({ reported_limits: jsonObjectSchema }) }) + .parse(parseMcpToolError(result).error).details.reported_limits + .continuation; + const withBodies = { collection: "items", include_pseudocode: true }; + + // A file source continues from that file: its parent was never retained. + const file = { kind: "evidence-file", path }; + expect(continuationOf(await inspect(file, withBodies))).toMatchObject({ + arguments: { source: file, view: { limit: 1 } }, + }); + const oneRow = continuationOf( + await inspect(file, { ...withBodies, limit: 1 }), + ); + expect(oneRow).toMatchObject({ + arguments: { + source: file, + view: { collection: "pseudocode", index: 0 }, + }, + }); + const followed = await client.callTool( + z.object({ name: z.string(), arguments: jsonObjectSchema }).parse(oneRow), + ); + expect(followed.isError, JSON.stringify(followed)).not.toBe(true); + expect(session.hasEvidence(parent.evidence_id)).toBe(false); + + // An inline record cannot be repeated within the reply it overflowed. + const inline = { kind: "inline", evidence: parent }; + const overflowed = await inspect(inline, withBodies); + expect(parseMcpToolError(overflowed).error.code).toBe("resource_constraint"); + expect(continuationOf(overflowed)).toBeUndefined(); + + // A server that retains nothing still reads the file, and can name it again. + const unretained = createServer( + { + kind: "fixed", + analysis: { + execute: () => { + throw new Error("Retained batch views must not call a provider"); + }, + }, + }, + { delivery: createToolResultDelivery(budget) }, + ); + const fixedClient = new Client({ + name: "batch-unretained", + version: "1.0.0", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + onTestFinished(async () => { + await fixedClient.close(); + await unretained.close(); + }); + await unretained.connect(serverTransport); + await fixedClient.connect(clientTransport); + const noRecorder = continuationOf( + await fixedClient.callTool({ + name: "inspect_analysis_view", + arguments: { + source: file, + view: { kind: "batch-decompile", ...withBodies }, + }, + }), + ); + expect(noRecorder).toMatchObject({ + arguments: { source: file, view: { limit: 1 } }, + }); + const followedUnretained = await fixedClient.callTool( + z + .object({ name: z.string(), arguments: jsonObjectSchema }) + .parse( + continuationOf( + await fixedClient.callTool( + z + .object({ name: z.string(), arguments: jsonObjectSchema }) + .parse(noRecorder), + ), + ), + ), + ); + expect( + followedUnretained.isError, + JSON.stringify(followedUnretained), + ).not.toBe(true); +}); + +it("delivers no summary without retention, on a ledger failure or after cancellation", async () => { + onTestFinished(closeEnhancedToolResources); + let calls = 0; + const unretained = await connectFixedAnalysis({ + execute: () => { + calls++; + throw new Error("Selected delivery must fail before analysis"); + }, + }); + expect( + parseMcpToolError( + await unretained.callTool({ + name: "batch_decompile", + arguments: { addresses: ["0x1"], detail: "summary" }, + }), + ).error, + ).toMatchObject({ code: "capability_unavailable" }); + expect(calls).toBe(0); + + const ledger = await connect("full"); + vi.spyOn(ledger.session, "recordEvidence").mockReturnValue( + err(new AnalysisOutputError("record_evidence", "ledger refused the batch")), + ); + const refused = await ledger.reply("batch_decompile", { + addresses: ["main"], + detail: "summary", + }); + expect(parseMcpToolError(refused).error.code).toBe("unreadable_output"); + + const cancelled = await connect("full", { + execute: (operation, input) => + Promise.resolve( + operation === "procedure_pseudo_code" + ? err(new GhidraSessionError("cancelled", "Decompile cancelled")) + : ok(ghidraFixtureResult(operation, input)), + ), + }); + const stopped = await cancelled.reply("batch_decompile", { + addresses: ["main"], + detail: "summary", + }); + expect(parseMcpToolError(stopped).error.code).toBe("cancelled"); + const records = await cancelled.reply("get_evidence_bundle", { + detail: "summary", + filters: { operation: "batch_decompile" }, + }); + expect(records.structuredContent).toMatchObject({ result: { records: [] } }); +}); + +it("binds views to their parent and each page to its own collection", async () => { + const harness = await connect("full"); + const summary = viewOf( + ( + await harness.call("batch_decompile", { + addresses: [], + detail: "summary", + }) + ).evidence, + ); + expect(summary.summary).toMatchObject({ + batch: { total: 0, succeeded: 0, failed: 0 }, + procedures: { distinct_resolved: 0, unknown_identity: 0 }, + pseudocode: { code_units: 0, largest: null }, + }); + expect( + await view(harness, z.string().parse(summary.parent_evidence_id), { + collection: "items", + }), + ).toMatchObject({ + items: [], + coverage: { status: "empty", exhausted: true }, + }); + + const parent = ( + await harness.call("batch_decompile", { addresses: ["main"] }) + ).evidence.evidence_id; + const items = await view(harness, parent, { collection: "items" }); + const body = await view(harness, parent, { + collection: "pseudocode", + index: 0, + }); + expect(analysisViewResultSchema.safeParse(items).success).toBe(true); + expect(analysisViewResultSchema.safeParse(body).success).toBe(true); + expect( + analysisViewResultSchema.safeParse({ ...items, view: body.view }).success, + ).toBe(false); + const { item: _item, ...withoutItem } = body; + expect( + analysisViewResultSchema.safeParse({ ...withoutItem, items: items.items }) + .success, + ).toBe(false); + + const inspect = async (source: unknown, selected: unknown) => + harness.reply("inspect_analysis_view", { source, view: selected }); + expect( + parseMcpToolError( + await inspect(retained(parent), { kind: "native-inventory" }), + ).error, + ).toMatchObject(invalidIssue("batch_decompile Evidence supports")); + const inventory = (await harness.call("list_procedures", {})).evidence; + expect( + parseMcpToolError( + await inspect(retained(inventory.evidence_id), { + kind: "batch-decompile", + collection: "items", + }), + ).error.code, + ).toBe("invalid_request"); + + // Inline parents authenticate their predicate and result before selection. + const inline = ( + predicateType: string, + result: Evidence["normalized_result"], + ) => + inspect( + { + kind: "inline", + evidence: createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "batch_decompile", + parameters: {}, + result, + predicateType, + confidence: "derived", + authority: "shipped-artifact", + }, + ), + }, + { kind: "batch-decompile", collection: "items" }, + ); + const valid = { + items: [ + { + address: "main", + procedure: { status: "resolved", address: "0x1", name: null }, + status: "ok", + pseudocode: "x", + }, + ], + total: 1, + succeeded: 1, + failed: 0, + }; + // A parent without a target stays explicit rather than inventing identity. + const accepted = await inline("rea.analysis", valid); + expect(viewOf(parseEvidence(accepted.structuredContent))).toMatchObject({ + artifact: null, + decompiling_provider: null, + source_analysis_profile_digest: null, + }); + for (const rejected of [ + await inline("rea.other", valid), + await inline("rea.analysis", { + ...valid, + items: [{ address: "main", status: "ok" }], + }), + ]) + expect(parseMcpToolError(rejected).error.code).toBe("invalid_request"); +}); diff --git a/tests/boundary/mcp/browserSourceCoordinates.test.ts b/tests/boundary/mcp/browserSourceCoordinates.test.ts index 3724412a2f..c26384bedd 100644 --- a/tests/boundary/mcp/browserSourceCoordinates.test.ts +++ b/tests/boundary/mcp/browserSourceCoordinates.test.ts @@ -1,22 +1,21 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; -it("reports malformed source coordinates as unknown through MCP", async () => { +/** Report one call frame for a console event, a request initiator and a WebMCP registration. */ +const inspectSourceCoordinates = async (position: { + readonly lineNumber: number; + readonly columnNumber: number; +}) => { const browser = await startFakeCdpBrowser({ webMcpTools: true, webMcpStackTrace: (origin) => ({ - callFrames: [ - { url: `${origin}/app.js`, lineNumber: -3, columnNumber: 7.5 }, - ], + callFrames: [{ url: `${origin}/app.js`, ...position }], }), commandEvents(command, origin) { - const frame = { - url: `${origin}/app.js`, - lineNumber: -3, - columnNumber: 7.5, - }; + const frame = { url: `${origin}/app.js`, ...position }; const session = command.sessionId === undefined ? {} : { sessionId: command.sessionId }; if (command.method === "Runtime.enable") @@ -38,7 +37,7 @@ it("reports malformed source coordinates as unknown through MCP", async () => { ...session, method: "Network.requestWillBeSent", params: { - requestId: "bad-coordinates", + requestId: "coordinate-request", timestamp: 1, type: "Script", request: { url: `${origin}/app.js`, method: "GET" }, @@ -71,6 +70,18 @@ it("reports malformed source coordinates as unknown through MCP", async () => { name: "inspect_web_page", arguments: args, }); + const tools = await client.callTool({ + name: "discover_webmcp_tools", + arguments: args, + }); + return { client, page, tools }; +}; + +it("reports malformed source coordinates as unknown through MCP", async () => { + const { page, tools } = await inspectSourceCoordinates({ + lineNumber: -3, + columnNumber: 7.5, + }); expect(page.isError).not.toBe(true); expect(page.structuredContent).toMatchObject({ normalized_result: { @@ -80,7 +91,7 @@ it("reports malformed source coordinates as unknown through MCP", async () => { network: { requests: expect.arrayContaining([ expect.objectContaining({ - request_id: "bad-coordinates", + request_id: "coordinate-request", initiator: expect.objectContaining({ line: null, column: null }), }), ]), @@ -99,10 +110,6 @@ it("reports malformed source coordinates as unknown through MCP", async () => { }, }, }); - const tools = await client.callTool({ - name: "discover_webmcp_tools", - arguments: args, - }); expect(tools.isError).not.toBe(true); expect(tools.structuredContent).toMatchObject({ normalized_result: { @@ -120,3 +127,69 @@ it("reports malformed source coordinates as unknown through MCP", async () => { }, }); }, 20_000); + +it("keeps a negative column Chrome reports in passive source locations", async () => { + const { client, page, tools } = await inspectSourceCoordinates({ + lineNumber: 21, + columnNumber: -40, + }); + const advertised = (await client.listTools()).tools; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + for (const [name, response] of [ + ["inspect_web_page", page], + ["discover_webmcp_tools", tools], + ] as const) { + expect(response.isError).not.toBe(true); + const outputSchema: Record | undefined = advertised.find( + (tool) => tool.name === name, + )?.outputSchema; + if (outputSchema === undefined) + throw new Error(`Missing advertised output schema: ${name}`); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + } + const location = { line: 21, column: -40 }; + expect(page.structuredContent).toMatchObject({ + normalized_result: { + console: { events: [expect.objectContaining(location)] }, + network: { + requests: expect.arrayContaining([ + expect.objectContaining({ + request_id: "coordinate-request", + initiator: expect.objectContaining(location), + }), + ]), + }, + }, + }); + expect(tools.structuredContent).toMatchObject({ + normalized_result: { + tools: { + items: [ + expect.objectContaining({ + registration_source: expect.objectContaining(location), + }), + ], + }, + }, + }); + for (const [response, section] of [ + [page, "console_events"], + [page, "network_initiators"], + [tools, "webmcp_tools"], + ] as const) + expect(response.structuredContent).toMatchObject({ + normalized_result: { + completeness: { + excluded: expect.not.arrayContaining([ + expect.objectContaining({ + section, + reason: "invalid_protocol_value", + }), + ]), + }, + }, + }); +}, 20_000); diff --git a/tests/boundary/mcp/deferredRuntime.test.ts b/tests/boundary/mcp/deferredRuntime.test.ts index 61cf7122bd..f52e3f166b 100644 --- a/tests/boundary/mcp/deferredRuntime.test.ts +++ b/tests/boundary/mcp/deferredRuntime.test.ts @@ -11,6 +11,7 @@ import { } from "node:fs/promises"; import { resolve, join, dirname } from "node:path"; import { expect, it, onTestFinished } from "vitest"; +import { artifactMcpCall } from "../../../scripts/lib/artifact-e2e.mjs"; import { closeVerifierResources } from "../../../scripts/lib/verifier-cleanup.mjs"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { GENERATED_MCP_TOOL_CATALOG } from "../../fixtures/mcpToolCatalog.js"; @@ -112,15 +113,20 @@ registerHooks({ const { client, stderr } = await connect("legacy", environment, [ `--import=${hook}`, ]); - await client.listTools(); + const discovery = await client.listTools(); await client.listPrompts(); expect(await loadedModules()).toEqual( new Set(eager ? ["entrypoint", ...modules] : ["entrypoint"]), ); - const result = await client.callTool({ - name: "list_unknowns", - arguments: {}, - }); + // Keep output-off coverage too, using this connection's named definition. + const toolDefinition = discovery.tools.find( + ({ name }) => name === "list_unknowns", + ); + if (toolDefinition === undefined) throw new Error("Missing list_unknowns"); + const result = await client.callTool( + { name: "list_unknowns", arguments: {} }, + { toolDefinition }, + ); expect(result.isError).not.toBe(true); expect(await loadedModules()).toEqual(new Set(["entrypoint", ...modules])); expect(stderr()).toBe(""); @@ -195,9 +201,14 @@ it.each([ prompt, ), ); + // Discovery is checked in full above. Validate each called tool's actual + // schema without compiling unrelated outputs in every fresh SDK client. const results = await Promise.all([ - client.callTool({ name: "list_unknowns", arguments: {} }), - client.callTool({ name: "list_unknowns", arguments: { limit: 1 } }), + artifactMcpCall(client, { name: "list_unknowns", arguments: {} }), + artifactMcpCall(client, { + name: "list_unknowns", + arguments: { limit: 1 }, + }), ]); expect(results.every((result) => result.isError !== true)).toBe(true); expect(results.map((result) => result.structuredContent)).toEqual([ @@ -205,7 +216,7 @@ it.each([ { result: { items: [], total: 0, offset: 0, next_offset: null } }, ]); expect(await client.listTools()).toEqual(before); - const invalid = await client.callTool({ + const invalid = await artifactMcpCall(client, { name: "list_unknowns", arguments: { limit: 1.5 }, }); @@ -238,7 +249,7 @@ it("composes a scoped registration instead of serving the recorded catalog", asy expect(advertised).not.toContain("inspect_macho"); // The recorded catalog cannot verify a scoped identity, so the scope must // serve real calls instead of failing every one after materialization. - const status = await client.callTool({ + const status = await artifactMcpCall(client, { name: "binary_session", arguments: {}, }); @@ -263,7 +274,7 @@ globalThis.Date = class extends NativeDate { const { client } = await connect("legacy", {}, [`--import=${hook}`]); await client.listTools(); await writeFile(clock, "2021-01-01T00:00:00.000Z"); - const first = await client.callTool({ + const first = await artifactMcpCall(client, { name: "binary_session", arguments: {}, }); @@ -271,7 +282,7 @@ globalThis.Date = class extends NativeDate { expect(first.structuredContent).toMatchObject({ result: { server_identity: { server: { started_at: startedAt } } }, }); - const again = await client.callTool({ + const again = await artifactMcpCall(client, { name: "binary_session", arguments: {}, }); @@ -596,10 +607,14 @@ it.each([{}, { REA_MCP_OUTPUT_SCHEMA_PROFILE: "off" }])( ) : catalog.tools; expect(before.tools).toEqual(expected); - const result = await client.callTool({ - name: "list_unknowns", - arguments: {}, - }); + const toolDefinition = before.tools.find( + ({ name }) => name === "list_unknowns", + ); + if (toolDefinition === undefined) throw new Error("Missing list_unknowns"); + const result = await client.callTool( + { name: "list_unknowns", arguments: {} }, + { toolDefinition }, + ); expect(result.isError).not.toBe(true); expect(await client.listTools()).toEqual(before); }, diff --git a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts index ce4b98cf4f..d5a30233f2 100644 --- a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts +++ b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts @@ -8,6 +8,7 @@ import { AnalysisOutputError, } from "../../../src/domain/analysisErrorCore.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; +import { batchDecompileResultSchema } from "../../../src/domain/native/batchDecompile.js"; import { ENHANCED_TOOL_CONTRACTS } from "../../../src/contracts/enhancedToolContracts.js"; import { NULL_PSEUDOCODE_GUIDANCE } from "../../../src/application/EnhancedTools.js"; import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js"; @@ -726,7 +727,9 @@ describe("enhanced MCP batch answers without pseudocode", () => { succeeded: 1, failed: 5, }); - const { items } = evidence.normalized_result; + const { items } = batchDecompileResultSchema.parse( + evidence.normalized_result, + ); // Successful text, a caveat-free null and a typed provider failure carry // no item caveats; the batch Evidence aggregates every observation's. for (const index of [0, 2, 5]) diff --git a/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts b/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts new file mode 100644 index 0000000000..2981381ca3 --- /dev/null +++ b/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts @@ -0,0 +1,398 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterAll, expect, it } from "vitest"; +import { z } from "zod"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; +import { MCP_TRANSPORT_EVIDENCE_LINKS } from "../../../src/domain/analysisErrorPresentation.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { + EVIDENCE_LINK_INSPECTION_LIMIT, + EVIDENCE_LINK_INSPECTION_LIMITATIONS, + evidenceBundleSummarySchema, + evidenceLinkInspectionSchema, +} from "../../../src/domain/evidenceRecordSummary.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const schemaValidation = createMcpSchemaValidation(); +afterAll(() => schemaValidation.clear()); + +const GHIDRA = { id: "ghidra", name: "Ghidra", version: "12.1.4" }; +const TARGET = { + path: "/fixtures/a.macho", + format: "mach-o" as const, + sha256: "a".repeat(64), +}; +const record = (operation: string, evidenceLinks: readonly string[] = []) => + createEvidence(TARGET, GHIDRA, { + operation, + parameters: {}, + result: { operation }, + evidenceLinks, + }); + +// top -> middle -> absent source; top -> leaf; unrelated stands alone. +const source = record("xrefs"); +const leaf = record("procedure_address"); +const middle = record("trace_feature", [source.evidence_id]); +const top = record("analyze_function_summary", [ + middle.evidence_id, + leaf.evidence_id, +]); +const unrelated = record("list_names"); +const PARTIAL = [top, middle, leaf, unrelated]; + +const connect = async ( + options: { + readonly profile?: "full" | "compact"; + readonly records?: readonly ReturnType[]; + readonly maximumResponseBytes?: number; + } = {}, +) => { + const session = createTestBinarySession(() => { + throw new Error("Link inspection must not open a provider"); + }); + const { client } = await createApplicationMcpHarness({ + session, + clientIdentity: { name: "evidence-link-inspection", version: "1" }, + schemaValidator: schemaValidation, + serverOptions: { + environment: { + ...process.env, + REA_MCP_INPUT_SCHEMA_PROFILE: options.profile ?? "full", + REA_MCP_OUTPUT_SCHEMA_PROFILE: options.profile ?? "full", + }, + ...(options.maximumResponseBytes === undefined + ? {} + : { delivery: createToolResultDelivery(options.maximumResponseBytes) }), + }, + }); + for (const retained of options.records ?? PARTIAL) + expect(session.recordEvidence(retained).ok).toBe(true); + const reply = (args: Record) => + client.callTool({ name: "get_evidence_bundle", arguments: args }); + // Every delivered result must satisfy what this profile actually advertises. + const { tools } = await client.listTools(); + const advertised = tools.find(({ name }) => name === "get_evidence_bundle"); + if (advertised?.outputSchema === undefined) + throw new Error("get_evidence_bundle advertises no output schema"); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + const validation = schemaValidation.assertions; + expect(validation.validateSchema(inputSchema)).toBe(true); + expect(validation.validateSchema(outputSchema)).toBe(true); + const resultOf = async (args: Record) => { + expect( + validation.validate(inputSchema, args), + JSON.stringify(validation.errors), + ).toBe(true); + const result = await reply(args); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + expect( + validation.validate(outputSchema, result.structuredContent), + JSON.stringify(validation.errors), + ).toBe(true); + return z.object({ result: z.unknown() }).parse(result.structuredContent) + .result; + }; + const links = async (args: Record = {}) => + evidenceLinkInspectionSchema.parse( + await resultOf({ detail: "links", ...args }), + ); + const summary = async (args: Record = {}) => + evidenceBundleSummarySchema.parse( + await resultOf({ detail: "summary", ...args }), + ); + return { client, session, reply, links, summary }; +}; + +const exactly = (record_: { readonly evidence_id: string }) => ({ + filters: { evidence_id: record_.evidence_id }, +}); + +it.each(["full", "compact"] as const)( + "reads direct membership against the whole session, not the selection (%s)", + async (profile) => { + const { links, summary } = await connect({ profile }); + + // A retained-but-unselected source is retained, and direct makes no closure claim. + const selected = await links(exactly(top)); + expect(selected).toEqual({ + kind: "evidence-link-inspection", + filters: exactly(top).filters, + traversal: "direct", + link_status: "missing", + total_retained_records: 4, + matching_records: 1, + examined_records: 1, + links: { total: 2, retained: 2, missing: 0 }, + retained_closure: null, + offset: 0, + limit: EVIDENCE_LINK_INSPECTION_LIMIT, + matching_links: 0, + next_offset: null, + rows: [], + limitations: [...EVIDENCE_LINK_INSPECTION_LIMITATIONS], + }); + + // The record that names the absent source reports it without any payload. + const missing = await links(exactly(middle)); + expect(missing.links).toEqual({ total: 1, retained: 0, missing: 1 }); + expect(missing.rows).toEqual([ + { + evidence_id: middle.evidence_id, + linked_evidence_id: source.evidence_id, + status: "missing", + }, + ]); + expect(JSON.stringify(missing)).not.toContain("trace_feature"); + + // The summary counts direct links only for its matching records. + expect(await summary()).toMatchObject({ + matching_records: 4, + direct_links: 3, + missing_direct_links: 1, + }); + expect(await summary(exactly(top))).toMatchObject({ + matching_records: 1, + direct_links: 2, + missing_direct_links: 0, + }); + }, +); + +it("follows retained links only when asked and leaves the rest unknown", async () => { + const { links } = await connect(); + const walk = { traversal: "retained-closure" }; + + // Top looks closed directly, but its closure reaches the absent source. + const closure = await links({ ...exactly(top), ...walk }); + expect(closure).toMatchObject({ + traversal: "retained-closure", + matching_records: 1, + examined_records: 3, + links: { total: 3, retained: 2, missing: 1 }, + retained_closure: "incomplete", + rows: [ + { + evidence_id: middle.evidence_id, + linked_evidence_id: source.evidence_id, + status: "missing", + }, + ], + }); + + // A branch without an absent link is complete, including a record with no links. + expect(await links({ ...exactly(leaf), ...walk })).toMatchObject({ + examined_records: 1, + links: { total: 0, retained: 0, missing: 0 }, + retained_closure: "complete", + }); + + // Selecting nothing supports no claim about a closure. + expect( + await links({ filters: { operation: "absent_operation" }, ...walk }), + ).toMatchObject({ + matching_records: 0, + examined_records: 0, + links: { total: 0 }, + retained_closure: null, + rows: [], + }); +}); + +it("counts a repeated link once in every delivery", async () => { + const repeated = record("trace_feature", [ + source.evidence_id, + source.evidence_id, + ]); + const { links, summary } = await connect({ records: [repeated] }); + expect(await summary()).toMatchObject({ + direct_links: 1, + missing_direct_links: 1, + }); + const inspected = await links(); + expect(inspected.links).toEqual({ total: 1, retained: 0, missing: 1 }); + expect(inspected.rows).toHaveLength(1); +}); + +it("pages selected link rows in a stable order without skips or duplicates", async () => { + const { links } = await connect(); + const everything = await links({ link_status: "all" }); + expect(everything).toMatchObject({ + link_status: "all", + links: { total: 3, retained: 2, missing: 1 }, + matching_links: 3, + next_offset: null, + }); + const key = ({ + evidence_id: id, + linked_evidence_id: link, + }: (typeof everything.rows)[number]) => `${id}>${link}`; + const order = everything.rows.map(key); + expect(order).toEqual( + [...order].sort((left, right) => (left < right ? -1 : 1)), + ); + + const paged: string[] = []; + let offset: number | null = 0; + while (offset !== null) { + const page: z.output = await links({ + link_status: "all", + offset, + limit: 1, + }); + expect(page.rows).toHaveLength(1); + // Counts describe every inspected link on every page. + expect(page.links.total).toBe(3); + paged.push(...page.rows.map(key)); + offset = page.next_offset; + } + expect(paged).toEqual(order); + + const retainedOnly = await links({ link_status: "retained" }); + expect(retainedOnly.matching_links).toBe(2); + expect(retainedOnly.rows.every(({ status }) => status === "retained")).toBe( + true, + ); + expect(await links({ link_status: "all", offset: 9 })).toMatchObject({ + matching_links: 3, + next_offset: null, + rows: [], + }); +}); + +it("reports the source a composed workflow refuses, and clears it on import", async () => { + const dossier = FUNCTION_COMPARISON_EXAMPLE.left; + if (dossier.subject === null) throw new Error("Expected a subject"); + const health = record("get_project_health"); + const linked = createEvidence( + { + path: dossier.subject.local_path, + sha256: dossier.subject.digest.sha256, + format: dossier.subject.format, + }, + dossier.provider, + { + operation: dossier.operation, + parameters: dossier.parameters, + result: dossier.normalized_result, + confidence: dossier.confidence, + authority: dossier.authority, + evidenceLinks: [health.evidence_id], + }, + ); + const { client, links, summary } = await connect({ records: [linked] }); + const walk = { traversal: "retained-closure" }; + expect(await links(walk)).toMatchObject({ + retained_closure: "incomplete", + rows: [ + { + evidence_id: linked.evidence_id, + linked_evidence_id: health.evidence_id, + }, + ], + }); + expect(await summary()).toMatchObject({ missing_direct_links: 1 }); + + const procedure = z + .looseObject({ procedure: z.looseObject({ address: z.string() }) }) + .parse(dossier.normalized_result).procedure.address; + const callPath = () => + client.callTool({ + name: "build_call_path", + arguments: { + start: { address: procedure }, + goal: { address: procedure }, + functions: [ + { kind: "retained-evidence", evidence_id: linked.evidence_id }, + ], + }, + }); + // The inspection predicts the same absent ID the workflow refuses. + expect(JSON.stringify(parseMcpToolError(await callPath()))).toContain( + health.evidence_id, + ); + + const root = await createTestTempDirectory("rea-evidence-links-"); + const bundlePath = join(root, "source.json"); + await writeFile(bundlePath, JSON.stringify(createEvidenceBundle([health]))); + const imported = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path: bundlePath }, + }); + expect(imported.isError, JSON.stringify(imported)).not.toBe(true); + + // Membership changes, the original record does not, and the workflow proceeds. + expect(await links(walk)).toMatchObject({ + examined_records: 2, + links: { total: 1, retained: 1, missing: 0 }, + retained_closure: "complete", + rows: [], + }); + expect(await summary()).toMatchObject({ missing_direct_links: 0 }); + const proceeded = await callPath(); + expect(proceeded.isError, JSON.stringify(proceeded)).not.toBe(true); + expect(parseEvidence(proceeded.structuredContent).evidence_links).toContain( + linked.evidence_id, + ); +}); + +it("refuses link selections outside link delivery and names a smaller page", async () => { + const { reply } = await connect(); + for (const [detail, field, value, expected] of [ + ["summary", "traversal", "direct", "detail: links"], + ["records", "link_status", "all", "detail: links"], + ["complete", "traversal", "retained-closure", "detail: links"], + ["summary", "offset", 1, "detail: records or detail: links"], + ["summary", "limit", 1, "detail: records or detail: links"], + ] as const) + expect( + parseMcpToolError(await reply({ detail, [field]: value })).error, + ).toMatchObject({ + code: "invalid_request", + details: { + issues: [expect.objectContaining({ path: [field], expected })], + }, + }); + expect( + parseMcpToolError( + await reply({ detail: "complete", filters: exactly(top).filters }), + ).error, + ).toMatchObject({ code: "invalid_request" }); + + // Many links on one record overflow a small response budget with a named step. + const many = Array.from( + { length: 64_000 }, + (_, index) => `ev_${index.toString(16).padStart(64, "0")}`, + ); + const crowded = await connect({ + records: [record("trace_feature", many)], + maximumResponseBytes: 10 * 1024 * 1024, + }); + const large = await crowded.reply({ detail: "links", limit: 64_000 }); + expect(parseMcpToolError(large).error).toMatchObject({ + code: "resource_constraint", + remediation: { + action: expect.stringContaining(MCP_TRANSPORT_EVIDENCE_LINKS), + }, + }); + expect( + evidenceLinkInspectionSchema.parse( + z + .object({ result: z.unknown() }) + .parse( + (await crowded.reply({ detail: "links", limit: 2 })) + .structuredContent, + ).result, + ), + ).toMatchObject({ + links: { total: 64_000, missing: 64_000 }, + next_offset: 2, + }); +}); diff --git a/tests/boundary/mcp/ghidraMcpHarness.ts b/tests/boundary/mcp/ghidraMcpHarness.ts index 5b6a5c5b3e..36fe94a0d9 100644 --- a/tests/boundary/mcp/ghidraMcpHarness.ts +++ b/tests/boundary/mcp/ghidraMcpHarness.ts @@ -3,6 +3,7 @@ import type { GhidraJavaObservation } from "../../../src/ghidra/GhidraJavaProbe. import { expect } from "vitest"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import type { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; import type { BinarySession } from "../../../src/application/binary/BinarySession.js"; import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; @@ -15,6 +16,7 @@ import type { GhidraProviderClientFactory } from "../../../src/ghidra/GhidraProv import { GHIDRA_SESSION_CAPABILITIES } from "../../../src/ghidra/GhidraSessionValues.js"; import type { GhidraSessionInfo } from "../../../src/ghidra/GhidraSessionValues.js"; import { silentLogger } from "../../../src/logger.js"; +import type { ToolResultDelivery } from "../../../src/server/ToolResultDelivery.js"; import { ghidraFunctionClassification, ghidraFunctionDossier, @@ -33,6 +35,9 @@ export const connectGhidraMcp = async ( readonly sessionTarget?: GhidraSessionInfo["target"]; readonly attestDosTargetSnapshot?: ReturnType["attestDosTargetSnapshot"]; readonly close?: ReturnType["close"]; + readonly delivery?: ToolResultDelivery; + readonly environment?: Readonly>; + readonly schemaValidator?: ReturnType; } = {}, ) => { const calls: GhidraOperation[] = []; @@ -72,12 +77,17 @@ export const connectGhidraMcp = async ( const { client: mcp, close } = await createApplicationMcpHarness({ session, clientIdentity: { name, version: "1.0.0" }, + ...(fixture.schemaValidator === undefined + ? {} + : { schemaValidator: fixture.schemaValidator }), serverOptions: { logger: silentLogger, environment: { ...process.env, REA_MCP_MAX_INPUT_ELEMENTS: process.env.REA_MCP_MAX_INPUT_ELEMENTS, + ...fixture.environment, }, + ...(fixture.delivery === undefined ? {} : { delivery: fixture.delivery }), }, }); try { diff --git a/tests/boundary/mcp/ghidraReopen.test.ts b/tests/boundary/mcp/ghidraReopen.test.ts index fc2bdc7232..2690a795d6 100644 --- a/tests/boundary/mcp/ghidraReopen.test.ts +++ b/tests/boundary/mcp/ghidraReopen.test.ts @@ -39,6 +39,8 @@ type FixtureLaunch = { class FixtureLauncher implements GhidraLauncher { readonly launches: FixtureLaunch[] = []; readonly requests: string[] = []; + /** Extra handshake fields, one entry per launch in order. */ + readonly handshakeReports: Record[] = []; cleanupAllowed = true; cleanupAttempts = 0; @@ -57,6 +59,7 @@ class FixtureLauncher implements GhidraLauncher { profileDigest: session.profileDigest, targetSha256: session.targetSha256, transport: session.transport, + handshakeReports: this.handshakeReports.shift() ?? {}, }), }, }); @@ -208,6 +211,22 @@ const connectGhidra = async () => { }; }; +const UNCOMMITTED_SEEDS = { + analysis_seeds: { + format: "rea-ghidra-seeds-v1", + sha256: "b".repeat(64), + entries: 1, + function_created: 1, + function_existing: 0, + function_failed: 0, + code_decoded: 0, + code_failed: 0, + label_applied: 0, + label_failed: 0, + unmapped: 0, + }, +}; + const firstLaunch = (launcher: FixtureLauncher): FixtureLaunch => { const first = launcher.launches[0]; if (first === undefined) throw new Error("Expected a running Ghidra fixture"); @@ -347,3 +366,81 @@ describe.skipIf(process.platform === "win32")( }); }, ); + +describe.skipIf(process.platform === "win32")( + "Ghidra MCP handshake rejection over Unix sockets", + () => { + it("refuses an uncommitted handshake report before the first query and replaces that session on reopen", async () => { + const harness = await connectGhidra(); + try { + harness.launcher.handshakeReports.push(UNCOMMITTED_SEEDS); + expect((await harness.open()).isError).not.toBe(true); + expect(harness.launcher.launches).toHaveLength(0); + expect(parseMcpToolError(await harness.query(1))).toMatchObject({ + error: { + details: { + operation: "list_strings", + diagnostics: { + reason: expect.stringContaining( + "reported analysis seeds that the profile did not commit", + ), + }, + }, + }, + }); + const rejected = firstLaunch(harness.launcher); + await expectReleased(rejected); + expect(harness.launcher.requests).not.toContain("list_strings"); + + expect((await harness.query(2)).isError).toBe(true); + expect(harness.launcher.launches).toHaveLength(1); + expect(harness.launcher.requests).not.toContain("list_strings"); + + expect((await harness.open()).isError).not.toBe(true); + expect((await harness.query(3)).isError).not.toBe(true); + expect(harness.clients).toHaveLength(2); + expect(harness.launcher.launches).toHaveLength(2); + await expect + .poll(() => + harness.launcher.requests.filter( + (method) => method === "list_strings", + ), + ) + .toHaveLength(1); + } finally { + await harness.close(); + } + }); + + it("retains a rejected session's failed cleanup and refuses replacement until it can be retried", async () => { + const harness = await connectGhidra(); + try { + harness.launcher.handshakeReports.push(UNCOMMITTED_SEEDS); + harness.launcher.cleanupAllowed = false; + expect((await harness.open()).isError).not.toBe(true); + expect(parseMcpToolError(await harness.query(1))).toMatchObject({ + error: { code: "cleanup_incomplete" }, + }); + expect(harness.launcher.cleanupAttempts).toBe(1); + expect(harness.launcher.requests).not.toContain("list_strings"); + const rejected = firstLaunch(harness.launcher); + await access(rejected.runtimeRoot); + + expect(parseMcpToolError(await harness.open())).toMatchObject({ + error: { code: "cleanup_incomplete" }, + }); + expect(harness.launcher.cleanupAttempts).toBe(2); + expect(harness.launcher.launches).toHaveLength(1); + await access(rejected.runtimeRoot); + + harness.launcher.cleanupAllowed = true; + expect((await harness.open()).isError).not.toBe(true); + await expectReleased(rejected); + expect((await harness.query(2)).isError).not.toBe(true); + expect(harness.launcher.launches).toHaveLength(2); + } finally { + await harness.close(); + } + }); + }, +); diff --git a/tests/boundary/mcp/ghidraRoutingMcp.test.ts b/tests/boundary/mcp/ghidraRoutingMcp.test.ts index ac191dd057..dcca3a5017 100644 --- a/tests/boundary/mcp/ghidraRoutingMcp.test.ts +++ b/tests/boundary/mcp/ghidraRoutingMcp.test.ts @@ -1,3 +1,4 @@ +import { batchDecompileResultSchema } from "../../../src/domain/native/batchDecompile.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { describe, expect, it } from "vitest"; import { connectGhidraMcp, ghidraFixtureResult } from "./ghidraMcpHarness.js"; @@ -112,9 +113,11 @@ describe("Ghidra MCP capability routing", () => { arguments: { addresses: ["0x401000", bodyless] }, }); expect(batch.isError).not.toBe(true); - const result = toolContract("batch_decompile").outputSchema.parse( - batch.structuredContent, - ).normalized_result; + const result = batchDecompileResultSchema.parse( + toolContract("batch_decompile").outputSchema.parse( + batch.structuredContent, + ).normalized_result, + ); expect(result.items[0]).not.toHaveProperty("limitations"); expect(result).toEqual({ items: [ diff --git a/tests/boundary/mcp/ghidraStartupCancellationMcp.test.ts b/tests/boundary/mcp/ghidraStartupCancellationMcp.test.ts index f2f64b9670..cc33a01f10 100644 --- a/tests/boundary/mcp/ghidraStartupCancellationMcp.test.ts +++ b/tests/boundary/mcp/ghidraStartupCancellationMcp.test.ts @@ -7,7 +7,6 @@ import { AnalysisProviderRegistry } from "../../../src/application/binary/Analys import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; import { parseConfig } from "../../../src/config/parseConfig.js"; import { GhidraClient } from "../../../src/ghidra/GhidraClient.js"; -import type { GhidraQueuedRequestResult } from "../../../src/ghidra/GhidraRequestQueue.js"; import { ghidraProfileInstallationHost } from "../../../src/ghidra/GhidraInstallation.fixture.js"; import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; import type { GhidraProviderClientFactory } from "../../../src/ghidra/GhidraProviderClient.js"; @@ -26,9 +25,13 @@ it.each([0, 1])( const fixture = new GhidraFixtureLauncher(); const roots = fixture.runtimeRoots; const children = fixture.processes; - const results: (GhidraQueuedRequestResult | undefined)[] = []; + const startResults: ( + | Awaited> + | undefined + )[] = []; let producerSignal: AbortSignal | undefined; - let callCount = 0; + let startCount = 0; + let queryCount = 0; const factory: GhidraProviderClientFactory = (options) => { const raw = new GhidraClient({ ...options, @@ -42,17 +45,21 @@ it.each([0, 1])( }, }); return { - start: raw.start.bind(raw), - close: raw.close.bind(raw), - callTool: (operation, parameters, requestOptions) => { - const index = callCount++; - const pending = raw.callTool(operation, parameters, requestOptions); + // Every query waits here for the checked handshake before it can run. + start: (signal, progress) => { + const index = startCount++; + const pending = raw.start(signal, progress); admitted[index]?.resolve(); return pending.then((result) => { - results[index] = result; + startResults[index] = result; return result; }); }, + close: raw.close.bind(raw), + callTool: (operation, parameters, requestOptions) => { + queryCount++; + return raw.callTool(operation, parameters, requestOptions); + }, }; }; const config = parseConfig({ @@ -107,7 +114,7 @@ it.each([0, 1])( await entered.promise; controllers[cancelledIndex]?.abort(); await vi.waitFor(() => - expect(results[cancelledIndex]).toMatchObject({ + expect(startResults[cancelledIndex]).toMatchObject({ ok: false, error: { kind: "cancelled" }, }), @@ -115,16 +122,18 @@ it.each([0, 1])( expect(await calls[cancelledIndex]).toMatchObject({ ok: false }); expect(producerSignal?.aborted).toBe(false); expect(children).toHaveLength(0); + expect(queryCount).toBe(0); gate.resolve(); const staying = await calls[1 - cancelledIndex]; if (staying === undefined || !staying.ok) throw new Error("Live MCP peer did not complete", { cause: staying }); expect(staying.value.isError).not.toBe(true); - expect(results[1 - cancelledIndex]).toMatchObject({ ok: true }); + expect(startResults[1 - cancelledIndex]).toMatchObject({ ok: true }); expect(controllers[1 - cancelledIndex]?.signal.aborted).toBe(false); const warm = await mcp.callTool({ name: "list_strings", arguments: {} }); expect(warm.isError).not.toBe(true); - expect(callCount).toBe(3); + expect(startCount).toBe(3); + expect(queryCount).toBe(2); expect(children).toHaveLength(1); } finally { for (const controller of controllers) controller.abort(); diff --git a/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts b/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts index 77b3413ed5..36c4d736d0 100644 --- a/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts +++ b/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts @@ -1,5 +1,5 @@ import { ok as resultOk } from "../../../src/domain/result.js"; -import { rm, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -27,20 +27,14 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; describe("JavaScript runtime observation MCP tools", () => { const resources: Array<{ close(): Promise }> = []; - const temporary: string[] = []; afterEach(async () => { await Promise.all(resources.splice(0).map((item) => item.close())); - await Promise.all( - temporary - .splice(0) - .map((path) => rm(path, { recursive: true, force: true })), - ); }); registerRuntimeParameterContract(resources); registerRuntimeMetadataContract(); - registerRetainedRuntimeEvidence(resources, temporary); + registerRetainedRuntimeEvidence(resources); }); function registerRuntimeParameterContract( @@ -206,11 +200,9 @@ function registerRuntimeMetadataContract(): void { function registerRetainedRuntimeEvidence( resources: Array<{ close(): Promise }>, - temporary: string[], ): void { test("lists and observes one target as retained Evidence", async () => { const root = await createTestTempDirectory("rea-v8-mcp-"); - temporary.push(root); const { entry, inspector, client } = await createObservationClient( root, resources, @@ -288,8 +280,10 @@ function registerRetainedRuntimeEvidence( expect(new Set(inspector.commands.map(({ method }) => method))).toEqual( new Set(["Runtime.enable", "Debugger.enable"]), ); - const direct = await observeDirectly(inspector); - expect(direct.ok).toBe(true); + const provider = new V8InspectorProvider(); + resources.push(provider); + const direct = await observeDirectly(inspector, provider); + expect(direct.ok, direct.ok ? undefined : direct.error.message).toBe(true); if (direct.ok) expect(evidenceId).toBe(direct.value.evidence_id); }); } @@ -344,9 +338,10 @@ const createObservationClient = async ( const observeDirectly = ( inspector: Awaited>, + provider: V8InspectorProvider, ) => observeJavaScriptRuntime( - new V8InspectorProvider(), + provider, observeJavaScriptRuntimeInputSchema.parse({ inspector_endpoint: inspector.endpoint, target_id: inspector.targetId, diff --git a/tests/boundary/mcp/kunaCloseRetryMcp.test.ts b/tests/boundary/mcp/kunaCloseRetryMcp.test.ts index 9e52ecb78a..3e4e00f809 100644 --- a/tests/boundary/mcp/kunaCloseRetryMcp.test.ts +++ b/tests/boundary/mcp/kunaCloseRetryMcp.test.ts @@ -88,9 +88,21 @@ it("names the same-connection close_binary retry for an unproven Kuna export sto expect(failed.error.remediation.action).toBe( `${analysisErrorRemediationAction(delivered.error)} ${cleanupRetryRemediation({ operation: "close_binary" })}`, ); - expect(await sessionStatus(client)).toMatchObject({ + const pendingStatus = await sessionStatus(client); + expect(pendingStatus).toMatchObject({ open: false, cleanup_pending: true, + cleanup_retry: { operation: "close_binary" }, + }); + const unavailable = pendingStatus.tool_availability.groups.find( + ({ names }) => names.includes("procedure_assembly"), + ); + expect(unavailable).toMatchObject({ + available: false, + reason: "target_required", + remediation: expect.stringContaining( + cleanupRetryRemediation({ operation: "close_binary" }), + ), }); // Reopening stays blocked; only close_binary offers its retry. const blocked = parseMcpToolError( @@ -109,10 +121,12 @@ it("names the same-connection close_binary retry for an unproven Kuna export sto unproven.release(); const recovered = await call("close_binary"); expect(recovered.isError, JSON.stringify(recovered.content)).not.toBe(true); - expect(await sessionStatus(client)).toMatchObject({ + const settledStatus = await sessionStatus(client); + expect(settledStatus).toMatchObject({ open: false, cleanup_pending: false, }); + expect(settledStatus).not.toHaveProperty("cleanup_retry"); expect(await readdir(join(fixture.cacheRoot, "staging"))).toEqual([]); expect(Buffer.compare(await readFile(program), targetBytes)).toBe(0); } finally { diff --git a/tests/boundary/mcp/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts index 64e55f5972..005be263e4 100644 --- a/tests/boundary/mcp/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -1,8 +1,8 @@ import { contractsForToolFamilies } from "../../../src/contracts/toolFamilies.js"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { execFile } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; +import { execFile, spawn } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -50,6 +50,42 @@ const expectDoctorDeadline = ( ); }; +const captureProductionCli = async ( + arguments_: readonly string[], + environment: NodeJS.ProcessEnv, +) => + new Promise<{ + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; + }>((finish, reject) => { + const child = spawn( + process.execPath, + [resolve("scripts/rea.mjs"), ...arguments_], + { + cwd: process.cwd(), + env: environment, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + const timeout = setTimeout(() => child.kill("SIGTERM"), 15_000); + child.once("error", reject); + child.once("close", (exitCode) => { + clearTimeout(timeout); + finish({ exitCode, stdout, stderr }); + }); + }); + afterEach(() => vi.useRealTimers()); const assertCliCatalogIdentity = async ( @@ -358,6 +394,70 @@ describe("production MCP doctor family scopes", () => { }); describe("production MCP doctor output", () => { + it("keeps both structured stdout formats parseable when prompts are not advertised", async () => { + const root = await createTestTempDirectory("rea-mcp-doctor-output-"); + const directories = { + home: join(root, "home"), + config: join(root, "config"), + data: join(root, "data"), + cache: join(root, "cache"), + state: join(root, "state"), + temp: join(root, "temp"), + }; + await Promise.all( + Object.values(directories).map((path) => + mkdir(path, { recursive: true }), + ), + ); + const environment = { + ...process.env, + REA_MCP_TOOL_FAMILIES: "browser", + HOME: directories.home, + XDG_CONFIG_HOME: directories.config, + XDG_DATA_HOME: directories.data, + XDG_CACHE_HOME: directories.cache, + XDG_STATE_HOME: directories.state, + TMPDIR: directories.temp, + }; + + const json = await captureProductionCli( + ["mcp", "doctor", "--json"], + environment, + ); + const jsonl = await captureProductionCli( + ["mcp", "doctor", "--format", "jsonl"], + environment, + ); + + expect([json.exitCode, jsonl.exitCode]).toEqual([1, 1]); + const parseable = [json.stdout, jsonl.stdout].map((output) => { + try { + JSON.parse(output); + return true; + } catch { + return false; + } + }); + expect(parseable).toEqual([true, true]); + expect(JSON.parse(json.stdout)).toMatchObject({ + healthy: false, + inventory: { + prompts: { expected: 0, observed: 0, missing: [], unexpected: [] }, + }, + }); + expect(JSON.parse(jsonl.stdout)).toMatchObject({ + $rea: "jsonl/v1", + type: "error", + ok: false, + result: { + healthy: false, + inventory: { + prompts: { expected: 0, observed: 0, missing: [], unexpected: [] }, + }, + }, + }); + }, 35_000); + it.each([false, true])( "frames unhealthy JSONL diagnostics and retains the selected envelope (%s)", async (full) => { diff --git a/tests/boundary/mcp/mcpSchemaValidation.test.ts b/tests/boundary/mcp/mcpSchemaValidation.test.ts index 2ce015a4f9..e82a633d77 100644 --- a/tests/boundary/mcp/mcpSchemaValidation.test.ts +++ b/tests/boundary/mcp/mcpSchemaValidation.test.ts @@ -7,7 +7,8 @@ it("reuses equal schemas and recompiles changed schemas sharing an id", () => { const schema = { $schema: "https://json-schema.org/draft/2020-12/schema", $id: "https://fixture.test/result", - type: "string" as "string" | "integer", + $defs: { value: { type: "string" as "string" | "integer" } }, + $ref: "#/$defs/value", }; const first = validation.getValidator(schema); expect(validation.getValidator(structuredClone(schema))).toBe(first); @@ -16,7 +17,7 @@ it("reuses equal schemas and recompiles changed schemas sharing an id", () => { expect(validation.assertions.validate(schema, "recorded")).toBe(true); expect(validation.assertions.validate(schema, 42)).toBe(false); - schema.type = "integer"; + schema.$defs.value.type = "integer"; const changed = validation.getValidator(schema); expect(changed).not.toBe(first); expect(changed(42).valid).toBe(true); @@ -31,13 +32,17 @@ it("reuses equal schemas and recompiles changed schemas sharing an id", () => { } }); -it("preserves SDK format validation and declared dialect dispatch", () => { +it.each([ + "https://json-schema.org/draft/2020-12/schema", + "https://json-schema.org/draft/2019-09/schema", + "http://json-schema.org/draft-07/schema#", +])("preserves SDK format validation and references for %s", (dialect) => { const validation = createMcpSchemaValidation(); try { const schema = { - $schema: "http://json-schema.org/draft-07/schema#", - type: "string", - format: "email", + $schema: dialect, + $defs: { address: { type: "string", format: "email" } }, + $ref: "#/$defs/address", } as const; const validator = validation.getValidator(schema); expect(validator("analyst@example.test").valid).toBe(true); diff --git a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts index 2ecc0c29c0..a6c31a1c2c 100644 --- a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts +++ b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts @@ -119,6 +119,33 @@ it("delivers native primitive and graph summaries with exact retained parents an ); expect(rejected.isError).toBe(true); } + const references = await call("xrefs", { address: "0x401000" }); + const source = { + kind: "retained-evidence", + evidence_id: references.evidence_id, + }; + const before = harness.calls.length; + const page = await call("inspect_analysis_view", { + source, + view: { kind: "native-observation", offset: 0, limit: 1 }, + }); + expect(page.normalized_result).toMatchObject({ + parent_evidence_id: references.evidence_id, + parent_operation: "xrefs", + items: { + type: "addresses", + rows: [{ source_index: 0, address: "0x401001" }], + }, + coverage: { total: 1, exhausted: true }, + }); + const caveats = await call("inspect_analysis_view", { + source, + view: { kind: "native-context", facet: "limitations" }, + }); + expect(caveats.normalized_result).toMatchObject({ + parent_operation: "xrefs", + }); + expect(harness.calls).toHaveLength(before); } finally { await harness.close(); } @@ -183,9 +210,12 @@ it("follows native overflow continuations through MCP without repeating provider for (const name of [ ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, "get_call_graph", + "xrefs", ] as const) { const input = - name === "get_call_graph" || name === "resolve_containing_procedure" + name === "get_call_graph" || + name === "resolve_containing_procedure" || + name === "xrefs" ? { address: "0x1000" } : { procedure: "fixture_main" }; const complete = await client.callTool({ name, arguments: input }); diff --git a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts index 941253bf05..d0ce556ea1 100644 --- a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts +++ b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts @@ -14,6 +14,11 @@ import { } from "../../../src/domain/analysisErrorCore.js"; import { inspectNativeDispatchMetadata } from "../../../src/domain/native/objcSwiftMetadata.js"; import { nativeInvestigationTraceSchema } from "../../../src/domain/native/nativeInvestigationGraph.js"; +import { + buildInterfaceBuilderAnalysis, + interfaceBuilderLimitsSchema, +} from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { err, ok, type Result } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; @@ -111,6 +116,12 @@ const noClassifiedReferences = { }; type Plan = { + readonly ui?: ReturnType; + readonly implementations?: ReturnType< + typeof inspectNativeDispatchMetadata + >["objc_dispatch_implementations"]; + readonly action?: string; + readonly maxDepth?: number; readonly references?: unknown; readonly referencesFailure?: AnalysisError; readonly uiFailure?: AnalysisError; @@ -142,6 +153,12 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { plan.referencesFailure !== undefined ) return err(plan.referencesFailure); + if (operation === "decode_interface_builder" && plan.ui !== undefined) + return ok( + createAnalysisExecution(jsonValueSchema.parse(plan.ui), identity, { + subject, + }), + ); if (operation === "decode_interface_builder") return err( new AnalysisCapabilityUnavailableError( @@ -157,7 +174,12 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { target_sha256: sha256, provider: identity, analysis_profile_digest: null, - result: inspectNativeDispatchMetadata([], 1), + result: { + ...inspectNativeDispatchMetadata([], 1), + ...(plan.implementations === undefined + ? {} + : { objc_dispatch_implementations: plan.implementations }), + }, }, identity, { subject }, @@ -220,10 +242,10 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { const result = await client.callTool({ name: "trace_native_ui_action", arguments: { - action: "0x1000", + action: plan.action ?? "0x1000", max_nodes: maxNodes, max_edges: maxEdges, - max_depth: 1, + max_depth: plan.maxDepth ?? 1, }, }); return result; @@ -237,6 +259,93 @@ const traceFrom = (result: { structuredContent?: unknown }) => }) .parse(result.structuredContent).normalized_result; +it.each([ + { label: "thin record", recordOffset: 1944 }, + { label: "selected-slice container record", recordOffset: 6040 }, + { label: "symbol-only record", recordOffset: null }, +])( + "keeps handler offsets distinct from $label Evidence through public MCP", + async ({ recordOffset }) => { + // Explicit synthetic decoded facts; no AppKit, Ghidra or Kuna runtime claim. + const ui = buildInterfaceBuilderAnalysis({ + targetSha256: sha256, + toolVersion: "synthetic", + documents: [ + { + relativePath: "Main.storyboardc/scene.nib/objects.nib", + archiveSha256: "b".repeat(64), + documentKind: "storyboard_scene", + raw: { + "com.apple.ibtool.document.objects": { + receiver: { customClass: "AuditController" }, + button: { class: "UIButton" }, + }, + "com.apple.ibtool.document.connections": { + button: [ + { + type: "action", + label: "runAction:", + destinationId: "receiver", + }, + ], + }, + }, + }, + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + const recordLocation = { + address: recordOffset === null ? "0x10000077c" : "0x100000798", + file_offset: recordOffset, + }; + const recordEvidence = [ + { + kind: + recordOffset === null + ? ("symbol" as const) + : ("binary_metadata" as const), + description: "Explicit synthetic method entry", + location: recordLocation, + artifact_path: subject.path, + artifact_sha256: sha256, + }, + ]; + const response = await publicTrace({ + ui, + action: "runAction:", + maxDepth: 4, + references: completeReferences([]), + callees: [], + implementations: [ + { + class_name: "AuditController", + selector: "runAction:", + method_type: "instance", + implementation_address: "0x10000077c", + location: recordLocation, + decode: { status: "decoded", reason: null }, + evidence: recordEvidence, + }, + ], + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const trace = traceFrom(response); + const handler = trace.nodes.find( + ({ id }) => id === "native:function:0x10000077c", + ); + expect(handler?.location).toEqual({ + address: "0x10000077c", + file_offset: null, + }); + expect(handler?.evidence).toEqual(recordEvidence); + expect( + trace.edges.find(({ relation }) => relation === "objc_dispatch") + ?.evidence, + ).toEqual(expect.arrayContaining(recordEvidence)); + expect(recordEvidence[0]?.location).toEqual(recordLocation); + }, +); + it("keeps typed callsite evidence and distinct fallback-provider routes for mixed reference kinds", async () => { const response = await publicTrace({}); expect(response.isError).not.toBe(true); diff --git a/tests/boundary/mcp/nativeValueTraceCleanupIncomplete.test.ts b/tests/boundary/mcp/nativeValueTraceCleanupIncomplete.test.ts new file mode 100644 index 0000000000..6e28b05750 --- /dev/null +++ b/tests/boundary/mcp/nativeValueTraceCleanupIncomplete.test.ts @@ -0,0 +1,334 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { CLEANUP_STOPPED_VALUE_TRACE } from "../../../src/application/native/NativeValueTrace.js"; +import { + failingValueTraceReads, + valueTraceDiamond, + valueTraceProvider as provider, + VALUE_TRACE_DIAMOND_READS as ALL_READS, + type ValueTraceDiamondRead, +} from "../../../src/application/native/NativeValueTrace.fixture.js"; +import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { nativeValueTraceSchema } from "../../../src/domain/native/nativeValueTrace.js"; +import { ok } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + closeEnhancedToolResources, + connect, + jsonResult, + resources, +} from "./enhancedToolsHarness.js"; + +afterEach(closeEnhancedToolResources); + +const CHILD_FAILURE = "controlled child failure"; + +const childFailure = (operation: string, cleanup: boolean) => { + const failure = new AnalysisCapabilityUnavailableError( + provider.id, + operation, + CHILD_FAILURE, + cleanup + ? { + cleanup: { + reason: "cleanup proof refused", + resources: ["owned worker"], + }, + } + : undefined, + ); + // The owner retaining the worker binds this retry; the trace must keep it. + return cleanup + ? failure.bindCleanupRetry({ operation: "close_binary" }) + : failure; +}; + +/** Serve the diamond, failing each listed read, and record every dispatched read. */ +const failingDiamond = ( + failures: Partial>, + options: { readonly sessionSubject?: boolean } = {}, +) => failingValueTraceReads(valueTraceDiamond(options).port, failures); + +const request = { + procedure: "0x1000", + max_depth: 1, + max_functions: 4, + max_call_sites: 20, + max_nodes: 100, + max_edges: 200, + limit: 100, + offset: 0, +}; +const trace = { name: "trace_native_values", arguments: request }; + +const stops = [ + { + read: "resolve_native_call_targets:0x1004", + stop: { procedure: "0x1000", address: "0x1004" }, + procedures: ["0x1000"], + calls: [], + // An unresolved call never discovers 0x2000. + ordinaryReads: [0, 1, 2, 5], + }, + { + read: "analyze_function:0x2000", + stop: { procedure: "0x2000", address: null }, + procedures: ["0x1000"], + calls: ["0x1000/call-b->0x2000", "0x1000/call-c->0x3000"], + // A failed dossier has no call sites to resolve. + ordinaryReads: [0, 1, 2, 3, 5], + }, + { + read: "resolve_native_call_targets:0x2004", + stop: { procedure: "0x2000", address: "0x2004" }, + procedures: ["0x1000", "0x2000"], + calls: ["0x1000/call-b->0x2000", "0x1000/call-c->0x3000"], + ordinaryReads: [0, 1, 2, 3, 4, 5], + }, +] as const; + +const callEdges = (value: z.output) => + value.edges + .filter(({ kind }) => kind === "call") + .map(({ source, target }) => `${source}->${target}`); + +describe("trace_native_values after a validated seed", () => { + it.each(stops)( + "stops at unconfirmed cleanup from $read and reports it with the admitted trace", + async ({ read, stop, procedures, calls }) => { + const operation = read.split(":")[0] ?? ""; + const { port, reads } = failingDiamond({ + [read]: childFailure(operation, true), + }); + const client = await connect(port); + + const { error } = parseMcpToolError(await client.callTool(trace)); + + // Nothing is dispatched after the read whose cleanup is unconfirmed. + expect(reads).toEqual(ALL_READS.slice(0, ALL_READS.indexOf(read) + 1)); + expect(error).toMatchObject({ + code: "cleanup_incomplete", + recovery: { kind: "retry_cleanup", operation: "close_binary" }, + details: { + operation, + cleanup: "incomplete", + resources: ["owned worker"], + cleanup_reason: "cleanup proof refused", + execution_failure: "capability_unavailable", + }, + }); + expect(JSON.stringify(error)).toContain(CHILD_FAILURE); + // Without an Evidence writer the admitted trace stays inline. + const partial = parseEvidence(error.details?.partial_observation); + expect(partial).toMatchObject({ + operation: "trace_native_values", + parameters: request, + }); + const admitted = nativeValueTraceSchema.parse(partial.normalized_result); + expect(admitted.procedures.map(({ address }) => address)).toEqual( + procedures, + ); + expect(callEdges(admitted)).toEqual(calls); + expect(admitted).toMatchObject({ + traversal: { status: "truncated", reasons: ["cleanup_incomplete"] }, + truncated: true, + }); + expect(admitted.unknowns).toContainEqual({ + ...stop, + reason: CLEANUP_STOPPED_VALUE_TRACE, + }); + // The stopping failure is the outcome, not one of the trace's unknowns. + expect(JSON.stringify(admitted.unknowns)).not.toContain(CHILD_FAILURE); + }, + ); + + it.each(stops)( + "keeps an ordinary failure from $read as a located unknown and continues", + async ({ read, stop, ordinaryReads }) => { + const operation = read.split(":")[0] ?? ""; + const { port, reads } = failingDiamond({ + [read]: childFailure(operation, false), + }); + const client = await connect(port); + + const response = await client.callTool(trace); + + expect(response.isError).not.toBe(true); + expect(reads).toEqual(ordinaryReads.map((index) => ALL_READS[index])); + const completed = nativeValueTraceSchema.parse(jsonResult(response)); + expect(completed.traversal).toEqual({ status: "complete" }); + expect(completed.unknowns).toContainEqual({ + ...stop, + reason: expect.stringContaining(CHILD_FAILURE), + }); + }, + ); + + it("keeps an earlier ordinary failure and dispatches nothing after a later cleanup failure", async () => { + const { port, reads } = failingDiamond({ + "resolve_native_call_targets:0x1004": childFailure( + "resolve_native_call_targets", + false, + ), + "resolve_native_call_targets:0x1008": childFailure( + "resolve_native_call_targets", + true, + ), + }); + const client = await connect(port); + + const { error } = parseMcpToolError(await client.callTool(trace)); + + expect(reads).toEqual(ALL_READS.slice(0, 3)); + expect(error).toMatchObject({ + code: "cleanup_incomplete", + details: { resources: ["owned worker"] }, + }); + const admitted = nativeValueTraceSchema.parse( + parseEvidence(error.details?.partial_observation).normalized_result, + ); + expect(admitted.unknowns).toEqual([ + { + procedure: "0x1000", + address: "0x1004", + reason: expect.stringContaining(CHILD_FAILURE), + }, + { + procedure: "0x1000", + address: "0x1008", + reason: CLEANUP_STOPPED_VALUE_TRACE, + }, + ]); + }); + + it("returns a seed cleanup failure without a trace", async () => { + const { port, reads } = failingDiamond({ + "analyze_function:0x1000": childFailure("analyze_function", true), + }); + const client = await connect(port); + + const { error } = parseMcpToolError(await client.callTool(trace)); + + expect(reads).toEqual(["analyze_function:0x1000"]); + expect(error).toMatchObject({ + code: "cleanup_incomplete", + details: { resources: ["owned worker"] }, + }); + expect(error.details?.partial_observation).toBeUndefined(); + }); +}); + +/** Serve the failing diamond through a production session with a committed profile. */ +const openSession = async (port: AnalysisOperationPort) => { + const directory = await createTestTempDirectory("rea-value-trace-cleanup-"); + const target = join(directory, "fixture.com"); + await writeFile(target, Buffer.from([0xb8, 0x00, 0x4c, 0xcd, 0x21])); + const session = createTestBinarySession({ + identity: () => provider, + capabilities: () => + (["analyze_function", "resolve_native_call_targets"] as const).map( + (operation) => ({ + provider, + operation, + available: true, + reason: null, + cachePolicy: "live", + limitations: [], + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + }), + ), + // A committed profile lets the session retain each admitted read. + resolveAnalysisProfile: async () => + ok({ profile: createAnalysisProfile(provider, { fixture: true }) }), + createClient: () => ({ + execute: (operation, parameters, options) => + port.execute(operation, parameters, options), + close: async () => ok(null), + }), + }); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "value-trace-cleanup", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: target, format: "dos-com" }, + }); + expect(opened.isError).not.toBe(true); + return { client, session }; +}; + +it("retains the admitted trace and its completed sources as session Evidence", async () => { + const { port, reads } = failingDiamond( + { + "analyze_function:0x3000": childFailure("analyze_function", true), + }, + { sessionSubject: true }, + ); + const { client, session } = await openSession(port); + + const { error } = parseMcpToolError(await client.callTool(trace)); + + expect(reads).toEqual(ALL_READS); + expect(error).toMatchObject({ + code: "cleanup_incomplete", + details: { resources: ["owned worker"] }, + }); + const reference = z + .strictObject({ + kind: z.literal("retained-evidence"), + evidence_id: z.string(), + }) + .parse(error.details?.partial_observation); + const exported = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect(exported.isError).not.toBe(true); + const bundle = evidenceBundleSchema.parse( + z.object({ result: z.unknown() }).parse(exported.structuredContent).result, + ); + expect(bundle.records.map(({ evidence_id }) => evidence_id)).toContain( + reference.evidence_id, + ); + const retained = session.evidenceForAnalysis(reference.evidence_id); + expect(retained).toMatchObject({ + operation: "trace_native_values", + subject: { format: "dos-com" }, + }); + expect( + nativeValueTraceSchema.parse(retained?.normalized_result).traversal, + ).toEqual({ status: "truncated", reasons: ["cleanup_incomplete"] }); + // Both dossiers completed before the stop stay linked as sources. + const dossiers = (retained?.evidence_links ?? []).flatMap((link) => { + const source = session.evidenceForAnalysis(link); + return source?.operation === "analyze_function" + ? [String(source.parameters.procedure)] + : []; + }); + expect(dossiers.sort()).toEqual(["0x1000", "0x2000"]); +}); diff --git a/tests/boundary/mcp/shutdown.test.ts b/tests/boundary/mcp/shutdown.test.ts index 21222d7bd5..bf01cebbd2 100644 --- a/tests/boundary/mcp/shutdown.test.ts +++ b/tests/boundary/mcp/shutdown.test.ts @@ -6,6 +6,10 @@ import { expect, it } from "vitest"; import { createManagedBinarySession } from "../../../src/composition/binary.js"; import { MCP_SHUTDOWN_FAILED } from "../../../src/main/messages.js"; import { createShutdown } from "../../../src/main/shutdown.js"; +import { + OwnedDebuggerCleanupError, + retainedDebuggerRemediation, +} from "../../../src/process/ownedDebuggerCleanupError.js"; import { registerProcessShutdown } from "../../../src/main/processShutdown.js"; import type { RuntimeDependencies } from "../../../src/main/types.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; @@ -75,6 +79,79 @@ it("keeps shutdown output stable while logging the rejected cause at debug level ); }); +it("names retained debuggers in stderr and every log level when shutdown ends", async () => { + const logs: string[] = []; + const output: string[] = []; + const requests: Array<() => void> = []; + const retained = new OwnedDebuggerCleanupError( + [new Error("detach unverified")], + "One or more owned GDB processes did not stop cleanly", + [4242], + ); + const dependencies: RuntimeDependencies = { + env: {}, + serve: () => { + throw new Error("unused fixture transport"); + }, + writeStderr: (text) => output.push(text), + setExitCode: () => undefined, + registerShutdown: (request) => { + requests.push(request); + return () => undefined; + }, + }; + createShutdown({ + // Runtime owners wrap this failure before it reaches the shutdown report. + handle: { + close: () => + Promise.reject( + new AggregateError( + [new AggregateError([retained], "REA provider cleanup failed")], + "REA server cleanup failed", + ), + ), + }, + session: createManagedBinarySession(), + dependencies, + serverLogger: pino( + { level: "debug" }, + { write: (line) => logs.push(line) }, + ), + }); + + requests[0]?.(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(output).toEqual([ + `${MCP_SHUTDOWN_FAILED}\n`, + `${retainedDebuggerRemediation([4242])}\n`, + ]); + // Comparing with the formatter alone would still pass if it dropped the process. + expect(output[1]).toMatch(/\b4242\b/u); + const parsed = logs.map((line) => JSON.parse(line)); + expect(parsed).toContainEqual( + expect.objectContaining({ + level: 50, + msg: MCP_SHUTDOWN_FAILED, + retained_debugger_pids: [4242], + }), + ); + expect(parsed).toContainEqual( + expect.objectContaining({ + level: 20, + failure_cause: expect.objectContaining({ + errors: [ + expect.objectContaining({ + errors: [ + expect.objectContaining({ retained_debugger_pids: [4242] }), + ], + }), + ], + }), + }), + ); +}); + it("reports a typed binary cleanup failure and retains the client for retry", async () => { const [target] = await createBinarySessionTargets(); const failure = new ProviderCleanupError("fixture", ["owned-runtime"], { diff --git a/tests/boundary/mcp/targetContentFramingMcp.test.ts b/tests/boundary/mcp/targetContentFramingMcp.test.ts index 2b575e5368..044509f6ab 100644 --- a/tests/boundary/mcp/targetContentFramingMcp.test.ts +++ b/tests/boundary/mcp/targetContentFramingMcp.test.ts @@ -1,4 +1,8 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { serverInstructions } from "../../../src/server/serverInstructions.js"; +import { SCOPED_NO_TARGET_RECOVERY } from "../../../src/server/recoveryInstructions.js"; +import { NoBinaryOpenError } from "../../../src/domain/configurationErrors.js"; import { UNTRUSTED_CONTENT_INSTRUCTIONS } from "../../../src/server/toolResultEncoding.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { @@ -8,6 +12,8 @@ import type { import { afterEach, describe, expect, it } from "vitest"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { peExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { err, ok } from "../../../src/domain/result.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; @@ -272,3 +278,83 @@ describe.each([ }, ); }); + +it.each([ + { scope: "direct", sessionAvailable: false }, + { scope: "session,direct", sessionAvailable: true }, + { scope: undefined, sessionAvailable: true }, +])( + "guides a missing target using the advertised scope ($scope)", + async ({ scope, sessionAvailable }) => { + const environment = { ...process.env }; + if (scope === undefined) delete environment.REA_MCP_TOOL_FAMILIES; + else environment.REA_MCP_TOOL_FAMILIES = scope; + const client = await connect(environment); + const names = (await client.listTools()).tools.map(({ name }) => name); + expect(names.includes("open_binary")).toBe(sessionAvailable); + const { error } = parseMcpToolError( + await client.callTool({ + name: "read_bytes", + arguments: { address: "0x1000", length: 4 }, + }), + ); + expect(error.code).toBe("target_unavailable"); + if (sessionAvailable) { + expect(error.remediation.action).toBe(new NoBinaryOpenError().message); + } else { + expect(error.message).toBe(SCOPED_NO_TARGET_RECOVERY); + expect(error.remediation.action).toBe(SCOPED_NO_TARGET_RECOVERY); + expect(error.remediation.action).toContain("REA_MCP_TOOL_FAMILIES"); + expect(error.remediation.action).toContain("open_binary"); + await expect( + client.callTool({ name: "open_binary", arguments: { path: "/tmp/x" } }), + ).rejects.toThrow(/disabled/u); + } + }, +); + +it("warns that retained evidence does not survive the scoped restart", async () => { + const directory = await createTestTempDirectory("rea-scoped-retained-"); + const path = join(directory, "fixture.exe"); + await writeFile(path, peExportImage({})); + const before = await connect({ + ...process.env, + REA_MCP_TOOL_FAMILIES: "direct,native,application", + }); + const observed = await before.callTool({ + name: "inspect_native_exports", + arguments: { path, detail: "summary" }, + }); + expect(observed.isError).not.toBe(true); + const summary = observed.structuredContent as { + normalized_result: { parent_evidence_id: string }; + }; + const view = { + source: { + kind: "retained-evidence", + evidence_id: summary.normalized_result.parent_evidence_id, + }, + view: { kind: "native-exports", collection: "exports" }, + }; + expect( + (await before.callTool({ name: "inspect_analysis_view", arguments: view })) + .isError, + ).not.toBe(true); + const failure = parseMcpToolError( + await before.callTool({ + name: "read_bytes", + arguments: { address: "0x1000", length: 4 }, + }), + ); + expect(failure.error.remediation.action).toBe(SCOPED_NO_TARGET_RECOVERY); + await before.close(); + + const after = await connect({ + ...process.env, + REA_MCP_TOOL_FAMILIES: "session,direct,native,application", + }); + const missing = parseMcpToolError( + await after.callTool({ name: "inspect_analysis_view", arguments: view }), + ); + expect(missing.error.code).toBe("evidence_integrity_mismatch"); +}); diff --git a/tests/boundary/mcp/webRuntimeMcp.test.ts b/tests/boundary/mcp/webRuntimeMcp.test.ts index 915214b2ce..7f66b2eba9 100644 --- a/tests/boundary/mcp/webRuntimeMcp.test.ts +++ b/tests/boundary/mcp/webRuntimeMcp.test.ts @@ -1,6 +1,6 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { Ajv2020 } from "ajv/dist/2020.js"; -import { expect, it, onTestFinished } from "vitest"; +import { afterAll, expect, it, onTestFinished } from "vitest"; +import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; import { WebRuntimeService } from "../../../src/application/WebRuntimeService.js"; import { recordingWebRuntimePort } from "../../../src/application/WebRuntimeService.fixture.js"; import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js"; @@ -11,11 +11,16 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { cliTest } from "../../support/cli/cliFixture.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { + negativeColumnScript, runtimeSourceA, runtimeSourceB, + startNegativeColumnBrowser, startRuntimeBrowser, } from "../../fixtures/webRuntime.js"; +const schemaValidation = createMcpSchemaValidation(); +afterAll(() => schemaValidation.clear()); + it("advertises actual SDK schemas, instrumentation effects and session-owned inline runtime evidence", async () => { const session = createTestBinarySession(() => { throw new Error("Binary provider must not start"); @@ -26,7 +31,10 @@ it("advertises actual SDK schemas, instrumentation effects and session-owned inl webRuntime: new WebRuntimeService(recordingWebRuntimePort()), }, ); - const client = new Client({ name: "web-runtime-contract", version: "1" }); + const client = new Client( + { name: "web-runtime-contract", version: "1" }, + { jsonSchemaValidator: schemaValidation }, + ); onTestFinished(async () => { await client.close(); await server.close(); @@ -37,7 +45,7 @@ it("advertises actual SDK schemas, instrumentation effects and session-owned inl await server.connect(serverTransport); await client.connect(clientTransport); const tools = (await client.listTools()).tools; - const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const ajv = schemaValidation.assertions; const input = { cdp_endpoint: "http://127.0.0.1:9222", target_id: "allowed-page", @@ -116,7 +124,10 @@ cliTest( { kind: "session", session }, { webRuntime: new WebRuntimeService(provider) }, ); - const client = new Client({ name: "runtime-frame-identity", version: "1" }); + const client = new Client( + { name: "runtime-frame-identity", version: "1" }, + { jsonSchemaValidator: schemaValidation }, + ); onTestFinished(async () => { await client.close(); await server.close(); @@ -179,6 +190,94 @@ cliTest( 20_000, ); +cliTest( + "delivers negative columns Chrome reports through MCP and the compiled CLI", + async ({ cli }) => { + const browser = await startNegativeColumnBrowser(); + const provider = new CdpWebRuntimeProvider(); + const session = createTestBinarySession(() => { + throw new Error("Binary provider must not start"); + }); + const server = createServer( + { kind: "session", session }, + { webRuntime: new WebRuntimeService(provider) }, + ); + const client = new Client( + { name: "runtime-negative-columns", version: "1" }, + { jsonSchemaValidator: schemaValidation }, + ); + onTestFinished(async () => { + await client.close(); + await server.close(); + await provider.close(); + await session.close(); + await browser.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const advertised = (await client.listTools()).tools.find( + ({ name }) => name === "inspect_web_event_listeners", + )?.outputSchema; + if (advertised === undefined) + throw new Error("Missing advertised listener output schema"); + const outputSchema: Record = advertised; + const response = await client.callTool({ + name: "inspect_web_event_listeners", + arguments: { + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + allowed_origins: [browser.allowedOrigin], + selector: "#selected", + }, + }); + expect(response.isError).not.toBe(true); + const ajv = schemaValidation.assertions; + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const cliResult = await cli.run({ + arguments: [ + "inspect-web-event-listeners", + browser.endpoint, + "allowed-page", + "#selected", + "--allowed-origins", + browser.allowedOrigin, + "--json", + ], + environment: process.env, + }); + expect(cliResult.exitCode).toBe(0); + for (const evidence of [ + toolContract("inspect_web_event_listeners").outputSchema.parse( + response.structuredContent, + ), + cliResult.json, + ]) + expect(parseEvidence(evidence).normalized_result).toMatchObject({ + listeners: [ + { + location: { + script_id: negativeColumnScript.scriptId, + line_number: 21, + column_number: -50, + }, + }, + ], + sources: [ + { + script_id: negativeColumnScript.scriptId, + resource_start: { line_number: 21, column_number: -60 }, + }, + ], + }); + }, + 20_000, +); + it.each([ { name: "observe_web_execution" as const, @@ -203,7 +302,10 @@ it.each([ { kind: "session", session }, { webRuntime: new WebRuntimeService(new CdpWebRuntimeProvider()) }, ); - const client = new Client({ name: "web-runtime-partial", version: "1" }); + const client = new Client( + { name: "web-runtime-partial", version: "1" }, + { jsonSchemaValidator: schemaValidation }, + ); onTestFinished(async () => { await client.close(); await server.close(); diff --git a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts index 7660265016..ef1dac9294 100644 --- a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts +++ b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts @@ -356,9 +356,11 @@ describe("MCP composed workflow snapshot replay", () => { arguments: {}, }); expect(retained.isError, JSON.stringify(retained.content)).not.toBe(true); - const retainedBundle = toolContract( - "get_evidence_bundle", - ).outputSchema.parse(retained.structuredContent).result; + const retainedBundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + retained.structuredContent, + ).result, + ); expect( retainedBundle.records.find( ({ evidence_id }) => evidence_id === primitiveEvidence.evidence_id, diff --git a/tests/boundary/process/adbVerifierCleanup.test.ts b/tests/boundary/process/adbVerifierCleanup.test.ts index bfe2e4fe2c..1b56fb29c1 100644 --- a/tests/boundary/process/adbVerifierCleanup.test.ts +++ b/tests/boundary/process/adbVerifierCleanup.test.ts @@ -144,7 +144,13 @@ export class AdbDeviceAnalysisService { (text) => text.trim().split("\n"), () => [], ); - return { ...run, created, closed, adbCalls, deviceFiles: await readdir(device) }; + return { + ...run, + created, + closed, + adbCalls, + deviceFiles: await readdir(device), + }; }; // The fake adb client is a POSIX shell script. @@ -257,14 +263,15 @@ it("reports both an unexpected verifier throw and provider cleanup failure", asy expect(result.closed).toBe("closed"); }); - it.each([["--install-apk", "/tmp/unrelated.apk"], ["--install-apk"]])( "rejects %s before contacting the device or acquiring provider resources", async (...args) => { const result = await runVerifier({ args }); expect(result.status).toBe(1); - expect(result.stderr).toContain("APK identity and device cleanup ownership cannot be established safely"); + expect(result.stderr).toContain( + "APK identity and device cleanup ownership cannot be established safely", + ); expect(result.created).toBeNull(); expect(result.closed).toBeNull(); expect(result.adbCalls).toEqual([]); diff --git a/tests/boundary/process/claudeVerifierSetupTargets.test.ts b/tests/boundary/process/claudeVerifierSetupTargets.test.ts index 995770f6e7..ae065868fe 100644 --- a/tests/boundary/process/claudeVerifierSetupTargets.test.ts +++ b/tests/boundary/process/claudeVerifierSetupTargets.test.ts @@ -5,16 +5,20 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, it } from "vitest"; -it.each(([ - ["claude", "relative path", false], - ["claude", "sibling", false], - ["claude", "parent traversal", false], - ["claude", "owned child", true], - ["opencode", "relative path", false], - ["opencode", "sibling", false], - ["opencode", "parent traversal", false], - ["opencode", "owned child", true], -] as const).filter(([client]) => process.platform !== "win32" || client !== "opencode"))( +it.each( + ( + [ + ["claude", "relative path", false], + ["claude", "sibling", false], + ["claude", "parent traversal", false], + ["claude", "owned child", true], + ["opencode", "relative path", false], + ["opencode", "sibling", false], + ["opencode", "parent traversal", false], + ["opencode", "owned child", true], + ] as const + ).filter(([client]) => process.platform !== "win32" || client !== "opencode"), +)( "%s checks a %s setup-plan target before writing", async (client, kind, allowed) => { const state = await mkdtemp(join(tmpdir(), "rea-claude-plan-")); diff --git a/tests/boundary/process/clientVerifierSetupTargets.test.ts b/tests/boundary/process/clientVerifierSetupTargets.test.ts index cbb0f16077..eb2cd6357d 100644 --- a/tests/boundary/process/clientVerifierSetupTargets.test.ts +++ b/tests/boundary/process/clientVerifierSetupTargets.test.ts @@ -5,25 +5,29 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, it } from "vitest"; -it.each(([ - ["gemini", "relative path", false], - ["gemini", "profile traversal", false], - ["gemini", "profile root alias", false], - ["gemini", "skill root alias", false], - ["gemini", "skill traversal", false], - ["gemini", "owned child", true], - ["copilot", "relative path", false], - ["copilot", "sibling", false], - ["copilot", "skill traversal", false], - ["copilot", "owned child", true], - ["qwen", "relative path", false], - ["qwen", "profile traversal", false], - ["qwen", "profile root alias", false], - ["qwen", "skill root alias", false], - ["qwen", "skill traversal", false], - ["qwen", "profile child", true], - ["qwen", "skill child", true], -] as const).filter(([client]) => process.platform !== "win32" || client !== "copilot"))( +it.each( + ( + [ + ["gemini", "relative path", false], + ["gemini", "profile traversal", false], + ["gemini", "profile root alias", false], + ["gemini", "skill root alias", false], + ["gemini", "skill traversal", false], + ["gemini", "owned child", true], + ["copilot", "relative path", false], + ["copilot", "sibling", false], + ["copilot", "skill traversal", false], + ["copilot", "owned child", true], + ["qwen", "relative path", false], + ["qwen", "profile traversal", false], + ["qwen", "profile root alias", false], + ["qwen", "skill root alias", false], + ["qwen", "skill traversal", false], + ["qwen", "profile child", true], + ["qwen", "skill child", true], + ] as const + ).filter(([client]) => process.platform !== "win32" || client !== "copilot"), +)( "%s checks %s setup-plan targets before writing", async (client, kind, allowed) => { const state = await mkdtemp(join(tmpdir(), "rea-client-plan-")); diff --git a/tests/boundary/process/commandcodeVerifierSkillBody.test.ts b/tests/boundary/process/commandcodeVerifierSkillBody.test.ts index 3df9272e11..457f39c8a2 100644 --- a/tests/boundary/process/commandcodeVerifierSkillBody.test.ts +++ b/tests/boundary/process/commandcodeVerifierSkillBody.test.ts @@ -76,7 +76,8 @@ export async function createVerifierCommands() { `.replace( "REPLACE_CONTRACTS", JSON.stringify( - new URL("../../../dist/contracts/toolContracts.js", import.meta.url).href, + new URL("../../../dist/contracts/toolContracts.js", import.meta.url) + .href, ), ); await writeFile(join(state, "commands.mjs"), commands); diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index dfd4edc423..63f77789c4 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -1317,6 +1317,29 @@ it.skipIf(!onDarwin)( missingAppleBoundaryFailsClosed: true, malformedBuffersRejected: true, invalidPointerSizeFailsClosed: true, + incompleteRestrictedArguments: [ + "malformed_procargs_path_padding", + "malformed_procargs_arguments", + "apple_vector_unavailable", + ].map((reason) => [ + { + state: "unavailable", + reason: "platform_binary_environment_withheld", + }, + { + state: "unavailable", + reason: "restricted_process_environment_withheld", + }, + { state: "unavailable", reason }, + ]), + signedReadableTokens: [true, true], + signedInvalidTokens: [ + "duplicate_run_token", + "invalid_run_token_encoding", + ].map((reason) => [ + { state: "unavailable", reason }, + { state: "unavailable", reason }, + ]), malformedStages: [ { state: "unavailable", reason: "malformed_procargs_header" }, { state: "unavailable", reason: "malformed_procargs_argument_count" }, @@ -1389,6 +1412,7 @@ it.skipIf(!onDarwin)( zombieLifecycleIsSeparateFromIdentity: true, effectiveUidComesFromNativeIdentity: true, nativeRelationIsSeparateFromIdentity: true, + nativeNameIsSeparateFromIdentity: true, }); } finally { await rm(root, { recursive: true, force: true }); diff --git a/tests/boundary/process/deepseekVerifierSkillBody.test.ts b/tests/boundary/process/deepseekVerifierSkillBody.test.ts index f20d1c0e25..2020878453 100644 --- a/tests/boundary/process/deepseekVerifierSkillBody.test.ts +++ b/tests/boundary/process/deepseekVerifierSkillBody.test.ts @@ -26,7 +26,8 @@ posix.each([ "import {mkdir,readFile,writeFile} from 'node:fs/promises'; import {join} from 'node:path'; import {TOOL_CONTRACTS} from REPLACE_CONTRACTS;\nlet setup=0;\nexport async function createVerifierCommands(){return {async exec(command,args,options={}){\n if(command==='git')return {stdout:'',stderr:''}; if(args.includes('--version'))return {stdout:'fake',stderr:''};\n const profile=options.env.DSH_HOME;const skill=join(options.env.HOME,'.agents','skills','reverse-engineer-anything');const config=join(profile,'cordis.patch.yml');\n if(args.includes('setup')&&args.includes('--dry-run'))return {stdout:JSON.stringify({status:'planned',plannedActions:[{target:config},{target:skill}]}),stderr:''};\n if(args.includes('setup')){if(setup++===0){await writeFile(config+'.rea.backup',await readFile(config));await mkdir(skill,{recursive:true});await writeFile(join(skill,'SKILL.md'),('---\\nname: reverse-engineer-anything\\n---\\n'+(process.env.BODY==='complete tags'?'Literal and \\n':'')+'Essential \"authored\" instructions\\nKeep a \\\\ literal\\n').replaceAll('\\n',process.env.BODY==='complete CRLF'?'\\r\\n':'\\n'));return {stdout:JSON.stringify({status:'ready'}),stderr:''};}return {stdout:JSON.stringify({appliedActions:[]}),stderr:''};}\n if(args.includes('doctor'))return {stdout:JSON.stringify({healthy:true}),stderr:''};\n const tools=[...TOOL_CONTRACTS.map(t=>({name:'mcp__rea__'+t.name,parameters:{type:'object',properties:{v:{type:'string',pattern:'^x$'}}}})),{name:'skill'}];\n const first=await globalThis.fakeRequest({body:{messages:[]},tools,requestIndex:1});\n const body=((process.env.BODY==='complete tags'?'Literal and \\n':'')+'Essential \"authored\" instructions\\nKeep a \\\\ literal').replaceAll('\\n',process.env.BODY==='complete CRLF'?'\\r\\n':'\\n');\n const escapedSkill=skill.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>'); const wrap='\\n\\n'+escapedSkill+(process.env.BODY==='closed early'?'':'')+(process.env.BODY==='inside resources'?body:'')+'\\n\\n\\n';\n const payload=wrap+(process.env.BODY==='closed wrapper'?'':'')+((process.env.BODY.startsWith('complete')||process.env.BODY==='closed wrapper'||process.env.BODY==='closed early')?'\\n'+body+'\\n\\n':'')+''+(process.env.BODY==='outside wrapper'?body:'');\n const messages=[{role:'tool',content:payload},...(process.env.BODY==='split messages'?[{role:'tool',content:body}]:[])];\n const second=await globalThis.fakeRequest({body:{messages},tools,requestIndex:2});\n await writeFile(process.env.STATE+'/observed.json',JSON.stringify({first,second,payload}));throw Error('intentional stop after empty-skill request');\n },async close(){}};}\n".replace( "REPLACE_CONTRACTS", JSON.stringify( - new URL("../../../dist/contracts/toolContracts.js", import.meta.url).href, + new URL("../../../dist/contracts/toolContracts.js", import.meta.url) + .href, ), ); await writeFile(join(state, "commands.mjs"), commands); diff --git a/tests/boundary/process/geminiVerifierSkillBody.test.ts b/tests/boundary/process/geminiVerifierSkillBody.test.ts index 46d1d53d9c..422b3b3ec8 100644 --- a/tests/boundary/process/geminiVerifierSkillBody.test.ts +++ b/tests/boundary/process/geminiVerifierSkillBody.test.ts @@ -23,7 +23,8 @@ it.each([ "import {mkdir,readFile,writeFile} from 'node:fs/promises';import {join} from 'node:path';import {TOOL_CONTRACTS} from REPLACE_CONTRACTS;\nlet setup=0;export async function createVerifierCommands(){return {async exec(command,args,options={}){\nif(command==='git')return {stdout:'',stderr:''};if(args.includes('--version'))return {stdout:'fake',stderr:''};\nconst profile=options.env.GEMINI_CLI_HOME;const config=join(profile,'.gemini','settings.json');const skill=join(profile,'.agents','skills','reverse-engineer-anything');\nif(args.includes('setup')&&args.includes('--dry-run'))return {stdout:JSON.stringify({status:'planned',plannedActions:[{target:config},{target:join(skill,'SKILL.md')}]}),stderr:''};\nif(args.includes('setup')){if(setup++===0){await writeFile(config+'.rea.backup',await readFile(config));await mkdir(skill,{recursive:true});await writeFile(join(skill,'SKILL.md'),('---\\nname: reverse-engineer-anything\\n---\\n'+(process.env.BODY==='complete tags'?'literal and \\n':'')+'Essential \"instructions\"\\nKeep a \\\\ literal\\n').replaceAll('\\n',process.env.BODY==='complete CRLF'?'\\r\\n':'\\n'));return {stdout:JSON.stringify({status:'ready'}),stderr:''};}return {stdout:JSON.stringify({appliedActions:[]}),stderr:''};}\nif(args.includes('doctor'))return {stdout:JSON.stringify({healthy:true}),stderr:''};\nconst tools=[...TOOL_CONTRACTS.map(t=>({name:'rea__'+t.name,parametersJsonSchema:{type:'object'}})),{name:'activate_skill'}];\nconst first=await globalThis.fakeRequest({body:{contents:[]},tools});\nconst body=((process.env.BODY==='complete tags'?'literal and \\n':'')+'Essential \"instructions\"\\nKeep a \\\\ literal').replaceAll('\\n',process.env.BODY==='complete CRLF'?'\\r\\n':'\\n');\nconst valid=(process.env.BODY.startsWith('complete')||process.env.BODY==='closed early');\nconst instructions=valid?' \\n '+body+'\\n \\n\\n':'';\nconst output='\\n'+instructions+' \\n '+skill+(process.env.BODY==='closed early'?'':'')+(process.env.BODY==='inside resources'?body:'')+'\\n \\n'+(process.env.BODY==='outside wrapper'?body:'');\nconst responses=[{functionResponse:{name:'activate_skill',response:{output}}},...(process.env.BODY==='split messages'?[{functionResponse:{name:'activate_skill',response:{output:body}}}]:[])];\nconst second=await globalThis.fakeRequest({body:{contents:[{parts:responses}]},tools});const payload=output;\nawait writeFile(process.env.STATE+'/observed.json',JSON.stringify({first,second,payload}));throw Error('intentional stop before client mutation');},async close(){}};}\n".replace( "REPLACE_CONTRACTS", JSON.stringify( - new URL("../../../dist/contracts/toolContracts.js", import.meta.url).href, + new URL("../../../dist/contracts/toolContracts.js", import.meta.url) + .href, ), ); await writeFile(join(state, "commands.mjs"), commands); diff --git a/tests/boundary/process/mcpVerifierResults.test.ts b/tests/boundary/process/mcpVerifierResults.test.ts index 0aa4891be6..a249e08f80 100644 --- a/tests/boundary/process/mcpVerifierResults.test.ts +++ b/tests/boundary/process/mcpVerifierResults.test.ts @@ -199,6 +199,8 @@ it("retains summary discovery in the generic lifecycle reader but refuses it as total_retained_records: 0, matching_records: 0, unknown_revisions: 0, + direct_links: 0, + missing_direct_links: 0, records: [], }; expect( diff --git a/tests/boundary/process/nativeUiVerifierLane.test.ts b/tests/boundary/process/nativeUiVerifierLane.test.ts new file mode 100644 index 0000000000..5ddf216c58 --- /dev/null +++ b/tests/boundary/process/nativeUiVerifierLane.test.ts @@ -0,0 +1,222 @@ +import { spawn } from "node:child_process"; +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { systemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js"; +import { liveProcesses } from "../../../src/process/ProcessOwnershipProcessTree.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +// Stands in for /usr/bin/open: starts a detached app that inherits its environment. +// Each stand-in records itself and leaves when the test releases it, or expires. +const standInPrelude = ` +import { appendFileSync, existsSync, readFileSync } from "node:fs"; +const config = JSON.parse(readFileSync(new URL("./config.json", import.meta.url), "utf8")); +const record = (kind) => appendFileSync(config.events, JSON.stringify({ kind, pid: process.pid }) + "\\n"); +setInterval(() => { + if (existsSync(config.release)) process.exit(0); +}, 10); +setTimeout(() => process.exit(0), 60000); +`; + +const launcherSource = ` +import { spawn } from "node:child_process"; +import { openSync } from "node:fs"; +${standInPrelude} +record("launcher"); +const args = process.argv.slice(2); +const coordinates = args[args.indexOf("--stdout") + 1]; +spawn(process.execPath, [new URL("./fixture.mjs", import.meta.url).pathname], { + detached: true, + stdio: ["ignore", openSync(coordinates, "a"), "ignore"], +}).unref(); +`; + +const fixtureSource = ` +import { writeSync } from "node:fs"; +${standInPrelude} +record("fixture"); +writeSync(1, JSON.stringify({ pid: process.pid, window_id: 7 }) + "\\n"); +`; + +// Runs the actual verifier and observation mapping. Only the compiler and launcher +// are replaced: the compiled helper is an authored script answering as the real +// helper does, so this covers the lane's assertions and not native capture. +const preloadSource = ` +import { chmodSync, copyFileSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire, syncBuiltinESMExports } from "node:module"; +import { basename } from "node:path"; +import { promisify } from "node:util"; +const config = JSON.parse(readFileSync(new URL("./config.json", import.meta.url), "utf8")); +const childProcess = createRequire(import.meta.url)("node:child_process"); +const { execFile, spawn } = childProcess; +const script = (path, body) => { + writeFileSync(path, "#!/bin/sh\\n" + body + "\\n"); + chmodSync(path, 0o755); +}; +const compile = (args) => { + const output = args[args.indexOf("-o") + 1]; + if (basename(output) === "fixture") copyFileSync("/usr/bin/true", output); + else if (basename(output) === "child-retrieval-test") + script(output, "printf '%s\\\\n' '{\\"a\\":\\"b\\"}' '{\\"n\\":1.0}'"); + else script(output, config.helper); +}; +const compiled = (file, args) => file === "/usr/bin/xcrun" && args.includes("-o"); +childProcess.execFile = (file, args, ...rest) => { + if (!compiled(file, args)) return execFile(file, args, ...rest); + compile(args); + const done = rest.at(-1); + queueMicrotask(() => done(null, "", "")); + return spawn("/usr/bin/true", [], { stdio: "ignore" }); +}; +childProcess.execFile[promisify.custom] = (file, args, ...rest) => { + if (!compiled(file, args)) return execFile[promisify.custom](file, args, ...rest); + compile(args); + return Promise.resolve({ stdout: "", stderr: "" }); +}; +childProcess.spawn = (file, args, options) => { + if (compiled(file, args)) { + compile(args); + return spawn("/usr/bin/true", [], options); + } + return file === "/usr/bin/open" + ? spawn(process.execPath, [config.launcherEntry, ...args], options) + : spawn(file, args, options); +}; +syncBuiltinESMExports(); +`; + +const denied = `printf '%s' '{"ok":false,"code":"screen-recording-denied","message":"authored permission denial"}'`; +const answersChangedBytes = `case "$1" in *'"sha256":"0000000000'*) printf '%s' '{"ok":false,"code":"target-mismatch","message":"authored digest refusal"}'; exit 0;; esac; ${denied}`; + +const standInSchema = z.object({ + kind: z.enum(["launcher", "fixture"]), + pid: z.number().int().positive(), +}); + +const runVerifier = async (helper: string, flags: readonly string[]) => { + const workspace = await createTestTempDirectory("rea-native-ui-lane-"); + const paths = { + events: join(workspace, "events.jsonl"), + release: join(workspace, "release"), + launcherEntry: join(workspace, "launcher.mjs"), + }; + await writeFile(paths.events, ""); + await writeFile(paths.launcherEntry, launcherSource); + await writeFile(join(workspace, "fixture.mjs"), fixtureSource); + await writeFile(join(workspace, "preload.mjs"), preloadSource); + await writeFile( + join(workspace, "config.json"), + JSON.stringify({ ...paths, helper }), + ); + const child = spawn( + process.execPath, + [ + "--import", + pathToFileURL(join(workspace, "preload.mjs")).href, + resolve("scripts/verify-native-ui.mjs"), + ...flags, + ], + { + env: { ...process.env, TMPDIR: workspace }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const standIns = async () => + (await readFile(paths.events, "utf8")) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => standInSchema.parse(JSON.parse(line))); + const isLive = async (pid: number) => + liveProcesses(await systemProcessOwnershipHost.listProcesses()).some( + (process) => process.pid === pid, + ); + const untilGone = async (pid: number) => { + const deadline = performance.now() + 15_000; + while ((await isLive(pid)) && performance.now() < deadline) await delay(25); + return !(await isLive(pid)); + }; + // Runs on a timeout or failed assertion too: the verifier cannot release a stand-in + // once killed, and its fixture would otherwise outlive the test until it expired. + onTestFinished(async () => { + child.kill("SIGKILL"); + await writeFile(paths.release, ""); + for (const { pid } of await standIns()) + expect(await untilGone(pid)).toBe(true); + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + const code = await new Promise((settle) => { + child.once("close", settle); + }); + const fixture = (await standIns()).find(({ kind }) => kind === "fixture"); + expect(fixture, stderr).toBeDefined(); + return { + code, + stdout, + stderr, + fixtureLive: !(await untilGone(fixture?.pid ?? -1)), + roots: (await readdir(workspace)).filter((name) => + name.startsWith("rea-ui-fixture-"), + ), + }; +}; + +// The verifier refuses other hosts before any fixture is created. +const macosIt = it.skipIf(process.platform !== "darwin"); + +macosIt( + "completes at the permission boundary when the helper refuses changed target bytes", + { timeout: 30_000 }, + async () => { + const result = await runVerifier(answersChangedBytes, [ + "--allow-permission-denial", + ]); + expect(result.code, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + verification_status: "permission-boundary-only", + positive_e2e: false, + mismatch_rejected: true, + }); + expect(result.fixtureLive).toBe(false); + expect(result.roots).toEqual([]); + }, +); + +macosIt( + "fails when the helper does not refuse changed target bytes", + { timeout: 30_000 }, + async () => { + const result = await runVerifier(denied, ["--allow-permission-denial"]); + expect(result.code).toBe(1); + expect(result.stderr).toContain( + "Real helper did not reject changed target bytes", + ); + expect(result.stdout).toBe(""); + expect(result.fixtureLive).toBe(false); + expect(result.roots).toEqual([]); + }, +); + +macosIt( + "keeps a permission denial a failure in the positive lane", + { timeout: 30_000 }, + async () => { + const result = await runVerifier(answersChangedBytes, []); + expect(result.code).toBe(1); + expect(result.stderr).toContain("requires successful capture"); + expect(result.stdout).toBe(""); + expect(result.fixtureLive).toBe(false); + expect(result.roots).toEqual([]); + }, +); diff --git a/tests/boundary/process/nativeValueVerifierComparison.test.ts b/tests/boundary/process/nativeValueVerifierComparison.test.ts new file mode 100644 index 0000000000..f73878257a --- /dev/null +++ b/tests/boundary/process/nativeValueVerifierComparison.test.ts @@ -0,0 +1,102 @@ +import { fileURLToPath } from "node:url"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { processTest } from "../../support/process/processFixture.js"; + +const moduleUrl = (path: string) => + JSON.stringify(new URL(`../../../${path}`, import.meta.url).href); + +processTest.for([ + { coverage: "complete", status: "unchanged" }, + { coverage: "partial", status: "unknown" }, +] as const)( + "compares retained $coverage function Evidence within default MCP limits", + async ({ coverage, status }, { processes }) => { + const root = await createTestTempDirectory("rea-verifier-comparison-"); + const source = ` + import assert from "node:assert/strict"; + import { writeFile } from "node:fs/promises"; + import { join } from "node:path"; + import { Client } from "@modelcontextprotocol/client"; + import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + import { FUNCTION_COMPARISON_EXAMPLE } from ${moduleUrl("dist/contracts/functionComparisonExample.js")}; + import { createEvidence } from ${moduleUrl("dist/domain/evidence.js")}; + import { createEvidenceBundle } from ${moduleUrl("dist/domain/evidenceBundle.js")}; + import { functionDossierSchema } from ${moduleUrl("dist/domain/native/functionDossier.js")}; + import { compareRetainedFunctionEvidence } from ${moduleUrl("scripts/lib/native-value-e2e.mjs")}; + import { requireMcpEvidenceBundle } from ${moduleUrl("scripts/lib/mcp-verifier-results.mjs")}; + + // Explicit synthetic facts qualify this adapter boundary, not real Ghidra. + const dossier = functionDossierSchema.parse( + structuredClone(FUNCTION_COMPARISON_EXAMPLE.left.normalized_result)); + dossier.assembly = Array.from({ length: 1200 }, (_, i) => "synthetic instruction " + i); + const evidence = createEvidence({ path: "/synthetic/function.elf", + sha256: "a".repeat(64), format: "elf", architecture: "x86_64" }, + { id: "synthetic-verifier-facts", name: "Explicit synthetic facts", version: "1" }, + { operation: "analyze_function", parameters: { procedure: "main" }, result: dossier, + relationshipCoverage: { status: ${JSON.stringify(coverage)}, + ...(${JSON.stringify(coverage)} === "partial" + ? { limitations: ["Explicit synthetic unresolved relationships."] } : {}) } }); + const bundlePath = join(process.argv[1], "source.json"); + await writeFile(bundlePath, JSON.stringify(createEvidenceBundle([evidence], []))); + const transport = new StdioClientTransport({ command: process.execPath, + args: [${JSON.stringify(fileURLToPath(new URL("../../../scripts/rea.mjs", import.meta.url)))}, "mcp"], + env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent", + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper" }, stderr: "pipe" }); + const client = new Client({ name: "synthetic-verifier-comparison", version: "1" }); + try { + await client.connect(transport); + const imported = await client.callTool({ name: "import_evidence_bundle", + arguments: { path: bundlePath } }); + assert.notEqual(imported.isError, true, JSON.stringify(imported)); + const inline = await client.callTool({ name: "compare_functions", + arguments: { left: evidence, right: evidence } }); + assert.equal(inline.isError, true, JSON.stringify(inline)); + const error = JSON.parse(inline.content.find(item => item.type === "text").text).error; + assert.equal(error.code, "resource_constraint"); + assert.equal(error.details.reported_limits.max_input_elements, 1024); + + // Execute the same request producer used by both real-provider controls. + const compared = await compareRetainedFunctionEvidence(client, evidence); + assert.equal(compared.normalized_result.status, ${JSON.stringify(status)}); + assert.ok(compared.evidence_links.includes(evidence.evidence_id)); + const bundle = requireMcpEvidenceBundle(await client.callTool({ + name: "get_evidence_bundle", arguments: {} })); + for (const record of [evidence, compared]) + assert.deepEqual(bundle.records.find(item => item.evidence_id === record.evidence_id), record); + const missingId = "ev_" + "b".repeat(64); + let missingFailure; + const checkedClient = { callTool: async (...args) => { + const reply = await client.callTool(...args); + assert.equal(reply.isError, true); + missingFailure = JSON.parse(reply.content.find(item => item.type === "text").text).error; + return reply; + } }; + await assert.rejects(() => compareRetainedFunctionEvidence(checkedClient, + { ...evidence, evidence_id: missingId }), { name: "AssertionError" }); + assert.equal(missingFailure.details.reason, "missing"); + assert.equal(missingFailure.details.evidence_id, missingId); + assert.match(missingFailure.message, /not available from the selected source/); + console.log(JSON.stringify({ status: compared.normalized_result.status, + exact_source_link: true, complete_records_retained: true, missing_reference_refused: true })); + } finally { + await client.close(); + await transport.close(); + } + `; + const result = await processes.run(process.execPath, [ + "--input-type=module", + "--eval", + source, + root, + ]); + expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ + status, + exact_source_link: true, + complete_records_retained: true, + missing_reference_refused: true, + }); + }, +); diff --git a/tests/boundary/process/ownedDebuggerSession.test.ts b/tests/boundary/process/ownedDebuggerSession.test.ts index 155f7457e1..c46a66d7ab 100644 --- a/tests/boundary/process/ownedDebuggerSession.test.ts +++ b/tests/boundary/process/ownedDebuggerSession.test.ts @@ -9,8 +9,14 @@ import { spawn as spawnPty } from "@lydell/node-pty"; import { expect, it, onTestFinished, vi } from "vitest"; +import { + OwnedDebuggerCleanupError, + retainedDebuggerProcessIds, + retainedDebuggerRemediation, +} from "../../../src/process/ownedDebuggerCleanupError.js"; import { closeOwnedDebuggerSessions, + type OwnedDebuggerStopResult, stopOwnedDebuggerProcess, verifyOwnedInferiorsEnded, } from "../../../src/process/ownedDebuggerSession.js"; @@ -29,6 +35,15 @@ const createFakeSupervisor = () => ({ limitAdditionalOutputBytes: vi.fn(), dispose: vi.fn(), unrefOutputStreams: vi.fn(), + ref: vi.fn(), + unref: vi.fn(), +}); + +/** A session reduced to what closing all sessions reads from it. */ +const closableSession = (id: string, pid?: number) => ({ + id, + launched: { process: { pid } }, + supervisor: { ref: vi.fn(), unref: vi.fn() }, }); const startStopProbe = (source: string, pipeInput = false) => { @@ -533,7 +548,7 @@ it("retains an exited debugger after provider detach failed", async () => { get exited() { return exited; }, - launched: { process: { kill: () => true } }, + launched: { process: { pid: 101, kill: () => true } }, detachInferior: async () => { throw new Error("MI detach failed"); }, @@ -545,6 +560,8 @@ it("retains an exited debugger after provider detach failed", async () => { disposals += 1; }, unrefOutputStreams: () => {}, + ref: () => {}, + unref: () => {}, }, }; const sessions = new Map([[session.id, session]]); @@ -580,7 +597,7 @@ it("recovers failed detach only after the identified inferior ends and output se get exited() { return exited; }, - launched: { process: { kill: vi.fn(() => true) } }, + launched: { process: { pid: 101, kill: vi.fn(() => true) } }, detachInferior: async () => { throw new Error("detach failed"); }, @@ -765,7 +782,7 @@ it("retains an exited session while a timed-out detach attempt is still pending" get exited() { return exited; }, - launched: { process: { kill: () => true } }, + launched: { process: { pid: 101, kill: () => true } }, detachInferior: () => { detachCalls += 1; return new Promise<{ readonly status: "no-inferior" }>((resolve) => { @@ -778,6 +795,8 @@ it("retains an exited session while a timed-out detach attempt is still pending" waitForOutputClose: async () => true, dispose: vi.fn(), unrefOutputStreams: vi.fn(), + ref: vi.fn(), + unref: vi.fn(), }, }; const sessions = new Map([[session.id, session]]); @@ -1365,8 +1384,8 @@ it.skipIf(process.platform === "win32")( it("removes stopped sessions and retains incomplete sessions when closing all", async () => { const sessions = new Map([ - ["stopped", { id: "stopped" }], - ["incomplete", { id: "incomplete" }], + ["stopped", closableSession("stopped")], + ["incomplete", closableSession("incomplete")], ]); await expect( @@ -1385,8 +1404,8 @@ it("removes stopped sessions and retains incomplete sessions when closing all", }); it("does not remove a replacement session installed under the same key while stopping", async () => { - const original = { id: "shared-key", generation: "old" }; - const replacement = { id: "shared-key", generation: "new" }; + const original = closableSession("shared-key"); + const replacement = closableSession("shared-key"); const sessions = new Map([["shared-key", original]]); let finishStop!: (result: { readonly status: "stopped" }) => void; const stopping = new Promise<{ readonly status: "stopped" }>((resolve) => { @@ -1407,7 +1426,7 @@ it("does not remove a replacement session installed under the same key while sto }); it("closes an empty owned-session collection", async () => { - const sessions = new Map(); + const sessions = new Map>(); await expect( closeOwnedDebuggerSessions( sessions, @@ -1419,10 +1438,10 @@ it("closes an empty owned-session collection", async () => { it("settles every stop and retains rejected sessions while removing successful ones", async () => { const sessions = new Map([ - ["stopped", { id: "stopped" }], - ["rejected", { id: "rejected" }], - ["string-rejected", { id: "string-rejected" }], - ["incomplete", { id: "incomplete" }], + ["stopped", closableSession("stopped")], + ["rejected", closableSession("rejected")], + ["string-rejected", closableSession("string-rejected")], + ["incomplete", closableSession("incomplete")], ]); const calls: string[] = []; @@ -1455,3 +1474,88 @@ it("settles every stop and retains rejected sessions while removing successful o "incomplete", ]); }); + +it("stops retained sessions holding the host and names them, leaving stopped peers alone", async () => { + const stopped = closableSession("stopped", 101); + const rejected = closableSession("rejected", 102); + const incomplete = closableSession("incomplete", 103); + const unidentified = closableSession("unidentified"); + const sessions = new Map( + [stopped, rejected, incomplete, unidentified].map((session) => [ + session.id, + session, + ]), + ); + + const failure: unknown = await closeOwnedDebuggerSessions( + sessions, + async (session) => { + if (session === stopped) return { status: "stopped" }; + if (session === rejected) throw new Error("signal failed"); + return { status: "incomplete", reason: "still alive" }; + }, + "Rizin", + ).catch((cause: unknown) => cause); + + expect(failure).toBeInstanceOf(OwnedDebuggerCleanupError); + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as OwnedDebuggerCleanupError).retainedProcessIds).toEqual([ + 102, 103, + ]); + expect(stopped.supervisor.unref).not.toHaveBeenCalled(); + for (const retained of [rejected, incomplete, unidentified]) + expect(retained.supervisor.unref).toHaveBeenCalledOnce(); + expect([...sessions.keys()]).toEqual([ + "rejected", + "incomplete", + "unidentified", + ]); +}); + +it("holds the host again while retrying a retained session and leaves it held on success", async () => { + const session = closableSession("retained", 101); + const sessions = new Map([[session.id, session]]); + const order: string[] = []; + session.supervisor.ref.mockImplementation(() => order.push("ref")); + session.supervisor.unref.mockImplementation(() => order.push("unref")); + let stopResult: OwnedDebuggerStopResult = { + status: "incomplete", + reason: "still alive", + }; + const stop = async () => { + order.push("stop"); + return stopResult; + }; + + await expect( + closeOwnedDebuggerSessions(sessions, stop, "GDB"), + ).rejects.toBeInstanceOf(OwnedDebuggerCleanupError); + expect(order).toEqual(["ref", "stop", "unref"]); + + order.length = 0; + stopResult = { status: "stopped" }; + await closeOwnedDebuggerSessions(sessions, stop, "GDB"); + expect(order).toEqual(["ref", "stop"]); + expect(sessions.size).toBe(0); +}); + +it("finds retained debugger IDs through composed failures without looping on cycles", () => { + const first = new OwnedDebuggerCleanupError([], "first", [101, 102]); + const second = new OwnedDebuggerCleanupError([], "second", [102, 103]); + const cyclic = new Error("cyclic", { cause: second }); + Object.assign(second, { cause: cyclic }); + const composed = new AggregateError( + [first, new Error("wrapped", { cause: cyclic }), new Error("peer"), null], + "outer", + ); + + expect(retainedDebuggerProcessIds(composed)).toEqual([101, 102, 103]); + expect(retainedDebuggerProcessIds(new Error("peer"))).toEqual([]); + expect(retainedDebuggerProcessIds(undefined)).toEqual([]); +}); + +it("names every retained debugger in its remediation", () => { + const remediation = retainedDebuggerRemediation([4242, 4243]); + expect(remediation).toMatch(/\b4242\b/u); + expect(remediation).toMatch(/\b4243\b/u); +}); diff --git a/tests/boundary/process/processSessionMcp.test.ts b/tests/boundary/process/processSessionMcp.test.ts index 565f16a744..094757bd90 100644 --- a/tests/boundary/process/processSessionMcp.test.ts +++ b/tests/boundary/process/processSessionMcp.test.ts @@ -115,11 +115,20 @@ itWithCaptureCapability( }; const expanded = await capture("x".repeat(500)); - expect(expanded.frames).toHaveLength(0); expect(expanded.truncated).toBe(true); + const retainedNormalized = expanded.frames.map(({ data }) => data).join(""); + const retainedRaw = expanded.frames + .map(({ raw_data, data }) => raw_data ?? data) + .join(""); + // PTY read boundaries need not match the child's write. A small chunk can + // fit even after another chunk was omitted for exceeding the budget. + expect(retainedNormalized).toBe( + retainedRaw.replaceAll("a", "x".repeat(500)), + ); + expect(Buffer.byteLength(retainedNormalized)).toBeLessThanOrEqual(1_000); expect(expanded.truncation_details.normalized_terminal).toMatchObject({ budget_bytes: 1_000, - retained_bytes: 0, + retained_bytes: Buffer.byteLength(retainedNormalized), omitted_frames: expect.any(Number), }); expect( @@ -128,9 +137,9 @@ itWithCaptureCapability( expect(expanded.truncation_details.raw_terminal).toMatchObject({ budget_bytes: 1_000, observed_bytes: expect.any(Number), - retained_bytes: 0, + retained_bytes: Buffer.byteLength(retainedRaw), observed_frames: expect.any(Number), - retained_frames: 0, + retained_frames: expanded.frames.length, }); expect( expanded.limitations.some((limitation) => diff --git a/tests/boundary/process/providerVerifierCleanup.test.ts b/tests/boundary/process/providerVerifierCleanup.test.ts index 037917f2c2..1ec3be5aba 100644 --- a/tests/boundary/process/providerVerifierCleanup.test.ts +++ b/tests/boundary/process/providerVerifierCleanup.test.ts @@ -57,7 +57,7 @@ const verifierFixture = async (provider: Provider) => { if (provider === "frida") { await mkdir(join(root, "dist", "frida")); - await mkdir(join(root, "dist", "contracts")); + await mkdir(join(root, "dist", "domain")); await writeFile( join(root, "dist", "frida", "FridaInstrumentationManager.js"), `import { appendFile } from "node:fs/promises"; @@ -89,7 +89,7 @@ export class FridaInstrumentationManager { `, ); await writeFile( - join(root, "dist", "contracts", "fridaRemoteAddress.js"), + join(root, "dist", "domain", "fridaRemoteAddress.js"), "export const isValidFridaRemoteAddress = () => false;\n", ); } else { diff --git a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts index a633278d30..329ee3c8b2 100644 --- a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts @@ -201,7 +201,7 @@ test("traces a procedure found only by its alias through the composed feature tr test("omits a data entry an alias names while keeping the code entry that shares it", async ({ mcp, }) => { - const { search, observe } = await aliasSession( + const { client, search, observe } = await aliasSession( mcp, "rea-kuna-alias-macho-", { @@ -234,4 +234,52 @@ test("omits a data entry an alias names while keeping the code entry that shares ), ); expect(await search({ pattern: "global_count" })).toEqual([]); + const { error } = parseMcpToolError( + await client.callTool({ + name: "procedure_address", + arguments: { procedure: "global_count" }, + }), + ); + expect(error.recovery).toBeUndefined(); + expect(error.remediation.action).toContain("address_name"); +}); + +test("reports lookup failures with recovery for the selected target, not the input schema", async ({ + mcp, +}) => { + const { client } = await aliasSession(mcp, "rea-kuna-lookup-recovery-", { + functions: VERSIONED, + }); + for (const [procedure, guidance] of [ + ["0x1335", "list_procedures"], + ["absent_name", "search_procedures"], + ["audit_transform", "exact address"], + ]) { + const response = await client.callTool({ + name: "procedure_address", + arguments: { procedure }, + }); + const { error } = parseMcpToolError(response); + expect(error.code).toBe("invalid_request"); + expect(error.recovery).toBeUndefined(); + expect(error.remediation.action).toContain(guidance); + expect(error.remediation.action).not.toContain("input schema"); + } + const assemblyError = parseMcpToolError( + await client.callTool({ + name: "procedure_assembly", + arguments: { procedure: "0x1335" }, + }), + ).error; + expect(assemblyError.remediation.action).toContain("Kuna procedure"); + expect(assemblyError.remediation.action).not.toContain("decompile"); + const missingAddress = parseMcpToolError( + await client.callTool({ + name: "xrefs", + arguments: { address: "absent_name" }, + }), + ).error; + expect(missingAddress.recovery).toBeUndefined(); + expect(missingAddress.message).toContain("no inventory entry named"); + expect(missingAddress.remediation.action).toContain("address"); }); diff --git a/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts b/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts index afe215505a..59365f847e 100644 --- a/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts @@ -1,3 +1,4 @@ +import { batchDecompileResultSchema } from "../../../../src/domain/native/batchDecompile.js"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -88,11 +89,43 @@ test("keeps each Kuna no-pseudocode observation's limitations on its batch item" }) ).structuredContent, ); + // The provider's version policy travels with this observation, while its + // profile commits only analysis inputs. Composed and retained views must + // preserve the same warning for agents reading those results. + const verificationWarning = + "Kuna 1.833 is not REA's verified 1.989 release"; + expect(batch.limitations).toContainEqual( + expect.stringContaining(verificationWarning), + ); + expect(broken.analysis_profile?.parameters).not.toHaveProperty( + "verified_release", + ); + const summary = parseEvidence( + (await call("analyze_function", { procedure: "main", detail: "summary" })) + .structuredContent, + ); + const parent = summary.normalized_result as { parent_evidence_id: string }; + const page = parseEvidence( + ( + await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: parent.parent_evidence_id, + }, + view: { kind: "native", facet: "pseudocode" }, + }) + ).structuredContent, + ); + expect(page.limitations).toContainEqual( + expect.stringContaining(verificationWarning), + ); if (batchDecompile === undefined || getEvidenceBundle === undefined) throw new Error( "Missing batch_decompile or get_evidence_bundle contract", ); - const result = batchDecompile.outputSchema.parse(batch).normalized_result; + const result = batchDecompileResultSchema.parse( + batchDecompile.outputSchema.parse(batch).normalized_result, + ); expect(result).toMatchObject({ total: 4, succeeded: 1, failed: 3 }); const [main, failed, imported, unknown] = result.items; expect(main).toMatchObject({ address: "main", status: "ok" }); diff --git a/tests/boundary/providers/kuna/kunaDoctor.test.ts b/tests/boundary/providers/kuna/kunaDoctor.test.ts index 8c3a5f052a..6de81dfeba 100644 --- a/tests/boundary/providers/kuna/kunaDoctor.test.ts +++ b/tests/boundary/providers/kuna/kunaDoctor.test.ts @@ -34,7 +34,7 @@ it("reports an unconfigured Kuna as a missing optional engine", async () => { it("publishes the caller's explicit settings once the installation validates", async () => { const root = await createTestTempDirectory("rea-kuna-doctor-"); const kuna = await writeFakeKuna(root, { - version: { stdout: "kuna 1.988\n" }, + version: { stdout: "kuna 1.989\n" }, }); const cache = join(root, "cache"); @@ -48,7 +48,7 @@ it("publishes the caller's explicit settings once the installation validates", a expect(inspection).toMatchObject({ configured: true, available: true, - providerVersion: "1.988", + providerVersion: "1.989", registrationEnvironment: { REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cache, @@ -59,10 +59,10 @@ it("publishes the caller's explicit settings once the installation validates", a name: "installation", ok: true, detail: expect.stringMatching( - /reports Kuna 1\.988 \(sha256 [a-f0-9]{64}\)\.$/u, + /reports Kuna 1\.989 \(sha256 [a-f0-9]{64}\)\.$/u, ), remediation: null, - details: { version: "1.988", verified_version: "1.988" }, + details: { version: "1.989", verified_version: "1.989" }, }, { name: "cache", @@ -88,7 +88,7 @@ it("flags an unverified release without failing the installation", async () => { "You may continue with the selected unverified release.", ), remediation: expect.stringContaining("setting REA_KUNA_COMMAND"), - details: { verified_version: "1.988" }, + details: { verified_version: "1.989" }, }); }); diff --git a/tests/boundary/providers/kuna/kunaInstallation.test.ts b/tests/boundary/providers/kuna/kunaInstallation.test.ts index 1f66253993..6082bf5063 100644 --- a/tests/boundary/providers/kuna/kunaInstallation.test.ts +++ b/tests/boundary/providers/kuna/kunaInstallation.test.ts @@ -22,7 +22,7 @@ const it = test.skipIf(process.platform === "win32"); it("identifies a caller-selected kuna by version and digest", async () => { const root = await createTestTempDirectory("rea-kuna-install-"); const kuna = await writeFakeKuna(root, { - version: { stdout: "kuna 1.988\n" }, + version: { stdout: "kuna 1.989\n" }, }); const link = join(root, "current-kuna"); await symlink(kuna, link); @@ -37,7 +37,7 @@ it("identifies a caller-selected kuna by version and digest", async () => { value: { configuration: { command: link, cacheRoot: join(root, "cache") }, executable: await realpath(kuna), - version: "1.988", + version: "1.989", verified: true, // A `#!` launcher reports its interpreter; ownership checks must not expect the path. script: true, @@ -104,14 +104,14 @@ fs.renameSync(process.argv[1] + ".new", process.argv[1]); it("accepts another release but reports it as unverified", async () => { const root = await createTestTempDirectory("rea-kuna-unverified-"); const kuna = await writeFakeKuna(root, { - version: { stdout: "kuna 1.986\n" }, + version: { stdout: "kuna 1.988\n" }, }); const result = await inspectKunaInstallation({ REA_KUNA_COMMAND: kuna }); expect(result).toMatchObject({ ok: true, - value: { version: "1.986", verified: false }, + value: { version: "1.988", verified: false }, }); }); diff --git a/tests/boundary/providers/kuna/kunaParallelMcp.test.ts b/tests/boundary/providers/kuna/kunaParallelMcp.test.ts new file mode 100644 index 0000000000..a4133e93a9 --- /dev/null +++ b/tests/boundary/providers/kuna/kunaParallelMcp.test.ts @@ -0,0 +1,83 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; +import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; +import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js"; +import { createServer } from "../../../../src/server/createServer.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../../support/mcp/mcpFixture.js"; +import { writeFakeKuna } from "./fakeKuna.js"; + +const test = mcpTest.skipIf(process.platform === "win32"); +const caveat = + "Parallel Kuna project export does not propagate callee-first prototypes"; + +for (const jobs of [1, 2]) { + test(`retains source limitations in batch and paged dossier with ${String(jobs)} worker(s)`, async ({ + mcp, + }) => { + const root = await createTestTempDirectory("rea-kuna-parallel-mcp-"); + const kuna = await writeFakeKuna(root, { + functions: [{ name: "main", addr: "0x1000" }], + }); + const target = join(root, "program"); + await writeFile(target, elfNoteFixture({ bits: 64, type: 2 })); + const session = composeBinarySession( + new AnalysisProviderRegistry( + [ + new KunaProvider( + { + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: join(root, "cache"), + REA_KUNA_JOBS: String(jobs), + }, + { platform: process.platform }, + ), + ], + "kuna", + ), + ); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + const call = async (name: string, arguments_: Record) => { + const response = await client.callTool({ name, arguments: arguments_ }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return response.structuredContent; + }; + await call("open_binary", { path: target }); + // Assembly waits for the complete project, avoiding timing-based source selection. + await call("procedure_assembly", { procedure: "main" }); + const batch = parseEvidence( + await call("batch_decompile", { + addresses: ["0x1000"], + detail: "complete", + }), + ); + expect(batch.limitations.some((item) => item.includes(caveat))).toBe( + jobs > 1, + ); + const dossier = parseEvidence( + await call("analyze_function", { procedure: "main", detail: "summary" }), + ); + const summary = dossier.normalized_result as { parent_evidence_id: string }; + const view = parseEvidence( + await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { kind: "native", facet: "pseudocode" }, + }), + ); + expect(view.limitations.some((item) => item.includes(caveat))).toBe( + jobs > 1, + ); + await call("close_binary", {}); + }); +} diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 8907678718..690b4fee9c 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -182,22 +182,42 @@ it("waits for an aborted pre-session hash before retrying its late close owner", it("commits the observed Kuna release and digest to the analysis profile", async () => { const { profile, kuna } = await session("rea-kuna-profile-", { - version: { stdout: "kuna 1.988\n" }, + version: { stdout: "kuna 1.989\n" }, }); expect(profile).toMatchObject({ - provider: { id: "kuna", name: "Kuna", version: "1.988" }, + provider: { id: "kuna", name: "Kuna", version: "1.989" }, parameters: { - kuna_version: "1.988", + kuna_version: "1.989", kuna_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u), kuna_command: kuna, - verified_release: true, jobs: 1, cache_policy: "live", }, }); }); +it("reports an unverified release on observations without binding policy into the profile", async () => { + const { profile, open } = await session("rea-kuna-policy-", { + version: { stdout: "kuna 1.988\n" }, + }); + expect(profile.parameters).not.toHaveProperty("verified_release"); + expect(profile.parameters).toMatchObject({ + kuna_version: "1.988", + kuna_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u), + }); + const client = open(); + const inventory = await run(client, "list_procedures"); + const warning = "Kuna 1.988 is not REA's verified 1.989 release"; + expect(inventory.limitations.join("\n")).toContain(warning); + const dossier = await run(client, "analyze_function", { procedure: "main" }); + expect(dossier.limitations.join("\n")).toContain(warning); + expect( + (dossier.result as { limitations: string[] }).limitations.join("\n"), + ).toContain(warning); + await client.close(); +}); + it("lists the selected document without starting target analysis", async () => { const { kuna, target, cacheRoot, open } = await session( "rea-kuna-document-metadata-", @@ -215,6 +235,39 @@ it("lists the selected document without starting target analysis", async () => { } }); +it("qualifies only parallel project pseudocode, including dossier results", async () => { + const { open } = await session( + "rea-kuna-parallel-context-", + { delayMs: 300 }, + { REA_KUNA_JOBS: "2" }, + ); + const client = open(); + const caveat = + "Parallel Kuna project export does not propagate callee-first prototypes"; + const cold = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(cold.rawResult).toMatchObject({ source: { kind: "single-function" } }); + expect(cold.limitations.join("\n")).not.toContain(caveat); + await run(client, "procedure_assembly", { procedure: "main" }); + const warm = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(warm.rawResult).toMatchObject({ source: { kind: "project-export" } }); + expect(warm.limitations.join("\n")).toContain(caveat); + const dossier = await run(client, "analyze_function", { procedure: "main" }); + expect(dossier.limitations.join("\n")).toContain(caveat); + expect( + (dossier.result as { limitations: string[] }).limitations.join("\n"), + ).toContain(caveat); + const missing = await run(client, "procedure_pseudo_code", { + procedure: "broken", + }); + expect(missing.result).toBeNull(); + expect(missing.limitations.join("\n")).not.toContain(caveat); + await client.close(); +}); + it("answers a cold request from one function, then serves the finished export", async () => { const { kuna, cacheRoot, open } = await session("rea-kuna-cold-", { delayMs: 300, @@ -846,7 +899,7 @@ it.each([ behavior: { project: "hang" } as const, environment: { REA_KUNA_PROJECT_TIMEOUT_MS: "300" }, cause: "AnalysisTimeoutError", - next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms or raise REA_KUNA_JOBS in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", + next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", details: { timeout_ms: 300, captured_output: { truncated: expect.any(Boolean) }, diff --git a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts index 2af75bb630..76fda7c2b4 100644 --- a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts +++ b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts @@ -1246,11 +1246,16 @@ describe("native DMG verification resource availability", () => { exitCode: 1, }; - it("retries the explicit transient refusal before attachment and retains both attempts", async () => { + it("settles an explicit transient refusal within the verification deadline before attaching", async () => { const { host, state } = createOmittedInventoryHost(); state.inventoryMode = "listed"; state.detachAttempts = 4; - const delays = vi.fn(async () => {}); + let now = 0; + const clock = vi.spyOn(performance, "now").mockImplementation(() => now); + onTestFinished(() => clock.mockRestore()); + const delays = vi.fn(async (milliseconds: number) => { + now += milliseconds; + }); const timeouts: number[] = []; let attempts = 0; const reader = await createAttachedReader( @@ -1266,36 +1271,45 @@ describe("native DMG verification resource availability", () => { if (options === undefined) throw new Error("missing verify deadline"); timeouts.push(options.timeoutMs); - if (attempts === 1) return transientFailure; + if (attempts < 5) return transientFailure; } return host.run(arguments_, signal, options); }, }, ); onTestFinished(() => reader.close()); - expect(attempts).toBe(2); - expect(delays.mock.calls).toEqual([[250]]); + expect(attempts).toBe(5); + expect(now).toBeGreaterThan(750); expect(timeouts[0]).toBeLessThanOrEqual(120_000); - expect(timeouts[1]).toBeLessThanOrEqual(timeouts[0]!); - expect(reader.provenance().slice(0, 2)).toMatchObject([ + expect(timeouts.at(-1)).toBeLessThan(timeouts[0]!); + expect( + reader + .provenance() + .slice(0, 5) + .map(({ exit_code }) => exit_code), + ).toEqual([1, 1, 1, 1, 0]); + expect(reader.provenance().slice(0, 1)).toMatchObject([ { arguments: ["verify", "/tmp/image.dmg"], exit_code: 1, effects: ["read"], }, - { - arguments: ["verify", "/tmp/image.dmg"], - exit_code: 0, - effects: ["read"], - }, ]); await reader.close(); expect(state.attached).toBe(false); }); it("bounds repeated transient refusals and never attaches an unverified image", async () => { - const run = vi.fn(async () => transientFailure); - const delay = vi.fn(async () => {}); + let now = 0; + const clock = vi.spyOn(performance, "now").mockImplementation(() => now); + onTestFinished(() => clock.mockRestore()); + const run = vi.fn(async () => { + now += 40_000; + return transientFailure; + }); + const delay = vi.fn(async (milliseconds: number) => { + now += milliseconds; + }); const reader = new NativeDmgArtifactReader("/tmp/image.dmg", process.env, { run, delay, @@ -1306,7 +1320,8 @@ describe("native DMG verification resource availability", () => { }); await reader.close(); expect(run.mock.calls).toHaveLength(3); - expect(delay.mock.calls).toEqual([[250], [500]]); + expect(now).toBeGreaterThanOrEqual(120_000); + expect(delay.mock.calls).toHaveLength(2); expect(reader.provenance().map(({ exit_code }) => exit_code)).toEqual([ 1, 1, 1, ]); diff --git a/tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts b/tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts new file mode 100644 index 0000000000..a2de197863 --- /dev/null +++ b/tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts @@ -0,0 +1,138 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; +import { AnalysisInputError } from "../../../../src/domain/analysisErrorCore.js"; +import { createNativeUiHelperRuntime } from "../../../../src/native/NativeUiHelperRuntime.js"; +import { observeNativeUi } from "../../../../src/native/NativeUiObservation.js"; +import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js"; +import { + ProviderProcessSupervisor, + spawnOwnedProviderProcess, +} from "../../../../src/process/ProviderProcess.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +const scopeSchema = z.object({ + pid: z.number().int().positive(), + window_id: z.number().int().positive(), +}); + +// Ownership starts with the launch: the app inherits the launcher's run token, so +// the sweep finds it with or without a ready record. /usr/bin/open's own token is +// withheld, so its retained handle must stop it before the sweep can succeed. +const launch = async (bundle: string, name: string) => { + const coordinates = join(bundle, "..", `${name}.json`); + await writeFile(coordinates, ""); + const owned = await spawnOwnedProviderProcess({ + command: "/usr/bin/open", + arguments: ["-W", "-n", "-g", "--stdout", coordinates, bundle], + runId: randomUUID(), + sweepTokenOwnedProcesses: true, + }); + const launcher = new ProviderProcessSupervisor( + { process: owned.process, ownsProcessLifetime: true }, + { maxDiagnosticBytes: { stdout: 0, stderr: 64 * 1024 } }, + ); + onTestFinished(async () => { + const stopped = await launcher.stop(); + const swept = await cleanupOwnedProcessGroup(owned.ownership); + expect(stopped.status).not.toBe("incomplete"); + expect(swept).toMatchObject({ cleaned: true }); + }); + const deadline = performance.now() + 30_000; + for (;;) { + const [line] = (await readFile(coordinates, "utf8")).split("\n"); + if (line !== undefined && line.length > 0) + return scopeSchema.parse(JSON.parse(line)); + if (performance.now() >= deadline) + throw new Error("The off-screen fixture never reported its window"); + await delay(50); + } +}; + +// The fixture window is real and never ordered in; the helper is compiled and run +// by the production runtime. Capture is off for the raw helper reads, so these +// facts need no Accessibility or Screen Recording permission. +it.skipIf(process.platform !== "darwin")( + "selects a window that exists off screen and still refuses absent and foreign windows", + { timeout: 240_000 }, + async () => { + const root = await createTestTempDirectory("rea-native-ui-offscreen-"); + const bundle = join(root, "Fixture.app"); + const contents = join(bundle, "Contents"); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await writeFile( + join(contents, "Info.plist"), + `CFBundleExecutablefixtureCFBundleIdentifierio.rea.source-owned-offscreen-fixtureCFBundleNameREA Offscreen FixtureCFBundlePackageTypeAPPL`, + ); + const executable = join(contents, "MacOS", "fixture"); + await promisify(execFile)("/usr/bin/xcrun", [ + "swiftc", + "-module-cache-path", + join(root, "modules"), + resolve("tests/conformance/native/ui-offscreen.swift"), + "-o", + executable, + ]); + const selected = await launch(bundle, "selected"); + const foreign = await launch(bundle, "foreign"); + const target = await parseBinaryTarget(executable); + if (!target.ok) throw target.error; + + const runtime = createNativeUiHelperRuntime(process.env); + onTestFinished(() => runtime.close()); + const read = (windowId: number) => + runtime.invoke({ + pid: selected.pid, + window_id: windowId, + executable: target.value.path, + sha256: target.value.sha256, + screenshot: false, + accessibility: false, + max_nodes: 1, + }); + + expect(await read(selected.window_id)).toMatchObject({ + ok: true, + result: { window: { pid: selected.pid, window_id: selected.window_id } }, + }); + for (const windowId of [foreign.window_id, 2_147_483_000]) + expect(await read(windowId)).toMatchObject({ + ok: false, + code: "window-mismatch", + }); + + // Through the shared observation, the window is no longer called absent. Its + // outcome depends on the host's permissions, which this test does not select. + const options = { environment: process.env, runtime }; + const observed = await observeNativeUi( + target.value, + "observe_native_ui", + selected, + options, + ); + if (!observed.ok) + expect(JSON.stringify(observed.error)).not.toContain("window-mismatch"); + + // The fact the verifier's changed-bytes check reads. + const changed = await observeNativeUi( + { ...target.value, sha256: "0".repeat(64) }, + "observe_native_ui", + selected, + options, + ); + expect(changed.ok).toBe(false); + if (changed.ok) return; + expect(changed.error).toBeInstanceOf(AnalysisInputError); + expect((changed.error as AnalysisInputError).issues?.[0]).toMatchObject({ + path: ["pid"], + message: expect.stringMatching(/^target-mismatch:/u), + }); + }, +); diff --git a/tests/boundary/setup/macosHopperPublisherVerification.test.ts b/tests/boundary/setup/macosHopperPublisherVerification.test.ts index dcb85cca5e..32488043c7 100644 --- a/tests/boundary/setup/macosHopperPublisherVerification.test.ts +++ b/tests/boundary/setup/macosHopperPublisherVerification.test.ts @@ -17,7 +17,7 @@ const run = promisify(execFile); const itDarwin = it.skipIf(process.platform !== "darwin"); const executableSegments = ["Contents", "MacOS", "hopper"]; -const infoPlist = (identifier: string): string => +const infoPlist = (identifier: string, entries = ""): string => ` @@ -27,7 +27,7 @@ const infoPlist = (identifier: string): string => CFBundleExecutable hopper CFBundlePackageType - APPL + APPL${entries} `; @@ -56,8 +56,25 @@ const buildBundle = async ( return app; }; +const minimumEntry = (value: string): string => + `\n LSMinimumSystemVersion\n ${value}`; + +/** An unsigned bundle carrying only an Info.plist, for requirement reads. */ +const infoPlistBundle = async (entries: string): Promise => { + const root = await createTestTempDirectory("rea-hopper-minimum-"); + const app = join(root, "Hopper Disassembler.app"); + await mkdir(join(app, "Contents"), { recursive: true }); + await writeFile( + join(app, "Contents", "Info.plist"), + infoPlist(HOPPER_PUBLISHER.bundleIdentifier, entries), + ); + return app; +}; + const host = () => - systemMacHopperInstallHost("/home/rea-test", { ...process.env }); + systemMacHopperInstallHost("/home/rea-test", { ...process.env }, () => + Promise.resolve(undefined), + ); describe("macOS Hopper publisher verification", () => { itDarwin( @@ -89,6 +106,36 @@ describe("macOS Hopper publisher verification", () => { await expect(host().validBundle(app)).resolves.toBe("invalid_signature"); }); + it.each([ + { entries: minimumEntry("12.4"), declared: "12.4" }, + { entries: "", declared: undefined }, + ])( + "reads the XML Info.plist minimum macOS version $declared", + async ({ entries, declared }) => { + const app = await infoPlistBundle(entries); + await expect(host().minimumSystemVersion(app)).resolves.toBe(declared); + }, + ); + + itDarwin("reads a binary Info.plist minimum macOS version", async () => { + const app = await infoPlistBundle(minimumEntry("13.0.1")); + await run("/usr/bin/plutil", [ + "-convert", + "binary1", + join(app, "Contents", "Info.plist"), + ]); + await expect(host().minimumSystemVersion(app)).resolves.toBe("13.0.1"); + }); + + it("refuses a minimum macOS version that is not text", async () => { + const app = await infoPlistBundle(minimumEntry("12.4")); + const failure = host().minimumSystemVersion(app); + await expect(failure).rejects.toBeInstanceOf(TypeError); + // The refusal names the key and the exact plist it read. + await expect(failure).rejects.toThrow("LSMinimumSystemVersion"); + await expect(failure).rejects.toThrow(join(app, "Contents", "Info.plist")); + }); + it("discloses the exact code requirement the installer enforces", () => { expect(macHopperInstallDisclosure.commands).toContain( `codesign --verify --strict '-R= identifier "${HOPPER_PUBLISHER.bundleIdentifier}" and anchor apple generic and certificate leaf[subject.OU] = "${HOPPER_PUBLISHER.teamIdentifier}"' `, @@ -122,6 +169,9 @@ describe("macOS Hopper publisher verification", () => { "accepts the officially signed Hopper application", async () => { await expect(host().validBundle(realApp ?? "")).resolves.toBe("verified"); + await expect(host().minimumSystemVersion(realApp ?? "")).resolves.toMatch( + /^\d+(?:\.\d+)*$/u, + ); await expect( verifyMacHopperNativeHelper( join(realApp ?? "", ...executableSegments), diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 476f2f2d15..ca4b34d3e8 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -310,9 +310,10 @@ it("reports an external cleanup blocker until its exact owner confirms release", ); expect(await session.status()).toMatchObject({ cleanup_pending: true }); expect(await session.open(target)).toEqual(err(failure)); - await session.close(); + await session.close({ cleanupRetry: { operation: "close_binary" } }); expect(released).toBe(false); expect(await session.status()).toMatchObject({ cleanup_pending: true }); + expect(await session.status()).not.toHaveProperty("cleanup_retry"); await lease.release(); expect(await session.status()).toMatchObject({ cleanup_pending: false }); expect((await session.open(target)).ok).toBe(true); @@ -322,6 +323,63 @@ it("reports an external cleanup blocker until its exact owner confirms release", } }); +it("does not offer close retry while an external owner also blocks cleanup", async () => { + const [target] = await createBinarySessionTargets(); + const externalFailure = new ProviderCleanupError( + "external-debugger", + ["external-run"], + {}, + ); + const clientFailure = new ProviderCleanupError("fixture", ["client-run"], {}); + let clientReleased = false; + const externalLease = new AnalysisCleanupLease(async () => {}, [ + "external-run", + ]); + const clientLease = new AnalysisCleanupLease(async () => { + clientReleased = true; + }, ["client-run"]); + externalLease.bindCleanupError(externalFailure); + clientLease.bindCleanupError(clientFailure); + const provider = createCacheProvider([]); + const session = createTestBinarySession({ + ...provider, + createClient: (...args) => { + const client = provider.createClient(...args); + return { + ...client, + close: () => + clientReleased ? client.close() : Promise.resolve(err(clientFailure)), + }; + }, + }); + try { + expect((await session.open(target)).ok).toBe(true); + await session.execute("address_name", { + address: "0x1000", + document: "fixture", + }); + await session.withAdmittedExternalMutation( + target, + undefined, + async () => err(externalFailure), + (_value, error) => error, + ); + expect( + (await session.close({ cleanupRetry: { operation: "close_binary" } })).ok, + ).toBe(false); + expect(await session.status()).toMatchObject({ + open: false, + cleanup_pending: true, + }); + expect(await session.status()).not.toHaveProperty("cleanup_retry"); + } finally { + await clientLease.release(); + await externalLease.release(); + const closed = await session.close(); + if (!closed.ok) throw closed.error; + } +}); + it("retires analysis when an external mutation uses a hard link", async () => { const [target] = await createBinarySessionTargets(); const alias = join(dirname(target), "hard-link-alias.hop"); diff --git a/tests/composition/reverseEngineering.test.ts b/tests/composition/reverseEngineering.test.ts index cc5987bcb6..4c353e2818 100644 --- a/tests/composition/reverseEngineering.test.ts +++ b/tests/composition/reverseEngineering.test.ts @@ -73,7 +73,7 @@ describe("reverse engineering command services", () => { exit_code: 0, output_truncated: false, }); - expect(result.value.provider.id).toBe("gnu.objdump"); + expect(result.value.provider.id).toBe("objdump.command"); expect(result.value.subject?.local_path).toBe(path); } finally { await rm(directory, { recursive: true, force: true }); diff --git a/tests/composition/reverseEngineeringAdmission.test.ts b/tests/composition/reverseEngineeringAdmission.test.ts index 6c7511f7c0..3ca64cf576 100644 --- a/tests/composition/reverseEngineeringAdmission.test.ts +++ b/tests/composition/reverseEngineeringAdmission.test.ts @@ -292,7 +292,7 @@ it.each([ expect(result.error.cause).toBe(code === "ABORT_ERR" ? reason : failure); if (code === "EACCES") { expect(result.error).toMatchObject({ - providerId: "objdump", + providerId: "objdump.command", diagnostics: { boundary: "provider-launch", command: "objdump", diff --git a/tests/composition/reverseProviderLaunchErrors.test.ts b/tests/composition/reverseProviderLaunchErrors.test.ts index 5cd951a8ba..8513f9b08e 100644 --- a/tests/composition/reverseProviderLaunchErrors.test.ts +++ b/tests/composition/reverseProviderLaunchErrors.test.ts @@ -39,7 +39,7 @@ it.each(["ENOENT", "EACCES", "EPERM", "ENOTDIR"] as const)( code: "capability_unavailable", retryable: false, details: { - provider_id: "objdump", + provider_id: "objdump.command", diagnostics: { boundary: "provider-launch", command, diff --git a/tests/conformance/native/ui-offscreen.swift b/tests/conformance/native/ui-offscreen.swift new file mode 100644 index 0000000000..599a1a836f --- /dev/null +++ b/tests/conformance/native/ui-offscreen.swift @@ -0,0 +1,14 @@ +import AppKit + +// A real window that exists and is never ordered in, so it stays off screen and +// never appears on the desktop. It expires in case its launcher loses track of it. +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let window = NSWindow(contentRect: NSRect(x: 120, y: 120, width: 240, height: 80), styleMask: [.titled], backing: .buffered, defer: false) +window.title = "REA off-screen window fixture" +DispatchQueue.main.async { + let record = "{\"pid\":\(ProcessInfo.processInfo.processIdentifier),\"window_id\":\(window.windowNumber)}\n" + FileHandle.standardOutput.write(Data(record.utf8)) +} +DispatchQueue.main.asyncAfter(deadline: .now() + 300) { exit(0) } +app.run() diff --git a/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts b/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts index 200361bec9..3ec28e10c4 100644 --- a/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts +++ b/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts @@ -22,8 +22,8 @@ describe("real Windows Ghidra workflow trust boundary", () => { for (const name of ["native-artifact", "verify"]) { const job = workflow.jobs[name]; expect(job.if).toBe("github.ref == 'refs/heads/main'"); - const checkouts = job.steps.filter( - (step: { uses?: string }) => step.uses?.startsWith("actions/checkout@"), + const checkouts = job.steps.filter((step: { uses?: string }) => + step.uses?.startsWith("actions/checkout@"), ); expect(checkouts).toHaveLength(1); expect(checkouts[0].uses).toMatch(/^actions\/checkout@[0-9a-f]{40}$/); diff --git a/tests/conformance/providers/managed/managedNativeVerification.test.ts b/tests/conformance/providers/managed/managedNativeVerification.test.ts index b4e25f1b25..e86a3a4318 100644 --- a/tests/conformance/providers/managed/managedNativeVerification.test.ts +++ b/tests/conformance/providers/managed/managedNativeVerification.test.ts @@ -913,7 +913,6 @@ it("retains every observed native candidate", () => { expect(result.pinvoke_imports[0]?.candidates).toHaveLength(2); }); - describe("managed/native verification result algebra", () => { it("rejects states without their required native match", () => { const result = verifyManagedNativeVerificationSource( diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index 67bcbfe77e..5ef111d92b 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -13,6 +13,7 @@ const { targetSha256, transport, mode = "success", + handshakeReports = {}, } = configuration ?? {}; if ( @@ -386,14 +387,17 @@ const handlePing = (socket, request, state) => { return; } if (state.mode === "hang_after_start" && state.pings.value > 1) return; - const result = sessionInfo({ - runId: state.runId, - providerVersion: - state.mode === "wrong_identity" ? "0.0.0" : state.providerVersion, - profileDigest: state.profileDigest, - targetSha256: state.targetSha256, - timedOut: state.mode === "analysis_timeout", - }); + const result = { + ...sessionInfo({ + runId: state.runId, + providerVersion: + state.mode === "wrong_identity" ? "0.0.0" : state.providerVersion, + profileDigest: state.profileDigest, + targetSha256: state.targetSha256, + timedOut: state.mode === "analysis_timeout", + }), + ...handshakeReports, + }; const response = `${JSON.stringify({ id: request.id, ok: true, result })}\n`; if (state.mode === "fragmented") { const split = Math.floor(response.length / 2); diff --git a/tests/fixtures/inspector/fakeV8Inspector.ts b/tests/fixtures/inspector/fakeV8Inspector.ts index 35ef3cb69a..544fc52622 100644 --- a/tests/fixtures/inspector/fakeV8Inspector.ts +++ b/tests/fixtures/inspector/fakeV8Inspector.ts @@ -48,6 +48,9 @@ export const startFakeV8Inspector = async ( const sockets = new Set(); let port = 0; const http = createServer((request, response) => { + // The in-process client may block on schema validation past the idle socket + // deadline. Keep discovery independent of pooled-socket expiry races. + response.setHeader("connection", "close"); response.setHeader("content-type", "application/json"); if (request.url === "/json/version") { response.end( diff --git a/tests/fixtures/mcpSchemaValidation.ts b/tests/fixtures/mcpSchemaValidation.ts index 3e81a0a8d6..c082006275 100644 --- a/tests/fixtures/mcpSchemaValidation.ts +++ b/tests/fixtures/mcpSchemaValidation.ts @@ -2,7 +2,10 @@ import type { JsonSchemaType, JsonSchemaValidator, } from "@modelcontextprotocol/client"; -import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; +import { + addFormats, + AjvJsonSchemaValidator, +} from "@modelcontextprotocol/client/validators/ajv"; import { Ajv2020 } from "ajv/dist/2020.js"; import type { ValidateFunction } from "ajv"; @@ -19,9 +22,27 @@ export const createMcpSchemaValidation = () => { const key = JSON.stringify(schema); let validator = sdkValidators.get(key); if (validator === undefined) { - // Each full schema owns its default provider, including dialect dispatch. + // Keep shared definitions as compiled functions instead of expanding + // them at every reference. Other dialects retain SDK dispatch. + const provider = + schema.$schema === undefined || + schema.$schema === "https://json-schema.org/draft/2020-12/schema" + ? new AjvJsonSchemaValidator( + addFormats( + new Ajv2020({ + strict: false, + validateFormats: true, + validateSchema: false, + allErrors: true, + inlineRefs: false, + code: { optimize: false }, + }), + ), + ) + : new AjvJsonSchemaValidator(); + // Each full schema owns its provider, including dialect dispatch. // Distinct schemas may legally reuse the same $id in separate servers. - validator = new AjvJsonSchemaValidator().getValidator(JSON.parse(key)); + validator = provider.getValidator(JSON.parse(key)); sdkValidators.set(key, validator); } return validator as JsonSchemaValidator; @@ -44,6 +65,8 @@ export const createMcpSchemaValidation = () => { validator = new Ajv2020({ strict: false, validateFormats: false, + inlineRefs: false, + code: { optimize: false }, }).compile(JSON.parse(key)); assertionValidators.set(key, validator); } diff --git a/tests/fixtures/nodeRuntimeReconciliation.ts b/tests/fixtures/nodeRuntimeReconciliation.ts new file mode 100644 index 0000000000..cf6e75ba23 --- /dev/null +++ b/tests/fixtures/nodeRuntimeReconciliation.ts @@ -0,0 +1,73 @@ +import { createJavaScriptRuntimeObservationEvidence } from "../../src/application/javascript/JavaScriptRuntimeObservationEvidence.js"; +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { javascriptRuntimeObservationSchema } from "../../src/domain/javascript/javascriptRuntimeObservation.js"; +import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../src/inspector/providerIdentity.js"; + +/** Synthetic, schema-validated capture; this is not a live Inspector observation. */ +export const nodeReconciliationFixture = (scriptObserved: boolean) => { + const location = { + kind: "file", + file_path: "/Applications/Example.app/Contents/Resources/app/renderer.js", + }; + const runtime = javascriptRuntimeObservationSchema.parse({ + runtime: { + product: "node.js/v24.22.1", + protocol_version: "1.3", + v8_version: null, + }, + target: { + target_id: "synthetic-node", + protocol_type: "node", + attached: null, + location, + runtime_kind: "unknown", + runtime_kind_authority: "not-declared", + }, + capture: { + observation_ms: 100, + events_observed: scriptObserved ? 1 : 0, + events_retained: scriptObserved ? 1 : 0, + events_dropped: 0, + metadata_bytes_retained: 0, + truncated: false, + truncation_reasons: [], + }, + scripts: { + items: scriptObserved + ? [ + { + script_key: `v8_script_${"4".repeat(64)}`, + location, + execution_context_key: null, + cdp_hash: null, + length: null, + is_module: null, + status: "observed-loaded", + }, + ] + : [], + observed_total: scriptObserved ? 1 : 0, + excluded: { unsupported_location: 0, invalid_protocol_value: 0 }, + }, + execution_contexts: [], + directly_observed: [], + unavailable_without_instrumentation: [], + unknowns: ["Synthetic bounded capture."], + limitations: ["Synthetic fixture, not a live runtime."], + }); + return { + static_layers: [{ analysis: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE }], + runtime_observations: [ + createJavaScriptRuntimeObservationEvidence( + "observe_javascript_runtime", + { + inspector_endpoint: "http://127.0.0.1:9229", + target_id: "synthetic-node", + observation_ms: 100, + }, + runtime, + V8_INSPECTOR_PROVIDER_IDENTITY, + ), + ], + }; +}; diff --git a/tests/fixtures/processRunTokenParserProbe.swift b/tests/fixtures/processRunTokenParserProbe.swift index ed359869d8..335ce435b3 100644 --- a/tests/fixtures/processRunTokenParserProbe.swift +++ b/tests/fixtures/processRunTokenParserProbe.swift @@ -193,6 +193,40 @@ struct ProcessRunTokenParserProbe { parse: .failure(.invalidBuffer), codeSigningFlags: { 0 } ) + // A restricted native process can expose an incomplete argv layout before + // the parser reaches the withheld environment (CI observed mdworker here). + let incompleteRestrictedArguments = [ + RunTokenReadError.invalidBufferAt(.pathPadding), + .invalidBufferAt(.arguments), + .appleVectorUnavailable, + ].map { failure in + [UInt32(0x0400_0000), UInt32(0x0000_0800), nil].map { flags in + let observation = processRunTokenObservation( + pid: 1, + parse: .failure(failure), + codeSigningFlags: { flags } + ) + return ["state": observation.state, "reason": observation.reason ?? ""] + } + } + let signedReadableTokens = [UInt32(0x0400_0000), UInt32(0x0000_0800)].map { flags in + let observation = processRunTokenObservation( + pid: 1, + parse: .success(realToken), + codeSigningFlags: { flags } + ) + return observation.state == "readable" && observation.run_id == "owned-token" + } + let signedInvalidTokens = [RunTokenReadError.duplicateToken, .invalidTokenEncoding].map { failure in + [UInt32(0x0400_0000), UInt32(0x0000_0800)].map { flags in + let observation = processRunTokenObservation( + pid: 1, + parse: .failure(failure), + codeSigningFlags: { flags } + ) + return ["state": observation.state, "reason": observation.reason ?? ""] + } + } let duplicateTokenFailsClosed: Bool do { _ = try parseProcessEnvironment(in: fixture( @@ -311,6 +345,9 @@ struct ProcessRunTokenParserProbe { "malformedBuffersRejected": malformedBuffersRejected, "invalidPointerSizeFailsClosed": invalidPointerSizeFailsClosed, "malformedStages": malformedStages, + "incompleteRestrictedArguments": incompleteRestrictedArguments, + "signedReadableTokens": signedReadableTokens, + "signedInvalidTokens": signedInvalidTokens, "malformedObservation": [ "state": malformedObservation.state, "reason": malformedObservation.reason as Any? ?? NSNull(), diff --git a/tests/fixtures/processRunTokenSnapshotProbe.swift b/tests/fixtures/processRunTokenSnapshotProbe.swift index 77a758d705..393809c9e8 100644 --- a/tests/fixtures/processRunTokenSnapshotProbe.swift +++ b/tests/fixtures/processRunTokenSnapshotProbe.swift @@ -45,7 +45,7 @@ struct ProcessRunTokenSnapshotProbe { let zombieIdentity = readProcessIdentities( pids: [42], snapshot: ProcessSnapshot( - metadata: [42: ProcessMetadata(identity: "start:42", effectiveUid: 501, lifecycle: "zombie", parentPid: 7, processGroupId: 9, pointerSize: 8)], + metadata: [42: ProcessMetadata(identity: "start:42", effectiveUid: 501, lifecycle: "zombie", parentPid: 7, processGroupId: 9, pointerSize: 8, name: "probe-name")], failure: nil ) ) @@ -58,7 +58,8 @@ struct ProcessRunTokenSnapshotProbe { "invalidPidIsUnavailable": invalidIdentity.first?.state == "unavailable" && invalidIdentity.first?.reason == "invalid_process_id", "zombieLifecycleIsSeparateFromIdentity": zombieIdentity.first?.state == "readable" && zombieIdentity.first?.identity == "start:42" && zombieIdentity.first?.lifecycle == "zombie", "effectiveUidComesFromNativeIdentity": zombieIdentity.first?.effective_uid == 501, - "nativeRelationIsSeparateFromIdentity": zombieIdentity.first?.relation?.parent_pid == 7 && zombieIdentity.first?.relation?.process_group_id == 9 + "nativeRelationIsSeparateFromIdentity": zombieIdentity.first?.relation?.parent_pid == 7 && zombieIdentity.first?.relation?.process_group_id == 9, + "nativeNameIsSeparateFromIdentity": zombieIdentity.first?.name == "probe-name" && absentIdentities.first?.name == nil && failedIdentities.first?.name == nil ] let data = try JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]) FileHandle.standardOutput.write(data) diff --git a/tests/fixtures/webRuntime.ts b/tests/fixtures/webRuntime.ts index 5b93bccd6d..9d7dcc1db0 100644 --- a/tests/fixtures/webRuntime.ts +++ b/tests/fixtures/webRuntime.ts @@ -162,3 +162,55 @@ const runtimeEvents = (command: FakeCdpCommand, origin: string) => { }, })); }; + +/** Positions Chrome 156 reported for an inline script in a helper frame. */ +export const negativeColumnScript = { + scriptId: "helper-inline", + executionContextId: 7, + startLine: 21, + startColumn: -60, + endLine: 21, + endColumn: -15, + scriptLanguage: "JavaScript", +} as const; +export const negativeColumnScriptUrl = (origin: string) => + `${origin}/empty.html?cross-origin-isolated=1`; + +/** + * Replays that script and a listener on its first line. Positions on a + * script's first line include its start column, so the listener's is negative. + */ +export const startNegativeColumnBrowser = () => + startRuntimeBrowser({ + commandEvents: (command, origin) => + command.method === "Debugger.enable" + ? [ + { + ...(command.sessionId === undefined + ? {} + : { sessionId: command.sessionId }), + method: "Debugger.scriptParsed", + params: { + ...negativeColumnScript, + url: negativeColumnScriptUrl(origin), + }, + }, + ] + : undefined, + commandResult: (command) => + command.method === "DOMDebugger.getEventListeners" + ? { + listeners: [ + { + type: "message", + useCapture: false, + passive: false, + once: false, + scriptId: negativeColumnScript.scriptId, + lineNumber: 21, + columnNumber: -50, + }, + ], + } + : undefined, + }); diff --git a/tests/process-global/macHopperReleaseMinimum.test.ts b/tests/process-global/macHopperReleaseMinimum.test.ts new file mode 100644 index 0000000000..8cb5d7538a --- /dev/null +++ b/tests/process-global/macHopperReleaseMinimum.test.ts @@ -0,0 +1,105 @@ +import { afterEach, expect, it, vi } from "vitest"; + +import { createDoctorHostFixture } from "../../src/application/Doctor.fixture.js"; +import { HOPPER_RELEASES_URL } from "../../src/application/HopperReleaseFeed.js"; +import { macHopperHostVersionDetail } from "../../src/application/MacHopper.js"; +import { setupInstallFailure } from "../../src/application/SetupInstallFailure.js"; +import { runSetup } from "../../src/application/Setup.js"; +import { systemSetupHost } from "../../src/application/SetupHost.js"; + +afterEach(() => vi.unstubAllGlobals()); + +const PACKAGE_URL = + "https://www.hopperapp.com:443/downloader/public/Hopper-6.6.1-demo.dmg"; +// The public macOS release row observed on 2026-11-01, without its changelog +// and Sparkle signatures. +const FEED = JSON.stringify({ + "OS X": { + version: "6.6.1", + filename: PACKAGE_URL, + file_length: "34838169", + file_hash: "b2a74b6c47abc15d0f0ba2cbc36ed98eed8bd5d1", + public: "1", + beta: "0", + deployment_target: "12.4", + }, +}); + +/** Approved Hopper-only setup on a Mac without Hopper; vendor responses are local. */ +const setupHopperOnMacos = async (version: string) => { + const fetch = vi.fn((input) => + Promise.resolve( + String(input) === HOPPER_RELEASES_URL + ? new Response(FEED) + : new Response(null, { status: 404 }), + ), + ); + vi.stubGlobal("fetch", fetch); + const host = { + ...systemSetupHost( + createDoctorHostFixture({ + platform: "darwin", + macosVersion: () => Promise.resolve(version), + executable: () => Promise.resolve(false), + validTarget: () => Promise.resolve(false), + }), + { HOME: "/fixture/home", USERPROFILE: "/fixture/home" }, + ), + // Keep the runtime gate independent of the Node.js running the suite. + nodeVersion: "24.22.1", + }; + try { + const result = await runSetup( + { + approved: true, + installHopper: true, + structured: true, + clientIds: [], + installSkill: false, + }, + host, + ); + return { + result, + requests: fetch.mock.calls.map(([input]) => String(input)), + }; + } finally { + await host.close?.(); + } +}; + +it.each(["12.0", "12.3"])( + "refuses Hopper on macOS %s from the release deployment target before downloading the package", + async (version) => { + const { result, requests } = await setupHopperOnMacos(version); + + expect(result).toMatchObject({ + status: "needs_human", + code: "incompatible_host_version", + appliedActions: [], + }); + // Recovery and installer detail are delivered from their owners; the + // observed facts are checked independently of that wording. + expect(result.remediation).toContain( + setupInstallFailure("incompatible_host_version").remediation, + ); + expect(result.remediation).toContain( + macHopperHostVersionDetail({ + source: "release_feed", + packageUrl: PACKAGE_URL, + minimumVersion: "12.4", + hostVersion: version, + }), + ); + for (const fact of [PACKAGE_URL, "12.4", version, "HOPPER_LAUNCHER_PATH"]) + expect(result.remediation).toContain(fact); + expect(requests).toEqual([HOPPER_RELEASES_URL]); + }, +); + +it("requests the package once macOS meets the release deployment target", async () => { + const { result, requests } = await setupHopperOnMacos("12.4"); + + expect(result).toMatchObject({ code: "download_failed" }); + expect(requests).toEqual([HOPPER_RELEASES_URL, PACKAGE_URL]); +}); diff --git a/tests/process-global/oneShotSourceClosure.test.ts b/tests/process-global/oneShotSourceClosure.test.ts new file mode 100644 index 0000000000..2ce020b86c --- /dev/null +++ b/tests/process-global/oneShotSourceClosure.test.ts @@ -0,0 +1,379 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Cli } from "incur"; +import { afterEach, expect, it, vi } from "vitest"; + +import type { UndeliveredSourceClosure } from "../../src/application/AnalysisEvidenceSources.js"; +import * as matchingService from "../../src/application/native/NativeFunctionMatchingService.js"; +import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; +import { registerNativeFunctionMatchingCommands } from "../../src/cli/nativeFunctionMatchingCommands.js"; +import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js"; +import { createEvidence, parseEvidence } from "../../src/domain/evidence.js"; +import { + createEvidenceBundle, + parseEvidenceBundle, +} from "../../src/domain/evidenceBundle.js"; +import { jsonByteLength } from "../../src/domain/jsonSerialization.js"; +import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js"; +import { silentLogger } from "../../src/logger.js"; +import { + nativeMatchingInventory, + nativeMatchingProvider, +} from "../fixtures/nativeFunctionMatching.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +const host = vi.hoisted(() => ({ + listeners: new Map void>(), + get exitCode() { + return process.exitCode; + }, + set exitCode(value: typeof process.exitCode) { + process.exitCode = value; + }, + on(signal: string, listener: () => void) { + this.listeners.set(signal, listener); + }, + off(signal: string, listener: () => void) { + if (this.listeners.get(signal) === listener) this.listeners.delete(signal); + }, +})); + +vi.mock("../../src/cli/commandCancellation.js", async (importOriginal) => { + const original = + await importOriginal< + typeof import("../../src/cli/commandCancellation.js") + >(); + return { + ...original, + withCommandCancellation: ( + operation: (signal: AbortSignal) => Promise, + ) => original.withCommandCancellation(operation, host), + }; +}); + +afterEach(() => { + vi.restoreAllMocks(); + host.listeners.clear(); +}); + +type FinalClose = + | "control" + | "interrupt" + | "cleanup-failure" + | "interrupt-cleanup-failure"; + +const fixture = async (dependencyText = "Synthetic dependency") => { + const root = await createTestTempDirectory("rea-source-closure-"); + const dependency = createEvidence(undefined, nativeMatchingProvider, { + operation: "fixture_dependency", + parameters: {}, + result: { source: dependencyText }, + }); + const left = nativeMatchingInventory( + "1", + [{ address: "0x10", value: "main" }], + [dependency.evidence_id], + ); + const right = nativeMatchingInventory("2", [ + { address: "0x20", value: "main" }, + ]); + const sources = join(root, "sources.json"); + await writeFile(sources, JSON.stringify(createEvidenceBundle([dependency]))); + const request = join(root, "request.json"); + await writeFile(request, JSON.stringify({ left, right, detail: "complete" })); + return { + root, + dependency, + left, + right, + request, + sources, + output: join(root, "evidence.json"), + }; +}; + +/** + * Run the registered command, faulting only the close of the scope the command + * gave to matching, after that matching completed. Another scope's close cannot + * move the fault, and a command that stops reaching this close fails here. + */ +const runMatching = async ( + input: Awaited>, + finalClose: FinalClose, + options: { + readonly environment?: Readonly>; + readonly earlierScopeClose?: boolean; + } = {}, +) => { + const match = matchingService.matchNativeFunctionsFromBundles; + const close = ArtifactResourceScope.prototype.close; + let commandScope: ArtifactResourceScope | undefined; + let completed = false; + let finalCloses = 0; + const matching = vi + .spyOn(matchingService, "matchNativeFunctionsFromBundles") + .mockImplementation(async (request, paths, resources, matchOptions) => { + commandScope = resources; + if (options.earlierScopeClose === true) + await new ArtifactResourceScope().close(); + const result = await match(request, paths, resources, matchOptions); + completed = result.ok; + return result; + }); + const seam = vi + .spyOn(ArtifactResourceScope.prototype, "close") + .mockImplementation(async function (this: ArtifactResourceScope) { + if (this !== commandScope || !completed) return close.call(this); + finalCloses += 1; + if ( + finalClose === "interrupt" || + finalClose === "interrupt-cleanup-failure" + ) + host.listeners.get("SIGINT")?.(); + await close.call(this); + if ( + finalClose === "cleanup-failure" || + finalClose === "interrupt-cleanup-failure" + ) + throw new ProviderCleanupError( + "fixture", + ["owned-root"], + { reason: "controlled final close failure" }, + { operation: "match_native_functions" }, + ); + }); + const cli = Cli.create("one-shot-source-closure-test", { sync: false }); + registerNativeFunctionMatchingCommands( + cli, + silentLogger, + options.environment ?? {}, + ); + let stdout = ""; + const previous = process.exitCode; + try { + process.exitCode = undefined; + await cli.serve( + [ + "match-native-functions", + input.request, + "--source_bundle", + input.sources, + "--evidence_output", + input.output, + "--json", + ], + { + stdout: (part) => { + stdout += part; + }, + exit: () => {}, + }, + ); + expect(finalCloses, stdout).toBe(1); + return { stdout, exitCode: process.exitCode }; + } finally { + process.exitCode = previous; + seam.mockRestore(); + matching.mockRestore(); + } +}; + +/** Above V8's default call-argument limit of about 125,000 on a forked main thread. */ +const DATA_SIZED_LINKS = 200_000; + +const closureOf = (stdout: string) => { + const failure = analysisErrorProjectionSchema.parse(JSON.parse(stdout)); + const closure = failure.details?.source_closure as unknown as + | UndeliveredSourceClosure + | undefined; + if (closure === undefined) throw new Error(`No source closure: ${stdout}`); + return { failure, closure }; +}; + +const recordIds = (closure: UndeliveredSourceClosure) => + closure.records.map((record) => parseEvidence(record).evidence_id).sort(); + +it.each([ + ["interrupt", "cancelled", 130], + ["cleanup-failure", "cleanup_incomplete", 1], + ["interrupt-cleanup-failure", "cleanup_incomplete", 130], +] as const)( + "keeps captured linked sources through final close: %s", + async (finalClose, code, exitCode) => { + const input = await fixture(); + const { stdout, exitCode: observedExit } = await runMatching( + input, + finalClose, + ); + expect(observedExit).toBe(exitCode); + const { failure, closure } = closureOf(stdout); + expect(failure.code).toBe(code); + const observation = parseEvidence(failure.details?.partial_observation); + expect(observation.evidence_links).toEqual( + [input.left.evidence_id, input.right.evidence_id].sort(), + ); + // Sources are authentic originals, including the transitively linked one. + expect(recordIds(closure)).toEqual( + [ + input.dependency.evidence_id, + input.left.evidence_id, + input.right.evidence_id, + ].sort(), + ); + expect(closure).toMatchObject({ + publication: "not_published", + complete: true, + omitted_count: 0, + omitted: [], + }); + // Late cancellation never turns into successful or silent publication. + expect(await readdir(input.root)).not.toContain("evidence.json"); + expect(host.listeners.size).toBe(0); + }, +); + +it("keeps the fault on the final close when another scope closes first", async () => { + const input = await fixture(); + const { stdout, exitCode } = await runMatching(input, "interrupt", { + earlierScopeClose: true, + }); + expect(exitCode).toBe(130); + const { failure, closure } = closureOf(stdout); + expect(failure.code).toBe("cancelled"); + expect(parseEvidence(failure.details?.partial_observation).operation).toBe( + "match_native_functions", + ); + expect(closure.complete).toBe(true); +}); + +it.each([ + ["interrupt", "cancelled"], + ["cleanup-failure", "cleanup_incomplete"], +] as const)( + "keeps the typed failure for a captured source with a data-sized link set: %s", + async (finalClose, code) => { + // Repeated links are valid Evidence, and only one distinct record backs them. + const ancestor = createEvidence(undefined, nativeMatchingProvider, { + operation: "fixture_ancestor", + parameters: {}, + result: { source: "Synthetic ancestor" }, + }); + const wide = createEvidence(undefined, nativeMatchingProvider, { + operation: "fixture_dependency", + parameters: {}, + result: { source: "Synthetic wide dependency" }, + evidenceLinks: Array.from( + { length: DATA_SIZED_LINKS }, + () => ancestor.evidence_id, + ), + }); + const input = await fixture(); + const left = nativeMatchingInventory( + "1", + [{ address: "0x10", value: "main" }], + [wide.evidence_id], + ); + await writeFile( + input.sources, + JSON.stringify(createEvidenceBundle([ancestor, wide])), + ); + await writeFile( + input.request, + JSON.stringify({ left, right: input.right, detail: "complete" }), + ); + const { stdout } = await runMatching(input, finalClose, { + environment: { + REA_NATIVE_MATCHING_REPRESENTATION_BYTES: String(1024 ** 3), + }, + }); + const { failure, closure } = closureOf(stdout); + expect(failure.code).toBe(code); + expect(parseEvidence(failure.details?.partial_observation).operation).toBe( + "match_native_functions", + ); + // The closure is deduplicated: each record is examined and reported once. + expect(recordIds(closure)).toEqual( + [ancestor.evidence_id, left.evidence_id, input.right.evidence_id].sort(), + ); + expect(closure).toMatchObject({ + complete: false, + omitted_count: 1, + omitted: [{ evidence_id: wide.evidence_id, reason: "not_inlined" }], + }); + }, + 60_000, +); + +it("hands off the same closure for summary delivery and permits a clean retry", async () => { + const input = await fixture(); + await writeFile( + input.request, + JSON.stringify({ + left: input.left, + right: input.right, + detail: "summary", + }), + ); + const cancelled = await runMatching(input, "interrupt"); + const { closure } = closureOf(cancelled.stdout); + expect(closure.complete).toBe(true); + + const retried = await runMatching(input, "control"); + expect(retried.exitCode).toBeUndefined(); + expect(JSON.parse(retried.stdout)).not.toHaveProperty( + "details.source_closure", + ); + const saved = parseEvidenceBundle( + JSON.parse(await readFile(input.output, "utf8")), + ); + const savedIds = saved.records.map((record) => record.evidence_id); + expect(savedIds).toEqual(expect.arrayContaining(recordIds(closure))); +}); + +it("keeps an oversized source explicit instead of expanding the failure", async () => { + const input = await fixture("x".repeat(128 * 1024)); + const { stdout } = await runMatching(input, "interrupt"); + const { failure, closure } = closureOf(stdout); + expect(failure.code).toBe("cancelled"); + expect(recordIds(closure)).toEqual( + [input.left.evidence_id, input.right.evidence_id].sort(), + ); + expect(closure).toMatchObject({ + complete: false, + omitted_count: 1, + omitted: [ + { evidence_id: input.dependency.evidence_id, reason: "not_inlined" }, + ], + }); + expect(jsonByteLength(closure)).toBeLessThan(64 * 1024); + + // The omitted record stays reachable through ordinary delivery. + const retried = await runMatching(input, "control"); + expect(retried.exitCode).toBeUndefined(); + const saved = parseEvidenceBundle( + JSON.parse(await readFile(input.output, "utf8")), + ); + expect(saved.records.map((record) => record.evidence_id)).toContain( + input.dependency.evidence_id, + ); +}); + +it("leaves ordinary successful delivery free of closure reporting", async () => { + const input = await fixture(); + const { stdout, exitCode } = await runMatching(input, "control"); + expect(exitCode).toBeUndefined(); + expect(stdout).not.toContain("source_closure"); + const delivered = parseEvidence(JSON.parse(stdout)); + const saved = parseEvidenceBundle( + JSON.parse(await readFile(input.output, "utf8")), + ); + expect(saved.records.map((record) => record.evidence_id).sort()).toEqual( + [ + delivered.evidence_id, + input.dependency.evidence_id, + input.left.evidence_id, + input.right.evidence_id, + ].sort(), + ); +}); diff --git a/tests/support/hostCleanup.ts b/tests/support/hostCleanup.ts index 443ba85817..811c065509 100644 --- a/tests/support/hostCleanup.ts +++ b/tests/support/hostCleanup.ts @@ -50,8 +50,9 @@ const expectHostCleanupReport = (report: { process.platform === "linux" ? "environment_errno_(?:EACCES|EPERM)" : "environment_unavailable"; + // Native identity facts may follow; a JSON-quoted name can hold parentheses. const candidate = - "[1-9][0-9]*=(?:environment_unavailable|environment_errno_(?:EACCES|EPERM))"; + '[1-9][0-9]*=(?:environment_unavailable|environment_errno_(?:EACCES|EPERM))(?: \\((?:[^()"]|"(?:[^"\\\\]|\\\\.)*")*\\))?'; expect(reason).toMatch( new RegExp( `^process ownership token could not be read for [1-9][0-9]* live process\\(es\\): ` + diff --git a/tests/support/reverseEngineeringCompositionEdgeCases.ts b/tests/support/reverseEngineeringCompositionEdgeCases.ts index 6886226a70..8863974da8 100644 --- a/tests/support/reverseEngineeringCompositionEdgeCases.ts +++ b/tests/support/reverseEngineeringCompositionEdgeCases.ts @@ -327,7 +327,7 @@ const registerSharedReverseEngineeringEdgeCasesPart3 = (): void => { ...(code === "ETIMEDOUT" ? { timeoutMs: 120_000 } : {}), ...(code === "EACCES" || code === "EPERM" ? { - providerId: "objdump", + providerId: "objdump.command", diagnostics: { boundary: "provider-launch", command: "objdump", @@ -337,7 +337,7 @@ const registerSharedReverseEngineeringEdgeCasesPart3 = (): void => { : {}), ...(code === "unknown" ? { - providerId: "objdump", + providerId: "objdump.command", reason: "Unable to run configured command (unknown error)", } : {}), @@ -425,7 +425,7 @@ const registerSharedReverseEngineeringEdgeCasesPart4 = (): void => { ok: false, error: { _tag: "AnalysisCapabilityUnavailableError", - providerId: "objdump", + providerId: "objdump.command", diagnostics: { boundary: "provider-launch", command: "objdump", @@ -535,7 +535,7 @@ const registerSharedReverseEngineeringEdgeCasesPart5 = (): void => { ok: false, error: { _tag: "AnalysisCapabilityUnavailableError", - providerId: "objdump", + providerId: "objdump.command", reason: "Unable to run configured command (ENOENT)", cause: { message: "launch failed" }, }, diff --git a/website/README.md b/website/README.md index 47ea63ca14..d8cd2d5f41 100644 --- a/website/README.md +++ b/website/README.md @@ -486,10 +486,66 @@ The mask SVG is drawn from the seventh decoded mask. It selects zero-based position 21 and compares its character code with 55 (`7`). Keep the figure, solver constants and evidence notes aligned when changing this case. +## Agent discovery and Markdown + +`scripts/website_agents.py` runs during asset preparation, after translations. +It generates these public files in ignored `_site/`: + +- `.well-known/agent-skills/index.json`: the draft v0.2.0 skills discovery index. + Its archive contains the maintained + `.agents/skills/reverse-engineer-anything/SKILL.md` and Markdown references. + The name and description come from YAML frontmatter; SHA-256 covers the + complete, reproducible ZIP, so reference changes invalidate the digest too. +- `.well-known/ard.json`: an ARD manifest with representative analyst queries. +- `.well-known/ai-catalog.json`: an AI Catalog 1.0 document listing that same + downloadable skill. These advertise installation instructions for the local + tool, without claiming a hosted analysis endpoint. +- `index.md` beside each indexable rendered reading page, including translations. + Markdown retains code, evidence disclosures and image descriptions, with + absolute resource links. Drafts, labeled English fallbacks and target example + applications retain their HTML and indexing policy. + +The [skills discovery draft](https://github.com/cloudflare/agent-skills-discovery-rfc), +[ARD proposal](https://agenticresourcediscovery.org/spec/) and +[AI Catalog proposal](https://ai-catalog.io/) are evolving formats. ARD v0.91 +uses `ard.json` and `rel="ard"`; AI Catalog uses its separate `ai-catalog.json` +envelope. Both entries point to the same ZIP with the registered +`application/zip` media type. + +Cloudflare's Worker selects Markdown when an explicit `Accept: text/markdown` +has higher quality than HTML. HTML wins ties and remains the default. Successful +reading-page responses include `Vary: Accept` and an alternate Markdown Link; +Markdown uses `Content-Type: text/markdown; charset=utf-8`. Static `_headers` +advertises discovery resources, sets discovery CORS to `*`, and supplies MIME +types. Build-generated HTML `` elements also advertise these resources. + +GitHub Pages and the Python preview serve the same files and HTML links, with +direct Markdown URLs such as `guides/javascript/index.md`. They do not execute +the Worker or apply Cloudflare's `_headers`: content negotiation, custom MIME +overrides and discovery CORS headers are Cloudflare behavior. The publish +workflow packages `.well-known` explicitly because `upload-pages-artifact` +excludes dot directories by default. + +`python3 scripts/website.py check` includes artifact/content regressions and +Worker request-contract tests. To check the actual local Cloudflare runtime +after preparing assets, use the repository's pinned Node.js and Wrangler: + +```sh +npx wrangler@4.149.0 dev --cwd website --env production --local +curl -i -H 'Accept: text/markdown' http://localhost:8787/guides/javascript/ +curl -I http://localhost:8787/.well-known/agent-skills/index.json +``` + +The `production` environment here skips the preview build and uses the already +verified artifact; `--local` runs on your machine. Check HTML and Markdown, +GET and HEAD, discovery CORS, the downloaded ZIP digest, and unknown routes. +Production remains published only by the manual website workflow. + ## Cloudflare Workers `wrangler.toml` serves `website/_site/` through Workers Static Assets. There is -no Worker script or frontend framework. Before a preview or deployment, Wrangler +one small Worker (`worker.mjs`) for Markdown content negotiation and no frontend +framework. Before a preview or deployment, Wrangler runs the Python scripts to build the language editions, prepare the example ZIP and verify the website. Node.js and Python 3 must be available; the commands below use a pinned Wrangler version without adding it to REA's package dependencies. diff --git a/website/public/_headers b/website/public/_headers new file mode 100644 index 0000000000..2065dd5df3 --- /dev/null +++ b/website/public/_headers @@ -0,0 +1,11 @@ +/* + Link: ; rel="ard", ; rel="ai-catalog", ; rel="related"; type="application/json"; title="Agent skills index" + +/.well-known/* + Access-Control-Allow-Origin: * + +/.well-known/ai-catalog.json + Content-Type: application/ai-catalog+json + +/*.md + Content-Type: text/markdown; charset=utf-8 diff --git a/website/requirements.txt b/website/requirements.txt index e6099d25dc..67e1541cfd 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -2,3 +2,6 @@ CairoSVG==2.9.1 # Standard gettext PO/POT catalogs; no translation service is used by builds. polib==1.2.0 +# Build Markdown reading pages and parse the maintained skill's YAML metadata. +markdownify==1.2.3 +PyYAML==6.0.3 diff --git a/website/worker.mjs b/website/worker.mjs new file mode 100644 index 0000000000..b87bf8acfb --- /dev/null +++ b/website/worker.mjs @@ -0,0 +1,103 @@ +// Negotiate prebuilt Markdown while keeping the static asset service's routing. +function prefersMarkdown(accept) { + const ranges = (accept ?? "") + .toLowerCase() + .split(",") + .map((range) => { + const [type, ...parameters] = range.trim().split(";"); + const quality = parameters + .map((value) => value.trim()) + .find((value) => value.startsWith("q=")); + const q = quality === undefined ? 1 : Number(quality.slice(2)); + return { + type: type.trim(), + q: Number.isFinite(q) && q >= 0 && q <= 1 ? q : 0, + }; + }); + const quality = (type) => { + for (const match of [type, "text/*", "*/*"]) { + const matches = ranges.filter((range) => range.type === match); + if (matches.length) return Math.max(...matches.map((range) => range.q)); + } + return 0; + }; + // Wildcards alone never opt a browser into Markdown; HTML wins ties. + return ( + ranges.some((range) => range.type === "text/markdown") && + quality("text/markdown") > 0 && + quality("text/markdown") > quality("text/html") + ); +} + +function advertiseMarkdown(response, path) { + const vary = new Set( + (response.headers.get("Vary") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + if (![...vary].some((s) => s.toLowerCase() === "accept" || s === "*")) + vary.add("Accept"); + response.headers.set("Vary", [...vary].join(", ")); + response.headers.append( + "Link", + `<${path}>; rel="alternate"; type="text/markdown"`, + ); + return response; +} + +export default { + async fetch(request, env) { + const useMarkdown = prefersMarkdown(request.headers.get("Accept")); + // HTML validators describe a different representation. Probe the route first, + // then let the asset service evaluate validators/ranges against the Markdown. + const probe = new Request(request); + if (useMarkdown) { + for (const name of [ + "If-None-Match", + "If-Modified-Since", + "If-Match", + "If-Unmodified-Since", + "If-Range", + "Range", + ]) + probe.headers.delete(name); + } + const original = await env.ASSETS.fetch(probe); + if ( + ![200, 206, 304].includes(original.status) || + !["GET", "HEAD"].includes(request.method) || + !original.headers.get("Content-Type")?.startsWith("text/html") + ) + return original; + + const url = new URL(request.url); + if (!url.pathname.endsWith("/") && !url.pathname.endsWith(".html")) + return original; + const markdown = new URL(url); + markdown.pathname = url.pathname.endsWith("/") + ? `${url.pathname}index.md` + : url.pathname.slice(0, -5) + ".md"; + const candidate = await env.ASSETS.fetch( + useMarkdown + ? new Request(markdown, request) + : new Request(markdown, { method: "HEAD" }), + ); + if (!candidate.ok && ![304, 412, 416].includes(candidate.status)) { + await candidate.body?.cancel(); + return original; + } + const response = new Response( + useMarkdown ? candidate.body : original.body, + useMarkdown ? candidate : original, + ); + if (useMarkdown) { + await original.body?.cancel(); + if (candidate.status < 400) + response.headers.set("Content-Type", "text/markdown; charset=utf-8"); + } else { + await candidate.body?.cancel(); + } + return advertiseMarkdown(response, markdown.pathname); + }, +}; diff --git a/website/worker.test.mjs b/website/worker.test.mjs new file mode 100644 index 0000000000..7c3ae74f9f --- /dev/null +++ b/website/worker.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import worker from "./worker.mjs"; + +function assets({ markdownStatus = 200 } = {}) { + return { + ASSETS: { + async fetch(request) { + const url = new URL(request.url); + const markdown = url.pathname.endsWith("index.md"); + if (url.pathname === "/missing/") + return new Response("Missing", { status: 404 }); + if (url.pathname === "/guide") + return Response.redirect(`${url.origin}/guide/`, 307); + const headers = { + "Content-Type": markdown ? "text/plain" : "text/html", + ETag: markdown ? '"markdown"' : '"html"', + Link: '; rel="ard"', + }; + const status = + request.headers.get("If-None-Match") === headers.ETag + ? 304 + : markdown + ? markdownStatus + : 200; + return new Response( + request.method === "HEAD" || status === 304 + ? null + : markdown + ? "# Guide" + : "

Guide

", + { headers, status }, + ); + }, + }, + }; +} + +await test("HTML is the default and explicit Accept preferences select Markdown", async () => { + for (const [accept, markdown] of [ + [null, false], + ["*/*", false], + ["text/html", false], + ["text/markdown", true], + ["text/markdown;q=0", false], + ["text/markdown;q=0.5,text/html", false], + ["text/markdown,text/html;q=0.5", true], + ["text/markdown,text/html", false], + ["TEXT/MARKDOWN; q=1,text/*;q=0.5", true], + ["text/markdown;q=bad", false], + ["text/markdown;q=0,*/*;q=1", false], + ]) { + const response = await worker.fetch( + new Request("https://rea.tools/guide/", { + headers: accept === null ? {} : { Accept: accept }, + }), + assets(), + ); + assert.equal( + response.headers.get("Content-Type"), + markdown ? "text/markdown; charset=utf-8" : "text/html", + accept, + ); + assert.equal( + await response.text(), + markdown ? "# Guide" : "

Guide

", + accept, + ); + assert.match(response.headers.get("Vary"), /Accept/); + assert.match(response.headers.get("Link"), /rel="ard"/); + assert.match( + response.headers.get("Link"), + /<\/guide\/index.md>; rel="alternate"/, + ); + } +}); + +await test("HEAD and validators use the selected representation", async () => { + for (const method of ["GET", "HEAD"]) { + const response = await worker.fetch( + new Request("https://rea.tools/guide/", { + method, + headers: { Accept: "text/markdown", "If-None-Match": '"html"' }, + }), + assets(), + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get("ETag"), '"markdown"'); + assert.equal(await response.text(), method === "HEAD" ? "" : "# Guide"); + } + for (const [accept, tag] of [ + ["text/html", '"html"'], + ["text/markdown", '"markdown"'], + ]) { + const response = await worker.fetch( + new Request("https://rea.tools/guide/", { + headers: { Accept: accept, "If-None-Match": tag }, + }), + assets(), + ); + assert.equal(response.status, 304); + assert.equal(await response.text(), ""); + assert.match(response.headers.get("Vary"), /Accept/); + } +}); + +await test("missing Markdown keeps HTML, and asset redirects and missing routes survive", async () => { + const html = await worker.fetch( + new Request("https://rea.tools/guide/", { + headers: { Accept: "text/markdown" }, + }), + assets({ markdownStatus: 404 }), + ); + assert.equal(await html.text(), "

Guide

"); + for (const [path, status] of [ + ["/guide", 307], + ["/missing/", 404], + ]) { + const response = await worker.fetch( + new Request(`https://rea.tools${path}`, { + headers: { Accept: "text/markdown" }, + }), + assets(), + ); + assert.equal(response.status, status); + } +}); diff --git a/website/wrangler.toml b/website/wrangler.toml index cb3f21b386..649d31cfe1 100644 --- a/website/wrangler.toml +++ b/website/wrangler.toml @@ -1,18 +1,26 @@ # Serve only the public website. The default deployment is a workers.dev preview. name = "rea-website-preview" +main = "worker.mjs" compatibility_date = "2026-10-08" workers_dev = true preview_urls = false [assets] directory = "./_site" +binding = "ASSETS" +run_worker_first = ["/", "/*/", "/*.html"] html_handling = "auto-trailing-slash" not_found_handling = "none" [build] cwd = ".." command = "python3 scripts/website.py check" -watch_dir = ["./public", "./locales", "./templates"] +watch_dir = [ + "./public", + "./locales", + "./templates", + "../.agents/skills/reverse-engineer-anything", +] # Public HTTPS depends on zone activation and certificate readiness. [env.production]