Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a3b4106b2 |
@@ -175,9 +175,7 @@ additional evidence only when the provider accepts and matches them.
|
||||
REA has a built-in read-only snapshot parser for x64 PE targets with .NET 8 RTR
|
||||
9.1 metadata. An optional headless Ghidra adapter adds database annotations and
|
||||
broader metadata recovery on Linux; it requires an explicitly configured
|
||||
NativeAOT JAR. These are separate routes. The built-in parser also runs on
|
||||
Windows Ghidra P0; read frozen literals from its report, because
|
||||
`search_strings` does not include them. See the
|
||||
NativeAOT JAR. These are separate routes. See the
|
||||
[NativeAOT guide](https://github.com/morluto/rea/blob/main/docs/ghidra-nativeaot.md)
|
||||
for setup, supported layouts, and host restrictions. Preserve binary identity
|
||||
and distinguish derived metadata from Ghidra database observations. Recovered
|
||||
@@ -249,9 +247,3 @@ resolution is explicit; missing references and size mismatch remain visible.
|
||||
Other payloads are opaque, and this operation does not decode images or simulate
|
||||
Windows resource loading. Consult the budgets and supported tree profile in
|
||||
[offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/binary-diagnostics.md).
|
||||
|
||||
For exact core-module import/export differences, use `compare_wasm_interfaces`
|
||||
with two complete inspection Evidence records (or current-session retained
|
||||
references) and explicit scope. Preserve unknown types/duplicate names; static
|
||||
interface equality is not runtime compatibility. CLI accepts complete inline
|
||||
Evidence in `rea compare-wasm-interfaces input.json --json`.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "6.5.0"
|
||||
".": "6.4.0"
|
||||
}
|
||||
|
||||
@@ -1,79 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [6.5.0](https://github.com/morluto/rea/compare/rea-agents-6.4.0...rea-agents-6.5.0) (2026-10-13)
|
||||
|
||||
These notes describe the source changes between published 6.4.0 and the pinned 6.5.0 candidate. Platform qualifications below summarize existing records in [the testing guide](docs/testing.md); no new real-provider verification is claimed by this editorial update.
|
||||
|
||||
### Highlights
|
||||
|
||||
- Analyze selected JavaScript sources and recover related bundles while preserving input identity and explicit unknowns.
|
||||
- Read compact summaries and pages of retained Evidence across binary, managed, Android, Dart, WASM and network workflows.
|
||||
- Inspect more offline formats and bounded architecture profiles with explicit tool/runtime prerequisites.
|
||||
- Preserve failure observations and resource ownership across analysis, capture, setup and Evidence workflows.
|
||||
|
||||
### Changes
|
||||
|
||||
- **JavaScript source selection:** analyze exact application paths without expanding imports into the selected scope. Unselected files retain identity and explicit unknown coverage; summary delivery remains separate from source selection. ([#1944](https://github.com/morluto/rea/pull/1944), [issue morluto/rea#1848](https://github.com/morluto/rea/issues/1848)). Thanks @kaoru0822-kitauji, @N0zoM1z0.
|
||||
- **Code-split recovery:** recover related bundles from multiple selected inputs, retaining per-file provenance and downstream cross-chunk imports. Recorded CLI/MCP coverage is Linux x64 with Wakaru 1.14.0 and source-owned esbuild/webpack fixtures; other hosts, releases and arbitrary application equivalence remain unverified. ([#1686](https://github.com/morluto/rea/pull/1686), [issue morluto/rea#1605](https://github.com/morluto/rea/issues/1605)). Thanks @JingHao-Leon, @pionxzh.
|
||||
- **Package imports:** resolve exact package import aliases using the selected loader and package ancestry, rather than guessing sibling files. ([#1862](https://github.com/morluto/rea/pull/1862), [issue morluto/rea#1838](https://github.com/morluto/rea/issues/1838)). Thanks @AWUQAQ, @mvanhorn.
|
||||
- **Retained analysis views:** opt into compact inventory/WASM, managed metadata, Android manifest, Dart AOT, Mach-O and EVM summaries, then page retained Evidence without rerunning analysis. Summary delivery preserves the complete recorded result and its original coverage. ([#2122](https://github.com/morluto/rea/pull/2122), [#2193](https://github.com/morluto/rea/pull/2193), [#2413](https://github.com/morluto/rea/pull/2413), [#2438](https://github.com/morluto/rea/pull/2438), [#2455](https://github.com/morluto/rea/pull/2455), [#2634](https://github.com/morluto/rea/pull/2634), [#2308](https://github.com/morluto/rea/pull/2308), [9a18251](https://github.com/morluto/rea/commit/9a18251426f891e6a24856b715ef8002ab8b9610)). Thanks @kaoru0822-kitauji, @N0zoM1z0.
|
||||
- **Managed assemblies:** select declaring types when inspecting members, with CLI selections bound to the member contract. ([#1706](https://github.com/morluto/rea/pull/1706), [#2125](https://github.com/morluto/rea/pull/2125), [issue morluto/rea#2121](https://github.com/morluto/rea/issues/2121)). Thanks @kaoru0822-kitauji, @N0zoM1z0, @skoodster.
|
||||
- **Packet captures:** inspect saved packets with bounded TShark output, compact capture summaries and retained pages; discover installed protocol/field definitions. Recorded real-engine coverage uses Linux and Wireshark 4.6.6; native Windows/macOS and Bun behavior remain unverified. ([#2318](https://github.com/morluto/rea/pull/2318), [#2340](https://github.com/morluto/rea/pull/2340), [#2496](https://github.com/morluto/rea/pull/2496)). Thanks @3QL, @N0zoM1z0, @Olorinm.
|
||||
- **Recorded signals:** decode existing recordings and discover installed decoders/options through caller-supplied sigrok-cli 0.7.2. This is Linux-only and requires util-linux `prlimit`; it acquires no hardware samples. ([#2339](https://github.com/morluto/rea/pull/2339), [#2487](https://github.com/morluto/rea/pull/2487)). Thanks @N0zoM1z0, @Olorinm.
|
||||
- **Web captures:** derive an inferred method/origin/path-template API surface from retained HAR data without exposing query values, headers or bodies. ([#1891](https://github.com/morluto/rea/pull/1891)). Thanks @mvanhorn.
|
||||
- **QUIC captures:** inspect retained aioquic 1.5.0 JSON qlog 0.3 objects and page traces/streams/observations. Other qlog layouts remain unsupported; overlap and loss declarations do not prove retransmission or network loss. ([#2277](https://github.com/morluto/rea/pull/2277)). Thanks @LuisTaffelli, @N0zoM1z0.
|
||||
- **Fiddler captures:** inspect native SAZ archives with retained HTTP provenance and bounded decoding. Recorded producer coverage is the pinned public encoding corpus; current Fiddler Everywhere variants and Bun-native execution remain unverified. ([#2409](https://github.com/morluto/rea/pull/2409)). Thanks @N0zoM1z0, @zahhak.
|
||||
- **SQLite snapshots:** inspect an explicitly selected offline database with table/column selection and bounded Evidence. This operation requires Node 24.x >=24.15 or 26+ with `DatabaseSync.limits`; supported Node 22 and earlier 24 runtimes cannot use it. A live database/WAL copy does not establish an atomic snapshot. ([#2271](https://github.com/morluto/rea/pull/2271)). Thanks @N0zoM1z0, @Y0UW1SH.
|
||||
- **WASM artifacts:** inspect local modules with bring-your-own WABT 1.0.42, without executing the module. The tools are not bundled or installed by REA; recorded real-tool CLI/MCP coverage is macOS arm64 with WABT 1.0.42; other hosts remain unverified. ([#1533](https://github.com/morluto/rea/pull/1533), [issue morluto/rea#884](https://github.com/morluto/rea/issues/884)). Thanks @N0zoM1z0, @thangldw.
|
||||
- **Go binaries:** inspect embedded build metadata, including file-backed unmapped Mach-O debug segments. ([#1363](https://github.com/morluto/rea/pull/1363), [7fb097d](https://github.com/morluto/rea/commit/7fb097d72c9931dd5dfae481ad2b9d3bb6029f15)). Thanks @Y0UW1SH.
|
||||
- **Flutter APKs:** identify snapshot/engine builds and inspect AOT structure without an external engine. Recorded real APK coverage uses the documented Flutter Gallery fixture; macOS/Windows and complete pool semantics remain unverified, and pool coverage stays partial. ([#1582](https://github.com/morluto/rea/pull/1582)). Thanks @3QL.
|
||||
- **HarmonyOS packages:** inventory HAP/HSP/App Pack structures without executing code. The recorded real workflow covers one pinned HAP through repository-built CLI/MCP; installed-package and real HSP/App Pack coverage remain unverified. ([#1447](https://github.com/morluto/rea/pull/1447)). Thanks @BaaRai.
|
||||
- **Ghidra raw inputs and references:** compatible Linux/macOS Ghidra profiles accept caller-selected raw architecture/entry information and expose typed incoming references at exact addresses. These are Ghidra capabilities; Windows Ghidra P0 and other providers do not inherit raw-input or reference-facet support. ([#1868](https://github.com/morluto/rea/pull/1868), [issue morluto/rea#1836](https://github.com/morluto/rea/issues/1836), [#1715](https://github.com/morluto/rea/pull/1715), [issue morluto/rea#1245](https://github.com/morluto/rea/issues/1245)). Thanks @bugstan, @casesolved-co-uk, @mvanhorn, @N0zoM1z0.
|
||||
- **Ghidra MIPS:** add bounded MIPS32r2/o32 relocatable objects, soft-float integer-register storage and admitted MIPS16 shared ELF profiles. Admission follows each documented ABI/flag/relocation boundary and the recorded source-fixture lane, rather than generic MIPS compatibility. ([#1949](https://github.com/morluto/rea/pull/1949), [issue morluto/rea#1937](https://github.com/morluto/rea/issues/1937), [#1950](https://github.com/morluto/rea/pull/1950), [issue morluto/rea#1938](https://github.com/morluto/rea/issues/1938), [#1991](https://github.com/morluto/rea/pull/1991), [issue morluto/rea#1851](https://github.com/morluto/rea/issues/1851)). Thanks @AurelioB, @kaoru0822-kitauji, @N0zoM1z0.
|
||||
- **Ghidra RISC-V:** admit little-endian RV32/RV64 `ET_EXEC` ELF declaring the supported double-float ABI. Relocatable/shared, big-endian and other ABI profiles remain refused; see [the profile and recorded lane](docs/ghidra-riscv.md). ([#2151](https://github.com/morluto/rea/pull/2151)). Thanks @NIU-123370.
|
||||
- **PSP static ELF profile:** the Ghidra profile is Linux x64, bounded little-endian ELF32 `ET_EXEC` with the documented PSP/ABI declarations and a compatible caller-installed Allegrex extension. PRX, shared/relocatable images, PBP/ISO/CSO and runtime emulation are outside scope. Historical extension experiments are separate from public-provider verification; real public Ghidra PSP coverage remains unverified. ([#1599](https://github.com/morluto/rea/pull/1599)). Thanks @jaelliot.
|
||||
- **Ghidra analysis settings:** apply validated analyzer overrides before analysis while retaining the selected analysis profile. ([#1913](https://github.com/morluto/rea/pull/1913)). Thanks @akushonkamen, @kaoru0822-kitauji, @N0zoM1z0.
|
||||
- **Attached native IDA MCP:** add an optional native bridge alongside existing legacy/headless profiles. Recorded native runs use Linux IDA Free 9.4 with a navigation correction and Windows IDA Pro 9.3 with a loopback-listener change; stock native switching, native macOS and Windows IDA Free remain unverified. Attached databases remain read-only and are never saved or closed. ([#1544](https://github.com/morluto/rea/pull/1544)). Thanks @imissapixel, @N0zoM1z0.
|
||||
- **Frida instrumentation:** add explicit local/remote target selection, caller-supplied scripts and retained messages, with script-read/input diagnostics and bounded message eviction. Recorded real coverage is the Linux local Node marker workflow; macOS, Windows and remote devices/servers remain unverified. Instrumentation executes foreign code and is destructive, not read-only. ([#1359](https://github.com/morluto/rea/pull/1359), [#2211](https://github.com/morluto/rea/pull/2211), [issue morluto/rea#2209](https://github.com/morluto/rea/issues/2209), [#2217](https://github.com/morluto/rea/pull/2217), [issue morluto/rea#2213](https://github.com/morluto/rea/issues/2213), [#2628](https://github.com/morluto/rea/pull/2628)). Thanks @dandgabr, @kaoru0822-kitauji, @N0zoM1z0.
|
||||
- **Client setup:** register Qoder, Trae, ZCode, DeepSeek Harness, Kimi Code and Factory Droid through the existing scoped setup flow. DeepSeek is included from 6.5.0; the historical npm 6.4 check did not cover it. Live Trae and DeepSeek API integration remain unverified. ([#1564](https://github.com/morluto/rea/pull/1564), [#1572](https://github.com/morluto/rea/pull/1572), [#1573](https://github.com/morluto/rea/pull/1573), [#1929](https://github.com/morluto/rea/pull/1929), [issue morluto/rea#1839](https://github.com/morluto/rea/issues/1839), [#2744](https://github.com/morluto/rea/pull/2744), [#2309](https://github.com/morluto/rea/pull/2309)). Thanks @BaaRai, @IvenKooLab, @whoishaha, @ysntony, @0xRafie.
|
||||
- **Website:** add maintained language editions, system-aware/persistent light and dark themes, clearer setup navigation, keyboard styling and nested Evidence indentation, plus the reverse-engineering models essay. ([#1779](https://github.com/morluto/rea/pull/1779), [issue morluto/rea#1022](https://github.com/morluto/rea/issues/1022), [#1699](https://github.com/morluto/rea/pull/1699), [#1714](https://github.com/morluto/rea/pull/1714), [#1894](https://github.com/morluto/rea/pull/1894), [#2250](https://github.com/morluto/rea/pull/2250), [#2432](https://github.com/morluto/rea/pull/2432), [#2732](https://github.com/morluto/rea/pull/2732)). Thanks @CassieFox23, @kaoru0822-kitauji, @N0zoM1z0.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Windows artifact classification:** identify `.exe`, `.com`, `.bat` and `.cmd` artifacts as launchable host executables. ([#1824](https://github.com/morluto/rea/pull/1824), [issue morluto/rea#1819](https://github.com/morluto/rea/issues/1819)). Thanks @dollarsmoney, @JasonBuildAI.
|
||||
|
||||
- **CLI/MCP delivery:** defer command-specific startup work, bundle runtime entries, retain field/input correction details across output formats and preserve shared optional-provider lifetime. MCP selectors, paging continuation and retained partial-error references remain explicit; session liveness/provider-attribution corrections retain their controlled-provider verification limits. ([#1691](https://github.com/morluto/rea/pull/1691), [issue morluto/rea#1685](https://github.com/morluto/rea/issues/1685), [#1705](https://github.com/morluto/rea/pull/1705), [issue morluto/rea#1695](https://github.com/morluto/rea/issues/1695), [#1755](https://github.com/morluto/rea/pull/1755), [issue morluto/rea#1746](https://github.com/morluto/rea/issues/1746), [#1773](https://github.com/morluto/rea/pull/1773), [issue morluto/rea#1771](https://github.com/morluto/rea/issues/1771), [#1780](https://github.com/morluto/rea/pull/1780), [#1814](https://github.com/morluto/rea/pull/1814), [#1910](https://github.com/morluto/rea/pull/1910), [issue morluto/rea#1849](https://github.com/morluto/rea/issues/1849), [#1996](https://github.com/morluto/rea/pull/1996), [#2140](https://github.com/morluto/rea/pull/2140), [issue morluto/rea#2135](https://github.com/morluto/rea/issues/2135), [#2205](https://github.com/morluto/rea/pull/2205), [issue morluto/rea#2195](https://github.com/morluto/rea/issues/2195), [#2230](https://github.com/morluto/rea/pull/2230), [issue morluto/rea#2180](https://github.com/morluto/rea/issues/2180), [#2294](https://github.com/morluto/rea/pull/2294), [#2482](https://github.com/morluto/rea/pull/2482), [a82d14e](https://github.com/morluto/rea/commit/a82d14e4db4dca989ffa2e7dbe78fd43551da256), [a4dd4e8](https://github.com/morluto/rea/commit/a4dd4e8e8700ce350e1a0c612ebd61b91ca5e585), [48c352b](https://github.com/morluto/rea/commit/48c352b6bfa34c820a530272cd36600158c37b1e), [07b36c1](https://github.com/morluto/rea/commit/07b36c148946f46b99a4ce04e55726e97d0ee03a), [570c40c](https://github.com/morluto/rea/commit/570c40cde55d31e455ef9bc484b7b908658ded58), [8fc2043](https://github.com/morluto/rea/commit/8fc2043eb8bcedbf34dce064f977e455438635d6), [7fc0b5e](https://github.com/morluto/rea/commit/7fc0b5ec9c2532bbf789d7783b7a0cab6fc054d8)). Thanks @369Apps, @chqcjq, @cold-atom, @coygeek, @JackOdegard, @kaoru0822-kitauji, @N0zoM1z0, @sx4im.
|
||||
- **Evidence import/replay:** import bundles across session transitions while authenticating selected target and file identities, preserve literal keys, metadata/mutation intent and relationship coverage, and honor cancellation before session mutation. Reuse authenticated snapshots and comparisons within their owned lifetime; reject malformed/non-object snapshots and unsupported metadata without disguising uncertainty. ([#1694](https://github.com/morluto/rea/pull/1694), [#1716](https://github.com/morluto/rea/pull/1716), [issue morluto/rea#1680](https://github.com/morluto/rea/issues/1680), [#1721](https://github.com/morluto/rea/pull/1721), [issue morluto/rea#1718](https://github.com/morluto/rea/issues/1718), [#1733](https://github.com/morluto/rea/pull/1733), [issue morluto/rea#1723](https://github.com/morluto/rea/issues/1723), [issue morluto/rea#1724](https://github.com/morluto/rea/issues/1724), [#1776](https://github.com/morluto/rea/pull/1776), [#1873](https://github.com/morluto/rea/pull/1873), [#2001](https://github.com/morluto/rea/pull/2001), [#2013](https://github.com/morluto/rea/pull/2013), [issue morluto/rea#2009](https://github.com/morluto/rea/issues/2009), [#2083](https://github.com/morluto/rea/pull/2083), [#2303](https://github.com/morluto/rea/pull/2303), [#2685](https://github.com/morluto/rea/pull/2685), [issue morluto/rea#2675](https://github.com/morluto/rea/issues/2675), [c002b89](https://github.com/morluto/rea/commit/c002b89e2d8130a3c07e25e5fd604876be18c703), [9c85114](https://github.com/morluto/rea/commit/9c8511409c0706f9979c2b68dd56aaaf9f21e191), [aada0ca](https://github.com/morluto/rea/commit/aada0ca8126bf14c60750a4ccd4a79667770a1e4), [4a4d36f](https://github.com/morluto/rea/commit/4a4d36f881861bbab3c734b0dd459f805766caec), [1c64fe3](https://github.com/morluto/rea/commit/1c64fe3eff48679c6880f42d2f0a4af46686ade7), [6fb3a34](https://github.com/morluto/rea/commit/6fb3a345fe952b8e8f59221aec993acc37ccbf31), [3bcc68c](https://github.com/morluto/rea/commit/3bcc68c037e9415c78093cb286069c1fcca6a2f8), [6bae774](https://github.com/morluto/rea/commit/6bae774328724403255590aa2ddad4d7a2aa727f), [e997085](https://github.com/morluto/rea/commit/e9970855ba16ba711816ac30212c5ca17ae085cd), [d1dcebc](https://github.com/morluto/rea/commit/d1dcebce2c0820469ed63e33497a14258971b710), [b123b2e](https://github.com/morluto/rea/commit/b123b2e51cc7d6d2157525664be2df88fb1ba3bd), [049132a](https://github.com/morluto/rea/commit/049132ab43fa962d31774f2b258c15a71217148b), [042dcaa](https://github.com/morluto/rea/commit/042dcaa1ee66f1b8668f08b193f38ab01bceaacb), [acf75e6](https://github.com/morluto/rea/commit/acf75e6700ace28bb1db25e54e56673ac15e0ed3), [1f7f808](https://github.com/morluto/rea/commit/1f7f808c4f3bb5eca6b13bcc71d5cceb2ace6614), [26bffb9](https://github.com/morluto/rea/commit/26bffb94bf80355c35e22a98a8fc629c6210307e)). Thanks @coygeek, @DeryFerd, @jiangjiang248, @kaoru0822-kitauji, @khanukov, @N0zoM1z0, @NIU-123370, @rohitg00, @sx4im.
|
||||
- **Ghidra setup and analysis:** preserve launcher/JVM diagnostics, Java-property and extension identity, stale-client recovery, UTF-8 bridge output and pending-startup progress. File-offset decompilation is Ghidra-specific; recorded coverage is Linux x64 with Ghidra 12.1.4. Startup/progress/compiler failure paths that use simulations remain unverified with a real engine. ([#1698](https://github.com/morluto/rea/pull/1698), [#1877](https://github.com/morluto/rea/pull/1877), [#1931](https://github.com/morluto/rea/pull/1931), [#2011](https://github.com/morluto/rea/pull/2011), [issue morluto/rea#1992](https://github.com/morluto/rea/issues/1992), [#2014](https://github.com/morluto/rea/pull/2014), [#2101](https://github.com/morluto/rea/pull/2101), [issue morluto/rea#2078](https://github.com/morluto/rea/issues/2078), [#2206](https://github.com/morluto/rea/pull/2206), [issue morluto/rea#2204](https://github.com/morluto/rea/issues/2204), [2187e16](https://github.com/morluto/rea/commit/2187e16f149a7a7b512a4cb45d5f468cc04091fe), [b953f96](https://github.com/morluto/rea/commit/b953f96c0aae27e1876c317a8f7db1d6f292b8ff)). Thanks @EDMND-SRC, @Ha1baraA11, @kaoru0822-kitauji, @lovecontroing, @lyly-bonbon, @N0zoM1z0, @nosmile99, @Petru101, @sx4im, @Zhong-fan.
|
||||
- **Process and cleanup ownership:** retain private runtime/file owners and detailed incomplete-cleanup observations, bound trace cycles/order state, preserve caller paths, and use monotonic time for budgets while retaining wall dates. Windows affinity/admission ([#2202](https://github.com/morluto/rea/pull/2202), [issue morluto/rea#2181](https://github.com/morluto/rea/issues/2181)) and Bun memory-enforcement paths retain their recorded native-versus-controlled-fixture limits; these fixes are not a general process-ownership guarantee. ([#1700](https://github.com/morluto/rea/pull/1700), [issue morluto/rea#1697](https://github.com/morluto/rea/issues/1697), [#1730](https://github.com/morluto/rea/pull/1730), [#1756](https://github.com/morluto/rea/pull/1756), [issue morluto/rea#1753](https://github.com/morluto/rea/issues/1753), [#1760](https://github.com/morluto/rea/pull/1760), [issue morluto/rea#1757](https://github.com/morluto/rea/issues/1757), [#1826](https://github.com/morluto/rea/pull/1826), [#1907](https://github.com/morluto/rea/pull/1907), [issue morluto/rea#1856](https://github.com/morluto/rea/issues/1856), [#1999](https://github.com/morluto/rea/pull/1999), [issue morluto/rea#1993](https://github.com/morluto/rea/issues/1993), [#2232](https://github.com/morluto/rea/pull/2232), [issue morluto/rea#2170](https://github.com/morluto/rea/issues/2170), [#2252](https://github.com/morluto/rea/pull/2252), [issue morluto/rea#2218](https://github.com/morluto/rea/issues/2218), [#2467](https://github.com/morluto/rea/pull/2467), [issue morluto/rea#2257](https://github.com/morluto/rea/issues/2257), [#2468](https://github.com/morluto/rea/pull/2468), [#2488](https://github.com/morluto/rea/pull/2488), [09b6828](https://github.com/morluto/rea/commit/09b68285166bef2b7633402c8adf7eb25823b47e), [7285e15](https://github.com/morluto/rea/commit/7285e15839a4fecf9940683699b781a60ccc4775), [b5ededd](https://github.com/morluto/rea/commit/b5ededd0f9c28d0f696a205103066928117368ee), [d8c76b6](https://github.com/morluto/rea/commit/d8c76b629639bda4405afa9ae2f7676b694f2f10), [98d8470](https://github.com/morluto/rea/commit/98d8470d36cfbf7cd25820a4ed430aebae20c0c5), [c22111e](https://github.com/morluto/rea/commit/c22111eca2c4bc84a084f12711f405b3e7422a40), [cecc6ec](https://github.com/morluto/rea/commit/cecc6ecbffe84689a173d1634f342e046b0c8ceb), [10935b7](https://github.com/morluto/rea/commit/10935b7b1136accab2a43b308ab380749da0f7ae), [63e796d](https://github.com/morluto/rea/commit/63e796d50ea323ae8dcfef80f86c8154bb106790), [d9e9fb2](https://github.com/morluto/rea/commit/d9e9fb2273543caab4eb21d896c33d462ada6971), [9049dba](https://github.com/morluto/rea/commit/9049dbaf0c3df32357419f9899965df2b611f493), [ed881c6](https://github.com/morluto/rea/commit/ed881c6d2ef6a8adba0c2a241ebc9ab4603b7868)). Thanks @Ashen00001, @charan-rathore, @coygeek, @EDMND-SRC, @JasonBuildAI, @jimpablo, @kaoru0822-kitauji, @N0zoM1z0, @NIU-123370, @Rinkia, @rudycelekli, @sx4im, @xeropresence, @XiaoBinGan, @xthunder0, @hzh666kevin-hue.
|
||||
- **Native image identity:** preserve universal Mach-O containers and selected slice/subtype identity, chained fixup locations and library ordinals, exact signing text, PE resource capacity and ELF/header framing. Mach-O summary selection and independent slice commands avoid unnecessary delivery/work. Controlled routing/parser fixtures do not establish every installed-provider path. ([#1703](https://github.com/morluto/rea/pull/1703), [issue morluto/rea#1702](https://github.com/morluto/rea/issues/1702), [#1708](https://github.com/morluto/rea/pull/1708), [#1709](https://github.com/morluto/rea/pull/1709), [#1713](https://github.com/morluto/rea/pull/1713), [#1772](https://github.com/morluto/rea/pull/1772), [issue morluto/rea#1765](https://github.com/morluto/rea/issues/1765), [#1784](https://github.com/morluto/rea/pull/1784), [issue morluto/rea#1775](https://github.com/morluto/rea/issues/1775), [#1982](https://github.com/morluto/rea/pull/1982), [#1995](https://github.com/morluto/rea/pull/1995), [issue morluto/rea#1990](https://github.com/morluto/rea/issues/1990), [#2040](https://github.com/morluto/rea/pull/2040), [#2107](https://github.com/morluto/rea/pull/2107), [#2161](https://github.com/morluto/rea/pull/2161), [issue morluto/rea#2105](https://github.com/morluto/rea/issues/2105), [#2248](https://github.com/morluto/rea/pull/2248), [issue morluto/rea#2176](https://github.com/morluto/rea/issues/2176), [#2281](https://github.com/morluto/rea/pull/2281), [issue morluto/rea#2274](https://github.com/morluto/rea/issues/2274), [#2292](https://github.com/morluto/rea/pull/2292), [#2466](https://github.com/morluto/rea/pull/2466), [#2506](https://github.com/morluto/rea/pull/2506), [#2733](https://github.com/morluto/rea/pull/2733), [issue morluto/rea#2661](https://github.com/morluto/rea/issues/2661), [f598bab](https://github.com/morluto/rea/commit/f598bab488e38c378d69346fa3c106f9e18529ed), [108b5f9](https://github.com/morluto/rea/commit/108b5f94254281639badd25b1f448cca35dc89a2), [f1924cf](https://github.com/morluto/rea/commit/f1924cf9c1910d5d29d11e7bf634b51af8d738cf), [8bd5ba2](https://github.com/morluto/rea/commit/8bd5ba2d234cbd9a5bb9dd5daeaa191c3a1cad24), [b38429c](https://github.com/morluto/rea/commit/b38429cb6a0de8f4bb5ae01633e24abe2d7cde46), [8d4d631](https://github.com/morluto/rea/commit/8d4d631606c701aac150cf009d3b80ae4a7ae7d5), [ac57c88](https://github.com/morluto/rea/commit/ac57c88efe64c3125850b54d3d833227e6b8ec51), [dc038d1](https://github.com/morluto/rea/commit/dc038d16bba9046afd28970de9c47f6259a43524), [34a4bb6](https://github.com/morluto/rea/commit/34a4bb6508006fa113463ef20ed799a2994e9ee8), [ebc22d9](https://github.com/morluto/rea/commit/ebc22d99d62225505c0a84f6e39823ba4ef2f829), [c02fb0e](https://github.com/morluto/rea/commit/c02fb0ea8055f98def2b12ad557aed0faefd3639)). Thanks @1NormalGuy, @Aliciawque, @baron, @coygeek, @dhl84, @hemanthrayuduu, @JasonBuildAI, @kaoru0822-kitauji, @N0zoM1z0, @thangldw, @viniciusdsandrade, @XiaoBinGan, @Zburgers.
|
||||
- **Archive and artifact provenance:** keep physical ZIP occurrence identity, exact signatures/names and partial inventory coverage; bound decompression and reject malformed keys/integers. Directory/ASAR/DMG reads retain ownership through cleanup and publication cancellation. Duplicate ZIP names remain explicitly ambiguous where the public projection cannot represent them; synthetic archive/cleanup cases do not prove every real Unblob or DMG failure path. ([#1726](https://github.com/morluto/rea/pull/1726), [issue morluto/rea#1725](https://github.com/morluto/rea/issues/1725), [#1823](https://github.com/morluto/rea/pull/1823), [issue morluto/rea#1819](https://github.com/morluto/rea/issues/1819), [#1854](https://github.com/morluto/rea/pull/1854), [issue morluto/rea#1844](https://github.com/morluto/rea/issues/1844), [#1865](https://github.com/morluto/rea/pull/1865), [issue morluto/rea#1860](https://github.com/morluto/rea/issues/1860), [#2069](https://github.com/morluto/rea/pull/2069), [#2106](https://github.com/morluto/rea/pull/2106), [#2144](https://github.com/morluto/rea/pull/2144), [#2365](https://github.com/morluto/rea/pull/2365), [#2493](https://github.com/morluto/rea/pull/2493), [918713a](https://github.com/morluto/rea/commit/918713af376e307ecf24d75e6a0dd9d1b544545b), [eb97da3](https://github.com/morluto/rea/commit/eb97da33881e3d1472d97a7d788ee35944fb6b07), [2260887](https://github.com/morluto/rea/commit/2260887f2092be6a3d9c79509e4ae4a3e52fce7f), [74d7da6](https://github.com/morluto/rea/commit/74d7da6ced0f869936ee0316b00616931b088066), [ed485a3](https://github.com/morluto/rea/commit/ed485a3dc8cc3a4baf8cabe01acb7aa0490b73b3), [921bb95](https://github.com/morluto/rea/commit/921bb9575505065f6cfd57b14d97d80d0f238546), [d8ceb3f](https://github.com/morluto/rea/commit/d8ceb3f276a65c181b146f68c0729ff4b6f9fa61), [9435c7a](https://github.com/morluto/rea/commit/9435c7aae279ba184937c0dfeb713fd574ba3f29), [bb54082](https://github.com/morluto/rea/commit/bb540826d65316c164d2de8ba4ef10f81a8ad073), [a73a55f](https://github.com/morluto/rea/commit/a73a55f53cc2b2e5b7618effe6ecb5cee6e0cee2), [3c57e1a](https://github.com/morluto/rea/commit/3c57e1a8b21b379fca55032d177078e1d8426e64), [ec0b081](https://github.com/morluto/rea/commit/ec0b081371f118e6e5e042fbef3479359c672ddb), [e7882b3](https://github.com/morluto/rea/commit/e7882b34f4ac4a26f903c5f49264acc2c19bb562), [b955b82](https://github.com/morluto/rea/commit/b955b825fc002a4bb408f2910b78768c566c2cd1)). Thanks @1052326311, @1NormalGuy, @baron, @Bortlesboat, @Chaoqi31, @DeryFerd, @JasonBuildAI, @kaoru0822-kitauji, @N0zoM1z0, @XiaoBinGan.
|
||||
- **Setup/uninstall recovery:** preserve unrelated YAML text, caller-selected roots, symlinked data and existing recovery snapshots; explain malformed configuration shapes and retain failed rollback/cleanup owners. Show terminal dry-run plans and each selected skill location. Bunx registration and fault-injected recovery cases remain simulated rather than live-client verification. ([#1731](https://github.com/morluto/rea/pull/1731), [#1754](https://github.com/morluto/rea/pull/1754), [#1821](https://github.com/morluto/rea/pull/1821), [issue morluto/rea#1816](https://github.com/morluto/rea/issues/1816), [#1983](https://github.com/morluto/rea/pull/1983), [#2118](https://github.com/morluto/rea/pull/2118), [issue morluto/rea#2111](https://github.com/morluto/rea/issues/2111), [#2154](https://github.com/morluto/rea/pull/2154), [issue morluto/rea#2152](https://github.com/morluto/rea/issues/2152), [#2157](https://github.com/morluto/rea/pull/2157), [#2182](https://github.com/morluto/rea/pull/2182), [issue morluto/rea#2159](https://github.com/morluto/rea/issues/2159), [#2481](https://github.com/morluto/rea/pull/2481), [bb50813](https://github.com/morluto/rea/commit/bb508134efbba392a404b651d6ec05be8b7eac1e), [c240768](https://github.com/morluto/rea/commit/c2407689cb04df082e8db0b6afa8f8d022f6bcd1), [dcfc5d4](https://github.com/morluto/rea/commit/dcfc5d403ac997c2b08bee4753e57a1a4a8a8538), [21eff3b](https://github.com/morluto/rea/commit/21eff3bae10f58711a35ad44f86e9f96f9bb4790), [94153f1](https://github.com/morluto/rea/commit/94153f1c757e07a6d092e00903aa8b125d4a0afa), [2397137](https://github.com/morluto/rea/commit/23971376158e7815edcbe6c04a8df7c9504650dc), [c339f9e](https://github.com/morluto/rea/commit/c339f9ebe7bd1f30608d6ccb7175da523dd3c485), [f6775a3](https://github.com/morluto/rea/commit/f6775a36694a6a9c620f4248638e8056a6cbcb66)). Thanks @BeqaKs, @Jax367, @kaoru0822-kitauji, @NIU-123370, @shoyann, @viniciusdsandrade, @xeropresence, @zaydiscold.
|
||||
- **Bun updates:** locate global installations before changing directories and reuse parsed client activation during maintenance. The update flow is covered with package-manager/filesystem simulations; a real Bun global installation update remains unverified. ([#1735](https://github.com/morluto/rea/pull/1735), [e90acae](https://github.com/morluto/rea/commit/e90acaea674f5f7f9f17ea472104afdc1422ada6), [b045df1](https://github.com/morluto/rea/commit/b045df100e1489e08a36d6ff04cea7feecf1332c)). Thanks @xeropresence.
|
||||
- **JavaScript relationships:** preserve loader-specific package/TypeScript/CommonJS boundaries, exact file-URL and source-map identity, Electron intrinsic receivers/IPC guards and chunk metadata. Index repeated lookups and retain completed facts through resource limits and cancellation. Real Electron integration, Bun worker integration and failure-path cleanup remain unverified where the recorded lanes use fixtures. ([#1740](https://github.com/morluto/rea/pull/1740), [#1759](https://github.com/morluto/rea/pull/1759), [issue morluto/rea#1748](https://github.com/morluto/rea/issues/1748), [#1762](https://github.com/morluto/rea/pull/1762), [issue morluto/rea#1761](https://github.com/morluto/rea/issues/1761), [#1767](https://github.com/morluto/rea/pull/1767), [#1788](https://github.com/morluto/rea/pull/1788), [issue morluto/rea#1781](https://github.com/morluto/rea/issues/1781), [issue morluto/rea#1782](https://github.com/morluto/rea/issues/1782), [#1789](https://github.com/morluto/rea/pull/1789), [#1799](https://github.com/morluto/rea/pull/1799), [issue morluto/rea#1793](https://github.com/morluto/rea/issues/1793), [#1827](https://github.com/morluto/rea/pull/1827), [issue morluto/rea#1802](https://github.com/morluto/rea/issues/1802), [#1833](https://github.com/morluto/rea/pull/1833), [issue morluto/rea#1831](https://github.com/morluto/rea/issues/1831), [#1857](https://github.com/morluto/rea/pull/1857), [issue morluto/rea#1842](https://github.com/morluto/rea/issues/1842), [#1871](https://github.com/morluto/rea/pull/1871), [#1874](https://github.com/morluto/rea/pull/1874), [#1905](https://github.com/morluto/rea/pull/1905), [#1912](https://github.com/morluto/rea/pull/1912), [issue morluto/rea#1909](https://github.com/morluto/rea/issues/1909), [#1915](https://github.com/morluto/rea/pull/1915), [issue morluto/rea#1911](https://github.com/morluto/rea/issues/1911), [#1916](https://github.com/morluto/rea/pull/1916), [#1921](https://github.com/morluto/rea/pull/1921), [#1934](https://github.com/morluto/rea/pull/1934), [#1939](https://github.com/morluto/rea/pull/1939), [issue morluto/rea#1881](https://github.com/morluto/rea/issues/1881), [issue morluto/rea#1882](https://github.com/morluto/rea/issues/1882), [issue morluto/rea#1883](https://github.com/morluto/rea/issues/1883), [#1941](https://github.com/morluto/rea/pull/1941), [#2062](https://github.com/morluto/rea/pull/2062), [issue morluto/rea#2058](https://github.com/morluto/rea/issues/2058), [#2104](https://github.com/morluto/rea/pull/2104), [#2422](https://github.com/morluto/rea/pull/2422), [#2475](https://github.com/morluto/rea/pull/2475), [#2737](https://github.com/morluto/rea/pull/2737), [issue morluto/rea#2663](https://github.com/morluto/rea/issues/2663), [d3088c3](https://github.com/morluto/rea/commit/d3088c3795f6c37cb3b6aec0bb0c371dd0640ee8), [1dfb933](https://github.com/morluto/rea/commit/1dfb933e47db0156cf1cb61f28715b81e740c57b), [f8513a4](https://github.com/morluto/rea/commit/f8513a42dc44451a621127f06edde5d38bc93f69), [b8fab74](https://github.com/morluto/rea/commit/b8fab7429771bb17a7ca0297661de3c9935212af), [42b0ed1](https://github.com/morluto/rea/commit/42b0ed126df7d5b908ac369715378d5e519051b7), [c0904b4](https://github.com/morluto/rea/commit/c0904b4c1aabfccf2f0ab12f44960a0a1fbbbfae), [9f67e44](https://github.com/morluto/rea/commit/9f67e44e5d451d705bae9a579ad9802d5c9f097c), [e4532b5](https://github.com/morluto/rea/commit/e4532b537663026c3e5b784380eecd009c959a36), [1c37914](https://github.com/morluto/rea/commit/1c37914306b3ff257d7a0360f11c9bf2a3f2cac7), [5ab0cbb](https://github.com/morluto/rea/commit/5ab0cbb48ea5a4f11542eac44479b9af48f1dabd), [b7169c8](https://github.com/morluto/rea/commit/b7169c8b747cbc032913b31f008a3b81c77b6219), [f43fc02](https://github.com/morluto/rea/commit/f43fc0247527aebb8efc7d9db04276fe2d489323), [3ae826c](https://github.com/morluto/rea/commit/3ae826ceba6f25b08bce50bcb51001209bc16b57), [1b95302](https://github.com/morluto/rea/commit/1b953021eadf4cac09e20be388d79536903633b3), [eaa9616](https://github.com/morluto/rea/commit/eaa961658578522cecfbba7c7be1acf1c64da65a), [c923fb0](https://github.com/morluto/rea/commit/c923fb0dc9462e302b84a15770131ff899f89455), [c7a890d](https://github.com/morluto/rea/commit/c7a890d12987d9b2d496119c1fbf1a88c0fc298e)). Thanks @Aurantium-47, @baron, @chqcjq, @coygeek, @cyanseek, @fengdwx, @flitzrrr, @immanuwell, @kaoru0822-kitauji, @N0zoM1z0, @Nelglor, @ranmori, @rudycelekli, @shoyann, @xu91102.
|
||||
- **Browser observations:** preserve effective source-map URLs, empty query/fragment identity, response-body provenance, service-worker policies and explicit uncertainty for malformed events. Bound discovery lifetimes and retain cleanup ownership. Interrupt/retry/failure cases validated with controlled CDP/session fixtures do not establish those paths in real Chromium. ([#1742](https://github.com/morluto/rea/pull/1742), [issue morluto/rea#1729](https://github.com/morluto/rea/issues/1729), [#1829](https://github.com/morluto/rea/pull/1829), [#2074](https://github.com/morluto/rea/pull/2074), [#2099](https://github.com/morluto/rea/pull/2099), [#2239](https://github.com/morluto/rea/pull/2239), [issue morluto/rea#2169](https://github.com/morluto/rea/issues/2169), [#2477](https://github.com/morluto/rea/pull/2477), [486b567](https://github.com/morluto/rea/commit/486b56724c4eaba50719177bcb9eae84d6907297), [a82e37c](https://github.com/morluto/rea/commit/a82e37c8065cc200a0ca3e14e7c4f85e4e468580), [a8d4984](https://github.com/morluto/rea/commit/a8d4984404b611e60b88bc42289c0ba07eee4578), [966f7cc](https://github.com/morluto/rea/commit/966f7cc84b4a8bb0043641cb64d7a7aaf8715e05), [221c856](https://github.com/morluto/rea/commit/221c85648108e9846d9920b0964dafa7f108d581), [951b095](https://github.com/morluto/rea/commit/951b095e11adf9faa9c4a27e212260060aa6b095), [f09bb8a](https://github.com/morluto/rea/commit/f09bb8a99773f79c7306b8799c86f856fe2c0d4b), [6bd2b28](https://github.com/morluto/rea/commit/6bd2b28161411f70ed5dc72e42c2736a1aab758b)). Thanks @1052326311, @chAng-L19, @chqcjq, @coygeek, @evenafterallisaid, @fisforfaheem, @itsF4LCON, @N0zoM1z0, @shayanxcyber.
|
||||
- **Managed metadata/CIL:** preserve ExportedType and pointer-table ownership, generic/array/function-pointer/modifier signatures, ReadyToRun host identity, metadata ExtraData and signed zero. Charge retained facts and decoded instruction counts instead of broad decoding bounds; malformed layouts remain caller input errors. ([#1743](https://github.com/morluto/rea/pull/1743), [issue morluto/rea#1736](https://github.com/morluto/rea/issues/1736), [#1768](https://github.com/morluto/rea/pull/1768), [#1822](https://github.com/morluto/rea/pull/1822), [issue morluto/rea#1801](https://github.com/morluto/rea/issues/1801), [#1828](https://github.com/morluto/rea/pull/1828), [issue morluto/rea#1812](https://github.com/morluto/rea/issues/1812), [#1835](https://github.com/morluto/rea/pull/1835), [issue morluto/rea#1832](https://github.com/morluto/rea/issues/1832), [#1886](https://github.com/morluto/rea/pull/1886), [#1893](https://github.com/morluto/rea/pull/1893), [#1897](https://github.com/morluto/rea/pull/1897), [issue morluto/rea#1878](https://github.com/morluto/rea/issues/1878), [#1943](https://github.com/morluto/rea/pull/1943), [issue morluto/rea#1940](https://github.com/morluto/rea/issues/1940), [#1985](https://github.com/morluto/rea/pull/1985), [#2194](https://github.com/morluto/rea/pull/2194), [issue morluto/rea#2191](https://github.com/morluto/rea/issues/2191), [#2437](https://github.com/morluto/rea/pull/2437), [52e76c5](https://github.com/morluto/rea/commit/52e76c5d39b58d1685bb5e22bd40d19524dfa2cb)). Thanks @ac1982, @chqcjq, @fisHarly0, @flitzrrr, @JasonBuildAI, @JayOfTheKeyboard, @jhuang-tw, @kaoru0822-kitauji, @N0zoM1z0, @Nelglor.
|
||||
- **Hopper failure ownership:** preserve startup/process outcomes, retain cleanup leases across one-shot failures, wait for late bridges, and stop owned Linux demo children when their REA owner exits. Denied/crashed/late-bridge/queued-request failure paths use simulated process or bridge fixtures; they are not fresh installed-Hopper verification. ([#1763](https://github.com/morluto/rea/pull/1763), [issue morluto/rea#1751](https://github.com/morluto/rea/issues/1751), [#1817](https://github.com/morluto/rea/pull/1817), [#1866](https://github.com/morluto/rea/pull/1866), [#1927](https://github.com/morluto/rea/pull/1927), [#2474](https://github.com/morluto/rea/pull/2474), [#2499](https://github.com/morluto/rea/pull/2499), [issue morluto/rea#2459](https://github.com/morluto/rea/issues/2459)). Thanks @coygeek, @Joshwong1908, @kaoru0822-kitauji, @N0zoM1z0, @rudycelekli.
|
||||
- **IDA session boundaries:** retain non-x86 call coverage and unresolved-procedure uncertainty, and bound private universal-Mach-O inputs to the exact selected slice. Transient headless busy recovery was exercised on macOS arm64 IDA Pro 9.1; Linux headless, attached GUI navigation and worker-reaped cleanup paths retain their documented verification gaps. ([#1764](https://github.com/morluto/rea/pull/1764), [issue morluto/rea#1712](https://github.com/morluto/rea/issues/1712), [#1778](https://github.com/morluto/rea/pull/1778), [issue morluto/rea#1766](https://github.com/morluto/rea/issues/1766), [#1785](https://github.com/morluto/rea/pull/1785), [issue morluto/rea#1769](https://github.com/morluto/rea/issues/1769), [#1906](https://github.com/morluto/rea/pull/1906), [issue morluto/rea#1794](https://github.com/morluto/rea/issues/1794), [#2201](https://github.com/morluto/rea/pull/2201), [issue morluto/rea#2139](https://github.com/morluto/rea/issues/2139), [#2291](https://github.com/morluto/rea/pull/2291), [issue morluto/rea#2285](https://github.com/morluto/rea/issues/2285), [#2324](https://github.com/morluto/rea/pull/2324), [12fa8fd](https://github.com/morluto/rea/commit/12fa8fd9f986dbd4ff58165c3ec71a46dc8a8a3b)). Thanks @akushonkamen, @coygeek, @kaoru0822-kitauji, @N0zoM1z0, @nikolay-dementiev, @zecaro.
|
||||
- **Input and capability diagnostics:** identify invalid paths, format refusals, missing host tools and non-retryable errors without losing caller selections or retryable capability facts. Foreign execution/device mutation annotations remain destructive, analysis-target text is untrusted, and output ordering follows code points rather than host locale. ([#1770](https://github.com/morluto/rea/pull/1770), [#1867](https://github.com/morluto/rea/pull/1867), [#1869](https://github.com/morluto/rea/pull/1869), [issue morluto/rea#1855](https://github.com/morluto/rea/issues/1855), [#1872](https://github.com/morluto/rea/pull/1872), [#1879](https://github.com/morluto/rea/pull/1879), [issue morluto/rea#1853](https://github.com/morluto/rea/issues/1853), [#1899](https://github.com/morluto/rea/pull/1899), [#1902](https://github.com/morluto/rea/pull/1902), [#1904](https://github.com/morluto/rea/pull/1904), [#1908](https://github.com/morluto/rea/pull/1908), [#1925](https://github.com/morluto/rea/pull/1925), [#1987](https://github.com/morluto/rea/pull/1987), [issue morluto/rea#1979](https://github.com/morluto/rea/issues/1979), [#2004](https://github.com/morluto/rea/pull/2004), [issue morluto/rea#2002](https://github.com/morluto/rea/issues/2002), [#2026](https://github.com/morluto/rea/pull/2026), [#2091](https://github.com/morluto/rea/pull/2091), [#2133](https://github.com/morluto/rea/pull/2133), [issue morluto/rea#2129](https://github.com/morluto/rea/issues/2129), [#2145](https://github.com/morluto/rea/pull/2145), [issue morluto/rea#2143](https://github.com/morluto/rea/issues/2143), [#2472](https://github.com/morluto/rea/pull/2472), [#2478](https://github.com/morluto/rea/pull/2478), [issue morluto/rea#2454](https://github.com/morluto/rea/issues/2454), [#2494](https://github.com/morluto/rea/pull/2494), [#2507](https://github.com/morluto/rea/pull/2507), [#2509](https://github.com/morluto/rea/pull/2509), [#2708](https://github.com/morluto/rea/pull/2708), [3d83135](https://github.com/morluto/rea/commit/3d8313598785359c424d7305f503cb69fc8639a6), [c9fed50](https://github.com/morluto/rea/commit/c9fed50691ae014890e91a72534773d2fb58ce44), [8b18171](https://github.com/morluto/rea/commit/8b181716aca2cb467be499cba226abfb5ae19141), [7a2cd1e](https://github.com/morluto/rea/commit/7a2cd1ea0a63e8c44166860b7ee9c7a1aa43492b)). Thanks @astralyx0129, @CassieFox23, @DeryFerd, @dollarsmoney, @fisforfaheem, @flitzrrr, @Ha1baraA11, @jimpablo, @kaoru0822-kitauji, @larinyo, @N0zoM1z0, @rudycelekli, @SII-YDD, @viniciusdsandrade, @XiaoBinGan.
|
||||
- **Android analysis:** retain decoded values, duplicate-class inventories and executable static initializers, reject malformed JADX UTF-8, and keep inline search provenance. Apktool accepts `--no-strings` and explains host requirements. The Windows batch-launcher change covers explicitly configured `.bat`/`.cmd` paths only; PATH-resolved batch launchers remain an open follow-up. Recorded Apktool coverage is Linux/OpenJDK, and changed JADX/batch failure paths do not establish native Windows behavior. ([#1777](https://github.com/morluto/rea/pull/1777), [issue morluto/rea#1774](https://github.com/morluto/rea/issues/1774), [#1876](https://github.com/morluto/rea/pull/1876), [issue morluto/rea#1846](https://github.com/morluto/rea/issues/1846), [#1890](https://github.com/morluto/rea/pull/1890), [issue morluto/rea#1850](https://github.com/morluto/rea/issues/1850), [#1946](https://github.com/morluto/rea/pull/1946), [issue morluto/rea#1945](https://github.com/morluto/rea/issues/1945), [#2707](https://github.com/morluto/rea/pull/2707), [issue morluto/rea#2672](https://github.com/morluto/rea/issues/2672), [0677468](https://github.com/morluto/rea/commit/0677468877465c8dfbe25c72f142a7b5bc8a3212), [fe8d0bd](https://github.com/morluto/rea/commit/fe8d0bdd36fe12bc0836468d1a702a34fbdc219b)). Thanks @akushonkamen, @coygeek, @HeadmasterEggy, @kaoru0822-kitauji, @N0zoM1z0, @psipher.
|
||||
- **ADB acquisition:** preserve device paths and directory identity, prevent host destination collisions/overwrites, count UTF-8 logcat bytes and restricted permissions, and evaluate the complete Activity Manager start report. Launch attempts, status, activity and raw failure output remain observations; real-device timeout/mixed-report and Android 8 directory-read paths remain unverified beyond the documented fixtures. ([#1962](https://github.com/morluto/rea/pull/1962), [#2212](https://github.com/morluto/rea/pull/2212), [issue morluto/rea#2175](https://github.com/morluto/rea/issues/2175), [#2221](https://github.com/morluto/rea/pull/2221), [issue morluto/rea#2220](https://github.com/morluto/rea/issues/2220), [#2359](https://github.com/morluto/rea/pull/2359), [c42232f](https://github.com/morluto/rea/commit/c42232f566d55fe7191763649755148c768eef9f), [55b6148](https://github.com/morluto/rea/commit/55b61485e9f7568b69597eef8696ff6abfe53cfd), [3bdc873](https://github.com/morluto/rea/commit/3bdc8730bd98e073522bcb8ba86527be402c0cbe), [c89972c](https://github.com/morluto/rea/commit/c89972c8777d0114566fe76a060849a18bdd2659), [e1cd292](https://github.com/morluto/rea/commit/e1cd292aa6e8f5f13441d408c66126782c8dbb0d)). Thanks @coygeek, @dakjdakd, @DeryFerd, @huiq777, @sx4im.
|
||||
- **WASM names and diagnostics:** preserve debug/custom-section names including NUL and leading BOM, accept unpadded WABT section rows, and avoid irrelevant tool-remediation advice on unrelated runtime-root failures. ([#1963](https://github.com/morluto/rea/pull/1963), [issue morluto/rea#1956](https://github.com/morluto/rea/issues/1956), [#1967](https://github.com/morluto/rea/pull/1967), [#1981](https://github.com/morluto/rea/pull/1981), [#2084](https://github.com/morluto/rea/pull/2084), [#2102](https://github.com/morluto/rea/pull/2102)). Thanks @1052326311, @bugstan, @N0zoM1z0, @sathariels, @STiFLeR7, @viniciusdsandrade.
|
||||
|
||||
- **Source-map and path handling:** retain source-map entries when embedded-content counts differ or an optional `sourceMappingURL` is used, and keep `file:` URLs out of operator-relative path resolution. ([#2873](https://github.com/morluto/rea/pull/2873), [#2886](https://github.com/morluto/rea/pull/2886), [#2882](https://github.com/morluto/rea/pull/2882), [issue morluto/rea#2821](https://github.com/morluto/rea/issues/2821), [issue morluto/rea#2883](https://github.com/morluto/rea/issues/2883), [issue morluto/rea#2577](https://github.com/morluto/rea/issues/2577)). Thanks @JFSAS, @rileygramlich, @shoyann, @shubhamahuja9999, @coygeek.
|
||||
- **Native metadata:** accept declared facts in native dispatch inventories and share native verification input diagnostics. ([#2906](https://github.com/morluto/rea/pull/2906), [#2901](https://github.com/morluto/rea/pull/2901), [issue morluto/rea#2564](https://github.com/morluto/rea/issues/2564), [issue morluto/rea#2621](https://github.com/morluto/rea/issues/2621)). Thanks @ShitK, @coygeek.
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
- **Runtime attachment state:** `attached` may be `null` in JavaScript, browser and Electron discovery/inspection results. Treat null as unknown; do not coerce it to false. ([e55ef72](https://github.com/morluto/rea/commit/e55ef7213515ba204c3d88a4487b4ee6a2773b06)).
|
||||
- **Source maps:** original-source and mapping rows preserve a null source name when unavailable. Handle null explicitly instead of expecting an invented unknown-source string; new index fields remain optional when reading older Evidence. ([ee50ed1](https://github.com/morluto/rea/commit/ee50ed121e38cc6e1a2ff0dc8b789f15f71c5402)).
|
||||
- **Streamed filtered JSON:** `--filter-output` now emits JSON `null` when the selected value is null; consumers must distinguish a present null from a missing selection. Use `--full-output` when an object envelope is required. ([#2333](https://github.com/morluto/rea/pull/2333)). Thanks @Jackmeson1.
|
||||
- **Call graphs:** `get_call_graph.normalized_result` is now `{ graph, limitations }`; read traversal-depth groups under `graph` and retain relationship-specific caveats. Contract/fixture validation does not establish new Ghidra or IDA engine coverage. ([5dfb9d0](https://github.com/morluto/rea/commit/5dfb9d050c2cc44a61b183dae765bef4d22a53b7)).
|
||||
- **MCP discovery:** `binary_session` no longer repeats `server_identity.catalog.tools`. Use the connected server tool list, grouped `tool_availability` and provider capabilities; see [MCP contracts](docs/mcp-contracts.md). Origin: [6395e8d](https://github.com/morluto/rea/commit/6395e8d3007d5ee453477460e7ee1c51cff76be0). ([#1988](https://github.com/morluto/rea/pull/1988)). Thanks @kaoru0822-kitauji, @N0zoM1z0.
|
||||
|
||||
Full contribution record: [633 commits and 427 merged PRs](https://github.com/morluto/rea/blob/rea-agents-6.5.0/docs/releases/6.5.0.contributions.json).
|
||||
|
||||
|
||||
## [6.4.0](https://github.com/morluto/rea/compare/rea-agents-6.3.0...rea-agents-6.4.0) (2026-10-11)
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -28,7 +28,6 @@ import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Iterator;
|
||||
@@ -142,7 +141,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
"set_addresses_names",
|
||||
"inspect_native_load_image",
|
||||
"read_bytes",
|
||||
"search_bytes",
|
||||
"address_to_file_offset",
|
||||
"ping",
|
||||
"shutdown",
|
||||
@@ -380,7 +378,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return switch (request.method) {
|
||||
case "inspect_native_load_image" -> inspectNativeLoadImage(request.params);
|
||||
case "read_bytes" -> readMemoryBytes(request.params);
|
||||
case "search_bytes" -> searchMemoryBytes(request.params, descriptor);
|
||||
case "address_to_file_offset" -> addressToFileOffset(request.params);
|
||||
case "address_name" -> addressName(request.params);
|
||||
case "list_documents" -> listDocuments(request.params);
|
||||
@@ -687,160 +684,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonObject searchMemoryBytes(JsonObject params, SessionDescriptor descriptor) throws Exception {
|
||||
// mask, start, end, and cursor are omitted when absent; JSON encoding drops those keys.
|
||||
Set<String> keys = params.keySet();
|
||||
if (!keys.containsAll(Set.of("document", "pattern", "limit")) ||
|
||||
!Set.of("document", "pattern", "mask", "start", "end", "limit", "cursor").containsAll(keys))
|
||||
throw new RequestFailure("invalid_request", "Bridge request fields are invalid");
|
||||
requireDocument(params);
|
||||
// Public input validation owns the grammar; validate again at the socket boundary.
|
||||
String pattern = requireString(params, "pattern").replaceAll("[\\s\\p{Z}\\uFEFF]", "");
|
||||
String mask = params.has("mask") ? requireString(params, "mask") : null;
|
||||
if (mask != null) mask = mask.replaceAll("[\\s\\p{Z}\\uFEFF]", "");
|
||||
if (pattern.isEmpty() || pattern.length() % 2 != 0 ||
|
||||
(mask != null && mask.length() != pattern.length()))
|
||||
throw new RequestFailure("invalid_request", "Mask length must equal a nonempty byte pattern length");
|
||||
byte[] bytes = new byte[pattern.length() / 2];
|
||||
byte[] masks = new byte[bytes.length];
|
||||
boolean constrained = false;
|
||||
for (int index = 0; index < bytes.length; index++) {
|
||||
monitor.checkCancelled();
|
||||
String pair = pattern.substring(index * 2, index * 2 + 2);
|
||||
if ((!pair.equals("??") && !isHexadecimal(pair, 0, 2)) ||
|
||||
(mask != null && !isHexadecimal(mask, index * 2, index * 2 + 2)))
|
||||
throw new RequestFailure("invalid_request", "Use hexadecimal byte pairs or ?? wildcards");
|
||||
int selected = mask == null ? 255 : Integer.parseInt(mask.substring(index * 2, index * 2 + 2), 16);
|
||||
bytes[index] = (byte) (pair.equals("??") ? 0 : Integer.parseInt(pair, 16));
|
||||
masks[index] = (byte) (pair.equals("??") ? 0 : selected);
|
||||
constrained |= masks[index] != 0;
|
||||
}
|
||||
if (!constrained) throw new RequestFailure("invalid_request", "Pattern must constrain at least one bit");
|
||||
int limit = requireBoundedInteger(params, "limit", 1, 1000);
|
||||
Address start = params.has("start") ? requireAddress(params, "start") : null;
|
||||
Address end = params.has("end") ? requireAddress(params, "end") : null;
|
||||
if (start != null && end != null &&
|
||||
(!start.getAddressSpace().equals(end.getAddressSpace()) || start.compareTo(end) > 0))
|
||||
throw new RequestFailure("invalid_request", "Search bounds must be ordered in one address space");
|
||||
JsonArray identity = new JsonArray();
|
||||
for (String value : new String[] { descriptor.targetSha256, descriptor.profileDigest,
|
||||
bytesHex(bytes), bytesHex(masks), start == null ? "" : canonicalAddress(start),
|
||||
end == null ? "" : canonicalAddress(end), optionalString(params, "document") }) identity.add(value);
|
||||
String binding = bytesHex(MessageDigest.getInstance("SHA-256").digest(identity.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
String cursor = params.has("cursor") ? requireString(params, "cursor") : null;
|
||||
Address resume = null;
|
||||
if (cursor != null) {
|
||||
if (!cursor.startsWith(binding + ":"))
|
||||
throw new RequestFailure("invalid_request", "Byte search cursor does not match the target and query");
|
||||
resume = parseReaAddress(cursor.substring(binding.length() + 1));
|
||||
MemoryBlock resumedBlock = currentProgram.getMemory().getBlock(resume);
|
||||
Address bound = start == null ? end : start;
|
||||
if (resumedBlock == null || !resumedBlock.isInitialized() ||
|
||||
(bound != null && !bound.getAddressSpace().equals(resume.getAddressSpace())) ||
|
||||
(start != null && resume.compareTo(start) < 0) ||
|
||||
(end != null && resume.compareTo(end) > 0))
|
||||
throw new RequestFailure("invalid_request", "Byte search cursor lies outside initialized query memory");
|
||||
}
|
||||
List<MemoryBlock> ordered = new ArrayList<>(List.of(currentProgram.getMemory().getBlocks()));
|
||||
ordered.sort(Comparator.comparing(MemoryBlock::getStart));
|
||||
JsonArray matches = new JsonArray();
|
||||
JsonArray blocks = new JsonArray();
|
||||
JsonArray skipped = new JsonArray();
|
||||
JsonArray notLoaded = new JsonArray();
|
||||
long covered = 0;
|
||||
String next = null;
|
||||
for (MemoryBlock block : ordered) {
|
||||
monitor.checkCancelled();
|
||||
Address low = block.getStart();
|
||||
Address high = block.getEnd();
|
||||
Address bound = start == null ? end : start;
|
||||
if (bound != null && !bound.getAddressSpace().equals(low.getAddressSpace())) continue;
|
||||
// Without bounds, search loaded memory only. Non-loaded file parts
|
||||
// (such as .symtab) and overlays stay reachable through explicit bounds.
|
||||
if (bound == null && (!block.isLoaded() || block.isOverlay())) {
|
||||
notLoaded.add(byteSearchCoverage(block, low, high));
|
||||
continue;
|
||||
}
|
||||
if (start != null && low.compareTo(start) < 0) low = start;
|
||||
if (end != null && high.compareTo(end) > 0) high = end;
|
||||
if (low.compareTo(high) > 0) continue;
|
||||
if (!block.isInitialized()) {
|
||||
skipped.add(byteSearchCoverage(block, low, high));
|
||||
continue;
|
||||
}
|
||||
if (next != null) continue;
|
||||
if (resume != null && low.compareTo(resume) < 0) low = resume;
|
||||
if (low.compareTo(high) > 0 || !low.getAddressSpace().equals(high.getAddressSpace())) continue;
|
||||
Address at = low;
|
||||
Address examinedEnd = high;
|
||||
while (at != null && at.compareTo(high) <= 0 && high.subtract(at) >= bytes.length - 1L) {
|
||||
monitor.checkCancelled();
|
||||
// Ghidra's end address bounds candidate starts, not the final matched byte.
|
||||
Address found = currentProgram.getMemory().findBytes(at, high.subtract(bytes.length - 1L), bytes, masks, true, monitor);
|
||||
monitor.checkCancelled();
|
||||
if (found == null || high.subtract(found) < bytes.length - 1L) break;
|
||||
if (matches.size() == limit) {
|
||||
next = binding + ":" + canonicalAddress(found);
|
||||
examinedEnd = found.addNoWrap(bytes.length - 1L);
|
||||
break;
|
||||
}
|
||||
JsonObject match = new JsonObject();
|
||||
match.addProperty("address", canonicalAddress(found));
|
||||
match.addProperty("segment", block.getName());
|
||||
Long offset = targetFileOffset(block, found).offset();
|
||||
match.add("file_offset", offset == null ? JsonNull.INSTANCE : GSON.toJsonTree(offset));
|
||||
Function function = currentProgram.getFunctionManager().getFunctionContaining(found);
|
||||
JsonObject procedure = new JsonObject();
|
||||
if (function != null) {
|
||||
procedure.addProperty("address", canonicalAddress(function.getEntryPoint()));
|
||||
procedure.addProperty("name", function.getName(true));
|
||||
}
|
||||
match.add("procedure", function == null ? JsonNull.INSTANCE : procedure);
|
||||
matches.add(match);
|
||||
at = found.next();
|
||||
}
|
||||
JsonObject coverage = byteSearchCoverage(block, low, examinedEnd);
|
||||
covered = Math.addExact(covered, coverage.get("bytes").getAsLong());
|
||||
blocks.add(coverage);
|
||||
}
|
||||
JsonObject searched = new JsonObject();
|
||||
searched.add("blocks", blocks);
|
||||
searched.addProperty("bytes", covered);
|
||||
searched.add("skipped_uninitialized", skipped);
|
||||
searched.add("skipped_not_loaded", notLoaded);
|
||||
JsonObject result = new JsonObject();
|
||||
result.add("matches", matches);
|
||||
result.add("searched", searched);
|
||||
result.addProperty("complete", next == null);
|
||||
result.add("next_cursor", next == null ? JsonNull.INSTANCE : GSON.toJsonTree(next));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonObject byteSearchCoverage(MemoryBlock block, Address low, Address high) {
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("segment", block.getName());
|
||||
result.addProperty("start", canonicalAddress(low));
|
||||
result.addProperty("end", canonicalAddress(high));
|
||||
result.addProperty("bytes", high.subtract(low) + 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
private record TargetFileOffset(Long offset, int mappings) {}
|
||||
|
||||
private TargetFileOffset targetFileOffset(MemoryBlock block, Address address) throws Exception {
|
||||
Long offset = null;
|
||||
int mappings = 0;
|
||||
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
|
||||
if (!source.contains(address) || source.getFileBytes().isEmpty() ||
|
||||
!isTargetSource(source.getFileBytes().get())) continue;
|
||||
long candidate = source.getFileBytesOffset(address);
|
||||
if (candidate < 0) continue;
|
||||
offset = candidate;
|
||||
mappings++;
|
||||
}
|
||||
return new TargetFileOffset(mappings == 1 && offset <= 9007199254740991L ? offset : null, mappings);
|
||||
}
|
||||
|
||||
private JsonObject addressToFileOffset(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of("document", "address"));
|
||||
requireDocument(params);
|
||||
@@ -848,12 +691,18 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
MemoryBlock block = currentProgram.getMemory().getBlock(address);
|
||||
if (block == null || !block.isInitialized())
|
||||
throw new RequestFailure("invalid_request", "Address has no initialized file-backed memory: " + canonicalAddress(address));
|
||||
TargetFileOffset mapping = targetFileOffset(block, address);
|
||||
if (mapping.mappings() != 1)
|
||||
throw new RequestFailure("invalid_request", "Address has " + mapping.mappings() + " authoritative target-file mappings: " + canonicalAddress(address));
|
||||
Long fileOffset = mapping.offset();
|
||||
if (fileOffset == null)
|
||||
throw new RequestFailure("invalid_request", "Address lacks a safe file offset: " + canonicalAddress(address));
|
||||
Long fileOffset = null;
|
||||
int mappings = 0;
|
||||
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
|
||||
if (!source.contains(address) || source.getFileBytes().isEmpty() ||
|
||||
!isTargetSource(source.getFileBytes().get())) continue;
|
||||
long offset = source.getFileBytesOffset(address);
|
||||
if (offset < 0) continue;
|
||||
fileOffset = offset;
|
||||
mappings++;
|
||||
}
|
||||
if (mappings != 1)
|
||||
throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative target-file mappings: " + canonicalAddress(address));
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("address", canonicalAddress(address));
|
||||
result.addProperty("file_offset", fileOffset);
|
||||
|
||||
@@ -11,12 +11,6 @@ import sys
|
||||
|
||||
PROFILE = "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2"
|
||||
|
||||
# Linux ET_CORE machine identifiers whose PRSTATUS layout is interpreted with
|
||||
# unchanged pwntools 4.15.0 ctypes. Both share pr_cursig@12, pr_pid@32 and
|
||||
# pr_reg@112; only the register window differs: amd64 is 27 x 8 bytes for a
|
||||
# 336-byte descriptor, AArch64 is 34 x 8 bytes for a 392-byte descriptor.
|
||||
CORE_MACHINES = (62, 183)
|
||||
|
||||
|
||||
class CoreFailure(Exception):
|
||||
def __init__(self, reason, message):
|
||||
@@ -57,27 +51,12 @@ def inspect_core(path, cache):
|
||||
# Other declared OSABIs must never select Linux note ctypes.
|
||||
if content[7] not in (0, 3) or content[8] != 0:
|
||||
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires System V or GNU/Linux OSABI with ABI version zero; selected OSABI=" + str(content[7]) + ", ABI version=" + str(content[8]) + ".")
|
||||
machine = int.from_bytes(content[18:20], "little")
|
||||
if int.from_bytes(content[16:18], "little") != 4 or machine not in CORE_MACHINES:
|
||||
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires ET_CORE for x86-64 or AArch64; selected ET_TYPE=" + str(int.from_bytes(content[16:18], "little")) + ", e_machine=" + str(machine) + ".")
|
||||
if int.from_bytes(content[16:18], "little") != 4 or int.from_bytes(content[18:20], "little") != 62:
|
||||
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires x86-64 ET_CORE.")
|
||||
from elftools.common.exceptions import ELFError
|
||||
from elftools.common.utils import struct_parse
|
||||
from elftools.elf.elffile import ELFFile
|
||||
from pwnlib.elf.datatypes import (
|
||||
elf_prstatus_aarch64,
|
||||
elf_prstatus_amd64,
|
||||
elf_siginfo_64,
|
||||
user_regs_struct_aarch64,
|
||||
user_regs_struct_amd64,
|
||||
)
|
||||
if machine == 62:
|
||||
profile_label = "x86-64"
|
||||
architecture = "x86_64-little-endian"
|
||||
prstatus_type, registers_type = elf_prstatus_amd64, user_regs_struct_amd64
|
||||
else:
|
||||
profile_label = "AArch64"
|
||||
architecture = "aarch64-little-endian"
|
||||
prstatus_type, registers_type = elf_prstatus_aarch64, user_regs_struct_aarch64
|
||||
from pwnlib.elf.datatypes import elf_prstatus_amd64, elf_siginfo_64, user_regs_struct_amd64
|
||||
try:
|
||||
image = ELFFile(io.BytesIO(content))
|
||||
header = image.header
|
||||
@@ -128,17 +107,17 @@ def inspect_core(path, cache):
|
||||
if owner != b"CORE\0":
|
||||
continue
|
||||
if note.n_type == "NT_PRSTATUS":
|
||||
if len(descriptor) < ctypes.sizeof(prstatus_type):
|
||||
raise CoreFailure("format", "Linux " + profile_label + " NT_PRSTATUS descriptor is truncated.")
|
||||
status = prstatus_type.from_buffer_copy(descriptor)
|
||||
if len(descriptor) < ctypes.sizeof(elf_prstatus_amd64):
|
||||
raise CoreFailure("format", "Linux amd64 NT_PRSTATUS descriptor is truncated.")
|
||||
status = elf_prstatus_amd64.from_buffer_copy(descriptor)
|
||||
registers = []
|
||||
for name, kind in registers_type._fields_:
|
||||
relative = prstatus_type.pr_reg.offset + getattr(registers_type, name).offset
|
||||
for name, kind in user_regs_struct_amd64._fields_:
|
||||
relative = elf_prstatus_amd64.pr_reg.offset + getattr(user_regs_struct_amd64, name).offset
|
||||
registers.append({"name": name, "value": hex(getattr(status.pr_reg, name)), "location": location(descriptor_start + relative, ctypes.sizeof(kind), end)})
|
||||
threads.append({"note_index": note_index, "historical_pid": ctypes.c_int32(status.pr_pid).value, "recorded_current_signal": status.pr_cursig, "registers": registers})
|
||||
elif note.n_type == "NT_SIGINFO":
|
||||
if len(descriptor) < ctypes.sizeof(elf_siginfo_64):
|
||||
raise CoreFailure("format", "Linux " + profile_label + " NT_SIGINFO descriptor is truncated.")
|
||||
raise CoreFailure("format", "Linux amd64 NT_SIGINFO descriptor is truncated.")
|
||||
signal = elf_siginfo_64.from_buffer_copy(descriptor)
|
||||
number = ctypes.c_int32(signal.si_signo).value
|
||||
code = ctypes.c_int32(signal.si_code).value
|
||||
@@ -146,7 +125,7 @@ def inspect_core(path, cache):
|
||||
signals.append({"note_index": note_index, "number": number, "code": code, "errno": ctypes.c_int32(signal.si_errno).value,
|
||||
"fault_address": hex(signal.sigfault_addr) if known else None,
|
||||
"fault_address_meaning": "recorded-sigsegv-address" if known else "unknown", "thread_association": "unknown"})
|
||||
return {"format": "elf-core", "architecture": architecture, "target_execution": "not-performed", "live_process_identity": "unknown",
|
||||
return {"format": "elf-core", "architecture": "x86_64-little-endian", "target_execution": "not-performed", "live_process_identity": "unknown",
|
||||
"segments": segments, "notes": notes, "note_padding": padding, "threads": threads, "signals": signals,
|
||||
"note_interpretation_completeness": "unknown",
|
||||
"limitations": ["The initial Linux note profile accepts System V/unspecified or GNU/Linux OSABI with ABI version zero; an unspecified OSABI alone does not prove operating-system origin.",
|
||||
|
||||
@@ -107,47 +107,6 @@ first navigation. For a dedicated investigation profile, add
|
||||
then describe an unloaded page; REA continues to require an allowed, committed
|
||||
URL. See [the startup investigation](https://github.com/morluto/rea/issues/1384#issuecomment-6093781686).
|
||||
|
||||
### Browsers without HTTP discovery
|
||||
|
||||
Some browsers expose a loopback browser WebSocket but no `/json/version` or
|
||||
`/json/list`. Chrome 144 and later can enable remote debugging on a running
|
||||
browser from `chrome://inspect/#remote-debugging`; this mode is reported to
|
||||
serve only the browser socket. Since Chrome 136, `--remote-debugging-port` is
|
||||
ignored for the default profile, so this is the remaining way to inspect a page
|
||||
in an existing profile.
|
||||
|
||||
For such a browser, pass the browser socket instead of the HTTP endpoint:
|
||||
|
||||
```bash
|
||||
rea list-browser-targets ws://127.0.0.1:9222/devtools/browser/<id>
|
||||
```
|
||||
|
||||
Chrome writes the port and the `/devtools/browser/<id>` path to the
|
||||
`DevToolsActivePort` file in its user data directory. REA does not read that
|
||||
file for you.
|
||||
|
||||
- Only `list_browser_targets`, `inspect_web_page`, `analyze_web_bundle`,
|
||||
`observe_web_session`, `discover_webmcp_tools`, and `capture_web_screenshot`
|
||||
accept a browser socket. Website runtime attribution, Electron inspection,
|
||||
and scenario attach mode still require HTTP discovery.
|
||||
- Each call opens one WebSocket and uses it for discovery
|
||||
(`Browser.getVersion`, `Target.getTargets`) and for the selected page.
|
||||
Discovery must finish within the same 10-second deadline as HTTP discovery.
|
||||
Chrome may ask you to approve each connection.
|
||||
- The browser identifier controls access to the socket. Evidence records the
|
||||
endpoint as `ws://127.0.0.1:9222/devtools/browser/[REDACTED]`.
|
||||
- `browser.revision` is the socket's `Browser.getVersion` value, such as
|
||||
`@e533e98…`. HTTP discovery reports `WebKit-Version`, such as
|
||||
`537.36 (@e533e98…)`.
|
||||
- `Target.getTargets` reports whether each target is attached. Chrome's
|
||||
`/json/list` can omit it, so HTTP discovery may report `attached` as `null`.
|
||||
- An existing profile can hold many unrelated pages. Target listing returns
|
||||
each eligible page's URL and title, so pass `--allowed-origins` to list only
|
||||
the origins you are investigating.
|
||||
|
||||
A browser driven only through `--remote-debugging-pipe` or a vendor's private
|
||||
automation channel has neither endpoint, and REA cannot attach to it.
|
||||
|
||||
## Request boundary
|
||||
|
||||
CDP connections enforce the shared 64 MiB limit per WebSocket message. A larger
|
||||
@@ -157,13 +116,9 @@ decoded strings or JSON objects.
|
||||
|
||||
Discovery supplies a literal loopback CDP endpoint and returns all eligible
|
||||
page targets unless an exact HTTP(S) origin filter narrows the result. Follow-up
|
||||
inspection requests select one target; their default scope is its current origin.
|
||||
The endpoint is either `http://127.0.0.1:PORT` or a
|
||||
[browser socket](#browsers-without-http-discovery),
|
||||
`ws://127.0.0.1:PORT/devtools/browser/ID`; `[::1]` may replace `127.0.0.1`.
|
||||
`localhost`, private-LAN addresses, HTTPS and `wss:` endpoints, credentials,
|
||||
queries, fragments, implicit ports, and any other path are not valid CDP
|
||||
endpoints. Origin filters contain only scheme,
|
||||
inspection requests select one target; their default scope is its current origin. `localhost`, private-LAN
|
||||
addresses, HTTPS CDP endpoints, credentials, paths, queries, fragments, and
|
||||
implicit ports are not valid CDP endpoints. Origin filters contain only scheme,
|
||||
host, and port; paths, credentials, queries, fragments, and wildcards are not
|
||||
valid origins.
|
||||
|
||||
|
||||
-28
@@ -214,36 +214,8 @@ Import an older source tree to compare with the current artifact:
|
||||
|
||||
```bash
|
||||
rea import-reference-source /absolute/path/to/source
|
||||
rea import-reference-source /absolute/path/to/source --detail summary --outline-depth 2 --json
|
||||
```
|
||||
|
||||
The default (`--detail full`) returns the complete `HistoricalSourceGraph`.
|
||||
Choose `--detail summary` for a `HistoricalSourceSummary` of that same committed
|
||||
inventory: its `HistoricalSourceManifest` contains the full graph and root
|
||||
digests, with provenance, VCS facts, languages, manifest paths, entry and
|
||||
relationship counts, parse-failure counts, file/directory states, exclusions
|
||||
and limitations. The summary is a presentation view, not a graph accepted by
|
||||
source-to-bundle comparison; keep the full output for that workflow.
|
||||
|
||||
`--outline-depth` requires `--detail summary` and accepts a nonnegative safe
|
||||
integer. Depth 1 shows root children, depth 2 (the default) also shows their
|
||||
children, and depth 0 omits all outline entries. Paths retain canonical Unicode
|
||||
code-point order. `outline.omitted_entry_count` counts only inventoried entries
|
||||
hidden by the chosen depth; it does not count unexamined children of excluded or
|
||||
unreadable directories, or change `inventory_state`. Exclusions and source
|
||||
limitations remain separate from this presentation omission.
|
||||
|
||||
`file_sizes.known_bytes` is the exact sum of known inventoried file sizes as
|
||||
decimal text, so large sums cannot lose integer precision. Unknown sizes count
|
||||
in `file_sizes.unknown_file_count`; excluded or unexamined paths are not included
|
||||
in that total. A zero known-byte sum therefore does not establish an empty source
|
||||
tree. Full output retains individual file hashes, relationships and parse-failure
|
||||
diagnostics. Summary mode performs the same import and cleanup, then projects
|
||||
the committed result without rereading source files. It reduces the selected
|
||||
output, not the amount of source examined. A shallow tree or many exclusions can
|
||||
still produce a large summary; depth 0 removes the outline but preserves the
|
||||
other facts. No fixed output-size or runtime improvement is promised.
|
||||
|
||||
The import records hashes and metadata for the supplied files, separately
|
||||
from observations of the current app. For a directory inside a Git checkout,
|
||||
it records the HEAD of the nearest enclosing repository; whether the working
|
||||
|
||||
@@ -129,18 +129,6 @@ and Claude Code reports that autocompact is thrashing. To avoid this:
|
||||
The sizes above come from REA's own `tools/list`. A live Claude Code session
|
||||
against DeepSeek's API is unverified.
|
||||
|
||||
## Kimi Code
|
||||
|
||||
Setup registers `rea` in `~/.kimi-code/mcp.json`, or
|
||||
`$KIMI_CODE_HOME/mcp.json` when set, while preserving unrelated settings and
|
||||
creating a `.rea.backup` before updating an existing file. Kimi Code infers
|
||||
stdio from the `command` entry, so setup does not add a `type` field. The
|
||||
registration includes `startupTimeoutMs = 30000` and `toolTimeoutMs = 120000`.
|
||||
Kimi Code also discovers personal skills in `~/.agents/skills` and project-local
|
||||
`.agents/skills`, so no separate skill root is required; setup still installs
|
||||
and verifies the matching skill files through the normal shared-skill flow.
|
||||
Start a new Kimi Code session to pick up MCP and skill changes.
|
||||
|
||||
## Pi
|
||||
|
||||
For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md),
|
||||
|
||||
@@ -458,7 +458,7 @@ artifact path. Its `expectedFirstTool` is `null`, and
|
||||
`targetClarificationPassed` grades that limited routing outcome independently
|
||||
of analysis prose heuristics. Analyzing a nearby example cannot pass this case.
|
||||
|
||||
The packaged desktop, parser-comparison, and managed scenarios assess a closed set of
|
||||
The packaged desktop and parser-comparison scenarios now assess a closed set of
|
||||
known fixture claims. Each final answer must be a strict JSON manifest containing
|
||||
exactly the requested claim IDs, values, Evidence IDs, and Evidence authority and
|
||||
confidence. Golden values are not included in the prompt. The evaluator checks
|
||||
@@ -475,24 +475,15 @@ analyses and use the requested module and export selectors. Boundary tests packa
|
||||
the same source-owned desktop fixture and compare the same source-owned parser
|
||||
files through actual MCP tool results, then verify that fabricated answers fail.
|
||||
|
||||
The managed rubric checks the exact public top-level type set and the CLI entry
|
||||
point's declaring type and method name. The exact type-set claim requires an
|
||||
unfiltered member inventory: `selection` must be null and the Evidence parameters
|
||||
must omit `type_names`. A selected inventory can still ground the entry-point
|
||||
claim. Both inspections must describe the freshly built DLL's SHA-256. Entry-point
|
||||
resolution additionally requires complete artifact and member metadata, matching
|
||||
non-null module MVIDs, and a unique method at the header's metadata token. These are
|
||||
static metadata claims, not runtime behavior;
|
||||
compiler-generated member counts, token values, and MVIDs are not golden answers.
|
||||
|
||||
Per-scenario `factualCorrectness` is `passed`, `failed`, or `not_assessed` within
|
||||
the `configured_fixture_claims` scope. A pass establishes only the configured
|
||||
claims, not unrestricted factual correctness. Native, browser,
|
||||
claims, not unrestricted factual correctness. Native, managed, browser,
|
||||
navigation-context, and address-context scenarios have no factual rubric and
|
||||
remain `not_assessed`.
|
||||
remain `not_assessed`; the managed workflow requires both artifact and member
|
||||
inspection to answer its type and entry-point question.
|
||||
|
||||
The closed factual scenarios request complete producer Evidence on the initial
|
||||
inspection or analysis so their source selectors can authenticate all configured facts.
|
||||
analysis so their source selectors can authenticate all configured facts.
|
||||
Summary/page/item workflows are exercised separately by `javascript-module-view`;
|
||||
its heuristic gate does not establish full-producer factual correctness.
|
||||
|
||||
|
||||
@@ -17,14 +17,10 @@ submodule and is a separate, more capable workflow.
|
||||
The parser currently handles x64 PE32+ and RTR 9.1, including bounded
|
||||
DEHYDRATED_DATA processing, MethodTable relationships, virtual slots, and
|
||||
frozen strings. Other RTR versions and PE architectures are reported as
|
||||
unsupported or partial. The matching .NET 8.0.22 fixture is source-built.
|
||||
|
||||
On a Windows host, Windows Ghidra P0 runs the parser without a JAR. Metadata,
|
||||
MethodTable relationships, virtual slots and frozen strings come from the
|
||||
parser report, and `analyze_function` decompiles slot targets. Tools that read
|
||||
the Ghidra database do not see rehydrated bytes (see below), because only the
|
||||
optional adapter writes them into the database and Windows Ghidra P0 has no
|
||||
database mutation authority. A PE/CLI or ReadyToRun
|
||||
unsupported or partial. The matching .NET 8.0.22 fixture is source-built and
|
||||
the parser's Windows-host Ghidra workflow has not yet had a real Windows-host
|
||||
run. This does not establish Windows Ghidra import or decompiler behavior.
|
||||
Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun
|
||||
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
|
||||
|
||||
## Build and configure
|
||||
@@ -61,9 +57,7 @@ returns `observations.metadata_recovery` inline from the captured source snapsho
|
||||
including the RTR header, MethodTable addresses, frozen strings, derived overlay
|
||||
identity, and examined/recovered counts. Its enclosing `unsupported` load-image
|
||||
status means independent PE load-image verification was not performed; it does
|
||||
not mean the metadata parser failed or the executable failed to load. Without
|
||||
the optional adapter, each recovered type's `type` is `null`, because no Ghidra
|
||||
DataType exists; select it by `address`.
|
||||
not mean the metadata parser failed or the executable failed to load.
|
||||
|
||||
Pass a returned MethodTable address to `inspect_native_data_type`. A direct PE
|
||||
query also derives the snapshot metadata from the authenticated Ghidra session
|
||||
@@ -73,12 +67,8 @@ The database result remains `unavailable` when Ghidra has no DataType at that
|
||||
address; its `metadata_recovery.source` is `read-only-derived-overlay` and
|
||||
carries related type, interface, slot, and target observations. Generated
|
||||
address identities do not claim original managed names. The parser does not
|
||||
merge frozen literals into Ghidra's database string inventory: read them from
|
||||
`metadata_recovery.frozen_strings`, not `search_strings`. Slot targets come
|
||||
from the parser report; on Windows P0 without the adapter, a `read_bytes` of
|
||||
a slot in the rehydrated region returns no bytes. The Linux adapter route
|
||||
verifies a complete slot read and its target. Follow slot targets with `analyze_function` and ordinary
|
||||
cross-references.
|
||||
merge frozen literals into Ghidra's database string inventory. Follow slot
|
||||
targets with `analyze_function` and ordinary cross-references.
|
||||
|
||||
The built-in CLI path uses a MethodTable address returned by the parser or
|
||||
another observation:
|
||||
@@ -139,11 +129,3 @@ compiler/linker symbols and raw directory bytes provide the oracle. Stripped
|
||||
fixtures exercise heuristic detection. Tests do not execute the resulting
|
||||
NativeAOT binaries. Fixture generation and adapter compilation are optional and
|
||||
never prerequisites of ordinary native analysis or setup.
|
||||
|
||||
The real MCP lane runs only on a Linux x64 host with the adapter. The
|
||||
Windows-host behavior above was checked manually over MCP on Windows 11 x64
|
||||
(non-elevated, Ghidra 12.1.4, JDK 21, no JAR) with the source-built win-x64
|
||||
symbols fixture. The format, RTR version and frozen strings matched the fixture
|
||||
oracle; the derived MethodTable, its base type, its interface and a virtual
|
||||
slot reaching the overridden method matched the linker map; and pseudocode was
|
||||
readable. No CI lane repeats this check.
|
||||
|
||||
@@ -198,32 +198,3 @@ It accepts the same architecture and DLL flags and verifies the selected PE role
|
||||
in the analysis profile.
|
||||
All lanes require matching native controls; package startup alone establishes
|
||||
only package compatibility.
|
||||
|
||||
## Masked byte search
|
||||
|
||||
`search_bytes` (`rea search-bytes PATH '48 8B ?? ?? 89' --provider ghidra`)
|
||||
searches initialized analyzed memory, including loader fixups. Supply hexadecimal
|
||||
byte pairs, with `??` for any byte. An optional equal-length hexadecimal `mask`
|
||||
selects bits: `pattern: "a0"`, `mask: "f0"` matches bytes from `a0` through `af`.
|
||||
Wildcards remain unconstrained even with an explicit mask. At least one bit
|
||||
must be constrained.
|
||||
|
||||
Optional `start` and `end` addresses are inclusive and select one address space.
|
||||
A match must end at or before `end`, so `end` set inside a match excludes it.
|
||||
Without bounds, loaded memory blocks are eligible, in Ghidra address order.
|
||||
Blocks that are not loaded (file parts such as `.symtab`) and overlay blocks are
|
||||
listed in `searched.skipped_not_loaded` and are not searched; pass `start` or
|
||||
`end` in their address space to search them.
|
||||
Overlapping matches are included; matches never cross block boundaries.
|
||||
Each match reports its address, segment, unique source file offset (or null),
|
||||
and containing procedure (or null).
|
||||
|
||||
Pages default to 100 matches and accept 1 to 1000. Pass `next_cursor` as `cursor`
|
||||
with the same target, profile, pattern, mask and bounds to continue. `complete`
|
||||
means no further matches remain. `searched.blocks` and `searched.bytes` describe
|
||||
the inclusive memory ranges examined for this page, including lookahead for the
|
||||
next match. Coverage can overlap between pages; do not sum it as unique memory.
|
||||
`searched.skipped_uninitialized` lists eligible uninitialized blocks on every
|
||||
page. These blocks are never searched. Cancellation returns a typed error,
|
||||
discards the active Ghidra database, and does not return a successful partial page.
|
||||
Hopper byte search remains unavailable until its implementation is verified.
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
# Verify real HTTP/3 MITM captures
|
||||
|
||||
This optional Linux laboratory checks native mitmproxy captures through REA's
|
||||
public CLI and MCP. It changes no runtime contracts or adapters and establishes
|
||||
only the specific producer, mode and clients exercised here.
|
||||
|
||||
## Prepare a disposable guest
|
||||
|
||||
The exercised image is [Omarchy 4.0.4, build 440](https://nightly.omarchy.org/stable/4.0.4/440/omarchy-4.0.4.qcow2),
|
||||
SHA-256 `43412b896fe3d730e373a51a8acf99343d7c3e6dee78aa9f9bd7fca181127264`.
|
||||
Download it without installing guest packages on the host, check its digest,
|
||||
and use a **new qcow2 overlay** with that file as a read-only backing image.
|
||||
The matching [boot artifacts](https://nightly.omarchy.org/stable/4.0.4/440/unpacked/)
|
||||
include the kernel, initrd, cmdline and upstream vm-boot recipe. Use a fresh SSH
|
||||
key, loopback forwarding and guest disk; do not reuse another investigation's
|
||||
writable overlay or host trust configuration. Shut the guest down before
|
||||
switching overlays. Preserve the image and boot artifact hashes with evidence.
|
||||
|
||||
This sealed guest already supplies cURL 8.22.0 with OpenSSL 3.6.4, ngtcp2 1.25.0,
|
||||
nghttp3 1.18.0 and nghttp2 1.70.0. **No guest rolling package upgrade is needed.**
|
||||
Run `curl --version` and verify HTTP2 and HTTP3 features before starting. A stock
|
||||
cURL from another distribution may omit HTTP/3. For a different guest, building
|
||||
cURL requires a compatible TLS library, ngtcp2 and nghttp3; follow the
|
||||
[official build instructions](https://curl.se/docs/http3.html), install to a guest
|
||||
private prefix, and record exact source revisions and build inputs. That alternative
|
||||
has not been exercised by this laboratory; it is not the pinned reproduction path.
|
||||
|
||||
Use a fresh VM overlay over a sealed Linux image. Use user-mode networking with
|
||||
SSH forwarding bound to host loopback; keep host directories unmounted. Install
|
||||
and build dependencies inside the guest, then remove its default route before
|
||||
generating traffic. Host trust, routes and installed packages stay unchanged.
|
||||
|
||||
Record the image digest, REA source revision and these dependency versions:
|
||||
|
||||
- REA's `.nvmrc`, `package.json#packageManager` and locked npm dependencies.
|
||||
- Official Linux x86_64 mitmproxy **12.2.3** standalone archive. SHA-256:
|
||||
`2e95286b618fa6fd33e5e62a78c2e5112571d85f42ec2bac29b97ee242bdb5c5`.
|
||||
- A private Python virtual environment using the complete pinned graph in
|
||||
`scripts/fixtures/http3-lab-requirements.txt`. Save `pip freeze` and build/download logs.
|
||||
- OpenSSL and cURL advertising HTTP3 and HTTP2 in `curl --version`.
|
||||
|
||||
The verified guest used Python 3.14.7, cURL 8.22.0, ngtcp2 1.25.0 and nghttp3
|
||||
1.18.0. Other dependency profiles need verification. Back up the guest's
|
||||
`/etc/hosts` before adding `127.0.0.1 lab.test`. Reserve guest TCP/UDP loopback
|
||||
ports 18443 and 19443. These changes belong only to the disposable guest.
|
||||
|
||||
From the guest REA checkout, use its pinned Node/npm toolchain:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:cached
|
||||
python3 -m venv /absolute/lab/venv
|
||||
/absolute/lab/venv/bin/pip install --no-deps -r scripts/fixtures/http3-lab-requirements.txt
|
||||
/absolute/lab/venv/bin/pip check
|
||||
/absolute/lab/venv/bin/pip freeze > /absolute/lab/python-requirements.txt
|
||||
```
|
||||
|
||||
Before removing the guest default route, download the official mitmproxy archive
|
||||
and verify it, then extract to the guest's private tools directory:
|
||||
|
||||
```sh
|
||||
mkdir -p /absolute/lab/tools
|
||||
curl --fail --location https://downloads.mitmproxy.org/12.2.3/mitmproxy-12.2.3-linux-x86_64.tar.gz \
|
||||
--output /absolute/lab/mitmproxy.tar.gz
|
||||
printf '%s %s\n' 2e95286b618fa6fd33e5e62a78c2e5112571d85f42ec2bac29b97ee242bdb5c5 \
|
||||
/absolute/lab/mitmproxy.tar.gz | sha256sum --check --strict
|
||||
tar -xzf /absolute/lab/mitmproxy.tar.gz -C /absolute/lab/tools
|
||||
/absolute/lab/tools/mitmdump --version
|
||||
```
|
||||
|
||||
Pin the REA revision with `git rev-parse HEAD` or archive SHA-256. Build using
|
||||
the exact `.nvmrc` and npm version; do not copy a different checkout's `dist`.
|
||||
For stronger offline dependency reproduction, preserve downloaded Python
|
||||
archives, their digests and built wheels along with the image and toolchain.
|
||||
The requirement versions pin the runtime graph, not every transient build tool;
|
||||
source builds and alternate interpreters are not guaranteed byte-identical.
|
||||
|
||||
## Run the narrow profile
|
||||
|
||||
Select absolute executables and a new evidence directory. The producer refuses
|
||||
to reuse a directory. No certificates are installed in a system trust store;
|
||||
clients and proxy receive explicit paths to freshly generated test CAs.
|
||||
|
||||
```sh
|
||||
REA_MITMDUMP_COMMAND=/absolute/lab/tools/mitmdump \
|
||||
REA_HTTP3_PYTHON_COMMAND=/absolute/lab/venv/bin/python \
|
||||
npm run verify:web:http3 -- /absolute/evidence/run-1
|
||||
```
|
||||
|
||||
The producer sends three concurrent binary POSTs on one HTTP/3 connection to the
|
||||
same URL through mitmproxy reverse mode to an aioquic HTTP/3 origin. The origin
|
||||
waits for all complete requests, then dispatches responses in reverse order of
|
||||
completion at the origin. QUIC stream send/receive orders need not coincide. A cURL HTTP/3-only
|
||||
request checks a second client implementation. Mitmproxy's real save addon
|
||||
writes `normal.mitm`; no native flows are synthesized.
|
||||
|
||||
A second proxy run disables HTTP/3. cURL `--http3` falls back to HTTP/2, reaching
|
||||
an HTTP/1.1 TLS test origin. `fallback.mitm` must retain those lower protocols,
|
||||
so a successful HTTP request cannot masquerade as an HTTP/3 success.
|
||||
|
||||
## Acceptance and evidence
|
||||
|
||||
Each execution creates `verifications/<run-UUID>/` with its own diagnostics.
|
||||
`verification-latest.json` belongs to the most recently registered attempt and
|
||||
changes atomically from `running` to `passed` or `failed`. Registration and
|
||||
the ownership check/replacement use the same SQLite transaction lock. An older
|
||||
attempt finishing later keeps its own reports without replacing the marker.
|
||||
The `.verification-lock.sqlite` pathname must remain in place while attempts
|
||||
may run; SQLite releases the lock on connection close or process death.
|
||||
Lock acquisition waits for the current owner without a timeout or lease
|
||||
stealing. The verifier has per-command deadlines, but no overall run deadline;
|
||||
a live stalled lock owner can therefore delay publication until it exits.
|
||||
Only a successful attempt writes its own `verification.json`, after
|
||||
all cleanup and input-integrity checks. `result.json` also records failures and
|
||||
the original error separately from cleanup errors. Older successes remain in
|
||||
identified history; a legacy root `verification.json` is archived before a rerun.
|
||||
A killed verifier may leave `running`, which must never be interpreted as success.
|
||||
|
||||
Capture inputs live in `captures/`. To inspect them again without new traffic:
|
||||
|
||||
```sh
|
||||
REA_MITMDUMP_COMMAND=/absolute/lab/tools/mitmdump \
|
||||
npm run verify:web:http3 -- /absolute/evidence/run-1 --inspect-existing
|
||||
```
|
||||
|
||||
Acceptance for the current run:
|
||||
|
||||
- Every positive native record reports HTTP/3, UDP, ALPN h3 and successful TLS
|
||||
timestamps on both legs. Connection IDs, producer TLS labels, exact TLS setup
|
||||
timestamps and connection errors match the UUID-matched producer observation
|
||||
on each leg. Endpoint handshakes independently observe QUIC v1.
|
||||
- Native flow UUIDs remain distinct. Three endpoint streams share a connection
|
||||
on each leg. Origin dispatch reverses completed receipt. Client and capture
|
||||
ordering is observed, while association is verified independent of their order.
|
||||
- CLI and MCP agree. Request/response bytes, lengths, SHA-256, ordered duplicate
|
||||
headers, method, scheme, authority and path match independent observations.
|
||||
Readable header names and values, request URL fields and ALPN also match;
|
||||
non-UTF-8 header bytes retain a null display value rather than replacement text.
|
||||
- Actual MCP advertised schemas validate, Evidence preserves source digests and
|
||||
provider provenance, and the complete returned Evidence matches the retained MCP session records.
|
||||
- The fallback capture reports client HTTP/2 over TCP and origin HTTP/1.1, with
|
||||
cURL independently reporting version 2. Input files remain unchanged.
|
||||
- Every cleanup action is attempted on success and failure, and final lineage
|
||||
contains no live descendants. Recorded producer groups are inspected separately
|
||||
after token-verified cleanup, including after their launcher has exited.
|
||||
|
||||
Retain captures, endpoint/producer JSONL, proxy diagnostics, cURL output, manifest,
|
||||
CLI/MCP output, advertised schema and session bundle. Export public certificates
|
||||
and dependency/source provenance separately. Do not commit evidence, private
|
||||
keys, dependencies or VM disks. Shut down the guest after exporting evidence.
|
||||
|
||||
The reverse proxy can preserve authority `lab.test:18443` while recording origin
|
||||
port 19443. Mitmproxy's serialized flow/connection UUIDs are not stream IDs.
|
||||
Endpoint stream IDs are scoped observations; this test invents no cross-leg
|
||||
mapping. Mitmproxy 12.2.3's `QUICv1` label alone does not prove negotiation: the
|
||||
fixture checks the negotiated `_quic._version` in pinned aioquic 1.2.0.
|
||||
|
||||
Coverage is native capture, Linux reverse mode, QUIC v1 and these clients.
|
||||
Empty/absent/incomplete/redacted body semantics, cancellations, resets,
|
||||
certificate failures, malformed captures, HAR and qlog are separate increments.
|
||||
Local/WireGuard, browsers, QUIC v2, migration, 0-RTT, mTLS, replay, sustained load
|
||||
and loss tolerance remain unverified by this profile. Passing it does not
|
||||
establish general HTTP/3 support in REA.
|
||||
|
||||
Official references: [mitmproxy protocol support](https://github.com/mitmproxy/mitmproxy/blob/v12.2.3/docs/src/content/concepts/protocols.md),
|
||||
[HTTP/3 implementation](https://github.com/mitmproxy/mitmproxy/blob/v12.2.3/mitmproxy/proxy/layers/http/_http3.py),
|
||||
[serialized HTTP state](https://github.com/mitmproxy/mitmproxy/blob/v12.2.3/mitmproxy/http.py),
|
||||
[aioquic configuration](https://github.com/aiortc/aioquic/blob/1.2.0/src/aioquic/quic/configuration.py).
|
||||
|
||||
## Verifier regressions
|
||||
|
||||
After building REA, run these inside the same isolated guest:
|
||||
|
||||
```sh
|
||||
node --test scripts/lib/http3-verifier-lifecycle.test.mjs scripts/lib/http3-capture-assertions.test.mjs
|
||||
/absolute/lab/venv/bin/python scripts/fixtures/http3-lab.test.py
|
||||
```
|
||||
|
||||
These cover different QUIC stream completion orders, stale success reports,
|
||||
multiple failed closes, separately owned process groups and partially acquired
|
||||
resources, plus producer TLS fact mismatches on both legs and altered readable
|
||||
fields with unchanged binary evidence. They test verifier
|
||||
semantics; they do not add HTTP/3 provider coverage.
|
||||
@@ -279,7 +279,6 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
|
||||
| GitHub Copilot CLI | `copilot_cli` |
|
||||
| Command Code | `commandcode` |
|
||||
| Qwen Code | `qwen_code` |
|
||||
| Kimi Code | `kimi` |
|
||||
| VS Code | `vscode` |
|
||||
| Grok Build | `grok_build` |
|
||||
| OMP | `omp` |
|
||||
@@ -291,8 +290,8 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
|
||||
| ZCode | `zcode` |
|
||||
| DeepSeek Harness | `dsh` |
|
||||
|
||||
`dsh` and `kimi` require a release containing them. The registry check above does
|
||||
not cover them.
|
||||
`dsh` is main-only until a release after 6.4.0. The registry check above does
|
||||
not cover it.
|
||||
|
||||
See [client configuration and troubleshooting](client-configuration.md) for
|
||||
configuration paths, profiles, trust policies, and schema-limit workarounds.
|
||||
@@ -311,12 +310,9 @@ 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+, Ubuntu 24.04+, Fedora 41+,
|
||||
Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. REA itself runs on any
|
||||
Linux distribution with a recognized apt, dnf, or pacman package family, where
|
||||
an existing Hopper launcher is selected through `HOPPER_LAUNCHER_PATH`. Ghidra
|
||||
and IDA have their own provider-specific host requirements; Windows Ghidra uses
|
||||
the [experimental P0 boundary](windows-ghidra-p0.md).
|
||||
The supported native host baseline is macOS 12+, Ubuntu 24.04+, Fedora 41+,
|
||||
Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. Ghidra and IDA have their own provider-specific
|
||||
host requirements; Windows Ghidra uses the [experimental P0 boundary](windows-ghidra-p0.md).
|
||||
|
||||
On macOS, approved setup downloads the official DMG, checks its published size
|
||||
and digest, validates the application bundle, and atomically installs it to
|
||||
|
||||
@@ -233,9 +233,8 @@ these routes, their layout/host restrictions, derived-memory provenance, and
|
||||
verification coverage.
|
||||
|
||||
REA's experimental Windows Ghidra boundary admits native x86 and x86-64 PE
|
||||
applications and DLLs for static analysis. For x64 PE NativeAOT, the read-only
|
||||
parser also runs there; the [NativeAOT guide](ghidra-nativeaot.md) lists what a
|
||||
Windows host covers.
|
||||
applications and DLLs for static analysis. This does not establish Windows
|
||||
NativeAOT metadata-recovery coverage.
|
||||
|
||||
For NativeAOT analysis, preserve the selected native artifact's path and digest
|
||||
and report recovered type data with its returned provenance. Linking those types
|
||||
|
||||
+9
-14
@@ -65,7 +65,7 @@ unresolved relationship reads. Cancellation remains a tool error.
|
||||
REA prepares complete MCP results within the pinned stdio client's 10 MiB
|
||||
receive-buffer budget, including both text and structured representations and
|
||||
room for the JSON-RPC envelope. Tool errors carry only their text
|
||||
representation and `isError` flag, so only those count against the budget. Response budget settings are captured at startup;
|
||||
representation, so only that text counts against the budget. Response budget settings are captured at startup;
|
||||
restart or recreate the server to apply changes. If a result cannot fit, REA returns
|
||||
`resource_constraint` with `details.resource: "transport"` before constructing
|
||||
a document-sized string. Analysis Evidence remains complete in the current
|
||||
@@ -271,11 +271,9 @@ Evidence remain complete inline, and both input forms pass the same semantic,
|
||||
identity, authority, and provenance checks without running the producer again.
|
||||
|
||||
References belong to the current connection's Evidence ledger. Opening another
|
||||
target or restarting the current target preserves retained records, including
|
||||
when provider cleanup fails; records remain available for export and retry.
|
||||
`close_binary` clears them, even when no binary is active. A fresh connection
|
||||
has its own ledger. A missing reference reports its exact ID and
|
||||
`details.reason: "missing"`; the server cannot infer
|
||||
target preserves retained records; `close_binary` clears them, even when no
|
||||
binary is active. A fresh connection has its own ledger. A missing reference
|
||||
reports its exact ID and `details.reason: "missing"`; the server cannot infer
|
||||
whether it was never recorded, cleared, or retained by another connection.
|
||||
Supply complete inline Evidence, repeat its producer, or import an exported
|
||||
Evidence bundle before referencing that imported record. Export a bundle before
|
||||
@@ -604,12 +602,8 @@ failures for such entries name this cause and the operation to rerun with
|
||||
|
||||
Advertised input and output schemas contain no reachable recursive references.
|
||||
Schemas share repeated definitions through schema-local references while
|
||||
preserving complete nested fields and validation rules. Input schemas whose
|
||||
shared definitions would exhaust a client's declaration-rendering reference
|
||||
budget (Pi 1.1.0 code-mode shares 32 reference expansions across an input and
|
||||
renders every later reference as `unknown`) are re-projected without shared
|
||||
definitions, so every advertised field renders with complete structure.
|
||||
Input properties retain their descriptions and literal examples.
|
||||
preserving complete nested fields and validation rules. Input properties retain
|
||||
their descriptions and literal examples.
|
||||
|
||||
When a host forwards input schemas to the model, their examples consume context.
|
||||
REA's advertised examples therefore never copy complete producer Evidence.
|
||||
@@ -656,7 +650,7 @@ JSON Schema:
|
||||
objects or arrays.
|
||||
- The few schemas whose validation structure alone exceeds the budget
|
||||
(`compare_web_captures`, `build_reconstruction_obligation_ledger`,
|
||||
`evaluate_reconstruction_coverage`,
|
||||
`evaluate_reconstruction_coverage`, `project_managed_application_graph`,
|
||||
`compare_application_versions`, `capture_browser_scenario`) are advertised
|
||||
in a reduced form: property names, reference-resolved types (including union
|
||||
alternatives), first-level guidance, and the root's required-field constraints,
|
||||
@@ -666,7 +660,8 @@ JSON Schema:
|
||||
instead of silent acceptance.
|
||||
|
||||
Boundary tests pin every advertised compact schema to the budget, keep them
|
||||
valid JSON Schema that still accepts every canonical example. Client verification
|
||||
valid JSON Schema that still accepts every canonical example, and hold the
|
||||
set of six reduced presentations as contracts evolve. Client verification
|
||||
also checks provider-facing sizes and canonical examples after conversion by
|
||||
the actual Kimi Code CLI; local captures do not establish Moonshot acceptance.
|
||||
|
||||
|
||||
@@ -549,17 +549,3 @@ Value projection preserves serialized node kinds, IDs, references and pagination
|
||||
Exact and ambiguous value counts describe emitted root/object value instances;
|
||||
a root and an object may contain the same value. Archive digests identify the
|
||||
original bytes.
|
||||
|
||||
## Locate a byte signature
|
||||
|
||||
Use `search_bytes` after opening a target with Ghidra to locate a known constant
|
||||
or instruction sequence. For example, `{"pattern":"48 8B ?? ?? 89","limit":100}`
|
||||
returns ordered addresses with segments, source file offsets and containing
|
||||
procedures. The CLI equivalent is
|
||||
`rea search-bytes ./program '48 8B ?? ?? 89' --provider ghidra`.
|
||||
Use `start`/`end` to constrain the inclusive address window, and continue with
|
||||
`next_cursor` until `complete` is true. Inspect `searched` for page coverage and
|
||||
skipped uninitialized blocks. A missing match does not establish absence from
|
||||
uninitialized memory or across block boundaries. See
|
||||
[Ghidra byte search](ghidra-provider.md#masked-byte-search) for mask and coverage
|
||||
semantics. This query never executes or modifies target bytes.
|
||||
|
||||
+37
-44
@@ -51,7 +51,7 @@ Bir uygulamada kendi ürününüze eklemek istediğiniz bir özellik mi gördün
|
||||
|
||||
REA, ajanınızı yerel makine kodu içeren ikili dosyaları, JavaScript ve Electron uygulamalarını, .NET derlemelerini ve web sitelerini inceleyen araçlara bağlar. Aynı araçları terminalden de kullanabilirsiniz. Analiz yerel olarak çalışır; sonuçlar her çıkarımın dayandığı kanıtları ve sınırlamaları içerir.
|
||||
|
||||
Kurulum, REA'yı ajanınıza kaydeder ve uyumlu iş akışı talimatlarını yükler. Yerel makine kodu analizi mevcut bir Hopper, Ghidra veya IDA kurulumunu kullanabilir; kurulum işlemi, onayınızla isteğe bağlı olarak Hopper da yükleyebilir. Statik JavaScript analizi bu yerel analiz motorlarından hiçbirine ihtiyaç duymaz.
|
||||
Kurulum, REA'yı ajanınıza kaydeder ve uyumlu iş akışı talimatlarını yükler. Yerel makine kodu analizi mevcut bir Hopper veya Ghidra kurulumunu kullanabilir; kurulum işlemi, onayınızla isteğe bağlı olarak Hopper da yükleyebilir. Statik JavaScript analizi bu iki motora da ihtiyaç duymaz.
|
||||
|
||||
> Kurulum talimatları, görselli rehberler ve gerçek örnek çalışmalar için **[REA web sitesini ziyaret edin](https://rea.tools/tr/)**.
|
||||
|
||||
@@ -100,14 +100,12 @@ Yerel makine kodu analizi için önce bir sağlayıcı yapılandırın. Yerel an
|
||||
|
||||
REA hızla değişir ve yeni sürümler sık sık hata düzeltmeleri içerir. Kurulumunuzu güncel tutun.
|
||||
|
||||
`npm install --global` veya `bun add --global` ile yüklenen CLI için:
|
||||
npm ile yüklenen CLI için:
|
||||
|
||||
```bash
|
||||
rea update
|
||||
```
|
||||
|
||||
Bun ile yapılan global güncellemeler için Bun 1.2.17 veya üzeri gerekir. Daha eski bir Bun sürümünü kullanıyorsanız `rea update` komutunu çalıştırmadan önce Bun'u manuel olarak yükseltin.
|
||||
|
||||
Ajan kayıtlarını ve skill'i güncellemek için güncellemenin çıktısında gösterilen kurulum komutunu çalıştırın.
|
||||
|
||||
`npx` kullanıyorsanız ajan kurulumunu şu komutla güncelleyin:
|
||||
@@ -116,7 +114,7 @@ Ajan kayıtlarını ve skill'i güncellemek için güncellemenin çıktısında
|
||||
npx rea-agents@latest setup
|
||||
```
|
||||
|
||||
Kurulum değişikliklerini inceleyin ve ajanınızı yeniden başlatın. Tek seferlik CLI işlemleri için `npx rea-agents@latest` komutunun ardından istediğiniz komutu yazın. Bun kullanıyorsanız `npx rea-agents@latest` yerine `bunx rea-agents@latest` kullanın.
|
||||
Kurulum değişikliklerini inceleyin ve ajanınızı yeniden başlatın. Tek seferlik CLI işlemleri için `npx rea-agents@latest` komutunun ardından istediğiniz komutu yazın.
|
||||
|
||||
## REA nasıl çalışır
|
||||
|
||||
@@ -132,36 +130,33 @@ Ajanınız, hedefi incelemek ve ilgili kodu izlemek için MCP üzerinden REA'yı
|
||||
|
||||
REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araçlar ve desteklenen ana bilgisayar sistemleri hedefe bağlıdır:
|
||||
|
||||
| Hedef | REA'nın döndürdükleri | Gereksinimler ve rehber |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Yerel makine kodu içeren ikili dosyalar | Sözde kod, assembly kodu, dizeler, semboller, çağrılar ve referanslar | Hopper, Ghidra veya IDA; [yerel kod analizi](https://rea.tools/tr/guides/native/) |
|
||||
| Çevrimdışı ELF yapısı | Bölümler, segmentler, özgün semboller/yeniden konumlandırmalar ve statik analizden olası güvenlik önlemleri | Linux x64 üzerinde çağıranın sağladığı pwntools; [ikili dosya tanılama](../binary-diagnostics.md) |
|
||||
| Go ikili dosyaları | Gömülü compiler sürümü, modüller, replacement'lar ve build ayarları | Yerleşik statik ELF/PE/thin Mach-O incelemesi; Go kurulumu gerekmez |
|
||||
| WASM artefaktı | Doğrulama, bölümler, tam import/export WAT biçimleri ve decode edilmiş WAT | Çağıranın sağladığı WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](../evm-bytecode.md) |
|
||||
| SQLite veritabanları | Tablolar, sütunlar, index'ler, view'lar, trigger'lar ve seçilen sıradan tablo satırları | Yerel database/WAL snapshot'ı; native SQLite sınırlarıyla Node.js 24.x >=24.15 veya 26+ |
|
||||
| Kaydedilmiş Linux çökmeleri | Ham note kayıtları, kaydedilen her iş parçacığının yazmaçları/sinyalleri ve isteğe bağlı eşleme adayları | Linux x64 üzerinde çağıranın sağladığı pwntools; isteğe bağlı GDB/pwndbg; [kaydedilmiş çökmeler](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | Modüller, içe aktarmalar, kaynak haritaları, rotalar, IPC ve yerel eklenti ilişkileri | Node.js ve npm; [uygulama analizi](https://rea.tools/tr/guides/javascript/) |
|
||||
| Web siteleri | Sayfa yapısı, betikler, ağ gözlemleri ve istenen ekran görüntüleri | Chrome ailesinden bir tarayıcı; [tarayıcı analizi](https://rea.tools/tr/guides/browser/) |
|
||||
| Kaydedilmiş ağ yakalamaları | İstekler, yanıtlar, erişilebilir yük içerikleri ve kaynak konumları | HAR veya Fiddler Everywhere SAZ; Linux'ta native mitmproxy için mitmdump; [ağ yakalama rehberi](../web-network-captures.md) |
|
||||
| .NET derlemeleri | Meta veriler, CIL talimatları, bildirilmiş yerel bağımlılıklar ve derleme karşılaştırmaları | Statik inceleme; [yönetilen kod rehberi](../managed-code-analysis.md) |
|
||||
| Android APK'leri | Manifest bildirimleri, sınıflar, tersine derlenmiş metotlar ve referanslar | Linux/macOS/Windows x64 üzerinde arayüzsüz JADX ve tam bir JDK; [Android rehberi](../android-analysis.md) |
|
||||
| Android cihazları | Cihazlar, paketler, süreçler, günlükler, dökümler, ekran görüntüleri ve APK/dosya aktarımları | Çağıranın sağladığı adb (emülatör veya cihaz); [ADB rehberi](../adb-device-analysis.md) |
|
||||
| JEB projeleri | Motor destekli proje birimleri, artefakt özetleri ve tür/metot sözdekodu | Çağıranın başlattığı, MCP sunan bir JEB istemcisi, REA_JEB_MCP_URL ile seçilir; [JEB rehberi](../jeb-analysis.md) |
|
||||
| Android kaynakları | Çözülmüş manifest, dize tabloları, diller ve sürüm bilgileri | Çağıranın sağladığı Apktool başlatıcısı; [kaynak rehberi](../apktool-resource-analysis.md) |
|
||||
| Flutter uygulamaları | Dart anlık görüntü karması, ABI başına kitaplık özetleri ve motor derleme kökeni | Yok (statik APK çözümleme); [Flutter rehberi](../flutter-analysis.md) |
|
||||
| Donanım yazılımı | Bölgeler, çıkarma sonuçları ve yerel kod analizine devirler | Linux üzerinde Binwalk / Unblob; [donanım yazılımı rehberi](../firmware-analysis.md) |
|
||||
| Kaydedilmiş paketler | Seçilen alanlar, yinelenen değerler, filtreler ve çözümleme kuralları | Linux: TShark 4.x + prlimit; [yakalama kılavuzu](../packet-captures.md) |
|
||||
| Paket özetleri | Uç noktalar, DNS, TLS SNI, protokol dağılımı ve HTTP istekleri | Çağıranın sağladığı tshark ve capinfos (Linux üzerinde doğrulandı); [pcap rehberi](../pcap-analysis.md) |
|
||||
| Kaydedilmiş mantık sinyalleri | Kod çözücü açıklamaları, örnek aralıkları ve tanılama | Linux: sigrok-cli 0.7.2 + prlimit; [sinyal kılavuzu](../signal-recordings.md) |
|
||||
| Hedef | REA'nın döndürdükleri | Gereksinimler ve rehber |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Yerel makine kodu içeren ikili dosyalar | Sözde kod, assembly kodu, dizeler, semboller, çağrılar ve referanslar | Hopper, Ghidra veya IDA; [yerel kod analizi](https://rea.tools/tr/guides/native/) |
|
||||
| Çevrimdışı ELF yapısı | Bölümler, segmentler, özgün semboller/yeniden konumlandırmalar ve statik analizden olası güvenlik önlemleri | Linux x64 üzerinde çağıranın sağladığı pwntools; [ikili dosya tanılama](../binary-diagnostics.md) |
|
||||
| EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](../evm-bytecode.md) |
|
||||
| Kaydedilmiş Linux çökmeleri | Ham note kayıtları, kaydedilen her iş parçacığının yazmaçları/sinyalleri ve isteğe bağlı eşleme adayları | Linux x64 üzerinde çağıranın sağladığı pwntools; isteğe bağlı GDB/pwndbg; [kaydedilmiş çökmeler](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | Modüller, içe aktarmalar, kaynak haritaları, rotalar, IPC ve yerel eklenti ilişkileri | Node.js ve npm; [uygulama analizi](https://rea.tools/tr/guides/javascript/) |
|
||||
| Web siteleri | Sayfa yapısı, betikler, ağ gözlemleri ve istenen ekran görüntüleri | Chrome ailesinden bir tarayıcı; [tarayıcı analizi](https://rea.tools/tr/guides/browser/) |
|
||||
| Kaydedilmiş ağ yakalamaları | İstekler, yanıtlar, erişilebilir yük içerikleri ve kaynak konumları | HAR; mitmproxy'nin kendi biçimindeki yakalamalar için Linux üzerinde mitmdump; [ağ yakalama rehberi](../web-network-captures.md) |
|
||||
| Paket yakalamaları | Uç noktalar, DNS, TLS SNI, protokol dağılımı ve HTTP istekleri | Çağıranın sağladığı tshark ve capinfos; [pcap rehberi](../pcap-analysis.md) |
|
||||
| .NET derlemeleri | Meta veriler, CIL talimatları, bildirilmiş yerel bağımlılıklar ve derleme karşılaştırmaları | Statik inceleme; [yönetilen kod rehberi](../managed-code-analysis.md) |
|
||||
| Android APK'leri | Manifest bildirimleri, sınıflar, tersine derlenmiş metotlar ve referanslar | Linux/macOS/Windows x64 üzerinde arayüzsüz JADX ve tam bir JDK; [Android rehberi](../android-analysis.md) |
|
||||
| Android cihazları | Cihazlar, paketler, süreçler, günlükler, dökümler, ekran görüntüleri ve APK/dosya aktarımları | Çağıranın sağladığı adb (emülatör veya cihaz); [ADB rehberi](../adb-device-analysis.md) |
|
||||
| JEB projeleri | Motor destekli proje birimleri, artefakt özetleri ve tür/metot sözdekodu | Çağıranın başlattığı, MCP sunan bir JEB istemcisi, REA_JEB_MCP_URL ile seçilir; [JEB rehberi](../jeb-analysis.md) |
|
||||
| Android kaynakları | Çözülmüş manifest, dize tabloları, diller ve sürüm bilgileri | Çağıranın sağladığı Apktool başlatıcısı; [kaynak rehberi](../apktool-resource-analysis.md) |
|
||||
| Flutter uygulamaları | Dart anlık görüntü karması, ABI başına kitaplık özetleri ve motor derleme kökeni | Yok (statik APK çözümleme); [Flutter rehberi](../flutter-analysis.md) |
|
||||
| Donanım yazılımı | Bölgeler, çıkarma sonuçları ve yerel kod analizine devirler | Linux üzerinde Binwalk / Unblob; [donanım yazılımı rehberi](../firmware-analysis.md) |
|
||||
| Kaydedilmiş paketler | Seçilen alanlar, yinelenen değerler, filtreler ve çözümleme kuralları | Linux: TShark 4.x + prlimit; [yakalama kılavuzu](../packet-captures.md) |
|
||||
| Kaydedilmiş mantık sinyalleri | Kod çözücü açıklamaları, örnek aralıkları ve tanılama | Linux: sigrok-cli 0.7.2 + prlimit; [sinyal kılavuzu](../signal-recordings.md) |
|
||||
| Paketler ve kaynaklar | Dosya envanterleri, özet değerleri, plist'ler, Apple paket yapısı ve çıkarılmış kaynaklar | [Artefakt ve JavaScript rehberi](../javascript-artifact-reconstruction.md), [Apple uygulamaları](../apple-application-analysis.md) |
|
||||
| Süreç davranışı | Terminal çıktısı, etkileşimler, sonlanma ve dosya sistemi gözlemleri, çalıştırma karşılaştırmaları | Yerel PTY ile Linux/macOS; [süreç yakalama](../process-capture.md) |
|
||||
| Süreç davranışı | Terminal çıktısı, etkileşimler, sonlanma ve dosya sistemi gözlemleri, çalıştırma karşılaştırmaları | Yerel PTY ile Linux/macOS; [süreç yakalama](../process-capture.md) |
|
||||
|
||||
Statik JavaScript ve .NET incelemesi, uygulamayı çalıştırmadan sağlanan dosyaları okur. Çalışma zamanı yakalaması, seçilen hedefi kullanıcı izinlerinizle çalıştırır veya onunla etkileşime girer; her çalışma zamanı rehberi etkilerini açıklar.
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Yerel makine kodu içeren ikili dosya biçimleri ve ana bilgisayar desteği sağlayıcıya göre değişir. [Hopper ve Ghidra kurulumu](../installation.md#hopper), isteğe bağlı native MCP bridge'i de açıklayan [IDA rehberi](../ida-provider.md) ve [Windows için deneysel Ghidra desteği](../windows-ghidra-p0.md) belgelerine bakın. Ghidra ayrıca [16 bit DOS analizini](../ghidra-dos.md) destekler. Büyük ikili dosyalar için sağlayıcının başlatma zaman aşımını `REA_GHIDRA_STARTUP_TIMEOUT_MS` veya `REA_HOPPER_STARTUP_TIMEOUT_MS` ile artırın. Sağlayıcı seçimi için [CLI rehberine](../cli.md#choose-a-provider) bakın. En son npm sürümünden sonra eklenen özellikler için [sürümlerde kullanılabilirliği](../installation.md#released-package-and-main) kontrol edin.
|
||||
Yerel makine kodu içeren ikili dosya biçimleri ve ana bilgisayar desteği sağlayıcıya göre değişir. [Hopper ve Ghidra kurulumu](../installation.md#hopper), [IDA rehberi](../ida-provider.md) ve [Windows için deneysel Ghidra desteği](../windows-ghidra-p0.md) belgelerine bakın. Ghidra ayrıca [16 bit DOS analizini](../ghidra-dos.md) destekler. Sağlayıcı seçimi için [CLI rehberine](../cli.md#choose-a-provider) bakın. En son npm sürümünden sonra eklenen özellikler için [sürümlerde kullanılabilirliği](../installation.md#released-package-and-main) kontrol edin.
|
||||
|
||||
## Örnek çalışmalar
|
||||
|
||||
@@ -285,9 +280,9 @@ Sorumlu kullanım yönergeleri için [DISCLAIMER.md](../../DISCLAIMER.md) dosyas
|
||||
|
||||
REA; yasal araştırma, eğitim, birlikte çalışabilirlik, yazılımın korunması ve sahibi olduğunuz ya da inceleme yetkiniz bulunan yazılımların analizi için tasarlanmıştır.
|
||||
|
||||
Projenin yasa dışı veya yetkisiz kullanıma ilişkin tutumu tüm REA sürümleri,
|
||||
dağıtım kanalları ve platformlar için geçerlidir; her kopya geçerli lisanslarını
|
||||
korur.
|
||||
The project's position on illegal or unauthorized use covers all REA
|
||||
versions, distribution channels, and platforms; each copy retains its
|
||||
applicable licenses.
|
||||
|
||||
## Katkıda bulunma
|
||||
|
||||
@@ -299,16 +294,14 @@ Geliştirme ortamı ve kontroller için [CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
|
||||
[MIT](../../LICENSE)
|
||||
|
||||
REA tarafından yazılan kod MIT lisansı altında lisanslanır. Üçüncü taraf
|
||||
materyaller kendi lisanslarını ve telif hakkı bildirimlerini korur; bağımlılık
|
||||
envanteri, korunan lisans metinleri ve harici araç koşulları için [Üçüncü taraf
|
||||
bildirimleri](../../THIRD_PARTY_NOTICES.md) dosyasına bakın.
|
||||
REA-authored code is licensed under MIT. Third-party material retains its own
|
||||
licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md)
|
||||
for the dependency inventory, preserved license texts, and external-tool terms.
|
||||
|
||||
**IDA Pro / Hex-Rays decompiler'ları, Hopper ve JEB dahil olmak üzere ticari
|
||||
araçları yasal olarak edinmeli ve ilgili lisanslar kapsamında kullanma
|
||||
yetkiniz olmalıdır.** REA hiçbir araç lisansı, anahtarı veya etkinleştirme hakkı
|
||||
vermez. Deneme sürümleri ve demolar; kullanıcı, özellik, otomasyon ve çıktı
|
||||
paylaşımı kısıtlamaları dahil olmak üzere satıcı koşullarına tabidir. Kırılmış
|
||||
kopyalar, çalınmış anahtarlar, yetkisiz anahtar paylaşımı ve lisans
|
||||
kısıtlamalarını aşma yetkili kullanım kapsamında değildir. Bu yönlendirme MIT
|
||||
veya üçüncü taraf lisanslarını değiştirmez.
|
||||
**You must legally obtain and be authorized to use commercial tools**, including
|
||||
IDA Pro / Hex-Rays decompilers, Hopper, and JEB, under their applicable licenses.
|
||||
REA grants no tool license, key, or activation entitlement. Trials and demos
|
||||
remain subject to vendor terms, including user, feature, automation, and output
|
||||
sharing restrictions. Cracked copies, stolen keys, unauthorized key sharing,
|
||||
and bypassing license restrictions are not authorized. This guidance does not
|
||||
amend MIT or third-party licenses.
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
REA 將 AI 代理連接到分析工具,用於檢查原生二進位檔案、JavaScript 和 Electron 應用、.NET 程式集以及網站。你也可以在終端中使用同樣的工具。分析在本機執行,結果包含支撐各項結論的證據和相關限制。
|
||||
|
||||
設定流程會向 AI 代理註冊 REA,並安裝對應的工作流程指引。原生分析可以使用已有的 Hopper、Ghidra 或 IDA;設定流程也可以在你核准後安裝 Hopper。靜態 JavaScript 分析不需要原生分析引擎。
|
||||
設定流程會向 AI 代理註冊 REA,並安裝對應的工作流程指引。原生分析可以使用已有的 Hopper 或 Ghidra;設定流程也可以在你核准後安裝 Hopper。靜態 JavaScript 分析不需要這兩個引擎。
|
||||
|
||||
> **[訪問 REA 網站](https://rea.tools/zh-TW/)**,查看設定說明、圖解指南和真實案例。
|
||||
|
||||
@@ -100,14 +100,12 @@ rea --help
|
||||
|
||||
REA 更新較快,新版本經常包含錯誤修復。請將安裝的版本保持最新。
|
||||
|
||||
通過 `npm install --global` 或 `bun add --global` 安裝的 CLI 使用:
|
||||
通過 npm 安裝的 CLI 使用:
|
||||
|
||||
```bash
|
||||
rea update
|
||||
```
|
||||
|
||||
Bun 全域更新需要 Bun 1.2.17 或更新版本。如果使用較舊的 Bun 版本,請先手動升級 Bun,再執行 `rea update`。
|
||||
|
||||
要更新 AI 代理註冊和 skill,請執行更新命令輸出的設定命令。
|
||||
|
||||
如果使用 `npx`,通過以下命令更新 AI 代理設定:
|
||||
@@ -116,7 +114,7 @@ Bun 全域更新需要 Bun 1.2.17 或更新版本。如果使用較舊的 Bun
|
||||
npx rea-agents@latest setup
|
||||
```
|
||||
|
||||
檢查設定變更並重新啟動 AI 代理。單次 CLI 操作可使用 `npx rea-agents@latest`,後接所需命令。使用 Bun 時,用 `bunx rea-agents@latest` 代替 `npx rea-agents@latest`。
|
||||
檢查設定變更並重新啟動 AI 代理。單次 CLI 操作可使用 `npx rea-agents@latest`,後接所需命令。
|
||||
|
||||
## REA 的工作原理
|
||||
|
||||
@@ -136,8 +134,6 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
|
||||
| --------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| 原生二進位檔案 | 偽程式碼、組合語言、字串、符號、呼叫和引用 | Hopper、Ghidra 或 IDA;[原生分析](https://rea.tools/zh-TW/guides/native/) |
|
||||
| 離線 ELF 佈局 | 節、段、原始符號/重定位資訊及靜態防護機制候選項 | Linux x64 上由呼叫方提供的 pwntools;[二進位診斷](../binary-diagnostics.md) |
|
||||
| Go 二進位檔案 | 內嵌編譯器版本、模組、替換項和建置設定 | 內建靜態 ELF/PE/thin Mach-O 檢查;無需安裝 Go |
|
||||
| WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) |
|
||||
| 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](https://rea.tools/zh-TW/guides/javascript/) |
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
REA 将智能体连接到分析工具,用于检查原生二进制文件、JavaScript 和 Electron 应用、.NET 程序集以及网站。你也可以在终端中使用同样的工具。分析在本机运行,结果包含支撑各项结论的证据和相关限制。
|
||||
|
||||
设置流程会向智能体注册 REA,并安装匹配的工作流指引。原生分析可以使用已有的 Hopper、Ghidra 或 IDA;设置流程也可以在你批准后安装 Hopper。静态 JavaScript 分析不需要原生分析引擎。
|
||||
设置流程会向智能体注册 REA,并安装匹配的工作流指引。原生分析可以使用已有的 Hopper 或 Ghidra;设置流程也可以在你批准后安装 Hopper。静态 JavaScript 分析不需要这两个引擎。
|
||||
|
||||
> **[访问 REA 网站](https://rea.tools/zh-CN/)**,查看设置说明、图解指南和真实案例。
|
||||
|
||||
@@ -100,14 +100,12 @@ rea --help
|
||||
|
||||
REA 更新较快,新版本经常包含错误修复。请保持安装的版本为最新。
|
||||
|
||||
通过 `npm install --global` 或 `bun add --global` 安装的 CLI 使用:
|
||||
通过 npm 安装的 CLI 使用:
|
||||
|
||||
```bash
|
||||
rea update
|
||||
```
|
||||
|
||||
Bun 全局更新需要 Bun 1.2.17 或更高版本。如果使用较旧的 Bun 版本,请先手动升级 Bun,再运行 `rea update`。
|
||||
|
||||
要更新智能体注册和 skill,请运行更新命令输出的设置命令。
|
||||
|
||||
如果使用 `npx`,通过以下命令更新智能体设置:
|
||||
@@ -116,7 +114,7 @@ Bun 全局更新需要 Bun 1.2.17 或更高版本。如果使用较旧的 Bun
|
||||
npx rea-agents@latest setup
|
||||
```
|
||||
|
||||
检查设置变更并重启智能体。单次 CLI 操作可使用 `npx rea-agents@latest`,后接所需命令。使用 Bun 时,用 `bunx rea-agents@latest` 代替 `npx rea-agents@latest`。
|
||||
检查设置变更并重启智能体。单次 CLI 操作可使用 `npx rea-agents@latest`,后接所需命令。
|
||||
|
||||
## REA 的工作原理
|
||||
|
||||
@@ -136,8 +134,6 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
|
||||
| --------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| 原生二进制文件 | 伪代码、汇编、字符串、符号、调用和引用 | Hopper、Ghidra 或 IDA;[原生分析](https://rea.tools/zh-CN/guides/native/) |
|
||||
| 离线 ELF 布局 | 节、段、原始符号/重定位信息及静态防护机制候选项 | Linux x64 上由调用方提供的 pwntools;[二进制诊断](../binary-diagnostics.md) |
|
||||
| Go 二进制文件 | 内嵌编译器版本、模块、替换项和构建设置 | 内置静态 ELF/PE/thin Mach-O 检查;无需安装 Go |
|
||||
| WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) |
|
||||
| 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](https://rea.tools/zh-CN/guides/javascript/) |
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
# Recorded crash evidence
|
||||
|
||||
`inspect_recorded_crash` / `rea inspect-recorded-crash` inspect a caller-selected
|
||||
Linux ELF64 little-endian core independently of an active binary target. Two
|
||||
architecture profiles are interpreted, x86-64 and AArch64; each binds registers
|
||||
to its own `NT_PRSTATUS` layout, and any other `e_machine` is refused as
|
||||
`unsupported_target` before note interpretation.
|
||||
Linux ELF64 x86-64 little-endian core independently of an active binary target.
|
||||
|
||||
```sh
|
||||
export REA_PWNTOOLS_PYTHON=/absolute/existing/venv/bin/python
|
||||
@@ -13,8 +10,8 @@ rea inspect-recorded-crash ./crash.core --json
|
||||
|
||||
The selected Python needs unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2.
|
||||
REA does not install these dependencies. ELFFile/its original ELF structures and
|
||||
pwntools' original Linux amd64 and aarch64 ctypes provide parsing and ABI layout.
|
||||
The adapter adds bounds, representation preservation and lifecycle ownership.
|
||||
pwntools' original Linux amd64 ctypes provide parsing and ABI layout. The adapter
|
||||
adds bounds, representation preservation and lifecycle ownership.
|
||||
The initial Linux note profile accepts System V/unspecified and GNU/Linux OSABI
|
||||
with ABI version zero. Other declared ABIs return `unsupported_target` before
|
||||
note interpretation; an unspecified OSABI alone does not prove OS origin.
|
||||
@@ -30,8 +27,7 @@ note interpretation; an unspecified OSABI alone does not prove OS origin.
|
||||
- Every exact Linux CORE PRSTATUS note as a separate recorded thread, with
|
||||
historical PID, signal and lossless uint64 registers bound to source bytes.
|
||||
Each supported note has exactly one interpretation; each thread contains the
|
||||
complete ABI register set for its architecture (27 registers for amd64,
|
||||
34 for AArch64), with names bound to their original ABI offsets.
|
||||
complete 27-register ABI set, with names bound to their original ABI offsets.
|
||||
Physical note ranges cannot overlap or leave segment gaps. ELF and note headers
|
||||
are independently bound to the snapshot, including extended program counts.
|
||||
- Signed SIGINFO number/code/errno. Initially only SIGSEGV codes 1/2 establish the
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,10 +0,0 @@
|
||||
# Plan
|
||||
|
||||
1. Check issue/timeline and complete open/closed PR searches; review #2406.
|
||||
2. Extend the existing core-module WABT inspection in an isolated worktree.
|
||||
3. Extend producer-owned inspection with optional typed interface facts;
|
||||
preserve unknown unsupported signatures and exact WAT forms.
|
||||
4. Add shared Evidence-only comparator and strict input/output contracts.
|
||||
5. Register additive CLI/MCP workflow and documentation.
|
||||
6. Validate original-module workflows, malformed/unknown/cancellation cases;
|
||||
run checks, independent review, repair findings and rerun affected checks.
|
||||
@@ -1,61 +0,0 @@
|
||||
# Core WebAssembly interface comparison (#2356)
|
||||
|
||||
## Outcome and scope
|
||||
|
||||
Compare two exact authenticated `inspect_wasm_artifact` Evidence inputs, inline
|
||||
or retained session references, under required `scope: imports | exports | both`.
|
||||
No module acquisition or execution occurs during comparison. This is a derived
|
||||
workflow within existing WABT inspection authority, not a new provider.
|
||||
|
||||
Producer-owned typed `interface` facts are additive and optional in historical
|
||||
inspection Evidence. WABT's validated canonical WAT owns decoding; exact original
|
||||
forms and names remain available. Comparison never reparses retained display text.
|
||||
Function/tag signatures resolve type uses; memory page size defaults to 65536 bytes and explicit custom page sizes are retained; table/memory limits and global types
|
||||
are compared exactly. Unsupported type/proposal forms remain unknown rather than
|
||||
being compared by raw text or inferred compatible. Names are exact decoded UTF-8,
|
||||
without normalization, including empty names, escapes, punctuation and BOM.
|
||||
|
||||
## Comparison contract
|
||||
|
||||
Return both Evidence IDs and original artifact path/digest/length. Group imports
|
||||
by exact module/name and exports by exact name, independently of kind, so a kind
|
||||
change is reported as changed. Unique admitted signatures yield added, removed,
|
||||
changed or unchanged. Duplicate keys retain every original occurrence and yield
|
||||
unknown; no Cartesian pairing or deduplication. Missing historical facets and
|
||||
incomplete inventories prevent absence claims. Unknown signatures do not erase
|
||||
independently observed entries. Ordering is deterministic by direction and names.
|
||||
|
||||
Both subject digest/path and inspection parameters must match normalized artifact
|
||||
identity. Authenticate Evidence before projection; summary Evidence must be
|
||||
resolved to its retained complete parent by the caller. Report unknown coverage
|
||||
and explicit limitations. Exact static equality is not runtime compatibility,
|
||||
ABI compatibility, linking success or source identity.
|
||||
|
||||
## Authority and overlap
|
||||
|
||||
Reads supplied Evidence only. MCP records derived Evidence through the existing
|
||||
handler/ledger; CLI accepts portable inline Evidence from an explicit JSON input.
|
||||
No new provider, implicit fetch, target mutation or foreign-code execution.
|
||||
PR #2406 (head 86865035) adds component inspection, preamble routing and embedded
|
||||
core-module inspection. Its component parser is not required or duplicated here;
|
||||
shared contract/CLI registration changes remain additive. Core GC/recursive types
|
||||
are outside admitted signature semantics and keep unknown mappings.
|
||||
|
||||
## Resource admission
|
||||
|
||||
Typed producer rows, names and declaration tokenization are admitted before
|
||||
allocation against the existing 32 MiB WABT output representation capacity.
|
||||
Comparison uses linear grouping and cooperative transport yields; it never
|
||||
builds Cartesian duplicate pairings. CLI JSON input retains the shared file
|
||||
admission gate. Token usage and peak process-family memory remain unmeasured.
|
||||
|
||||
## Verification
|
||||
|
||||
Original owned modules assembled using WABT 1.0.42 cover semantically equivalent
|
||||
encodings, changes of kind/signature, names/duplicates, imported index spaces,
|
||||
tables/memory/globals/tags, unknown types and historical incomplete Evidence.
|
||||
Exercise authenticated identity, cancellation, retained lookup, CLI/MCP parity
|
||||
and advertised schemas. Run focused existing WASM/Evidence tests, repository
|
||||
checks and docs validation; independent subagent review before handoff.
|
||||
|
||||
Format reference: https://webassembly.github.io/spec/core/binary/modules.html
|
||||
@@ -1,8 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Confirm scope and overlap boundaries against #2406.
|
||||
- [x] Record spec and implementation plan.
|
||||
- [x] Implement typed producer interface and shared comparison.
|
||||
- [x] Add CLI/MCP contracts and public workflow tests.
|
||||
- [x] Run focused, static and documentation checks.
|
||||
- [x] Resolve independent review findings and rerun relevant checks.
|
||||
@@ -1,47 +0,0 @@
|
||||
# Verification
|
||||
|
||||
Verified base: a2d8b94dd50dab8d966239dd63ab84718aa8b4b4 (fetched main).
|
||||
The change was reapplied without conflicts and relevant checks rerun.
|
||||
|
||||
- Focused existing/new parser, provider, service, contract and CLI/MCP tests:
|
||||
8 files / 56 tests passed.
|
||||
- After the final partial-uniqueness correction, reran service and compiled
|
||||
CLI/MCP boundary tests: 2 files / 5 tests passed.
|
||||
- `npm run check`: typecheck, lint, formatting, knip, module boundaries and
|
||||
architecture guards passed. Lint reports warnings; no errors.
|
||||
- `npm run docs:generate`, `npm run docs:check` and
|
||||
`npm run verify:test-discovery` passed; every test file was discovered exactly once.
|
||||
- Real WABT 1.0.42 comparison through compiled CLI and stdio MCP passed:
|
||||
equivalent padded LEB128 original encodings, function signatures, all admitted
|
||||
kinds, custom memory page size, escaped/BOM names, empty interfaces, explicit
|
||||
scope, duplicate imports and unadmitted exception reference signatures.
|
||||
- Existing real WABT artifact verifier passed after correcting its stale inline
|
||||
partial-observation assumption to resolve the MCP retained reference from the
|
||||
Evidence bundle and authenticate the original record.
|
||||
|
||||
## Independent review
|
||||
|
||||
Three findings repaired at their owning boundary:
|
||||
|
||||
1. Memory page size omitted: now preserved, default 65536 canonicalized.
|
||||
2. Shared signature expansion undercounted: charge every retained occurrence.
|
||||
Reviewer independently measured the 1000-params/10000-exports case: original
|
||||
262956-byte WAT produced 61824014-byte JSON before correction. It now retains
|
||||
1949 observations in a partial facet, with 12046676-byte JSON and an explicit
|
||||
capacity issue. Original WAT stays available; incomplete coverage prevents
|
||||
absence and uniqueness claims.
|
||||
3. Partial singleton uniqueness: comparison now returns unknown whenever either
|
||||
selected inventory is incomplete, including matched names.
|
||||
|
||||
Final independent source review reported no actionable findings. Review does not
|
||||
replace the executed tests or real-tool evidence.
|
||||
|
||||
## Scope and measurement limits
|
||||
|
||||
On the original implementation base f254f5a, the complete comparison verifier reported 7.98 seconds elapsed, 6.56 user and
|
||||
3.13 system seconds, and 429441024 bytes maximum RSS via macOS `/usr/bin/time -l`.
|
||||
These are command-accounting measurements, not aggregate concurrent process-family
|
||||
memory or model tokens. Token usage and full process-family peak are unmeasured.
|
||||
Real-tool verification covers macOS arm64 only; Linux/Windows remain unverified.
|
||||
No module was instantiated/executed and no source was fetched. Static equality
|
||||
is not runtime compatibility. Full CI is separate from this local verification record.
|
||||
@@ -646,12 +646,6 @@ records independent symbol/directory/SHA oracles; it never executes the target.
|
||||
Linux also builds stripped, ordinary-native and small malformed/ambiguous/layout
|
||||
negative inputs. The optional Windows fixture workflow builds a PE on a Windows
|
||||
runner; analyzing that PE on Linux does not verify a Windows Ghidra host.
|
||||
On a local Windows host, the .NET 8 ILCompiler finds the linker only through
|
||||
Visual Studio 2019 or 2022 (`vswhere -version [16,18)`). With only Visual
|
||||
Studio Build Tools 2026 installed, open its x64 Developer Command Prompt.
|
||||
In that same `cmd.exe` session, run `set IlcUseEnvironmentalTools=true` and
|
||||
then `npm run build:fixtures:nativeaot`. Running `vcvars64.bat` as a child of
|
||||
PowerShell does not preserve its environment in the parent PowerShell session.
|
||||
|
||||
Build the clean pinned upstream adapter with `build:ghidra:nativeaot`, then set
|
||||
`REA_GHIDRA_NATIVEAOT_JAR`. Run `verify:ghidra:nativeaot -- symbols`, `-- stripped`,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implementation_status,verification_result
|
||||
5,src/domain/native/bytePattern.ts,Unconstrained byte signature,Pattern must constrain at least one bit,Low risk; identifies input correction,Keep current copy,implemented,parser and MCP validation tests
|
||||
5,src/domain/native/bytePattern.ts,Unequal mask length,Mask length must equal pattern length,Low risk; identifies input correction,Keep current copy,implemented,parser and MCP validation tests
|
||||
5,bridge/ghidra/ReaGhidraBridge.java:searchMemoryBytes,Cursor reused with another query,Byte search cursor does not match the target and query,Low risk; identifies continuation mismatch,Keep current copy,implemented,real-provider verification required
|
||||
1,src/domain/errors.ts:AnalysisCapabilityUnavailableError,Requested analysis unavailable,"Provider {providerId} cannot execute {operation}: {reason}",Exposes provider and internal operation/reason,"This analysis is unavailable for the current target. Choose another analysis or target.",implemented,focused assertions passed; full `npm run check` passed
|
||||
2,src/domain/errors.ts:ProviderAdapterError,Provider adapter fails,"Provider {providerId} adapter failed during {operation}",Exposes provider and implementation boundary,"Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.",implemented,focused assertions passed; full `npm run check` passed
|
||||
3,src/domain/errors.ts:HopperRemoteError,Remote analysis request fails,"Hopper request failed ({code}): {safeMessage}",Exposes provider name code and upstream text,"Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.",implemented,focused assertions passed; full `npm run check` passed
|
||||
|
||||
|
@@ -13,11 +13,6 @@ Check `binary_session` for configured capability availability. A recognized
|
||||
header is not validation of the complete module; missing WABT is reported
|
||||
separately as an unavailable capability, not as missing native engine support.
|
||||
|
||||
The recognized WASM header is checked before automatic database routing from a
|
||||
.hop filename. Renaming a module to .hop therefore keeps the static-inspection
|
||||
diagnostic. An explicit database selection or raw/COM format selection retains
|
||||
the caller's requested interpretation; ordinary .hop database routing is unchanged.
|
||||
|
||||
Set `REA_WABT_BIN_DIRECTORY` to an absolute directory containing executable
|
||||
`wasm-validate`, `wasm-objdump` and `wasm2wat` (with `.exe` on Windows). Missing
|
||||
executables, unsupported banners and inaccessible tools produce actionable
|
||||
@@ -84,46 +79,3 @@ Both CLI and MCP use the same typed port and application service. MCP publishes
|
||||
SDK-compatible schemas and records Evidence for `get_evidence_bundle` readback.
|
||||
See [WABT provenance](https://github.com/morluto/rea/blob/main/third_party/wabt/README.md) and
|
||||
[testing](testing.md#offline-wasm-artifacts) for real-tool verification.
|
||||
|
||||
## Compare core-module interfaces
|
||||
|
||||
Inspection adds producer-owned typed `interface` facts while keeping exact WAT
|
||||
import/export forms. Function/tag signatures resolve type indices; table/memory
|
||||
limits, memory page size and global value type/mutability remain explicit.
|
||||
Unadmitted types (including GC reference signatures) retain null signatures and
|
||||
issues. Exact decoded names retain escapes, empty names, BOM and punctuation.
|
||||
This facet is absent in historical Evidence that did not examine typed interfaces.
|
||||
|
||||
`compare_wasm_interfaces` accepts two complete `inspect_wasm_artifact` Evidence
|
||||
records, or exact retained references in the current MCP session, and required
|
||||
`scope: "imports" | "exports" | "both"`. It reads no files and needs no active
|
||||
WABT toolchain. CLI uses portable inline Evidence in one JSON document:
|
||||
|
||||
```bash
|
||||
rea compare-wasm-interfaces comparison-input.json --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"left": { "kind": "retained-evidence", "evidence_id": "ev_<left-digest>" },
|
||||
"right": { "kind": "retained-evidence", "evidence_id": "ev_<right-digest>" },
|
||||
"scope": "both"
|
||||
}
|
||||
```
|
||||
|
||||
The references above are MCP-only; put complete Evidence objects in the CLI
|
||||
input. Resolve summary delivery to its retained complete parent before comparing.
|
||||
Results retain both input Evidence IDs, artifact paths/digests/lengths and every
|
||||
selected occurrence. Unique names are added, removed, changed or unchanged;
|
||||
duplicate names and unsupported signatures are unknown. Incomplete or historical
|
||||
inventories never establish absence. Imports group by module/name, exports by
|
||||
name, so a change of kind is visible. Output ordering is deterministic.
|
||||
|
||||
Static type equality does not establish runtime compatibility, ABI compatibility
|
||||
or linking success. Comparison covers core modules; WebAssembly components and
|
||||
canonical ABI relationships belong to their separate inspection workflow.
|
||||
|
||||
After building, run `REA_WABT_BIN_DIRECTORY=/absolute/wabt/bin node
|
||||
scripts/verify-wasm-interface-comparison.mjs` for original-module comparison
|
||||
through the compiled CLI and stdio MCP. It uses owned fixtures and WABT 1.0.42;
|
||||
no WASM module is instantiated or executed.
|
||||
|
||||
@@ -29,10 +29,8 @@ admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited
|
||||
to native PE and raw NMOS 6502 images. For x64 PE
|
||||
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
|
||||
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
|
||||
database mutation: MethodTable relationships, virtual slots and frozen strings.
|
||||
`analyze_function` decompiles slot targets. Database reads of the rehydrated
|
||||
bytes (`read_bytes`, `search_strings`) return nothing on this host. See
|
||||
[NativeAOT](ghidra-nativeaot.md).
|
||||
database mutation. A real Windows-host NativeAOT Ghidra run is still required
|
||||
to establish the complete host workflow.
|
||||
|
||||
This boundary does not establish general Windows feature parity. Ghidra GUI
|
||||
state, annotations, other target architectures and formats, and Hopper-only
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "6.5.0",
|
||||
"version": "6.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rea-agents",
|
||||
"version": "6.5.0",
|
||||
"version": "6.4.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "8.0.4",
|
||||
|
||||
+1
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"mcpName": "io.github.morluto/rea",
|
||||
"version": "6.5.0",
|
||||
"version": "6.4.0",
|
||||
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -266,11 +266,9 @@
|
||||
"verify:browser:source-maps": "npm run build:cached && node scripts/verify-browser-source-maps.mjs",
|
||||
"verify:browser:runtime": "npm run build:cached && node scripts/verify-browser-runtime.mjs",
|
||||
"verify:web:network-captures": "npm run build:cached && node scripts/verify-web-network-captures.mjs",
|
||||
"verify:web:http3": "npm run build:cached && node scripts/verify-http3-network-captures.mjs",
|
||||
"verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs",
|
||||
"verify:go:binary": "npm run build:cached && node scripts/verify-go-binary.mjs",
|
||||
"verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs",
|
||||
"verify:wasm-interface-comparison": "npm run build:cached && node scripts/verify-wasm-interface-comparison.mjs",
|
||||
"verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs",
|
||||
"verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs",
|
||||
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Exercised runtime graph: CPython 3.14.7, Linux x86_64 guest.
|
||||
aioquic==1.2.0
|
||||
attrs==26.1.0
|
||||
certifi==2026.7.22
|
||||
cffi==2.1.1
|
||||
cryptography==50.0.2
|
||||
pycparser==3.11
|
||||
pylsqpack==0.3.24
|
||||
pyOpenSSL==26.4.0
|
||||
service-identity==26.1.0
|
||||
@@ -1,416 +0,0 @@
|
||||
"""Real loopback HTTP/3 laboratory using aioquic and caller-supplied mitmdump.
|
||||
|
||||
Run inside a disposable Linux guest. All native records are written by
|
||||
mitmproxy's real save addon, not constructed fixtures. Endpoint stream IDs are
|
||||
observations scoped to that endpoint; no MITM stream mapping is claimed.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import ssl
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import aioquic
|
||||
from aioquic.asyncio import QuicConnectionProtocol, connect, serve
|
||||
from aioquic.h3.connection import H3Connection
|
||||
from aioquic.h3.events import DataReceived, HeadersReceived
|
||||
from aioquic.quic.configuration import QuicConfiguration
|
||||
from aioquic.quic.events import ConnectionTerminated, HandshakeCompleted, StreamReset
|
||||
from aioquic.quic.packet import QuicProtocolVersion
|
||||
|
||||
|
||||
def binary(value):
|
||||
return {"base64": base64.b64encode(value).decode("ascii"), "bytes": len(value),
|
||||
"sha256": hashlib.sha256(value).hexdigest()}
|
||||
|
||||
|
||||
def pairs(headers):
|
||||
return [[binary(name), binary(value)] for name, value in headers]
|
||||
|
||||
|
||||
class Journal:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.events = []
|
||||
self.requests = {}
|
||||
self.responses = {}
|
||||
self.tasks = set()
|
||||
self.children = []
|
||||
self.writers = []
|
||||
|
||||
def emit(self, event, **data):
|
||||
value = {"event": event, "monotonic": time.monotonic(), **data}
|
||||
self.events.append(value)
|
||||
with (self.root / "endpoints.jsonl").open("a") as handle:
|
||||
handle.write(json.dumps(value) + "\n")
|
||||
|
||||
def task(self, coroutine):
|
||||
task = asyncio.create_task(coroutine)
|
||||
self.tasks.add(task)
|
||||
return task
|
||||
|
||||
|
||||
|
||||
class Endpoint(QuicConnectionProtocol):
|
||||
def __init__(self, *args, journal, endpoint, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.journal, self.endpoint = journal, endpoint
|
||||
self.http = H3Connection(self._quic)
|
||||
self.messages = {}
|
||||
self.waiters = {}
|
||||
self.concurrent = []
|
||||
self.identity = f"{endpoint}-{len(journal.events)}"
|
||||
|
||||
def quic_event_received(self, event):
|
||||
if isinstance(event, HandshakeCompleted):
|
||||
# This is the negotiated runtime value in pinned aioquic 1.2.0,
|
||||
# not configuration.supported_versions or mitmproxy's QUICv1 label.
|
||||
self.journal.emit("handshake", endpoint=self.endpoint, connection=self.identity,
|
||||
alpn=event.alpn_protocol, quic_version=int(self._quic._version))
|
||||
if isinstance(event, (StreamReset, ConnectionTerminated)):
|
||||
self.journal.emit(type(event).__name__, endpoint=self.endpoint,
|
||||
connection=self.identity, error_code=event.error_code,
|
||||
stream_id=getattr(event, "stream_id", None),
|
||||
reason=getattr(event, "reason_phrase", None))
|
||||
targets = self.waiters.values() if isinstance(event, ConnectionTerminated) else [self.waiters.get(event.stream_id)]
|
||||
for waiter in targets:
|
||||
if waiter is not None and not waiter.done():
|
||||
waiter.set_exception(ConnectionError(type(event).__name__))
|
||||
for item in self.http.handle_event(event):
|
||||
if isinstance(item, (HeadersReceived, DataReceived)):
|
||||
message = self.messages.setdefault(item.stream_id, {"headers": [], "body": bytearray()})
|
||||
if isinstance(item, HeadersReceived):
|
||||
message["headers"].extend(item.headers)
|
||||
else:
|
||||
message["body"].extend(item.data)
|
||||
if item.stream_ended:
|
||||
self.completed(item.stream_id, message)
|
||||
|
||||
def completed(self, stream_id, message):
|
||||
body = bytes(message["body"])
|
||||
if self.endpoint == "client":
|
||||
self.journal.emit("response-received", endpoint="client", connection=self.identity,
|
||||
stream_id=stream_id, headers=pairs(message["headers"]), body=binary(body))
|
||||
self.waiters[stream_id].set_result(message)
|
||||
return
|
||||
headers = dict(message["headers"])
|
||||
case = headers.get(b"x-lab-case", b"curl").decode("ascii")
|
||||
self.journal.requests[case] = {"headers": pairs(message["headers"]), "body": binary(body)}
|
||||
self.journal.emit("request-received", endpoint="origin", connection=self.identity,
|
||||
stream_id=stream_id, case=case, **self.journal.requests[case])
|
||||
if case.startswith("parallel-"):
|
||||
self.concurrent.append((case, stream_id, body))
|
||||
if len(self.concurrent) == 3:
|
||||
self.journal.task(self.answer_parallel())
|
||||
else:
|
||||
self.answer(case, stream_id, b"\x00\xfeanswer:" + case.encode() + b":" + body[::-1])
|
||||
|
||||
async def answer_parallel(self):
|
||||
for case, stream_id, body in reversed(self.concurrent):
|
||||
self.answer(case, stream_id, b"\x00\xfeanswer:" + case.encode() + b":" + body[::-1])
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
def answer(self, case, stream_id, body):
|
||||
headers = [(b":status", b"200"), (b"content-type", b"application/octet-stream"),
|
||||
(b"content-length", str(len(body)).encode()),
|
||||
(b"x-lab-case", case.encode()), (b"x-duplicate", b"one"), (b"x-duplicate", b"two")]
|
||||
self.journal.responses[case] = {"headers": pairs(headers), "body": binary(body), "finished": True}
|
||||
self.journal.emit("response-sent", endpoint="origin", connection=self.identity,
|
||||
stream_id=stream_id, case=case, **self.journal.responses[case])
|
||||
self.http.send_headers(stream_id, headers)
|
||||
self.http.send_data(stream_id, body, end_stream=True)
|
||||
self.transmit()
|
||||
|
||||
def request(self, case, body):
|
||||
stream_id = self._quic.get_next_available_stream_id()
|
||||
headers = [(b":method", b"POST"), (b":scheme", b"https"),
|
||||
(b":authority", b"lab.test:18443"), (b":path", b"/same?token=ordinary&x=%2F"),
|
||||
(b"x-lab-case", case.encode()), (b"content-type", b"application/octet-stream"),
|
||||
(b"content-length", str(len(body)).encode()),
|
||||
(b"x-duplicate", b"first"), (b"x-duplicate", b"second")]
|
||||
self.waiters[stream_id] = asyncio.get_running_loop().create_future()
|
||||
self.journal.emit("request-sent", endpoint="client", connection=self.identity,
|
||||
stream_id=stream_id, case=case, headers=pairs(headers), body=binary(body), finished=True)
|
||||
self.http.send_headers(stream_id, headers)
|
||||
self.http.send_data(stream_id, body, end_stream=True)
|
||||
self.transmit()
|
||||
return stream_id, self.waiters[stream_id]
|
||||
|
||||
|
||||
|
||||
class Proxy:
|
||||
def __init__(self, args, root, name, http3=True):
|
||||
self.args, self.root, self.name, self.http3 = args, root, name, http3
|
||||
self.process = None
|
||||
self.log = None
|
||||
|
||||
async def start(self):
|
||||
self.log = (self.root / f"{self.name}.proxy.log").open("wb")
|
||||
command = [self.args.mitmdump, "--mode", "reverse:https://lab.test:19443@127.0.0.1:18443",
|
||||
"--set", f"confdir={self.root / 'proxy-config'}", "--set", "connection_strategy=lazy",
|
||||
"--set", "keep_host_header=true", "--set", "store_streamed_bodies=false",
|
||||
"--set", f"rea_lab_log={self.root / (self.name + '.producer.jsonl')}",
|
||||
"-w", str(self.root / (self.name + '.mitm')),
|
||||
"-s", str(Path(__file__).with_name("observe-mitmproxy-http3.py"))]
|
||||
command.extend(["--set", f"ssl_verify_upstream_trusted_ca={self.root / 'origin-ca.pem'}"])
|
||||
if not self.http3:
|
||||
command.extend(["--set", "http3=false"])
|
||||
self.process = subprocess.Popen(command, stdout=self.log, stderr=subprocess.STDOUT,
|
||||
start_new_session=True)
|
||||
lease = {"runId": os.environ["REA_PROCESS_RUN_ID"], "leaderPid": self.process.pid,
|
||||
"processGroupId": self.process.pid}
|
||||
with (self.root / "process-groups.jsonl").open("a") as handle:
|
||||
handle.write(json.dumps(lease) + "\n")
|
||||
deadline = time.monotonic() + 15
|
||||
journal = self.root / f"{self.name}.producer.jsonl"
|
||||
while time.monotonic() < deadline:
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError(f"Proxy exited: {self.log.name}")
|
||||
if journal.exists() and '"event": "ready"' in journal.read_text():
|
||||
return self
|
||||
await asyncio.sleep(0.05)
|
||||
raise TimeoutError(f"Proxy readiness: {self.log.name}")
|
||||
|
||||
async def stop(self):
|
||||
if self.process is not None and self.process.poll() is None:
|
||||
# The standalone mitmdump distribution has a bootloader parent.
|
||||
# Signal its owned session together, including the interpreter child.
|
||||
os.killpg(self.process.pid, signal.SIGINT)
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.to_thread(self.process.wait), 10)
|
||||
except TimeoutError:
|
||||
os.killpg(self.process.pid, signal.SIGKILL)
|
||||
await asyncio.to_thread(self.process.wait)
|
||||
raise
|
||||
if self.process is not None:
|
||||
for _ in range(40):
|
||||
if group_is_empty(self.process.pid):
|
||||
return
|
||||
await asyncio.sleep(0.05)
|
||||
raise RuntimeError(f"Owned proxy group still live: {self.process.pid}")
|
||||
|
||||
async def close_log(self):
|
||||
if self.log is not None:
|
||||
self.log.close()
|
||||
|
||||
|
||||
def certificates(root):
|
||||
for name in ["origin"]:
|
||||
subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", str(root / f"{name}-key.pem"), "-out", str(root / f"{name}-ca.pem"),
|
||||
"-days", "2", "-subj", "/CN=lab.test", "-addext", "subjectAltName=DNS:lab.test",
|
||||
"-addext", "basicConstraints=critical,CA:TRUE"], check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def client_config(root):
|
||||
config = QuicConfiguration(is_client=True, alpn_protocols=["h3"], supported_versions=[QuicProtocolVersion.VERSION_1],
|
||||
server_name="lab.test", idle_timeout=3)
|
||||
config.load_verify_locations(cafile=str(root / "proxy-config/mitmproxy-ca-cert.pem"))
|
||||
return config
|
||||
|
||||
|
||||
def connection(root, journal):
|
||||
return connect("127.0.0.1", 18443, configuration=client_config(root),
|
||||
create_protocol=lambda *a, **kw: Endpoint(*a, journal=journal, endpoint="client", **kw))
|
||||
|
||||
|
||||
async def curl_request(root, journal, name, version, path="/curl", maximum=8):
|
||||
child = await asyncio.create_subprocess_exec("curl", version, "--silent", "--show-error",
|
||||
"--max-time", str(maximum), "--cacert", str(root / "proxy-config/mitmproxy-ca-cert.pem"),
|
||||
"--resolve", "lab.test:18443:127.0.0.1", "--noproxy", "*", "-H", f"x-lab-case: {name}",
|
||||
"--output", str(root / (name + ".body")), "--dump-header", str(root / (name + ".headers")),
|
||||
"--write-out", "%{json}", "https://lab.test:18443" + path,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
journal.children.append(child)
|
||||
out, err = await child.communicate()
|
||||
(root / (name + ".json")).write_bytes(out)
|
||||
(root / (name + ".stderr")).write_bytes(err)
|
||||
return child.returncode, json.loads(out)
|
||||
|
||||
|
||||
async def tcp_origin(reader, writer, journal):
|
||||
journal.writers.append(writer)
|
||||
try:
|
||||
head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 5)
|
||||
case = "tcp"
|
||||
for line in head.split(b"\r\n")[1:]:
|
||||
if line.lower().startswith(b"x-lab-case:"):
|
||||
case = line.split(b":", 1)[1].strip().decode("ascii")
|
||||
body = b"\x00\xfftcp-origin"
|
||||
journal.emit("tcp-request", case=case, alpn=writer.get_extra_info("ssl_object").selected_alpn_protocol(),
|
||||
headers=binary(head), body=binary(body))
|
||||
writer.write(b"HTTP/1.1 200 OK\r\ncontent-type: application/octet-stream\r\ncontent-length: " +
|
||||
str(len(body)).encode() + b"\r\nconnection: close\r\n\r\n" + body)
|
||||
await writer.drain()
|
||||
finally:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
|
||||
def group_is_empty(group):
|
||||
for entry in Path("/proc").iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
fields = (entry / "stat").read_text().rsplit(")", 1)[1].split()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if fields[0] != "Z" and int(fields[2]) == group:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def attempt_all(actions):
|
||||
failures = []
|
||||
for name, action in actions:
|
||||
try:
|
||||
await asyncio.wait_for(action(), 12)
|
||||
except BaseException as error:
|
||||
failures.append((name, error))
|
||||
return failures
|
||||
|
||||
|
||||
async def close_resource(resource):
|
||||
resource.close()
|
||||
|
||||
|
||||
async def finish_child(child):
|
||||
if child.returncode is None:
|
||||
child.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(child.wait(), 3)
|
||||
except TimeoutError:
|
||||
child.kill()
|
||||
await child.wait()
|
||||
|
||||
|
||||
async def finish_tasks(journal):
|
||||
for task in journal.tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
results = await asyncio.gather(*journal.tasks, return_exceptions=True)
|
||||
failures = [error for error in results if isinstance(error, BaseException)
|
||||
and not isinstance(error, asyncio.CancelledError)]
|
||||
if failures:
|
||||
raise BaseExceptionGroup("Endpoint tasks failed", failures)
|
||||
|
||||
|
||||
async def run(args):
|
||||
if aioquic.__version__ != "1.2.0":
|
||||
raise RuntimeError("This laboratory requires aioquic 1.2.0")
|
||||
root = Path(args.output).resolve()
|
||||
root.mkdir(mode=0o700)
|
||||
os.umask(0o077)
|
||||
journal = Journal(root)
|
||||
loop = asyncio.get_running_loop()
|
||||
asynchronous_errors = []
|
||||
def unexpected_exception(active_loop, context):
|
||||
asynchronous_errors.append(context.get("message", "Unexpected asynchronous error"))
|
||||
active_loop.default_exception_handler(context)
|
||||
loop.set_exception_handler(unexpected_exception)
|
||||
certificates(root)
|
||||
server = tcp_server = None
|
||||
proxies = []
|
||||
scenarios = []
|
||||
primary_error = None
|
||||
cleanup_errors = []
|
||||
try:
|
||||
config = QuicConfiguration(is_client=False, alpn_protocols=["h3"],
|
||||
supported_versions=[QuicProtocolVersion.VERSION_1], idle_timeout=3)
|
||||
config.load_cert_chain(str(root / "origin-ca.pem"), str(root / "origin-key.pem"))
|
||||
server = await serve("127.0.0.1", 19443, configuration=config,
|
||||
create_protocol=lambda *a, **kw: Endpoint(*a, journal=journal, endpoint="origin", **kw))
|
||||
tls_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
tls_context.load_cert_chain(str(root / "origin-ca.pem"), str(root / "origin-key.pem"))
|
||||
tls_context.set_alpn_protocols(["http/1.1"])
|
||||
tcp_server = await asyncio.start_server(lambda r, w: journal.task(tcp_origin(r, w, journal)),
|
||||
"127.0.0.1", 19443, ssl=tls_context)
|
||||
proxy = Proxy(args, root, "normal")
|
||||
proxies.append(proxy)
|
||||
await proxy.start()
|
||||
async with connection(root, journal) as client:
|
||||
requests = [client.request(f"parallel-{i}", bytes([0, 255, i]) + bytes(range(256))) for i in range(3)]
|
||||
await asyncio.wait_for(asyncio.gather(*(future for _, future in requests)), 10)
|
||||
# cURL supplies an independently implemented HTTP/3 smoke case.
|
||||
code, result = await curl_request(root, journal, "curl", "--http3-only")
|
||||
if code != 0:
|
||||
raise RuntimeError("HTTP/3 cURL failed; inspect curl.stderr")
|
||||
if result["http_version"] != "3":
|
||||
raise AssertionError("cURL silently downgraded")
|
||||
await proxy.stop()
|
||||
scenarios.append({"name": "normal", "capture": "normal.mitm"})
|
||||
|
||||
proxy = Proxy(args, root, "fallback", http3=False)
|
||||
proxies.append(proxy)
|
||||
await proxy.start()
|
||||
code, result = await curl_request(root, journal, "fallback", "--http3", path="/fallback")
|
||||
if code != 0 or result["http_version"] != "2":
|
||||
raise AssertionError(f"Fallback control: {result}")
|
||||
await proxy.stop()
|
||||
scenarios.append({"name": "fallback", "capture": "fallback.mitm"})
|
||||
|
||||
except BaseException as error:
|
||||
primary_error = error
|
||||
finally:
|
||||
actions = []
|
||||
for proxy in proxies:
|
||||
actions.extend([(f"proxy-{proxy.name}", proxy.stop),
|
||||
(f"proxy-log-{proxy.name}", proxy.close_log)])
|
||||
if server is not None:
|
||||
actions.append(("quic-server", lambda: close_resource(server)))
|
||||
if tcp_server is not None:
|
||||
actions.extend([("tcp-listener-close", lambda: close_resource(tcp_server)),
|
||||
("tcp-listener-wait", tcp_server.wait_closed)])
|
||||
for index, writer in enumerate(journal.writers):
|
||||
actions.extend([(f"tcp-writer-close-{index}", lambda w=writer: close_resource(w)),
|
||||
(f"tcp-writer-wait-{index}", writer.wait_closed)])
|
||||
actions.append(("endpoint-tasks", lambda: finish_tasks(journal)))
|
||||
for index, child in enumerate(journal.children):
|
||||
actions.append((f"curl-child-{index}", lambda c=child: finish_child(c)))
|
||||
cleanup_errors = await attempt_all(actions)
|
||||
# This runs after every close attempt, including failed setup.
|
||||
groups = []
|
||||
async def audit_groups():
|
||||
for proxy in proxies:
|
||||
if proxy.process is not None:
|
||||
groups.append({"group": proxy.process.pid, "empty": group_is_empty(proxy.process.pid)})
|
||||
if any(not item["empty"] for item in groups):
|
||||
raise RuntimeError("Proxy groups remain live")
|
||||
cleanup_errors.extend(await attempt_all([("process-groups", audit_groups)]))
|
||||
try:
|
||||
(root / "producer-cleanup.json").write_text(json.dumps({
|
||||
"primary_error": None if primary_error is None else str(primary_error),
|
||||
"cleanup_errors": [{"action": name, "type": type(error).__name__, "message": str(error)}
|
||||
for name, error in cleanup_errors], "groups": groups,
|
||||
"asynchronous_errors": asynchronous_errors}, indent=2))
|
||||
except BaseException as error:
|
||||
cleanup_errors.append(("cleanup-report", error))
|
||||
if primary_error is not None:
|
||||
for name, error in cleanup_errors:
|
||||
primary_error.add_note(f"Cleanup {name}: {error}")
|
||||
raise primary_error
|
||||
if cleanup_errors:
|
||||
raise BaseExceptionGroup("HTTP/3 producer cleanup failed", [error for _, error in cleanup_errors])
|
||||
if asynchronous_errors:
|
||||
raise RuntimeError(f"Unexpected asynchronous errors: {asynchronous_errors}")
|
||||
(root / "manifest.json").write_text(json.dumps({"profile": "http3-minimal-v1", "aioquic": aioquic.__version__,
|
||||
"scenarios": scenarios, "requests": journal.requests, "responses": journal.responses,
|
||||
"events": journal.events, "limitations": [
|
||||
"Endpoint stream IDs are scoped observations, not mitmproxy serialized IDs or mappings.",
|
||||
"The fixture reads negotiated _version from pinned aioquic 1.2.0.",
|
||||
"Only QUIC v1, Linux reverse proxy and the selected endpoints are exercised."]}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--mitmdump", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
asyncio.run(run(parser.parse_args()))
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Lifecycle regressions; these tests do not establish HTTP/3 provider support."""
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location("http3_lab", Path(__file__).with_name("http3-lab.py"))
|
||||
lab = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(lab)
|
||||
|
||||
|
||||
class CleanupTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_all_actions_run_after_multiple_close_failures(self):
|
||||
attempted = []
|
||||
async def fail():
|
||||
attempted.append("failed-close")
|
||||
raise RuntimeError("close failed")
|
||||
async def audit():
|
||||
attempted.append("audit")
|
||||
failures = await lab.attempt_all([("one", fail), ("two", fail), ("audit", audit)])
|
||||
self.assertEqual(attempted, ["failed-close", "failed-close", "audit"])
|
||||
self.assertEqual([name for name, _ in failures], ["one", "two"])
|
||||
|
||||
async def test_partial_setup_keeps_primary_error_and_audits_after_close_failure(self):
|
||||
original_serve = lab.serve
|
||||
closed = []
|
||||
class ClosingServer:
|
||||
def __init__(self, server):
|
||||
self.server = server
|
||||
def close(self):
|
||||
self.server.close()
|
||||
closed.append(True)
|
||||
raise RuntimeError("injected close failure")
|
||||
async def serve(*args, **kwargs):
|
||||
return ClosingServer(await original_serve(*args, **kwargs))
|
||||
async def fail_tcp(*args, **kwargs):
|
||||
raise RuntimeError("injected primary setup failure")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "capture"
|
||||
with patch.object(lab, "serve", serve), patch.object(lab.asyncio, "start_server", fail_tcp):
|
||||
with self.assertRaisesRegex(RuntimeError, "injected primary setup failure") as caught:
|
||||
await lab.run(SimpleNamespace(output=str(root), mitmdump="unused"))
|
||||
self.assertEqual(closed, [True])
|
||||
self.assertIn("injected close failure", " ".join(caught.exception.__notes__))
|
||||
report = json.loads((root / "producer-cleanup.json").read_text())
|
||||
self.assertEqual(report["primary_error"], "injected primary setup failure")
|
||||
self.assertEqual(report["cleanup_errors"][0]["action"], "quic-server")
|
||||
self.assertEqual(report["groups"], [])
|
||||
self.assertFalse((root / "manifest.json").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Test-only observation of real flows; never synthesizes flow or stream IDs."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from mitmproxy import ctx
|
||||
|
||||
|
||||
def bytes_view(value):
|
||||
if value is None:
|
||||
return None
|
||||
return {"base64": base64.b64encode(value).decode("ascii"),
|
||||
"bytes": len(value), "sha256": hashlib.sha256(value).hexdigest()}
|
||||
|
||||
|
||||
class Observer:
|
||||
def load(self, loader):
|
||||
loader.add_option("rea_lab_log", str, "", "HTTP/3 verifier observations")
|
||||
|
||||
def emit(self, event, **data):
|
||||
with Path(ctx.options.rea_lab_log).open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps({"event": event, **data}) + "\n")
|
||||
|
||||
def running(self):
|
||||
self.emit("ready")
|
||||
|
||||
def connection(self, conn):
|
||||
return {"id": conn.id, "transport_protocol": conn.transport_protocol,
|
||||
"alpn": bytes_view(conn.alpn), "tls_version": conn.tls_version,
|
||||
"timestamp_tls_setup": conn.timestamp_tls_setup,
|
||||
"error": conn.error}
|
||||
|
||||
def tls_established_client(self, data):
|
||||
self.emit("tls-established-client", connection=self.connection(data.conn))
|
||||
|
||||
def tls_established_server(self, data):
|
||||
self.emit("tls-established-server", connection=self.connection(data.conn))
|
||||
|
||||
def flow(self, event, flow):
|
||||
def message(message):
|
||||
if message is None:
|
||||
return None
|
||||
return {"http_version": message.http_version,
|
||||
"content": bytes_view(message.raw_content),
|
||||
"timestamp_end": message.timestamp_end}
|
||||
self.emit(event, id=flow.id, case=flow.request.headers.get("x-lab-case", "curl"),
|
||||
request=message(flow.request), response=message(flow.response),
|
||||
error=None if flow.error is None else flow.error.msg,
|
||||
client_conn=self.connection(flow.client_conn),
|
||||
server_conn=self.connection(flow.server_conn))
|
||||
|
||||
def request(self, flow):
|
||||
self.flow("request", flow)
|
||||
|
||||
def response(self, flow):
|
||||
self.flow("response", flow)
|
||||
|
||||
def error(self, flow):
|
||||
self.flow("error", flow)
|
||||
|
||||
|
||||
addons = [Observer()]
|
||||
@@ -8,10 +8,7 @@ import { spawnOwnedProviderProcess } from "../../dist/process/ProviderProcess.js
|
||||
import { createSystemProcessOwnershipHost } from "../../dist/process/ProcessOwnershipObservation.js";
|
||||
import { liveProcesses } from "../../dist/process/ProcessOwnershipProcessTree.js";
|
||||
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
|
||||
import {
|
||||
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
waitForBrowserDevtoolsPort,
|
||||
} from "../../dist/browser/BrowserProcessStartup.js";
|
||||
import { waitForBrowserDevtoolsPort } from "../../dist/browser/BrowserProcessStartup.js";
|
||||
|
||||
/** Launch the synthetic fixture browser and await CDP in its own verified process group. */
|
||||
export async function startRuntimeFixtureBrowser(executable, origin) {
|
||||
@@ -53,7 +50,7 @@ export async function startRuntimeFixtureBrowser(executable, origin) {
|
||||
stderr: () => stderr,
|
||||
// Match the broader real-browser verifier's allowance for cold Chrome
|
||||
// startup on shared runners; early process failures still reject at once.
|
||||
timeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
return {
|
||||
port,
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
listBrowserTargetsInputSchema,
|
||||
} from "../../dist/domain/browserObservation.js";
|
||||
import { compareWebCapturesInputSchema } from "../../dist/domain/webCaptureDiffSchemas.js";
|
||||
import { artifactCliEvidence } from "./artifact-e2e.mjs";
|
||||
|
||||
/** Verify browser-socket discovery and inspection against HTTP discovery. */
|
||||
export async function verifyBrowserSocketTransport({
|
||||
provider,
|
||||
profile,
|
||||
endpoint,
|
||||
target,
|
||||
origin,
|
||||
}) {
|
||||
const [port, path] = (
|
||||
await readFile(join(profile, "DevToolsActivePort"), "utf8")
|
||||
).split("\n");
|
||||
const socket = `ws://127.0.0.1:${port}${path}`;
|
||||
const version = await (await fetch(`${endpoint}/json/version`)).json();
|
||||
assert.equal(
|
||||
new URL(version.webSocketDebuggerUrl).pathname,
|
||||
path,
|
||||
"DevToolsActivePort and /json/version named different browser sockets",
|
||||
);
|
||||
const list = async (cdpEndpoint) => {
|
||||
const listed = await provider.listTargets(
|
||||
listBrowserTargetsInputSchema.parse({
|
||||
cdp_endpoint: cdpEndpoint,
|
||||
allowed_origins: [origin],
|
||||
}),
|
||||
);
|
||||
if (!listed.ok) throw listed.error;
|
||||
return listed.value;
|
||||
};
|
||||
const throughHttp = await list(endpoint);
|
||||
const throughSocket = await list(socket);
|
||||
// Target.getTargets reports attachment state that /json/list may omit.
|
||||
const withoutAttachment = (targets) =>
|
||||
targets.map(({ attached: _attached, ...rest }) => rest);
|
||||
assert.ok(throughSocket.targets.length > 0, "Browser socket listed no page");
|
||||
assert.deepEqual(
|
||||
withoutAttachment(throughSocket.targets),
|
||||
withoutAttachment(throughHttp.targets),
|
||||
);
|
||||
assert.ok(
|
||||
throughSocket.targets.every(
|
||||
({ attached }) => typeof attached === "boolean",
|
||||
),
|
||||
"Browser socket omitted target attachment state",
|
||||
);
|
||||
assert.equal(throughSocket.browser.product, throughHttp.browser.product);
|
||||
|
||||
const evidence = await artifactCliEvidence("inspect-web-page", socket, [
|
||||
target,
|
||||
"--allowed-origins",
|
||||
origin,
|
||||
"--observation-ms",
|
||||
"100",
|
||||
]);
|
||||
assert.equal(evidence.normalized_result.target.target_id, target);
|
||||
assert.ok(
|
||||
evidence.normalized_result.dom.nodes.length > 0,
|
||||
"Browser-socket inspection returned no DOM nodes",
|
||||
);
|
||||
assert.equal(
|
||||
evidence.parameters.cdp_endpoint,
|
||||
`ws://127.0.0.1:${port}/devtools/browser/[REDACTED]`,
|
||||
);
|
||||
assert.ok(
|
||||
!JSON.stringify(evidence).includes(path),
|
||||
"Browser-socket Evidence retained the socket identifier",
|
||||
);
|
||||
const throughHttpInspection = await provider.inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [origin],
|
||||
target_id: target,
|
||||
observation_ms: 100,
|
||||
}),
|
||||
);
|
||||
if (!throughHttpInspection.ok) throw throughHttpInspection.error;
|
||||
const compared = await provider.compareCaptures(
|
||||
compareWebCapturesInputSchema.parse({
|
||||
before: { inspection: throughHttpInspection.value },
|
||||
after: { inspection: evidence.normalized_result },
|
||||
}),
|
||||
);
|
||||
if (!compared.ok) throw compared.error;
|
||||
assert.equal(compared.value.dimensions.dom_structure.status, "unchanged");
|
||||
assert.equal(compared.value.dimensions.scripts.status, "unchanged");
|
||||
return {
|
||||
targets: throughSocket.targets.length,
|
||||
revision: throughSocket.browser.revision,
|
||||
};
|
||||
}
|
||||
@@ -213,9 +213,7 @@ export const providerCatalog = (sources) => {
|
||||
},
|
||||
{
|
||||
identity: sources.wasmProvider.WABT_PROVIDER_IDENTITY,
|
||||
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS.filter(
|
||||
({ kind }) => kind === "artifact-provider",
|
||||
),
|
||||
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export function assertCleanLinuxHopperRuntime(
|
||||
launcherPaths?: readonly string[],
|
||||
procRoot?: string,
|
||||
): Promise<void>;
|
||||
|
||||
@@ -16,7 +16,6 @@ export const assertCleanLinuxHopperRuntime = async (
|
||||
? []
|
||||
: [process.env.HOPPER_LAUNCHER_PATH]),
|
||||
],
|
||||
procRoot = "/proc",
|
||||
) => {
|
||||
const { stdout } = await execFileAsync("ps", ["-ax", "-o", "command="]);
|
||||
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(stdout))
|
||||
@@ -33,17 +32,17 @@ export const assertCleanLinuxHopperRuntime = async (
|
||||
// /proc exposes the executed file even when argv[0] names a removed symlink.
|
||||
// Compare file identity, not a display path or a process name.
|
||||
let inspectionError;
|
||||
for (const entry of await readdir(procRoot, { withFileTypes: true })) {
|
||||
for (const entry of await readdir("/proc", { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue;
|
||||
let executable;
|
||||
try {
|
||||
executable = await stat(`${procRoot}/${entry.name}/exe`, { bigint: true });
|
||||
executable = await stat(`/proc/${entry.name}/exe`, { bigint: true });
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT" || error?.code === "ESRCH") continue;
|
||||
// Other users' executables may be unreadable. The setup lane launches
|
||||
// providers as the caller; unreadable caller-owned processes fail closed.
|
||||
if (error?.code === "EACCES") {
|
||||
const owner = await stat(`${procRoot}/${entry.name}`).catch((cause) => {
|
||||
const owner = await stat(`/proc/${entry.name}`).catch((cause) => {
|
||||
if (cause?.code === "ENOENT") return undefined;
|
||||
throw cause;
|
||||
});
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { isUtf8 } from "node:buffer";
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
// The native decoder displays strict UTF-8 and retains invalid bytes as null text.
|
||||
const textOf = (expected) => {
|
||||
const value = Buffer.from(expected.base64, "base64");
|
||||
return isUtf8(value) ? value.toString("utf8") : null;
|
||||
};
|
||||
const field = (record, pointer) =>
|
||||
record.binary_fields.find((item) => item.pointer === pointer);
|
||||
const bytes = (record, pointer) => {
|
||||
const value = field(record, pointer);
|
||||
assert.equal(
|
||||
value?.state,
|
||||
"retained",
|
||||
`Missing retained bytes at ${pointer}`,
|
||||
);
|
||||
const decoded = Buffer.from(value.content_base64, "base64");
|
||||
assert.equal(value.bytes, decoded.length);
|
||||
assert.equal(value.sha256, digest(decoded));
|
||||
return decoded;
|
||||
};
|
||||
const binaryEqual = (record, pointer, expected) => {
|
||||
const decoded = bytes(record, pointer);
|
||||
assert.deepEqual(decoded, Buffer.from(expected.base64, "base64"), pointer);
|
||||
assert.equal(field(record, pointer).sha256, expected.sha256);
|
||||
assert.equal(field(record, pointer).bytes, expected.bytes);
|
||||
};
|
||||
const caseOf = (record) =>
|
||||
record.reported.request.headers.find(
|
||||
([name]) => name === "x-lab-case",
|
||||
)?.[1] ?? "curl";
|
||||
const byCase = (value, name) => {
|
||||
const selected = value.records.filter((record) => caseOf(record) === name);
|
||||
assert.equal(
|
||||
selected.length,
|
||||
1,
|
||||
`Expected exactly one producer record for ${name}`,
|
||||
);
|
||||
return selected[0];
|
||||
};
|
||||
const connectionH3 = (record, side) => {
|
||||
const conn = record.reported[side];
|
||||
assert.equal(conn.transport_protocol, "udp");
|
||||
assert.equal(bytes(record, `/${side}/alpn`).toString(), "h3");
|
||||
assert.equal(conn.alpn, "h3", `${side}.alpn readable value`);
|
||||
assert.equal(typeof conn.timestamp_tls_setup, "number");
|
||||
};
|
||||
|
||||
function assertHeaders(record, side, headers) {
|
||||
const regular = headers.filter(
|
||||
([name]) => !Buffer.from(name.base64, "base64").toString().startsWith(":"),
|
||||
);
|
||||
assert.equal(record.reported[side].headers.length, regular.length);
|
||||
for (const [index, pair] of regular.entries()) {
|
||||
binaryEqual(record, `/${side}/headers/${index}/0`, pair[0]);
|
||||
binaryEqual(record, `/${side}/headers/${index}/1`, pair[1]);
|
||||
assert.deepEqual(
|
||||
record.reported[side].headers[index],
|
||||
pair.map(textOf),
|
||||
`${side}.headers[${index}] readable values`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertEndpointHeaders(actual, expected) {
|
||||
// The proxy rebuilds pseudo fields from its HTTP state. Compare their values
|
||||
// by name while retaining the exact order and duplicates of regular headers.
|
||||
const partition = (headers) => {
|
||||
const pseudo = new Map();
|
||||
const regular = [];
|
||||
for (const pair of headers) {
|
||||
const name = Buffer.from(pair[0].base64, "base64").toString();
|
||||
if (name.startsWith(":")) {
|
||||
assert.equal(pseudo.has(name), false);
|
||||
pseudo.set(name, pair);
|
||||
} else regular.push(pair);
|
||||
}
|
||||
return { pseudo, regular };
|
||||
};
|
||||
assert.deepEqual(partition(actual), partition(expected));
|
||||
}
|
||||
|
||||
export function requireHttp3(record) {
|
||||
for (const side of ["request", "response"])
|
||||
assert.equal(record.reported[side].http_version, "HTTP/3");
|
||||
connectionH3(record, "client_conn");
|
||||
connectionH3(record, "server_conn");
|
||||
}
|
||||
|
||||
export function verifyNormalRecords(normal, manifest, finalProducer) {
|
||||
for (const record of normal.records) {
|
||||
const name = caseOf(record);
|
||||
const producer = finalProducer.find(
|
||||
(event) => event.id === record.reported.id,
|
||||
);
|
||||
assert.ok(producer, `No actual producer observation for ${name}`);
|
||||
assert.equal(record.reported.error, null);
|
||||
assert.equal(producer.error, null);
|
||||
assert.equal(record.reported.response.status_code, 200);
|
||||
requireHttp3(record);
|
||||
for (const side of ["request", "response"]) {
|
||||
assert.equal(
|
||||
record.reported[side].http_version,
|
||||
producer[side].http_version,
|
||||
);
|
||||
binaryEqual(record, `/${side}/content`, producer[side].content);
|
||||
assertHeaders(
|
||||
record,
|
||||
side,
|
||||
manifest[side === "request" ? "requests" : "responses"][name].headers,
|
||||
);
|
||||
}
|
||||
// Native UUIDs must be retained as UUIDs; the producer exports no stream mapping.
|
||||
for (const side of ["client_conn", "server_conn"])
|
||||
for (const key of ["id", "tls_version", "timestamp_tls_setup", "error"])
|
||||
assert.equal(
|
||||
record.reported[side][key],
|
||||
producer[side][key],
|
||||
`${side}.${key} differs from producer observation ${producer.id}`,
|
||||
);
|
||||
binaryEqual(record, "/request/content", manifest.requests[name].body);
|
||||
binaryEqual(record, "/response/content", manifest.responses[name].body);
|
||||
const pseudo = {
|
||||
":method": "method",
|
||||
":scheme": "scheme",
|
||||
":authority": "authority",
|
||||
":path": "path",
|
||||
};
|
||||
for (const [headerName, headerValue] of manifest.requests[name].headers) {
|
||||
const decodedName = Buffer.from(headerName.base64, "base64").toString();
|
||||
if (pseudo[decodedName]) {
|
||||
binaryEqual(record, `/request/${pseudo[decodedName]}`, headerValue);
|
||||
assert.equal(
|
||||
record.reported.request[pseudo[decodedName]],
|
||||
textOf(headerValue),
|
||||
`request.${pseudo[decodedName]} readable value`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { bytes, caseOf, byCase };
|
||||
|
||||
export function verifyConcurrency(normal, manifest) {
|
||||
const parallelNames = ["parallel-0", "parallel-1", "parallel-2"];
|
||||
const sent = manifest.events.filter(
|
||||
(event) =>
|
||||
event.event === "request-sent" && parallelNames.includes(event.case),
|
||||
);
|
||||
const received = manifest.events.filter(
|
||||
(event) =>
|
||||
event.event === "request-received" && parallelNames.includes(event.case),
|
||||
);
|
||||
const responses = manifest.events.filter(
|
||||
(event) =>
|
||||
event.event === "response-sent" && parallelNames.includes(event.case),
|
||||
);
|
||||
assert.equal(new Set(sent.map((event) => event.connection)).size, 1);
|
||||
assert.equal(new Set(received.map((event) => event.connection)).size, 1);
|
||||
assert.equal(new Set(sent.map((event) => event.stream_id)).size, 3);
|
||||
assert.equal(new Set(received.map((event) => event.stream_id)).size, 3);
|
||||
assert.ok(
|
||||
Math.max(...received.map((event) => event.monotonic)) <
|
||||
responses[0].monotonic,
|
||||
);
|
||||
assert.deepEqual(
|
||||
responses.map((event) => event.case),
|
||||
received.map((event) => event.case).reverse(),
|
||||
);
|
||||
for (const side of ["client_conn", "server_conn"])
|
||||
assert.equal(
|
||||
new Set(
|
||||
parallelNames.map((name) => byCase(normal, name).reported[side].id),
|
||||
).size,
|
||||
1,
|
||||
);
|
||||
const normalConnections = new Set(
|
||||
[...sent, ...received].map((event) => event.connection),
|
||||
);
|
||||
for (const connection of normalConnections) {
|
||||
const handshakes = manifest.events.filter(
|
||||
(event) => event.event === "handshake" && event.connection === connection,
|
||||
);
|
||||
assert.equal(handshakes.length, 1);
|
||||
assert.equal(handshakes[0].alpn, "h3");
|
||||
assert.equal(handshakes[0].quic_version, 1);
|
||||
}
|
||||
assert.equal(
|
||||
new Set(normal.records.map((record) => record.reported.id)).size,
|
||||
4,
|
||||
);
|
||||
assert.equal(sent.length, 3);
|
||||
const receivedByClient = manifest.events.filter(
|
||||
(event) =>
|
||||
event.event === "response-received" &&
|
||||
sent.some(
|
||||
(request) =>
|
||||
request.connection === event.connection &&
|
||||
request.stream_id === event.stream_id,
|
||||
),
|
||||
);
|
||||
assert.equal(receivedByClient.length, 3);
|
||||
for (const request of sent) {
|
||||
const response = receivedByClient.find(
|
||||
(event) =>
|
||||
event.connection === request.connection &&
|
||||
event.stream_id === request.stream_id,
|
||||
);
|
||||
assert.ok(
|
||||
response,
|
||||
"Each sent request needs an independently observed client response",
|
||||
);
|
||||
assert.deepEqual(response.body, manifest.responses[request.case].body);
|
||||
assert.deepEqual(request.body, manifest.requests[request.case].body);
|
||||
assertEndpointHeaders(
|
||||
request.headers,
|
||||
manifest.requests[request.case].headers,
|
||||
);
|
||||
assertEndpointHeaders(
|
||||
response.headers,
|
||||
manifest.responses[request.case].headers,
|
||||
);
|
||||
}
|
||||
return {
|
||||
sent: sent.map((event) => event.case),
|
||||
completed_at_origin: received.map((event) => event.case),
|
||||
dispatched_at_origin: responses.map((event) => event.case),
|
||||
completed_at_client: receivedByClient.map(
|
||||
(event) =>
|
||||
sent.find(
|
||||
(request) =>
|
||||
request.connection === event.connection &&
|
||||
request.stream_id === event.stream_id,
|
||||
).case,
|
||||
),
|
||||
saved_in_capture: normal.records
|
||||
.filter((record) => parallelNames.includes(caseOf(record)))
|
||||
.map(caseOf),
|
||||
};
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { test } from "node:test";
|
||||
import { verifyNormalRecords } from "./http3-capture-assertions.mjs";
|
||||
|
||||
function connectionFactsFixture() {
|
||||
const body = Buffer.from([0, 255, 128, 1]);
|
||||
const view = (bytes) => ({
|
||||
base64: bytes.toString("base64"),
|
||||
bytes: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
});
|
||||
const producer = {
|
||||
id: "flow-id",
|
||||
error: null,
|
||||
request: { http_version: "HTTP/3", content: view(body) },
|
||||
response: { http_version: "HTTP/3", content: view(body) },
|
||||
client_conn: {
|
||||
id: "client-id",
|
||||
transport_protocol: "udp",
|
||||
tls_version: "QUICv1",
|
||||
timestamp_tls_setup: 1_700_000_000.125678,
|
||||
error: null,
|
||||
},
|
||||
server_conn: {
|
||||
id: "server-id",
|
||||
transport_protocol: "udp",
|
||||
tls_version: "QUICv1",
|
||||
timestamp_tls_setup: 1_700_000_000.987654,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
const reported = structuredClone(producer);
|
||||
const requestFields = {
|
||||
method: "POST",
|
||||
scheme: "https",
|
||||
authority: "lab.test:18443",
|
||||
path: "/echo?case=readable",
|
||||
};
|
||||
Object.assign(reported.request, requestFields);
|
||||
const headers = [
|
||||
[Buffer.from("x-repeat"), Buffer.from("first-α")],
|
||||
[Buffer.from("x-repeat"), Buffer.from("second-β")],
|
||||
[Buffer.from("x-binary"), Buffer.from([255])],
|
||||
];
|
||||
for (const side of ["request", "response"])
|
||||
reported[side].headers = [
|
||||
["x-repeat", "first-α"],
|
||||
["x-repeat", "second-β"],
|
||||
["x-binary", null],
|
||||
];
|
||||
reported.client_conn.alpn = "h3";
|
||||
reported.server_conn.alpn = "h3";
|
||||
reported.response.status_code = 200;
|
||||
const binary_fields = [
|
||||
["/request/content", body],
|
||||
["/response/content", body],
|
||||
["/client_conn/alpn", Buffer.from("h3")],
|
||||
["/server_conn/alpn", Buffer.from("h3")],
|
||||
...Object.entries(requestFields).map(([name, value]) => [
|
||||
`/request/${name}`,
|
||||
Buffer.from(value),
|
||||
]),
|
||||
...["request", "response"].flatMap((side) =>
|
||||
headers.flatMap((pair, index) =>
|
||||
pair.map((value, part) => [`/${side}/headers/${index}/${part}`, value]),
|
||||
),
|
||||
),
|
||||
].map(([pointer, bytes]) => {
|
||||
const { base64, ...facts } = view(bytes);
|
||||
return {
|
||||
pointer,
|
||||
representation: "producer-bytes",
|
||||
state: "retained",
|
||||
content_base64: base64,
|
||||
...facts,
|
||||
};
|
||||
});
|
||||
return {
|
||||
normal: { records: [{ reported, binary_fields }] },
|
||||
manifest: {
|
||||
requests: {
|
||||
curl: {
|
||||
headers: [
|
||||
...Object.entries(requestFields).map(([name, value]) => [
|
||||
view(Buffer.from(`:${name}`)),
|
||||
view(Buffer.from(value)),
|
||||
]),
|
||||
...headers.map((pair) => pair.map(view)),
|
||||
],
|
||||
body: view(body),
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
curl: {
|
||||
headers: headers.map((pair) => pair.map(view)),
|
||||
body: view(body),
|
||||
},
|
||||
},
|
||||
},
|
||||
producer: [producer],
|
||||
};
|
||||
}
|
||||
|
||||
test("positive assertions retain exact producer connection facts on both legs", () => {
|
||||
const { normal, manifest, producer } = connectionFactsFixture();
|
||||
verifyNormalRecords(normal, manifest, producer);
|
||||
});
|
||||
|
||||
for (const [field, value] of [
|
||||
["method", "GET"],
|
||||
["scheme", "http"],
|
||||
["authority", "other.test:18443"],
|
||||
["path", "/different"],
|
||||
])
|
||||
test(`positive assertions reject changed readable request ${field}`, () => {
|
||||
const { normal, manifest, producer } = connectionFactsFixture();
|
||||
normal.records[0].reported.request[field] = value;
|
||||
assert.throws(() => verifyNormalRecords(normal, manifest, producer), {
|
||||
code: "ERR_ASSERTION",
|
||||
});
|
||||
});
|
||||
|
||||
for (const side of ["client_conn", "server_conn"])
|
||||
test(`positive assertions reject null readable ${side} ALPN`, () => {
|
||||
const { normal, manifest, producer } = connectionFactsFixture();
|
||||
normal.records[0].reported[side].alpn = null;
|
||||
assert.throws(() => verifyNormalRecords(normal, manifest, producer), {
|
||||
code: "ERR_ASSERTION",
|
||||
});
|
||||
});
|
||||
|
||||
for (const side of ["request", "response"])
|
||||
for (const [index, part, value] of [
|
||||
[0, 0, "x-changed"],
|
||||
[0, 1, "changed-first"],
|
||||
[1, 1, "changed-second"],
|
||||
[2, 1, "�"],
|
||||
])
|
||||
test(`positive assertions reject changed readable ${side} header ${index}/${part}`, () => {
|
||||
const { normal, manifest, producer } = connectionFactsFixture();
|
||||
normal.records[0].reported[side].headers[index][part] = value;
|
||||
assert.throws(() => verifyNormalRecords(normal, manifest, producer), {
|
||||
code: "ERR_ASSERTION",
|
||||
});
|
||||
});
|
||||
|
||||
for (const side of ["client_conn", "server_conn"])
|
||||
for (const [field, value] of [
|
||||
["tls_version", null],
|
||||
["tls_version", "changed-producer-label"],
|
||||
["error", "changed-connection-error"],
|
||||
["error", undefined],
|
||||
["timestamp_tls_setup", 0],
|
||||
["timestamp_tls_setup", 1_700_000_000],
|
||||
])
|
||||
test(`positive assertions reject ${side}.${field} changed to ${String(value)}`, () => {
|
||||
const { normal, manifest, producer } = connectionFactsFixture();
|
||||
normal.records[0].reported[side][field] = value;
|
||||
assert.throws(() => verifyNormalRecords(normal, manifest, producer), {
|
||||
code: "ERR_ASSERTION",
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { cleanupOwnedProcessGroup } from "../../dist/process/ProcessOwnership.js";
|
||||
import { observeOwnedProcessGroups } from "../../dist/process/ProcessOwnershipObservation.js";
|
||||
|
||||
export const describeError = (error) => ({
|
||||
name: error?.name ?? "Error",
|
||||
message: error?.message ?? String(error),
|
||||
...(error?.stack ? { stack: error.stack } : {}),
|
||||
});
|
||||
|
||||
export async function attemptAll(actions) {
|
||||
const failures = [];
|
||||
for (const [name, action] of actions) {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
failures.push({ action: name, ...describeError(error) });
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
async function latest(root, report) {
|
||||
const temporary = join(root, `.verification-${report.run_id}.tmp`);
|
||||
await writeFile(temporary, JSON.stringify(report, null, 2), { flag: "wx" });
|
||||
await rename(temporary, join(root, "verification-latest.json"));
|
||||
}
|
||||
|
||||
async function withLatestLock(root, action) {
|
||||
// Keep this pathname stable: removing it could split concurrent lock owners.
|
||||
// SQLite releases its OS-backed lock when the connection/process closes.
|
||||
const database = new DatabaseSync(join(root, ".verification-lock.sqlite"));
|
||||
try {
|
||||
for (;;) {
|
||||
try {
|
||||
database.exec("BEGIN EXCLUSIVE");
|
||||
break;
|
||||
} catch (error) {
|
||||
if (
|
||||
error.code !== "ERR_SQLITE_ERROR" ||
|
||||
(error.errcode !== 5 && error.errcode !== 6)
|
||||
)
|
||||
throw error;
|
||||
// Yield while another process (or async operation here) owns the lock.
|
||||
// A synchronous busy timeout would block that operation's fs callbacks.
|
||||
await setTimeout(10);
|
||||
}
|
||||
}
|
||||
return await action();
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareAttempt(root, inspectExisting, run) {
|
||||
if (!inspectExisting) await mkdir(root, { mode: 0o700 });
|
||||
const directory = join(root, "verifications", run.run_id);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
await withLatestLock(root, async () => {
|
||||
// Preserve older reports as history, without leaving an ambiguous success
|
||||
// at the capture root. Registration is the shared marker's ordering point.
|
||||
try {
|
||||
await rename(
|
||||
join(root, "verification.json"),
|
||||
join(directory, "legacy-verification.json"),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
await latest(root, {
|
||||
run_id: run.run_id,
|
||||
status: "running",
|
||||
started_at: new Date().toISOString(),
|
||||
directory,
|
||||
});
|
||||
});
|
||||
return { directory, captureRoot: join(root, "captures") };
|
||||
}
|
||||
|
||||
export async function publishAttempt(root, directory, report) {
|
||||
await writeFile(
|
||||
join(directory, "result.json"),
|
||||
JSON.stringify(report, null, 2),
|
||||
{ flag: "wx" },
|
||||
);
|
||||
if (report.status === "passed")
|
||||
await writeFile(
|
||||
join(directory, "verification.json"),
|
||||
JSON.stringify(report, null, 2),
|
||||
{ flag: "wx" },
|
||||
);
|
||||
await withLatestLock(root, async () => {
|
||||
const current = JSON.parse(
|
||||
await readFile(join(root, "verification-latest.json"), "utf8"),
|
||||
);
|
||||
if (current.run_id !== report.run_id) return;
|
||||
// Ownership check and replacement share one lock with attempt registration.
|
||||
// An older attempt still keeps its reports, but cannot replace this marker.
|
||||
await latest(root, {
|
||||
run_id: report.run_id,
|
||||
status: report.status,
|
||||
directory,
|
||||
finished_at: report.finished_at,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function finishProducerGroups(captureRoot, runId) {
|
||||
let leases;
|
||||
try {
|
||||
leases = (await readFile(join(captureRoot, "process-groups.jsonl"), "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map(JSON.parse);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return { groups: [], observation: [] };
|
||||
throw error;
|
||||
}
|
||||
for (const lease of leases) assert.equal(lease.runId, runId);
|
||||
const failures = await attemptAll(
|
||||
leases.map((lease) => [
|
||||
`producer-group-${lease.processGroupId}`,
|
||||
async () => {
|
||||
const result = await cleanupOwnedProcessGroup(lease);
|
||||
assert.equal(result.cleaned, true, JSON.stringify(result));
|
||||
},
|
||||
]),
|
||||
);
|
||||
// Inspect even when one close failed; the groups can outlive their bootloader
|
||||
// parent and disappear from a plain descendant-only observation.
|
||||
const observation = [
|
||||
...(await observeOwnedProcessGroups(
|
||||
runId,
|
||||
leases.map((lease) => lease.processGroupId),
|
||||
)),
|
||||
];
|
||||
return { groups: leases, failures, observation };
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
attemptAll,
|
||||
prepareAttempt,
|
||||
publishAttempt,
|
||||
finishProducerGroups,
|
||||
} from "./http3-verifier-lifecycle.mjs";
|
||||
import { cleanupOwnedProcessGroup } from "../../dist/process/ProcessOwnership.js";
|
||||
import { runOwnedCommand } from "../../dist/process/OwnedCommand.js";
|
||||
import { verifyConcurrency } from "./http3-capture-assertions.mjs";
|
||||
|
||||
async function waitForFile(path) {
|
||||
const signal = AbortSignal.timeout(5_000);
|
||||
for (;;) {
|
||||
try {
|
||||
await readFile(path);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
await setTimeout(10, undefined, { signal });
|
||||
}
|
||||
}
|
||||
|
||||
function startAttemptWorker(root, report) {
|
||||
const worker = `
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { prepareAttempt, publishAttempt } from ${JSON.stringify(
|
||||
new URL("./http3-verifier-lifecycle.mjs", import.meta.url).href,
|
||||
)};
|
||||
const [root, serialized] = process.argv.slice(1);
|
||||
const report = JSON.parse(serialized);
|
||||
const attempt = await prepareAttempt(root, true, report);
|
||||
await writeFile(join(root, report.run_id + ".ready"), "");
|
||||
for (;;) {
|
||||
try { await readFile(join(root, report.run_id + ".finish")); break; }
|
||||
catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
await setTimeout(10);
|
||||
}
|
||||
await publishAttempt(root, attempt.directory, report);
|
||||
`;
|
||||
return runOwnedCommand(
|
||||
{
|
||||
command: process.execPath,
|
||||
arguments: [
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
worker,
|
||||
root,
|
||||
JSON.stringify(report),
|
||||
],
|
||||
cwd: root,
|
||||
hostEnvironment: process.env,
|
||||
runId: randomUUID(),
|
||||
},
|
||||
{ timeoutMs: 10_000, diagnosticBytes: 4096 },
|
||||
).then(
|
||||
(result) => ({ result }),
|
||||
(error) => ({ error }),
|
||||
);
|
||||
}
|
||||
|
||||
test("cleanup continues through failed closes and reaches its audit", async () => {
|
||||
const attempted = [];
|
||||
const failures = await attemptAll(
|
||||
["client", "transport", "audit"].map((name) => [
|
||||
name,
|
||||
async () => {
|
||||
attempted.push(name);
|
||||
if (name !== "audit") throw new Error(`${name} failed`);
|
||||
},
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(attempted, ["client", "transport", "audit"]);
|
||||
assert.deepEqual(
|
||||
failures.map((failure) => failure.action),
|
||||
["client", "transport"],
|
||||
);
|
||||
});
|
||||
|
||||
test("a failed reinspection cannot leave the earlier success as the current result", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-http3-results-"));
|
||||
try {
|
||||
await writeFile(
|
||||
join(root, "verification.json"),
|
||||
'{"status":"passed","run_id":"legacy"}',
|
||||
);
|
||||
const first = await prepareAttempt(root, true, { run_id: "first" });
|
||||
await publishAttempt(root, first.directory, {
|
||||
run_id: "first",
|
||||
status: "passed",
|
||||
});
|
||||
const next = await prepareAttempt(root, true, { run_id: "next" });
|
||||
assert.equal(
|
||||
JSON.parse(await readFile(join(root, "verification-latest.json"))).status,
|
||||
"running",
|
||||
);
|
||||
await publishAttempt(root, next.directory, {
|
||||
run_id: "next",
|
||||
status: "failed",
|
||||
error: { message: "new failure" },
|
||||
});
|
||||
assert.equal(
|
||||
JSON.parse(await readFile(join(root, "verification-latest.json"))).run_id,
|
||||
"next",
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(await readFile(join(root, "verification-latest.json"))).status,
|
||||
"failed",
|
||||
);
|
||||
await assert.rejects(readFile(join(next.directory, "verification.json")), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
await assert.rejects(readFile(join(root, "verification.json")), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
assert.equal(
|
||||
JSON.parse(await readFile(join(first.directory, "verification.json")))
|
||||
.status,
|
||||
"passed",
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(
|
||||
await readFile(join(first.directory, "legacy-verification.json")),
|
||||
).run_id,
|
||||
"legacy",
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("separate overlapping attempts finishing in reverse order retain the newer failure", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-http3-overlap-"));
|
||||
const commands = [];
|
||||
const failed = {
|
||||
run_id: "newer",
|
||||
status: "failed",
|
||||
error: { message: "newer inspection failed" },
|
||||
cleanup_failures: [{ action: "transport-close", message: "close failed" }],
|
||||
};
|
||||
let primaryError;
|
||||
const start = (report) => {
|
||||
const command = startAttemptWorker(root, report);
|
||||
commands.push(command);
|
||||
return command;
|
||||
};
|
||||
try {
|
||||
const older = start({ run_id: "older", status: "passed" });
|
||||
await waitForFile(join(root, "older.ready"));
|
||||
const newer = start(failed);
|
||||
await waitForFile(join(root, "newer.ready"));
|
||||
await writeFile(join(root, "newer.finish"), "");
|
||||
const newerResult = await newer;
|
||||
if (newerResult.error) throw newerResult.error;
|
||||
assert.equal(newerResult.result.exitCode, 0);
|
||||
assert.equal(newerResult.result.signal, null);
|
||||
const marker = await readFile(join(root, "verification-latest.json"));
|
||||
await writeFile(join(root, "older.finish"), "");
|
||||
const olderResult = await older;
|
||||
if (olderResult.error) throw olderResult.error;
|
||||
assert.equal(olderResult.result.exitCode, 0);
|
||||
assert.equal(olderResult.result.signal, null);
|
||||
assert.deepEqual(
|
||||
await readFile(join(root, "verification-latest.json")),
|
||||
marker,
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(await readFile(join(root, "verifications/newer/result.json"))),
|
||||
failed,
|
||||
);
|
||||
await assert.rejects(
|
||||
readFile(join(root, "verifications/newer/verification.json")),
|
||||
{
|
||||
code: "ENOENT",
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(
|
||||
await readFile(join(root, "verifications/older/verification.json")),
|
||||
).status,
|
||||
"passed",
|
||||
);
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
} finally {
|
||||
// Release both barriers even after an assertion/setup failure. Each command
|
||||
// retains native supervision through termination before the root is removed.
|
||||
const failures = await attemptAll(
|
||||
["older", "newer"].map((name) => [
|
||||
name,
|
||||
() => writeFile(join(root, name + ".finish"), ""),
|
||||
]),
|
||||
);
|
||||
const outcomes = await Promise.all(commands);
|
||||
const retained = outcomes.filter(
|
||||
({ error }) => error?.cleanupFailure || error?.cleanupResult,
|
||||
);
|
||||
if (retained.length)
|
||||
primaryError = new AggregateError(
|
||||
[primaryError, ...retained.map(({ error }) => error)].filter(Boolean),
|
||||
`Worker cleanup failed; retained test evidence at ${root}`,
|
||||
{ cause: primaryError },
|
||||
);
|
||||
else
|
||||
failures.push(
|
||||
...(await attemptAll([
|
||||
["root", () => rm(root, { recursive: true, force: true })],
|
||||
])),
|
||||
);
|
||||
if (failures.length)
|
||||
primaryError = new AggregateError(
|
||||
[primaryError, ...failures].filter(Boolean),
|
||||
"Test cleanup failed",
|
||||
{ cause: primaryError },
|
||||
);
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
});
|
||||
|
||||
test("publication waits for the lock before reading the current owner", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-http3-lock-"));
|
||||
let database;
|
||||
let publication;
|
||||
let primaryError;
|
||||
try {
|
||||
const older = await prepareAttempt(root, true, { run_id: "older" });
|
||||
database = new DatabaseSync(join(root, ".verification-lock.sqlite"));
|
||||
database.exec("BEGIN EXCLUSIVE");
|
||||
publication = publishAttempt(root, older.directory, {
|
||||
run_id: "older",
|
||||
status: "passed",
|
||||
});
|
||||
await waitForFile(join(older.directory, "verification.json"));
|
||||
const marker = join(root, "verification-latest.json");
|
||||
assert.equal(JSON.parse(await readFile(marker)).status, "running");
|
||||
// This connection represents another marker writer. Its replacement must
|
||||
// be observed only after the blocked publisher acquires the same lock.
|
||||
const newer = { run_id: "newer", status: "failed" };
|
||||
await writeFile(marker, JSON.stringify(newer));
|
||||
database.close();
|
||||
database = undefined;
|
||||
await publication;
|
||||
assert.deepEqual(JSON.parse(await readFile(marker)), newer);
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
} finally {
|
||||
const failures = await attemptAll([
|
||||
["lock", () => database?.close()],
|
||||
[
|
||||
"publication",
|
||||
async () => {
|
||||
if (publication) await publication;
|
||||
},
|
||||
],
|
||||
["root", () => rm(root, { recursive: true, force: true })],
|
||||
]);
|
||||
if (failures.length)
|
||||
primaryError = new AggregateError(
|
||||
[primaryError, ...failures].filter(Boolean),
|
||||
"Test cleanup failed",
|
||||
{ cause: primaryError },
|
||||
);
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
});
|
||||
|
||||
test("a failed marker read releases the lock and preserves the attempt report", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-http3-lock-failure-"));
|
||||
try {
|
||||
const older = await prepareAttempt(root, true, { run_id: "older" });
|
||||
await writeFile(join(root, "verification-latest.json"), "{");
|
||||
const report = {
|
||||
run_id: "older",
|
||||
status: "failed",
|
||||
error: { message: "inspection failed" },
|
||||
};
|
||||
await assert.rejects(
|
||||
publishAttempt(root, older.directory, report),
|
||||
SyntaxError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(await readFile(join(older.directory, "result.json"))),
|
||||
report,
|
||||
);
|
||||
const next = await prepareAttempt(root, true, { run_id: "next" });
|
||||
await publishAttempt(root, next.directory, {
|
||||
run_id: "next",
|
||||
status: "passed",
|
||||
});
|
||||
assert.equal(
|
||||
JSON.parse(await readFile(join(root, "verification-latest.json"))).run_id,
|
||||
"next",
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("dispatch reverses origin completion; client and capture may arrive in other orders", () => {
|
||||
const names = ["parallel-0", "parallel-1", "parallel-2"];
|
||||
const header = (text) => ({ base64: Buffer.from(text).toString("base64") });
|
||||
const messages = Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
{
|
||||
headers: [[header("x-lab-case"), header(name)]],
|
||||
body: { base64: header(name).base64 },
|
||||
},
|
||||
]),
|
||||
);
|
||||
const event = (kind, index, connection, monotonic) => ({
|
||||
event: kind,
|
||||
case: names[index],
|
||||
stream_id: index * 4,
|
||||
connection,
|
||||
monotonic,
|
||||
...messages[names[index]],
|
||||
});
|
||||
const manifest = {
|
||||
requests: messages,
|
||||
responses: messages,
|
||||
events: [
|
||||
...[0, 1, 2].map((i) => event("request-sent", i, "client", i)),
|
||||
...[1, 2, 0].map((i) => event("request-received", i, "origin", 10)),
|
||||
...[0, 2, 1].map((i) => event("response-sent", i, "origin", 20)),
|
||||
...[2, 0, 1].map((i) => event("response-received", i, "client", 30)),
|
||||
...["client", "origin"].map((connection) => ({
|
||||
event: "handshake",
|
||||
connection,
|
||||
alpn: "h3",
|
||||
quic_version: 1,
|
||||
})),
|
||||
],
|
||||
};
|
||||
const record = (name) => ({
|
||||
reported: {
|
||||
id: name,
|
||||
request: { headers: [["x-lab-case", name]] },
|
||||
client_conn: { id: "client-uuid" },
|
||||
server_conn: { id: "origin-uuid" },
|
||||
},
|
||||
});
|
||||
const normal = {
|
||||
records: [...[1, 0, 2].map((i) => record(names[i])), record("curl")],
|
||||
};
|
||||
const orders = verifyConcurrency(normal, manifest);
|
||||
assert.deepEqual(orders.dispatched_at_origin, [
|
||||
"parallel-0",
|
||||
"parallel-2",
|
||||
"parallel-1",
|
||||
]);
|
||||
manifest.events = manifest.events.filter(
|
||||
(item) => item.event !== "response-sent",
|
||||
);
|
||||
manifest.events.push(
|
||||
...[2, 1, 0].map((i) => event("response-sent", i, "origin", 20)),
|
||||
);
|
||||
assert.throws(() => verifyConcurrency(normal, manifest), {
|
||||
code: "ERR_ASSERTION",
|
||||
});
|
||||
});
|
||||
|
||||
test("cleanup verifies and retires a separately owned producer group", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-http3-group-"));
|
||||
const runId = randomUUID();
|
||||
const child = spawn("/usr/bin/sleep", ["60"], {
|
||||
detached: true,
|
||||
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
|
||||
stdio: "ignore",
|
||||
});
|
||||
let primaryError;
|
||||
let cleaned = false;
|
||||
try {
|
||||
await once(child, "spawn");
|
||||
await writeFile(
|
||||
join(root, "process-groups.jsonl"),
|
||||
JSON.stringify({
|
||||
runId,
|
||||
leaderPid: child.pid,
|
||||
processGroupId: child.pid,
|
||||
}) + "\n",
|
||||
);
|
||||
const result = await finishProducerGroups(root, runId);
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.equal(result.observation[0][1].state, "empty");
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
} finally {
|
||||
const failures = await attemptAll([
|
||||
[
|
||||
"process",
|
||||
async () => {
|
||||
const result = await cleanupOwnedProcessGroup({
|
||||
runId,
|
||||
leaderPid: child.pid,
|
||||
processGroupId: child.pid,
|
||||
});
|
||||
assert.equal(
|
||||
result.cleaned,
|
||||
true,
|
||||
`Retained ownership evidence at ${root}: ${JSON.stringify(result)}`,
|
||||
);
|
||||
cleaned = true;
|
||||
},
|
||||
],
|
||||
[
|
||||
"root",
|
||||
async () => {
|
||||
if (cleaned) await rm(root, { recursive: true, force: true });
|
||||
},
|
||||
],
|
||||
]);
|
||||
if (failures.length)
|
||||
primaryError = new AggregateError(
|
||||
[primaryError, ...failures].filter(Boolean),
|
||||
"Test cleanup failed",
|
||||
{ cause: primaryError },
|
||||
);
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
@@ -260,7 +259,7 @@ try {
|
||||
const execution = await runCodex(
|
||||
answerInstructions.length === 0
|
||||
? scenario.prompt
|
||||
: `${scenario.prompt}\n\nThis closed factual rubric needs complete producer Evidence in the transcript. Request complete detail on each initial inspection or analysis when supported; summary or view records alone are not accepted by this rubric.\n\n${answerInstructions}`,
|
||||
: `${scenario.prompt}\n\nThis closed factual rubric needs complete producer Evidence in the transcript. Request complete detail on each initial application analysis when supported; summary or view records alone are not accepted by this rubric.\n\n${answerInstructions}`,
|
||||
scenario.approvedTools ?? [],
|
||||
);
|
||||
const transcriptDirectory = process.env.REA_AGENT_EVAL_TRANSCRIPT_DIR;
|
||||
@@ -441,13 +440,6 @@ async function createTargets(root, includeManaged) {
|
||||
...fixtures,
|
||||
largeJavascript,
|
||||
managed: join(managedOutput, "AgentEval.dll"),
|
||||
...(includeManaged
|
||||
? {
|
||||
managedSha256: createHash("sha256")
|
||||
.update(await readFile(join(managedOutput, "AgentEval.dll")))
|
||||
.digest("hex"),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { runOwnedCommand } from "../dist/process/OwnedCommand.js";
|
||||
import { toolContract } from "../dist/contracts/toolContracts.js";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import {
|
||||
bytes,
|
||||
byCase,
|
||||
verifyNormalRecords,
|
||||
requireHttp3,
|
||||
verifyConcurrency,
|
||||
} from "./lib/http3-capture-assertions.mjs";
|
||||
import {
|
||||
attemptAll,
|
||||
describeError,
|
||||
prepareAttempt,
|
||||
publishAttempt,
|
||||
finishProducerGroups,
|
||||
} from "./lib/http3-verifier-lifecycle.mjs";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
|
||||
const root = process.argv[2];
|
||||
const inspectExisting = process.argv[3] === "--inspect-existing";
|
||||
const mitmdump = process.env.REA_MITMDUMP_COMMAND;
|
||||
const python = process.env.REA_HTTP3_PYTHON_COMMAND;
|
||||
assert.equal(
|
||||
process.platform,
|
||||
"linux",
|
||||
"HTTP/3 native verification requires Linux",
|
||||
);
|
||||
assert.ok(
|
||||
root && isAbsolute(root),
|
||||
"Select an absolute, new evidence directory",
|
||||
);
|
||||
assert.ok(
|
||||
mitmdump && isAbsolute(mitmdump),
|
||||
"Select caller-supplied mitmdump 12.2.3",
|
||||
);
|
||||
if (!inspectExisting)
|
||||
assert.ok(
|
||||
python && isAbsolute(python),
|
||||
"Select a Python environment with aioquic 1.2.0",
|
||||
);
|
||||
const run = createVerifierRun();
|
||||
const cwd = fileURLToPath(new URL("..", import.meta.url));
|
||||
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, value]) => typeof value === "string"),
|
||||
);
|
||||
const client = new Client({
|
||||
name: "real-http3-capture-verifier",
|
||||
version: "1",
|
||||
});
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const contract = toolContract("inspect_web_network_capture");
|
||||
const maxMcpStderrBytes = 64 * 1024;
|
||||
let mcpStderr = Buffer.alloc(0);
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
mcpStderr = Buffer.concat([mcpStderr, chunk]);
|
||||
if (mcpStderr.length > maxMcpStderrBytes)
|
||||
mcpStderr = mcpStderr.subarray(-maxMcpStderrBytes);
|
||||
});
|
||||
const checks = [];
|
||||
const retainedEvidence = [];
|
||||
const originals = new Map();
|
||||
let directory;
|
||||
let captureRoot = join(root, "captures");
|
||||
let primaryError;
|
||||
let verifier;
|
||||
let producerAudit;
|
||||
let cleanupFailures = [];
|
||||
let validateOutput;
|
||||
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
|
||||
const readLines = async (path) =>
|
||||
(await readFile(path, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map(JSON.parse);
|
||||
|
||||
async function cli(input, name) {
|
||||
const command = await runOwnedCommand(
|
||||
{
|
||||
command: process.execPath,
|
||||
arguments: [
|
||||
entrypoint,
|
||||
"inspect-web-network-capture",
|
||||
input.capture_path,
|
||||
input.format,
|
||||
"--json",
|
||||
],
|
||||
cwd,
|
||||
hostEnvironment: environment,
|
||||
runId: run.run_id,
|
||||
},
|
||||
{
|
||||
timeoutMs: 40_000,
|
||||
diagnosticBytes: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
assert.notEqual(command.diagnosticTruncated, true);
|
||||
await writeFile(
|
||||
join(directory, `${name}.cli.stdout.json`),
|
||||
command.stdout.text,
|
||||
);
|
||||
await writeFile(
|
||||
join(directory, `${name}.cli.stderr.log`),
|
||||
command.stderr.text,
|
||||
);
|
||||
const value = JSON.parse(command.stdout.text);
|
||||
return contract.outputSchema.parse(value);
|
||||
}
|
||||
|
||||
async function mcp(input, name) {
|
||||
const reply = await client.callTool({
|
||||
name: contract.name,
|
||||
arguments: input,
|
||||
});
|
||||
await writeFile(
|
||||
join(directory, `${name}.mcp.reply.json`),
|
||||
JSON.stringify(reply, null, 2),
|
||||
);
|
||||
const value = JSON.parse(mcpTextValue(reply));
|
||||
assert.notEqual(reply.isError, true, mcpTextValue(reply));
|
||||
assert.ok(
|
||||
validateOutput(reply.structuredContent),
|
||||
JSON.stringify(validateOutput.errors),
|
||||
);
|
||||
const evidence = contract.outputSchema.parse(value);
|
||||
assert.deepEqual(reply.structuredContent, value);
|
||||
retainedEvidence.push(evidence);
|
||||
return evidence;
|
||||
}
|
||||
|
||||
async function inspect(name) {
|
||||
const input = {
|
||||
capture_path: join(captureRoot, `${name}.mitm`),
|
||||
format: "mitmproxy",
|
||||
};
|
||||
const cliResult = await cli(input, name);
|
||||
const mcpResult = await mcp(input, name);
|
||||
assert.deepEqual(cliResult.normalized_result, mcpResult.normalized_result);
|
||||
for (const evidence of [cliResult, mcpResult]) {
|
||||
assert.equal(evidence.authority, "historical-reference");
|
||||
assert.equal(evidence.confidence, "observed");
|
||||
assert.equal(evidence.provider.version, "12.2.3");
|
||||
assert.equal(evidence.normalized_result.runtime_attribution, "unknown");
|
||||
assert.deepEqual(evidence.provider, evidence.normalized_result.decoder);
|
||||
assert.equal(
|
||||
evidence.subject.digest.sha256,
|
||||
digest(await readFile(input.capture_path)),
|
||||
);
|
||||
}
|
||||
checks.push({
|
||||
case: name,
|
||||
outcome: "passed",
|
||||
records: mcpResult.normalized_result.records.length,
|
||||
});
|
||||
return mcpResult.normalized_result;
|
||||
}
|
||||
|
||||
try {
|
||||
({ directory, captureRoot } = await prepareAttempt(
|
||||
root,
|
||||
inspectExisting,
|
||||
run,
|
||||
));
|
||||
if (!inspectExisting) {
|
||||
const produced = await runOwnedCommand(
|
||||
{
|
||||
command: python,
|
||||
arguments: [
|
||||
fileURLToPath(new URL("./fixtures/http3-lab.py", import.meta.url)),
|
||||
"--mitmdump",
|
||||
mitmdump,
|
||||
"--output",
|
||||
captureRoot,
|
||||
],
|
||||
cwd,
|
||||
hostEnvironment: environment,
|
||||
runId: run.run_id,
|
||||
},
|
||||
{ timeoutMs: 120_000, diagnosticBytes: 1024 * 1024 },
|
||||
);
|
||||
await writeFile(
|
||||
join(directory, "producer.stdout.log"),
|
||||
produced.stdout.text,
|
||||
);
|
||||
await writeFile(
|
||||
join(directory, "producer.stderr.log"),
|
||||
produced.stderr.text,
|
||||
);
|
||||
}
|
||||
const manifest = await readJson(join(captureRoot, "manifest.json"));
|
||||
assert.equal(manifest.aioquic, "1.2.0");
|
||||
assert.equal(manifest.profile, "http3-minimal-v1");
|
||||
await client.connect(transport);
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === contract.name,
|
||||
);
|
||||
assert.ok(advertised?.outputSchema, "Missing named advertised schema");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
assert.ok(ajv.validateSchema(advertised.inputSchema));
|
||||
assert.ok(ajv.validateSchema(advertised.outputSchema));
|
||||
validateOutput = ajv.compile(advertised.outputSchema);
|
||||
const inputValidator = ajv.compile(advertised.inputSchema);
|
||||
assert.ok(
|
||||
inputValidator({
|
||||
capture_path: join(captureRoot, "normal.mitm"),
|
||||
format: "mitmproxy",
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
inputValidator({
|
||||
capture_path: join(captureRoot, "normal.mitm"),
|
||||
format: "mitmproxy",
|
||||
stream_id: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
await writeFile(
|
||||
join(directory, "advertised-tool.json"),
|
||||
JSON.stringify(advertised, null, 2),
|
||||
);
|
||||
|
||||
for (const name of ["normal", "fallback"])
|
||||
originals.set(name, await readFile(join(captureRoot, `${name}.mitm`)));
|
||||
const normal = await inspect("normal");
|
||||
assert.equal(normal.total_records, 4);
|
||||
const finalProducer = (
|
||||
await readLines(join(captureRoot, "normal.producer.jsonl"))
|
||||
).filter((event) => ["response", "error"].includes(event.event));
|
||||
verifyNormalRecords(normal, manifest, finalProducer);
|
||||
const orders = verifyConcurrency(normal, manifest);
|
||||
assert.equal(
|
||||
(await readJson(join(captureRoot, "curl.json"))).http_version,
|
||||
"3",
|
||||
);
|
||||
assert.deepEqual(
|
||||
await readFile(join(captureRoot, "curl.body")),
|
||||
bytes(byCase(normal, "curl"), "/response/content"),
|
||||
);
|
||||
checks.push({
|
||||
case: "concurrency-bytes",
|
||||
outcome: "passed",
|
||||
parallel_streams: 3,
|
||||
orders,
|
||||
});
|
||||
const fallback = await inspect("fallback");
|
||||
assert.equal(fallback.total_records, 1);
|
||||
const record = byCase(fallback, "fallback");
|
||||
assert.throws(
|
||||
() => requireHttp3(record),
|
||||
{ code: "ERR_ASSERTION" },
|
||||
"The same positive HTTP/3 check must reject the fallback record",
|
||||
);
|
||||
assert.equal(record.reported.client_conn.transport_protocol, "tcp");
|
||||
assert.equal(record.reported.server_conn.transport_protocol, "tcp");
|
||||
assert.equal(record.reported.request.http_version, "HTTP/2.0");
|
||||
assert.equal(record.reported.response.http_version, "HTTP/1.1");
|
||||
assert.equal(record.reported.server_conn.alpn, "http/1.1");
|
||||
assert.equal(
|
||||
(await readJson(join(captureRoot, "fallback.json"))).http_version,
|
||||
"2",
|
||||
);
|
||||
checks.push({
|
||||
case: "fallback-detected",
|
||||
outcome: "passed",
|
||||
client_http: "2",
|
||||
origin_http: "1.1",
|
||||
});
|
||||
|
||||
const bundleReply = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
assert.notEqual(bundleReply.isError, true, mcpTextValue(bundleReply));
|
||||
const bundle = JSON.parse(mcpTextValue(bundleReply)).result;
|
||||
const records = bundle.records;
|
||||
assert.ok(Array.isArray(records), "Evidence bundle omitted its records");
|
||||
for (const evidence of retainedEvidence) {
|
||||
const retained = records.find(
|
||||
(record) => record.evidence_id === evidence.evidence_id,
|
||||
);
|
||||
assert.deepEqual(
|
||||
retained,
|
||||
evidence,
|
||||
`MCP must retain the complete Evidence for ${evidence.evidence_id}`,
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(directory, "session-bundle.reply.json"),
|
||||
JSON.stringify(bundleReply, null, 2),
|
||||
);
|
||||
checks.push({
|
||||
case: "mcp-session-retention",
|
||||
outcome: "passed",
|
||||
records: retainedEvidence.length,
|
||||
});
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
} finally {
|
||||
cleanupFailures = await attemptAll([
|
||||
["client-close", () => client.close()],
|
||||
["transport-close", () => transport.close()],
|
||||
[
|
||||
"producer-groups",
|
||||
async () => {
|
||||
if (!inspectExisting) {
|
||||
producerAudit = await finishProducerGroups(captureRoot, run.run_id);
|
||||
assert.deepEqual(producerAudit.failures ?? [], []);
|
||||
for (const [, observation] of producerAudit.observation)
|
||||
assert.equal(
|
||||
observation.state,
|
||||
"empty",
|
||||
JSON.stringify(observation),
|
||||
);
|
||||
}
|
||||
},
|
||||
],
|
||||
...[...originals].map(([name, original]) => [
|
||||
`input-immutability-${name}`,
|
||||
async () => {
|
||||
assert.deepEqual(
|
||||
await readFile(join(captureRoot, `${name}.mitm`)),
|
||||
original,
|
||||
);
|
||||
},
|
||||
]),
|
||||
[
|
||||
"mcp-stderr-diagnostics",
|
||||
async () => {
|
||||
if (directory)
|
||||
await writeFile(join(directory, "mcp.stderr.log"), mcpStderr);
|
||||
},
|
||||
],
|
||||
[
|
||||
"failed-command-diagnostics",
|
||||
async () => {
|
||||
if (directory && primaryError?.snapshot) {
|
||||
await writeFile(
|
||||
join(directory, "failed-command.stdout.log"),
|
||||
primaryError.snapshot.stdout.text,
|
||||
);
|
||||
await writeFile(
|
||||
join(directory, "failed-command.stderr.log"),
|
||||
primaryError.snapshot.stderr.text,
|
||||
);
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
"final-lineage",
|
||||
async () => {
|
||||
verifier = await completeVerifierRun(run);
|
||||
assert.equal(verifier.process_lineage.status, "verified");
|
||||
assert.deepEqual(verifier.process_lineage.descendants, []);
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
const report = {
|
||||
run_id: run.run_id,
|
||||
status: primaryError || cleanupFailures.length ? "failed" : "passed",
|
||||
finished_at: new Date().toISOString(),
|
||||
...(primaryError
|
||||
? {
|
||||
error: describeError(primaryError),
|
||||
mcp_stderr_log: "mcp.stderr.log",
|
||||
}
|
||||
: {}),
|
||||
cleanup_failures: cleanupFailures,
|
||||
checks,
|
||||
verifier,
|
||||
producer_audit: producerAudit,
|
||||
scope:
|
||||
"Real Linux reverse HTTP/3, mitmproxy 12.2.3, aioquic 1.2.0 and guest cURL",
|
||||
limitations: [
|
||||
"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.",
|
||||
],
|
||||
};
|
||||
if (directory) {
|
||||
try {
|
||||
await publishAttempt(root, directory, report);
|
||||
} catch (error) {
|
||||
console.error("Could not publish this attempt's result:", error);
|
||||
primaryError ??= error;
|
||||
}
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
if (cleanupFailures.length)
|
||||
throw new AggregateError(cleanupFailures, "HTTP/3 verifier cleanup failed");
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
@@ -9,10 +9,7 @@ import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
|
||||
import {
|
||||
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
waitForBrowserDevtoolsPort,
|
||||
} from "../dist/browser/BrowserProcessStartup.js";
|
||||
import { waitForBrowserDevtoolsPort } from "../dist/browser/BrowserProcessStartup.js";
|
||||
import { createBrowserScenarioProvider } from "../dist/composition/browserScenario.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
@@ -45,12 +42,12 @@ import { verifyBrowserNetworkEvidence } from "./lib/browser-network-e2e.mjs";
|
||||
import { verifyBrowserScriptExport } from "./lib/browser-script-export-e2e.mjs";
|
||||
import { verifyBrowserModules } from "./lib/browser-module-e2e.mjs";
|
||||
import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs";
|
||||
import { verifyBrowserSocketTransport } from "./lib/browser-socket-e2e.mjs";
|
||||
import { verifyBrowserCaptureMetadataBudget } from "./lib/browser-capture-metadata-budget-e2e.mjs";
|
||||
import { artifactCliEvidence, artifactMcpResult } from "./lib/artifact-e2e.mjs";
|
||||
import { verifyScenarioEnvironment } from "./lib/browser-scenario-environment-e2e.mjs";
|
||||
import { verifyScenarioStorage } from "./lib/browser-scenario-storage-e2e.mjs";
|
||||
|
||||
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000;
|
||||
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
|
||||
const SCENARIO_URL_SECRET_VALUE = "rea-browser-url-verifier-secret";
|
||||
process.env.REA_BROWSER_VERIFIER_SECRET = SCENARIO_SECRET_VALUE;
|
||||
@@ -95,7 +92,7 @@ try {
|
||||
executable,
|
||||
activePortPath: join(profile, "DevToolsActivePort"),
|
||||
stderr: () => stderr,
|
||||
timeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
|
||||
timeoutMs: REAL_BROWSER_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
const endpoint = `http://127.0.0.1:${String(port)}`;
|
||||
const provider = new CdpBrowserProvider();
|
||||
@@ -235,13 +232,6 @@ try {
|
||||
|
||||
pageProxy = await startPageCdpProxy(endpoint);
|
||||
await verifyPageScopedTransport(provider, pageProxy, site.origin);
|
||||
const browserSocketTransport = await verifyBrowserSocketTransport({
|
||||
provider,
|
||||
profile,
|
||||
endpoint,
|
||||
target,
|
||||
origin: site.origin,
|
||||
});
|
||||
const attachedScenarioInput = browserScenario(
|
||||
{
|
||||
mode: "connect",
|
||||
@@ -385,7 +375,6 @@ try {
|
||||
bundle_cli_and_stdio_mcp: true,
|
||||
sessionEvents: session.value.timeline.length,
|
||||
pageScopedTransport: true,
|
||||
browserSocketTransport,
|
||||
screenshotBytes: screenshot.value.artifact.bytes,
|
||||
largeScreenshot,
|
||||
popupEvents,
|
||||
|
||||
@@ -114,22 +114,6 @@ try {
|
||||
length: bytes.length,
|
||||
});
|
||||
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
|
||||
const signature = await call("search_bytes", {
|
||||
pattern: "1e ?? 2f e1",
|
||||
limit: 1000,
|
||||
});
|
||||
assert.equal(signature.normalized_result.complete, true);
|
||||
assert.equal(signature.normalized_result.next_cursor, null);
|
||||
assert.equal(signature.normalized_result.searched.bytes, bytes.length);
|
||||
assert.deepEqual(
|
||||
signature.normalized_result.matches.map((match) => match.address),
|
||||
[entry],
|
||||
);
|
||||
assert.equal(signature.normalized_result.matches[0].file_offset, 0);
|
||||
assert.equal(
|
||||
signature.normalized_result.matches[0].procedure.address,
|
||||
entry,
|
||||
);
|
||||
const procedures = await call("list_procedures");
|
||||
assert.ok(
|
||||
procedures.normalized_result.some(
|
||||
@@ -227,69 +211,6 @@ try {
|
||||
return_42: true,
|
||||
});
|
||||
}
|
||||
const repeatedPath = join(workspace, "signatures.bin");
|
||||
const repeatedBytes = Buffer.from("b82a000000c3".repeat(1005), "hex");
|
||||
const repeatedSha256 = createHash("sha256")
|
||||
.update(repeatedBytes)
|
||||
.digest("hex");
|
||||
await writeFile(repeatedPath, repeatedBytes);
|
||||
await call("open_binary", {
|
||||
path: repeatedPath,
|
||||
format: "raw-binary",
|
||||
architecture: "x86_64",
|
||||
entry_point: "0x1000",
|
||||
});
|
||||
// Import/analysis is complete before timing the first uncached search.
|
||||
await call("read_bytes", { address: "0x1000", length: 6 }, repeatedSha256);
|
||||
const started = performance.now();
|
||||
const page = await call(
|
||||
"search_bytes",
|
||||
{ pattern: "b8 ?? 00 00 00 c3", limit: 1000 },
|
||||
repeatedSha256,
|
||||
);
|
||||
const searchElapsedMs = performance.now() - started;
|
||||
assert.equal(page.normalized_result.matches.length, 1000);
|
||||
assert.equal(page.normalized_result.complete, false);
|
||||
const rest = await call(
|
||||
"search_bytes",
|
||||
{
|
||||
pattern: "b8 ?? 00 00 00 c3",
|
||||
limit: 1000,
|
||||
cursor: page.normalized_result.next_cursor,
|
||||
},
|
||||
repeatedSha256,
|
||||
);
|
||||
assert.equal(rest.normalized_result.matches.length, 5);
|
||||
assert.equal(
|
||||
rest.normalized_result.matches[0].address,
|
||||
`0x${(0x1000 + 6000).toString(16)}`,
|
||||
);
|
||||
assert.equal(rest.normalized_result.complete, true);
|
||||
const masked = await call(
|
||||
"search_bytes",
|
||||
{ pattern: "bf", mask: "f0", start: "0x1000", end: "0x1005" },
|
||||
repeatedSha256,
|
||||
);
|
||||
assert.deepEqual(
|
||||
masked.normalized_result.matches.map((match) => match.address),
|
||||
["0x1000"],
|
||||
);
|
||||
const overlapping = await call(
|
||||
"search_bytes",
|
||||
{ pattern: "00 00", start: "0x1000", end: "0x1005" },
|
||||
repeatedSha256,
|
||||
);
|
||||
assert.deepEqual(
|
||||
overlapping.normalized_result.matches.map((match) => match.address),
|
||||
["0x1002", "0x1003"],
|
||||
);
|
||||
const wrongCursor = await client.callTool({
|
||||
name: "search_bytes",
|
||||
arguments: { pattern: "c3", cursor: page.normalized_result.next_cursor },
|
||||
});
|
||||
assert.equal(wrongCursor.isError, true);
|
||||
await call("close_binary");
|
||||
assert.deepEqual(await readFile(repeatedPath), repeatedBytes);
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
@@ -310,15 +231,6 @@ try {
|
||||
load_images_verified: true,
|
||||
original_unchanged: true,
|
||||
x86_targets: x86Targets,
|
||||
byte_search: {
|
||||
matches: 1005,
|
||||
page_size: 1000,
|
||||
first_search_elapsed_ms: searchElapsedMs,
|
||||
timing_scope: "MCP request after import; first uncached search",
|
||||
token_usage: "unmeasured",
|
||||
process_family_cpu_time: "unmeasured",
|
||||
peak_process_family_memory: "unmeasured",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (stderr.trim()) process.stderr.write(stderr);
|
||||
|
||||
@@ -310,23 +310,7 @@ try {
|
||||
}),
|
||||
);
|
||||
assert.equal(error.category, "invalid_input");
|
||||
const partial = error.details.partial_observation;
|
||||
assert.equal(partial.kind, "retained-evidence");
|
||||
const failedBundleReply = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
assert.notEqual(
|
||||
failedBundleReply.isError,
|
||||
true,
|
||||
mcpTextValue(failedBundleReply),
|
||||
);
|
||||
const failedBundle = JSON.parse(mcpTextValue(failedBundleReply)).result;
|
||||
const failed = parseEvidence(
|
||||
failedBundle.records.find(
|
||||
({ evidence_id }) => evidence_id === partial.evidence_id,
|
||||
),
|
||||
);
|
||||
const failed = parseEvidence(error.details.partial_observation);
|
||||
assert.equal(failed.subject.digest.sha256, sha256(invalidBytes));
|
||||
assert.equal(
|
||||
failed.normalized_result.validation,
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
|
||||
import { join, resolve, isAbsolute } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const bin = process.env.REA_WABT_BIN_DIRECTORY;
|
||||
if (!isAbsolute(bin ?? ""))
|
||||
throw new Error("Supply absolute REA_WABT_BIN_DIRECTORY with WABT 1.0.42");
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-wasm-interface-real-"));
|
||||
const cli = resolve("scripts/rea.mjs");
|
||||
const environment = { ...process.env };
|
||||
const command = async (file, args) =>
|
||||
(
|
||||
await execute(file, args, {
|
||||
env: environment,
|
||||
cwd: root,
|
||||
timeout: 120000,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
})
|
||||
).stdout;
|
||||
const client = new Client({
|
||||
name: "wasm-interface-real-verifier",
|
||||
version: "1",
|
||||
});
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [cli, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const source = (parameter) =>
|
||||
`(module (type (func (param ${parameter}) (result i64))) (import "env" "f" (func (type 0))) (table 1 3 funcref) (memory 1 2) (global (mut i64) (i64.const 0)) (tag (param i32)) (export "run\\22\\ef\\bb\\bf" (func 0)) (export "table" (table 0)) (export "memory" (memory 0)) (export "global" (global 0)) (export "tag" (tag 0)))`;
|
||||
const assemble = async (name, wat) => {
|
||||
await writeFile(join(root, `${name}.wat`), wat);
|
||||
const path = join(root, `${name}.wasm`);
|
||||
await command(join(bin, "wat2wasm"), [
|
||||
"--enable-all",
|
||||
`${name}.wat`,
|
||||
"-o",
|
||||
path,
|
||||
]);
|
||||
return path;
|
||||
};
|
||||
const inspect = async (path) => {
|
||||
const result = await client.callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: { path },
|
||||
});
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
return parseEvidence(result.structuredContent);
|
||||
};
|
||||
const compare = async (left, right, scope = "both") => {
|
||||
const input = {
|
||||
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
|
||||
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
|
||||
scope,
|
||||
};
|
||||
const reply = await client.callTool({
|
||||
name: "compare_wasm_interfaces",
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(reply.isError, true, JSON.stringify(reply));
|
||||
const mcp = parseEvidence(reply.structuredContent);
|
||||
const file = join(root, "comparison.json");
|
||||
await writeFile(file, JSON.stringify({ left, right, scope }));
|
||||
const local = parseEvidence(
|
||||
JSON.parse(
|
||||
await command(process.execPath, [
|
||||
cli,
|
||||
"compare-wasm-interfaces",
|
||||
file,
|
||||
"--json",
|
||||
]),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(local, mcp);
|
||||
return mcp.normalized_result;
|
||||
};
|
||||
try {
|
||||
assert.equal(
|
||||
(await command(join(bin, "wat2wasm"), ["--version"])).trim(),
|
||||
"1.0.42",
|
||||
);
|
||||
await client.connect(transport);
|
||||
const path = await assemble("baseline", source("i32"));
|
||||
const original = await readFile(path);
|
||||
assert.equal(original[8], 1);
|
||||
assert.ok(original[9] < 128);
|
||||
// Section lengths allow padded u32 LEB128 encodings. Different original
|
||||
// byte identities must compare equal after validated type resolution.
|
||||
const equivalentPath = join(root, "equivalent.wasm");
|
||||
await writeFile(
|
||||
equivalentPath,
|
||||
Buffer.concat([
|
||||
original.subarray(0, 9),
|
||||
Buffer.from([original[9] | 128, 0]),
|
||||
original.subarray(10),
|
||||
]),
|
||||
);
|
||||
const left = await inspect(path);
|
||||
const equivalent = await inspect(equivalentPath);
|
||||
assert.notEqual(left.subject.digest.sha256, equivalent.subject.digest.sha256);
|
||||
const same = await compare(left, equivalent);
|
||||
assert.deepEqual(same.summary, {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 0,
|
||||
unchanged: 6,
|
||||
unknown: 0,
|
||||
});
|
||||
const facts = left.normalized_result.interface.entries;
|
||||
assert.deepEqual(facts.find((e) => e.name === "memory").signature, {
|
||||
kind: "memory",
|
||||
page_size: "65536",
|
||||
address_type: "i32",
|
||||
minimum: "1",
|
||||
maximum: "2",
|
||||
shared: false,
|
||||
});
|
||||
assert.deepEqual(facts.find((e) => e.name === "tag").signature, {
|
||||
kind: "tag",
|
||||
params: ["i32"],
|
||||
results: [],
|
||||
});
|
||||
assert.deepEqual(facts.find((e) => e.name === 'run"\ufeff').signature, {
|
||||
kind: "func",
|
||||
params: ["i32"],
|
||||
results: ["i64"],
|
||||
});
|
||||
const changed = await inspect(await assemble("changed", source("i64")));
|
||||
assert.deepEqual((await compare(left, changed)).summary, {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 2,
|
||||
unchanged: 4,
|
||||
unknown: 0,
|
||||
});
|
||||
const smallPage = await inspect(
|
||||
await assemble(
|
||||
"small-page",
|
||||
'(module (memory 1 2 (pagesize 1)) (export "memory" (memory 0)))',
|
||||
),
|
||||
);
|
||||
const defaultPage = await inspect(
|
||||
await assemble(
|
||||
"default-page",
|
||||
'(module (memory 1 2) (export "memory" (memory 0)))',
|
||||
),
|
||||
);
|
||||
assert.equal((await compare(defaultPage, smallPage)).summary.changed, 1);
|
||||
const empty = await inspect(await assemble("empty", "(module)"));
|
||||
assert.equal((await compare(left, empty)).summary.removed, 6);
|
||||
assert.equal((await compare(empty, left, "exports")).summary.added, 5);
|
||||
const duplicate = await inspect(
|
||||
await assemble(
|
||||
"duplicate",
|
||||
'(module (import "e" "same" (func (param i32))) (import "e" "same" (func (param i64))))',
|
||||
),
|
||||
);
|
||||
const duplicates = await compare(duplicate, duplicate, "imports");
|
||||
assert.equal(duplicates.summary.unknown, 1);
|
||||
assert.equal(duplicates.changes[0].left.length, 2);
|
||||
const unsupported = await inspect(
|
||||
await assemble(
|
||||
"exnref",
|
||||
'(module (import "e" "abstract-ref" (global exnref)))',
|
||||
),
|
||||
);
|
||||
assert.equal((await compare(unsupported, unsupported)).summary.unknown, 1);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
verified: true,
|
||||
toolchain: "WABT 1.0.42",
|
||||
transport: "compiled CLI + stdio MCP",
|
||||
interface_fact_bytes: Buffer.byteLength(
|
||||
JSON.stringify(left.normalized_result.interface),
|
||||
),
|
||||
comparison_result_bytes: Buffer.byteLength(JSON.stringify(same)),
|
||||
original_sha256: left.subject.digest.sha256,
|
||||
equivalent_sha256: equivalent.subject.digest.sha256,
|
||||
cases: [
|
||||
"equivalent padded LEB128",
|
||||
"function signature change",
|
||||
"all admitted kinds",
|
||||
"custom memory page-size change",
|
||||
"escaped/BOM name",
|
||||
"empty interface additions/removals",
|
||||
"scope",
|
||||
"duplicate imports",
|
||||
"unsupported exception reference",
|
||||
],
|
||||
runtime_execution: "not-performed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -89,30 +89,6 @@ check(
|
||||
["src/domain/javascript/javascriptSemanticTraversal.ts"],
|
||||
);
|
||||
|
||||
// A producer that pins both the soft and hard ceiling of every resource must take
|
||||
// the vector from one owner. Audited 2026-10-13 against the other prlimit lanes:
|
||||
// src/pcap/PcapNativeBound.ts pairs soft and hard but budgets 300 CPU seconds and
|
||||
// has no resident-input ceiling; src/evm/EvmoleInterfaceProvider.ts,
|
||||
// src/firmware/FirmwareCommand.ts and src/javascript/recovery/WakaruCommand.ts
|
||||
// spell soft-only forms (`--core=0:`, `--core=0`). Those four stay as they are;
|
||||
// a new paired producer calls prlimitArgumentVector instead of spelling options.
|
||||
check(
|
||||
"inline paired prlimit ceilings (use prlimitArgumentVector from src/process/LinuxResourceLimits)",
|
||||
files,
|
||||
/--core=0:0/,
|
||||
["src/process/LinuxResourceLimits.ts", "src/pcap/PcapNativeBound.ts"],
|
||||
);
|
||||
|
||||
// Census is spelling-shaped, like the rules above: a lane that expresses the same
|
||||
// ceilings differently (`1024 ** 3`, camelCase fields) is not caught here, which is
|
||||
// why the four lanes above are listed rather than left to a future reader.
|
||||
check(
|
||||
"inline resident-input budget (use BOUNDED_PRODUCER_LIMITS from src/process/LinuxResourceLimits)",
|
||||
files,
|
||||
/input_bytes:\s*32 \* 1024 \* 1024/,
|
||||
["src/process/LinuxResourceLimits.ts"],
|
||||
);
|
||||
|
||||
// Fixed-arity backtick keys audited 2026-10-10: every prefix field is NUL-free
|
||||
// (hex digests, file paths, enums, numbers, or compositeKey JSON which escapes
|
||||
// NUL), with free-form source strings only in final position. New backtick-\0
|
||||
|
||||
+2
-2
@@ -8,13 +8,13 @@
|
||||
"url": "https://github.com/morluto/rea",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "6.5.0",
|
||||
"version": "6.4.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "npm",
|
||||
"registryBaseUrl": "https://registry.npmjs.org",
|
||||
"identifier": "rea-agents",
|
||||
"version": "6.5.0",
|
||||
"version": "6.4.0",
|
||||
"runtimeHint": "npx",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
|
||||
@@ -1127,68 +1127,6 @@ exit 1`,
|
||||
expect(projected.message).toContain("not installed for 0");
|
||||
});
|
||||
|
||||
// adb exits 1 on a refused package change: `adb install` after printing the
|
||||
// status line to stderr, `cmd package uninstall` after printing it to stdout.
|
||||
it("preserves the device's install refusal when adb exits nonzero", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then
|
||||
printf 'Performing Streamed Install\\n'
|
||||
printf 'adb: failed to install app.apk: Failure [INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.]\\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 1`,
|
||||
);
|
||||
const apk = join(root, "app.apk");
|
||||
await writeFile(apk, "apk");
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
operation: "install_adb_package",
|
||||
input: { serial: "emulator-5554", apk_path: apk, replace: false },
|
||||
});
|
||||
expect(projected).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
details: {
|
||||
reason:
|
||||
"INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.",
|
||||
},
|
||||
});
|
||||
expect(projected.message).toContain(
|
||||
"INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the device's uninstall refusal when adb exits nonzero", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 1; fi
|
||||
exit 1`,
|
||||
);
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
operation: "uninstall_adb_package",
|
||||
input: { serial: "emulator-5554", package: "com.absent" },
|
||||
});
|
||||
expect(projected).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
details: { reason: "not installed for 0" },
|
||||
});
|
||||
expect(projected.message).toContain("not installed for 0");
|
||||
});
|
||||
|
||||
it("keeps a nonzero install without a device verdict as an unreadable result", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'adb: failed to install app.apk: closed\\n' >&2; exit 1; fi
|
||||
exit 1`,
|
||||
);
|
||||
const apk = join(root, "app.apk");
|
||||
await writeFile(apk, "apk");
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
operation: "install_adb_package",
|
||||
input: { serial: "emulator-5554", apk_path: apk, replace: false },
|
||||
});
|
||||
expect(projected).toMatchObject({ code: "unreadable_output" });
|
||||
});
|
||||
|
||||
it("starts an app through resolved launcher component and force-stops it", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
|
||||
+2
-36
@@ -71,7 +71,6 @@ import {
|
||||
parseBugreportCopiedPath,
|
||||
parseDumpsysPackageOutput,
|
||||
parseFeatureListOutput,
|
||||
packageChangeFailureReason,
|
||||
parsePackageChangeOutput,
|
||||
parseProcessListOutput,
|
||||
parsePngDimensions,
|
||||
@@ -249,31 +248,6 @@ const runAdb = async (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* adb exits nonzero when the device refuses a package change, printing the
|
||||
* `Failure [...]` verdict to stderr (install) or stdout (uninstall). Read the
|
||||
* refusal from that failed run so it is not reported as unreadable output.
|
||||
*/
|
||||
const packageChangeRefusal = (
|
||||
operation: string,
|
||||
serial: string,
|
||||
error: AnalysisError,
|
||||
): Result<never, AnalysisError> | undefined => {
|
||||
if (!(error instanceof AnalysisProtocolError)) return undefined;
|
||||
const failure = execFileOutputFailure(error.cause);
|
||||
if (failure === undefined) return undefined;
|
||||
const reason = packageChangeFailureReason(
|
||||
`${failure.stdout}\n${failure.stderr}`,
|
||||
);
|
||||
return reason === null
|
||||
? undefined
|
||||
: err(
|
||||
new AnalysisUnsupportedTargetError(operation, serial, reason, {
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const clientIdentityResult = (
|
||||
selection: AdbBinarySelection,
|
||||
identity: AdbClientIdentity,
|
||||
@@ -1780,11 +1754,7 @@ export class AdbProvider {
|
||||
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
|
||||
signal,
|
||||
);
|
||||
if (!installed.ok)
|
||||
return (
|
||||
packageChangeRefusal(operation, input.serial, installed.error) ??
|
||||
installed
|
||||
);
|
||||
if (!installed.ok) return installed;
|
||||
const verdict = parsePackageChangeOutput(installed.value.stdout);
|
||||
if (verdict.status === "failure")
|
||||
return err(
|
||||
@@ -1833,11 +1803,7 @@ export class AdbProvider {
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (!uninstalled.ok)
|
||||
return (
|
||||
packageChangeRefusal(operation, input.serial, uninstalled.error) ??
|
||||
uninstalled
|
||||
);
|
||||
if (!uninstalled.ok) return uninstalled;
|
||||
const verdict = parsePackageChangeOutput(uninstalled.value.stdout);
|
||||
if (verdict.status === "failure")
|
||||
return err(
|
||||
|
||||
@@ -293,10 +293,6 @@ export const parseBugreportCopiedPath = (stdout: string): string | null => {
|
||||
return match === null ? null : match[1]!;
|
||||
};
|
||||
|
||||
/** Read the device's `Failure [REASON]` verdict from package-change output. */
|
||||
export const packageChangeFailureReason = (output: string): string | null =>
|
||||
/Failure \[([^\]]+)\]/u.exec(output)?.[1] ?? null;
|
||||
|
||||
/**
|
||||
* Parse `adb install`/`adb uninstall` output. adb can print `Failure [...]`
|
||||
* with a zero exit code, so the text decides, not the exit status.
|
||||
@@ -309,11 +305,10 @@ export const parsePackageChangeOutput = (
|
||||
} => {
|
||||
const text = stdout.trim();
|
||||
if (/^Success$/mu.test(text)) return { status: "success", reason: null };
|
||||
const failure = /Failure \[([^\]]+)\]/u.exec(text);
|
||||
return {
|
||||
status: "failure",
|
||||
reason:
|
||||
packageChangeFailureReason(text) ??
|
||||
(text === "" ? null : text.slice(0, 512)),
|
||||
reason: failure?.[1] ?? (text === "" ? null : text.slice(0, 512)),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
|
||||
|
||||
describe("app bundle Info.plist magic", () => {
|
||||
it("does not decode a high-bit fold of bplist as a binary Info.plist", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-bplist-fold-"));
|
||||
try {
|
||||
const bundle = join(root, "Fixture.app");
|
||||
const plistPath = join(bundle, "Contents", "Info.plist");
|
||||
await mkdir(join(bundle, "Contents"), { recursive: true });
|
||||
const plist = Buffer.from("bplist00");
|
||||
plist[0] = (plist[0] ?? 0) | 0x80;
|
||||
await writeFile(plistPath, plist);
|
||||
let decoded = false;
|
||||
const result = await resolveAppBundleExecutable(bundle, {
|
||||
readFile: async () => plist,
|
||||
realpath: async (path) => path,
|
||||
decodeBinaryPlist: async () => {
|
||||
decoded = true;
|
||||
return "Fixture";
|
||||
},
|
||||
});
|
||||
expect(decoded).toBe(false);
|
||||
expect(result.ok).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -162,12 +162,6 @@ export const parseBinaryTarget = async (
|
||||
availableArchitectures: ["x86"],
|
||||
});
|
||||
}
|
||||
// Probe WASM content before suffix-only database routing; an explicit
|
||||
// database selection keeps the caller's chosen interpretation.
|
||||
const artifactFormat =
|
||||
targetKind === "database"
|
||||
? undefined
|
||||
: await detectArtifactFormat(path, handle, signal);
|
||||
if (
|
||||
targetKind === "database" ||
|
||||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
|
||||
@@ -179,6 +173,7 @@ export const parseBinaryTarget = async (
|
||||
kind: "database",
|
||||
format: "analysis-database",
|
||||
});
|
||||
const artifactFormat = await detectArtifactFormat(path, handle, signal);
|
||||
if (artifactFormat !== undefined) {
|
||||
const identity = {
|
||||
path,
|
||||
|
||||
@@ -131,7 +131,7 @@ const browserParameters = (
|
||||
}
|
||||
const observedOrigin = browserResultTargetOrigin(result);
|
||||
const scope = {
|
||||
cdp_endpoint: input.cdp_endpoint.recorded,
|
||||
cdp_endpoint: input.cdp_endpoint,
|
||||
allowed_origins:
|
||||
input.allowed_origins.length > 0
|
||||
? input.allowed_origins
|
||||
|
||||
@@ -110,12 +110,9 @@ const parseDocument = (
|
||||
return requireObject(parseToml(text), "The configuration root");
|
||||
// Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so
|
||||
// comments, trailing commas, a BOM, or an empty existing file must be
|
||||
// rejected instead of registering into a document the client cannot load.
|
||||
// Kimi also reads mcp.json with JSON.parse and an empty-file fallback.
|
||||
if (format === "pi" || format === "kimi") {
|
||||
if (format === "kimi" && text.trim().length === 0) return {};
|
||||
// rejected instead of registering into a document Pi cannot load.
|
||||
if (format === "pi")
|
||||
return requireObject(JSON.parse(text), "The configuration root");
|
||||
}
|
||||
// An empty file, which some clients create before any server is added,
|
||||
// holds no settings to preserve.
|
||||
if (isEmptyClientConfigurationText(text)) return {};
|
||||
@@ -1449,13 +1446,6 @@ export const clientRegistrationEntry = (
|
||||
? {}
|
||||
: { environment: { ...environment } }),
|
||||
};
|
||||
case "kimi":
|
||||
// Kimi infers stdio from command; it uses `transport`, not `type`.
|
||||
return {
|
||||
command: executable,
|
||||
args,
|
||||
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
||||
};
|
||||
case "opencode_v2":
|
||||
// V2 connects servers unless `disabled` is true.
|
||||
return {
|
||||
|
||||
@@ -66,8 +66,6 @@ const registrationSchema = z
|
||||
command: z.string().min(1),
|
||||
args: z.array(z.string()).default([]),
|
||||
startup_timeout_sec: z.number().positive().optional(),
|
||||
startupTimeoutMs: z.number().positive().optional(),
|
||||
toolTimeoutMs: z.number().positive().optional(),
|
||||
type: z.string().optional(),
|
||||
transport: z.string().optional(),
|
||||
serverName: z.string().optional(),
|
||||
@@ -260,21 +258,6 @@ const registrationAligned = (
|
||||
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
client.name === "kimi" &&
|
||||
(registration.startupTimeoutMs !==
|
||||
MCP_STARTUP_POLICY.kimiStartupTimeoutMs ||
|
||||
registration.toolTimeoutMs !== MCP_STARTUP_POLICY.kimiToolTimeoutMs)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
(client.format === "kimi" ||
|
||||
client.format === "omp" ||
|
||||
client.format === "pi") &&
|
||||
registration.type !== undefined &&
|
||||
registration.type !== "stdio"
|
||||
)
|
||||
return false;
|
||||
if (client.format === "vscode" && registration.type !== "stdio") return false;
|
||||
// A Harness registration only connects as the rea stdio server; another
|
||||
// server name or transport leaves the session without REA tools.
|
||||
@@ -283,6 +266,13 @@ const registrationAligned = (
|
||||
(registration.serverName !== "rea" || registration.transport !== "stdio")
|
||||
)
|
||||
return false;
|
||||
// OMP and Pi infer stdio for a command entry without an explicit type.
|
||||
if (
|
||||
(client.format === "omp" || client.format === "pi") &&
|
||||
registration.type !== undefined &&
|
||||
registration.type !== "stdio"
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
client.format === "copilot_cli" &&
|
||||
(registration.type !== "stdio" ||
|
||||
@@ -341,12 +331,6 @@ const parseRegistration = (
|
||||
});
|
||||
}
|
||||
const registration = registrationSchema.parse(value);
|
||||
if (
|
||||
client.format === "kimi" &&
|
||||
registration.transport !== undefined &&
|
||||
registration.transport !== "stdio"
|
||||
)
|
||||
throw new TypeError("Expected an stdio registration");
|
||||
if (
|
||||
(client.format === "vscode" || client.format === "copilot_cli") &&
|
||||
registration.type !== "stdio"
|
||||
@@ -355,9 +339,7 @@ const parseRegistration = (
|
||||
if (client.format === "commandcode" && registration.transport !== "stdio")
|
||||
throw new TypeError("Expected an stdio registration");
|
||||
if (
|
||||
(client.format === "kimi" ||
|
||||
client.format === "omp" ||
|
||||
client.format === "pi") &&
|
||||
(client.format === "omp" || client.format === "pi") &&
|
||||
registration.type !== undefined &&
|
||||
registration.type !== "stdio"
|
||||
)
|
||||
|
||||
@@ -60,7 +60,6 @@ type DirectAnalysisTool =
|
||||
| "annotate_native_function"
|
||||
| "inspect_native_load_image"
|
||||
| "read_bytes"
|
||||
| "search_bytes"
|
||||
| "address_to_file_offset"
|
||||
| "binary_overview"
|
||||
| "procedure_pseudo_code"
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("doctor", () => {
|
||||
),
|
||||
).toEqual({
|
||||
node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
|
||||
host: "REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.",
|
||||
host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
|
||||
target: "Supply a readable local app or program path.",
|
||||
});
|
||||
@@ -418,9 +418,10 @@ describe("doctor scoped readiness", () => {
|
||||
platform: "linux",
|
||||
linuxDistribution: () =>
|
||||
Promise.resolve({
|
||||
id: "nixos",
|
||||
packageFamily: undefined,
|
||||
hopperSetupSupported: false,
|
||||
id: "debian",
|
||||
versionId: "13",
|
||||
packageFamily: "deb",
|
||||
supported: false,
|
||||
}),
|
||||
clientRegistrations: () =>
|
||||
Promise.resolve([
|
||||
@@ -450,31 +451,6 @@ describe("doctor scoped readiness", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a Debian-family host that has no tested Hopper package", async () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
platform: "linux",
|
||||
macosVersion: () => Promise.resolve(undefined),
|
||||
linuxDistribution: () =>
|
||||
Promise.resolve({
|
||||
id: "kali",
|
||||
versionId: "2026.3",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "host",
|
||||
ok: true,
|
||||
detail: "kali 2026.3",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires explicitly selected providers instead of any available provider", async () => {
|
||||
const ghidra = {
|
||||
id: "ghidra",
|
||||
@@ -526,7 +502,7 @@ describe("doctor Linux and Hopper discovery", () => {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: true,
|
||||
supported: true,
|
||||
}),
|
||||
configuredHopperPath: path,
|
||||
executable: (candidate) => Promise.resolve(candidate === path),
|
||||
@@ -546,7 +522,7 @@ describe("doctor Linux and Hopper discovery", () => {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: true,
|
||||
supported: true,
|
||||
}),
|
||||
linuxDemoRuntimeCheck: () =>
|
||||
Promise.resolve({
|
||||
@@ -578,7 +554,7 @@ describe("doctor Linux and Hopper discovery", () => {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: true,
|
||||
supported: true,
|
||||
}),
|
||||
executable: (candidate) => Promise.resolve(candidate === path),
|
||||
supportedLinuxHopper: () => Promise.resolve(false),
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
SUPPORTED_NODE_VERSION_PROSE,
|
||||
supportsNodeVersion,
|
||||
} from "../domain/runtimeVersion.js";
|
||||
import { linuxHopperLauncherPath, supportsReaHost } from "./LinuxHopper.js";
|
||||
import { linuxHopperLauncherPath } from "./LinuxHopper.js";
|
||||
import type {
|
||||
DoctorCheck,
|
||||
DoctorHost,
|
||||
@@ -129,7 +129,7 @@ const hostCheck = async (host: DoctorHost): Promise<DoctorCheck> => {
|
||||
host.platform === "linux" ? await host.linuxDistribution() : undefined;
|
||||
const supported =
|
||||
(host.platform === "darwin" && parseMajor(macosVersion) >= 12) ||
|
||||
(host.platform === "linux" && supportsReaHost(linuxDistribution)) ||
|
||||
(host.platform === "linux" && linuxDistribution?.supported === true) ||
|
||||
(host.platform === "win32" && host.architecture === "x64");
|
||||
const detail =
|
||||
macosVersion ??
|
||||
@@ -138,7 +138,7 @@ const hostCheck = async (host: DoctorHost): Promise<DoctorCheck> => {
|
||||
: `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`);
|
||||
return check("host", supported, detail, {
|
||||
remediation:
|
||||
"REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.",
|
||||
"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
classification: "unsupported_host",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import { CordisExpression, DshCordisYaml } from "./DshCordisYaml.js";
|
||||
export const DSH_ENTRY_ID = "rea-mcp";
|
||||
export const DSH_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client";
|
||||
const LAUNCHER_KEYS = new Set(["serverName", "transport", "command", "args"]);
|
||||
const SELECTOR_KEYS = new Set(["id", "name"]);
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
@@ -296,15 +295,10 @@ export class DshCordisDocument {
|
||||
carried = true;
|
||||
return [this.#entry(desired, invalidated)] as typeof inserted.items;
|
||||
});
|
||||
if (inserted.items.length === 0) copy.delete("insert");
|
||||
// An emptied operation keeps caller fields unless it would patch REA's row.
|
||||
const kept =
|
||||
inserted.items.length > 0 ||
|
||||
(this.#id(op.map) !== DSH_ENTRY_ID &&
|
||||
copy.items.some(
|
||||
(pair) => !SELECTOR_KEYS.has(this.#yaml.value(pair.key) as string),
|
||||
));
|
||||
replacements.set(op.source, kept ? copy : undefined);
|
||||
replacements.set(
|
||||
op.source,
|
||||
inserted.items.length === 0 ? undefined : copy,
|
||||
);
|
||||
}
|
||||
const output = this.#yaml.document.clone();
|
||||
const outputRoot = output.contents;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
linuxPackageManagerCommands,
|
||||
linuxSharedLibrariesAvailable,
|
||||
parseLinuxDistribution,
|
||||
supportsReaHost,
|
||||
type LinuxDistribution,
|
||||
type LinuxHopperDownload,
|
||||
type LinuxHopperInstallHost,
|
||||
@@ -20,7 +19,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: true,
|
||||
supported: true,
|
||||
};
|
||||
archive = new Uint8Array([1, 2, 3]);
|
||||
archiveOk = true;
|
||||
@@ -67,7 +66,7 @@ describe("Linux Hopper host classification", () => {
|
||||
(document, family) => {
|
||||
expect(parseLinuxDistribution(document)).toMatchObject({
|
||||
packageFamily: family,
|
||||
hopperSetupSupported: true,
|
||||
supported: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -100,32 +99,8 @@ describe("Linux Hopper host classification", () => {
|
||||
"ID=garuda\nID_LIKE=arch\n",
|
||||
"ID=omarchy\n",
|
||||
"ID=omarchy\nID_LIKE=debian\n",
|
||||
])(
|
||||
"rejects vendor/version combinations without a tested Hopper installer",
|
||||
(document) => {
|
||||
expect(parseLinuxDistribution(document).hopperSetupSupported).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'ID=kali\nVERSION_ID="2026.3"\nID_LIKE=debian\n',
|
||||
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
|
||||
'ID=linuxmint\nVERSION_ID="22.1"\nID_LIKE="ubuntu debian"\n',
|
||||
"ID=manjaro\nID_LIKE=arch\n",
|
||||
"ID=derivative\nID_LIKE='debian'\n",
|
||||
"ID='debian'\nVERSION_ID='13'\n",
|
||||
])(
|
||||
"hosts REA on a distribution Hopper has no tested package for: %s",
|
||||
(document) => {
|
||||
const distribution = parseLinuxDistribution(document);
|
||||
expect(distribution.hopperSetupSupported).toBe(false);
|
||||
expect(supportsReaHost(distribution)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("withholds host support from a distribution with no package family", () => {
|
||||
expect(supportsReaHost(parseLinuxDistribution("ID=nixos\n"))).toBe(false);
|
||||
expect(supportsReaHost(undefined)).toBe(false);
|
||||
])("rejects unsupported vendor/version combinations", (document) => {
|
||||
expect(parseLinuxDistribution(document).supported).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,7 +166,7 @@ describe("Linux Hopper installation", () => {
|
||||
host.distributionValue = {
|
||||
id: family,
|
||||
packageFamily: family,
|
||||
hopperSetupSupported: true,
|
||||
supported: true,
|
||||
};
|
||||
const result = await installFixture(host);
|
||||
expect(result).toEqual({
|
||||
@@ -219,7 +194,7 @@ describe("Linux Hopper installation", () => {
|
||||
host.distributionValue = {
|
||||
id: "debian",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: false,
|
||||
supported: false,
|
||||
};
|
||||
if (scenario === "package request failure") host.archiveOk = false;
|
||||
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);
|
||||
|
||||
@@ -76,35 +76,19 @@ interface LinuxDistributionIdentity {
|
||||
readonly versionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed Linux distribution information used for package selection.
|
||||
*
|
||||
* `hopperSetupSupported` is the automated Hopper installer's tested list. It is
|
||||
* narrower than the hosts where REA itself runs, which {@link supportsReaHost}
|
||||
* answers from the package family.
|
||||
*/
|
||||
/** Parsed Linux distribution information used for support and package selection. */
|
||||
export type LinuxDistribution = LinuxDistributionIdentity &
|
||||
(
|
||||
| {
|
||||
readonly packageFamily: LinuxPackageFamily;
|
||||
readonly hopperSetupSupported: true;
|
||||
readonly supported: true;
|
||||
}
|
||||
| {
|
||||
readonly packageFamily: LinuxPackageFamily | undefined;
|
||||
readonly hopperSetupSupported: false;
|
||||
readonly supported: false;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether REA's analysis providers run on this distribution. A recognized
|
||||
* package family means a glibc host with a known package layout, which is what
|
||||
* the providers need; downloading and installing the official Hopper package
|
||||
* additionally requires a distribution on its tested list.
|
||||
*/
|
||||
export const supportsReaHost = (
|
||||
distribution: LinuxDistribution | undefined,
|
||||
): boolean => distribution?.packageFamily !== undefined;
|
||||
|
||||
/** Download response kept independent of Node's global fetch implementation. */
|
||||
export interface LinuxHopperDownload {
|
||||
readonly ok: boolean;
|
||||
@@ -157,19 +141,16 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => {
|
||||
const key = match[1];
|
||||
const encoded = match[2];
|
||||
if (key === undefined || encoded === undefined) continue;
|
||||
const quote = encoded[0];
|
||||
const quoted = (quote === "'" || quote === '"') && encoded.endsWith(quote);
|
||||
const value = !quoted
|
||||
? encoded
|
||||
: quote === "'"
|
||||
? encoded.slice(1, -1)
|
||||
: encoded.slice(1, -1).replace(/\\(["\\$`])/gu, "$1");
|
||||
const value =
|
||||
encoded.startsWith('"') && encoded.endsWith('"')
|
||||
? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
|
||||
: encoded;
|
||||
fields.set(key, value);
|
||||
}
|
||||
const id = (fields.get("ID") ?? "unknown").toLowerCase();
|
||||
const versionId = fields.get("VERSION_ID");
|
||||
const packageFamily = packageFamilyFor(id, fields.get("ID_LIKE"));
|
||||
const hopperSetupSupported =
|
||||
const supported =
|
||||
packageFamily !== undefined &&
|
||||
(id === "arch" ||
|
||||
id === "cachyos" ||
|
||||
@@ -181,9 +162,9 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => {
|
||||
id,
|
||||
...(versionId === undefined ? {} : { versionId }),
|
||||
};
|
||||
return hopperSetupSupported
|
||||
? { ...identity, packageFamily, hopperSetupSupported: true }
|
||||
: { ...identity, packageFamily, hopperSetupSupported: false };
|
||||
return supported
|
||||
? { ...identity, packageFamily, supported: true }
|
||||
: { ...identity, packageFamily, supported: false };
|
||||
};
|
||||
|
||||
/** Read and classify the current Linux distribution. */
|
||||
@@ -212,7 +193,7 @@ export const installLinuxHopper = async (
|
||||
if (options.signal?.aborted === true)
|
||||
return { status: "failed", reason: "cancelled" };
|
||||
const distribution = await host.distribution();
|
||||
if (distribution?.hopperSetupSupported !== true)
|
||||
if (distribution?.supported !== true)
|
||||
return { status: "failed", reason: "unsupported_host" };
|
||||
let temporary: string | undefined;
|
||||
try {
|
||||
|
||||
@@ -43,7 +43,7 @@ export const executeProviderOperation = async <
|
||||
provider: ProviderOperationPort<TRequest>,
|
||||
confidence: "observed" | "derived",
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
if (options?.signal?.aborted)
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const request = schema.safeParse({ operation, input });
|
||||
if (!request.success)
|
||||
@@ -51,10 +51,8 @@ export const executeProviderOperation = async <
|
||||
const executed = await provider.execute(request.data, options);
|
||||
if (!executed.ok) return executed;
|
||||
const execution = executed.value;
|
||||
const evidence = createEvidence(
|
||||
execution.subject ?? undefined,
|
||||
execution.provider,
|
||||
{
|
||||
return ok(
|
||||
createEvidence(execution.subject ?? undefined, execution.provider, {
|
||||
operation,
|
||||
parameters: jsonObjectSchema.parse(request.data.input),
|
||||
result: execution.result,
|
||||
@@ -62,16 +60,6 @@ export const executeProviderOperation = async <
|
||||
limitations: execution.limitations,
|
||||
locations: execution.locations,
|
||||
confidence,
|
||||
},
|
||||
}),
|
||||
);
|
||||
// A provider that cannot observe the signal may finish its work after the
|
||||
// caller cancelled, so re-check rather than reporting a completed operation
|
||||
// the caller no longer wants. The observation it completed is still this
|
||||
// provider's Evidence, so the cancellation carries it as the partial
|
||||
// observation instead of discarding a finished result.
|
||||
if (options?.signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { partialObservation: evidence }),
|
||||
);
|
||||
return ok(evidence);
|
||||
};
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("setup workflow", () => {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: true,
|
||||
supported: true,
|
||||
};
|
||||
host.hopper = "/opt/hopper/bin/Hopper";
|
||||
host.linuxDemoRuntimeMissing = true;
|
||||
@@ -79,7 +79,7 @@ describe("setup workflow", () => {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: true,
|
||||
supported: true,
|
||||
};
|
||||
host.hopper = "/custom/Hopper";
|
||||
host.unsupportedHopperVersion = true;
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("setup workflow", () => {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: true,
|
||||
supported: true,
|
||||
};
|
||||
const result = await runSetup(
|
||||
{ ...options(false), structured: false },
|
||||
@@ -162,7 +162,7 @@ describe("setup workflow", () => {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: true,
|
||||
supported: true,
|
||||
};
|
||||
const result = await runSetup(options(true, true), host);
|
||||
expect(result.appliedActions).toEqual(["installed_hopper"]);
|
||||
@@ -236,7 +236,7 @@ describe("setup workflow lifecycle and provider planning", () => {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: true,
|
||||
supported: true,
|
||||
};
|
||||
host.ghidra = "/opt/ghidra_12.1.4_PUBLIC";
|
||||
host.javaHome = "/usr/lib/jvm/jdk-21";
|
||||
|
||||
@@ -310,7 +310,7 @@ describe("setup scoped readiness", () => {
|
||||
id: "debian",
|
||||
versionId: "13",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: false,
|
||||
supported: false,
|
||||
};
|
||||
const result = await runSetup(options(true, true), host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
@@ -326,7 +326,7 @@ describe("setup scoped readiness", () => {
|
||||
id: "debian",
|
||||
versionId: "13",
|
||||
packageFamily: "deb",
|
||||
hopperSetupSupported: false,
|
||||
supported: false,
|
||||
};
|
||||
host.ghidra = "/opt/ghidra_12.1.4_PUBLIC";
|
||||
host.javaHome = "/usr/lib/jvm/jdk-21";
|
||||
|
||||
@@ -704,14 +704,6 @@ const clientConfigurationDesired = (
|
||||
launcher,
|
||||
environment.value,
|
||||
);
|
||||
const previous = parsed?.servers[PRODUCT_IDENTITY.mcpServerKey];
|
||||
if (
|
||||
client.name === "kimi" &&
|
||||
typeof previous === "object" &&
|
||||
previous !== null &&
|
||||
"enabledTools" in previous
|
||||
)
|
||||
registration.enabledTools = previous.enabledTools;
|
||||
return {
|
||||
...registration,
|
||||
...(client.name === "codex" || client.name === "grok_build"
|
||||
@@ -719,11 +711,5 @@ const clientConfigurationDesired = (
|
||||
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||
}
|
||||
: {}),
|
||||
...(client.name === "kimi"
|
||||
? {
|
||||
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
|
||||
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -124,8 +124,7 @@ export const hostRemediation = async (
|
||||
? "Upgrade to macOS 12 or newer."
|
||||
: undefined;
|
||||
}
|
||||
if ((await host.linuxDistribution())?.hopperSetupSupported === true)
|
||||
return undefined;
|
||||
if ((await host.linuxDistribution())?.supported === true) return undefined;
|
||||
return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, and Omarchy; configure an existing supported provider instead.";
|
||||
};
|
||||
|
||||
|
||||
@@ -56,8 +56,7 @@ export const discoverSetupState = async (input: {
|
||||
const hopperInstallSupported =
|
||||
(input.host.platform === "darwin" &&
|
||||
majorVersion((await input.host.macosVersion()) ?? "0") >= 12) ||
|
||||
(input.host.platform === "linux" &&
|
||||
linuxDistribution?.hopperSetupSupported === true);
|
||||
(input.host.platform === "linux" && linuxDistribution?.supported === true);
|
||||
const installHopper =
|
||||
input.forceHopperInstall ||
|
||||
(hopperInstallSupported &&
|
||||
|
||||
@@ -72,15 +72,6 @@ interface CanonicalSkillFile {
|
||||
readonly original: SkillFileState | undefined;
|
||||
}
|
||||
|
||||
const canonicalSkillContent = (relativePath: string): Promise<string> =>
|
||||
readFile(
|
||||
new URL(
|
||||
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
export interface SkillInstallFileOperations {
|
||||
readonly mkdir: (path: string) => Promise<void>;
|
||||
readonly backup: (source: string, destination: string) => Promise<void>;
|
||||
@@ -253,36 +244,19 @@ const canonicalSkillFiles = async (
|
||||
const destination = join(root, relativePath);
|
||||
return {
|
||||
destination,
|
||||
content: await canonicalSkillContent(relativePath),
|
||||
content: await readFile(
|
||||
new URL(
|
||||
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
original: await readOptionalSkillState(destination, resources),
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const canonicalSkillNeedsInstallByReading = async (
|
||||
home: string,
|
||||
clientIds: readonly string[],
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<boolean> => {
|
||||
for (const { path: root } of skillDestinations(
|
||||
home,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
)) {
|
||||
for (const relativePath of MANAGED_SKILL_FILES) {
|
||||
const destination = join(root, relativePath);
|
||||
const content = await canonicalSkillContent(relativePath);
|
||||
const original = await readOptionalSkillState(destination, resources);
|
||||
if (original?.content !== content) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Report whether setup would change any file in the managed REA skill bundle. */
|
||||
export const canonicalSkillNeedsInstall = async (
|
||||
home: string,
|
||||
@@ -292,13 +266,15 @@ export const canonicalSkillNeedsInstall = async (
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
return await canonicalSkillNeedsInstallByReading(
|
||||
home,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
resources,
|
||||
);
|
||||
return (
|
||||
await canonicalSkillFiles(
|
||||
home,
|
||||
clientIds,
|
||||
environment,
|
||||
platform,
|
||||
resources,
|
||||
)
|
||||
).some(({ content, original }) => original?.content !== content);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof RegularFileCleanupFailure ||
|
||||
|
||||
@@ -45,7 +45,6 @@ export interface SetupClient {
|
||||
| "grok"
|
||||
| "omp"
|
||||
| "pi"
|
||||
| "kimi"
|
||||
| "hermes"
|
||||
| "zcode"
|
||||
| "dsh"
|
||||
@@ -166,10 +165,6 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
const codexDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.CODEX_HOME || join(home, ".codex");
|
||||
|
||||
/** Kimi Code stores MCP declarations beside the rest of its home data. */
|
||||
const kimiDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.KIMI_CODE_HOME || join(home, ".kimi-code");
|
||||
|
||||
const qoderDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.QODER_CONFIG_DIR || join(home, ".qoder");
|
||||
|
||||
@@ -581,14 +576,6 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||
markerPath: qwenCodeDirectory,
|
||||
format: "json",
|
||||
},
|
||||
{
|
||||
name: "kimi",
|
||||
displayName: "Kimi Code",
|
||||
configPath: (context: ClientPathContext) =>
|
||||
join(kimiDirectory(context), "mcp.json"),
|
||||
markerPath: kimiDirectory,
|
||||
format: "kimi",
|
||||
},
|
||||
{
|
||||
name: "vscode",
|
||||
displayName: "VS Code",
|
||||
|
||||
@@ -627,16 +627,11 @@ const removeManagedSkills = async (
|
||||
platform: NodeJS.Platform,
|
||||
writeBlocker?: (path: string) => string | undefined,
|
||||
): Promise<UninstallItem> => {
|
||||
// Every destination shares one summary item; preserve both client and path.
|
||||
// Every destination shares one summary item, so each result is labeled by
|
||||
// the directory it refers to.
|
||||
const results = await Promise.all(
|
||||
skillDestinations(home, undefined, environment, platform).map(
|
||||
({ client, path }) =>
|
||||
removeManagedSkill(
|
||||
path,
|
||||
`${client} (${path})`,
|
||||
fileSystem,
|
||||
writeBlocker,
|
||||
),
|
||||
skillDestinations(home, undefined, environment, platform).map(({ path }) =>
|
||||
removeManagedSkill(path, path, fileSystem, writeBlocker),
|
||||
),
|
||||
);
|
||||
const failed = results.find(({ status }) => status === "failed");
|
||||
|
||||
@@ -83,37 +83,3 @@ it("preserves cancellation before execution", async () => {
|
||||
if (result.ok) return;
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
});
|
||||
|
||||
it("preserves a cancellation that races a completed observation", async () => {
|
||||
const controller = new AbortController();
|
||||
const racingPort: ApktoolResourceAnalysisPort = {
|
||||
async inspectAvailability() {
|
||||
return { status: "available", code: null, reason: null, diagnostics: {} };
|
||||
},
|
||||
async close() {},
|
||||
async execute() {
|
||||
// The provider may finish its work before the abort is delivered, so a
|
||||
// successful result is not by itself proof the caller still wants it.
|
||||
controller.abort();
|
||||
return ok(execution);
|
||||
},
|
||||
};
|
||||
const result = await new ApktoolResourceAnalysisService(racingPort).execute(
|
||||
"decode_android_resources",
|
||||
{ path: "/targets/app.apk", include_strings: true },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
// The observation the provider completed is retained on the cancellation, so
|
||||
// a session still records the Evidence instead of losing finished work.
|
||||
const retained = parseEvidence(result.error.partialObservation);
|
||||
expect(retained.operation).toBe("decode_android_resources");
|
||||
expect(retained.provider).toEqual(identity);
|
||||
expect(retained.parameters).toEqual({
|
||||
path: "/targets/app.apk",
|
||||
include_strings: true,
|
||||
});
|
||||
expect(retained.confidence).toBe("observed");
|
||||
});
|
||||
|
||||
@@ -283,9 +283,7 @@ export class BinarySession
|
||||
previous.route.identity.id,
|
||||
);
|
||||
if (!closed.ok) {
|
||||
// A failed open retires the provider cache, not the investigation.
|
||||
// Preserve Evidence and Unknown history for export and recovery.
|
||||
this.clearSnapshot();
|
||||
this.clearSessionRecords();
|
||||
this.#clearRuntimeAvailability();
|
||||
return closed;
|
||||
}
|
||||
|
||||
@@ -30,29 +30,6 @@ it("labels an envelope containing debugger-derived maps as derived", async () =>
|
||||
expect(evidence.raw_result).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["x86_64-little-endian", "x86_64"],
|
||||
["aarch64-little-endian", "arm64"],
|
||||
] as const)(
|
||||
"names the CPU family carried by a %s core on created Evidence",
|
||||
async (architecture, expected) => {
|
||||
const fixture = recordedCrashFixture("/selected.core", architecture);
|
||||
const service = new RecordedCrashService({
|
||||
inspectAndClose(input, options) {
|
||||
return this.inspect(input, options);
|
||||
},
|
||||
identity: RECORDED_CRASH_TEST_PROVIDER,
|
||||
inspect: () => Promise.resolve(ok(fixture)),
|
||||
});
|
||||
const result = await service.inspect({
|
||||
path: fixture.artifact.path,
|
||||
include_debugger_context: false,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(parseEvidence(result.value).subject?.architecture).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ path: "relative.elf" },
|
||||
{ path: "/selected.core", approval: true },
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
inspectRecordedCrashInputSchema,
|
||||
RECORDED_LINUX_CORE_PROFILES,
|
||||
recordedCrashSchema,
|
||||
} from "../../domain/native/recordedCrash.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
@@ -102,9 +101,7 @@ export class RecordedCrashService {
|
||||
{
|
||||
path: value.artifact.path,
|
||||
format: "elf",
|
||||
architecture:
|
||||
RECORDED_LINUX_CORE_PROFILES[value.architecture]
|
||||
.evidenceArchitecture,
|
||||
architecture: "x86_64",
|
||||
sha256: value.artifact.sha256,
|
||||
},
|
||||
this.provider.identity,
|
||||
|
||||
@@ -100,10 +100,4 @@ const parameters = (
|
||||
...(input.source_paths === undefined
|
||||
? {}
|
||||
: { source_paths: input.source_paths }),
|
||||
...(input.max_heap_mb === undefined
|
||||
? {}
|
||||
: { max_heap_mb: input.max_heap_mb }),
|
||||
...(input.analysis_timeout_ms === undefined
|
||||
? {}
|
||||
: { analysis_timeout_ms: input.analysis_timeout_ms }),
|
||||
});
|
||||
|
||||
@@ -62,7 +62,26 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
});
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
reconstructed = await reconstructJavaScriptArtifact(
|
||||
input,
|
||||
{
|
||||
input_path: input.input_path,
|
||||
format: input.format,
|
||||
integrity_policy: input.integrity_policy,
|
||||
...(input.max_semantic_nodes === undefined
|
||||
? {}
|
||||
: { max_semantic_nodes: input.max_semantic_nodes }),
|
||||
...(input.max_semantic_nodes_per_file === undefined
|
||||
? {}
|
||||
: { max_semantic_nodes_per_file: input.max_semantic_nodes_per_file }),
|
||||
...(input.source_paths === undefined
|
||||
? {}
|
||||
: { source_paths: input.source_paths }),
|
||||
...(input.max_heap_mb === undefined
|
||||
? {}
|
||||
: { max_heap_mb: input.max_heap_mb }),
|
||||
...(input.analysis_timeout_ms === undefined
|
||||
? {}
|
||||
: { analysis_timeout_ms: input.analysis_timeout_ms }),
|
||||
},
|
||||
scope,
|
||||
options.signal,
|
||||
options.progress,
|
||||
|
||||
@@ -553,10 +553,10 @@ const collectSourceMapOriginals = (
|
||||
if (!Array.isArray(names))
|
||||
return invalidSourceMap(file, "Source map has no sources array.");
|
||||
const contents = map.sourcesContent;
|
||||
if (!hasValidSourceMapContents(contents))
|
||||
if (!hasValidSourceMapContents(names.length, contents))
|
||||
return invalidSourceMap(
|
||||
file,
|
||||
"Source map sourcesContent must be an array of strings or null entries.",
|
||||
"Source map sourcesContent must contain one string or null entry per source.",
|
||||
);
|
||||
if (map.sourceRoot != null && typeof map.sourceRoot !== "string")
|
||||
return invalidSourceMap(
|
||||
|
||||
@@ -457,8 +457,9 @@ const bareModuleCandidate = (
|
||||
dependencyDirectory = posix.dirname(confined);
|
||||
}
|
||||
const source = input.files.get(input.sourcePath);
|
||||
let directory = posix.dirname(input.sourcePath);
|
||||
let nearestNotFound: CandidateResolution | null = null;
|
||||
for (const directory of packageLookupDirectories(input)) {
|
||||
while (true) {
|
||||
const candidate = posix.join(directory, dependencyDirectory);
|
||||
const metadata = readPackageMetadata(input, candidate);
|
||||
const continueCommonJsLookup =
|
||||
@@ -486,6 +487,8 @@ const bareModuleCandidate = (
|
||||
nearestNotFound ??= packageResolution;
|
||||
} else return packageResolution;
|
||||
}
|
||||
if (directory === "." || directory === "") break;
|
||||
directory = posix.dirname(directory);
|
||||
}
|
||||
if (nearestNotFound !== null) return nearestNotFound;
|
||||
return unresolvedCandidate("external", [
|
||||
@@ -493,21 +496,6 @@ const bareModuleCandidate = (
|
||||
]);
|
||||
};
|
||||
|
||||
function* packageLookupDirectories(
|
||||
input: ResolveArtifactPathInput,
|
||||
): Generator<string> {
|
||||
let directory = posix.dirname(input.sourcePath);
|
||||
while (true) {
|
||||
if (
|
||||
input.moduleKind !== "require" ||
|
||||
posix.basename(directory) !== "node_modules"
|
||||
)
|
||||
yield directory;
|
||||
if (directory === "." || directory === "") return;
|
||||
directory = posix.dirname(directory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest same-container package.json, stopping at a `node_modules` directory
|
||||
* so a nested package cannot inherit an ancestor's map.
|
||||
@@ -972,29 +960,15 @@ const resolveCandidate = (
|
||||
candidate: string,
|
||||
packageChain: ReadonlySet<string>,
|
||||
emittedSourceCandidates: readonly TypeScriptEmittedSourceCandidate[] = [],
|
||||
): CandidateResolution => {
|
||||
if (!commonJsDirectoryRequest(input)) {
|
||||
const file =
|
||||
resolveFileCandidates(
|
||||
input,
|
||||
fileCandidates(candidate, input.moduleKind),
|
||||
) ??
|
||||
resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates);
|
||||
if (file !== null) return file;
|
||||
}
|
||||
return resolveDirectory(
|
||||
): CandidateResolution =>
|
||||
resolveFileCandidates(input, fileCandidates(candidate, input.moduleKind)) ??
|
||||
resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates) ??
|
||||
resolveDirectory(
|
||||
input,
|
||||
candidate,
|
||||
readPackageMetadata(input, candidate),
|
||||
packageChain,
|
||||
);
|
||||
};
|
||||
|
||||
const commonJsDirectoryRequest = (input: ResolveArtifactPathInput): boolean =>
|
||||
input.context === "module-specifier" &&
|
||||
input.moduleKind === "require" &&
|
||||
(input.declaredPath.endsWith("/") ||
|
||||
/(?:^|\/)\.{1,2}$/u.test(input.declaredPath));
|
||||
|
||||
const resolveDirectory = (
|
||||
input: ResolveArtifactPathInput,
|
||||
|
||||
@@ -277,9 +277,15 @@ export const reconstructJavaScriptArtifact = async (
|
||||
limitations: completed.graph.limitations,
|
||||
}),
|
||||
);
|
||||
const { max_heap_mb, analysis_timeout_ms, ...coreInput } = input;
|
||||
const parameters = {
|
||||
...coreInput,
|
||||
...(max_heap_mb === undefined ? {} : { max_heap_mb }),
|
||||
...(analysis_timeout_ms === undefined ? {} : { analysis_timeout_ms }),
|
||||
};
|
||||
const evidence =
|
||||
await createOwnedJavaScriptApplicationEvidenceCooperatively(
|
||||
input,
|
||||
parameters,
|
||||
partial,
|
||||
);
|
||||
throw interruption.retainPartialObservation(evidence);
|
||||
|
||||
@@ -23,20 +23,6 @@ import type { Result } from "../../domain/result.js";
|
||||
|
||||
const target = "a".repeat(64);
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const methodName = "-[BuildViewController buildTapped:]";
|
||||
const methodSymbol = {
|
||||
primary: true,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "function",
|
||||
source: "user_defined",
|
||||
};
|
||||
const methodInventoryEntry = {
|
||||
address: "0x1000",
|
||||
value: methodName,
|
||||
symbol: methodSymbol,
|
||||
};
|
||||
|
||||
const evidence = [
|
||||
{
|
||||
kind: "interface_builder_resource" as const,
|
||||
@@ -213,49 +199,6 @@ const testAnalysis = (
|
||||
});
|
||||
|
||||
describe("native UI action trace", () => {
|
||||
it("retains Hopper targetless call evidence alongside fallback callee routes", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
procedure_address: () => execution("procedure_address", "0x1000"),
|
||||
procedure_references: (parameters) =>
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
reference_kinds_available: false,
|
||||
references: [],
|
||||
unresolved_calls:
|
||||
parameters.procedure === "0x1000"
|
||||
? [{ address: "0x1008", reason: "No static target" }]
|
||||
: [],
|
||||
},
|
||||
{ id: "hopper", name: "Hopper", version: "fixture" },
|
||||
),
|
||||
),
|
||||
}),
|
||||
).execute("trace_native_ui_action", { action: "0x1000" });
|
||||
if (!result.ok) throw result.error;
|
||||
const trace = nativeInvestigationTraceSchema.parse(result.value);
|
||||
expect(trace.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "provider_call",
|
||||
to: "native:function:0x2000",
|
||||
}),
|
||||
);
|
||||
expect(trace.unresolved).toContainEqual(
|
||||
expect.objectContaining({
|
||||
from: "native:function:0x1000",
|
||||
to: null,
|
||||
reason: "No static target",
|
||||
evidence: [
|
||||
expect.objectContaining({
|
||||
description: "Hopper reported an unresolved call: No static target",
|
||||
location: { address: "0x1008", file_offset: null },
|
||||
artifact_sha256: target,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("accepts an explicit native seed without an authored UI connection", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
@@ -272,12 +215,8 @@ describe("native UI action trace", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
it("builds the evidence path from provider names with declared symbol facts", async () => {
|
||||
const tools = new EnhancedTools(
|
||||
testAnalysis({
|
||||
list_names: () => execution("list_names", [methodInventoryEntry]),
|
||||
}),
|
||||
);
|
||||
it("builds the evidence path from the active app and native provider", async () => {
|
||||
const tools = new EnhancedTools(testAnalysis());
|
||||
const result = await tools.execute("trace_native_ui_action", {
|
||||
action: "buildTapped:",
|
||||
});
|
||||
@@ -410,117 +349,18 @@ describe("bounded native UI tracing", () => {
|
||||
error: { _tag: "AnalysisInputError" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("native dispatch inventory", () => {
|
||||
it.each([
|
||||
{ label: "plain value", entry: { address: "0x1000", value: methodName } },
|
||||
{ label: "value with symbol facts", entry: methodInventoryEntry },
|
||||
{
|
||||
label: "legacy name with symbol facts",
|
||||
entry: { address: "0x1000", name: methodName, symbol: methodSymbol },
|
||||
},
|
||||
])(
|
||||
"binds $label inventory to the target without overstating dispatch coverage",
|
||||
async ({ entry }) => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
list_names: () => execution("list_names", [entry]),
|
||||
}),
|
||||
).execute("inspect_native_dispatch_metadata", { max_records: 10 });
|
||||
it("binds dispatch metadata to the active target digest", async () => {
|
||||
const result = await new EnhancedTools(testAnalysis()).execute(
|
||||
"inspect_native_dispatch_metadata",
|
||||
{ max_records: 10 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
target_sha256: target,
|
||||
provider,
|
||||
result: {
|
||||
objc_dispatch_implementations: [
|
||||
{
|
||||
class_name: "BuildViewController",
|
||||
selector: "buildTapped:",
|
||||
implementation_address: "0x1000",
|
||||
decode: { status: "partial", reason: "symbol_name_only" },
|
||||
},
|
||||
],
|
||||
coverage: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
facet: "objc_dispatch_implementations",
|
||||
status: "partial",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
facet: "swift_dispatch_tables",
|
||||
status: "unsupported",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
facet: "relative_pointers",
|
||||
status: "unsupported",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "malformed symbol flag",
|
||||
entry: {
|
||||
...methodInventoryEntry,
|
||||
symbol: { ...methodSymbol, primary: "true" },
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "unknown symbol source",
|
||||
entry: {
|
||||
...methodInventoryEntry,
|
||||
symbol: { ...methodSymbol, source: "unknown" },
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "incomplete symbol",
|
||||
entry: { ...methodInventoryEntry, symbol: { primary: true } },
|
||||
},
|
||||
{
|
||||
label: "malformed legacy symbol",
|
||||
entry: { address: "0x1000", name: methodName, symbol: null },
|
||||
},
|
||||
{
|
||||
label: "malformed address",
|
||||
entry: { ...methodInventoryEntry, address: 4096 },
|
||||
},
|
||||
{
|
||||
label: "malformed value with a legacy name",
|
||||
entry: { ...methodInventoryEntry, value: 42, name: methodName },
|
||||
},
|
||||
{
|
||||
label: "undeclared field",
|
||||
entry: { address: "0x1000", value: methodName, unexpected: true },
|
||||
},
|
||||
])(
|
||||
"rejects $label inventory in both dispatch consumers",
|
||||
async ({ entry }) => {
|
||||
const tools = new EnhancedTools(
|
||||
testAnalysis({
|
||||
list_names: () => execution("list_names", [entry]),
|
||||
}),
|
||||
);
|
||||
for (const [operation, parameters] of [
|
||||
["inspect_native_dispatch_metadata", { max_records: 10 }],
|
||||
["trace_native_ui_action", { action: "buildTapped:" }],
|
||||
] as const) {
|
||||
expect(await tools.execute(operation, parameters)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisOutputError",
|
||||
operation: "list_names",
|
||||
reason: "provider returned an invalid inventory",
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { target_sha256: target },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("native UI query outcomes", () => {
|
||||
|
||||
@@ -7,59 +7,6 @@ import { err, ok } from "../../domain/result.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
describe("untyped native call route fallback", () => {
|
||||
it.each([{ callees: [] }, { callees: ["0x2000"] }])(
|
||||
"preserves targetless calls and their provider when fallback callees are $callees",
|
||||
async ({ callees }) => {
|
||||
const calls: string[] = [];
|
||||
const referenceProvider = { ...provider, id: "hopper", name: "Hopper" };
|
||||
const fallbackProvider = { ...provider, id: "fallback" };
|
||||
const signal = new AbortController().signal;
|
||||
const unknown = { address: "0x1008", reason: "No static target" };
|
||||
const result = await readNativeCallRoutes(
|
||||
{
|
||||
execute: async (operation, parameters, options) => {
|
||||
calls.push(operation);
|
||||
expect(parameters.procedure).toBe("0x1000");
|
||||
expect(options).toEqual({ signal, selectorOrigin: "provider" });
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
operation === "procedure_references"
|
||||
? {
|
||||
reference_kinds_available: false,
|
||||
references: [],
|
||||
unresolved_calls: [unknown],
|
||||
}
|
||||
: callees,
|
||||
operation === "procedure_references"
|
||||
? referenceProvider
|
||||
: fallbackProvider,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
"0x1000",
|
||||
signal,
|
||||
"provider",
|
||||
);
|
||||
expect(calls).toEqual(["procedure_references", "procedure_callees"]);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
provider: fallbackProvider,
|
||||
routes: callees.map((address) => ({
|
||||
address,
|
||||
site: "0x1000",
|
||||
relation: "provider_call",
|
||||
resolution: "inferred",
|
||||
})),
|
||||
unknowns: [{ ...unknown, provider: referenceProvider }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("typed native call routes", () => {
|
||||
it("preserves computed targets, ambiguity and targetless call sites", async () => {
|
||||
const reference = jsonValueSchema.parse(ghidraReferenceEdge());
|
||||
|
||||
@@ -2,12 +2,12 @@ import { z } from "zod";
|
||||
import type {
|
||||
AnalysisOperationPort,
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import {
|
||||
parseRelatedAddresses,
|
||||
referenceKindSchema,
|
||||
} from "../../domain/hopperValues.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const referencesSchema = z.object({
|
||||
@@ -40,29 +40,15 @@ export const readNativeCallRoutes = async (
|
||||
{ procedure, direction: "outgoing" },
|
||||
options,
|
||||
);
|
||||
let unknowns: Array<{
|
||||
address: string;
|
||||
reason: string;
|
||||
provider: ProviderIdentity;
|
||||
}> = [];
|
||||
if (execution.ok) {
|
||||
const parsed = referencesSchema.safeParse(execution.value.result);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
ok: false as const,
|
||||
error: new AnalysisOutputError(
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"procedure_references",
|
||||
"Invalid typed call references",
|
||||
),
|
||||
unknowns,
|
||||
};
|
||||
// Missing detailed reference flags do not invalidate observed targetless
|
||||
// calls. Keep their producing provider even if another provider supplies
|
||||
// the fallback callee inventory.
|
||||
unknowns = parsed.data.unresolved_calls.map((call) => ({
|
||||
...call,
|
||||
provider: execution.value.provider,
|
||||
}));
|
||||
);
|
||||
const references = parsed.data.references;
|
||||
if (
|
||||
parsed.data.reference_kinds_available === true ||
|
||||
@@ -78,52 +64,45 @@ export const readNativeCallRoutes = async (
|
||||
targets.add(call.target_address);
|
||||
countBySite.set(call.source_address, targets);
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
value: {
|
||||
provider: execution.value.provider,
|
||||
routes: calls.map((call) => ({
|
||||
address: call.target_procedure?.address ?? call.target_address,
|
||||
site: call.source_address,
|
||||
relation:
|
||||
call.kind.available && (call.kind.computed || call.kind.indirect)
|
||||
? ("indirect_call" as const)
|
||||
: ("direct_call" as const),
|
||||
resolution:
|
||||
(countBySite.get(call.source_address)?.size ?? 0) > 1
|
||||
? ("ambiguous" as const)
|
||||
: call.kind.available &&
|
||||
(call.kind.computed || call.kind.indirect)
|
||||
? ("resolved" as const)
|
||||
: ("observed" as const),
|
||||
})),
|
||||
unknowns,
|
||||
},
|
||||
};
|
||||
return ok({
|
||||
provider: execution.value.provider,
|
||||
routes: calls.map((call) => ({
|
||||
address: call.target_procedure?.address ?? call.target_address,
|
||||
site: call.source_address,
|
||||
relation:
|
||||
call.kind.available && (call.kind.computed || call.kind.indirect)
|
||||
? ("indirect_call" as const)
|
||||
: ("direct_call" as const),
|
||||
resolution:
|
||||
(countBySite.get(call.source_address)?.size ?? 0) > 1
|
||||
? ("ambiguous" as const)
|
||||
: call.kind.available &&
|
||||
(call.kind.computed || call.kind.indirect)
|
||||
? ("resolved" as const)
|
||||
: ("observed" as const),
|
||||
})),
|
||||
unknowns: parsed.data.unresolved_calls,
|
||||
});
|
||||
}
|
||||
} else if (execution.error._tag !== "AnalysisCapabilityUnavailableError")
|
||||
return { ok: false as const, error: execution.error, unknowns };
|
||||
return err(execution.error);
|
||||
const callees = await analysis.execute(
|
||||
"procedure_callees",
|
||||
{ procedure },
|
||||
options,
|
||||
);
|
||||
if (!callees.ok)
|
||||
return { ok: false as const, error: callees.error, unknowns };
|
||||
if (!callees.ok) return callees;
|
||||
const parsed = parseRelatedAddresses(callees.value.result, "callees");
|
||||
return parsed.ok
|
||||
? {
|
||||
ok: true as const,
|
||||
value: {
|
||||
provider: callees.value.provider,
|
||||
routes: parsed.value.map((address) => ({
|
||||
address,
|
||||
site: procedure,
|
||||
relation: "provider_call" as const,
|
||||
resolution: "inferred" as const,
|
||||
})),
|
||||
unknowns,
|
||||
},
|
||||
}
|
||||
: { ok: false as const, error: parsed.error, unknowns };
|
||||
? ok({
|
||||
provider: callees.value.provider,
|
||||
routes: parsed.value.map((address) => ({
|
||||
address,
|
||||
site: procedure,
|
||||
relation: "provider_call" as const,
|
||||
resolution: "inferred" as const,
|
||||
})),
|
||||
unknowns: [],
|
||||
})
|
||||
: parsed;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { addressedValue } from "../../contracts/toolOutputSchemaPrimitives.js";
|
||||
import {
|
||||
nativeDispatchMetadataResultSchema,
|
||||
inspectNativeDispatchMetadata,
|
||||
@@ -37,11 +36,8 @@ export const inspectNativeDispatch = async (
|
||||
const names = z
|
||||
.array(
|
||||
z.union([
|
||||
addressedValue.strict(),
|
||||
addressedValue
|
||||
.omit({ value: true })
|
||||
.extend({ name: z.string() })
|
||||
.strict(),
|
||||
z.strictObject({ address: z.string(), value: z.string() }),
|
||||
z.strictObject({ address: z.string(), name: z.string() }),
|
||||
]),
|
||||
)
|
||||
.safeParse(execution.value.result);
|
||||
|
||||
@@ -414,44 +414,9 @@ const addDirectCallees = async (input: {
|
||||
"provider",
|
||||
);
|
||||
examined += 1;
|
||||
if (!execution.ok && execution.error._tag === "AnalysisCancelledError")
|
||||
return { error: execution.error };
|
||||
const unknowns = execution.ok
|
||||
? execution.value.unknowns
|
||||
: execution.unknowns;
|
||||
for (const [index, unknown] of unknowns.entries()) {
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted += unknowns.length - index;
|
||||
break;
|
||||
}
|
||||
const id = `unknown-dispatch:${current.address}:${unknown.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
from: current.node.id,
|
||||
to: null,
|
||||
relation: "indirect_call",
|
||||
resolution: "unresolved",
|
||||
reason: unknown.reason,
|
||||
evidence: [
|
||||
{
|
||||
kind: "reference",
|
||||
provider: unknown.provider,
|
||||
description: `${unknown.provider.name} reported an unresolved call: ${unknown.reason}`,
|
||||
location: { address: unknown.address, file_offset: null },
|
||||
artifact_path: null,
|
||||
artifact_sha256: input.targetSha256,
|
||||
},
|
||||
],
|
||||
limitations: [],
|
||||
});
|
||||
}
|
||||
if (!execution.ok) {
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted += 1;
|
||||
continue;
|
||||
}
|
||||
if (execution.error._tag === "AnalysisCancelledError")
|
||||
return { error: execution.error };
|
||||
const id = `unknown-callees:${current.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
@@ -467,6 +432,32 @@ const addDirectCallees = async (input: {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const unknown of execution.value.unknowns) {
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted++;
|
||||
break;
|
||||
}
|
||||
const id = `unknown-dispatch:${current.address}:${unknown.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
from: current.node.id,
|
||||
to: null,
|
||||
relation: "indirect_call",
|
||||
resolution: "unresolved",
|
||||
reason: unknown.reason,
|
||||
evidence: [
|
||||
{
|
||||
kind: "reference",
|
||||
description: unknown.reason,
|
||||
location: { address: unknown.address, file_offset: null },
|
||||
artifact_path: null,
|
||||
artifact_sha256: input.targetSha256,
|
||||
},
|
||||
],
|
||||
limitations: [],
|
||||
});
|
||||
}
|
||||
for (const [index, route] of execution.value.routes.entries()) {
|
||||
const address = route.address;
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
@@ -478,7 +469,6 @@ const addDirectCallees = async (input: {
|
||||
const evidence = [
|
||||
{
|
||||
kind: "reference" as const,
|
||||
provider: execution.value.provider,
|
||||
description: `${execution.value.provider.name} reported ${route.relation} from ${current.address} to ${address}`,
|
||||
location: { address: route.site, file_offset: null },
|
||||
artifact_path: null,
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { wasmInterfaceComparisonSchema } from "../../contracts/wasm/wasmComparisonContracts.js";
|
||||
import { compareWasmInterfaces } from "./WasmInterfaceComparisonService.js";
|
||||
import type { WasmInterface } from "../../domain/wasm/wasmInterface.js";
|
||||
import { wasmArtifactFixture } from "../../../tests/fixtures/wasm/artifact.js";
|
||||
|
||||
const facts = (
|
||||
name: string,
|
||||
parameter: "i32" | "i64" = "i32",
|
||||
): WasmInterface => ({
|
||||
state: "complete",
|
||||
issues: [],
|
||||
entries: [
|
||||
{
|
||||
direction: "export",
|
||||
module: null,
|
||||
name,
|
||||
kind: "func",
|
||||
index: 0,
|
||||
signature: { kind: "func", params: [parameter], results: [] },
|
||||
wat_form: `(export "${name}" (func 0))`,
|
||||
issue: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const evidence = (interfaceFacts?: WasmInterface) => {
|
||||
const report = wasmArtifactFixture();
|
||||
if (interfaceFacts !== undefined) report.interface = interfaceFacts;
|
||||
return createEvidence(
|
||||
{
|
||||
path: report.artifact.path,
|
||||
sha256: report.artifact.sha256,
|
||||
format: "file",
|
||||
},
|
||||
{ id: "fixture", name: "typed fixture", version: "1" },
|
||||
{
|
||||
operation: "inspect_wasm_artifact",
|
||||
parameters: { path: report.artifact.path },
|
||||
result: report,
|
||||
confidence: "observed",
|
||||
},
|
||||
);
|
||||
};
|
||||
const compare = async (
|
||||
left: ReturnType<typeof evidence>,
|
||||
right: ReturnType<typeof evidence>,
|
||||
) => {
|
||||
const result = await compareWasmInterfaces({ left, right, scope: "both" });
|
||||
if (!result.ok) throw result.error;
|
||||
return wasmInterfaceComparisonSchema.parse(result.value.normalized_result);
|
||||
};
|
||||
|
||||
it("reports all five comparison states, including duplicate ambiguity and explicit selected scope", async () => {
|
||||
const left = facts("changed");
|
||||
const right = facts("changed", "i64");
|
||||
left.entries.push(
|
||||
...facts("unchanged").entries,
|
||||
...facts("removed").entries,
|
||||
...facts("dup").entries,
|
||||
...facts("dup").entries,
|
||||
);
|
||||
right.entries.push(
|
||||
...facts("unchanged").entries,
|
||||
...facts("added").entries,
|
||||
...facts("dup").entries,
|
||||
);
|
||||
const report = await compare(evidence(left), evidence(right));
|
||||
expect(report.summary).toEqual({
|
||||
changed: 1,
|
||||
unchanged: 1,
|
||||
removed: 1,
|
||||
added: 1,
|
||||
unknown: 1,
|
||||
});
|
||||
expect(report.changes.find((c) => c.name === "dup")).toMatchObject({
|
||||
state: "unknown",
|
||||
left: [expect.anything(), expect.anything()],
|
||||
right: [expect.anything()],
|
||||
});
|
||||
const selected = await compareWasmInterfaces({
|
||||
left: evidence(left),
|
||||
right: evidence(right),
|
||||
scope: "imports",
|
||||
});
|
||||
if (!selected.ok) throw selected.error;
|
||||
expect(selected.value.normalized_result).toMatchObject({
|
||||
changes: [],
|
||||
coverage: "complete-within-inputs",
|
||||
});
|
||||
});
|
||||
|
||||
it("withholds absence claims for historical or partial inventories and unsupported signatures", async () => {
|
||||
const known = facts("f");
|
||||
const absent = await compare(evidence(), evidence(known));
|
||||
expect(absent).toMatchObject({
|
||||
coverage: "partial",
|
||||
changes: [{ state: "unknown" }],
|
||||
});
|
||||
const partial: WasmInterface = {
|
||||
state: "partial",
|
||||
issues: ["unread import table"],
|
||||
entries: [],
|
||||
};
|
||||
expect(await compare(evidence(partial), evidence(known))).toMatchObject({
|
||||
changes: [{ state: "unknown" }],
|
||||
});
|
||||
const unsupported = facts("f");
|
||||
unsupported.entries[0]!.signature = null;
|
||||
expect(await compare(evidence(known), evidence(unsupported))).toMatchObject({
|
||||
changes: [{ state: "unknown" }],
|
||||
});
|
||||
const partialMatch = facts("f");
|
||||
partialMatch.state = "partial";
|
||||
partialMatch.issues = ["Later imports were not examined"];
|
||||
expect(await compare(evidence(partialMatch), evidence(known))).toMatchObject({
|
||||
changes: [{ state: "unknown" }],
|
||||
});
|
||||
expect(await compare(evidence(), evidence())).toMatchObject({
|
||||
coverage: "unknown",
|
||||
changes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("authenticates semantic Evidence and binds display subject and selection identity", async () => {
|
||||
const left = JSON.parse(JSON.stringify(evidence(facts("f"))));
|
||||
left.normalized_result.interface.entries[0].name = "tampered";
|
||||
expect(
|
||||
await compareWasmInterfaces({
|
||||
left,
|
||||
right: evidence(facts("f")),
|
||||
scope: "both",
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
const rebound = JSON.parse(JSON.stringify(evidence(facts("f"))));
|
||||
rebound.subject.local_path = "/other.wasm";
|
||||
expect(
|
||||
await compareWasmInterfaces({
|
||||
left: rebound,
|
||||
right: evidence(facts("f")),
|
||||
scope: "both",
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
});
|
||||
|
||||
it("resolves exact retained references and permits cancellation between comparison batches", async () => {
|
||||
const left = evidence(facts("f"));
|
||||
const right = evidence(facts("f", "i64"));
|
||||
const references = {
|
||||
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
|
||||
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
|
||||
scope: "both",
|
||||
};
|
||||
expect(await compareWasmInterfaces(references)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "EvidenceIntegrityError" },
|
||||
});
|
||||
const result = await compareWasmInterfaces(references, {}, (id) =>
|
||||
id === left.evidence_id ? left : right,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.evidence_links).toEqual([
|
||||
left.evidence_id,
|
||||
right.evidence_id,
|
||||
]);
|
||||
const large = facts("f");
|
||||
large.entries.push(
|
||||
...Array.from({ length: 2000 }, (_, i) => facts(`f${i}`).entries[0]!),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const pending = compareWasmInterfaces(
|
||||
{ left: evidence(large), right: evidence(large), scope: "both" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
setImmediate(() => controller.abort());
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps a one-sided unsupported interface unknown with reverse=%s",
|
||||
async (reverse) => {
|
||||
const empty: WasmInterface = { state: "complete", issues: [], entries: [] };
|
||||
const unsupported = facts("exception");
|
||||
unsupported.entries[0]!.signature = null;
|
||||
unsupported.entries[0]!.issue = "Unsupported exnref global type";
|
||||
const sides = reverse ? [unsupported, empty] : [empty, unsupported];
|
||||
expect(await compare(evidence(sides[0]), evidence(sides[1]))).toMatchObject(
|
||||
{
|
||||
coverage: "partial",
|
||||
summary: { added: 0, removed: 0, unknown: 1 },
|
||||
changes: [{ state: "unknown" }],
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,250 +0,0 @@
|
||||
import {
|
||||
WASM_INTERFACE_REPRESENTATION_BYTES,
|
||||
wasmInterfaceEntryRepresentationBytes,
|
||||
} from "../../domain/wasm/wasmInterface.js";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import {
|
||||
compareWasmInterfacesInputSchema,
|
||||
wasmInterfaceComparisonSchema,
|
||||
} from "../../contracts/wasm/wasmComparisonContracts.js";
|
||||
import {
|
||||
createEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import {
|
||||
wasmArtifactSchema,
|
||||
type WasmArtifact,
|
||||
} from "../../domain/wasm/wasmArtifact.js";
|
||||
import type { WasmInterfaceEntry } from "../../domain/wasm/wasmInterface.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { ARTIFACT_COMPARISON_PROVIDER } from "../InvestigationProviders.js";
|
||||
import {
|
||||
resolvePairedEvidenceRequest,
|
||||
type EvidenceLookup,
|
||||
} from "../EvidenceInputResolver.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { z } from "zod";
|
||||
|
||||
const OPERATION = "compare_wasm_interfaces";
|
||||
const parseInspection = (
|
||||
raw: unknown,
|
||||
): { evidence: Evidence; result: WasmArtifact } => {
|
||||
const evidence = parseEvidence(raw);
|
||||
if (evidence.operation !== "inspect_wasm_artifact")
|
||||
throw new TypeError("Expected complete inspect_wasm_artifact Evidence");
|
||||
const result = wasmArtifactSchema.parse(evidence.normalized_result);
|
||||
if (
|
||||
evidence.subject?.digest.sha256 !== result.artifact.sha256 ||
|
||||
evidence.subject.local_path !== result.artifact.path ||
|
||||
evidence.parameters.path !== result.artifact.path
|
||||
)
|
||||
throw new TypeError(
|
||||
"WASM Evidence subject/selection does not match normalized artifact identity",
|
||||
);
|
||||
return { evidence, result };
|
||||
};
|
||||
|
||||
/** Compare only producer-owned typed facts; no raw WAT or original file is reread. */
|
||||
export const compareWasmInterfaces = async (
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
lookup?: EvidenceLookup,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const parsed = compareWasmInterfacesInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput),
|
||||
);
|
||||
const resolved = resolvePairedEvidenceRequest(parsed.data, lookup);
|
||||
if (!resolved.ok) return resolved;
|
||||
try {
|
||||
await setImmediate();
|
||||
options?.signal?.throwIfAborted();
|
||||
const left = parseInspection(resolved.value.left);
|
||||
const right = parseInspection(resolved.value.right);
|
||||
const scope = parsed.data.scope;
|
||||
const selected = (entry: WasmInterfaceEntry): boolean =>
|
||||
scope === "both" ||
|
||||
entry.direction === (scope === "imports" ? "import" : "export");
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
left: WasmInterfaceEntry[];
|
||||
right: WasmInterfaceEntry[];
|
||||
entry: WasmInterfaceEntry;
|
||||
}
|
||||
>();
|
||||
let work = 0;
|
||||
for (const [side, inspection] of [
|
||||
["left", left],
|
||||
["right", right],
|
||||
] as const) {
|
||||
for (const entry of inspection.result.interface?.entries ?? []) {
|
||||
if (++work % 256 === 0) {
|
||||
await setImmediate();
|
||||
options?.signal?.throwIfAborted();
|
||||
}
|
||||
if (!selected(entry)) continue;
|
||||
const key = JSON.stringify([entry.direction, entry.module, entry.name]);
|
||||
let group = groups.get(key);
|
||||
if (group === undefined) {
|
||||
group = { left: [], right: [], entry };
|
||||
groups.set(key, group);
|
||||
}
|
||||
group[side].push(entry);
|
||||
}
|
||||
}
|
||||
const knownInventory = (inspection: typeof left): boolean =>
|
||||
inspection.result.interface?.state === "complete";
|
||||
const changes: z.infer<typeof wasmInterfaceComparisonSchema>["changes"] =
|
||||
[];
|
||||
const summary = {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 0,
|
||||
unchanged: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
let remaining = WASM_INTERFACE_REPRESENTATION_BYTES;
|
||||
for (const group of groups.values()) {
|
||||
remaining -=
|
||||
512 +
|
||||
group.left.reduce(
|
||||
(sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry),
|
||||
0,
|
||||
) +
|
||||
group.right.reduce(
|
||||
(sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry),
|
||||
0,
|
||||
);
|
||||
if (remaining < 0)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"capacity",
|
||||
"WASM comparison exceeds typed interface representation capacity",
|
||||
{ maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES },
|
||||
);
|
||||
if (++work % 256 === 0) {
|
||||
await setImmediate();
|
||||
options?.signal?.throwIfAborted();
|
||||
}
|
||||
let state: keyof typeof summary;
|
||||
let reason: string | null = null;
|
||||
const l = group.left[0];
|
||||
const r = group.right[0];
|
||||
if (group.left.length > 1 || group.right.length > 1) {
|
||||
state = "unknown";
|
||||
reason =
|
||||
"Duplicate interface names are ambiguous; all occurrences retained";
|
||||
} else if (!knownInventory(left) || !knownInventory(right)) {
|
||||
state = "unknown";
|
||||
reason =
|
||||
"Incomplete interface inventories cannot establish per-key uniqueness or absence";
|
||||
} else if (
|
||||
[l, r].some(
|
||||
(entry) =>
|
||||
entry !== undefined &&
|
||||
(entry.signature === null ||
|
||||
entry.issue !== null ||
|
||||
entry.kind === "unknown"),
|
||||
)
|
||||
) {
|
||||
state = "unknown";
|
||||
reason =
|
||||
"Signature/type semantics were not admitted for every present entry";
|
||||
} else if (l === undefined) state = "added";
|
||||
else if (r === undefined) state = "removed";
|
||||
else if (l.kind !== r.kind) state = "changed";
|
||||
else
|
||||
state =
|
||||
JSON.stringify(l.signature) === JSON.stringify(r.signature)
|
||||
? "unchanged"
|
||||
: "changed";
|
||||
summary[state]++;
|
||||
changes.push({
|
||||
direction: group.entry.direction,
|
||||
module: group.entry.module,
|
||||
name: group.entry.name,
|
||||
state,
|
||||
left: group.left,
|
||||
right: group.right,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
const compareText = (a: string, b: string): number =>
|
||||
a < b ? -1 : a > b ? 1 : 0;
|
||||
changes.sort(
|
||||
(a, b) =>
|
||||
compareText(a.direction, b.direction) ||
|
||||
compareText(a.module ?? "", b.module ?? "") ||
|
||||
compareText(a.name, b.name),
|
||||
);
|
||||
const coverage =
|
||||
left.result.interface === undefined &&
|
||||
right.result.interface === undefined
|
||||
? "unknown"
|
||||
: !knownInventory(left) || !knownInventory(right) || summary.unknown > 0
|
||||
? "partial"
|
||||
: "complete-within-inputs";
|
||||
const limitations = [
|
||||
"Exact declared interface equality is not runtime/ABI compatibility, successful linking or original-source identity.",
|
||||
"Typed facts come from the selected validated WABT decoding; unsupported signature semantics and duplicate names remain unknown.",
|
||||
];
|
||||
const report = wasmInterfaceComparisonSchema.parse({
|
||||
left: {
|
||||
evidence_id: left.evidence.evidence_id,
|
||||
artifact: left.result.artifact,
|
||||
},
|
||||
right: {
|
||||
evidence_id: right.evidence.evidence_id,
|
||||
artifact: right.result.artifact,
|
||||
},
|
||||
scope,
|
||||
coverage,
|
||||
changes,
|
||||
summary,
|
||||
limitations,
|
||||
});
|
||||
await setImmediate();
|
||||
options?.signal?.throwIfAborted();
|
||||
return ok(
|
||||
createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, {
|
||||
operation: OPERATION,
|
||||
predicateType: "rea.wasm-interface-comparison",
|
||||
parameters: {
|
||||
left_evidence_id: left.evidence.evidence_id,
|
||||
right_evidence_id: right.evidence.evidence_id,
|
||||
scope,
|
||||
},
|
||||
result: report,
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
limitations,
|
||||
evidenceLinks: [left.evidence.evidence_id, right.evidence.evidence_id],
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: [],
|
||||
reason: "invalid_value",
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { orderAsarListing } from "./AsarArtifactReader.js";
|
||||
|
||||
describe("orderAsarListing", () => {
|
||||
it("orders host-spelled listings by their portable archive path", () => {
|
||||
const posix = orderAsarListing(["/a0", "/a", "/a/b"], "/");
|
||||
const windows = orderAsarListing(["\\a0", "\\a", "\\a\\b"], "\\");
|
||||
|
||||
// "/" (0x2F) precedes "0" (0x30); "\" (0x5C) would follow it.
|
||||
expect(posix.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]);
|
||||
expect(windows.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]);
|
||||
expect(windows.map(({ listed }) => listed)).toEqual([
|
||||
"\\a",
|
||||
"\\a\\b",
|
||||
"\\a0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps POSIX backslashes as literal filename characters", () => {
|
||||
expect(orderAsarListing(["/a\\b"], "/")).toEqual([
|
||||
{ listed: "/a\\b", path: "a\\b" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { isAbsolute, join, relative, sep } from "node:path";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
|
||||
import { getRawHeader, uncache } from "@electron/asar";
|
||||
import { listPackage, statFile, uncache } from "@electron/asar";
|
||||
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
@@ -26,11 +26,7 @@ import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
|
||||
import { admitAsarHeader } from "./AsarHeader.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
||||
import {
|
||||
readAsarListing,
|
||||
type AsarListedEntry,
|
||||
type AsarMetadata,
|
||||
} from "./AsarListing.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/**
|
||||
* Official Electron ASAR adapter with range-streamed member reads.
|
||||
@@ -55,7 +51,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
) {}
|
||||
|
||||
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
|
||||
let paths: readonly AsarListedEntry[];
|
||||
let paths: readonly AsarListedPath[];
|
||||
try {
|
||||
await this.prepareContainer(undefined, signal);
|
||||
paths = await this.#withSnapshotOperation(async () => {
|
||||
@@ -66,9 +62,8 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
this.#archiveSize = admitted.archiveBytes;
|
||||
this.#headerSize = admitted.headerBytes;
|
||||
abortIfNeeded(signal);
|
||||
const ordered = await readAsarListing(
|
||||
getRawHeader(snapshotPath).header,
|
||||
signal,
|
||||
const ordered = orderAsarListing(
|
||||
listPackage(snapshotPath, { isPack: false }),
|
||||
);
|
||||
const archiveMetadata = await lstat(snapshotPath);
|
||||
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
|
||||
@@ -82,25 +77,34 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
} catch (cause: unknown) {
|
||||
throw asarFailure(this.path, "inventory", cause);
|
||||
}
|
||||
for (const { metadata, path } of paths) {
|
||||
for (const { listed, path } of paths) {
|
||||
abortIfNeeded(signal);
|
||||
const providerPath = path.split("/").join(sep);
|
||||
if (path.length === 0) continue;
|
||||
const providerPath = listed.startsWith(sep)
|
||||
? listed.slice(sep.length)
|
||||
: listed;
|
||||
let metadata: ReturnType<typeof statFile>;
|
||||
try {
|
||||
metadata = statFile(this.#snapshotPath(), providerPath, false);
|
||||
} catch (cause: unknown) {
|
||||
throw asarFailure(this.path, `stat ${path}`, cause);
|
||||
}
|
||||
const kind =
|
||||
metadata.files !== undefined
|
||||
"files" in metadata
|
||||
? "directory"
|
||||
: metadata.link !== undefined
|
||||
: "link" in metadata
|
||||
? "symlink"
|
||||
: "file";
|
||||
const entry: ArtifactEntry = {
|
||||
path,
|
||||
kind,
|
||||
declaredSize: metadata.size ?? null,
|
||||
declaredSize: "size" in metadata ? metadata.size : null,
|
||||
compressedSize: null,
|
||||
executable: metadata.executable === true,
|
||||
executable: "executable" in metadata && metadata.executable,
|
||||
encrypted: false,
|
||||
byteOffset: null,
|
||||
declaredSha256:
|
||||
metadata.integrity !== undefined &&
|
||||
"integrity" in metadata &&
|
||||
metadata.integrity.algorithm === "SHA256" &&
|
||||
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
|
||||
? metadata.integrity.hash
|
||||
@@ -599,7 +603,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
}
|
||||
}
|
||||
|
||||
type AsarFileMetadata = AsarMetadata & { readonly size: number };
|
||||
type AsarFileMetadata = Extract<ReturnType<typeof statFile>, { size: number }>;
|
||||
type AsarEntryState = {
|
||||
readonly metadata: AsarFileMetadata;
|
||||
readonly entry: ArtifactEntry;
|
||||
@@ -648,11 +652,12 @@ const asarUnpackedEntryFailure = (
|
||||
};
|
||||
|
||||
const isAsarFileMetadata = (
|
||||
metadata: AsarMetadata,
|
||||
metadata: ReturnType<typeof statFile>,
|
||||
): metadata is AsarFileMetadata =>
|
||||
"size" in metadata &&
|
||||
typeof metadata.size === "number" &&
|
||||
metadata.files === undefined &&
|
||||
metadata.link === undefined;
|
||||
!("files" in metadata) &&
|
||||
!("link" in metadata);
|
||||
|
||||
const parseArchiveOffset = (value: string): number | undefined => {
|
||||
if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) return undefined;
|
||||
@@ -694,6 +699,33 @@ const waitForOperation = async (
|
||||
});
|
||||
};
|
||||
|
||||
interface AsarListedPath {
|
||||
readonly listed: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair each host-spelled listing with its portable archive path, ordered by
|
||||
* the portable path so Windows and POSIX hosts agree.
|
||||
*
|
||||
* @electron/asar returns paths assembled with the host path module. Keep that
|
||||
* spelling for its API calls, but expose archive paths with portable
|
||||
* separators. On POSIX, backslashes can be literal filename characters and
|
||||
* must remain untouched.
|
||||
*/
|
||||
export const orderAsarListing = (
|
||||
listed: readonly string[],
|
||||
separator: string = sep,
|
||||
): AsarListedPath[] =>
|
||||
listed
|
||||
.map((entry) => ({ listed: entry, path: toArtifactPath(entry, separator) }))
|
||||
.sort((left, right) => compareUnicodeCodePoints(left.path, right.path));
|
||||
|
||||
const toArtifactPath = (listed: string, separator: string): string => {
|
||||
const portable = separator === "\\" ? listed.replaceAll("\\", "/") : listed;
|
||||
return portable.replace(/^\/+|\/+$/gu, "");
|
||||
};
|
||||
|
||||
const asarFailure = (
|
||||
path: string,
|
||||
operation: string,
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Capacity for expanded paths and listing records, independent of packed bytes. */
|
||||
const MAX_LISTING_BYTES = 64 * 1024 * 1024;
|
||||
const nodeSchema = z.looseObject({
|
||||
files: z
|
||||
.custom<Record<string, unknown>>(
|
||||
(value) =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value),
|
||||
)
|
||||
.optional(),
|
||||
link: z.string().optional(),
|
||||
size: z.number().nonnegative().safe().optional(),
|
||||
offset: z.string().optional(),
|
||||
unpacked: z.boolean().optional(),
|
||||
executable: z.boolean().optional(),
|
||||
integrity: z
|
||||
.looseObject({ algorithm: z.string(), hash: z.string() })
|
||||
.optional(),
|
||||
});
|
||||
export type AsarMetadata = z.infer<typeof nodeSchema>;
|
||||
export interface AsarListedEntry {
|
||||
readonly path: string;
|
||||
readonly metadata: AsarMetadata;
|
||||
}
|
||||
|
||||
function* ownChildren(files: Record<string, unknown>) {
|
||||
for (const name in files)
|
||||
if (Object.hasOwn(files, name)) yield [name, files[name]] as const;
|
||||
}
|
||||
|
||||
/** Decode each node once; admit expanded paths before concatenation and retain no recursive lookup. */
|
||||
export const readAsarListing = async (
|
||||
header: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AsarListedEntry[]> => {
|
||||
const root = nodeSchema.parse(header);
|
||||
if (root.files === undefined)
|
||||
throw new ArtifactReaderFailure("format", "ASAR root has no files table");
|
||||
const entries: AsarListedEntry[] = [];
|
||||
const pending = [{ parent: "", children: ownChildren(root.files) }];
|
||||
let retainedBytes = 0;
|
||||
let examined = 0;
|
||||
while (pending.length > 0) {
|
||||
if (examined++ % 128 === 0) {
|
||||
await setImmediate();
|
||||
if (signal?.aborted)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"ASAR operation cancelled",
|
||||
);
|
||||
}
|
||||
const frame = pending.at(-1);
|
||||
if (frame === undefined) break;
|
||||
const next = frame.children.next();
|
||||
if (next.done) {
|
||||
pending.pop();
|
||||
continue;
|
||||
}
|
||||
const [name, raw] = next.value;
|
||||
// A header member is one component, not a host path to normalize. POSIX
|
||||
// backslashes remain literal; Windows cannot represent them as components.
|
||||
if (
|
||||
name.length === 0 ||
|
||||
name === "." ||
|
||||
name === ".." ||
|
||||
name.includes("/") ||
|
||||
name.includes("\0") ||
|
||||
(process.platform === "win32" && name.includes("\\"))
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
"ASAR header has an unsafe path component",
|
||||
);
|
||||
const units =
|
||||
frame.parent.length + name.length + (frame.parent.length === 0 ? 0 : 1);
|
||||
// UTF-16 storage, plus records and traversal bookkeeping. Charge every
|
||||
// occurrence even when the engine can share a string prefix.
|
||||
retainedBytes += units * 2 + 256;
|
||||
if (retainedBytes > MAX_LISTING_BYTES)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
"ASAR expanded listing exceeds its 64 MiB representation budget",
|
||||
);
|
||||
const path = frame.parent.length === 0 ? name : `${frame.parent}/${name}`;
|
||||
const metadata = nodeSchema.parse(raw);
|
||||
if (
|
||||
metadata.files === undefined &&
|
||||
metadata.link === undefined &&
|
||||
metadata.size === undefined
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"ASAR entry has no directory, link or file metadata",
|
||||
);
|
||||
entries.push({ path, metadata });
|
||||
if (metadata.files !== undefined)
|
||||
pending.push({
|
||||
parent: path,
|
||||
children: ownChildren(metadata.files),
|
||||
});
|
||||
}
|
||||
return entries.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.path, right.path),
|
||||
);
|
||||
};
|
||||
@@ -36,22 +36,6 @@ const archiveWithOverlappingObjectRanges = (objectCount: number): Buffer => {
|
||||
};
|
||||
|
||||
describe("NIBArchive decoder", () => {
|
||||
it("keeps leading U+FEFF in length-prefixed keys and class names", () => {
|
||||
const archive = encodeArchive({
|
||||
classes: ["\uFEFFWidget"],
|
||||
objects: [{ classIndex: 0, values: { count: 1, "\uFEFFcount": 2 } }],
|
||||
});
|
||||
expect(decodeNibArchive(archive).objects).toEqual([
|
||||
{
|
||||
id: 0,
|
||||
class_name: "\uFEFFWidget",
|
||||
values: { count: 1, "\uFEFFcount": 2 },
|
||||
},
|
||||
]);
|
||||
const invalid = Buffer.from(archive);
|
||||
invalid[invalid.indexOf(Buffer.from("\uFEFFcount"))] = 0xff;
|
||||
expect(() => decodeNibArchive(invalid)).toThrow(/not valid UTF-8/u);
|
||||
});
|
||||
it("decodes bounded object, key, class, and reference tables", () => {
|
||||
const archive = encodeArchive({
|
||||
classes: ["NSView\0", "NSString\0"],
|
||||
|
||||
@@ -14,7 +14,6 @@ export class BrowserStartupError extends Error {
|
||||
readonly failure: BrowserStartupFailure;
|
||||
readonly executable: string;
|
||||
readonly elapsedMs: number;
|
||||
readonly startupTimeoutMs: number;
|
||||
readonly exitCode: number | null;
|
||||
readonly signalCode: NodeJS.Signals | null;
|
||||
readonly stderr: string;
|
||||
@@ -24,7 +23,6 @@ export class BrowserStartupError extends Error {
|
||||
readonly failure: BrowserStartupFailure;
|
||||
readonly executable: string;
|
||||
readonly elapsedMs: number;
|
||||
readonly startupTimeoutMs: number;
|
||||
readonly exitCode: number | null;
|
||||
readonly signalCode: NodeJS.Signals | null;
|
||||
readonly stderr: string;
|
||||
@@ -35,22 +33,12 @@ export class BrowserStartupError extends Error {
|
||||
this.failure = diagnostics.failure;
|
||||
this.executable = diagnostics.executable;
|
||||
this.elapsedMs = diagnostics.elapsedMs;
|
||||
this.startupTimeoutMs = diagnostics.startupTimeoutMs;
|
||||
this.exitCode = diagnostics.exitCode;
|
||||
this.signalCode = diagnostics.signalCode;
|
||||
this.stderr = diagnostics.stderr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single owner of the wait for Chrome to publish its DevToolsActivePort.
|
||||
*
|
||||
* Callers that need a different budget pass `timeoutMs`; nobody else should
|
||||
* restate this number, because a per-caller literal silently changes how long
|
||||
* a cold Chrome launch is tolerated before the failure names its ceiling.
|
||||
*/
|
||||
export const BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Owned child process and startup policy for Chrome CDP discovery. */
|
||||
export interface BrowserStartupOptions {
|
||||
readonly child: ChildProcess;
|
||||
@@ -65,23 +53,23 @@ export interface BrowserStartupOptions {
|
||||
export const waitForBrowserDevtoolsPort = async (
|
||||
options: BrowserStartupOptions,
|
||||
): Promise<number> => {
|
||||
const timeoutMs = options.timeoutMs ?? BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS;
|
||||
const timeoutMs = options.timeoutMs ?? 15_000;
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 25;
|
||||
const startedAt = performance.now();
|
||||
const terminal = childTermination(options, startedAt, timeoutMs);
|
||||
const terminal = childTermination(options, startedAt);
|
||||
const deadline = startedAt + timeoutMs;
|
||||
|
||||
while (true) {
|
||||
const terminalState = currentTerminalState(options.child);
|
||||
if (terminalState !== null)
|
||||
throw startupError(options, startedAt, timeoutMs, terminalState);
|
||||
throw startupError(options, startedAt, terminalState);
|
||||
|
||||
const port = await readDevtoolsPort(options.activePortPath);
|
||||
if (port !== null) return port;
|
||||
|
||||
const remainingMs = deadline - performance.now();
|
||||
if (remainingMs <= 0)
|
||||
throw startupError(options, startedAt, timeoutMs, { failure: "timeout" });
|
||||
throw startupError(options, startedAt, { failure: "timeout" });
|
||||
|
||||
const outcome = await Promise.race([
|
||||
terminal,
|
||||
@@ -100,12 +88,11 @@ type TerminalState =
|
||||
const childTermination = (
|
||||
options: BrowserStartupOptions,
|
||||
startedAt: number,
|
||||
startupTimeoutMs: number,
|
||||
): Promise<BrowserStartupError> =>
|
||||
new Promise((resolve) => {
|
||||
options.child.once("error", (cause: unknown) => {
|
||||
resolve(
|
||||
startupError(options, startedAt, startupTimeoutMs, {
|
||||
startupError(options, startedAt, {
|
||||
failure: "spawn-error",
|
||||
cause,
|
||||
}),
|
||||
@@ -116,7 +103,6 @@ const childTermination = (
|
||||
startupError(
|
||||
options,
|
||||
startedAt,
|
||||
startupTimeoutMs,
|
||||
options.child.signalCode === null
|
||||
? { failure: "exited" }
|
||||
: { failure: "signalled" },
|
||||
@@ -134,7 +120,6 @@ const currentTerminalState = (child: ChildProcess): TerminalState | null => {
|
||||
const startupError = (
|
||||
options: BrowserStartupOptions,
|
||||
startedAt: number,
|
||||
startupTimeoutMs: number,
|
||||
state: TerminalState,
|
||||
): BrowserStartupError =>
|
||||
new BrowserStartupError(
|
||||
@@ -142,7 +127,6 @@ const startupError = (
|
||||
failure: state.failure,
|
||||
executable: options.executable,
|
||||
elapsedMs: Math.max(0, Math.round(performance.now() - startedAt)),
|
||||
startupTimeoutMs,
|
||||
exitCode: options.child.exitCode,
|
||||
signalCode: options.child.signalCode,
|
||||
stderr: options.stderr(),
|
||||
@@ -172,10 +156,9 @@ const browserStartupMessage = (
|
||||
diagnostics: {
|
||||
readonly executable: string;
|
||||
readonly elapsedMs: number;
|
||||
readonly startupTimeoutMs: number;
|
||||
readonly exitCode: number | null;
|
||||
readonly signalCode: NodeJS.Signals | null;
|
||||
readonly stderr: string;
|
||||
},
|
||||
): string =>
|
||||
`Chrome CDP startup ${failure}; executable=${diagnostics.executable}; elapsed_ms=${String(diagnostics.elapsedMs)}; startup_timeout_ms=${String(diagnostics.startupTimeoutMs)}; exit_code=${diagnostics.exitCode === null ? "null" : String(diagnostics.exitCode)}; signal=${diagnostics.signalCode ?? "null"}; stderr=${diagnostics.stderr.length === 0 ? "<empty>" : diagnostics.stderr}`;
|
||||
`Chrome CDP startup ${failure}; executable=${diagnostics.executable}; elapsed_ms=${String(diagnostics.elapsedMs)}; exit_code=${diagnostics.exitCode === null ? "null" : String(diagnostics.exitCode)}; signal=${diagnostics.signalCode ?? "null"}; stderr=${diagnostics.stderr.length === 0 ? "<empty>" : diagnostics.stderr}`;
|
||||
|
||||
@@ -49,14 +49,13 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
discoverCdpEndpoint,
|
||||
hasCdpTargetWebSocket,
|
||||
type CdpEndpointDiscovery,
|
||||
type CdpEndpointTarget,
|
||||
} from "./CdpEndpoint.js";
|
||||
import {
|
||||
attachCdpTargetSession,
|
||||
closeCdpTargetSession,
|
||||
openCdpEndpoint,
|
||||
openCdpTargetSession,
|
||||
type CdpTargetSession,
|
||||
} from "./CdpTargetSession.js";
|
||||
@@ -83,13 +82,11 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<BrowserTargetList, AnalysisError>> {
|
||||
try {
|
||||
const endpoint = await openCdpEndpoint(
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"list_browser_targets",
|
||||
options.signal,
|
||||
);
|
||||
await endpoint.connection?.close();
|
||||
const { discovery } = endpoint;
|
||||
const selected = selectTargets(
|
||||
discovery,
|
||||
input.allowed_origins.length === 0
|
||||
@@ -172,19 +169,45 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
input: ObserveWebSessionInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebObservationSession, AnalysisError>> {
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"observe_web_session",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
const scopedInput = scopeToTargetOrigin(input, target);
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
"observe_web_session",
|
||||
options.signal,
|
||||
);
|
||||
return ok(
|
||||
webObservationSessionSchema.parse(
|
||||
await withTargetSession(
|
||||
input,
|
||||
"observe_web_session",
|
||||
options,
|
||||
observeCdpSession,
|
||||
),
|
||||
await observeCdpSession({
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input: scopedInput,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "observe_web_session"));
|
||||
} finally {
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(
|
||||
targetSession,
|
||||
CLEANUP_DOMAINS,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,19 +215,45 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
input: DiscoverWebMcpToolsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebMcpDiscovery, AnalysisError>> {
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"discover_webmcp_tools",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
const scopedInput = scopeToTargetOrigin(input, target);
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
"discover_webmcp_tools",
|
||||
options.signal,
|
||||
);
|
||||
return ok(
|
||||
webMcpDiscoverySchema.parse(
|
||||
await withTargetSession(
|
||||
input,
|
||||
"discover_webmcp_tools",
|
||||
options,
|
||||
discoverWebMcp,
|
||||
),
|
||||
await discoverWebMcp({
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input: scopedInput,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "discover_webmcp_tools"));
|
||||
} finally {
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(
|
||||
targetSession,
|
||||
CLEANUP_DOMAINS,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,19 +273,45 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
input: CaptureWebScreenshotInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebScreenshot, AnalysisError>> {
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"capture_web_screenshot",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
const scopedInput = scopeToTargetOrigin(input, target);
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
"capture_web_screenshot",
|
||||
options.signal,
|
||||
);
|
||||
return ok(
|
||||
webScreenshotSchema.parse(
|
||||
await withTargetSession(
|
||||
input,
|
||||
"capture_web_screenshot",
|
||||
options,
|
||||
captureCdpScreenshot,
|
||||
),
|
||||
await captureCdpScreenshot({
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input: scopedInput,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "capture_web_screenshot"));
|
||||
} finally {
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(
|
||||
targetSession,
|
||||
CLEANUP_DOMAINS,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,82 +336,43 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
options: ExecutionOptions,
|
||||
operation: "inspect_web_page" | "analyze_web_bundle",
|
||||
): Promise<CapturedPage> {
|
||||
return await withTargetSession(
|
||||
input,
|
||||
operation,
|
||||
options,
|
||||
async (session) => await capturePage({ ...session, operation }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Facts and transport handed to one target operation. */
|
||||
interface CdpTargetOperationSession<Input> {
|
||||
readonly connection: CdpTargetSession["connection"];
|
||||
readonly sessionId: CdpTargetSession["sessionId"];
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: Input;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly progress?: NonNullable<ExecutionOptions["progress"]>;
|
||||
}
|
||||
|
||||
/** Authorize one discovered target, run work in its session, and close REA's socket. */
|
||||
const withTargetSession = async <
|
||||
Input extends Pick<
|
||||
InspectWebPageInput,
|
||||
"cdp_endpoint" | "target_id" | "allowed_origins"
|
||||
>,
|
||||
Output,
|
||||
>(
|
||||
input: Input,
|
||||
operation: BrowserObservationOperation,
|
||||
options: ExecutionOptions,
|
||||
work: (session: CdpTargetOperationSession<Input>) => Promise<Output>,
|
||||
): Promise<Output> => {
|
||||
const endpoint = await openCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
operation,
|
||||
options.signal,
|
||||
);
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const target = authorizeTarget(endpoint.discovery, input);
|
||||
const scopedInput = scopeToTargetOrigin(input, target);
|
||||
targetSession =
|
||||
endpoint.connection === undefined
|
||||
? await openCdpTargetSession(
|
||||
endpoint.discovery,
|
||||
target,
|
||||
operation,
|
||||
options.signal,
|
||||
)
|
||||
: await attachCdpTargetSession(
|
||||
endpoint.connection,
|
||||
target,
|
||||
operation,
|
||||
options.signal,
|
||||
);
|
||||
return await work({
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
discovery: endpoint.discovery,
|
||||
target,
|
||||
input: scopedInput,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.progress === undefined ? {} : { progress: options.progress }),
|
||||
});
|
||||
} finally {
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(
|
||||
targetSession,
|
||||
CLEANUP_DOMAINS,
|
||||
let targetSession: CdpTargetSession | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
operation,
|
||||
options.signal,
|
||||
);
|
||||
// An unadopted browser socket is closed here; closing is idempotent.
|
||||
await endpoint.connection?.close();
|
||||
const target = authorizeTarget(discovery, input);
|
||||
const scopedInput = scopeToTargetOrigin(input, target);
|
||||
targetSession = await openCdpTargetSession(
|
||||
discovery,
|
||||
target,
|
||||
operation,
|
||||
options.signal,
|
||||
);
|
||||
return await capturePage({
|
||||
connection: targetSession.connection,
|
||||
sessionId: targetSession.sessionId,
|
||||
operation,
|
||||
discovery,
|
||||
target,
|
||||
input: scopedInput,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
});
|
||||
} finally {
|
||||
if (targetSession !== undefined)
|
||||
await closeCdpTargetSession(
|
||||
targetSession,
|
||||
CLEANUP_DOMAINS,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const selectTargets = (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
|
||||
import { captureStorage } from "./CdpCaptureStorage.js";
|
||||
import { CdpCommandRejection } from "./CdpCommandRejection.js";
|
||||
import type { CdpConnection } from "./CdpConnection.js";
|
||||
|
||||
const NO_CACHE_COMMAND = Symbol("unsupported");
|
||||
|
||||
const captureWithCacheResult = async (
|
||||
cacheResult: unknown,
|
||||
): Promise<Awaited<ReturnType<typeof captureStorage>>["value"]> => {
|
||||
const connection = {
|
||||
send(method: string): Promise<unknown> {
|
||||
switch (method) {
|
||||
case "Storage.getUsageAndQuota":
|
||||
return Promise.resolve({ usage: 1, quota: 10 });
|
||||
case "DOMStorage.getDOMStorageItems":
|
||||
return Promise.resolve({ entries: [] });
|
||||
case "IndexedDB.requestDatabaseNames":
|
||||
return Promise.resolve({ databaseNames: [] });
|
||||
case "Network.getCookies":
|
||||
return Promise.resolve({ cookies: [] });
|
||||
case "CacheStorage.requestCacheNames":
|
||||
return cacheResult === NO_CACHE_COMMAND
|
||||
? Promise.reject(
|
||||
new CdpCommandRejection(
|
||||
"inspect_web_page",
|
||||
method,
|
||||
-32601,
|
||||
"Method not found",
|
||||
),
|
||||
)
|
||||
: Promise.resolve(cacheResult);
|
||||
case "CacheStorage.requestEntries":
|
||||
return Promise.resolve({ cacheDataEntries: [], returnCount: 0 });
|
||||
default:
|
||||
return Promise.reject(new Error(`Unexpected command: ${method}`));
|
||||
}
|
||||
},
|
||||
} as unknown as CdpConnection;
|
||||
const { value } = await captureStorage(
|
||||
{
|
||||
connection,
|
||||
sessionId: undefined,
|
||||
input: inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "fixture-page",
|
||||
include_storage_keys: true,
|
||||
include_storage_fingerprints: true,
|
||||
}),
|
||||
},
|
||||
"https://example.test/",
|
||||
[],
|
||||
);
|
||||
return value;
|
||||
};
|
||||
|
||||
it.each([
|
||||
["an observed empty cache list", { caches: [] }, true],
|
||||
["a response without a cache list", {}, false],
|
||||
["a non-array cache list", { caches: "nope" }, false],
|
||||
["a cache list with an unusable entry", { caches: [5] }, false],
|
||||
["an unsupported cache command", NO_CACHE_COMMAND, false],
|
||||
] as const)(
|
||||
"reports fingerprint completeness for %s",
|
||||
async (_label, cacheResult, complete) => {
|
||||
const value = await captureWithCacheResult(cacheResult);
|
||||
expect(value.cache_names).toEqual([]);
|
||||
expect(value.fingerprints_complete).toBe(complete);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves valid cache evidence beside an unusable entry: %s",
|
||||
async (mixed) => {
|
||||
const cache = { cacheName: "fixture-cache", cacheId: "fixture-cache-id" };
|
||||
const value = await captureWithCacheResult({
|
||||
caches: mixed ? [cache, 5] : [cache],
|
||||
});
|
||||
expect(value.cache_names).toEqual(["fixture-cache"]);
|
||||
expect(value.fingerprints_complete).toBe(!mixed);
|
||||
},
|
||||
);
|
||||
@@ -60,8 +60,8 @@ export const captureStorage = async (
|
||||
limitations,
|
||||
)
|
||||
: undefined;
|
||||
const cacheField = recordValue(cacheRaw)?.caches;
|
||||
const caches = capturedCaches(recordsValue(cacheField));
|
||||
const cacheValues = recordsValue(recordValue(cacheRaw)?.caches);
|
||||
const caches = capturedCaches(cacheValues);
|
||||
const fingerprints = context.input.include_storage_fingerprints
|
||||
? await captureStorageFingerprints({
|
||||
context,
|
||||
@@ -78,10 +78,8 @@ export const captureStorage = async (
|
||||
indexedRaw !== undefined &&
|
||||
Array.isArray(indexedValues) &&
|
||||
indexed.length === indexedValues.length &&
|
||||
// Like IndexedDB names, a missing, non-array or partly unusable cache
|
||||
// list is an incomplete enumeration rather than an empty one.
|
||||
Array.isArray(cacheField) &&
|
||||
caches.length === cacheField.length;
|
||||
cacheRaw !== undefined &&
|
||||
caches.length === cacheValues.length;
|
||||
return {
|
||||
value: {
|
||||
origin,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user