Merge branch 'main' into codex/review-fixes-pr-1977

This commit is contained in:
morluto 2026-10-14 02:45:55 +08:00 committed by GitHub
commit f45cb4b92a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
259 changed files with 12710 additions and 1417 deletions

View File

@ -175,7 +175,9 @@ 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. See the
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 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
@ -247,3 +249,9 @@ 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`.

View File

@ -33,7 +33,7 @@ env:
jobs:
changes:
name: Classify changes
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
@ -290,14 +290,19 @@ jobs:
- if: needs.changes.outputs.code == 'true' && success()
run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.changes.outputs.code == 'true' && success()
if: >-
!cancelled() && needs.changes.outputs.code == 'true' &&
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure')
with:
path: .vitest/blob
pattern: vitest-report-*
merge-multiple: true
- name: Merge coverage, JUnit, and timing reports
id: merge
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success()
if: >-
!cancelled() && needs.changes.outputs.code == 'true' &&
needs.changes.outputs.tier == 'full' &&
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure')
run: >-
npm run test:ci:merge --
--coverage.reportsDirectory=coverage
@ -312,7 +317,10 @@ jobs:
--outputFile.json=test-results.json
- name: Merge fast-suite JUnit and timing reports
id: merge-fast
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'fast' && success()
if: >-
!cancelled() && needs.changes.outputs.code == 'true' &&
needs.changes.outputs.tier == 'fast' &&
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure')
run: >-
npm exec -- vitest --merge-reports
--reporter=default
@ -777,7 +785,8 @@ jobs:
required:
name: CI required
if: ${{ always() }}
# Report failed dependencies, but release superseded PR concurrency slots.
if: ${{ !cancelled() }}
needs:
[
changes,
@ -804,7 +813,7 @@ jobs:
nativeaot,
website,
]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

View File

@ -14,7 +14,7 @@ concurrency:
jobs:
pr-title:
name: Conventional PR title
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 2
steps:
- name: Validate title for Release Please

View File

@ -181,6 +181,10 @@ The separate GitHub Pages host uses only the manual
## Development feedback and PR verification
For routine merges, wait for the required checks to pass for the current PR
revision. Administrators retain an override for deliberate maintainer recovery.
If `main` is broken, prioritize a focused repair before updating affected PRs.
Select checks for the behavior changed. For a source edit:
```bash

View File

@ -28,6 +28,7 @@ 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;
@ -141,6 +142,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
"set_addresses_names",
"inspect_native_load_image",
"read_bytes",
"search_bytes",
"address_to_file_offset",
"ping",
"shutdown",
@ -378,6 +380,7 @@ 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);
@ -684,6 +687,160 @@ 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);
@ -691,18 +848,12 @@ 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));
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));
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));
JsonObject result = new JsonObject();
result.addProperty("address", canonicalAddress(address));
result.addProperty("file_offset", fileOffset);

View File

@ -11,6 +11,12 @@ 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):
@ -51,12 +57,27 @@ 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]) + ".")
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.")
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) + ".")
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_amd64, elf_siginfo_64, user_regs_struct_amd64
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
try:
image = ELFFile(io.BytesIO(content))
header = image.header
@ -107,17 +128,17 @@ def inspect_core(path, cache):
if owner != b"CORE\0":
continue
if note.n_type == "NT_PRSTATUS":
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)
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)
registers = []
for name, kind in user_regs_struct_amd64._fields_:
relative = elf_prstatus_amd64.pr_reg.offset + getattr(user_regs_struct_amd64, name).offset
for name, kind in registers_type._fields_:
relative = prstatus_type.pr_reg.offset + getattr(registers_type, 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 amd64 NT_SIGINFO descriptor is truncated.")
raise CoreFailure("format", "Linux " + profile_label + " 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
@ -125,7 +146,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": "x86_64-little-endian", "target_execution": "not-performed", "live_process_identity": "unknown",
return {"format": "elf-core", "architecture": architecture, "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.",

View File

@ -107,6 +107,47 @@ 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
@ -116,9 +157,13 @@ 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. `localhost`, private-LAN
addresses, HTTPS CDP endpoints, credentials, paths, queries, fragments, and
implicit ports are not valid CDP endpoints. Origin filters contain only scheme,
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,
host, and port; paths, credentials, queries, fragments, and wildcards are not
valid origins.

View File

@ -214,8 +214,36 @@ 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

View File

@ -129,6 +129,18 @@ 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),

View File

@ -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 and parser-comparison scenarios now assess a closed set of
The packaged desktop, parser-comparison, and managed scenarios 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,15 +475,24 @@ 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, managed, browser,
claims, not unrestricted factual correctness. Native, browser,
navigation-context, and address-context scenarios have no factual rubric and
remain `not_assessed`; the managed workflow requires both artifact and member
inspection to answer its type and entry-point question.
remain `not_assessed`.
The closed factual scenarios request complete producer Evidence on the initial
analysis so their source selectors can authenticate all configured facts.
inspection or 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.

View File

@ -17,10 +17,14 @@ 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 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
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
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
## Build and configure
@ -57,7 +61,9 @@ 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.
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`.
Pass a returned MethodTable address to `inspect_native_data_type`. A direct PE
query also derives the snapshot metadata from the authenticated Ghidra session
@ -67,8 +73,12 @@ 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. Follow slot
targets with `analyze_function` and ordinary cross-references.
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.
The built-in CLI path uses a MethodTable address returned by the parser or
another observation:
@ -129,3 +139,11 @@ 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.

View File

@ -198,3 +198,32 @@ 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.

183
docs/http3-capture-lab.md Normal file
View File

@ -0,0 +1,183 @@
# 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.

View File

@ -279,6 +279,7 @@ 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` |
@ -290,8 +291,8 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
| ZCode | `zcode` |
| DeepSeek Harness | `dsh` |
`dsh` is main-only until a release after 6.4.0. The registry check above does
not cover it.
`dsh` and `kimi` require a release containing them. The registry check above does
not cover them.
See [client configuration and troubleshooting](client-configuration.md) for
configuration paths, profiles, trust policies, and schema-limit workarounds.
@ -310,9 +311,12 @@ 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 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).
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).
On macOS, approved setup downloads the official DMG, checks its published size
and digest, validates the application bundle, and atomically installs it to

View File

@ -233,8 +233,9 @@ 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. This does not establish Windows
NativeAOT metadata-recovery coverage.
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.
For NativeAOT analysis, preserve the selected native artifact's path and digest
and report recovered type data with its returned provenance. Linking those types

View File

@ -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, so only that text counts against the budget. Response budget settings are captured at startup;
representation and `isError` flag, so only those count 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,9 +271,11 @@ 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 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
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
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
@ -602,8 +604,12 @@ 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 properties retain
their descriptions and literal examples.
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.
When a host forwards input schemas to the model, their examples consume context.
REA's advertised examples therefore never copy complete producer Evidence.
@ -650,7 +656,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`, `project_managed_application_graph`,
`evaluate_reconstruction_coverage`,
`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,
@ -660,8 +666,7 @@ 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, and hold the
set of six reduced presentations as contracts evolve. Client verification
valid JSON Schema that still accepts every canonical example. 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.

View File

@ -549,3 +549,17 @@ 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.

View File

@ -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 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, 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 talimatları, görselli rehberler ve gerçek örnek çalışmalar için **[REA web sitesini ziyaret edin](https://rea.tools/tr/)**.
@ -100,12 +100,14 @@ 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 ile yüklenen CLI için:
`npm install --global` veya `bun add --global` 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:
@ -114,7 +116,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.
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.
## REA nasıl çalışır
@ -134,12 +136,14 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| 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; 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) |
| 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) |
@ -148,6 +152,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç
| 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) |
| 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) |
@ -156,7 +161,7 @@ Statik JavaScript ve .NET incelemesi, uygulamayı çalıştırmadan sağlanan do
<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), [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.
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.
## Örnek çalışmalar
@ -280,9 +285,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.
The project's position on illegal or unauthorized use covers all REA
versions, distribution channels, and platforms; each copy retains its
applicable licenses.
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.
## Katkıda bulunma
@ -294,14 +299,16 @@ Geliştirme ortamı ve kontroller için [CONTRIBUTING.md](../../CONTRIBUTING.md)
[MIT](../../LICENSE)
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.
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.
**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.
**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.

View File

@ -51,7 +51,7 @@
REA 將 AI 代理連接到分析工具,用於檢查原生二進位檔案、JavaScript 和 Electron 應用、.NET 程式集以及網站。你也可以在終端中使用同樣的工具。分析在本機執行,結果包含支撐各項結論的證據和相關限制。
設定流程會向 AI 代理註冊 REA,並安裝對應的工作流程指引。原生分析可以使用已有的 Hopper 或 Ghidra;設定流程也可以在你核准後安裝 Hopper。靜態 JavaScript 分析不需要這兩個引擎。
設定流程會向 AI 代理註冊 REA,並安裝對應的工作流程指引。原生分析可以使用已有的 Hopper、Ghidra 或 IDA;設定流程也可以在你核准後安裝 Hopper。靜態 JavaScript 分析不需要原生分析引擎。
> **[訪問 REA 網站](https://rea.tools/zh-TW/)**,查看設定說明、圖解指南和真實案例。
@ -100,12 +100,14 @@ rea --help
REA 更新較快,新版本經常包含錯誤修復。請將安裝的版本保持最新。
通過 npm 安裝的 CLI 使用:
通過 `npm install --global` 或 `bun add --global` 安裝的 CLI 使用:
```bash
rea update
```
Bun 全域更新需要 Bun 1.2.17 或更新版本。如果使用較舊的 Bun 版本,請先手動升級 Bun,再執行 `rea update`。
要更新 AI 代理註冊和 skill,請執行更新命令輸出的設定命令。
如果使用 `npx`,通過以下命令更新 AI 代理設定:
@ -114,7 +116,7 @@ rea update
npx rea-agents@latest setup
```
檢查設定變更並重新啟動 AI 代理。單次 CLI 操作可使用 `npx rea-agents@latest`,後接所需命令。
檢查設定變更並重新啟動 AI 代理。單次 CLI 操作可使用 `npx rea-agents@latest`,後接所需命令。使用 Bun 時,用 `bunx rea-agents@latest` 代替 `npx rea-agents@latest`。
## REA 的工作原理
@ -134,6 +136,8 @@ 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/) |

View File

@ -51,7 +51,7 @@
REA 将智能体连接到分析工具,用于检查原生二进制文件、JavaScript 和 Electron 应用、.NET 程序集以及网站。你也可以在终端中使用同样的工具。分析在本机运行,结果包含支撑各项结论的证据和相关限制。
设置流程会向智能体注册 REA,并安装匹配的工作流指引。原生分析可以使用已有的 Hopper 或 Ghidra;设置流程也可以在你批准后安装 Hopper。静态 JavaScript 分析不需要这两个引擎。
设置流程会向智能体注册 REA,并安装匹配的工作流指引。原生分析可以使用已有的 Hopper、Ghidra 或 IDA;设置流程也可以在你批准后安装 Hopper。静态 JavaScript 分析不需要原生分析引擎。
> **[访问 REA 网站](https://rea.tools/zh-CN/)**,查看设置说明、图解指南和真实案例。
@ -100,12 +100,14 @@ rea --help
REA 更新较快,新版本经常包含错误修复。请保持安装的版本为最新。
通过 npm 安装的 CLI 使用:
通过 `npm install --global` 或 `bun add --global` 安装的 CLI 使用:
```bash
rea update
```
Bun 全局更新需要 Bun 1.2.17 或更高版本。如果使用较旧的 Bun 版本,请先手动升级 Bun,再运行 `rea update`。
要更新智能体注册和 skill,请运行更新命令输出的设置命令。
如果使用 `npx`,通过以下命令更新智能体设置:
@ -114,7 +116,7 @@ rea update
npx rea-agents@latest setup
```
检查设置变更并重启智能体。单次 CLI 操作可使用 `npx rea-agents@latest`,后接所需命令。
检查设置变更并重启智能体。单次 CLI 操作可使用 `npx rea-agents@latest`,后接所需命令。使用 Bun 时,用 `bunx rea-agents@latest` 代替 `npx rea-agents@latest`。
## REA 的工作原理
@ -134,6 +136,8 @@ 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/) |

View File

@ -1,7 +1,10 @@
# Recorded crash evidence
`inspect_recorded_crash` / `rea inspect-recorded-crash` inspect a caller-selected
Linux ELF64 x86-64 little-endian core independently of an active binary target.
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.
```sh
export REA_PWNTOOLS_PYTHON=/absolute/existing/venv/bin/python
@ -10,8 +13,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 ctypes provide parsing and ABI layout. The adapter
adds bounds, representation preservation and lifecycle ownership.
pwntools' original Linux amd64 and aarch64 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.
@ -27,7 +30,8 @@ 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 27-register ABI set, with names bound to their original ABI offsets.
complete ABI register set for its architecture (27 registers for amd64,
34 for AArch64), 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

View File

@ -0,0 +1,10 @@
# 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.

View File

@ -0,0 +1,61 @@
# 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

View File

@ -0,0 +1,8 @@
# 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.

View File

@ -0,0 +1,47 @@
# 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.

View File

@ -646,6 +646,12 @@ 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`,

View File

@ -1,4 +1,7 @@
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

1 harm_rank location trigger current_copy user_risk proposed_replacement implementation_status verification_result
2 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
3 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
4 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
5 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
6 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
7 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

View File

@ -13,6 +13,11 @@ 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
@ -79,3 +84,46 @@ 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.

View File

@ -29,8 +29,10 @@ 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. A real Windows-host NativeAOT Ghidra run is still required
to establish the complete host workflow.
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).
This boundary does not establish general Windows feature parity. Ghidra GUI
state, annotations, other target architectures and formats, and Hopper-only

View File

@ -271,9 +271,11 @@
"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",

View File

@ -0,0 +1,10 @@
# 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

View File

@ -0,0 +1,416 @@
"""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()))

View File

@ -0,0 +1,56 @@
"""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()

View File

@ -0,0 +1,64 @@
"""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()]

View File

@ -0,0 +1,100 @@
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,
};
}

View File

@ -213,7 +213,9 @@ export const providerCatalog = (sources) => {
},
{
identity: sources.wasmProvider.WABT_PROVIDER_IDENTITY,
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS,
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS.filter(
({ kind }) => kind === "artifact-provider",
),
},
{
identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY,

View File

@ -1,3 +1,4 @@
export function assertCleanLinuxHopperRuntime(
launcherPaths?: readonly string[],
procRoot?: string,
): Promise<void>;

View File

@ -16,6 +16,7 @@ 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))
@ -32,17 +33,19 @@ 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("/proc", { withFileTypes: true })) {
for (const entry of await readdir(procRoot, { withFileTypes: true })) {
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue;
let executable;
try {
executable = await stat(`/proc/${entry.name}/exe`, { bigint: true });
executable = await stat(`${procRoot}/${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(`/proc/${entry.name}`).catch((cause) => {
const owner = await stat(`${procRoot}/${entry.name}`).catch((cause) => {
if (cause?.code === "ENOENT") return undefined;
throw cause;
});

View File

@ -0,0 +1,243 @@
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),
};
}

View File

@ -0,0 +1,163 @@
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, "<22>"],
])
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",
});
});

View File

@ -0,0 +1,143 @@
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 };
}

View File

@ -0,0 +1,433 @@
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;
});

View File

@ -1,4 +1,5 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmod,
copyFile,
@ -259,7 +260,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 application 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 inspection or 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;
@ -440,6 +441,13 @@ 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"),
}
: {}),
};
}

View File

@ -0,0 +1,408 @@
#!/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));

View File

@ -45,6 +45,7 @@ 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";
@ -234,6 +235,13 @@ 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",
@ -377,6 +385,7 @@ try {
bundle_cli_and_stdio_mcp: true,
sessionEvents: session.value.timeline.length,
pageScopedTransport: true,
browserSocketTransport,
screenshotBytes: screenshot.value.artifact.bytes,
largeScreenshot,
popupEvents,

View File

@ -114,6 +114,22 @@ 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(
@ -211,6 +227,69 @@ 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,
@ -231,6 +310,15 @@ 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);

View File

@ -310,7 +310,23 @@ try {
}),
);
assert.equal(error.category, "invalid_input");
const failed = parseEvidence(error.details.partial_observation);
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,
),
);
assert.equal(failed.subject.digest.sha256, sha256(invalidBytes));
assert.equal(
failed.normalized_result.validation,

View File

@ -0,0 +1,208 @@
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 });
}

View File

@ -89,6 +89,30 @@ 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

View File

@ -1127,6 +1127,68 @@ 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({

View File

@ -71,6 +71,7 @@ import {
parseBugreportCopiedPath,
parseDumpsysPackageOutput,
parseFeatureListOutput,
packageChangeFailureReason,
parsePackageChangeOutput,
parseProcessListOutput,
parsePngDimensions,
@ -248,6 +249,31 @@ 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,
@ -1754,7 +1780,11 @@ export class AdbProvider {
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
signal,
);
if (!installed.ok) return installed;
if (!installed.ok)
return (
packageChangeRefusal(operation, input.serial, installed.error) ??
installed
);
const verdict = parsePackageChangeOutput(installed.value.stdout);
if (verdict.status === "failure")
return err(
@ -1803,7 +1833,11 @@ export class AdbProvider {
},
signal,
);
if (!uninstalled.ok) return uninstalled;
if (!uninstalled.ok)
return (
packageChangeRefusal(operation, input.serial, uninstalled.error) ??
uninstalled
);
const verdict = parsePackageChangeOutput(uninstalled.value.stdout);
if (verdict.status === "failure")
return err(

View File

@ -293,6 +293,10 @@ 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.
@ -305,10 +309,11 @@ 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: failure?.[1] ?? (text === "" ? null : text.slice(0, 512)),
reason:
packageChangeFailureReason(text) ??
(text === "" ? null : text.slice(0, 512)),
};
};

View File

@ -0,0 +1,34 @@
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 });
}
});
});

View File

@ -162,6 +162,12 @@ 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"))
@ -173,7 +179,6 @@ export const parseBinaryTarget = async (
kind: "database",
format: "analysis-database",
});
const artifactFormat = await detectArtifactFormat(path, handle, signal);
if (artifactFormat !== undefined) {
const identity = {
path,

View File

@ -131,7 +131,7 @@ const browserParameters = (
}
const observedOrigin = browserResultTargetOrigin(result);
const scope = {
cdp_endpoint: input.cdp_endpoint,
cdp_endpoint: input.cdp_endpoint.recorded,
allowed_origins:
input.allowed_origins.length > 0
? input.allowed_origins

View File

@ -110,9 +110,12 @@ 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 Pi cannot load.
if (format === "pi")
// 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 {};
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 {};
@ -1446,6 +1449,13 @@ 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 {

View File

@ -66,6 +66,8 @@ 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(),
@ -258,6 +260,21 @@ 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.
@ -266,13 +283,6 @@ 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" ||
@ -331,6 +341,12 @@ 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"
@ -339,7 +355,9 @@ const parseRegistration = (
if (client.format === "commandcode" && registration.transport !== "stdio")
throw new TypeError("Expected an stdio registration");
if (
(client.format === "omp" || client.format === "pi") &&
(client.format === "kimi" ||
client.format === "omp" ||
client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio"
)

View File

@ -60,6 +60,7 @@ type DirectAnalysisTool =
| "annotate_native_function"
| "inspect_native_load_image"
| "read_bytes"
| "search_bytes"
| "address_to_file_offset"
| "binary_overview"
| "procedure_pseudo_code"

View File

@ -74,7 +74,7 @@ describe("doctor", () => {
),
).toEqual({
node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
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.",
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.",
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
target: "Supply a readable local app or program path.",
});
@ -418,10 +418,9 @@ describe("doctor scoped readiness", () => {
platform: "linux",
linuxDistribution: () =>
Promise.resolve({
id: "debian",
versionId: "13",
packageFamily: "deb",
supported: false,
id: "nixos",
packageFamily: undefined,
hopperSetupSupported: false,
}),
clientRegistrations: () =>
Promise.resolve([
@ -451,6 +450,31 @@ 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",
@ -502,7 +526,7 @@ describe("doctor Linux and Hopper discovery", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
}),
configuredHopperPath: path,
executable: (candidate) => Promise.resolve(candidate === path),
@ -522,7 +546,7 @@ describe("doctor Linux and Hopper discovery", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
}),
linuxDemoRuntimeCheck: () =>
Promise.resolve({
@ -554,7 +578,7 @@ describe("doctor Linux and Hopper discovery", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
}),
executable: (candidate) => Promise.resolve(candidate === path),
supportedLinuxHopper: () => Promise.resolve(false),

View File

@ -2,7 +2,7 @@ import {
SUPPORTED_NODE_VERSION_PROSE,
supportsNodeVersion,
} from "../domain/runtimeVersion.js";
import { linuxHopperLauncherPath } from "./LinuxHopper.js";
import { linuxHopperLauncherPath, supportsReaHost } 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" && linuxDistribution?.supported === true) ||
(host.platform === "linux" && supportsReaHost(linuxDistribution)) ||
(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+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.",
"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.",
classification: "unsupported_host",
});
};

View File

@ -8,6 +8,7 @@ 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 &&
@ -295,10 +296,15 @@ export class DshCordisDocument {
carried = true;
return [this.#entry(desired, invalidated)] as typeof inserted.items;
});
replacements.set(
op.source,
inserted.items.length === 0 ? undefined : copy,
);
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);
}
const output = this.#yaml.document.clone();
const outputRoot = output.contents;

View File

@ -7,6 +7,7 @@ import {
linuxPackageManagerCommands,
linuxSharedLibrariesAvailable,
parseLinuxDistribution,
supportsReaHost,
type LinuxDistribution,
type LinuxHopperDownload,
type LinuxHopperInstallHost,
@ -19,7 +20,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
};
archive = new Uint8Array([1, 2, 3]);
archiveOk = true;
@ -66,7 +67,7 @@ describe("Linux Hopper host classification", () => {
(document, family) => {
expect(parseLinuxDistribution(document)).toMatchObject({
packageFamily: family,
supported: true,
hopperSetupSupported: true,
});
},
);
@ -99,8 +100,32 @@ describe("Linux Hopper host classification", () => {
"ID=garuda\nID_LIKE=arch\n",
"ID=omarchy\n",
"ID=omarchy\nID_LIKE=debian\n",
])("rejects unsupported vendor/version combinations", (document) => {
expect(parseLinuxDistribution(document).supported).toBe(false);
])(
"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);
});
});
@ -166,7 +191,7 @@ describe("Linux Hopper installation", () => {
host.distributionValue = {
id: family,
packageFamily: family,
supported: true,
hopperSetupSupported: true,
};
const result = await installFixture(host);
expect(result).toEqual({
@ -194,7 +219,7 @@ describe("Linux Hopper installation", () => {
host.distributionValue = {
id: "debian",
packageFamily: "deb",
supported: false,
hopperSetupSupported: false,
};
if (scenario === "package request failure") host.archiveOk = false;
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);

View File

@ -76,19 +76,35 @@ interface LinuxDistributionIdentity {
readonly versionId?: string;
}
/** Parsed Linux distribution information used for support and package selection. */
/**
* 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.
*/
export type LinuxDistribution = LinuxDistributionIdentity &
(
| {
readonly packageFamily: LinuxPackageFamily;
readonly supported: true;
readonly hopperSetupSupported: true;
}
| {
readonly packageFamily: LinuxPackageFamily | undefined;
readonly supported: false;
readonly hopperSetupSupported: 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;
@ -141,16 +157,19 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => {
const key = match[1];
const encoded = match[2];
if (key === undefined || encoded === undefined) continue;
const value =
encoded.startsWith('"') && encoded.endsWith('"')
? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
: encoded;
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");
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 supported =
const hopperSetupSupported =
packageFamily !== undefined &&
(id === "arch" ||
id === "cachyos" ||
@ -162,9 +181,9 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => {
id,
...(versionId === undefined ? {} : { versionId }),
};
return supported
? { ...identity, packageFamily, supported: true }
: { ...identity, packageFamily, supported: false };
return hopperSetupSupported
? { ...identity, packageFamily, hopperSetupSupported: true }
: { ...identity, packageFamily, hopperSetupSupported: false };
};
/** Read and classify the current Linux distribution. */
@ -193,7 +212,7 @@ export const installLinuxHopper = async (
if (options.signal?.aborted === true)
return { status: "failed", reason: "cancelled" };
const distribution = await host.distribution();
if (distribution?.supported !== true)
if (distribution?.hopperSetupSupported !== true)
return { status: "failed", reason: "unsupported_host" };
let temporary: string | undefined;
try {

View File

@ -43,7 +43,7 @@ export const executeProviderOperation = async <
provider: ProviderOperationPort<TRequest>,
confidence: "observed" | "derived",
): Promise<Result<Evidence, AnalysisError>> => {
if (options?.signal?.aborted === true)
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(operation));
const request = schema.safeParse({ operation, input });
if (!request.success)
@ -51,8 +51,10 @@ export const executeProviderOperation = async <
const executed = await provider.execute(request.data, options);
if (!executed.ok) return executed;
const execution = executed.value;
return ok(
createEvidence(execution.subject ?? undefined, execution.provider, {
const evidence = createEvidence(
execution.subject ?? undefined,
execution.provider,
{
operation,
parameters: jsonObjectSchema.parse(request.data.input),
result: execution.result,
@ -60,6 +62,16 @@ 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);
};

View File

@ -57,7 +57,7 @@ describe("setup workflow", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
};
host.hopper = "/opt/hopper/bin/Hopper";
host.linuxDemoRuntimeMissing = true;
@ -79,7 +79,7 @@ describe("setup workflow", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
};
host.hopper = "/custom/Hopper";
host.unsupportedHopperVersion = true;

View File

@ -70,7 +70,7 @@ describe("setup workflow", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
};
const result = await runSetup(
{ ...options(false), structured: false },
@ -162,7 +162,7 @@ describe("setup workflow", () => {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: 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",
supported: true,
hopperSetupSupported: true,
};
host.ghidra = "/opt/ghidra_12.1.4_PUBLIC";
host.javaHome = "/usr/lib/jvm/jdk-21";

View File

@ -310,7 +310,7 @@ describe("setup scoped readiness", () => {
id: "debian",
versionId: "13",
packageFamily: "deb",
supported: false,
hopperSetupSupported: 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",
supported: false,
hopperSetupSupported: false,
};
host.ghidra = "/opt/ghidra_12.1.4_PUBLIC";
host.javaHome = "/usr/lib/jvm/jdk-21";

View File

@ -704,6 +704,14 @@ 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"
@ -711,5 +719,11 @@ const clientConfigurationDesired = (
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
}
: {}),
...(client.name === "kimi"
? {
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
}
: {}),
};
};

View File

@ -124,7 +124,8 @@ export const hostRemediation = async (
? "Upgrade to macOS 12 or newer."
: undefined;
}
if ((await host.linuxDistribution())?.supported === true) return undefined;
if ((await host.linuxDistribution())?.hopperSetupSupported === 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.";
};

View File

@ -56,7 +56,8 @@ export const discoverSetupState = async (input: {
const hopperInstallSupported =
(input.host.platform === "darwin" &&
majorVersion((await input.host.macosVersion()) ?? "0") >= 12) ||
(input.host.platform === "linux" && linuxDistribution?.supported === true);
(input.host.platform === "linux" &&
linuxDistribution?.hopperSetupSupported === true);
const installHopper =
input.forceHopperInstall ||
(hopperInstallSupported &&

View File

@ -72,6 +72,15 @@ 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>;
@ -244,19 +253,36 @@ const canonicalSkillFiles = async (
const destination = join(root, relativePath);
return {
destination,
content: await readFile(
new URL(
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
import.meta.url,
),
"utf8",
),
content: await canonicalSkillContent(relativePath),
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,
@ -266,15 +292,13 @@ export const canonicalSkillNeedsInstall = async (
resources?: ArtifactResourceScope,
): Promise<boolean> => {
try {
return (
await canonicalSkillFiles(
home,
clientIds,
environment,
platform,
resources,
)
).some(({ content, original }) => original?.content !== content);
return await canonicalSkillNeedsInstallByReading(
home,
clientIds,
environment,
platform,
resources,
);
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure ||

View File

@ -45,6 +45,7 @@ export interface SetupClient {
| "grok"
| "omp"
| "pi"
| "kimi"
| "hermes"
| "zcode"
| "dsh"
@ -165,6 +166,10 @@ 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");
@ -576,6 +581,14 @@ 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",

View File

@ -627,11 +627,16 @@ const removeManagedSkills = async (
platform: NodeJS.Platform,
writeBlocker?: (path: string) => string | undefined,
): Promise<UninstallItem> => {
// Every destination shares one summary item, so each result is labeled by
// the directory it refers to.
// Every destination shares one summary item; preserve both client and path.
const results = await Promise.all(
skillDestinations(home, undefined, environment, platform).map(({ path }) =>
removeManagedSkill(path, path, fileSystem, writeBlocker),
skillDestinations(home, undefined, environment, platform).map(
({ client, path }) =>
removeManagedSkill(
path,
`${client} (${path})`,
fileSystem,
writeBlocker,
),
),
);
const failed = results.find(({ status }) => status === "failed");

View File

@ -83,3 +83,37 @@ 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");
});

View File

@ -283,7 +283,9 @@ export class BinarySession
previous.route.identity.id,
);
if (!closed.ok) {
this.clearSessionRecords();
// A failed open retires the provider cache, not the investigation.
// Preserve Evidence and Unknown history for export and recovery.
this.clearSnapshot();
this.#clearRuntimeAvailability();
return closed;
}

View File

@ -30,6 +30,29 @@ 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 },

View File

@ -10,6 +10,7 @@ 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";
@ -101,7 +102,9 @@ export class RecordedCrashService {
{
path: value.artifact.path,
format: "elf",
architecture: "x86_64",
architecture:
RECORDED_LINUX_CORE_PROFILES[value.architecture]
.evidenceArchitecture,
sha256: value.artifact.sha256,
},
this.provider.identity,

View File

@ -100,4 +100,10 @@ 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 }),
});

View File

@ -62,26 +62,7 @@ export const analyzeJavaScriptApplicationValidated = async (
});
await checkpointJavaScriptAnalysis(options.signal);
reconstructed = await reconstructJavaScriptArtifact(
{
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 }),
},
input,
scope,
options.signal,
options.progress,

View File

@ -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(names.length, contents))
if (!hasValidSourceMapContents(contents))
return invalidSourceMap(
file,
"Source map sourcesContent must contain one string or null entry per source.",
"Source map sourcesContent must be an array of strings or null entries.",
);
if (map.sourceRoot != null && typeof map.sourceRoot !== "string")
return invalidSourceMap(

View File

@ -457,9 +457,8 @@ const bareModuleCandidate = (
dependencyDirectory = posix.dirname(confined);
}
const source = input.files.get(input.sourcePath);
let directory = posix.dirname(input.sourcePath);
let nearestNotFound: CandidateResolution | null = null;
while (true) {
for (const directory of packageLookupDirectories(input)) {
const candidate = posix.join(directory, dependencyDirectory);
const metadata = readPackageMetadata(input, candidate);
const continueCommonJsLookup =
@ -487,8 +486,6 @@ const bareModuleCandidate = (
nearestNotFound ??= packageResolution;
} else return packageResolution;
}
if (directory === "." || directory === "") break;
directory = posix.dirname(directory);
}
if (nearestNotFound !== null) return nearestNotFound;
return unresolvedCandidate("external", [
@ -496,6 +493,21 @@ 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.
@ -960,15 +972,29 @@ const resolveCandidate = (
candidate: string,
packageChain: ReadonlySet<string>,
emittedSourceCandidates: readonly TypeScriptEmittedSourceCandidate[] = [],
): CandidateResolution =>
resolveFileCandidates(input, fileCandidates(candidate, input.moduleKind)) ??
resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates) ??
resolveDirectory(
): CandidateResolution => {
if (!commonJsDirectoryRequest(input)) {
const file =
resolveFileCandidates(
input,
fileCandidates(candidate, input.moduleKind),
) ??
resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates);
if (file !== null) return file;
}
return 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,

View File

@ -277,15 +277,9 @@ 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(
parameters,
input,
partial,
);
throw interruption.retainPartialObservation(evidence);

View File

@ -23,6 +23,20 @@ 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,
@ -199,6 +213,49 @@ 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({
@ -215,8 +272,12 @@ describe("native UI action trace", () => {
},
});
});
it("builds the evidence path from the active app and native provider", async () => {
const tools = new EnhancedTools(testAnalysis());
it("builds the evidence path from provider names with declared symbol facts", async () => {
const tools = new EnhancedTools(
testAnalysis({
list_names: () => execution("list_names", [methodInventoryEntry]),
}),
);
const result = await tools.execute("trace_native_ui_action", {
action: "buildTapped:",
});
@ -349,18 +410,117 @@ describe("bounded native UI tracing", () => {
error: { _tag: "AnalysisInputError" },
});
});
});
it("binds dispatch metadata to the active target digest", async () => {
const result = await new EnhancedTools(testAnalysis()).execute(
"inspect_native_dispatch_metadata",
{ max_records: 10 },
);
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 });
expect(result).toMatchObject({
ok: true,
value: { target_sha256: target },
});
});
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",
},
});
}
},
);
});
describe("native UI query outcomes", () => {

View File

@ -7,6 +7,59 @@ 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());

View File

@ -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,15 +40,29 @@ 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 err(
new AnalysisOutputError(
return {
ok: false as const,
error: 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 ||
@ -64,45 +78,52 @@ export const readNativeCallRoutes = async (
targets.add(call.target_address);
countBySite.set(call.source_address, targets);
}
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,
});
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,
},
};
}
} else if (execution.error._tag !== "AnalysisCapabilityUnavailableError")
return err(execution.error);
return { ok: false as const, error: execution.error, unknowns };
const callees = await analysis.execute(
"procedure_callees",
{ procedure },
options,
);
if (!callees.ok) return callees;
if (!callees.ok)
return { ok: false as const, error: callees.error, unknowns };
const parsed = parseRelatedAddresses(callees.value.result, "callees");
return parsed.ok
? ok({
provider: callees.value.provider,
routes: parsed.value.map((address) => ({
address,
site: procedure,
relation: "provider_call" as const,
resolution: "inferred" as const,
})),
unknowns: [],
})
: parsed;
? {
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 };
};

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { addressedValue } from "../../contracts/toolOutputSchemaPrimitives.js";
import {
nativeDispatchMetadataResultSchema,
inspectNativeDispatchMetadata,
@ -36,8 +37,11 @@ export const inspectNativeDispatch = async (
const names = z
.array(
z.union([
z.strictObject({ address: z.string(), value: z.string() }),
z.strictObject({ address: z.string(), name: z.string() }),
addressedValue.strict(),
addressedValue
.omit({ value: true })
.extend({ name: z.string() })
.strict(),
]),
)
.safeParse(execution.value.result);

View File

@ -414,9 +414,44 @@ 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 (execution.error._tag === "AnalysisCancelledError")
return { error: execution.error };
if (edges.size >= input.input.max_edges) {
truncated = true;
omitted += 1;
continue;
}
const id = `unknown-callees:${current.address}`;
edges.set(id, {
id,
@ -432,32 +467,6 @@ 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) {
@ -469,6 +478,7 @@ 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,

View File

@ -6,6 +6,7 @@ import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
type AnalysisInputIssue,
} from "../../domain/analysisErrorCore.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import {
@ -41,22 +42,29 @@ export class WasmArtifactService {
cause: input.error,
}),
);
if (
![
input.data.path,
...input.data.glue_paths,
...input.data.candidate_paths,
].every(isAbsolute)
)
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["path"],
reason: "invalid_format",
message: "Expected an absolute filesystem path on this host.",
},
]),
);
const pathIssues = [
{ value: input.data.path, path: ["path"] },
...input.data.glue_paths.map((value, index) => ({
value,
path: ["glue_paths", index],
})),
...input.data.candidate_paths.map((value, index) => ({
value,
path: ["candidate_paths", index],
})),
].flatMap<AnalysisInputIssue>(({ value, path }) =>
isAbsolute(value)
? []
: [
{
path,
reason: "invalid_format",
message: "Expected an absolute filesystem path on this host.",
},
],
);
if (pathIssues.length > 0)
return err(new AnalysisInputError(OPERATION, undefined, pathIssues));
const inspected = await this.provider.inspect(input.data, options);
if (!inspected.ok) return inspected;
if (options?.signal?.aborted)

View File

@ -0,0 +1,198 @@
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" }],
},
);
},
);

View File

@ -0,0 +1,250 @@
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),
},
]),
);
}
};

View File

@ -1,25 +0,0 @@
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" },
]);
});
});

View File

@ -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 { listPackage, statFile, uncache } from "@electron/asar";
import { getRawHeader, uncache } from "@electron/asar";
import {
ArtifactReaderFailure,
@ -26,7 +26,11 @@ import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
import { admitAsarHeader } from "./AsarHeader.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import {
readAsarListing,
type AsarListedEntry,
type AsarMetadata,
} from "./AsarListing.js";
/**
* Official Electron ASAR adapter with range-streamed member reads.
@ -51,7 +55,7 @@ export class AsarArtifactReader implements ArtifactReader {
) {}
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
let paths: readonly AsarListedPath[];
let paths: readonly AsarListedEntry[];
try {
await this.prepareContainer(undefined, signal);
paths = await this.#withSnapshotOperation(async () => {
@ -62,8 +66,9 @@ export class AsarArtifactReader implements ArtifactReader {
this.#archiveSize = admitted.archiveBytes;
this.#headerSize = admitted.headerBytes;
abortIfNeeded(signal);
const ordered = orderAsarListing(
listPackage(snapshotPath, { isPack: false }),
const ordered = await readAsarListing(
getRawHeader(snapshotPath).header,
signal,
);
const archiveMetadata = await lstat(snapshotPath);
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
@ -77,34 +82,25 @@ export class AsarArtifactReader implements ArtifactReader {
} catch (cause: unknown) {
throw asarFailure(this.path, "inventory", cause);
}
for (const { listed, path } of paths) {
for (const { metadata, path } of paths) {
abortIfNeeded(signal);
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 providerPath = path.split("/").join(sep);
const kind =
"files" in metadata
metadata.files !== undefined
? "directory"
: "link" in metadata
: metadata.link !== undefined
? "symlink"
: "file";
const entry: ArtifactEntry = {
path,
kind,
declaredSize: "size" in metadata ? metadata.size : null,
declaredSize: metadata.size ?? null,
compressedSize: null,
executable: "executable" in metadata && metadata.executable,
executable: metadata.executable === true,
encrypted: false,
byteOffset: null,
declaredSha256:
"integrity" in metadata &&
metadata.integrity !== undefined &&
metadata.integrity.algorithm === "SHA256" &&
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
? metadata.integrity.hash
@ -603,7 +599,7 @@ export class AsarArtifactReader implements ArtifactReader {
}
}
type AsarFileMetadata = Extract<ReturnType<typeof statFile>, { size: number }>;
type AsarFileMetadata = AsarMetadata & { readonly size: number };
type AsarEntryState = {
readonly metadata: AsarFileMetadata;
readonly entry: ArtifactEntry;
@ -652,12 +648,11 @@ const asarUnpackedEntryFailure = (
};
const isAsarFileMetadata = (
metadata: ReturnType<typeof statFile>,
metadata: AsarMetadata,
): metadata is AsarFileMetadata =>
"size" in metadata &&
typeof metadata.size === "number" &&
!("files" in metadata) &&
!("link" in metadata);
metadata.files === undefined &&
metadata.link === undefined;
const parseArchiveOffset = (value: string): number | undefined => {
if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) return undefined;
@ -699,33 +694,6 @@ 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,

View File

@ -0,0 +1,110 @@
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),
);
};

View File

@ -49,13 +49,14 @@ 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";
@ -82,11 +83,13 @@ export class CdpBrowserProvider implements BrowserObservationPort {
options: ExecutionOptions = {},
): Promise<Result<BrowserTargetList, AnalysisError>> {
try {
const discovery = await discoverCdpEndpoint(
const endpoint = await openCdpEndpoint(
input.cdp_endpoint,
"list_browser_targets",
options.signal,
);
await endpoint.connection?.close();
const { discovery } = endpoint;
const selected = selectTargets(
discovery,
input.allowed_origins.length === 0
@ -169,45 +172,19 @@ 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 observeCdpSession({
connection: targetSession.connection,
sessionId: targetSession.sessionId,
discovery,
target,
input: scopedInput,
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.progress === undefined
? {}
: { progress: options.progress }),
}),
await withTargetSession(
input,
"observe_web_session",
options,
observeCdpSession,
),
),
);
} catch (cause: unknown) {
return err(providerError(cause, "observe_web_session"));
} finally {
if (targetSession !== undefined)
await closeCdpTargetSession(
targetSession,
CLEANUP_DOMAINS,
options.signal,
);
}
}
@ -215,45 +192,19 @@ 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 discoverWebMcp({
connection: targetSession.connection,
sessionId: targetSession.sessionId,
discovery,
target,
input: scopedInput,
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.progress === undefined
? {}
: { progress: options.progress }),
}),
await withTargetSession(
input,
"discover_webmcp_tools",
options,
discoverWebMcp,
),
),
);
} catch (cause: unknown) {
return err(providerError(cause, "discover_webmcp_tools"));
} finally {
if (targetSession !== undefined)
await closeCdpTargetSession(
targetSession,
CLEANUP_DOMAINS,
options.signal,
);
}
}
@ -273,45 +224,19 @@ 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 captureCdpScreenshot({
connection: targetSession.connection,
sessionId: targetSession.sessionId,
discovery,
target,
input: scopedInput,
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.progress === undefined
? {}
: { progress: options.progress }),
}),
await withTargetSession(
input,
"capture_web_screenshot",
options,
captureCdpScreenshot,
),
),
);
} catch (cause: unknown) {
return err(providerError(cause, "capture_web_screenshot"));
} finally {
if (targetSession !== undefined)
await closeCdpTargetSession(
targetSession,
CLEANUP_DOMAINS,
options.signal,
);
}
}
@ -336,44 +261,83 @@ export class CdpBrowserProvider implements BrowserObservationPort {
options: ExecutionOptions,
operation: "inspect_web_page" | "analyze_web_bundle",
): Promise<CapturedPage> {
let targetSession: CdpTargetSession | undefined;
try {
const discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
operation,
options.signal,
);
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,
);
}
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,
options.signal,
);
// An unadopted browser socket is closed here; closing is idempotent.
await endpoint.connection?.close();
}
};
const selectTargets = (
discovery: CdpEndpointDiscovery,
allowedOrigins: ReadonlySet<string> | undefined,

View File

@ -0,0 +1,84 @@
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);
},
);

View File

@ -60,8 +60,8 @@ export const captureStorage = async (
limitations,
)
: undefined;
const cacheValues = recordsValue(recordValue(cacheRaw)?.caches);
const caches = capturedCaches(cacheValues);
const cacheField = recordValue(cacheRaw)?.caches;
const caches = capturedCaches(recordsValue(cacheField));
const fingerprints = context.input.include_storage_fingerprints
? await captureStorageFingerprints({
context,
@ -78,8 +78,10 @@ export const captureStorage = async (
indexedRaw !== undefined &&
Array.isArray(indexedValues) &&
indexed.length === indexedValues.length &&
cacheRaw !== undefined &&
caches.length === cacheValues.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;
return {
value: {
origin,

View File

@ -9,6 +9,7 @@ import type { BrowserObservationOperation } from "../domain/browserObservationEr
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js";
import { safeParseJson } from "../domain/safeJson.js";
import type { CdpConnection } from "./CdpConnection.js";
const endpointVersionSchema = z.object({
Browser: z.string().min(1),
@ -40,6 +41,26 @@ interface CdpDiscoveryResponseLimits {
readonly timeoutMs: number;
}
const browserSocketVersionSchema = z.object({
protocolVersion: z.string().min(1),
product: z.string().min(1),
revision: z.string(),
userAgent: z.string(),
jsVersion: z.string(),
});
const browserSocketTargetsSchema = z.object({
targetInfos: z.array(
z.object({
targetId: z.string().min(1),
type: z.string().min(1),
title: z.string().default(""),
url: z.string(),
attached: z.boolean().optional(),
}),
),
});
/** Validated direct CDP WebSocket bound to one discovered page target. */
interface CdpPageWebSocketEndpoint {
readonly scope: "page";
@ -124,6 +145,88 @@ export const discoverCdpEndpoint = async (
};
};
/**
* Run browser-socket discovery work, including its WebSocket handshake, under
* the HTTP discovery deadline. Reaching the deadline is a timeout, not a
* caller cancellation, so the internal abort is not carried as its cause.
*/
export const withCdpDiscoveryDeadline = async <T>(
operation: BrowserObservationOperation,
signal: AbortSignal | undefined,
work: (deadlineSignal: AbortSignal) => Promise<T>,
timeoutMs: number = CDP_DISCOVERY_RESPONSE_LIMITS.timeoutMs,
): Promise<T> => {
const deadline = new AbortController();
const timer = setTimeout(() => deadline.abort(), timeoutMs);
try {
return await work(
signal === undefined
? deadline.signal
: AbortSignal.any([signal, deadline.signal]),
);
} catch (cause: unknown) {
if (deadline.signal.aborted && signal?.aborted !== true)
throw new BrowserObservationError(operation, "timeout", {
detail: `CDP browser-socket discovery exceeded its ${String(timeoutMs)} ms deadline.`,
});
throw cause;
} finally {
clearTimeout(timer);
}
};
/**
* Read discovery facts over a browser socket that has no HTTP discovery.
* Each reply is bounded by the connection's protocol message limit; callers
* apply the discovery deadline through `signal`.
*/
export const discoverCdpBrowserSocket = async (
connection: CdpConnection,
url: string,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<CdpEndpointDiscovery> => {
const versionInput = await connection.send(
"Browser.getVersion",
{},
undefined,
signal,
);
const targetsInput = await connection.send(
"Target.getTargets",
{},
undefined,
signal,
);
const version = parseCdpEndpointValue(
browserSocketVersionSchema,
versionInput,
operation,
);
const targets = parseCdpEndpointValue(
browserSocketTargetsSchema,
targetsInput,
operation,
);
return {
webSocket: { scope: "browser", url },
version: {
product: version.product,
protocol_version: version.protocolVersion,
revision: version.revision,
user_agent: version.userAgent,
js_version: version.jsVersion,
},
targets: targets.targetInfos.map((target) => ({
id: target.targetId,
type: target.type,
title: sanitizeTargetTitle(target.title, target.url),
url: target.url,
attached: target.attached ?? null,
})),
};
};
const sanitizeTargetTitle = (value: string, targetUrl: string): string => {
let url: URL;
try {

View File

@ -1,8 +1,12 @@
import type { BrowserObservationEndpoint } from "../domain/browserObservation.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { CdpConnection } from "./CdpConnection.js";
import {
cdpTargetWebSocket,
discoverCdpBrowserSocket,
discoverCdpEndpoint,
withCdpDiscoveryDeadline,
type CdpEndpointDiscovery,
type CdpEndpointTarget,
} from "./CdpEndpoint.js";
@ -13,6 +17,59 @@ export interface CdpTargetSession {
readonly sessionId: string | undefined;
}
/** Discovery for one caller-selected endpoint and the browser socket that read it. */
export interface CdpOpenedEndpoint {
readonly discovery: CdpEndpointDiscovery;
/**
* Browser socket kept for the target session, because a browser may ask
* the user to approve each socket. HTTP discovery opens no socket.
*/
readonly connection: CdpConnection | undefined;
}
/**
* Discover one passive browser endpoint through its selected transport. A
* browser socket's handshake and discovery share one discovery deadline.
*/
export const openCdpEndpoint = async (
endpoint: BrowserObservationEndpoint,
operation: BrowserObservationOperation,
signal?: AbortSignal,
timeoutMs?: number,
): Promise<CdpOpenedEndpoint> => {
if (endpoint.transport === "http_discovery")
return {
discovery: await discoverCdpEndpoint(endpoint.url, operation, signal),
connection: undefined,
};
return await withCdpDiscoveryDeadline(
operation,
signal,
async (deadlineSignal) => {
const connection = await CdpConnection.connect(
endpoint.url,
operation,
deadlineSignal,
);
try {
return {
discovery: await discoverCdpBrowserSocket(
connection,
endpoint.url,
operation,
deadlineSignal,
),
connection,
};
} catch (cause: unknown) {
await connection.close();
throw cause;
}
},
timeoutMs,
);
};
/** Open one authorized target through its supported browser or page transport. */
export const openCdpTargetSession = async (
discovery: CdpEndpointDiscovery,
@ -29,6 +86,16 @@ export const openCdpTargetSession = async (
limits,
);
if (webSocket.scope === "page") return { connection, sessionId: undefined };
return await attachCdpTargetSession(connection, target, operation, signal);
};
/** Attach one target through an open browser socket, which the session then owns. */
export const attachCdpTargetSession = async (
connection: CdpConnection,
target: CdpEndpointTarget,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<CdpTargetSession> => {
try {
const attached = await connection.send(
"Target.attachToTarget",

View File

@ -743,8 +743,8 @@ const originalModuleEdges = (
check();
const dependency = originalDependency(node);
if (dependency === null) return;
const { kind, specifier } = dependency;
if (kind === "require" && t.isCallExpression(node)) {
const { kind, specifier, requireCallee } = dependency;
if (requireCallee !== undefined) {
unboundRequires ??= new Set(
analyzeParsedJavaScriptReferences(parsed, "require")
.filter(
@ -756,7 +756,7 @@ const originalModuleEdges = (
`${String(location.start.line)}:${String(location.start.column)}`,
),
);
const location = node.callee.loc?.start;
const location = requireCallee.loc?.start;
if (
location === undefined ||
!unboundRequires.has(
@ -1039,6 +1039,7 @@ const originalDependency = (
): {
readonly kind: ParsedSourceMapItem["original_module_edges"][number]["kind"];
readonly specifier: string;
readonly requireCallee?: t.Identifier;
} | null => {
if (
t.isImportDeclaration(node) ||
@ -1051,11 +1052,15 @@ const originalDependency = (
if (t.isImportExpression(node) && t.isStringLiteral(node.source))
return { kind: "dynamic_import", specifier: node.source.value };
if (
t.isCallExpression(node) &&
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
t.isIdentifier(node.callee, { name: "require" }) &&
t.isStringLiteral(node.arguments[0])
)
return { kind: "require", specifier: node.arguments[0].value };
return {
kind: "require",
specifier: node.arguments[0].value,
requireCallee: node.callee,
};
// `import x = require("m")` is only legal at module top level and always
// refers to the host loader, so it needs no unbound-`require` check. The
// `moduleReference` is an entity name for a local alias instead, which

View File

@ -8,6 +8,8 @@ import {
FIELD_RECORD_BUDGET,
} from "./parseFieldDiscovery.js";
import {
BOUNDED_PRODUCER_LIMITS as LIMITS,
prlimitArgumentVector,
readLinuxResourceLimits,
type LinuxResourceLimits,
} from "../process/LinuxResourceLimits.js";
@ -60,15 +62,6 @@ export const TSHARK_PROVIDER_IDENTITY = {
name: "REA TShark capture adapter",
version: "tshark@4",
} as const;
// Bound the resident input copy and retained text before allocation/collection.
const LIMITS = {
input_bytes: 32 * 1024 * 1024,
output_bytes: 4 * 1024 * 1024,
command_timeout_ms: 30_000,
address_space_bytes: 1024 * 1024 * 1024,
file_bytes: 64 * 1024 * 1024,
cpu_seconds: 30,
} as const;
type Launcher = NonNullable<Parameters<typeof runOwnedCommand>[2]>["launcher"];
/** Inspect selected capture files only, retaining cleanup ownership across failures. */
@ -165,14 +158,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
options?.signal,
);
effectiveLimits = await this.#resources.readLimits(limits);
const limitArguments = [
`--as=${effectiveLimits.address_space_bytes}:${effectiveLimits.address_space_bytes}`,
`--fsize=${effectiveLimits.file_bytes}:${effectiveLimits.file_bytes}`,
`--cpu=${effectiveLimits.cpu_seconds}:${effectiveLimits.cpu_seconds}`,
"--core=0:0",
"--",
command,
];
const limitArguments = prlimitArgumentVector(effectiveLimits, command);
phase = "workspace";
root = await this.createRuntime();
this.#resources.retain(root, undefined);

Some files were not shown because too many files have changed in this diff Show More