Merge branch 'main' into codex/review-fixes-pr-1977
This commit is contained in:
commit
f45cb4b92a
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
28
docs/cli.md
28
docs/cli.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/) |
|
||||
|
|
|
|||
|
|
@ -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/) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()))
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()]
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export function assertCleanLinuxHopperRuntime(
|
||||
launcherPaths?: readonly string[],
|
||||
procRoot?: string,
|
||||
): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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"),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -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),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue