Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a8efb8226 | ||
|
|
1d4c4eb030 | ||
|
|
9eee709f0c | ||
|
|
beb80a07ba | ||
|
|
108b5f9425 | ||
|
|
a8d4984404 | ||
|
|
c002b89e2d | ||
|
|
2260887f20 | ||
|
|
c2407689cb | ||
|
|
39418ea173 | ||
|
|
7d92d93a55 | ||
|
|
eb97da3388 | ||
|
|
290d301ec7 | ||
|
|
5e219de441 | ||
|
|
5ba1642a7d | ||
|
|
804c287e44 | ||
|
|
918713af37 | ||
|
|
a82e37c806 | ||
|
|
c2b56e3e1c | ||
|
|
12fa8fd9f9 | ||
|
|
dc303a8ee8 | ||
|
|
3d83135987 | ||
|
|
09b6828516 | ||
|
|
f64790bfc3 | ||
|
|
c42232f566 | ||
|
|
52e76c5d39 | ||
|
|
1449e8e051 | ||
|
|
7cdb7981c3 | ||
|
|
0ae38e1e31 | ||
|
|
c2cf7dcb86 | ||
|
|
14935e4d47 | ||
|
|
85c1d2587a | ||
|
|
0d8b728d24 | ||
|
|
9878bb7296 | ||
|
|
12ee785e5e | ||
|
|
e59db2f5de | ||
|
|
d802304c54 | ||
|
|
0f4992fb9e | ||
|
|
72489a9445 | ||
|
|
b6651cb99f | ||
|
|
0b2aa69531 | ||
|
|
b5b73019f9 | ||
|
|
d0edeec100 | ||
|
|
3b490a743f | ||
|
|
3298219d8c | ||
|
|
b07e54a10e | ||
|
|
fb43dd9716 | ||
|
|
0485cd3bce | ||
|
|
c32d84622f | ||
|
|
601814ed0e | ||
|
|
dfe31172ca | ||
|
|
6407718f30 | ||
|
|
f8513a42dc | ||
|
|
a95795c6ed | ||
|
|
369cd3dfdc | ||
|
|
2976fac30d | ||
|
|
f598bab488 | ||
|
|
bb508134ef | ||
|
|
1dfb933e47 | ||
|
|
486b56724c | ||
|
|
9d8e42bdd6 | ||
|
|
d28b41ea4e | ||
|
|
e8491e023a | ||
|
|
097f0d54af | ||
|
|
e4ac7bbfc2 | ||
|
|
0b7fc40eee | ||
|
|
7a0140b4aa | ||
|
|
9b3e59804d | ||
|
|
1769417001 | ||
|
|
898d5096f9 | ||
|
|
38539a2f4a | ||
|
|
23d50ea3be | ||
|
|
499ac18011 | ||
|
|
7c207c7951 | ||
|
|
d3e83b0899 | ||
|
|
29d2624b34 | ||
|
|
fa9a7e5ec0 | ||
|
|
efbc25fbea | ||
|
|
ea40f717b2 |
@@ -2,7 +2,7 @@
|
||||
name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "38"
|
||||
version: "39"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -9,7 +9,22 @@ provenance; inputs and recovered code are never executed. Recovery requires a
|
||||
caller-supplied Wakaru on Linux x64; see [the recovery guide](https://github.com/morluto/rea/blob/main/docs/javascript-recovery.md).
|
||||
|
||||
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
|
||||
extracted tree. When size is unknown or large and the connected schema advertises
|
||||
extracted tree. When the question concerns known source paths and the connected
|
||||
schema advertises `source_paths`, select exact inventoried paths such as
|
||||
`["package.json", "main.js", "preload.js"]`. Include supporting manifests or
|
||||
source maps explicitly; dependencies are not implicitly analyzed. REA retains
|
||||
the complete artifact inventory and identity but reads source text and analyzes
|
||||
only selected files. The result and summary record `source_selection`; files
|
||||
outside scope and their relationships remain unknown, so partial application
|
||||
coverage does not mean the selected source failed. Use repeated `--source-path`
|
||||
on either static application CLI route. Omit the selection when the question
|
||||
requires complete application analysis. `detail: "summary"` reduces delivery
|
||||
and does not by itself reduce analysis work.
|
||||
|
||||
Exact `#alias` specifiers resolve through the importer's
|
||||
enclosing package `imports` map to package-local files; nested `node_modules`
|
||||
packages use their own map. Wildcard, recursive, and external-package import
|
||||
targets stay unresolved with that limitation. When size is unknown or large and the connected schema advertises
|
||||
`detail`, request `detail: "summary"`. This returns an `inspect_analysis_view`
|
||||
Evidence record with a distinct ID and a `normalized_result.parent_evidence_id`
|
||||
for the complete retained application analysis. Source-map contents are part of
|
||||
|
||||
@@ -150,6 +150,13 @@ For headerless DOS COM, explicitly open with `format: "dos-com"` (CLI
|
||||
analysis: the entry is `0x10100`, file offset zero, with imposed real-mode segment
|
||||
context. PSP/stack/device state remains unmodeled. See [DOS guide](https://github.com/morluto/rea/blob/main/docs/ghidra-dos.md).
|
||||
|
||||
For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ
|
||||
headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`,
|
||||
`arm`, or `arm64`), and `entry_point` (`0x` hex). File offset 0 is mapped at
|
||||
that address. Hopper and IDA refuse this interpretation; Ghidra uses
|
||||
`BinaryLoader` with a little-endian generic language. Big-endian, Thumb-only,
|
||||
and Cortex-M variants are unsupported. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
|
||||
|
||||
### Function annotations in Ghidra
|
||||
|
||||
Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic and session-scoped; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits until close. CLI `annotate-native-function` returns the updated analysis before discarding the session. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations. For naming only, `set_address_name` / `set_addresses_names` also work with Ghidra: they name function entries or data addresses (primary labels) and return compact per-address success booleans instead of a dossier.
|
||||
|
||||
@@ -493,7 +493,7 @@ jobs:
|
||||
tests/acceptance/webSourceLocationCli.test.ts
|
||||
tests/boundary/cli/referenceSourceImportStatus.test.ts
|
||||
tests/boundary/filesystem/asarInventoryFreshness.test.ts
|
||||
tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts
|
||||
tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts
|
||||
tests/conformance/providers/ghidra/windowsCapabilities.test.ts
|
||||
src/ghidra/GhidraInstallation.test.ts
|
||||
tests/boundary/providers/ghidra/ghidraTransport.test.ts
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Test files are intentionally NOT excluded: the gate used to report zero clones because it skipped **/*.test.ts, hiding all test-tree duplication. Current actual is ~2.47% lines across src+ and tests/, down from 3.4% when tests were included. The 2.6% limit leaves modest headroom for ordinary changes; lower it as duplication is removed.",
|
||||
"threshold": 2.6,
|
||||
"_comment": "Test files remain included so the gate measures duplication across both src/ and tests/. The 3.0% limit leaves headroom for ordinary changes while keeping duplication visible.",
|
||||
"threshold": 3.0,
|
||||
"reporters": ["consoleFull"],
|
||||
"ignore": [
|
||||
"node_modules/**",
|
||||
|
||||
@@ -18,6 +18,8 @@ Give each interpretation and resource a clear owner. Preserve identity, omission
|
||||
|
||||
Assess brittleness in touched code during implementation, debugging, review, and maintenance. Fix the producing representation or ownership rule before adding downstream repairs. Normalize once at the owning boundary using format-aware parsers. Remove superseded representations, aliases, wrappers, and redundant tests after verifying affected consumers. Introduce abstractions only when they remove observed complexity.
|
||||
|
||||
When fixing a bug, inspect the producing boundary and affected consumers for duplicated interpretation or unclear ownership. Complete any directly related refactor in the same change and remove superseded code. Passing regression tests alone does not establish that the brittleness is resolved. If related cleanup remains, explain what remains and why.
|
||||
|
||||
Parse unknown boundary values and model expected failures with the tagged error algebra and `Result`. Preserve partial facts and meaningful failure reasons. Keep identity and lookup values separate from display formatting; preserve source values and explicit unknowns when normalization loses information. Absolute paths, file URLs, and HTTP paths have different semantics.
|
||||
|
||||
Validate advertised JSON Schemas after SDK conversion against their declared dialect and actual clients. Bind handlers to named contracts with exact types; catalog ordering must never select an operation or schema.
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**See a feature you like. Understand how it works, down to the binary level.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#what-you-can-analyze)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
@@ -245,7 +244,9 @@ Ghidra and IDA use your existing installations. See [provider setup](docs/instal
|
||||
<summary><strong>Do I need to start Hopper first?</strong></summary>
|
||||
|
||||
REA starts Hopper when an operation needs it. On macOS, a first-run dialog may
|
||||
ask you to choose demo mode or activate your license. See
|
||||
ask you to choose demo mode or activate your license. Complete that prompt in the
|
||||
same user session before your first CLI/MCP operation or unattended job. Open
|
||||
Hopper manually if needed. See
|
||||
[Hopper startup and troubleshooting](docs/installation.md#launcher-paths-and-troubleshooting).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**وجدت ميزة تعجبك؟ افهم طريقة عملها حتى مستوى الشيفرة الثنائية.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Eine Funktion gefällt dir? Verstehe, wie sie funktioniert – bis hinunter auf die Binärebene.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**¿Ves una función que te gusta? Entiende cómo funciona, hasta el nivel binario.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**قابلیتی دیدهاید که دوستش دارید؟ سازوکارش را تا سطح باینری درک کنید.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Repérez une fonctionnalité qui vous plaît. Comprenez son fonctionnement, jusqu'au niveau du binaire.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Melihat fitur yang Anda sukai. Pahami cara kerjanya hingga ke tingkat biner.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Vedi una funzionalità che ti piace. Capisci come funziona, fino al livello binario.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#cosa-puoi-analizzare)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**気に入った機能を見つけたら、その仕組みをバイナリのレベルまで理解する。**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**마음에 드는 기능을 발견했다면, 바이너리 수준까지 작동 원리를 이해하세요.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Widzisz funkcję, która Ci się podoba? Dowiedz się, jak działa, aż do poziomu kodu binarnego.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Viu uma funcionalidade de que gostou? Entenda como ela funciona, até o nível binário.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Нашли интересную функцию? Разберитесь, как она работает, вплоть до двоичного кода.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**เห็นฟีเจอร์ที่ชอบแล้วอยากรู้ว่าทำงานอย่างไร? เข้าใจได้ลึกถึงระดับไบนารี**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Beğendiğiniz bir özellik mi gördünüz? İkili kod düzeyine kadar nasıl çalıştığını anlayın.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Побачили цікаву функцію? Зрозумійте, як вона працює, аж до рівня двійкового коду.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**Thấy một tính năng bạn thích? Hiểu cách nó hoạt động, đến tận mức mã nhị phân.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**看到喜歡的功能,弄清它的工作原理,深入到二進位層面。**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
**看到喜欢的功能,弄清它的工作原理,深入到二进制层面。**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
@@ -126,6 +126,20 @@ class ReaJadxBridge {
|
||||
return result;
|
||||
}
|
||||
|
||||
private JadxSession.SmartCode methodBody(JadxSession current, ClassSnapshot cls,
|
||||
MethodSnapshot selected, JavaMethod method) {
|
||||
if (!selected.shortId().equals("<clinit>()V"))
|
||||
return current.getMethodBodySmart(method, current.getMaxSourceBytes(), true);
|
||||
// Java code generation can fold all or part of <clinit> into field declarations.
|
||||
// Its method text cannot establish coverage, even when a static block remains.
|
||||
JadxSession.MethodSmali smali = current.getMethodSmali(cls.node(), method.getName());
|
||||
if (!smali.getFound() || smali.getBlocks().size() != 1)
|
||||
throw new IllegalArgumentException("Static initializer disassembly requires one exact method: " + selected.shortId());
|
||||
return new JadxSession.SmartCode(
|
||||
TextUtilKt.truncateToBytes(smali.getBlocks().get(0), current.getMaxSourceBytes()),
|
||||
"smali", true, List.of("static_initializer_disassembly"));
|
||||
}
|
||||
|
||||
private CallToolResult handle(String name, JsonObject args) {
|
||||
if (name.equals("rea_jvm_status")) return json(Map.of(
|
||||
"engine_reported_version", BuildInfo.VERSION,
|
||||
@@ -150,7 +164,7 @@ class ReaJadxBridge {
|
||||
int index = integer(args, "overload_index", 0);
|
||||
if (index >= overloads.size()) throw new InputFailure("overload_index", "overload_index out of range: " + index);
|
||||
JavaMethod method = method(cls, overloads.get(index));
|
||||
JadxSession.SmartCode code = current.getMethodBodySmart(method, current.getMaxSourceBytes(), true);
|
||||
JadxSession.SmartCode code = methodBody(current, cls, overloads.get(index), method);
|
||||
return json(Map.of("class_name", cls.summary().get("full_name"),
|
||||
"method_name", overloads.get(index).name(), "full_name", method.getFullName(),
|
||||
"overload_index", index, "overload_count", overloads.size(),
|
||||
@@ -211,7 +225,7 @@ class ReaJadxBridge {
|
||||
String description = switch (name) {
|
||||
case "list_classes" -> "Indexed pages of all top-level class names, with offset and limit.";
|
||||
case "get_class_summary" -> "Parsed member metadata for one exact class name; no source generation.";
|
||||
case "get_method_body" -> "Source for an exact class and stable same-name overload index, with smali fallback.";
|
||||
case "get_method_body" -> "Source for an exact class and stable same-name overload index; static initializers use smali to preserve folded field initialization.";
|
||||
default -> "Complete static references to an exact class or unique method; source lines are unresolved.";
|
||||
};
|
||||
Tool narrowed = new Tool(name, new ToolSchema(null, new JsonObject(properties),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import java.util.Map;
|
||||
|
||||
import ghidra.app.plugin.core.analysis.AutoAnalysisManager;
|
||||
import ghidra.app.util.headless.HeadlessScript;
|
||||
|
||||
/** Disable the named auto-analyzers before default auto-analysis. */
|
||||
public final class ReaGhidraAnalysisOptions extends HeadlessScript {
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
String[] arguments = getScriptArgs();
|
||||
if (arguments.length != 1 || arguments[0].isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"ReaGhidraAnalysisOptions requires one comma-separated analyzer-name argument");
|
||||
}
|
||||
String[] names = arguments[0].split(",");
|
||||
AutoAnalysisManager analysis = AutoAnalysisManager.getAnalysisManager(currentProgram);
|
||||
Map<String, String> options = getCurrentAnalysisOptionsAndValues(currentProgram);
|
||||
for (String requestedName : names) {
|
||||
monitor.checkCancelled();
|
||||
String name = requestedName.trim();
|
||||
if (analysis.getAnalyzer(name) == null || !options.containsKey(name)) {
|
||||
throw new IllegalArgumentException("Unknown Ghidra analyzer for this program: " + name);
|
||||
}
|
||||
}
|
||||
for (String requestedName : names) {
|
||||
monitor.checkCancelled();
|
||||
setAnalysisOption(currentProgram, requestedName.trim(), "false");
|
||||
}
|
||||
options = getCurrentAnalysisOptionsAndValues(currentProgram);
|
||||
for (String requestedName : names) {
|
||||
monitor.checkCancelled();
|
||||
String name = requestedName.trim();
|
||||
if (!"false".equals(options.get(name))) {
|
||||
throw new IllegalStateException("Ghidra analyzer remained enabled: " + name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,10 +182,17 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
}
|
||||
sessionDefaultAddressSpace =
|
||||
currentProgram.getAddressFactory().getDefaultAddressSpace();
|
||||
if (currentProgram.getExecutableFormat().equals("Raw Binary") &&
|
||||
!currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO)
|
||||
.getBoolean("REA DOS COM prepared", false)) {
|
||||
throw new IllegalStateException("REA DOS COM entry/context preparation failed");
|
||||
if (currentProgram.getExecutableFormat().equals("Raw Binary")) {
|
||||
boolean dosCom = currentProgram.getLanguageID().getIdAsString()
|
||||
.equals("x86:LE:16:Real Mode");
|
||||
String preparation = dosCom ? "REA DOS COM prepared" : "REA raw binary prepared";
|
||||
if (!currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO)
|
||||
.getBoolean(preparation, false)) {
|
||||
throw new IllegalStateException(
|
||||
dosCom ? "REA DOS COM entry/context preparation failed" :
|
||||
"REA raw binary entry preparation failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!Application.getApplicationVersion().equals(descriptor.providerVersion)) {
|
||||
throw new IllegalStateException("Ghidra provider version does not match the session");
|
||||
@@ -443,6 +450,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
String seeds = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO)
|
||||
.getString("REA analysis seeds", null);
|
||||
if (seeds != null) result.add("analysis_seeds", JsonParser.parseString(seeds));
|
||||
// Written by ReaGhidraMipsSoftFloat after analysis; REA requires it
|
||||
// exactly when the committed profile declares soft-float storage.
|
||||
String softFloat = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO)
|
||||
.getString("REA MIPS soft-float storage", null);
|
||||
if (softFloat != null) result.add("mips_soft_float_storage", JsonParser.parseString(softFloat));
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import org.osgi.framework.BundleActivator;
|
||||
import org.osgi.framework.BundleContext;
|
||||
|
||||
// Compile the no-op activator with REA's scripts. Otherwise Ghidra generates
|
||||
// one afterward and opens a second Java compiler and file manager.
|
||||
public final class ReaGhidraBundleActivator implements BundleActivator {
|
||||
@Override
|
||||
public void start(BundleContext context) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop(BundleContext context) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.database.SpecExtension;
|
||||
import ghidra.program.model.data.AbstractFloatDataType;
|
||||
import ghidra.program.model.data.DataType;
|
||||
import ghidra.program.model.data.DoubleDataType;
|
||||
import ghidra.program.model.data.FloatDataType;
|
||||
import ghidra.program.model.data.FunctionDefinition;
|
||||
import ghidra.program.model.data.IntegerDataType;
|
||||
import ghidra.program.model.data.ParameterDefinition;
|
||||
import ghidra.program.model.data.TypeDef;
|
||||
import ghidra.program.model.lang.CompilerSpec;
|
||||
import ghidra.program.model.lang.PrototypeModel;
|
||||
import ghidra.program.model.lang.Register;
|
||||
import ghidra.program.model.listing.Function;
|
||||
import ghidra.program.model.listing.Parameter;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.program.model.listing.VariableStorage;
|
||||
import ghidra.program.model.pcode.XmlEncode;
|
||||
import java.io.StringReader;
|
||||
import java.io.StringWriter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.transform.OutputKeys;
|
||||
import javax.xml.transform.TransformerFactory;
|
||||
import javax.xml.transform.dom.DOMSource;
|
||||
import javax.xml.transform.stream.StreamResult;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
import org.xml.sax.InputSource;
|
||||
|
||||
/**
|
||||
* Soft-float o32 passes and returns float/double values exactly where same-size integers go.
|
||||
* Ghidra's MIPS32 compiler specifications model only hard-float o32, so every typed float
|
||||
* prototype (functions, externals, thunks via their targets, and function-pointer definitions)
|
||||
* would otherwise read $f12/$f14/$f0. Derive a program-specific prototype model from the
|
||||
* program's own default o32 model with its floating-point entries and rules removed, install it
|
||||
* as a specification extension, and assign it to every callable whose formal types use floats.
|
||||
*/
|
||||
public final class ReaGhidraMipsSoftFloat extends GhidraScript {
|
||||
static final String REPORT_KEY = "REA MIPS soft-float storage";
|
||||
static final String POLICY = "o32-soft-float-prototype-v1";
|
||||
static final String PROTOTYPE = "__rea_soft_float";
|
||||
private static final Pattern FPU_REGISTER = Pattern.compile("f\\d+(_\\d+)?");
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
String processor = currentProgram.getLanguage().getProcessor().toString();
|
||||
if (!processor.equals("MIPS") || currentProgram.getDefaultPointerSize() != 4)
|
||||
throw new IllegalStateException(
|
||||
"REA soft-float storage requires a 32-bit MIPS program, not " + processor);
|
||||
CompilerSpec spec = currentProgram.getCompilerSpec();
|
||||
PrototypeModel base = spec.getDefaultCallingConvention();
|
||||
new SpecExtension(currentProgram)
|
||||
.addReplaceCompilerSpecExtension(softFloatPrototype(base, spec), monitor);
|
||||
PrototypeModel soft = currentProgram.getCompilerSpec().getCallingConvention(PROTOTYPE);
|
||||
if (soft == null)
|
||||
throw new IllegalStateException("REA soft-float prototype was not installed");
|
||||
requireIntegerStorage(
|
||||
soft.getStorageLocations(
|
||||
currentProgram,
|
||||
new DataType[] {
|
||||
FloatDataType.dataType, IntegerDataType.dataType, DoubleDataType.dataType,
|
||||
FloatDataType.dataType, DoubleDataType.dataType
|
||||
},
|
||||
false),
|
||||
"soft-float prototype self-check");
|
||||
int functions = 0;
|
||||
List<Function> callables = new ArrayList<>();
|
||||
currentProgram.getFunctionManager().getFunctions(true).forEach(callables::add);
|
||||
currentProgram.getFunctionManager().getExternalFunctions().forEach(callables::add);
|
||||
for (Function function : callables) {
|
||||
// A thunk's signature is its target's; the target is in this list.
|
||||
if (function.isThunk() || !usesFloat(function)) continue;
|
||||
if (function.hasCustomVariableStorage()) function.setCustomVariableStorage(false);
|
||||
function.setCallingConvention(PROTOTYPE);
|
||||
List<VariableStorage> storage = new ArrayList<>();
|
||||
storage.add(function.getReturn().getVariableStorage());
|
||||
for (Parameter parameter : function.getParameters()) storage.add(parameter.getVariableStorage());
|
||||
requireIntegerStorage(storage.toArray(VariableStorage[]::new), function.getName(true));
|
||||
functions += 1;
|
||||
}
|
||||
int definitions = 0;
|
||||
Iterator<DataType> types = currentProgram.getDataTypeManager().getAllDataTypes();
|
||||
while (types.hasNext()) {
|
||||
if (!(types.next() instanceof FunctionDefinition definition) || !usesFloat(definition))
|
||||
continue;
|
||||
definition.setCallingConvention(PROTOTYPE);
|
||||
definitions += 1;
|
||||
}
|
||||
// Written last: any failure above leaves no report, and REA refuses the session.
|
||||
currentProgram.getOptions(Program.PROGRAM_INFO).setString(
|
||||
REPORT_KEY,
|
||||
"{\"policy\":\"" + POLICY + "\",\"functions\":" + functions +
|
||||
",\"definitions\":" + definitions + "}");
|
||||
println("REA MIPS soft-float prototype: functions=" + functions + " definitions=" + definitions);
|
||||
}
|
||||
|
||||
/** The default o32 model without floating-point registers or float-consuming rules. */
|
||||
private static String softFloatPrototype(PrototypeModel base, CompilerSpec spec) throws Exception {
|
||||
XmlEncode encoder = new XmlEncode();
|
||||
base.encode(encoder, spec.getPcodeInjectLibrary());
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
Document document =
|
||||
factory.newDocumentBuilder().parse(new InputSource(new StringReader(encoder.toString())));
|
||||
Element root = document.getDocumentElement();
|
||||
if (!root.getTagName().equals("prototype"))
|
||||
throw new IllegalStateException("Unexpected default prototype encoding " + root.getTagName());
|
||||
root.setAttribute("name", PROTOTYPE);
|
||||
List<Node> removed = new ArrayList<>();
|
||||
collect(root.getElementsByTagName("pentry"), "metatype", "float", null, removed);
|
||||
collect(root.getElementsByTagName("consume"), "storage", "float", "rule", removed);
|
||||
if (removed.isEmpty())
|
||||
throw new IllegalStateException("Default prototype has no floating-point entries to remove");
|
||||
for (Node node : removed) node.getParentNode().removeChild(node);
|
||||
StringWriter xml = new StringWriter();
|
||||
var transformer = TransformerFactory.newInstance().newTransformer();
|
||||
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
|
||||
transformer.transform(new DOMSource(document), new StreamResult(xml));
|
||||
return xml.toString();
|
||||
}
|
||||
|
||||
/** Select matching elements, or their nearest `ancestor` element when one is named. */
|
||||
private static void collect(
|
||||
NodeList nodes, String attribute, String value, String ancestor, List<Node> selected) {
|
||||
for (int index = 0; index < nodes.getLength(); index += 1) {
|
||||
Element element = (Element) nodes.item(index);
|
||||
if (!value.equals(element.getAttribute(attribute))) continue;
|
||||
Node target = element;
|
||||
while (ancestor != null && target != null && !ancestor.equals(target.getNodeName()))
|
||||
target = target.getParentNode();
|
||||
if (target != null && !selected.contains(target)) selected.add(target);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireIntegerStorage(VariableStorage[] storage, String owner) {
|
||||
for (VariableStorage item : storage) {
|
||||
if (item.isBadStorage() || item.isUnassignedStorage())
|
||||
throw new IllegalStateException("REA soft-float storage unresolved for " + owner);
|
||||
List<Register> registers = item.getRegisters();
|
||||
if (registers == null) continue; // stack-only storage
|
||||
for (Register register : registers) {
|
||||
if (FPU_REGISTER.matcher(register.getName()).matches())
|
||||
throw new IllegalStateException(
|
||||
"REA soft-float storage still uses " + register.getName() + " for " + owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean usesFloat(Function function) {
|
||||
if (isFloat(function.getReturn().getFormalDataType())) return true;
|
||||
for (Parameter parameter : function.getParameters()) {
|
||||
if (isFloat(parameter.getFormalDataType())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean usesFloat(FunctionDefinition definition) {
|
||||
if (isFloat(definition.getReturnType())) return true;
|
||||
for (ParameterDefinition argument : definition.getArguments()) {
|
||||
if (isFloat(argument.getDataType())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isFloat(DataType type) {
|
||||
DataType current = type;
|
||||
while (current instanceof TypeDef typeDef) current = typeDef.getBaseDataType();
|
||||
return current instanceof AbstractFloatDataType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Program;
|
||||
|
||||
/** Seed the caller-supplied raw-binary entry before default auto-analysis. */
|
||||
public final class ReaGhidraPrepareRaw extends GhidraScript {
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
String[] arguments = getScriptArgs();
|
||||
if (arguments.length != 2) {
|
||||
throw new IllegalArgumentException(
|
||||
"Raw binary preparation requires the committed language and entry address"
|
||||
);
|
||||
}
|
||||
String expectedLanguage = arguments[0];
|
||||
String entryHex = arguments[1];
|
||||
if (!currentProgram.getLanguageID().getIdAsString().equals(expectedLanguage) ||
|
||||
!currentProgram.getExecutableFormat().equals("Raw Binary")) {
|
||||
throw new IllegalArgumentException(
|
||||
"Raw binary preparation requires the committed language and BinaryLoader import"
|
||||
);
|
||||
}
|
||||
Address entry = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress(entryHex);
|
||||
Address start = currentProgram.getMinAddress();
|
||||
if (entry == null || !entry.equals(start)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Raw binary import must map file offset 0 at the caller-supplied entry"
|
||||
);
|
||||
}
|
||||
addEntryPoint(entry);
|
||||
if (!disassemble(entry) || createFunction(entry, "entry") == null) {
|
||||
throw new IllegalStateException("Raw binary entry cannot be decoded as a function");
|
||||
}
|
||||
currentProgram.getOptions(Program.PROGRAM_INFO).setBoolean("REA raw binary prepared", true);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export default defineConfig({
|
||||
{ text: "IDA", link: "/ida-provider" },
|
||||
{ text: "Ghidra on Windows", link: "/windows-ghidra-p0" },
|
||||
{ text: "Ghidra DOS analysis", link: "/ghidra-dos" },
|
||||
{ text: "Ghidra raw binary", link: "/raw-binary" },
|
||||
{ text: "Ghidra NativeAOT", link: "/ghidra-nativeaot" },
|
||||
{ text: "Ghidra semantics", link: "/ghidra-provider" },
|
||||
],
|
||||
|
||||
@@ -108,7 +108,13 @@ existing analysis families directly, for example
|
||||
`ro.kernel.qemu` value, `null` when absent.
|
||||
- **Device paths are not host paths.** `base_apk_device_path` values live on
|
||||
the device filesystem. Only pulls and captures produce host-local paths,
|
||||
each paired with byte size and SHA-256 digest.
|
||||
each paired with byte size and SHA-256 digest. A pulled file's host name is
|
||||
the device path's last segment, accepted only when every host can store it:
|
||||
a `pull_adb_file` whose device path ends in a separator, `.`/`..`, a
|
||||
control or reserved character, a Windows reserved device name, a trailing
|
||||
dot or space, or a `:` alternate-data-stream marker is refused as
|
||||
`invalid_request` before any `adb pull` runs, and REA never renames a
|
||||
device-reported file.
|
||||
- **Splits are part of the answer.** `pm path` enumerates the base APK and
|
||||
every installed split; pulls cover the whole set. System packages whose
|
||||
APKs keep other names report the `unknown` role rather than guessing.
|
||||
|
||||
@@ -94,6 +94,12 @@ fallback for an overloaded method instead of attributing all bodies to one
|
||||
selected overload. Native/abstract methods with no decompiled body report
|
||||
`body_status: "not_available"` without inferring which implementation flag caused it.
|
||||
|
||||
Static initializers (`<clinit>`) return smali with the
|
||||
`static_initializer_disassembly` marker. Java decompilation can move their
|
||||
instructions into field declarations, leaving an empty or incomplete method
|
||||
body. The selected method's disassembly preserves those instructions and retains
|
||||
the same source byte limit and explicit truncation reporting.
|
||||
|
||||
## Interpret the result
|
||||
|
||||
- Package inspection retains decoded manifest XML, summary declarations,
|
||||
|
||||
+2
-1
@@ -59,7 +59,8 @@ alone, use `rea instructions`. A macOS `.app` bundle can be supplied directly.
|
||||
Run `rea --help` or a command's `--help` for arguments and output options.
|
||||
The [generated catalog](product-catalog.json) lists all CLI commands and MCP
|
||||
tools. Provider-specific target support is described in the
|
||||
[native guide](native-investigation.md), [DOS guide](ghidra-dos.md), and
|
||||
[native guide](native-investigation.md), [DOS guide](ghidra-dos.md),
|
||||
[raw binary guide](raw-binary.md), and
|
||||
[Windows Ghidra guide](windows-ghidra-p0.md).
|
||||
|
||||
## Choose a provider
|
||||
|
||||
+99
-15
@@ -13,13 +13,13 @@ retains class, byte order, `e_type`, raw `e_flags`, and inspected ABI declaratio
|
||||
Recognition does not mean every provider or MIPS variant is supported. Existing
|
||||
PE, managed and Mach-O architecture sets are unchanged.
|
||||
|
||||
The first Ghidra lane admits ELF32 `ET_EXEC`, little or big endian, with explicit
|
||||
o32 and `EF_MIPS_ARCH_32R2` flags and no machine-specific or ASE encoding flags.
|
||||
These are header declarations, not proof that every instruction conforms to
|
||||
that ISA. ELF64, n32/n64, unspecified ABI, MIPS16/microMIPS, machine-specific
|
||||
variants, shared/relocatable objects and PSP PRX are not covered by this lane.
|
||||
Their family may be identified, but provider selection returns the specific
|
||||
unsupported constraint rather than guessing a compatible profile.
|
||||
The Ghidra lane admits ELF32 `ET_EXEC` and `ET_REL`, little or big endian, with
|
||||
explicit o32 and `EF_MIPS_ARCH_32R2` flags and no machine-specific or ASE
|
||||
encoding flags. These are header declarations, not proof that every instruction
|
||||
conforms to that ISA. ELF64, n32/n64, unspecified ABI, MIPS16/microMIPS,
|
||||
machine-specific variants, shared objects and PSP PRX are not covered by this
|
||||
lane. Their family may be identified, but provider selection returns the
|
||||
specific unsupported constraint rather than guessing a compatible profile.
|
||||
|
||||
Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their
|
||||
absence is insufficient: Clang can emit identical `e_flags` for FP32, FP64 and
|
||||
@@ -39,12 +39,14 @@ absent or supply a default ABI.
|
||||
The field layout follows
|
||||
[`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h).
|
||||
|
||||
Provider admission requires record version 0, MIPS32r2, 32-bit GPR/CPR1, no CPR2,
|
||||
and a double-precision or FPXX ABI declaration with no ISA extension, ASE or
|
||||
unknown general flags. Missing records remain unknown rather than defaulting
|
||||
to a compatible ABI, and this initial lane refuses them. Unknown record values
|
||||
are retained for diagnosis but do not gain provider support. These checks bind
|
||||
the declared interpretation; the integer-only fixture does not establish full
|
||||
Provider admission requires record version 0, MIPS32r2, 32-bit GPRs, no CPR2,
|
||||
no ISA extension, ASE or unknown general flags, and one of two floating-point
|
||||
declarations: 32-bit CPR1 with a double-precision or FPXX ABI, or no CPR1 with
|
||||
the soft-float ABI. Other combinations, including single-float, FP64 and
|
||||
unspecified ABIs, are refused. Missing records remain unknown rather than
|
||||
defaulting to a compatible ABI, and this lane refuses them. Unknown record
|
||||
values are retained for diagnosis but do not gain provider support. These checks
|
||||
bind the declared interpretation; the fixtures do not establish full
|
||||
floating-point behavioral conformance.
|
||||
|
||||
MIPS header and ABI record facts are committed in the analysis profile so
|
||||
@@ -53,6 +55,51 @@ identity is versioned independently of file identity. Evidence, lifecycle
|
||||
outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters
|
||||
do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only.
|
||||
|
||||
### Soft-float prototype
|
||||
|
||||
Ghidra's MIPS32 compiler specifications model hard-float o32 only: a typed
|
||||
`float` or `double` parameter is bound to `$f12`/`$f14` and returned in `$f0`.
|
||||
Soft-float o32 passes those values in integer registers and stack slots, so a
|
||||
typed prototype (for example from DWARF) would make the decompiler read FPU
|
||||
registers the code never writes and drop the real dataflow. That applies to
|
||||
functions, external functions and indirect calls through typed function
|
||||
pointers alike.
|
||||
|
||||
For soft-float targets REA commits
|
||||
`mips_soft_float_storage: o32-soft-float-prototype-v1` in the analysis profile
|
||||
and runs `ReaGhidraMipsSoftFloat.java` after auto-analysis. The script encodes
|
||||
the program's own default o32 prototype, removes its floating-point parameter
|
||||
and return entries and the rules that consume floating-point storage, and
|
||||
installs the result as the program-specific `__rea_soft_float` prototype through
|
||||
a Ghidra specification extension. Deriving it keeps the byte-order-specific
|
||||
aggregate rules and the hidden struct-return pointer. The script then assigns
|
||||
that prototype, with dynamic storage, to every non-thunk function and external
|
||||
function, and to every function-pointer definition, whose declared types use
|
||||
`float` or `double`. Thunks follow their targets. Types are kept.
|
||||
|
||||
The script fails, and writes no report, if the derived prototype still places a
|
||||
float in an FPU register or any reassigned function does. It records the
|
||||
function and definition counts last; REA requires that report exactly when the
|
||||
profile commits the policy and refuses the session otherwise, before serving any
|
||||
operation. Session Evidence carries the policy and counts as limitations.
|
||||
Callables without typed float signatures keep Ghidra's default prototype, under
|
||||
which Ghidra already infers integer-register parameters. Hard-float profiles do
|
||||
not gain the policy, so their digests are unchanged.
|
||||
|
||||
### Relocatable objects
|
||||
|
||||
`ET_REL` objects, such as Linux kernel modules (`.ko`), are imported by Ghidra's
|
||||
ELF loader, which assigns each allocated section an analysis address and applies
|
||||
the object's REL relocations there. Undefined symbols are placed in Ghidra's
|
||||
`EXTERNAL` block: a thunk for code, a label for data. These addresses are
|
||||
analysis coordinates, not the addresses a kernel or linker would choose, and
|
||||
session Evidence says so. `inspect_native_load_image` reports each relocation
|
||||
with Ghidra's status; HI16 entries report `PARTIAL` because Ghidra applies them
|
||||
together with their paired LO16. Relocatable targets commit
|
||||
`mips_support_lane: elf32-rel-o32-arch32r2-standard-v1`, separate from the
|
||||
executable lane, whose identity executable targets keep. Kernel modules are
|
||||
usually soft-float and therefore also use the soft-float storage policy.
|
||||
|
||||
## Use
|
||||
|
||||
With a caller-installed Ghidra and compatible JDK on a supported Linux/macOS host:
|
||||
@@ -70,7 +117,10 @@ REA's ELF parser.
|
||||
|
||||
## Verification
|
||||
|
||||
The source-owned freestanding fixture is `tests/conformance/c/mips.c`. The
|
||||
### Executables
|
||||
|
||||
The source-owned freestanding fixture is `tests/conformance/c/mips.c`; the
|
||||
soft-float variant also links `tests/conformance/c/mips-soft-float.c`. The
|
||||
optional cross-target lane requires a caller-supplied Clang with MIPS targets,
|
||||
LLD, GNU `readelf`, Ghidra and its compatible JDK; ordinary host-native checks
|
||||
do not acquire these tools. `REA_MIPS_CLANG` selects Clang and
|
||||
@@ -81,7 +131,8 @@ These are test-time prerequisites, not REA runtime providers.
|
||||
GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra npm run verify:ghidra:mips
|
||||
```
|
||||
|
||||
The lane compiles both byte orders and never executes the targets. GNU readelf
|
||||
The lane compiles both byte orders with hard-float and soft-float ABIs and never
|
||||
executes the targets. GNU readelf
|
||||
independently supplies header/ABI facts and symbol addresses, compared with the
|
||||
production REA target resolver before provider startup. Its version, bounded
|
||||
command output and selected tool path are retained in the verification report.
|
||||
@@ -104,9 +155,42 @@ all program-header payloads. It checks the actual Ghidra language, entry-address
|
||||
decompilation, distinct artifact digest and missing-section limitations through
|
||||
CLI Evidence and an MCP-produced snapshot. Caller-selected Ghidra heap limits
|
||||
are passed to these child workflows.
|
||||
For soft-float fixtures the lane also checks, through `inspect_native_api`, the
|
||||
exact parameter and return types and storage of float, double, mixed (pair
|
||||
alignment and stack spill) and struct-returning functions; that a caller's
|
||||
decompilation receives every source float constant without FPU inputs,
|
||||
including an indirect call through a typed function pointer; and that the
|
||||
snapshot profile and Evidence retain the policy and outcome counts.
|
||||
Pseudocode presence is a liveness check, not proof of semantic equivalence.
|
||||
Instruction decoding and reader agreement likewise do not establish runtime,
|
||||
delay-slot execution or complete floating-point semantics. Assertion regressions
|
||||
reject incorrect byte order, constants, destinations and loaded identities.
|
||||
Parser/profile tests are narrower checks, not substitutes for this real-provider
|
||||
run. Report the actual lanes run and leave unexecuted verification pending.
|
||||
|
||||
### Relocatable objects
|
||||
|
||||
This lane has its own command and fixture:
|
||||
|
||||
```bash
|
||||
GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra npm run verify:ghidra:mips-relocatable
|
||||
```
|
||||
|
||||
It compiles `tests/conformance/c/mips-relocatable.c` with `-c -G0` in both byte
|
||||
orders, plus a little-endian soft-float `-mlong-calls` variant matching Linux
|
||||
module options. GNU readelf independently supplies sections, symbols and REL
|
||||
entries. For every relocation in an allocated section, the lane reads the
|
||||
loaded word through `read_bytes` and requires it to encode `S + A`: `S` from
|
||||
Ghidra's own section placement (`list_segments`) or `EXTERNAL` placement, `A`
|
||||
from the original file bytes. HI16/LO16 are checked as the effective address
|
||||
each LO16 forms with each HI16 it resolves, including several HI16s before one
|
||||
LO16, so a missing signed-low carry fails and a HI16 without a following LO16
|
||||
fails. The lane also requires matching Ghidra relocation entries and statuses,
|
||||
section file offsets and sizes, coverage of a shared HI16, a LO16 carry,
|
||||
external code and data and `R_MIPS_32` words, direct calls from the entry
|
||||
function, the relocatable support lane in the snapshot profile, the
|
||||
relocatable limitation in Evidence and an unchanged source object. The fixture
|
||||
also calls `powf`, an external whose float prototype comes from Ghidra's C
|
||||
library archive; every variant must decompile `powf(1.5,2.5)` without FPU
|
||||
inputs, and the soft-float variant must report that the soft-float prototype
|
||||
covered it.
|
||||
|
||||
@@ -98,7 +98,7 @@ For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <cl
|
||||
|
||||
## Coverage and evidence
|
||||
|
||||
Both profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance.
|
||||
Both profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance.
|
||||
|
||||
The legacy profile additionally supports direct callers. Its producer may report call-site or interior addresses; REA resolves the reported function symbol to its canonical entry. The modern profile does not prove direct callers from its code/data xref labels, so that capability is unavailable and the dossier marks it unknown.
|
||||
|
||||
|
||||
+39
-2
@@ -352,6 +352,12 @@ Other integrations, including Qwen Code, use `~/.agents/skills`.
|
||||
A mixed selection plans each distinct destination. Selecting the skill without a client uses the shared
|
||||
directory. Setup leaves existing skill copies in other locations untouched.
|
||||
|
||||
`rea setup --skill-client claude_code --yes --json` refreshes and verifies the
|
||||
selected skill destination independently of MCP registration selection. The
|
||||
final `doctor.scope.skill_clients` records which skill destinations were
|
||||
checked; `doctor.scope.clients` continues to describe required registrations.
|
||||
An unselected shared skill does not affect a client-only refresh.
|
||||
|
||||
`doctor --skill --json` verifies the selected copies against the bundled
|
||||
instructions and references. Consumers should use `identity.skill.state`,
|
||||
`installed_version`, and `installed_tool_count` for skill readiness. The
|
||||
@@ -561,8 +567,13 @@ need Xvfb, Python 3, X11 and XTEST; approved setup installs those dependencies
|
||||
on supported distributions. Add the curl installer's reported executable
|
||||
directory, commonly `~/.local/bin`, to your shell `PATH` if needed.
|
||||
|
||||
REA starts Hopper when needed. On macOS, its launcher can bring a window or
|
||||
first-run dialog forward; choose demo mode or activate your existing license.
|
||||
REA starts Hopper when needed. On macOS, complete its demo or license prompt in
|
||||
the same user session before the first CLI/MCP operation or unattended job. Open
|
||||
Hopper manually if needed; its launcher can also bring the dialog forward.
|
||||
If startup reports `Cannot launch Hopper! Connection is invalid.` or waits for
|
||||
bridge readiness, check Hopper for a pending demo, license, or loader dialog.
|
||||
Complete the dialog and retry. If REA reports `cleanup_incomplete`, follow the
|
||||
retained-resource guidance below before reopening the target.
|
||||
Hopper serializes analysis requests. Cancelling a wait can leave provider work
|
||||
running, which the session reports. Successful decompilation is cached until
|
||||
a relevant rename or comment changes it.
|
||||
@@ -591,6 +602,11 @@ if document closure is unconfirmed, REA retains the backing image and reports
|
||||
MCP-exit behavior.
|
||||
Startup deadlines report missing bridge readiness and preserve the launcher
|
||||
outcome; a successful helper exit does not prove that a loader dialog completed.
|
||||
If an owned provider process exits, request failures preserve its observed exit
|
||||
code or signal and bounded output in `details.launcher`. An exit can precede
|
||||
output-stream closure; `output_closed: false` keeps that uncertainty explicit.
|
||||
Later requests retain the known exit instead of reducing it to socket loss.
|
||||
An external GUI's helper exit does not establish that the GUI itself exited.
|
||||
Cursor navigation returns the observed object start when Hopper snaps an interior
|
||||
address; adjacent-object navigation rejects unmapped inputs and document ends.
|
||||
Native API text rejects NUL characters and unpaired Unicode surrogates before
|
||||
@@ -784,6 +800,27 @@ Each line is `address<TAB>kind[<TAB>name]`, where kind is `function`, `code` or
|
||||
|
||||
Label seeds can name mapped uninitialized storage such as `.bss`; function and code seeds require initialized bytes.
|
||||
|
||||
To skip a named Ghidra auto-analyzer (for example, when default auto-analysis
|
||||
stalls in one analyzer on a Swift/Objective-C arm64 Mach-O):
|
||||
|
||||
```bash
|
||||
export REA_GHIDRA_DISABLED_ANALYZERS="Objective-C Message Analyzer"
|
||||
```
|
||||
|
||||
The value is a comma-separated list of exact Ghidra analyzer names. A pre-script
|
||||
disables each name before auto-analysis, the analysis profile records the
|
||||
override in `analyzer_preset` (for example
|
||||
`ghidra-default+disabled:Objective-C Message Analyzer`), and a session fails to
|
||||
open when its committed profile and the setting differ. A name starts with a
|
||||
letter or digit and contains only letters, digits, spaces, periods, underscores,
|
||||
and hyphens; at most 32 names are admitted, each at most 120 characters.
|
||||
Names must identify analyzers registered for the selected program. Unknown names
|
||||
or options that remain enabled abort the headless import before auto-analysis
|
||||
and the bridge, so an unsuccessful override cannot produce a session claiming
|
||||
that the analyzer was disabled. For Windows PE resource analysis, the exact name
|
||||
is `WindowsResourceReference`. Disabling an analyzer skips the facts it would
|
||||
have produced.
|
||||
|
||||
### Ghidra heap and CPU controls
|
||||
|
||||
Set resource controls in the environment that launches the CLI or MCP server,
|
||||
|
||||
@@ -66,6 +66,45 @@ code-point order. Dependency manifests remain package observations but do not
|
||||
declare Electron main or renderer entries. If only dependency manifests are
|
||||
present, the inventory artifact remains the graph root.
|
||||
|
||||
## Select sources before analysis
|
||||
|
||||
For a question about known entry sources, supply exact artifact-relative file
|
||||
paths in `source_paths`, or repeat `--source-path` on either CLI route:
|
||||
|
||||
```bash
|
||||
rea analyze /absolute/path/to/app.asar \
|
||||
--source-path package.json --source-path main.js --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"input_path": "/absolute/path/to/app.asar",
|
||||
"source_paths": ["package.json", "main.js"],
|
||||
"detail": "summary"
|
||||
}
|
||||
```
|
||||
|
||||
REA inventories and verifies the complete artifact, including unselected bytes,
|
||||
but allocates source text and performs static analysis only for selected files.
|
||||
Include supporting manifests, HTML or source maps explicitly when needed;
|
||||
imports do not expand the selection. Paths must match relevant inventoried
|
||||
files exactly, including nested ASAR prefixes. Unknown paths, aliases and globs
|
||||
return an `invalid_request` correction. Selected files are analyzed in request
|
||||
order before unselected scopes are projected. Repeated paths preserve the
|
||||
caller's request and first-occurrence priority, analyzing the file once.
|
||||
Omit the selection for the existing complete
|
||||
application workflow.
|
||||
|
||||
The result and summary retain `source_selection.requested_paths` and
|
||||
`source_selection.unselected_files`. Every relevant file keeps its original
|
||||
inventory identity; unselected files report `text_status:
|
||||
"outside-analysis-scope"`. Their relationships remain unknown, and application
|
||||
coverage is partial while any file remains outside scope. Selection does not
|
||||
claim whole-application completeness or change integrity policy. Summary detail
|
||||
only reduces delivery; source selection reduces text acquisition and analysis.
|
||||
Explicit native provider or snapshot routing rejects `--source-path` rather
|
||||
than ignoring it.
|
||||
|
||||
## ASAR integrity
|
||||
|
||||
ASAR inventory checks Electron integrity metadata for embedded archive entries
|
||||
@@ -159,9 +198,11 @@ completed partial Evidence. MCP failure details return
|
||||
after recording succeeds, so `inspect_analysis_view` can open that analysis
|
||||
directly without serializing the entire partial graph into the error response.
|
||||
CLI failure output retains the complete partial Evidence.
|
||||
Cancellation after source analysis begins likewise
|
||||
retains completed facts; an SDK client may reject its cancelled request before
|
||||
receiving that reply. On the same MCP connection, inspect the Evidence bundle
|
||||
Cancellation after source analysis begins likewise retains completed facts,
|
||||
including cancellation during analyzer cleanup, graph construction, or Evidence
|
||||
sealing. REA finishes projecting those owned facts cooperatively to return partial
|
||||
Evidence. An SDK client may reject its cancelled request before receiving that
|
||||
reply. On the same MCP connection, inspect the Evidence bundle
|
||||
and use the retained analysis ID with `inspect_analysis_view`. Owned analyzer
|
||||
cleanup is verified before its private files are released. Cleanup uncertainty
|
||||
is reported separately and retains ownership for retry.
|
||||
@@ -231,6 +272,11 @@ unknown instead of silently treating them as absent or verified.
|
||||
Plain `.ts` artifact sources use TypeScript syntax without JSX, including
|
||||
angle-bracket type assertions. `.tsx` and `.jsx` retain JSX parsing.
|
||||
|
||||
Relative TypeScript imports can name their emitted `.js`, `.mjs`, or `.cjs`
|
||||
counterparts while resolving to inventoried source files. This substitution
|
||||
requires import or require semantics. Literal Electron preload, renderer, and
|
||||
utility-process runtime paths retain the runtime filename selected by the caller.
|
||||
|
||||
Selected bounded text is then parsed as inert data to recover:
|
||||
|
||||
- `package.json` metadata and declared main or renderer entrypoints;
|
||||
@@ -323,15 +369,44 @@ substitute version-dependent Electron defaults for omitted `webPreferences`.
|
||||
Dynamic option objects, bridge keys, API objects, and IPC channel expressions
|
||||
remain unknown and make coverage partial.
|
||||
|
||||
Electron API identity follows proven lexical module aliases. Bundled CommonJS
|
||||
namespace imports are also followed through the interop helpers that bundlers
|
||||
and compilers emit (`__toESM`, `__importStar`, `_interopRequireWildcard`),
|
||||
including the `electron = __toESM(electron)` rewrap that Rolldown emits. This
|
||||
step is inferred: a helper is recognized by its emitted name and a single
|
||||
program-scope function definition, and its body is not evaluated, so a local
|
||||
function that reuses one of these names is trusted to pass the module through.
|
||||
Other reassigned bindings, dynamic selections, and visibly overwritten namespace
|
||||
members are unresolved.
|
||||
Electron API identity follows proven lexical module aliases. Namespace interop
|
||||
also preserves identity when the complete Rolldown helper operations, lexical
|
||||
roles, live getters, and captured intrinsic origins match, or when a stable
|
||||
helper simply returns its module parameter. Harmless single-statement control
|
||||
blocks preserve recognition; added operations still require proof. Literal
|
||||
member and destructuring projections are retained from proven results.
|
||||
Omitted or literal node-mode arguments preserve named exports; a module-valued
|
||||
default projection requires a truthy literal node mode. Computed or extra
|
||||
arguments remain unresolved.
|
||||
Recognition uses structure rather than emitted names; other helper bodies and ambiguous
|
||||
runtime fallbacks remain unresolved. Captured function identities survive later global-property
|
||||
replacement, including copies through initialized aliases. Reads before an
|
||||
alias initializer remain unresolved. Visible intrinsic writes before capture or
|
||||
conversion are checked in their owning callable scope, including identifier leaves in nested/default/rest
|
||||
destructuring and loop targets. Property keys and shadowed local bindings do not
|
||||
overwrite the global intrinsic identity. Cross-call side effects, including
|
||||
writes inside directly invoked local functions, are not evaluated. Other
|
||||
reassignments, dynamic selections, and visibly overwritten namespace members
|
||||
are unresolved.
|
||||
Known Object/Reflect mutators remain recognizable through literal destructuring
|
||||
of intrinsic objects and stable bound-function aliases. Calls retain bound
|
||||
argument prefixes and the original bound receiver through `call`, `apply`, and
|
||||
`Reflect.apply`, including generic `Function.prototype` invocation and binding
|
||||
creation. Arguments follow each actual callable invocation rather than property
|
||||
chain depth; rebinding retains the original bound receiver.
|
||||
Visibly replaced invocation intrinsics produce unknown effects instead
|
||||
of using their former call semantics; captured function aliases retain their
|
||||
earlier identity. Unexamined targets remain unresolved. `Reflect.set` examines
|
||||
its explicit receiver as well as its target, retaining unknown receiver effects.
|
||||
Creating an unused binding does not itself mutate an intrinsic. Variable
|
||||
redeclarations retain a single unconditional initializer
|
||||
when they introduce no assignment or second initializer.
|
||||
Stable identifier chains to fresh object, array, function,
|
||||
undecorated class or RegExp syntax keep those targets separate from intrinsic
|
||||
objects when initialized before use; opaque, decorated, reassigned or
|
||||
not-yet-initialized targets remain unresolved. Writes during class initialization
|
||||
are still retained. Function declarations are available before their textual
|
||||
position; variable and class initializers must precede use.
|
||||
Direct and destructuring assignment, loop, update, and delete targets are checked
|
||||
through namespace aliases; unrelated member writes do not discard observed API
|
||||
identity. This static check does
|
||||
@@ -425,16 +500,53 @@ such as `./web3:app.js` still names a local artifact.
|
||||
Package `exports` fallback arrays are supported both at the top level and under
|
||||
the root `"."` entry. The resolver selects targets in declared order using the
|
||||
same conditional and invalid-entry handling as nested exports arrays.
|
||||
Nonempty arrays containing only inactive conditions remain unmatched, allowing
|
||||
the enclosing condition object to continue to a later active condition.
|
||||
Empty arrays and explicit null targets keep their blocking outcome.
|
||||
Refusal evidence distinguishes those source values and retains condition names
|
||||
from unmatched nested targets rather than reporting array indices.
|
||||
For imports and requires, the active Node conditions include `module-sync`;
|
||||
earlier active conditions retain precedence over later ones.
|
||||
|
||||
ESM relative module paths and selected package exports targets use URL suffix handling
|
||||
and one percent-decoding pass. CommonJS relative paths and legacy package main
|
||||
Package self-references use the importer's enclosing package scope before
|
||||
installed dependencies. ESM requires the parsed package name to equal the
|
||||
metadata name; a scope-only name such as `@scope` cannot capture `@scope/pkg`.
|
||||
CommonJS retains Node's raw-request name/prefix matching. Both paths preserve
|
||||
the original specifier and select exports under their own loader conditions.
|
||||
|
||||
Exact package-private `#alias` specifiers resolve against the importer's
|
||||
enclosing `package.json` `imports` map — the nearest same-container package
|
||||
scope, stopping at a `node_modules` directory so a nested package cannot inherit
|
||||
an ancestor's aliases. Selected targets are package-local `./` files with no
|
||||
extension or index fallback, using the same condition, array, and null selection
|
||||
as exports subpaths. A missing key, explicit null, or out-of-scope alias keeps
|
||||
an unresolved edge with that reason. Wildcard keys (`#internal/*`), recursive
|
||||
`#` targets, and external-package import targets remain unresolved.
|
||||
|
||||
Exact keys such as `#/x` are literal lookup identities. `#` and names ending
|
||||
in `/` are invalid. Imports arrays skip invalid parent-relative, absolute, and
|
||||
URL targets before selecting a later package-local file; an unsupported bare
|
||||
package or recursive alias keeps its unresolved outcome rather than selecting
|
||||
a later fallback.
|
||||
|
||||
ESM relative module paths, legacy package subpaths without exports, and selected
|
||||
package exports targets use URL suffix handling and one percent-decoding pass.
|
||||
Bare specifiers retain their complete query/fragment text for exports key matching.
|
||||
For dependency lookup, ESM constructs a package metadata URL instead of treating
|
||||
the parsed package name as a literal directory. A root request such as `legacy?x`
|
||||
does not load either `node_modules/legacy?x` or `node_modules/legacy`; an exact
|
||||
self-reference with that metadata name can still select its exports first.
|
||||
Without an exports map, ESM subpaths select exact files; directory imports and
|
||||
extension fallback are refused. CommonJS relative paths and legacy package main
|
||||
fields retain literal filename punctuation. Exports targets must start with
|
||||
`./` and exclude `.`, `..`, and `node_modules` path segments, including their
|
||||
encoded forms, before URL normalization. Arrays skip invalid exports targets
|
||||
in declared order; URL decoding and file lookup failures for a selected target
|
||||
do not select a later entry. Rejected references retain the package metadata
|
||||
in declared order, including URLs that escape the package root after normalization.
|
||||
Selected package targets use the normalized URL path for lookup; tabs and
|
||||
newlines in the URL text are removed by the URL parser. CommonJS package targets
|
||||
reject encoded separators anywhere in the selected URL; ESM checks its pathname.
|
||||
URL decoding and file lookup failures for a selected target do not select a
|
||||
later entry. Rejected references retain the package metadata
|
||||
path, original target, failed constraint, and importing source location.
|
||||
|
||||
HTML script references resolve to exact inventoried files after applying the
|
||||
|
||||
@@ -126,6 +126,24 @@ Member inspection binds build-local tokens, signatures, row offsets, method
|
||||
bodies, and decoded relationships to that artifact and MVID. Missing or malformed
|
||||
facts remain explicit in coverage and per-member status.
|
||||
|
||||
Metadata table streams may carry the CLR-compatible `ExtraData` extension
|
||||
(`HeapSizes` flag `0x40`), which Microsoft readers honor although it is not
|
||||
specified by ECMA-335. REA skips its fixed four-byte word after the row counts
|
||||
before decoding table rows; the word's value is not a length. An incomplete
|
||||
word is reported as a metadata coverage failure at its file offset, without
|
||||
reading beyond the declared table stream.
|
||||
|
||||
For unoptimized `#-` metadata, nonempty `FieldPtr` and `MethodPtr` tables
|
||||
translate TypeDef list positions to physical member definitions. REA resolves
|
||||
these indirections for member ownership and local attribute-constructor types;
|
||||
field and method tokens still identify the original definition rows.
|
||||
`field_list` and `method_list` retain the metadata's list coordinates, which
|
||||
refer to the pointer table when present rather than necessarily contiguous
|
||||
definition rows. Malformed or ambiguous pointers leave the owner unknown with
|
||||
partial coverage while preserving readable member and attribute facts.
|
||||
The retained inverse indexes are admitted against the existing representation
|
||||
budget before allocation.
|
||||
|
||||
`exported_types` reports ExportedType rows as declared. Facade assemblies such
|
||||
as .NET `System.Runtime.dll` provide their types this way: `forwarder: true`
|
||||
marks a type forwarder, and `implementation_token` names the AssemblyRef, File,
|
||||
|
||||
+49
-11
@@ -150,13 +150,15 @@ structured data and matching text projection. Oversized errors are retained
|
||||
as Evidence before delivery of a bounded diagnostic with an export reference;
|
||||
if retention is unavailable or fails, the diagnostic reports that reason.
|
||||
|
||||
REA accepts ordinary `tools/call` progress tokens. Updates are monotonic,
|
||||
rate-bounded to at most one intermediate update per 100 ms, and always allow a
|
||||
terminal update. Unknown totals are omitted; REA does not fabricate percentages.
|
||||
REA accepts ordinary `tools/call` progress tokens. The numeric `progress` counts
|
||||
emitted status observations and strictly increases, including when a waiting
|
||||
phase has completed no additional work. Updates are rate-bounded to at most one
|
||||
intermediate update per 100 ms and always allow a terminal update. `total` is
|
||||
omitted because the number of future status observations is unknown. Read
|
||||
`message` for the phase and observed work; REA does not fabricate percentages.
|
||||
`capture_process_scenario` uses that boundary only when the client supplies a
|
||||
progress token. Notifications set `progress` to the count of collected terminal
|
||||
frames, process samples, and interaction events, omit `total`, and put the
|
||||
lifecycle phase, elapsed time, and final cleanup status in `message`. A client
|
||||
progress token. Notifications put the lifecycle phase, elapsed time, and final
|
||||
cleanup status in `message`. A client
|
||||
that does not send a progress token receives no progress notifications. The
|
||||
tool result is unchanged either way.
|
||||
Provider calls receive the request cancellation signal. Artifact traversal,
|
||||
@@ -230,6 +232,18 @@ engine readiness or a percentage of Ghidra auto-analysis. A longer server
|
||||
startup allowance does not extend the client's deadline; progress also does not
|
||||
extend it unless the client explicitly implements that policy.
|
||||
|
||||
While Ghidra startup is pending, REA sends a waiting notification every ten
|
||||
seconds when the request has a progress token. Its message gives elapsed waiting
|
||||
time; import, auto-analysis and bridge readiness may all still be pending.
|
||||
Waiting notifications stop when startup settles or the caller cancels. They do
|
||||
not measure engine work completed.
|
||||
|
||||
The pinned SDK can reset the request's inactivity timeout on progress with
|
||||
`resetTimeoutOnProgress: true`. Also set `maxTotalTimeout` to bound the complete
|
||||
request; the provider's startup deadline still applies. These options belong in
|
||||
the same second argument of `callTool`. Without that explicit client policy, the
|
||||
default 60-second request deadline still expires despite waiting notifications.
|
||||
|
||||
The 240-second setting is a measured example, not a universal timeout: a
|
||||
controlled Linux x64/WSL2 fixture took about 64 seconds for its first overview
|
||||
with Ghidra 12.1.4 on a mounted NTFS installation, native JDK 21, two CPUs, and a
|
||||
@@ -265,9 +279,10 @@ observed process-family member remained after final close. This was a small
|
||||
1,024-byte fixture, so the longer request setting remains an example, not a
|
||||
guarantee for larger targets. No macOS cold-start recovery was exercised.
|
||||
|
||||
With a supplied progress token, the Windows run received an operation-start
|
||||
With a supplied progress token, that earlier Windows run received an operation-start
|
||||
notification before timeout and start/completion notifications for the
|
||||
successful retry. It received no intermediate import or auto-analysis progress.
|
||||
successful retry, before startup waiting notifications were added. It received
|
||||
no intermediate import or auto-analysis progress.
|
||||
These operation markers do not measure analysis work completed or extend the
|
||||
client's deadline. Each new provider session still imports the target into a
|
||||
fresh temporary project.
|
||||
@@ -295,6 +310,13 @@ directly to `compare_functions`, and `inspect_artifact` Evidence to
|
||||
`compare_artifacts`. Use `get_evidence_bundle` when the task needs broader
|
||||
retained session history or an explicit bundle for transfer.
|
||||
|
||||
`get_call_graph` keeps provider-limited relationship reads on their node as
|
||||
`limitations`, alongside any observed `calls`. An empty call list with such a
|
||||
limitation is incomplete coverage, not proof that the function has no calls.
|
||||
`trace_call_path` reports each limited expansion in `residual_unknowns` and
|
||||
sets `truncated: true`; any path already supported by observed edges remains
|
||||
visible. Tool errors remain in their existing error or failure fields.
|
||||
|
||||
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
|
||||
@@ -306,8 +328,18 @@ session. Its exact reference is reported in
|
||||
`details.reported_limits.evidence_reference`; call `inspect_analysis_view` with
|
||||
that reference for a summary, one section/module, or a stable page, or call
|
||||
`export_evidence_bundle` with a destination path. Complete
|
||||
bundle exports stream canonical JSON into an atomically published file. A broad
|
||||
follow-up or `get_evidence_bundle` can also exceed the response budget; exporting
|
||||
bundle exports stream canonical JSON into an atomically published file.
|
||||
|
||||
For oversized native function Evidence, `details.reported_limits.continuation`
|
||||
contains a ready-to-call `inspect_analysis_view` request for the retained
|
||||
pseudocode's first page. Use its `name` and `arguments` directly, then follow
|
||||
`normalized_result.coverage.next_offset` for subsequent pages. The suggested
|
||||
limit is the view contract's default, not a guarantee that arbitrary source or
|
||||
metadata fits; reduce the limit or export when needed. Continuations are
|
||||
offered for completed `analyze_function` results only after session recording is
|
||||
acknowledged; the complete result and its retention reference remain intact.
|
||||
|
||||
A broad follow-up or `get_evidence_bundle` can also exceed the response budget; exporting
|
||||
preserves the complete session without sending it through a single MCP frame.
|
||||
To discover existing records without their large payloads, call
|
||||
`get_evidence_bundle` with `detail: "summary"`. Its `result.records` contain
|
||||
@@ -435,7 +467,13 @@ Tool annotations describe effects and are hints, not authorization controls
|
||||
([MCP ToolAnnotations](https://modelcontextprotocol.io/specification/2025-06-18/schema#toolannotations)).
|
||||
`readOnlyHint` includes session state: an analysis call that records additive
|
||||
Evidence is marked non-read-only even when it leaves the target unchanged.
|
||||
`destructiveHint` describes possible data loss, not ordinary Evidence recording.
|
||||
`destructiveHint` is true when a tool may discard data (overwriting or clearing
|
||||
analyst metadata, replacing files, dropping session state), runs or drives code
|
||||
REA does not own (`executesForeignCode`: process, LLDB, Electron, browser, and
|
||||
native UI scenarios), or changes an attached Android device (`mutatesDevice`:
|
||||
push, install, uninstall, start, and stop). Ordinary Evidence recording is not
|
||||
destructive. The effect record in `binary_session` and the generated catalog
|
||||
carries each flag separately.
|
||||
Effect metadata covers possible behavior across supported inputs: DMG inventory
|
||||
can launch `hdiutil` and create an owned temporary mount directory; extraction
|
||||
creates a fresh output directory on every call. Comparing supplied web captures
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Headerless raw-binary analysis with Ghidra
|
||||
|
||||
REA admits a headerless executable when the caller supplies the CPU family and
|
||||
the address where file offset 0 should be mapped. That interpretation is not
|
||||
inferred from a `.bin` suffix or from unrecognized bytes.
|
||||
|
||||
## Open and inspect
|
||||
|
||||
```bash
|
||||
rea inspect /absolute/path/to/ecu.bin --target-format raw-binary --architecture arm --entry-point 0x8000000 --provider ghidra --json
|
||||
rea function /absolute/path/to/ecu.bin 0x8000000 --target-format raw-binary --architecture arm --entry-point 0x8000000 --provider ghidra --json
|
||||
rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-binary --architecture arm --entry-point 0x8000000 --provider ghidra --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "open_binary",
|
||||
"arguments": {
|
||||
"path": "/absolute/path/to/ecu.bin",
|
||||
"format": "raw-binary",
|
||||
"architecture": "arm",
|
||||
"entry_point": "0x8000000",
|
||||
"provider_id": "ghidra"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`architecture` is one of `x86`, `x86_64`, `arm`, or `arm64`. `entry_point` is a
|
||||
hexadecimal address of the form `0x…`. The complete file is mapped starting at
|
||||
that address, and Ghidra seeds an `entry` function there before auto-analysis.
|
||||
ARM and AArch64 entries must be 4-byte aligned; x86 entries may be byte-aligned.
|
||||
`open_binary` and an open `binary_session` return the canonical `entry_point`
|
||||
for raw targets so callers can verify the selected mapping directly.
|
||||
Omitted `format` keeps header-based detection; architecture and entry without
|
||||
`format=raw-binary` are rejected.
|
||||
|
||||
Ghidra imports a private snapshot with `BinaryLoader` and these little-endian
|
||||
generic languages and compiler specifications:
|
||||
|
||||
| architecture | language_id | compiler_spec_id |
|
||||
| ------------ | ------------------- | ---------------- |
|
||||
| x86 | `x86:LE:32:default` | `gcc` |
|
||||
| x86_64 | `x86:LE:64:default` | `gcc` |
|
||||
| arm | `ARM:LE:32:v7` | `default` |
|
||||
| arm64 | `AARCH64:LE:64:v8A` | `default` |
|
||||
|
||||
Compiler specifications select analysis conventions; they do not identify the
|
||||
original compiler or ABI of a headerless target. Ghidra's x86 languages do not
|
||||
provide a compiler specification named `default`.
|
||||
|
||||
Those settings, the caller-supplied entry, and `language_selection:
|
||||
caller-supplied-v1` are committed in the analysis profile. A different entry
|
||||
or architecture is a different profile; snapshots from header-detected targets
|
||||
do not replay.
|
||||
|
||||
The mapped start is independent of Ghidra's program image base, which remains
|
||||
`0x0` for BinaryLoader. Ghidra may report additional language-defined entry
|
||||
points, including unmapped ARM vectors. Load-image verification requires the
|
||||
caller-supplied entry and preserves those other entries as observations.
|
||||
Independent raw verification streams the complete private snapshot digest and
|
||||
extent with bounded memory and supports cancellation during the read.
|
||||
|
||||
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 continues to admit
|
||||
PE applications only.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Headers are not discovered from file bytes. This increment does not guess
|
||||
load addresses, vector tables, or memory maps.
|
||||
- Big-endian, Thumb-only, Cortex-M, and other Ghidra processor variants are
|
||||
unsupported.
|
||||
- Reset vectors, memory-mapped I/O, and firmware boot sequences are not
|
||||
emulated.
|
||||
- Independent load-image verification covers source identity, mapped file
|
||||
bytes, the bound language, and the caller-supplied entry. Original bytes
|
||||
stay unchanged.
|
||||
+26
-10
@@ -139,8 +139,11 @@ directory, so default OS-home discovery remains unverified. Live model APIs and
|
||||
native Windows are also unverified.
|
||||
|
||||
`verify:claude-client` requires installed Claude Code (verified with
|
||||
`@anthropic-ai/claude-code@2.1.296`); select it with `REA_VERIFY_CLAUDE_COMMAND`.
|
||||
The optional POSIX lane uses native `CLAUDE_CONFIG_DIR` discovery, an independent
|
||||
`@anthropic-ai/claude-code@2.1.296` on POSIX and native Windows); select it
|
||||
with `REA_VERIFY_CLAUDE_COMMAND`. On Windows, select
|
||||
`claude.exe`: npm installs put only a `claude.cmd` shim on `PATH`, and the real
|
||||
executable is under `node_modules/@anthropic-ai/claude-code/bin`.
|
||||
The optional lane uses native `CLAUDE_CONFIG_DIR` discovery, an independent
|
||||
Git workspace, user settings and default built-in tools. It preserves caller
|
||||
preferences and backups, checks idempotent setup, waits through the native
|
||||
`WaitForMcpServers` tool when discovery is pending, and validates the complete
|
||||
@@ -149,8 +152,8 @@ native `Skill`, checks its full body and verifies named-schema JavaScript Eviden
|
||||
for a Unicode path. Use `-- chat` for ordinary chat with REA enabled, or
|
||||
`REA_VERIFY_RUNTIME_ROOT` for a production-only installed package. The local
|
||||
Anthropic Messages/SSE fixture preserves native resource-hint envelopes in its
|
||||
request artifacts. Usage is synthetic; live Anthropic API, native Windows and
|
||||
bare-mode personal-skill activation remain unverified by this lane.
|
||||
request artifacts. Usage is synthetic; live Anthropic API and bare-mode
|
||||
personal-skill activation remain unverified by this lane.
|
||||
|
||||
`verify:copilot-client` requires installed GitHub Copilot CLI (verified with
|
||||
`@github/copilot@1.0.95`); select it with `REA_VERIFY_COPILOT_COMMAND`.
|
||||
@@ -279,10 +282,12 @@ establish live DeepSeek API or native Windows coverage. Receipts include
|
||||
request artifacts, result digests and owned-process lineage.
|
||||
|
||||
Full E2E tests invoke the production command dispatcher and real providers,
|
||||
without fake launchers, runners or responses. `verify:keyed-archive` writes an
|
||||
actual Foundation binary archive, runs the CLI and a separate stdio MCP
|
||||
subprocess, checks parity and pagination, rejects an escaping path and checks
|
||||
an XML graph golden. `verify:asset-catalog` compiles source-owned colors with
|
||||
without fake launchers, runners or responses. `verify:keyed-archive` writes
|
||||
Foundation binary, XML, and integer archives using one run-owned Swift module
|
||||
cache. It checks CLI and separate stdio MCP parity, pagination, escaping-path
|
||||
rejection, an XML graph golden, and numeric meaning for safe controls, unsafe
|
||||
integers, boxed numbers, and integral-valued reals. `verify:asset-catalog`
|
||||
compiles source-owned colors with
|
||||
`actool`, invokes real `assetutil`, then checks CLI/MCP results, exact catalog
|
||||
digest, every raw metadata field, pagination and malformed input rejection.
|
||||
Neither artifact workflow requires Hopper or launches it. Both run in macOS CI.
|
||||
@@ -458,7 +463,7 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
|
||||
| Ghidra lane | Supported runner/target | Additional local tools |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:seeds` | POSIX host; source-owned 32-bit x86 raw fixture; function order and BSS labels | Ghidra 12.1.4 and full JDK 21 |
|
||||
| `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback and unknown-name rejection | Ghidra 12.1.4 and full JDK 21 |
|
||||
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
|
||||
@@ -793,6 +798,16 @@ truncation constraint, while leading-zero coordinates still resolve correctly.
|
||||
It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS
|
||||
runtime or PC-98 device execution.
|
||||
|
||||
`npm run verify:ghidra:raw` uses a generated ARM little-endian headerless image
|
||||
through the compiled CLI and stdio MCP server. With the same Ghidra/JDK
|
||||
prerequisites, it checks preparation and bridge startup, verified load mappings,
|
||||
instruction and byte readback, canonical-entry session reuse, replacement when
|
||||
the entry changes without `provider_id`, unchanged source bytes, and owned
|
||||
cleanup. Six-byte x86 and x86_64 controls also verify supported compiler
|
||||
specifications, independent mappings, unchanged bytes, and decompilation of a
|
||||
known return value. AArch64 remains covered by deterministic tests rather than
|
||||
this real-provider lane. See [raw binary analysis](raw-binary.md).
|
||||
|
||||
## Apple Interface Builder archives
|
||||
|
||||
`npm run verify:interface-builder` compiles the source-owned AppKit XIB into a
|
||||
@@ -1371,7 +1386,8 @@ generation. It installs nothing. Without configuration, the default invocation
|
||||
reports a named skip; `--require-tools` makes missing configuration fail.
|
||||
|
||||
The verifier creates and validates real modules with custom sections, multiple
|
||||
import/export kinds, escaped names and empty modules. It compares exact WAT,
|
||||
import/export kinds, escaped names, an empty module, and a bulk-memory
|
||||
DataCount section. It compares exact WAT,
|
||||
section output and selected/tool digests through the compiled CLI and real MCP
|
||||
SDK, validates advertised schemas, checks identical CLI/MCP Evidence IDs and
|
||||
session bundle readback, retains invalid/truncated module diagnostics, preserves
|
||||
|
||||
+104
-88
@@ -1,4 +1,12 @@
|
||||
#!/bin/bash
|
||||
{
|
||||
# Truncation guard: this brace group opens on line 2 and closes on the last
|
||||
# line, so bash must read the entire file before it runs any command. On the
|
||||
# `curl ... | bash` path, a download cut off anywhere before the final `}` is
|
||||
# an unterminated compound command, which bash reports as a syntax error at
|
||||
# EOF without running anything. Without the guard, a cut right after `set`
|
||||
# would run the bare builtin and print every shell variable, and a cut right
|
||||
# after `main` would run the installer without the caller's flags.
|
||||
set -euo pipefail
|
||||
|
||||
PACKAGE="rea-agents"
|
||||
@@ -21,104 +29,112 @@ usage() {
|
||||
cat <<'EOF'
|
||||
Usage: install.sh [--version VERSION] [--dry-run] [--no-setup] [--no-prompt] [--verbose]
|
||||
|
||||
Installs only the REA CLI. Run `rea setup` separately to configure Hopper and agents.
|
||||
Installs the REA CLI, then starts `rea setup` interactively to configure Hopper
|
||||
and agents when a terminal is available. Pass --no-setup (or --no-prompt) to
|
||||
skip setup and run `rea setup` later.
|
||||
EOF
|
||||
}
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--version)
|
||||
(($# >= 2)) || fail "--version requires a value."
|
||||
version="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
--no-setup|--no-prompt)
|
||||
start_setup=false
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
verbose=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
main() {
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--version)
|
||||
(($# >= 2)) || fail "--version requires a value."
|
||||
version="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
--no-setup|--no-prompt)
|
||||
start_setup=false
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
verbose=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$verbose" == true ]]; then
|
||||
set -x
|
||||
fi
|
||||
if [[ "$verbose" == true ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
platform="$(uname -s)"
|
||||
[[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions."
|
||||
command -v curl >/dev/null 2>&1 || fail "curl is required. Install curl, then rerun this installer."
|
||||
command -v node >/dev/null 2>&1 || fail "Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ is required; install it from https://nodejs.org and retry."
|
||||
command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry."
|
||||
platform="$(uname -s)"
|
||||
[[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions."
|
||||
command -v curl >/dev/null 2>&1 || fail "curl is required. Install curl, then rerun this installer."
|
||||
command -v node >/dev/null 2>&1 || fail "Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ is required; install it from https://nodejs.org and retry."
|
||||
command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry."
|
||||
|
||||
node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
|
||||
IFS=. read -r node_major node_minor _node_patch <<<"$node_version"
|
||||
[[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry."
|
||||
if ! [[ "$_node_patch" =~ ^[0-9]+(\+[0-9A-Za-z.-]+)?$ ]] ||
|
||||
! ((node_major == 22 && node_minor >= 19 || node_major == 24 && node_minor >= 11 || node_major >= 26)); then
|
||||
fail "Node.js $node_version is unsupported; use Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+."
|
||||
fi
|
||||
node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
|
||||
IFS=. read -r node_major node_minor _node_patch <<<"$node_version"
|
||||
[[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry."
|
||||
if ! [[ "$_node_patch" =~ ^[0-9]+(\+[0-9A-Za-z.-]+)?$ ]] ||
|
||||
! ((node_major == 22 && node_minor >= 19 || node_major == 24 && node_minor >= 11 || node_major >= 26)); then
|
||||
fail "Node.js $node_version is unsupported; use Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+."
|
||||
fi
|
||||
|
||||
if [[ -n "$version" ]]; then
|
||||
[[ "$version" =~ ^${version_pattern}$ ]] || fail "version must be an exact semantic version."
|
||||
else
|
||||
release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry."
|
||||
tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})' 2>/dev/null)" || fail "the release response was invalid. Retry later or pass --version VERSION."
|
||||
[[ "$tag" =~ ^rea-agents-(${version_pattern})$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION."
|
||||
version="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
if [[ -n "$version" ]]; then
|
||||
[[ "$version" =~ ^${version_pattern}$ ]] || fail "version must be an exact semantic version."
|
||||
else
|
||||
release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry."
|
||||
tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})' 2>/dev/null)" || fail "the release response was invalid. Retry later or pass --version VERSION."
|
||||
[[ "$tag" =~ ^rea-agents-(${version_pattern})$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION."
|
||||
version="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
# npm's exact-version resolver cleans build metadata from the requested version,
|
||||
# so it may install the base release and make the post-install identity check fail.
|
||||
if [[ "$version" =~ \+ ]]; then
|
||||
fail "npm cannot install an exact version with build metadata. Pass a version without build metadata, then retry."
|
||||
fi
|
||||
# npm's exact-version resolver cleans build metadata from the requested version,
|
||||
# so it may install the base release and make the post-install identity check fail.
|
||||
if [[ "$version" =~ \+ ]]; then
|
||||
fail "npm cannot install an exact version with build metadata. Pass a version without build metadata, then retry."
|
||||
fi
|
||||
|
||||
install_args=(install --global)
|
||||
if [[ "$platform" == "Linux" ]]; then
|
||||
install_args+=(--prefix "$HOME/.local")
|
||||
install_bin="$HOME/.local/bin/rea"
|
||||
else
|
||||
npm_prefix="$(npm prefix --global)" || fail "the npm global prefix could not be read. Repair the npm configuration, then retry."
|
||||
install_bin="$npm_prefix/bin/rea"
|
||||
fi
|
||||
install_args+=("$PACKAGE@$version")
|
||||
install_args=(install --global)
|
||||
if [[ "$platform" == "Linux" ]]; then
|
||||
install_args+=(--prefix "$HOME/.local")
|
||||
install_bin="$HOME/.local/bin/rea"
|
||||
else
|
||||
npm_prefix="$(npm prefix --global)" || fail "the npm global prefix could not be read. Repair the npm configuration, then retry."
|
||||
install_bin="$npm_prefix/bin/rea"
|
||||
fi
|
||||
install_args+=("$PACKAGE@$version")
|
||||
|
||||
printf 'REA install plan\n'
|
||||
printf ' Version: %s\n' "$version"
|
||||
printf ' Runtime: Node.js %s\n' "$node_version"
|
||||
printf ' Command:'
|
||||
printf ' %q' npm "${install_args[@]}"
|
||||
printf '\n'
|
||||
printf ' Binary: %s\n' "$install_bin"
|
||||
printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')"
|
||||
printf 'REA install plan\n'
|
||||
printf ' Version: %s\n' "$version"
|
||||
printf ' Runtime: Node.js %s\n' "$node_version"
|
||||
printf ' Command:'
|
||||
printf ' %q' npm "${install_args[@]}"
|
||||
printf '\n'
|
||||
printf ' Binary: %s\n' "$install_bin"
|
||||
printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')"
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
printf 'Dry run complete; no changes made.\n'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
printf 'Dry run complete; no changes made.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
|
||||
npm "${install_args[@]}" || fail "npm could not install REA. Check registry access and npm permissions, then retry."
|
||||
[[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry."
|
||||
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry."
|
||||
[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry."
|
||||
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
|
||||
npm "${install_args[@]}" || fail "npm could not install REA. Check registry access and npm permissions, then retry."
|
||||
[[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry."
|
||||
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry."
|
||||
[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry."
|
||||
|
||||
printf 'REA %s is installed.\n' "$version"
|
||||
if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then
|
||||
printf 'Starting REA setup...\n'
|
||||
"$install_bin" setup </dev/tty >/dev/tty
|
||||
else
|
||||
printf 'Run %s setup to configure Hopper and agents.\n' "$install_bin"
|
||||
fi
|
||||
printf 'REA %s is installed.\n' "$version"
|
||||
if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then
|
||||
printf 'Starting REA setup...\n'
|
||||
"$install_bin" setup </dev/tty >/dev/tty
|
||||
else
|
||||
printf 'Run %s setup to configure Hopper and agents.\n' "$install_bin"
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
exit $?
|
||||
}
|
||||
|
||||
@@ -51,10 +51,14 @@
|
||||
"bridge/process/ProcessRunTokenReader.swift",
|
||||
"bridge/process/ProcessRunTokenReaderMain.swift",
|
||||
"bridge/ghidra/ReaGhidraBridge.java",
|
||||
"bridge/ghidra/ReaGhidraBundleActivator.java",
|
||||
"bridge/ghidra/ReaGhidraExtensions.java",
|
||||
"bridge/ghidra/ReaGhidraPrepareCom.java",
|
||||
"bridge/ghidra/ReaGhidraApplySeeds.java",
|
||||
"bridge/ghidra/ReaGhidraAnalysisOptions.java",
|
||||
"bridge/ghidra/ReaGhidraNoReturnFix.java",
|
||||
"bridge/ghidra/ReaGhidraMipsSoftFloat.java",
|
||||
"bridge/ghidra/ReaGhidraPrepareRaw.java",
|
||||
"bridge/android/ReaJadxBridge.java",
|
||||
"bridge/mitmproxy/capture.py",
|
||||
"scripts/rea.mjs",
|
||||
@@ -161,11 +165,13 @@
|
||||
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
|
||||
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
|
||||
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
|
||||
"verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs",
|
||||
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
|
||||
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
|
||||
"verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs",
|
||||
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
|
||||
"verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs",
|
||||
"verify:ghidra:raw": "npm run build:cached && node scripts/verify-real-ghidra-raw.mjs",
|
||||
"verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table",
|
||||
"verify:interface-builder": "npm run build:cached && node scripts/verify/apple/interface-builder.mjs",
|
||||
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",
|
||||
|
||||
@@ -401,6 +401,22 @@ class WebsiteTranslationChecks(unittest.TestCase):
|
||||
server.shutdown()
|
||||
worker.join()
|
||||
|
||||
def test_every_reading_page_offers_light_and_dark_theme_colors(self):
|
||||
site = ROOT / "website/_site"
|
||||
english_pages = reading_pages(ROOT / "website/public")
|
||||
self.assertTrue(english_pages)
|
||||
for language in languages(ROOT):
|
||||
code = language["code"]
|
||||
for original in english_pages:
|
||||
relative = original.relative_to(ROOT / "website/public")
|
||||
for path in {original, site / relative if code == "en" else site / code / relative}:
|
||||
with self.subTest(language=code, page=str(path.relative_to(ROOT))):
|
||||
doc = Document(path.read_text())
|
||||
media = sorted(node.attrs.get("media") for node in doc.nodes if node.tag == "meta" and node.attrs.get("name") == "theme-color")
|
||||
self.assertEqual(media, ["(prefers-color-scheme: dark)", "(prefers-color-scheme: light)"])
|
||||
styles = (ROOT / "website/public/assets/styles.css").read_text()
|
||||
self.assertTrue("@media (prefers-color-scheme: dark)" in styles, "styles.css has no dark color scheme block")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -13,10 +13,6 @@ import { exec } from "./lib/verify-package-core.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { createAnthropicModelFixture } from "./verify/clients/anthropic-model-fixture.mjs";
|
||||
|
||||
assert(
|
||||
process.platform !== "win32",
|
||||
"Native Claude Code verifier requires POSIX; Windows is unverified",
|
||||
);
|
||||
const repo = fileURLToPath(new URL("..", import.meta.url));
|
||||
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||
const command = process.env.REA_VERIFY_CLAUDE_COMMAND ?? "claude";
|
||||
@@ -46,8 +42,13 @@ try {
|
||||
await exec(command, ["--version"], { env: environment, timeout: 30_000 })
|
||||
).stdout.trim();
|
||||
} catch (cause) {
|
||||
// npm installs put only a claude.cmd shim on PATH, which Node does not spawn without a shell.
|
||||
const windowsHint =
|
||||
process.platform === "win32"
|
||||
? " On Windows, select claude.exe; npm installs provide it under node_modules/@anthropic-ai/claude-code/bin."
|
||||
: "";
|
||||
throw new Error(
|
||||
`Claude Code preflight failed for ${command}; install Claude Code or set REA_VERIFY_CLAUDE_COMMAND. ${String(cause)}`,
|
||||
`Claude Code preflight failed for ${command}; install Claude Code or set REA_VERIFY_CLAUDE_COMMAND.${windowsHint} ${String(cause)}`,
|
||||
);
|
||||
}
|
||||
const target = join(lab, "fixture α测试");
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
@@ -172,11 +172,14 @@ const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const runtimeParent = join(workspace, "runtime with spaces");
|
||||
await mkdir(runtimeParent);
|
||||
const callerDirectory = join(workspace, "caller cwd with spaces");
|
||||
// Keep sorted for comparison with the final caller-directory listing.
|
||||
const scriptCollisions = [
|
||||
"ReaGhidraBridge.java",
|
||||
"ReaGhidraPrepareCom.java",
|
||||
"ReaGhidraAnalysisOptions.java",
|
||||
"ReaGhidraApplySeeds.java",
|
||||
"ReaGhidraBridge.java",
|
||||
"ReaGhidraNoReturnFix.java",
|
||||
"ReaGhidraPrepareCom.java",
|
||||
"ReaGhidraPrepareRaw.java",
|
||||
];
|
||||
await mkdir(callerDirectory);
|
||||
for (const name of scriptCollisions) await mkdir(join(callerDirectory, name));
|
||||
@@ -290,6 +293,23 @@ try {
|
||||
await client.connect(transport);
|
||||
await call("open_binary", { path: target });
|
||||
await call("binary_overview", {});
|
||||
const activators = (
|
||||
await readdir(runtimeParent, { recursive: true })
|
||||
).filter((path) => basename(path) === "ReaGhidraBundleActivator.class");
|
||||
assert.equal(activators.length, 1);
|
||||
const [activator] = activators;
|
||||
assert.ok(activator !== undefined);
|
||||
const bundleDirectory = dirname(join(runtimeParent, activator));
|
||||
const manifest = await readFile(
|
||||
join(bundleDirectory, "META-INF", "MANIFEST.MF"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(manifest, /^Bundle-Activator: ReaGhidraBundleActivator\r?$/mu);
|
||||
await assert.rejects(
|
||||
readFile(join(bundleDirectory, "GeneratedActivator.class")),
|
||||
{ code: "ENOENT" },
|
||||
);
|
||||
report.bundleActivator = "ReaGhidraBundleActivator";
|
||||
assert.equal((await call("list_documents", {})).length, 1);
|
||||
await call("list_names", {});
|
||||
const procedures = await call("list_procedures", {});
|
||||
@@ -394,15 +414,15 @@ try {
|
||||
return evidence.normalized_result;
|
||||
},
|
||||
);
|
||||
const expected = (
|
||||
const { GHIDRA_MUTATING_OPERATIONS, GHIDRA_SESSION_CAPABILITIES } =
|
||||
await import(
|
||||
pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionValues.js"))
|
||||
)
|
||||
).GHIDRA_SESSION_CAPABILITIES.filter(
|
||||
);
|
||||
const expected = GHIDRA_SESSION_CAPABILITIES.filter(
|
||||
(name) =>
|
||||
name !== "ping" &&
|
||||
name !== "shutdown" &&
|
||||
name !== "annotate_native_function",
|
||||
!GHIDRA_MUTATING_OPERATIONS.has(name),
|
||||
);
|
||||
assert.ok(
|
||||
expected.every((name) => report.mcpOperations.includes(name)),
|
||||
@@ -440,7 +460,10 @@ try {
|
||||
token,
|
||||
"Analysis changed caller token authority.",
|
||||
);
|
||||
assert.deepEqual((await readdir(callerDirectory)).sort(), scriptCollisions);
|
||||
assert.deepEqual(
|
||||
(await readdir(callerDirectory)).sort(),
|
||||
[...scriptCollisions].sort(),
|
||||
);
|
||||
for (const name of scriptCollisions)
|
||||
assert.deepEqual(await readdir(join(callerDirectory, name)), []);
|
||||
report.callerScriptCollisionsPreserved = true;
|
||||
|
||||
@@ -5,10 +5,16 @@ import { promisify } from "node:util";
|
||||
const exec = promisify(execFile);
|
||||
const packedHopperBridge = "package/bridge/hopper_bridge.py";
|
||||
const packedGhidraBridge = "package/bridge/ghidra/ReaGhidraBridge.java";
|
||||
const packedGhidraActivator =
|
||||
"package/bridge/ghidra/ReaGhidraBundleActivator.java";
|
||||
|
||||
/** Check shipped bridge files; real-provider lanes verify runtime behavior. */
|
||||
export async function verifyPackedBridge({ workspace, tarball, packedFiles }) {
|
||||
for (const path of [packedHopperBridge, packedGhidraBridge]) {
|
||||
for (const path of [
|
||||
packedHopperBridge,
|
||||
packedGhidraBridge,
|
||||
packedGhidraActivator,
|
||||
]) {
|
||||
if (!packedFiles.includes(path)) throw new Error(`package omitted ${path}`);
|
||||
}
|
||||
await exec("tar", ["-xf", tarball, "-C", workspace]);
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
|
||||
import { functionDossierSchema } from "../dist/domain/hopperValues.js";
|
||||
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
|
||||
import { buildMipsRelocatableFixture } from "../tests/conformance/ghidra/mips-fixture.mjs";
|
||||
import {
|
||||
assertMipsRelocations,
|
||||
inspectMipsRelocatableReadelf,
|
||||
} from "../tests/conformance/ghidra/mips-relocatable-checks.mjs";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
// Optional cross-target lane for ET_REL objects. No tool acquisition, no
|
||||
// linking and no target execution.
|
||||
if (process.argv.length > 2)
|
||||
throw new Error(
|
||||
"Usage: node scripts/verify-real-ghidra-mips-relocatable.mjs",
|
||||
);
|
||||
if (!["linux", "darwin"].includes(process.platform))
|
||||
throw new Error(
|
||||
"MIPS verification requires a supported Linux/macOS Ghidra host",
|
||||
);
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR");
|
||||
const installation = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir: process.env.GHIDRA_INSTALL_DIR,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
: { javaHome: process.env.JAVA_HOME }),
|
||||
});
|
||||
if (installation.status !== "available")
|
||||
throw new Error(
|
||||
`Ghidra prerequisite unavailable: ${JSON.stringify(installation)}`,
|
||||
);
|
||||
const execute = promisify(execFile);
|
||||
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
||||
const run = createVerifierRun();
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-mips-rel-proof-"));
|
||||
const runtime = join(workspace, "runtime");
|
||||
await mkdir(runtime);
|
||||
const env = {
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
||||
TMPDIR: runtime,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_ANALYSIS_PROVIDER: "ghidra",
|
||||
REA_PROCESS_RUN_ID: run.run_id,
|
||||
GHIDRA_INSTALL_DIR: installation.installDir,
|
||||
JAVA_HOME: installation.javaHome,
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
|
||||
};
|
||||
for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
|
||||
if (process.env[setting] !== undefined) env[setting] = process.env[setting];
|
||||
}
|
||||
|
||||
/** Load every relocated word through the public read_bytes operation. */
|
||||
async function loadedWords(call, independent, segments, byteOrder) {
|
||||
const words = new Map();
|
||||
for (const relocation of independent.relocations) {
|
||||
const section = [...independent.sections.values()].find(
|
||||
(item) => item.name === relocation.section,
|
||||
);
|
||||
if (!section?.allocated) continue;
|
||||
const segment = segments.find((item) => item.name === section.name);
|
||||
assert.ok(segment, `Ghidra segment ${section.name}`);
|
||||
const address = BigInt(segment.start) + BigInt(relocation.offset);
|
||||
const read = await call("read_bytes", {
|
||||
address: `0x${address.toString(16)}`,
|
||||
length: 4,
|
||||
});
|
||||
assert.equal(read.returned_bytes, 4);
|
||||
const bytes = Buffer.from(read.bytes_hex, "hex");
|
||||
words.set(
|
||||
address,
|
||||
byteOrder === "little" ? bytes.readUInt32LE(0) : bytes.readUInt32BE(0),
|
||||
);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
async function verifyFixture(byteOrder, kernelModule) {
|
||||
const fixture = await buildMipsRelocatableFixture(
|
||||
workspace,
|
||||
byteOrder,
|
||||
kernelModule,
|
||||
);
|
||||
const independent = await inspectMipsRelocatableReadelf(
|
||||
fixture.path,
|
||||
byteOrder,
|
||||
fixture.float_abi,
|
||||
);
|
||||
const resolved = await parseBinaryTarget(fixture.path);
|
||||
if (!resolved.ok) throw resolved.error;
|
||||
assert.deepEqual(
|
||||
resolved.value.mips,
|
||||
independent.mips,
|
||||
"REA ELF/ABI interpretation disagrees with GNU readelf",
|
||||
);
|
||||
assert.equal(resolved.value.sha256, fixture.sha256);
|
||||
const label = `${byteOrder}${kernelModule ? "-module" : ""}`;
|
||||
const snapshotPath = join(workspace, `${label}.snapshot.json`);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-real-mips-rel-proof", version: "1" });
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
const call = async (name, args = {}) =>
|
||||
requireMcpOperationResult(
|
||||
await client.callTool({ name, arguments: args }, undefined, {
|
||||
timeout: 180000,
|
||||
}),
|
||||
name,
|
||||
);
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const target = await call("open_binary", {
|
||||
path: fixture.path,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(target.architecture, "mips");
|
||||
assert.equal(target.sha256, fixture.sha256);
|
||||
const image = (await call("inspect_native_load_image")).observations;
|
||||
assert.equal(
|
||||
image.language_id,
|
||||
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
|
||||
);
|
||||
assert.equal(image.source_files.length, 1);
|
||||
assert.equal(image.source_files[0].original_sha256, fixture.sha256);
|
||||
assert.notEqual(
|
||||
image.source_files[0].modified_sha256,
|
||||
fixture.sha256,
|
||||
"Relocations must change the loaded bytes",
|
||||
);
|
||||
const names = await call("list_names");
|
||||
const segments = await call("list_segments");
|
||||
const coverage = assertMipsRelocations({
|
||||
facts: independent,
|
||||
fileBytes: await readFile(fixture.path),
|
||||
segments,
|
||||
mappings: image.mappings,
|
||||
ghidraRelocations: image.relocations,
|
||||
words: await loadedWords(call, independent, segments, byteOrder),
|
||||
names,
|
||||
byteOrder,
|
||||
});
|
||||
assert.equal(coverage.unplaced, 0, "Fixture references an empty section");
|
||||
assert.ok(coverage.shared_hi16, "No HI16 shared by several LO16s");
|
||||
assert.ok(coverage.lo16_carry, "No LO16 >= 0x8000 forced a HI16 carry");
|
||||
assert.ok(coverage.external_code, "No external code relocation");
|
||||
assert.ok(coverage.external_data, "No external data relocation");
|
||||
assert.ok(coverage.types.R_MIPS_32 > 0, "No R_MIPS_32 relocation");
|
||||
assert.equal(
|
||||
coverage.types.R_MIPS_26 === undefined,
|
||||
kernelModule,
|
||||
"Long calls must replace every R_MIPS_26 with HI16/LO16",
|
||||
);
|
||||
const procedures = await call("list_procedures");
|
||||
const named = (name) => {
|
||||
const found = procedures.filter((item) => item.value === name);
|
||||
assert.equal(found.length, 1, `Procedure ${name}`);
|
||||
return found[0].address;
|
||||
};
|
||||
const entry = named("rea_mips_rel_entry");
|
||||
const callees = await call("procedure_callees", { procedure: entry });
|
||||
for (const callee of [
|
||||
named("rea_mips_rel_leaf"),
|
||||
named("rea_mips_rel_far"),
|
||||
])
|
||||
assert.ok(callees.includes(callee), `Missing direct call to ${callee}`);
|
||||
// Ghidra lists the external function and its EXTERNAL-block thunk.
|
||||
assert.ok(
|
||||
procedures.some(
|
||||
(item) =>
|
||||
item.value === "<EXTERNAL>::rea_mips_rel_external" &&
|
||||
callees.includes(item.address),
|
||||
),
|
||||
"Missing call to the external function",
|
||||
);
|
||||
const dossier = functionDossierSchema.parse(
|
||||
await call("analyze_function", { procedure: entry }),
|
||||
);
|
||||
for (const name of [
|
||||
"rea_mips_rel_leaf",
|
||||
"rea_mips_rel_far",
|
||||
"rea_mips_rel_external",
|
||||
])
|
||||
assert.ok(dossier.pseudocode.includes(name), `Pseudocode lost ${name}`);
|
||||
// powf's float prototype comes from Ghidra's C library archive; soft-float
|
||||
// builds pass 1.5f/2.5f in $a0/$a1, hard-float builds in $f12/$f14.
|
||||
const scaled = functionDossierSchema.parse(
|
||||
await call("analyze_function", {
|
||||
procedure: named("rea_mips_rel_scaled"),
|
||||
}),
|
||||
);
|
||||
assert.match(scaled.pseudocode, /\bpowf\(1\.5,2\.5\)/u);
|
||||
assert.doesNotMatch(scaled.pseudocode, /\bin_f\d+\b/u);
|
||||
await call("close_binary", { snapshot_path: snapshotPath });
|
||||
const snapshot = parseAnalysisSnapshot(
|
||||
JSON.parse(await readFile(snapshotPath, "utf8")),
|
||||
);
|
||||
const parameters = snapshot.binding.analysis_profile.parameters;
|
||||
assert.equal(parameters.mips_elf.type, 1);
|
||||
assert.equal(
|
||||
parameters.mips_support_lane,
|
||||
"elf32-rel-o32-arch32r2-standard-v1",
|
||||
);
|
||||
assert.equal(
|
||||
parameters.mips_soft_float_storage,
|
||||
kernelModule ? "o32-soft-float-prototype-v1" : undefined,
|
||||
);
|
||||
assert.ok(
|
||||
snapshot.evidence_bundle.records.some((record) =>
|
||||
parseEvidence(record).limitations.some((item) =>
|
||||
item.includes("Relocatable MIPS ELF"),
|
||||
),
|
||||
),
|
||||
"Relocatable limitation missing from retained Evidence",
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.evidence_bundle.records.some((record) =>
|
||||
parseEvidence(record).limitations.some((item) =>
|
||||
/Soft-float prototype outcome: [1-9]\d* functions/u.test(item),
|
||||
),
|
||||
),
|
||||
kernelModule,
|
||||
"Soft-float prototype must cover the archive-typed external in module builds",
|
||||
);
|
||||
await call("open_binary", {
|
||||
path: fixture.path,
|
||||
provider_id: "ghidra",
|
||||
snapshot_path: snapshotPath,
|
||||
});
|
||||
assert.deepEqual(await call("list_procedures"), procedures);
|
||||
await call("close_binary");
|
||||
// Same public operation through CLI, not a private injected provider.
|
||||
const cli = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"function",
|
||||
fixture.path,
|
||||
"rea_mips_rel_entry",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const evidence = parseEvidence(JSON.parse(cli.stdout));
|
||||
assert.equal(evidence.subject?.architecture, "mips");
|
||||
assert.equal(evidence.subject?.digest.sha256, fixture.sha256);
|
||||
assert.equal(
|
||||
functionDossierSchema.parse(evidence.normalized_result).procedure.address,
|
||||
dossier.procedure.address,
|
||||
);
|
||||
assert.equal(
|
||||
createHash("sha256")
|
||||
.update(await readFile(fixture.path))
|
||||
.digest("hex"),
|
||||
fixture.sha256,
|
||||
"Analysis modified the source object",
|
||||
);
|
||||
return {
|
||||
...fixture,
|
||||
independent_reader: {
|
||||
command: independent.command,
|
||||
version: independent.version,
|
||||
},
|
||||
effective_language: image.language_id,
|
||||
relocation_coverage: coverage,
|
||||
procedures: procedures.length,
|
||||
status: "passed",
|
||||
};
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`MIPS ${label} relocatable verification failed; MCP stderr: ${stderr}`,
|
||||
{ cause },
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reports = [];
|
||||
let primaryFailure;
|
||||
try {
|
||||
for (const [byteOrder, kernelModule] of [
|
||||
["little", false],
|
||||
["big", false],
|
||||
["little", true],
|
||||
])
|
||||
reports.push(await verifyFixture(byteOrder, kernelModule));
|
||||
} catch (cause) {
|
||||
primaryFailure = cause;
|
||||
}
|
||||
const completed = await completeVerifierRun(run);
|
||||
if (
|
||||
completed.process_lineage.status !== "verified" ||
|
||||
completed.process_lineage.descendants.length !== 0
|
||||
)
|
||||
throw new Error(
|
||||
`Cleanup unverified; retained diagnostic workspace: ${workspace}`,
|
||||
{ cause: primaryFailure },
|
||||
);
|
||||
if (primaryFailure !== undefined)
|
||||
throw new Error(
|
||||
`MIPS relocatable verification failed; retained workspace: ${workspace}`,
|
||||
{ cause: primaryFailure },
|
||||
);
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
console.log(JSON.stringify({ ...completed, fixtures: reports }, null, 2));
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
assertMipsLoadedImage,
|
||||
assertMipsProbe,
|
||||
assertMipsGlobal,
|
||||
assertMipsSoftFloatStorage,
|
||||
} from "../tests/conformance/ghidra/mips-checks.mjs";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
|
||||
@@ -77,16 +78,64 @@ const env = {
|
||||
for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
|
||||
if (process.env[setting] !== undefined) env[setting] = process.env[setting];
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed float signatures must use soft-float o32 storage, and callers must see
|
||||
* the source constants through it rather than FPU inputs.
|
||||
*/
|
||||
async function verifySoftFloat(call, symbols, byteOrder) {
|
||||
const boundaries = {};
|
||||
for (const name of [
|
||||
"rea_mips_soft_pick",
|
||||
"rea_mips_soft_wide",
|
||||
"rea_mips_soft_mixed",
|
||||
"rea_mips_soft_struct",
|
||||
]) {
|
||||
const api = await call("inspect_native_api", { procedure: symbols[name] });
|
||||
boundaries[name] = api.boundary;
|
||||
}
|
||||
assertMipsSoftFloatStorage(boundaries, byteOrder);
|
||||
const caller = functionDossierSchema.parse(
|
||||
await call("analyze_function", {
|
||||
procedure: symbols.rea_mips_soft_caller,
|
||||
}),
|
||||
);
|
||||
// Includes the struct call and the indirect call through a typed pointer.
|
||||
assert.doesNotMatch(caller.pseudocode, /\bin_f\d+\b/u);
|
||||
for (const literal of [
|
||||
"1.5",
|
||||
"2.5",
|
||||
"3.25",
|
||||
"4.75",
|
||||
"5.5",
|
||||
"6.125",
|
||||
"7.5",
|
||||
"8.5",
|
||||
"9.5",
|
||||
])
|
||||
assert.ok(
|
||||
caller.pseudocode.includes(literal),
|
||||
`Soft-float caller lost argument ${literal}`,
|
||||
);
|
||||
return { storage: boundaries };
|
||||
}
|
||||
const reports = [];
|
||||
let primaryFailure;
|
||||
try {
|
||||
for (const byteOrder of psp ? ["little"] : ["little", "big"]) {
|
||||
for (const [byteOrder, floatAbi] of psp
|
||||
? [["little", "hard"]]
|
||||
: [
|
||||
["little", "hard"],
|
||||
["big", "hard"],
|
||||
["little", "soft"],
|
||||
["big", "soft"],
|
||||
]) {
|
||||
const fixture = psp
|
||||
? await buildPspFixture(workspace)
|
||||
: await buildMipsFixture(workspace, byteOrder);
|
||||
: await buildMipsFixture(workspace, byteOrder, floatAbi);
|
||||
const independent = psp
|
||||
? fixture
|
||||
: await inspectMipsReadelf(fixture.path, byteOrder);
|
||||
: await inspectMipsReadelf(fixture.path, byteOrder, floatAbi);
|
||||
const resolved = await parseBinaryTarget(fixture.path);
|
||||
if (!resolved.ok) throw resolved.error;
|
||||
if (psp) {
|
||||
@@ -104,7 +153,10 @@ try {
|
||||
);
|
||||
}
|
||||
assert.equal(resolved.value.sha256, fixture.sha256);
|
||||
const snapshotPath = join(workspace, `${byteOrder}.snapshot.json`);
|
||||
const snapshotPath = join(
|
||||
workspace,
|
||||
`${byteOrder}-${floatAbi}.snapshot.json`,
|
||||
);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
@@ -238,6 +290,10 @@ try {
|
||||
callees.includes(leaf.address),
|
||||
"Expected direct call not recovered",
|
||||
);
|
||||
const softFloat =
|
||||
floatAbi === "soft"
|
||||
? await verifySoftFloat(call, independent.symbols, byteOrder)
|
||||
: undefined;
|
||||
const assembly = await call("procedure_assembly", {
|
||||
procedure: entry.address,
|
||||
});
|
||||
@@ -293,6 +349,19 @@ try {
|
||||
},
|
||||
"Snapshot profile did not retain the independently inspected ELF/ABI declaration",
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.binding.analysis_profile.parameters.mips_soft_float_storage,
|
||||
floatAbi === "soft" ? "o32-soft-float-prototype-v1" : undefined,
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.evidence_bundle.records.some((record) =>
|
||||
/Soft-float prototype outcome: 4 functions and [1-9]\d* function-pointer definitions use __rea_soft_float\./u.test(
|
||||
JSON.stringify(record),
|
||||
),
|
||||
),
|
||||
floatAbi === "soft",
|
||||
"Soft-float prototype outcome missing from, or unexpected in, retained Evidence",
|
||||
);
|
||||
assert.ok(snapshot.evidence_bundle.records.length > 0);
|
||||
if (psp) {
|
||||
assert.equal(
|
||||
@@ -419,7 +488,10 @@ try {
|
||||
assert.ok(read32(offset + 4) + read32(offset + 16) <= cut);
|
||||
}
|
||||
const carvedBytes = sourceBytes.subarray(0, cut);
|
||||
const carvedPath = join(workspace, `${byteOrder}-carved.elf`);
|
||||
const carvedPath = join(
|
||||
workspace,
|
||||
`${byteOrder}-${floatAbi}-carved.elf`,
|
||||
);
|
||||
const carvedSha256 = createHash("sha256")
|
||||
.update(carvedBytes)
|
||||
.digest("hex");
|
||||
@@ -460,7 +532,7 @@ try {
|
||||
assert.ok(carvedDossier.pseudocode.trim().length > 0);
|
||||
const carvedSnapshotPath = join(
|
||||
workspace,
|
||||
`${byteOrder}-carved.snapshot.json`,
|
||||
`${byteOrder}-${floatAbi}-carved.snapshot.json`,
|
||||
);
|
||||
await call("close_binary", { snapshot_path: carvedSnapshotPath });
|
||||
const carvedSnapshot = parseAnalysisSnapshot(
|
||||
@@ -523,7 +595,11 @@ try {
|
||||
: "conditional branch destination",
|
||||
"global word and marker bytes",
|
||||
"CLI/MCP and snapshot identity",
|
||||
...(floatAbi === "soft"
|
||||
? ["soft-float o32 parameter/return storage and caller constants"]
|
||||
: []),
|
||||
],
|
||||
...(softFloat === undefined ? {} : { soft_float: softFloat }),
|
||||
procedures: procedures.length,
|
||||
carved: carved ?? {
|
||||
status: "not_run",
|
||||
@@ -534,7 +610,7 @@ try {
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`MIPS ${byteOrder} verification failed; MCP stderr: ${stderr}`,
|
||||
`MIPS ${byteOrder} ${floatAbi}-float verification failed; MCP stderr: ${stderr}`,
|
||||
{ cause },
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
if (process.argv.length !== 2)
|
||||
throw new Error("Usage: node scripts/verify-real-ghidra-raw.mjs");
|
||||
if (!["linux", "darwin"].includes(process.platform))
|
||||
throw new Error(
|
||||
"Raw-binary verification requires a Linux or macOS Ghidra host.",
|
||||
);
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
throw new Error(
|
||||
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
|
||||
);
|
||||
const installation = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir: process.env.GHIDRA_INSTALL_DIR,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
: { javaHome: process.env.JAVA_HOME }),
|
||||
});
|
||||
assert.equal(installation.status, "available", JSON.stringify(installation));
|
||||
|
||||
const run = createVerifierRun();
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-raw-proof-"));
|
||||
const runtime = join(workspace, "runtime");
|
||||
await mkdir(runtime);
|
||||
const bytes = Buffer.from("1eff2fe1", "hex"); // ARM little-endian bx lr.
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const path = join(workspace, "image.bin");
|
||||
await writeFile(path, bytes);
|
||||
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
||||
const env = {
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
||||
TMPDIR: runtime,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_ANALYSIS_PROVIDER: "ghidra",
|
||||
REA_PROCESS_RUN_ID: run.run_id,
|
||||
GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
: { JAVA_HOME: process.env.JAVA_HOME }),
|
||||
...(process.env.GHIDRA_HEADLESS_MAXMEM === undefined
|
||||
? {}
|
||||
: { GHIDRA_HEADLESS_MAXMEM: process.env.GHIDRA_HEADLESS_MAXMEM }),
|
||||
...(process.env.REA_GHIDRA_DISABLED_ANALYZERS === undefined
|
||||
? {}
|
||||
: {
|
||||
REA_GHIDRA_DISABLED_ANALYZERS:
|
||||
process.env.REA_GHIDRA_DISABLED_ANALYZERS,
|
||||
}),
|
||||
};
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-real-raw-proof", version: "1" });
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
let report;
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const profiles = [];
|
||||
const sessions = [];
|
||||
for (const entryPoint of ["0x08000000", "0x8000000", "0x09000000"]) {
|
||||
// Omit provider_id to exercise reuse of the current provider route.
|
||||
const opened = await call("open_binary", {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "arm",
|
||||
entry_point: entryPoint,
|
||||
});
|
||||
const entry = `0x${BigInt(entryPoint).toString(16)}`;
|
||||
assert.equal(opened.entry_point, entry);
|
||||
const image = await call("inspect_native_load_image");
|
||||
assert.equal(
|
||||
image.normalized_result.status,
|
||||
"verified",
|
||||
JSON.stringify(image),
|
||||
);
|
||||
assert.equal(image.normalized_result.base_address, entry);
|
||||
assert.equal(image.normalized_result.entry.address, entry);
|
||||
assert.equal(
|
||||
image.normalized_result.observations.language_id,
|
||||
"ARM:LE:32:v7",
|
||||
);
|
||||
assert.ok(
|
||||
image.normalized_result.observations.entry_points.includes(entry),
|
||||
);
|
||||
assert.equal(image.analysis_profile.parameters.entry_point, entry);
|
||||
profiles.push(image.analysis_profile.digest);
|
||||
const status = await call("binary_session");
|
||||
assert.equal(status.entry_point, entry);
|
||||
sessions.push(status.analysis_run.run_id);
|
||||
const memory = await call("read_bytes", {
|
||||
address: entry,
|
||||
length: bytes.length,
|
||||
});
|
||||
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
|
||||
const procedures = await call("list_procedures");
|
||||
assert.ok(
|
||||
procedures.normalized_result.some(
|
||||
(procedure) => procedure.address === entry,
|
||||
),
|
||||
);
|
||||
const instruction = await call("inspect_native_instruction", {
|
||||
address: entry,
|
||||
});
|
||||
assert.equal(instruction.normalized_result.address, entry);
|
||||
}
|
||||
assert.equal(profiles[0], profiles[1]);
|
||||
assert.notEqual(profiles[1], profiles[2]);
|
||||
assert.equal(sessions[0], sessions[1]);
|
||||
assert.notEqual(sessions[1], sessions[2]);
|
||||
const rejected = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "arm64",
|
||||
entry_point: "0x8000001",
|
||||
},
|
||||
});
|
||||
assert.equal(rejected.isError, true);
|
||||
assert.match(JSON.stringify(rejected.content), /must be 4-byte aligned/);
|
||||
const unchanged = await call("binary_session");
|
||||
assert.equal(unchanged.analysis_run.run_id, sessions[2]);
|
||||
assert.equal(unchanged.entry_point, "0x9000000");
|
||||
await call("close_binary");
|
||||
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-native-load-image",
|
||||
path,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"arm",
|
||||
"--entry-point",
|
||||
"0x08000000",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const cli = parseEvidence(JSON.parse(stdout));
|
||||
assert.equal(cli.subject.digest.sha256, sha256);
|
||||
assert.equal(cli.normalized_result.status, "verified");
|
||||
assert.equal(cli.normalized_result.base_address, "0x8000000");
|
||||
assert.equal(cli.analysis_profile.digest, profiles[0]);
|
||||
assert.deepEqual(await readFile(path), bytes);
|
||||
const x86Targets = [];
|
||||
for (const architecture of ["x86", "x86_64"]) {
|
||||
const x86Bytes = Buffer.from("b82a000000c3", "hex"); // mov eax, 42; ret.
|
||||
const x86Sha256 = createHash("sha256").update(x86Bytes).digest("hex");
|
||||
const x86Path = join(workspace, `${architecture}.bin`);
|
||||
await writeFile(x86Path, x86Bytes);
|
||||
await call("open_binary", {
|
||||
path: x86Path,
|
||||
format: "raw-binary",
|
||||
architecture,
|
||||
entry_point: "0x1000",
|
||||
});
|
||||
const image = await call("inspect_native_load_image", {}, x86Sha256);
|
||||
assert.equal(
|
||||
image.normalized_result.status,
|
||||
"verified",
|
||||
JSON.stringify(image),
|
||||
);
|
||||
assert.equal(image.normalized_result.base_address, "0x1000");
|
||||
assert.equal(image.normalized_result.observations.compiler_spec_id, "gcc");
|
||||
assert.equal(image.analysis_profile.parameters.compiler_spec_id, "gcc");
|
||||
const memory = await call(
|
||||
"read_bytes",
|
||||
{ address: "0x1000", length: x86Bytes.length },
|
||||
x86Sha256,
|
||||
);
|
||||
assert.equal(memory.normalized_result.bytes_hex, x86Bytes.toString("hex"));
|
||||
const pseudo = await call(
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: "0x1000" },
|
||||
x86Sha256,
|
||||
);
|
||||
assert.match(pseudo.normalized_result, /\b(?:42|0x2a)\b/iu);
|
||||
await call("close_binary");
|
||||
assert.deepEqual(await readFile(x86Path), x86Bytes);
|
||||
x86Targets.push({
|
||||
architecture,
|
||||
sha256: x86Sha256,
|
||||
compiler_spec_id: "gcc",
|
||||
return_42: true,
|
||||
});
|
||||
}
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
lane: "ghidra-raw",
|
||||
host: process.platform,
|
||||
provider: { id: "ghidra", version: installation.providerVersion },
|
||||
target: {
|
||||
format: "raw-binary",
|
||||
architecture: "arm",
|
||||
sha256,
|
||||
bytes: bytes.length,
|
||||
},
|
||||
transport: { cli: true, stdio_mcp: true },
|
||||
canonical_entry_reuses_session: true,
|
||||
changed_entry_replaces_session: true,
|
||||
lifecycle_entry_preserved: true,
|
||||
invalid_entry_preserves_active_session: true,
|
||||
load_images_verified: true,
|
||||
original_unchanged: true,
|
||||
x86_targets: x86Targets,
|
||||
};
|
||||
} catch (error) {
|
||||
if (stderr.trim()) process.stderr.write(stderr);
|
||||
throw error;
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
const verifierRun = await completeVerifierRun(run);
|
||||
assert.equal(verifierRun.process_lineage.status, "verified");
|
||||
assert.deepEqual(verifierRun.process_lineage.descendants, []);
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
verifier_run: verifierRun,
|
||||
...report,
|
||||
cleanup: "complete",
|
||||
}) + "\n",
|
||||
);
|
||||
|
||||
async function call(name, args = {}, targetSha256 = sha256) {
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
const projected = requireMcpOperationResult(result, name);
|
||||
if (
|
||||
name === "open_binary" ||
|
||||
name === "close_binary" ||
|
||||
name === "binary_session"
|
||||
)
|
||||
return projected;
|
||||
const evidence = parseEvidence(result.structuredContent);
|
||||
assert.equal(evidence.subject.digest.sha256, targetSha256);
|
||||
assert.equal(evidence.subject.format, "raw-binary");
|
||||
return evidence;
|
||||
}
|
||||
@@ -21,14 +21,17 @@ import { SUPPORTED_GHIDRA_VERSION } from "../dist/ghidra/GhidraInstallationPolic
|
||||
import { GhidraHeadlessLauncher } from "../dist/ghidra/GhidraLauncher.js";
|
||||
import { GHIDRA_PROVIDER_IDENTITY } from "../dist/ghidra/GhidraProviderCapabilities.js";
|
||||
import { windowsP0Capabilities } from "../dist/ghidra/GhidraProviderCapabilities.js";
|
||||
import { GHIDRA_SESSION_CAPABILITIES } from "../dist/ghidra/GhidraSessionValues.js";
|
||||
import {
|
||||
GHIDRA_MUTATING_OPERATIONS,
|
||||
GHIDRA_SESSION_CAPABILITIES,
|
||||
} from "../dist/ghidra/GhidraSessionValues.js";
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
|
||||
|
||||
const verifierRun = createVerifierRun();
|
||||
const SESSION_CAPABILITIES = GHIDRA_SESSION_CAPABILITIES.filter(
|
||||
(name) => name !== "annotate_native_function",
|
||||
(name) => !GHIDRA_MUTATING_OPERATIONS.has(name),
|
||||
);
|
||||
|
||||
if (process.platform !== "win32" || process.arch !== "x64")
|
||||
@@ -236,10 +239,8 @@ try {
|
||||
|
||||
const expected = [
|
||||
...GHIDRA_INVENTORY_OPERATIONS,
|
||||
...GHIDRA_FUNCTION_OPERATIONS.filter(
|
||||
(name) => name !== "annotate_native_function",
|
||||
),
|
||||
];
|
||||
...GHIDRA_FUNCTION_OPERATIONS,
|
||||
].filter((name) => !GHIDRA_MUTATING_OPERATIONS.has(name));
|
||||
if (
|
||||
observed.size !== expected.length ||
|
||||
expected.some((operation) => !observed.has(operation))
|
||||
|
||||
@@ -122,6 +122,15 @@ try {
|
||||
new URL("../tests/fixtures/wasm/custom-names.wat", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
`(module
|
||||
(memory 1)
|
||||
(data "x")
|
||||
(func (export "init")
|
||||
i32.const 0
|
||||
i32.const 0
|
||||
i32.const 1
|
||||
memory.init 0
|
||||
data.drop 0))\n`,
|
||||
];
|
||||
for (const [index, source] of sources.entries()) {
|
||||
const fixtureRoot = join(root.path, `fixture-${index}`);
|
||||
@@ -220,6 +229,22 @@ try {
|
||||
'"unicode-é\\\t"',
|
||||
],
|
||||
);
|
||||
if (index === 4) {
|
||||
assert.match(
|
||||
headers,
|
||||
/^DataCount start=0x[\da-f]+ end=0x[\da-f]+ \(size=0x[\da-f]+\) count: 1$/m,
|
||||
);
|
||||
assert.deepEqual(
|
||||
report.sections.find(({ kind }) => kind === "DataCount"),
|
||||
{
|
||||
kind: "DataCount",
|
||||
start: 0x23,
|
||||
end: 0x24,
|
||||
bytes: 1,
|
||||
description: "count: 1",
|
||||
},
|
||||
);
|
||||
}
|
||||
const bundleReply = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
|
||||
@@ -7,13 +7,16 @@ import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../../../dist/domain/evidence.js";
|
||||
import { hashAndroidFile } from "../../../dist/android/AndroidTargetSnapshot.js";
|
||||
import { hashAndroidFile } from "./digest.mjs";
|
||||
import { JADX_RELEASE } from "../../../dist/android/JadxRelease.js";
|
||||
import fixture from "../../fixtures/android/apidemos.json" with { type: "json" };
|
||||
import { verifyAndroidCliCancellation } from "./cli-cancellation.mjs";
|
||||
import { verifyAndroidMetadata } from "./metadata.mjs";
|
||||
import { verifyAndroidFailureRecovery } from "./failure-recovery.mjs";
|
||||
import { resolveJadxConfiguration } from "../../../dist/android/JadxConfiguration.js";
|
||||
import {
|
||||
resolveJadxConfigurationInputs,
|
||||
validateJadxConfiguration,
|
||||
} from "../../../dist/android/JadxConfiguration.js";
|
||||
import { verifyWindowsAndroidLifecycle } from "./windows-lifecycle.mjs";
|
||||
|
||||
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
@@ -35,10 +38,16 @@ assert.equal(
|
||||
JADX_RELEASE.sha256,
|
||||
"Real Android lane requires the audited headless JADX release JAR",
|
||||
);
|
||||
const { java, jvmArguments } = await resolveJadxConfiguration(
|
||||
const configuration = await resolveJadxConfigurationInputs(
|
||||
process.env,
|
||||
"inspect_android_package",
|
||||
);
|
||||
await validateJadxConfiguration(
|
||||
configuration,
|
||||
process.env,
|
||||
"inspect_android_package",
|
||||
);
|
||||
const { java, jvmArguments } = configuration;
|
||||
const execute = promisify(execFile);
|
||||
await execute(java, ["-version"], { timeout: 10_000 });
|
||||
const entrypoint =
|
||||
@@ -52,6 +61,8 @@ await verifyAndroidMetadata({
|
||||
repository,
|
||||
execute,
|
||||
jvmArguments,
|
||||
entrypoint,
|
||||
environment,
|
||||
bridge: resolve(dirname(entrypoint), "../bridge/android/ReaJadxBridge.java"),
|
||||
});
|
||||
const tasks = [
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
|
||||
/** Verify artifact identity without depending on private runtime snapshot helpers. */
|
||||
export const hashAndroidFile = async (path) => {
|
||||
const hash = createHash("sha256");
|
||||
for await (const bytes of createReadStream(path)) hash.update(bytes);
|
||||
return hash.digest("hex");
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../../../dist/domain/evidence.js";
|
||||
import { hashAndroidFile } from "./digest.mjs";
|
||||
|
||||
/** Verify folded field assignments and remaining static-block calls through both public adapters. */
|
||||
export const verifyAndroidInitializers = async ({
|
||||
apk,
|
||||
repository,
|
||||
entrypoint,
|
||||
environment,
|
||||
execute,
|
||||
}) => {
|
||||
const digest = await hashAndroidFile(apk);
|
||||
const cases = [
|
||||
{
|
||||
class_name: "fixture.InitializationPolicy",
|
||||
witnesses: [
|
||||
"declared-policy",
|
||||
"->singletonList",
|
||||
"->POLICY:Ljava/util/List;",
|
||||
],
|
||||
},
|
||||
{
|
||||
class_name: "fixture.MixedInitialization",
|
||||
witnesses: ["field-value", "->LABEL:Ljava/lang/String;", "->touch()V"],
|
||||
},
|
||||
];
|
||||
const check = (value, scenario) => {
|
||||
const evidence = parseEvidence(value);
|
||||
assert.equal(evidence.subject.digest.sha256, digest);
|
||||
const result = evidence.normalized_result;
|
||||
assert.equal(result.class_name, scenario.class_name);
|
||||
assert.equal(result.method.name, "<clinit>");
|
||||
assert.equal(result.overload_count, 1);
|
||||
assert.equal(result.body_status, "available");
|
||||
assert.equal(result.representation, "smali");
|
||||
assert.equal(result.fell_back, true);
|
||||
assert.ok(result.markers.includes("static_initializer_disassembly"));
|
||||
assert.equal(result.source.status, "complete");
|
||||
for (const witness of scenario.witnesses)
|
||||
assert.ok(
|
||||
result.source.text.includes(witness),
|
||||
`Missing initializer evidence: ${witness}`,
|
||||
);
|
||||
return evidence;
|
||||
};
|
||||
const cli = new Map();
|
||||
for (const scenario of cases) {
|
||||
const response = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-android-method",
|
||||
apk,
|
||||
scenario.class_name,
|
||||
"<clinit>",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
timeout: 150_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
cli.set(scenario.class_name, check(JSON.parse(response.stdout), scenario));
|
||||
}
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const client = new Client({
|
||||
name: "rea-real-android-initializers",
|
||||
version: "1",
|
||||
});
|
||||
try {
|
||||
await client.connect(transport, { timeout: 30_000 });
|
||||
for (const scenario of cases) {
|
||||
const response = await client.callTool(
|
||||
{
|
||||
name: "inspect_android_method",
|
||||
arguments: {
|
||||
path: apk,
|
||||
class_name: scenario.class_name,
|
||||
method_name: "<clinit>",
|
||||
},
|
||||
},
|
||||
{ timeout: 150_000 },
|
||||
);
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
const evidence = check(response.structuredContent, scenario);
|
||||
assert.deepEqual(
|
||||
evidence.normalized_result,
|
||||
cli.get(scenario.class_name).normalized_result,
|
||||
);
|
||||
assert.deepEqual(
|
||||
evidence.parameters,
|
||||
cli.get(scenario.class_name).parameters,
|
||||
);
|
||||
assert.deepEqual(
|
||||
evidence.provider,
|
||||
cli.get(scenario.class_name).provider,
|
||||
);
|
||||
}
|
||||
await client.ping();
|
||||
} catch (cause) {
|
||||
process.stderr.write(stderr);
|
||||
throw cause;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
assert.equal(
|
||||
await hashAndroidFile(apk),
|
||||
digest,
|
||||
"Static inspection changed fixture bytes",
|
||||
);
|
||||
console.log(
|
||||
"PASS real CLI/MCP static initializer evidence, identity, parity and unchanged APK",
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join, resolve } from "node:path";
|
||||
import { verifyAndroidInitializers } from "./initializers.mjs";
|
||||
|
||||
/** Prove cold metadata stays unmaterialized and overload identities survive code generation. */
|
||||
export const verifyAndroidMetadata = async ({
|
||||
@@ -11,6 +12,8 @@ export const verifyAndroidMetadata = async ({
|
||||
bridge,
|
||||
repository,
|
||||
execute,
|
||||
entrypoint,
|
||||
environment,
|
||||
jvmArguments = [],
|
||||
}) => {
|
||||
const javac =
|
||||
@@ -55,6 +58,8 @@ export const verifyAndroidMetadata = async ({
|
||||
`${classes}${delimiter}${jar}`,
|
||||
"ReaJadxMetadataRegression",
|
||||
apk,
|
||||
resolve(repository, "tests/fixtures/android/static-initializers"),
|
||||
classes,
|
||||
],
|
||||
{ timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
|
||||
);
|
||||
@@ -62,9 +67,23 @@ export const verifyAndroidMetadata = async ({
|
||||
result.stdout,
|
||||
/METADATA_REGRESSION_PASS classes=\d+ overloads=3/u,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u,
|
||||
);
|
||||
console.log(
|
||||
"PASS real cold metadata, full generic types and stable overloaded method selection",
|
||||
);
|
||||
console.log(
|
||||
"PASS real folded and mixed static initializers, absent bodies and bounded smali",
|
||||
);
|
||||
await verifyAndroidInitializers({
|
||||
apk: resolve(classes, "initializers.apk"),
|
||||
repository,
|
||||
entrypoint,
|
||||
environment,
|
||||
execute,
|
||||
});
|
||||
} finally {
|
||||
await rm(classes, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ import { dirname, join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { resolveJadxConfiguration } from "../../../dist/android/JadxConfiguration.js";
|
||||
import {
|
||||
resolveJadxConfigurationInputs,
|
||||
validateJadxConfiguration,
|
||||
} from "../../../dist/android/JadxConfiguration.js";
|
||||
|
||||
/** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */
|
||||
export async function verifyWindowsAndroidLifecycle(options) {
|
||||
@@ -36,7 +39,12 @@ export async function verifyWindowsAndroidLifecycle(options) {
|
||||
}
|
||||
|
||||
async function verifyJavaSelection(environment) {
|
||||
const selected = await resolveJadxConfiguration(
|
||||
const selected = await resolveJadxConfigurationInputs(
|
||||
environment,
|
||||
"inspect_android_package",
|
||||
);
|
||||
await validateJadxConfiguration(
|
||||
selected,
|
||||
environment,
|
||||
"inspect_android_package",
|
||||
);
|
||||
@@ -51,7 +59,12 @@ async function verifyJavaSelection(environment) {
|
||||
// The owned junction borrows the selected JDK directory without copying it.
|
||||
await symlink(dirname(selected.java), alias, "junction");
|
||||
pathEnvironment.Path = `"${alias}"`;
|
||||
const onPath = await resolveJadxConfiguration(
|
||||
const onPath = await resolveJadxConfigurationInputs(
|
||||
pathEnvironment,
|
||||
"inspect_android_package",
|
||||
);
|
||||
await validateJadxConfiguration(
|
||||
onPath,
|
||||
pathEnvironment,
|
||||
"inspect_android_package",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { keyedArchiveResultSchema } from "../../../dist/domain/apple/keyedArchive.js";
|
||||
|
||||
const rowSchema = z.object({
|
||||
container: z.enum(["roots", "objects"]),
|
||||
root: z.string(),
|
||||
objectId: z.number().int().nonnegative().optional(),
|
||||
path: z.array(z.union([z.string(), z.number().int().nonnegative()])),
|
||||
kind: z.enum(["integer", "real"]),
|
||||
literal: z.string(),
|
||||
});
|
||||
const archiveSchema = z.object({
|
||||
filename: z.string(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
rows: z.array(rowSchema),
|
||||
});
|
||||
const oracleSchema = z.object({ xml: archiveSchema, binary: archiveSchema });
|
||||
|
||||
const formats = ["xml", "binary"];
|
||||
const requiredRows = [
|
||||
{ root: "unsafeSigned", kind: "integer", literal: "9007199254740993" },
|
||||
{
|
||||
root: "unsafeNegative",
|
||||
kind: "integer",
|
||||
literal: "-9223372036854775808",
|
||||
},
|
||||
{ root: "safeInteger", kind: "integer", literal: "9007199254740991" },
|
||||
{ root: "safeNegative", kind: "integer", literal: "-42" },
|
||||
{ root: "integralReal", kind: "real", literal: "9007199254740992" },
|
||||
{
|
||||
root: "boxedUnsigned",
|
||||
kind: "integer",
|
||||
literal: "9007199254740993",
|
||||
container: "objects",
|
||||
},
|
||||
{
|
||||
root: "boxedReal",
|
||||
kind: "real",
|
||||
literal: "9007199254740992",
|
||||
container: "objects",
|
||||
},
|
||||
];
|
||||
|
||||
const valueAt = (value, path) => {
|
||||
for (const key of path) {
|
||||
if (value === null || typeof value !== "object") return undefined;
|
||||
value = value[key];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const isUnsafeInteger = (row) =>
|
||||
row.kind === "integer" &&
|
||||
(BigInt(row.literal) > BigInt(Number.MAX_SAFE_INTEGER) ||
|
||||
BigInt(row.literal) < BigInt(Number.MIN_SAFE_INTEGER));
|
||||
|
||||
/** Verify the actual Foundation output and the production CLI/MCP result. */
|
||||
export async function verifyKeyedArchiveIntegerArchives(root) {
|
||||
const oracle = oracleSchema.parse(
|
||||
JSON.parse(await readFile(join(root, "oracle.json"), "utf8")),
|
||||
);
|
||||
const archives = [];
|
||||
|
||||
for (const format of formats) {
|
||||
const archive = oracle[format];
|
||||
for (const required of requiredRows) {
|
||||
assert.ok(
|
||||
archive.rows.some(
|
||||
(row) =>
|
||||
row.root === required.root &&
|
||||
row.kind === required.kind &&
|
||||
row.literal === required.literal &&
|
||||
(required.container === undefined ||
|
||||
row.container === required.container),
|
||||
),
|
||||
`${format} Foundation oracle omitted ${required.root} ${required.literal}`,
|
||||
);
|
||||
}
|
||||
|
||||
const archivePath = join(root, archive.filename);
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(archivePath))
|
||||
.digest("hex");
|
||||
assert.equal(digest, archive.sha256, `${format} archive digest`);
|
||||
archives.push({ format, archive, archivePath, digest });
|
||||
}
|
||||
return archives;
|
||||
}
|
||||
|
||||
/** Check every oracle numeric slot, preserving object-table identity lookups. */
|
||||
export function assertKeyedArchiveIntegerResult(
|
||||
result,
|
||||
format,
|
||||
digest,
|
||||
oracle,
|
||||
) {
|
||||
const parsed = keyedArchiveResultSchema.parse(result);
|
||||
assert.equal(parsed.archive_sha256, digest);
|
||||
assert.equal(parsed.target_sha256, digest);
|
||||
assert.equal(
|
||||
parsed.archive_format,
|
||||
format === "xml" ? "xml-plist" : "binary-plist",
|
||||
);
|
||||
|
||||
for (const row of oracle.rows) {
|
||||
const unsafe = isUnsafeInteger(row);
|
||||
const source =
|
||||
row.container === "roots"
|
||||
? parsed.roots
|
||||
: parsed.objects.find((object) => object.id === row.objectId)?.value;
|
||||
const value = valueAt(source, row.path);
|
||||
assert.notEqual(
|
||||
value,
|
||||
undefined,
|
||||
`${row.root} numeric slot remains present`,
|
||||
);
|
||||
|
||||
if (unsafe) {
|
||||
const exact =
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
value.$plist_type === "integer" &&
|
||||
value.decimal === BigInt(row.literal).toString();
|
||||
const disclosed = parsed.limitations.some(
|
||||
(reason) =>
|
||||
/integer|number/iu.test(reason) &&
|
||||
/precision|exact range|rounded/iu.test(reason),
|
||||
);
|
||||
assert.ok(
|
||||
exact || disclosed,
|
||||
`${row.root} ${row.literal} must be exact or disclose numeric loss`,
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
typeof value,
|
||||
"number",
|
||||
`${row.root} retains its numeric type`,
|
||||
);
|
||||
assert.equal(value, Number(row.literal), `${row.root} numeric value`);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile, spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "../../lib/artifact-e2e.mjs";
|
||||
import {
|
||||
assertKeyedArchiveIntegerResult,
|
||||
verifyKeyedArchiveIntegerArchives,
|
||||
} from "./keyed-archive-integers.mjs";
|
||||
|
||||
if (process.platform !== "darwin")
|
||||
throw new Error(
|
||||
@@ -170,26 +174,37 @@ try {
|
||||
);
|
||||
});
|
||||
}
|
||||
const numericTests = spawnSync(
|
||||
"npm",
|
||||
[
|
||||
"run",
|
||||
"test:focused",
|
||||
"--",
|
||||
"tests/boundary/filesystem/keyedArchiveNativeIntegers.test.ts",
|
||||
],
|
||||
{
|
||||
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
|
||||
stdio: "inherit",
|
||||
timeout: 240_000,
|
||||
},
|
||||
);
|
||||
if (numericTests.error) throw numericTests.error;
|
||||
assert.equal(
|
||||
numericTests.status,
|
||||
0,
|
||||
"Native Foundation integer cases failed",
|
||||
);
|
||||
const integers = join(root, "integers");
|
||||
await mkdir(integers);
|
||||
await promisify(execFile)("/usr/bin/xcrun", [
|
||||
"swift",
|
||||
"-module-cache-path",
|
||||
join(root, "modules"),
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../tests/conformance/native/keyed-archive-integers.swift",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
integers,
|
||||
]);
|
||||
for (const fixture of await verifyKeyedArchiveIntegerArchives(integers)) {
|
||||
const cliResult = assertKeyedArchiveIntegerResult(
|
||||
await artifactCli("inspect-keyed-archive", fixture.archivePath),
|
||||
fixture.format,
|
||||
fixture.digest,
|
||||
fixture.archive,
|
||||
);
|
||||
await withArtifactMcp(fixture.archivePath, async (client) => {
|
||||
const mcpResult = assertKeyedArchiveIntegerResult(
|
||||
await artifactMcpResult(client, "inspect_keyed_archive"),
|
||||
fixture.format,
|
||||
fixture.digest,
|
||||
fixture.archive,
|
||||
);
|
||||
assert.deepEqual(mcpResult, cliResult);
|
||||
});
|
||||
}
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
@@ -197,6 +212,9 @@ try {
|
||||
stdio_mcp: true,
|
||||
xml_golden: true,
|
||||
uid_named_root: true,
|
||||
numeric_formats: ["xml", "binary"],
|
||||
numeric_cli: true,
|
||||
numeric_stdio_mcp: true,
|
||||
format: graph.archive_format,
|
||||
objects: graph.total_objects,
|
||||
references: graph.total_references,
|
||||
|
||||
@@ -42,11 +42,66 @@ try {
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const output = await runHeadless(target, "seed-regression", [
|
||||
"-postScript",
|
||||
"ReaSeedRegressionProbe.java",
|
||||
seeds,
|
||||
]);
|
||||
// Headless can exit zero after a script fails, so require the probe's marker.
|
||||
assert.ok(output.includes("REA_SEED_REGRESSION_OK "), output);
|
||||
|
||||
const analyzer = "Decompiler Switch Analysis";
|
||||
const disabled = await runHeadless(target, "analyzer-regression-valid", [
|
||||
"-preScript",
|
||||
"ReaGhidraAnalysisOptions.java",
|
||||
analyzer,
|
||||
"-postScript",
|
||||
"ReaAnalyzerOptionsRegressionProbe.java",
|
||||
analyzer,
|
||||
]);
|
||||
assert.ok(
|
||||
disabled.includes(`REA_ANALYZER_OPTIONS_DISABLED_OK ${analyzer}`),
|
||||
disabled,
|
||||
);
|
||||
|
||||
const unknownAnalyzer = "REA Unknown Analyzer";
|
||||
const rejected = await runHeadless(target, "analyzer-regression-unknown", [
|
||||
"-preScript",
|
||||
"ReaGhidraAnalysisOptions.java",
|
||||
`${analyzer},${unknownAnalyzer}`,
|
||||
"-postScript",
|
||||
"ReaAnalyzerOptionsRegressionProbe.java",
|
||||
analyzer,
|
||||
]);
|
||||
assert.ok(
|
||||
rejected.includes(
|
||||
`Unknown Ghidra analyzer for this program: ${unknownAnalyzer}`,
|
||||
),
|
||||
rejected,
|
||||
);
|
||||
assert.ok(
|
||||
rejected.includes("Processing aborted as a result of pre-script."),
|
||||
rejected,
|
||||
);
|
||||
assert.ok(!rejected.includes("REA_ANALYZER_OPTIONS_DISABLED_OK"), rejected);
|
||||
} finally {
|
||||
if (supervisor !== undefined) {
|
||||
const stopped = await supervisor.stop();
|
||||
assert.notEqual(stopped.status, "incomplete", JSON.stringify(stopped));
|
||||
supervisor.dispose();
|
||||
}
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
console.log(
|
||||
"Real Ghidra pre-script regression passed: descending function order, out-of-range seed, BSS label, BSS code rejection, analyzer option readback, unknown-analyzer abort before post-scripts, and owned-process cleanup.",
|
||||
);
|
||||
|
||||
async function runHeadless(target, project, scriptArguments) {
|
||||
const launch = await spawnOwnedProviderProcess({
|
||||
command: join(installation, "support", "analyzeHeadless"),
|
||||
arguments: [
|
||||
workspace,
|
||||
"seed-regression",
|
||||
project,
|
||||
"-import",
|
||||
target,
|
||||
"-loader",
|
||||
@@ -63,9 +118,7 @@ try {
|
||||
new URL("../../../tests/conformance/ghidra", import.meta.url),
|
||||
),
|
||||
].join(";"),
|
||||
"-postScript",
|
||||
"ReaSeedRegressionProbe.java",
|
||||
seeds,
|
||||
...scriptArguments,
|
||||
],
|
||||
runId: run.run_id,
|
||||
expectedCommand: null,
|
||||
@@ -82,16 +135,9 @@ try {
|
||||
const snapshot = supervisor.snapshot();
|
||||
const output = `${snapshot.stdout.text}\n${snapshot.stderr.text}`;
|
||||
assert.equal(snapshot.exitCode, 0, output);
|
||||
// Headless can exit zero after a script fails, so require the probe's marker.
|
||||
assert.ok(output.includes("REA_SEED_REGRESSION_OK "), output);
|
||||
} finally {
|
||||
if (supervisor !== undefined) {
|
||||
const stopped = await supervisor.stop();
|
||||
assert.notEqual(stopped.status, "incomplete", JSON.stringify(stopped));
|
||||
supervisor.dispose();
|
||||
}
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
const stopped = await supervisor.stop();
|
||||
assert.notEqual(stopped.status, "incomplete", JSON.stringify(stopped));
|
||||
supervisor.dispose();
|
||||
supervisor = undefined;
|
||||
return output;
|
||||
}
|
||||
console.log(
|
||||
"Real Ghidra seed regression passed: descending function order, out-of-range seed, BSS label, BSS code rejection, and owned-process cleanup.",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { adbHostFileName } from "./AdbHostFileName.js";
|
||||
|
||||
describe("adbHostFileName", () => {
|
||||
it.each([
|
||||
["/data/local/tmp/notes.txt", "notes.txt"],
|
||||
["/data/data/com.example/databases/app.db", "app.db"],
|
||||
["/sdcard/My Notes.txt", "My Notes.txt"],
|
||||
["/system/etc/hosts", "hosts"],
|
||||
[
|
||||
"/data/local/tmp/split_config.arm64_v8a.apk",
|
||||
"split_config.arm64_v8a.apk",
|
||||
],
|
||||
["/data/local/tmp/résumé.xml", "résumé.xml"],
|
||||
["/data/local/tmp/a-b_c.d", "a-b_c.d"],
|
||||
["/data/local/tmp/", "tmp"],
|
||||
])("accepts the portable device path %s", (devicePath, fileName) => {
|
||||
expect(adbHostFileName(devicePath)).toEqual({ ok: true, fileName });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/", "empty_file_name"],
|
||||
["/data/local/tmp/..", "dot_segment"],
|
||||
["/data/local/tmp/.", "dot_segment"],
|
||||
["/data/local/tmp/a\\b", "path_separator"],
|
||||
["/data/local/tmp/re\u007fport", "control_character"],
|
||||
["/data/local/tmp/ta\tb", "control_character"],
|
||||
["/data/local/tmp/CON", "reserved_name"],
|
||||
["/data/local/tmp/nul.txt", "reserved_name"],
|
||||
["/data/local/tmp/com1", "reserved_name"],
|
||||
["/data/local/tmp/LPT9", "reserved_name"],
|
||||
["/data/local/tmp/report:2024", "alternate_data_stream"],
|
||||
["/data/local/tmp/report.", "trailing_dot_or_space"],
|
||||
["/data/local/tmp/report ", "trailing_dot_or_space"],
|
||||
["/data/local/tmp/a*b.txt", "unsupported_character"],
|
||||
["/data/local/tmp/a?b.txt", "unsupported_character"],
|
||||
] as const)("refuses %s as %s", (devicePath, reason) => {
|
||||
expect(adbHostFileName(devicePath)).toEqual({ ok: false, reason });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { posix } from "node:path";
|
||||
|
||||
/** Why a device-reported path cannot be used as a host file name. */
|
||||
export type AdbHostFileNameReason =
|
||||
| "empty_file_name"
|
||||
| "dot_segment"
|
||||
| "path_separator"
|
||||
| "control_character"
|
||||
| "unsupported_character"
|
||||
| "reserved_name"
|
||||
| "trailing_dot_or_space"
|
||||
| "alternate_data_stream";
|
||||
|
||||
/** A portable host file name, or the exact reason the device name was refused. */
|
||||
export type AdbHostFileNameResult =
|
||||
| { readonly ok: true; readonly fileName: string }
|
||||
| { readonly ok: false; readonly reason: AdbHostFileNameReason };
|
||||
|
||||
/**
|
||||
* Win32 resolves these basenames to a device regardless of case and of any
|
||||
* extension (`NUL`, `nul.txt`), so a portable host file name must avoid them on
|
||||
* every host. `(?:\.|$)` keeps ordinary names such as `console.txt` admissible.
|
||||
*/
|
||||
const WINDOWS_RESERVED_BASENAME =
|
||||
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu;
|
||||
|
||||
/** Characters that are not portable in a host file name on Windows or POSIX. */
|
||||
const UNSUPPORTED_CHARACTER = /[*?"<>|]/u;
|
||||
|
||||
/** Control characters (`Cc`), including C0 controls and DEL. */
|
||||
const CONTROL_CHARACTER = /\p{Cc}/u;
|
||||
|
||||
/**
|
||||
* Derive a portable host file name from a device-reported path.
|
||||
*
|
||||
* The device path is a POSIX path on the device filesystem, so its last
|
||||
* `/`-separated segment is taken with POSIX semantics rather than the host
|
||||
* `basename` (which would vary between POSIX and Windows hosts). A name is
|
||||
* accepted only when every host can store it and address it back unambiguously:
|
||||
* no separator, control, or reserved-device characters, no trailing dot or
|
||||
* space, no `:` alternate-data-stream marker, and no `.`/`..` segment.
|
||||
*/
|
||||
export const adbHostFileName = (devicePath: string): AdbHostFileNameResult => {
|
||||
const fileName = posix.basename(devicePath);
|
||||
if (fileName === "") return { ok: false, reason: "empty_file_name" };
|
||||
if (fileName === "." || fileName === "..")
|
||||
return { ok: false, reason: "dot_segment" };
|
||||
if (fileName.includes("\\") || fileName.includes("/"))
|
||||
return { ok: false, reason: "path_separator" };
|
||||
if (CONTROL_CHARACTER.test(fileName))
|
||||
return { ok: false, reason: "control_character" };
|
||||
if (fileName.includes(":"))
|
||||
return { ok: false, reason: "alternate_data_stream" };
|
||||
if (UNSUPPORTED_CHARACTER.test(fileName))
|
||||
return { ok: false, reason: "unsupported_character" };
|
||||
if (WINDOWS_RESERVED_BASENAME.test(fileName))
|
||||
return { ok: false, reason: "reserved_name" };
|
||||
if (/[. ]$/u.test(fileName))
|
||||
return { ok: false, reason: "trailing_dot_or_space" };
|
||||
return { ok: true, fileName };
|
||||
};
|
||||
@@ -336,12 +336,38 @@ it("reports a missing selected binary as a capability gap with recovery", async
|
||||
if (result.ok) return;
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected).toMatchObject({ code: "capability_unavailable" });
|
||||
expect(projected.message).toContain("REA_ADB_PATH");
|
||||
expect(projected.remediation).toMatchObject({
|
||||
action: expect.stringContaining("REA_ADB_PATH") as unknown,
|
||||
});
|
||||
expect(projected.details).toMatchObject({
|
||||
provider_id: "adb",
|
||||
reason: expect.stringContaining("not found") as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports relative adb configuration as an actionable refusal", async () => {
|
||||
const provider = new AdbProvider({
|
||||
environment: { REA_ADB_PATH: "relative/adb" },
|
||||
});
|
||||
const availability = await provider.inspectAvailability();
|
||||
expect(availability).toMatchObject({
|
||||
status: "unavailable",
|
||||
reason: expect.stringContaining("REA_ADB_PATH") as unknown,
|
||||
});
|
||||
const projected = await refusedBy(provider, {
|
||||
operation: "list_adb_devices",
|
||||
input: {},
|
||||
});
|
||||
expect(projected).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
message: expect.stringContaining("REA_ADB_PATH") as unknown,
|
||||
remediation: {
|
||||
action: expect.stringContaining("REA_ADB_PATH") as unknown,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels before spawning anything", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
(root) => `: > ${join(root, "spawned")}
|
||||
@@ -452,6 +478,48 @@ exit 1`,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/data/local/tmp/..", "dot_segment"],
|
||||
["/data/local/tmp/a\\b", "path_separator"],
|
||||
["/data/local/tmp/CON", "reserved_name"],
|
||||
["/data/local/tmp/report.", "trailing_dot_or_space"],
|
||||
["/data/local/tmp/report:2024", "alternate_data_stream"],
|
||||
] as const)(
|
||||
"refuses the unsafe device path %s before pulling",
|
||||
async (devicePath, reason) => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
: > "${join(root, "pull-ran")}"
|
||||
exit 0`,
|
||||
);
|
||||
const output = join(root, "pulls");
|
||||
const result = await providerOver(binary).execute({
|
||||
operation: "pull_adb_file",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
device_path: devicePath,
|
||||
output_directory: output,
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected).toMatchObject({ code: "invalid_request" });
|
||||
expect(projected.details).toMatchObject({
|
||||
issues: [
|
||||
{
|
||||
path: ["device_path"],
|
||||
reason: "invalid_value",
|
||||
message: expect.stringContaining(reason),
|
||||
},
|
||||
],
|
||||
});
|
||||
// No host directory and no adb invocation are effects of a refused pull.
|
||||
await expect(readFile(join(root, "pull-ran"))).rejects.toThrow();
|
||||
await expect(readFile(output)).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("pushes a file and verifies the device-side digest", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
|
||||
+20
-11
@@ -2,12 +2,13 @@ import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { access, lstat, mkdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderAvailability,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import { providerConfigurationRefusal } from "../domain/providerConfigurationRefusal.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisExecution,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js";
|
||||
import {
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
parsePackageListOutput,
|
||||
parsePmPathOutput,
|
||||
} from "./AdbDeviceOutput.js";
|
||||
import { adbHostFileName } from "./AdbHostFileName.js";
|
||||
import {
|
||||
parseAmStartOutput,
|
||||
parseBugreportCopiedPath,
|
||||
@@ -417,8 +418,8 @@ export class AdbProvider {
|
||||
async inspectAvailability(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProviderAvailability> {
|
||||
const selection = this.selection();
|
||||
try {
|
||||
const selection = this.selection();
|
||||
await inspectAdbClient(selection, signal);
|
||||
return {
|
||||
status: "available",
|
||||
@@ -555,21 +556,19 @@ export class AdbProvider {
|
||||
const signal = options?.signal;
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
const selection = this.selection();
|
||||
let selection: AdbBinarySelection;
|
||||
let identity: AdbClientIdentity;
|
||||
try {
|
||||
selection = this.selection();
|
||||
identity = await inspectAdbClient(selection, signal);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||
if (cause instanceof AdbConfigurationFailure)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
providerConfigurationRefusal(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
request.operation,
|
||||
typeof cause.diagnostics.remediation === "string"
|
||||
? `${cause.message} ${cause.diagnostics.remediation}`
|
||||
: cause.message,
|
||||
{ cause },
|
||||
cause,
|
||||
),
|
||||
);
|
||||
return err(
|
||||
@@ -1401,13 +1400,23 @@ export class AdbProvider {
|
||||
},
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
const derived = adbHostFileName(input.device_path);
|
||||
if (!derived.ok)
|
||||
return err(
|
||||
new AnalysisInputError(operation, undefined, [
|
||||
{
|
||||
path: ["device_path"],
|
||||
reason: "invalid_value",
|
||||
message: `The device path does not name a portable host file (${derived.reason}); REA never renames a device-reported file.`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const directory = await prepareOutputDirectory(
|
||||
operation,
|
||||
input.output_directory,
|
||||
);
|
||||
if (!directory.ok) return directory;
|
||||
const fileName = basename(input.device_path) || "pulled";
|
||||
const localPath = join(directory.value, fileName);
|
||||
const localPath = join(directory.value, derived.fileName);
|
||||
const existing = await refuseExistingTarget(operation, localPath);
|
||||
if (existing !== undefined) return err(existing);
|
||||
const pulled = await runAdb(
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
ExecutionOptions,
|
||||
ProviderAvailability,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import { providerConfigurationRefusal } from "../domain/providerConfigurationRefusal.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisExecution,
|
||||
@@ -153,12 +154,13 @@ const runApktool = async (
|
||||
invocation = apktoolInvocation(selection, arguments_, hostEnvironment);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof WindowsBatchCommandError)) throw cause;
|
||||
const reason = `The apktool batch launcher cannot receive REA's decode paths through cmd.exe: ${cause.message}`;
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
APKTOOL_PROVIDER_IDENTITY.id,
|
||||
operation,
|
||||
`The apktool batch launcher cannot receive REA's decode paths through cmd.exe: ${cause.message}`,
|
||||
{ cause },
|
||||
reason,
|
||||
{ cause, userMessage: reason },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -345,8 +347,8 @@ export class ApktoolProvider {
|
||||
async #inspectAvailability(
|
||||
signal: AbortSignal,
|
||||
): Promise<ProviderAvailability> {
|
||||
const selection = this.selection();
|
||||
try {
|
||||
const selection = this.selection();
|
||||
await inspectApktoolClient(selection, signal, this.environment);
|
||||
throwIfCancelled("inspect_apktool_client", signal);
|
||||
return {
|
||||
@@ -397,9 +399,10 @@ export class ApktoolProvider {
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
const selection = this.selection();
|
||||
let selection: ApktoolCommandSelection;
|
||||
let identity: ApktoolClientIdentity;
|
||||
try {
|
||||
selection = this.selection();
|
||||
identity = await inspectApktoolClient(
|
||||
selection,
|
||||
signal,
|
||||
@@ -407,17 +410,15 @@ export class ApktoolProvider {
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||
if (cause instanceof ApktoolConfigurationFailure)
|
||||
if (cause instanceof ApktoolConfigurationFailure) {
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
providerConfigurationRefusal(
|
||||
APKTOOL_PROVIDER_IDENTITY.id,
|
||||
request.operation,
|
||||
typeof cause.diagnostics.remediation === "string"
|
||||
? `${cause.message} ${cause.diagnostics.remediation}`
|
||||
: cause.message,
|
||||
{ cause },
|
||||
cause,
|
||||
),
|
||||
);
|
||||
}
|
||||
return err(
|
||||
new AnalysisProtocolError(
|
||||
`apktool version probe failed for ${request.operation}`,
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
validateDosComLength,
|
||||
type ExecutableFormatHint,
|
||||
} from "../domain/dosCom.js";
|
||||
import { admitRawBinary } from "../domain/rawBinary.js";
|
||||
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
|
||||
import { mzWindowsHeaderOffset } from "../domain/dosMz.js";
|
||||
import {
|
||||
hasZipSignature,
|
||||
@@ -45,6 +47,8 @@ export interface BinaryTargetResolverOptions {
|
||||
readonly hostArchitecture?: NodeJS.Architecture;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
readonly formatHint?: ExecutableFormatHint;
|
||||
readonly architecture?: BinaryArchitecture;
|
||||
readonly entryPoint?: string;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
@@ -61,7 +65,7 @@ export const parseBinaryTarget = async (
|
||||
> => {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const hostArchitecture = options.hostArchitecture ?? process.arch;
|
||||
const { targetKind, formatHint, signal } = options;
|
||||
const { targetKind, formatHint, signal, architecture, entryPoint } = options;
|
||||
const candidate = isAbsolute(input) ? input : resolve(cwd, input);
|
||||
try {
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
@@ -83,6 +87,40 @@ export const parseBinaryTarget = async (
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const metadata = await handle.stat();
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (formatHint === "raw-binary") {
|
||||
if (targetKind !== undefined && targetKind !== "executable")
|
||||
return err(
|
||||
new BinaryTargetError(
|
||||
path,
|
||||
"raw-binary format requires an executable target kind",
|
||||
),
|
||||
);
|
||||
const admitted = admitRawBinary({
|
||||
architecture,
|
||||
entryPoint,
|
||||
fileBytes: metadata.size,
|
||||
});
|
||||
if (!admitted.ok)
|
||||
return err(new BinaryTargetError(path, admitted.error));
|
||||
return ok({
|
||||
path,
|
||||
sourcePath: canonical,
|
||||
sha256: await sha256Handle(handle, path, metadata, signal),
|
||||
kind: "executable",
|
||||
format: "raw-binary",
|
||||
architecture: admitted.value.architecture,
|
||||
availableArchitectures: [admitted.value.architecture],
|
||||
entryPoint: admitted.value.entryPoint,
|
||||
entryPointSource: admitted.value.entryPointSource,
|
||||
});
|
||||
}
|
||||
if (architecture !== undefined || entryPoint !== undefined)
|
||||
return err(
|
||||
new BinaryTargetError(
|
||||
path,
|
||||
"architecture and entry_point apply only with format=raw-binary",
|
||||
),
|
||||
);
|
||||
if (formatHint === "dos-com") {
|
||||
if (targetKind !== undefined && targetKind !== "executable")
|
||||
return err(
|
||||
@@ -194,7 +232,13 @@ const detectArtifactFormat = async (
|
||||
): Promise<
|
||||
| Exclude<
|
||||
BinaryTarget["format"],
|
||||
"analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz" | "dos-com"
|
||||
| "analysis-database"
|
||||
| "mach-o"
|
||||
| "elf"
|
||||
| "pe"
|
||||
| "dos-mz"
|
||||
| "dos-com"
|
||||
| "raw-binary"
|
||||
>
|
||||
| undefined
|
||||
> => {
|
||||
@@ -244,7 +288,13 @@ const namedArtifactFormat = (
|
||||
const isArchiveFormat = (
|
||||
format: Exclude<
|
||||
BinaryTarget["format"],
|
||||
"analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz" | "dos-com"
|
||||
| "analysis-database"
|
||||
| "mach-o"
|
||||
| "elf"
|
||||
| "pe"
|
||||
| "dos-mz"
|
||||
| "dos-com"
|
||||
| "raw-binary"
|
||||
>,
|
||||
): format is Extract<BinaryTarget, { kind: "archive" }>["format"] =>
|
||||
isZipPackageFormat(format) || ["asar", "dmg", "pkg"].includes(format);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperation,
|
||||
ExecutionOptions,
|
||||
} from "./AnalysisProvider.js";
|
||||
@@ -17,6 +18,13 @@ type AnalysisCall = (
|
||||
selectorOrigin?: ExecutionOptions["selectorOrigin"],
|
||||
) => Promise<Result<JsonValue, AnalysisError>>;
|
||||
|
||||
type RelationshipCall = (
|
||||
name: AnalysisOperation,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
selectorOrigin?: ExecutionOptions["selectorOrigin"],
|
||||
) => Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
|
||||
export interface CallPathTraceInput {
|
||||
readonly start: string;
|
||||
readonly goal?: string | undefined;
|
||||
@@ -42,6 +50,7 @@ interface TraceState {
|
||||
readonly error: ReturnType<typeof projectAnalysisError>;
|
||||
}>;
|
||||
readonly residual: Set<string>;
|
||||
readonly limitations: Set<string>;
|
||||
queueIndex: number;
|
||||
traversalPath: string[];
|
||||
}
|
||||
@@ -63,6 +72,7 @@ interface DiscoveredEdge {
|
||||
/** Trace direct caller/callee relationships until the graph is exhausted or the goal is reached. */
|
||||
export const traceCallPath = async (
|
||||
call: AnalysisCall,
|
||||
callRelationship: RelationshipCall,
|
||||
input: CallPathTraceInput,
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult => {
|
||||
@@ -88,7 +98,7 @@ export const traceCallPath = async (
|
||||
if (address === undefined) break;
|
||||
const current = state.visited.get(address);
|
||||
if (current === undefined) continue;
|
||||
const found = await expandAddress(call, {
|
||||
const found = await expandAddress(callRelationship, {
|
||||
input: resolvedInput,
|
||||
state,
|
||||
address,
|
||||
@@ -106,12 +116,13 @@ const createTraceState = (input: CallPathTraceInput): TraceState => ({
|
||||
edges: new Map(),
|
||||
failures: [],
|
||||
residual: new Set(),
|
||||
limitations: new Set(),
|
||||
queueIndex: 0,
|
||||
traversalPath: input.goal === input.start ? [input.start] : [],
|
||||
});
|
||||
|
||||
const expandAddress = async (
|
||||
call: AnalysisCall,
|
||||
call: RelationshipCall,
|
||||
expansion: AddressExpansion,
|
||||
): Promise<boolean> => {
|
||||
const { input, state, address, current, signal } = expansion;
|
||||
@@ -127,7 +138,7 @@ const expandAddress = async (
|
||||
state.residual.add(`Call relationships were unavailable for ${address}.`);
|
||||
return false;
|
||||
}
|
||||
const related = parseRelatedAddresses(result.value, relation);
|
||||
const related = parseRelatedAddresses(result.value.result, relation);
|
||||
if (!related.ok) {
|
||||
state.failures.push({
|
||||
address,
|
||||
@@ -136,6 +147,12 @@ const expandAddress = async (
|
||||
state.residual.add(`Call relationships were unreadable for ${address}.`);
|
||||
return false;
|
||||
}
|
||||
for (const limitation of result.value.limitations) {
|
||||
state.limitations.add(limitation);
|
||||
state.residual.add(
|
||||
`Call relationships for ${address} are limited: ${limitation}`,
|
||||
);
|
||||
}
|
||||
for (const relatedAddress of sortedUniqueAddresses(related.value)) {
|
||||
recordEdge(input, state, {
|
||||
address,
|
||||
@@ -215,6 +232,7 @@ const projectTrace = (input: CallPathTraceInput, state: TraceState) => ({
|
||||
residual_unknowns: [...state.residual],
|
||||
limitations: [
|
||||
"Direct provider call relationships may omit unresolved indirect calls.",
|
||||
...state.limitations,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
clientRequirementsFor,
|
||||
NO_CLIENT_FEATURES,
|
||||
} from "./CapabilityClientRequirements.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
const statusSchema = z.object({
|
||||
open: z.boolean(),
|
||||
@@ -210,7 +211,7 @@ export const buildCapabilityInventory = (
|
||||
reason: availability.reason,
|
||||
remediation: availability.remediation,
|
||||
};
|
||||
}).sort((left, right) => left.name.localeCompare(right.name));
|
||||
}).sort((left, right) => compareUnicodeCodePoints(left.name, right.name));
|
||||
};
|
||||
|
||||
const availabilityFor = (context: AvailabilityContext): Availability => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { z } from "zod";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
export type ClientConfigurationFormat = NonNullable<SetupClient["format"]>;
|
||||
export type ClientServersKey = "mcp_servers" | "mcpServers" | "mcp" | "servers";
|
||||
@@ -63,7 +64,7 @@ const normalizeConfigurationValue = (value: unknown): unknown => {
|
||||
return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
|
||||
.map(([key, nested]) => [key, normalizeConfigurationValue(nested)]),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Read explicit REA overrides without dropping peers when one value is invalid. */
|
||||
export const clientRegistrationEnvironment = (
|
||||
@@ -47,7 +48,7 @@ export const clientRegistrationEnvironment = (
|
||||
return ok(
|
||||
Object.fromEntries(
|
||||
Object.entries(environment).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
compareUnicodeCodePoints(left, right),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
supportedClients,
|
||||
} from "./SupportedClients.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
interface ClientRegistrationStatusBase {
|
||||
readonly client: string;
|
||||
@@ -184,7 +185,7 @@ export const readClientRegistrationStatuses = async (
|
||||
}
|
||||
}
|
||||
return statuses.sort((left, right) =>
|
||||
left.client.localeCompare(right.client),
|
||||
compareUnicodeCodePoints(left.client, right.client),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExecutableFormatHint } from "../domain/dosCom.js";
|
||||
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js";
|
||||
import type { BinarySession } from "./binary/BinarySession.js";
|
||||
@@ -91,6 +92,8 @@ export const runDirectAnalysis = async (
|
||||
readonly signal?: AbortSignal;
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
readonly formatHint?: ExecutableFormatHint;
|
||||
readonly architecture?: BinaryArchitecture;
|
||||
readonly entryPoint?: string;
|
||||
} = {},
|
||||
): Promise<JsonValue> =>
|
||||
withProcessCancellation(options.signal, (signal) =>
|
||||
@@ -101,6 +104,12 @@ export const runDirectAnalysis = async (
|
||||
...(options.formatHint === undefined
|
||||
? {}
|
||||
: { formatHint: options.formatHint }),
|
||||
...(options.architecture === undefined
|
||||
? {}
|
||||
: { architecture: options.architecture }),
|
||||
...(options.entryPoint === undefined
|
||||
? {}
|
||||
: { entryPoint: options.entryPoint }),
|
||||
...(options.providerId === undefined
|
||||
? {}
|
||||
: { providerId: options.providerId }),
|
||||
@@ -187,6 +196,8 @@ const runAnalysis = async (
|
||||
readonly signal: AbortSignal;
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
readonly formatHint?: ExecutableFormatHint;
|
||||
readonly architecture?: BinaryArchitecture;
|
||||
readonly entryPoint?: string;
|
||||
},
|
||||
): Promise<JsonValue> => {
|
||||
const { logger, signal, snapshotPath } = options;
|
||||
@@ -203,6 +214,12 @@ const runAnalysis = async (
|
||||
...(options.formatHint === undefined
|
||||
? {}
|
||||
: { formatHint: options.formatHint }),
|
||||
...(options.architecture === undefined
|
||||
? {}
|
||||
: { architecture: options.architecture }),
|
||||
...(options.entryPoint === undefined
|
||||
? {}
|
||||
: { entryPoint: options.entryPoint }),
|
||||
});
|
||||
if (!prepared.ok) return cliError(prepared.error);
|
||||
const { snapshot } = prepared.value;
|
||||
@@ -214,6 +231,12 @@ const runAnalysis = async (
|
||||
...(options.formatHint === undefined
|
||||
? {}
|
||||
: { formatHint: options.formatHint }),
|
||||
...(options.architecture === undefined
|
||||
? {}
|
||||
: { architecture: options.architecture }),
|
||||
...(options.entryPoint === undefined
|
||||
? {}
|
||||
: { entryPoint: options.entryPoint }),
|
||||
...(options.providerId === undefined
|
||||
? {}
|
||||
: { providerId: options.providerId }),
|
||||
@@ -246,6 +269,12 @@ const runAnalysis = async (
|
||||
...(options.formatHint === undefined
|
||||
? {}
|
||||
: { formatHint: options.formatHint }),
|
||||
...(options.architecture === undefined
|
||||
? {}
|
||||
: { architecture: options.architecture }),
|
||||
...(options.entryPoint === undefined
|
||||
? {}
|
||||
: { entryPoint: options.entryPoint }),
|
||||
...(snapshot === undefined ? {} : { snapshot }),
|
||||
...(options.providerId === undefined
|
||||
? {}
|
||||
@@ -440,6 +469,8 @@ const withProcessCancellation = async <Value>(
|
||||
const prepareSnapshot = async (options: {
|
||||
readonly path: string;
|
||||
readonly formatHint?: ExecutableFormatHint;
|
||||
readonly architecture?: BinaryArchitecture;
|
||||
readonly entryPoint?: string;
|
||||
readonly snapshotPath: string | undefined;
|
||||
readonly signal: AbortSignal;
|
||||
}): Promise<
|
||||
@@ -458,6 +489,12 @@ const prepareSnapshot = async (options: {
|
||||
...(options.formatHint === undefined
|
||||
? {}
|
||||
: { formatHint: options.formatHint }),
|
||||
...(options.architecture === undefined
|
||||
? {}
|
||||
: { architecture: options.architecture }),
|
||||
...(options.entryPoint === undefined
|
||||
? {}
|
||||
: { entryPoint: options.entryPoint }),
|
||||
});
|
||||
if (!target.ok) return target;
|
||||
if (!snapshotMatchesTarget(loaded.value.target, target.value))
|
||||
|
||||
@@ -281,6 +281,42 @@ describe("doctor installation identity", () => {
|
||||
});
|
||||
|
||||
describe("doctor scoped readiness", () => {
|
||||
it("checks explicitly selected skill destinations independently of registrations", async () => {
|
||||
const selected: (readonly string[] | undefined)[] = [];
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
clientRegistrations: () => Promise.resolve([]),
|
||||
installedSkillIdentity: (clients) => {
|
||||
selected.push(clients);
|
||||
return Promise.resolve({
|
||||
version: "stale",
|
||||
toolCount: 0,
|
||||
canonical: false,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
clients: [],
|
||||
skillClients: ["claude_code", "claude_code"],
|
||||
skill: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(selected).toEqual([["claude_code"]]);
|
||||
expect(result.scope).toMatchObject({
|
||||
mode: "explicit",
|
||||
clients: [],
|
||||
skill_clients: ["claude_code"],
|
||||
skill: true,
|
||||
});
|
||||
expect(result.healthy).toBe(false);
|
||||
expect(result.scope_checks.map(({ name }) => name)).toEqual([
|
||||
"node",
|
||||
"skill:identity",
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes effective readiness to selected clients without hiding drift", async () => {
|
||||
const registrations: readonly ClientRegistrationStatus[] = [
|
||||
{
|
||||
|
||||
@@ -120,6 +120,7 @@ export interface DoctorReport {
|
||||
/** Caller-selected readiness boundary; omitted values retain audit semantics. */
|
||||
export interface DoctorScope {
|
||||
readonly clients?: readonly string[];
|
||||
readonly skillClients?: readonly string[];
|
||||
readonly providers?: readonly string[];
|
||||
readonly skill?: boolean;
|
||||
}
|
||||
@@ -206,9 +207,10 @@ const collectDoctorIdentity = async (
|
||||
const installationPaths = (await host.installationPaths?.()) ?? [];
|
||||
const registrations = (await host.clientRegistrations?.()) ?? [];
|
||||
const skillClientIds =
|
||||
scope.mode === "audit-wide"
|
||||
scope.skill_clients ??
|
||||
(scope.mode === "audit-wide"
|
||||
? registrations.map(({ client }) => client)
|
||||
: scope.clients;
|
||||
: scope.clients);
|
||||
const installedSkillIdentity =
|
||||
await host.installedSkillIdentity?.(skillClientIds);
|
||||
const skillAligned = skillIdentityAligned(installedSkillIdentity);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
export interface DoctorScopeReport {
|
||||
readonly mode: "audit-wide" | "explicit";
|
||||
readonly clients: readonly string[];
|
||||
readonly skill_clients?: readonly string[];
|
||||
readonly providers: readonly string[];
|
||||
readonly skill: boolean;
|
||||
readonly target: string | null;
|
||||
@@ -40,6 +41,9 @@ export const normalizeDoctorScope = (
|
||||
): DoctorScopeReport => ({
|
||||
mode: requested === undefined ? "audit-wide" : "explicit",
|
||||
clients: uniqueSorted(requested?.clients ?? []),
|
||||
...(requested?.skillClients === undefined
|
||||
? {}
|
||||
: { skill_clients: uniqueSorted(requested.skillClients) }),
|
||||
providers: uniqueSorted(requested?.providers ?? []),
|
||||
skill: requested?.skill === true,
|
||||
target: target ?? null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperation,
|
||||
AnalysisOperationPort,
|
||||
ExecutionOptions,
|
||||
@@ -166,6 +167,8 @@ export class EnhancedTools {
|
||||
return traceCallPath(
|
||||
(name, arguments_, operationSignal, selectorOrigin) =>
|
||||
this.#call(name, arguments_, operationSignal, selectorOrigin),
|
||||
(name, arguments_, operationSignal, selectorOrigin) =>
|
||||
this.#observe(name, arguments_, operationSignal, selectorOrigin),
|
||||
call.input,
|
||||
signal,
|
||||
);
|
||||
@@ -359,7 +362,7 @@ export class EnhancedTools {
|
||||
const level = String(current.depth);
|
||||
graph[level] ??= [];
|
||||
|
||||
const result = await this.#call(
|
||||
const result = await this.#observe(
|
||||
tool,
|
||||
{ procedure: current.address },
|
||||
signal,
|
||||
@@ -373,7 +376,7 @@ export class EnhancedTools {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const related = parseRelatedAddresses(result.value, relation);
|
||||
const related = parseRelatedAddresses(result.value.result, relation);
|
||||
if (!related.ok) {
|
||||
graph[level].push({
|
||||
address: current.address,
|
||||
@@ -386,6 +389,9 @@ export class EnhancedTools {
|
||||
address: current.address,
|
||||
status: "ok",
|
||||
calls: [...related.value],
|
||||
...(result.value.limitations.length === 0
|
||||
? {}
|
||||
: { limitations: [...result.value.limitations] }),
|
||||
});
|
||||
for (const address of related.value) {
|
||||
if (!discovered.has(address)) {
|
||||
@@ -523,13 +529,28 @@ export class EnhancedTools {
|
||||
signal?: AbortSignal,
|
||||
selectorOrigin?: ExecutionOptions["selectorOrigin"],
|
||||
): Promise<Result<JsonValue, AnalysisError>> {
|
||||
const execution = await this.#observe(
|
||||
name,
|
||||
arguments_,
|
||||
signal,
|
||||
selectorOrigin,
|
||||
);
|
||||
return execution.ok ? ok(execution.value.result) : execution;
|
||||
}
|
||||
|
||||
async #observe(
|
||||
name: AnalysisOperation,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
selectorOrigin?: ExecutionOptions["selectorOrigin"],
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
if (isAborted(signal)) return err(new AnalysisCancelledError(name));
|
||||
const execution = await this.analysis.execute(name, arguments_, {
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
...(selectorOrigin === undefined ? {} : { selectorOrigin }),
|
||||
});
|
||||
if (isAborted(signal)) return err(new AnalysisCancelledError(name));
|
||||
return execution.ok ? ok(execution.value.result) : execution;
|
||||
return execution;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
semanticObligationPolicy,
|
||||
type ReconstructionObligationCandidatePolicy,
|
||||
} from "./ReconstructionObligationPolicies.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type CandidateFields = Omit<
|
||||
ReconstructionObligation,
|
||||
@@ -54,7 +55,7 @@ export const deriveReconstructionObligationCandidates = (
|
||||
addCandidate(candidates, reviewedCandidate(obligation, records));
|
||||
return {
|
||||
candidates: [...candidates.values()].sort((left, right) =>
|
||||
left.obligation_id.localeCompare(right.obligation_id),
|
||||
compareUnicodeCodePoints(left.obligation_id, right.obligation_id),
|
||||
),
|
||||
limitations: [...limitations].sort(),
|
||||
};
|
||||
@@ -430,7 +431,7 @@ const uniqueBy = <Value>(
|
||||
key: (value: Value) => string,
|
||||
): Value[] =>
|
||||
[...new Map(values.map((value) => [key(value), value])).values()].sort(
|
||||
(left, right) => key(left).localeCompare(key(right)),
|
||||
(left, right) => compareUnicodeCodePoints(key(left), key(right)),
|
||||
);
|
||||
|
||||
const obligationId = (key: string): string =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ReconstructionObligationManifest,
|
||||
} from "../domain/reconstructionObligationLedgerSchemas.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type Diagnostic = ReconstructionObligation["diagnostics"][number];
|
||||
type Binding = NonNullable<ReconstructionObligation["binding"]>;
|
||||
@@ -50,7 +51,8 @@ export const uniqueObligationDiagnostics = (
|
||||
]),
|
||||
).values(),
|
||||
].sort((left, right) =>
|
||||
`${left.code}:${left.detail}`.localeCompare(
|
||||
compareUnicodeCodePoints(
|
||||
`${left.code}:${left.detail}`,
|
||||
`${right.code}:${right.detail}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
obligationReports,
|
||||
summarizeObligations,
|
||||
} from "./ReconstructionObligationLedgerProjection.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type Diagnostic = ReconstructionObligation["diagnostics"][number];
|
||||
type Binding = NonNullable<ReconstructionObligation["binding"]>;
|
||||
@@ -60,7 +61,7 @@ export const evaluateReconstructionObligationLedger = ({
|
||||
}): ReconstructionObligationLedger => {
|
||||
const bundle = parseEvidenceBundle(bundleInput);
|
||||
const allCandidates = [...candidatesInput].sort((left, right) =>
|
||||
left.obligation_id.localeCompare(right.obligation_id),
|
||||
compareUnicodeCodePoints(left.obligation_id, right.obligation_id),
|
||||
);
|
||||
const orphanManifestIds = findOrphanManifestIds(allCandidates, manifest);
|
||||
const candidates = allCandidates;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Evidence } from "../domain/evidence.js";
|
||||
import type { ReconstructionObligation } from "../domain/reconstructionObligationLedgerSchemas.js";
|
||||
import { evidenceSupportsOriginalAuthority } from "./ReconstructionObligationAuthority.js";
|
||||
import type { ReconstructionObligationCandidate } from "./ReconstructionObligationCandidates.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
type Binding = NonNullable<ReconstructionObligation["binding"]>;
|
||||
type Diagnostic = ReconstructionObligation["diagnostics"][number];
|
||||
@@ -21,7 +22,8 @@ export const originalCasesFor = (
|
||||
),
|
||||
).values(),
|
||||
].sort((left, right) =>
|
||||
`${left.kind}:${left.evidence_id}:${left.location}`.localeCompare(
|
||||
compareUnicodeCodePoints(
|
||||
`${left.kind}:${left.evidence_id}:${left.location}`,
|
||||
`${right.kind}:${right.evidence_id}:${right.location}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ReferenceSourceRead,
|
||||
} from "../reference/ReferenceSourceReaderTypes.js";
|
||||
import { PARSEABLE_REFERENCE_SOURCE_LANGUAGES } from "./ReferenceSourceImportTypes.js";
|
||||
import { typescriptEmittedSourceCandidates } from "./TypeScriptEmittedSourceCandidates.js";
|
||||
|
||||
export interface ParsedReferenceSourceEntries {
|
||||
readonly entries: HistoricalSourceGraphInput["entries"];
|
||||
@@ -99,22 +100,9 @@ const resolveInternalSpecifier = (
|
||||
`${normalized}${suffix}`,
|
||||
`${normalized}/index${suffix}`,
|
||||
]);
|
||||
// TypeScript source commonly names the extension emitted for NodeNext.
|
||||
// Retain present runtime files first; substitute only a missing counterpart.
|
||||
// Counterparts cover explicit filenames, not directory/index resolution.
|
||||
if (/\.(?:ts|tsx|mts|cts)$/u.test(fromPath)) {
|
||||
const sourceSuffixes = normalized.endsWith(".js")
|
||||
? [".ts", ".tsx", ".d.ts"]
|
||||
: normalized.endsWith(".mjs")
|
||||
? [".mts", ".d.mts"]
|
||||
: normalized.endsWith(".cjs")
|
||||
? [".cts", ".d.cts"]
|
||||
: normalized.endsWith(".jsx")
|
||||
? [".tsx", ".d.ts"]
|
||||
: [];
|
||||
const stem = normalized.slice(0, -posix.extname(normalized).length);
|
||||
candidates.push(...sourceSuffixes.map((suffix) => `${stem}${suffix}`));
|
||||
}
|
||||
candidates.push(...typescriptEmittedSourceCandidates(fromPath, normalized));
|
||||
const match = lookupPath.resolveCandidates(candidates);
|
||||
return match === undefined
|
||||
? { to: normalized, resolution: "unresolved" }
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type { SetupHopperInstallResult } from "./SetupInstallFailure.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { SetupSkillInstallError } from "./SetupSkill.js";
|
||||
import type { DoctorCheck, DoctorReport, DoctorScope } from "./Doctor.js";
|
||||
import type { LinuxDistribution } from "./LinuxHopper.js";
|
||||
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
|
||||
@@ -122,9 +124,22 @@ export class FakeSetupHost implements SetupHost {
|
||||
);
|
||||
skillNeedsInstall = (): Promise<boolean> =>
|
||||
Promise.resolve(this.skill !== "unchanged");
|
||||
installSkill = (): Promise<"installed" | "unchanged" | "failed"> => {
|
||||
installSkill = () => {
|
||||
this.skillInstalls += 1;
|
||||
return Promise.resolve(this.skill);
|
||||
return Promise.resolve(
|
||||
this.skill === "failed"
|
||||
? err(
|
||||
new SetupSkillInstallError({
|
||||
path: "/fixture/home/.agents/skills/reverse-engineer-anything/SKILL.md",
|
||||
stage: "write",
|
||||
reason: "injected skill write failure",
|
||||
rollbackFailures: [],
|
||||
recoveryBackupPaths: [],
|
||||
cause: new Error("injected skill write failure"),
|
||||
}),
|
||||
)
|
||||
: ok(this.skill),
|
||||
);
|
||||
};
|
||||
doctor = (scope?: DoctorScope): Promise<DoctorReport> => {
|
||||
this.doctorCalls += 1;
|
||||
@@ -159,6 +174,7 @@ export class FakeSetupHost implements SetupHost {
|
||||
scope === undefined
|
||||
? environmentHealthy
|
||||
: (scope.clients?.length ?? 0) === 0 &&
|
||||
(scope.skillClients?.length ?? 0) === 0 &&
|
||||
(scope.providers?.length ?? 0) === 0 &&
|
||||
scope.skill !== true
|
||||
? true
|
||||
@@ -169,6 +185,9 @@ export class FakeSetupHost implements SetupHost {
|
||||
scope: {
|
||||
mode: scope === undefined ? "audit-wide" : "explicit",
|
||||
clients: scope?.clients ?? [],
|
||||
...(scope?.skillClients === undefined
|
||||
? {}
|
||||
: { skill_clients: scope.skillClients }),
|
||||
providers: scope?.providers ?? [],
|
||||
skill: scope?.skill === true,
|
||||
target: null,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
|
||||
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
||||
import { runSetup } from "./Setup.js";
|
||||
import type { SetupProgressEvent } from "./SetupTypes.js";
|
||||
|
||||
describe("setup workflow", () => {
|
||||
it("rejects Node 25 before applying setup actions", async () => {
|
||||
@@ -57,14 +58,27 @@ describe("setup workflow", () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.skill = "failed";
|
||||
const progress: SetupProgressEvent[] = [];
|
||||
const result = await runSetup(
|
||||
{ ...options(true), installSkill: true, clientIds: [] },
|
||||
{
|
||||
...options(true),
|
||||
installSkill: true,
|
||||
clientIds: [],
|
||||
onProgress: (event) => {
|
||||
if (event.actionId === "install_skill") progress.push(event);
|
||||
},
|
||||
},
|
||||
host,
|
||||
);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toBe(
|
||||
"REA analysis skill could not be installed or verified. Check permissions for its planned skill directories, then rerun setup.",
|
||||
);
|
||||
expect(result.code).toBe("skill_install_failed");
|
||||
expect(result.remediation).toContain("REA skill write failed at");
|
||||
expect(result.remediation).toContain("injected skill write failure");
|
||||
expect(result.remediation).toContain("Check permissions and rerun setup.");
|
||||
expect(progress.at(-1)).toMatchObject({
|
||||
state: "failed",
|
||||
detail: expect.stringContaining("injected skill write failure"),
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates remaining unhealthy checks to doctor remediation", async () => {
|
||||
@@ -232,6 +246,7 @@ describe("setup scoped readiness", () => {
|
||||
clients: ["codex"],
|
||||
providers: [],
|
||||
skill: true,
|
||||
skillClients: ["codex"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+27
-15
@@ -26,6 +26,7 @@ import {
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import type { SetupSkillInstallError } from "./SetupSkill.js";
|
||||
|
||||
/** Discover, approve, and apply setup actions idempotently. */
|
||||
export const runSetup = async (
|
||||
@@ -125,7 +126,10 @@ const runSetupWorkflow = async (
|
||||
: (options.clientIds ??
|
||||
options.readinessScope?.clients ??
|
||||
discovery.defaultClientIds);
|
||||
let selectedSkillClientIds = options.skillClientIds ?? selectedClientIds;
|
||||
let selectedSkillClientIds =
|
||||
options.skillClientIds ??
|
||||
options.readinessScope?.skillClients ??
|
||||
selectedClientIds;
|
||||
let skillNeedsInstall = await host.skillNeedsInstall(selectedSkillClientIds);
|
||||
let skillSelected =
|
||||
options.installSkill === true ||
|
||||
@@ -220,6 +224,7 @@ const runSetupWorkflow = async (
|
||||
installHopper: planDiscovery.installHopper,
|
||||
installSkill,
|
||||
skillSelected,
|
||||
selectedSkillClientIds,
|
||||
selectedClients: planned.selectedClients,
|
||||
clientsToConfigure: planned.clientsToConfigure,
|
||||
};
|
||||
@@ -311,18 +316,20 @@ const runSetupWorkflow = async (
|
||||
: { onProgress: options.onProgress }),
|
||||
});
|
||||
if (clientFailure !== undefined) return fail(clientFailure);
|
||||
if (
|
||||
planSelection.installSkill &&
|
||||
!(await installSkillAction(
|
||||
if (planSelection.installSkill) {
|
||||
const skillFailure = await installSkillAction(
|
||||
host,
|
||||
options,
|
||||
appliedActions,
|
||||
selectedSkillClientIds,
|
||||
))
|
||||
)
|
||||
return fail(
|
||||
"REA analysis skill could not be installed or verified. Check permissions for its planned skill directories, then rerun setup.",
|
||||
);
|
||||
if (skillFailure !== undefined) {
|
||||
return {
|
||||
...(await fail(skillFailure.remediation)),
|
||||
code: "skill_install_failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
const doctor = summarizeDoctor(await host.doctor(readinessScope));
|
||||
const remediation = combineRemediation(
|
||||
finalSetupRemediation(
|
||||
@@ -413,7 +420,7 @@ const installSkillAction = async (
|
||||
options: SetupOptions,
|
||||
appliedActions: string[],
|
||||
skillClientIds: readonly string[],
|
||||
): Promise<boolean> => {
|
||||
): Promise<SetupSkillInstallError | undefined> => {
|
||||
const label = "REA reverse-engineering skill";
|
||||
emitProgress(options, {
|
||||
actionId: "install_skill",
|
||||
@@ -421,22 +428,23 @@ const installSkillAction = async (
|
||||
state: "started",
|
||||
});
|
||||
const skill = await host.installSkill(skillClientIds);
|
||||
if (skill === "failed") {
|
||||
if (!skill.ok) {
|
||||
emitProgress(options, {
|
||||
actionId: "install_skill",
|
||||
label,
|
||||
state: "failed",
|
||||
detail: skill.error.message,
|
||||
});
|
||||
return false;
|
||||
return skill.error;
|
||||
}
|
||||
if (skill === "installed") appliedActions.push("installed_skill");
|
||||
if (skill.value === "installed") appliedActions.push("installed_skill");
|
||||
emitProgress(options, {
|
||||
actionId: "install_skill",
|
||||
label,
|
||||
state: skill === "installed" ? "completed" : "warning",
|
||||
...(skill === "unchanged" ? { detail: "Already current" } : {}),
|
||||
state: skill.value === "installed" ? "completed" : "warning",
|
||||
...(skill.value === "unchanged" ? { detail: "Already current" } : {}),
|
||||
});
|
||||
return true;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const setupFailure = async (
|
||||
@@ -519,6 +527,7 @@ const resolvedReadinessScope = (
|
||||
readonly selectedClients: readonly SetupClient[];
|
||||
readonly installHopper: boolean;
|
||||
readonly skillSelected?: boolean;
|
||||
readonly selectedSkillClientIds: readonly string[];
|
||||
},
|
||||
): DoctorScope => ({
|
||||
clients: selection.selectedClients.map(({ name }) => name),
|
||||
@@ -526,6 +535,9 @@ const resolvedReadinessScope = (
|
||||
? ["hopper"]
|
||||
: (requested?.providers ?? []),
|
||||
skill: selection.skillSelected === true || requested?.skill === true,
|
||||
...(selection.skillSelected === true || requested?.skill === true
|
||||
? { skillClients: selection.selectedSkillClientIds }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const summarizeDoctor = (report: DoctorReport): SetupDoctorSummary => ({
|
||||
|
||||
@@ -11,6 +11,7 @@ export type SetupFailureCode =
|
||||
| "mount_failed"
|
||||
| "bundle_invalid"
|
||||
| "copy_failed"
|
||||
| "skill_install_failed"
|
||||
| "cleanup_failed";
|
||||
|
||||
export type SetupHopperInstallResult =
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type { DoctorReport } from "./Doctor.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Read-only setup facts shared by client selection and preflight. */
|
||||
export interface SetupDiscovery {
|
||||
@@ -308,7 +309,7 @@ const clientConfigurationDetail = (
|
||||
installHopper: boolean,
|
||||
): string => {
|
||||
const entries = Object.entries(environment)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
|
||||
.map(([name, value]) => `${name}=${value}`);
|
||||
const environmentDetail =
|
||||
entries.length === 0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DoctorProviderInspection } from "./Doctor.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Preserve adapter-published settings independently of runtime readiness. */
|
||||
export const providerRegistrationEnvironment = (
|
||||
@@ -9,5 +10,5 @@ export const providerRegistrationEnvironment = (
|
||||
.flatMap(({ registrationEnvironment }) =>
|
||||
Object.entries(registrationEnvironment),
|
||||
)
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
.sort(([left], [right]) => compareUnicodeCodePoints(left, right)),
|
||||
);
|
||||
|
||||
+183
-46
@@ -2,10 +2,15 @@ import { copyFile, lstat, mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
|
||||
import { readRegularFileText } from "./RegularFileRead.js";
|
||||
import {
|
||||
NonRegularFileReadError,
|
||||
RegularFileChangedError,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { withRegularFile } from "./RegularFileRead.js";
|
||||
import { clientSkillDirectories } from "./SupportedClients.js";
|
||||
|
||||
export const MANAGED_SKILL_FILES = [
|
||||
@@ -23,14 +28,119 @@ export const isManagedSkillManifest = (content: string): boolean =>
|
||||
content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`);
|
||||
|
||||
export const readSkillFile = (path: string): Promise<string> =>
|
||||
readRegularFileText(path, { symlinks: "reject" });
|
||||
readSkillFileState(path).then(({ content }) => content);
|
||||
|
||||
interface SkillFileState {
|
||||
readonly content: string;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
const readSkillFileState = async (path: string): Promise<SkillFileState> =>
|
||||
withRegularFile(
|
||||
path,
|
||||
async (handle, stats) => {
|
||||
const bytes = await readBoundedFileBytes(
|
||||
handle,
|
||||
stats.size,
|
||||
undefined,
|
||||
stats.size,
|
||||
);
|
||||
if (bytes === undefined || bytes.length !== stats.size)
|
||||
throw new RegularFileChangedError(path);
|
||||
return { content: bytes.toString("utf8"), mode: stats.mode & 0o7777 };
|
||||
},
|
||||
{ symlinks: "reject" },
|
||||
);
|
||||
|
||||
interface CanonicalSkillFile {
|
||||
readonly content: string;
|
||||
readonly destination: string;
|
||||
readonly original: string | undefined;
|
||||
readonly original: SkillFileState | undefined;
|
||||
}
|
||||
|
||||
export interface SkillInstallFileOperations {
|
||||
readonly mkdir: (path: string) => Promise<void>;
|
||||
readonly backup: (source: string, destination: string) => Promise<void>;
|
||||
readonly write: (
|
||||
path: string,
|
||||
content: string,
|
||||
mode: number,
|
||||
) => Promise<void>;
|
||||
readonly remove: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SkillInstallRollbackFailure {
|
||||
readonly path: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/** Expected failure while replacing a managed skill bundle. */
|
||||
export class SetupSkillInstallError extends Error {
|
||||
readonly _tag = "SetupSkillInstallError";
|
||||
readonly path: string;
|
||||
readonly stage: "prepare" | "backup" | "write" | "readback";
|
||||
readonly reason: string;
|
||||
readonly rollbackFailures: readonly SkillInstallRollbackFailure[];
|
||||
readonly recoveryBackupPaths: readonly string[];
|
||||
|
||||
constructor(input: {
|
||||
readonly path: string;
|
||||
readonly stage: "prepare" | "backup" | "write" | "readback";
|
||||
readonly reason: string;
|
||||
readonly rollbackFailures: readonly SkillInstallRollbackFailure[];
|
||||
readonly recoveryBackupPaths: readonly string[];
|
||||
readonly cause: unknown;
|
||||
}) {
|
||||
const message = [
|
||||
`REA skill ${input.stage} failed at ${input.path}: ${input.reason}.`,
|
||||
...(input.rollbackFailures.length === 0
|
||||
? []
|
||||
: [
|
||||
`Rollback also failed for ${input.rollbackFailures.map(({ path, reason }) => `${path} (${reason})`).join(", ")}.`,
|
||||
...(input.recoveryBackupPaths.length === 0
|
||||
? []
|
||||
: [
|
||||
`Existing-file backups remain at ${input.recoveryBackupPaths.join(", ")}.`,
|
||||
]),
|
||||
]),
|
||||
].join(" ");
|
||||
super(message, { cause: input.cause });
|
||||
this.path = input.path;
|
||||
this.stage = input.stage;
|
||||
this.reason = input.reason;
|
||||
this.rollbackFailures = input.rollbackFailures;
|
||||
this.recoveryBackupPaths = input.recoveryBackupPaths;
|
||||
}
|
||||
|
||||
get remediation(): string {
|
||||
return `${this.message} Check permissions and rerun setup.`;
|
||||
}
|
||||
}
|
||||
|
||||
export type SkillInstallValue = "installed" | "unchanged";
|
||||
export type SkillInstallResult = Result<
|
||||
SkillInstallValue,
|
||||
SetupSkillInstallError
|
||||
>;
|
||||
|
||||
const systemSkillInstallFileOperations: SkillInstallFileOperations = {
|
||||
mkdir: async (path) => {
|
||||
await mkdir(path, { recursive: true });
|
||||
},
|
||||
backup: async (source, destination) => {
|
||||
try {
|
||||
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
||||
return;
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
write: (path, content, mode) =>
|
||||
writeFileAtomic(path, content, { encoding: "utf8", mode }),
|
||||
remove: (path) => rm(path, { force: true }),
|
||||
};
|
||||
|
||||
/** Managed skill location selected for a client family. */
|
||||
export interface SkillDestination {
|
||||
readonly client: string;
|
||||
@@ -87,9 +197,11 @@ export const existingSkillDestinations = async (
|
||||
return existing;
|
||||
};
|
||||
|
||||
const readOptionalText = async (path: string): Promise<string | undefined> => {
|
||||
const readOptionalSkillState = async (
|
||||
path: string,
|
||||
): Promise<SkillFileState | undefined> => {
|
||||
try {
|
||||
return await readSkillFile(path);
|
||||
return await readSkillFileState(path);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||
return undefined;
|
||||
@@ -117,7 +229,7 @@ const canonicalSkillFiles = async (
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
original: await readOptionalText(destination),
|
||||
original: await readOptionalSkillState(destination),
|
||||
};
|
||||
}),
|
||||
),
|
||||
@@ -133,7 +245,7 @@ export const canonicalSkillNeedsInstall = async (
|
||||
try {
|
||||
return (
|
||||
await canonicalSkillFiles(home, clientIds, environment, platform)
|
||||
).some(({ content, original }) => original !== content);
|
||||
).some(({ content, original }) => original?.content !== content);
|
||||
} catch (cause: unknown) {
|
||||
// Unreadable skill state fails open to install so setup can repair it.
|
||||
void cause;
|
||||
@@ -141,39 +253,42 @@ export const canonicalSkillNeedsInstall = async (
|
||||
}
|
||||
};
|
||||
|
||||
const writeText = (path: string, content: string): Promise<void> =>
|
||||
writeFileAtomic(path, content, { encoding: "utf8", mode: 0o600 });
|
||||
const causeReason = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const preserveSkillBackup = async (
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
||||
return;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
const causePath = (cause: unknown): string | undefined =>
|
||||
cause instanceof Error && "path" in cause && typeof cause.path === "string"
|
||||
? cause.path
|
||||
: undefined;
|
||||
|
||||
const restoreSkillFiles = async (
|
||||
changed: readonly CanonicalSkillFile[],
|
||||
): Promise<void> => {
|
||||
for (const { destination, original } of [...changed].reverse()) {
|
||||
if (original === undefined) await rm(destination, { force: true });
|
||||
else await writeText(destination, original);
|
||||
attempted: readonly CanonicalSkillFile[],
|
||||
operations: SkillInstallFileOperations,
|
||||
): Promise<readonly SkillInstallRollbackFailure[]> => {
|
||||
const failures: SkillInstallRollbackFailure[] = [];
|
||||
for (const { destination, original } of [...attempted].reverse()) {
|
||||
try {
|
||||
if (original === undefined) await operations.remove(destination);
|
||||
else await operations.write(destination, original.content, original.mode);
|
||||
} catch (cause: unknown) {
|
||||
failures.push({ path: destination, reason: causeReason(cause) });
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
};
|
||||
|
||||
/** Transactionally install or upgrade the canonical REA skill and references. */
|
||||
/** Transactionally install the canonical skill bundle and optional references. */
|
||||
export const installCanonicalSkill = async (
|
||||
home: string,
|
||||
clientIds: readonly string[] = [],
|
||||
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<"installed" | "unchanged" | "failed"> => {
|
||||
let changed: readonly CanonicalSkillFile[] = [];
|
||||
operations: SkillInstallFileOperations = systemSkillInstallFileOperations,
|
||||
): Promise<SkillInstallResult> => {
|
||||
let attempted: CanonicalSkillFile[] = [];
|
||||
let stage: "prepare" | "backup" | "write" | "readback" = "prepare";
|
||||
let failingPath = "skill bundle";
|
||||
try {
|
||||
const canonical = await canonicalSkillFiles(
|
||||
home,
|
||||
@@ -181,30 +296,52 @@ export const installCanonicalSkill = async (
|
||||
environment,
|
||||
platform,
|
||||
);
|
||||
changed = canonical.filter(({ content, original }) => original !== content);
|
||||
if (changed.length === 0) return "unchanged";
|
||||
const changed = canonical.filter(
|
||||
({ content, original }) => original?.content !== content,
|
||||
);
|
||||
if (changed.length === 0) return ok("unchanged");
|
||||
|
||||
stage = "backup";
|
||||
for (const { destination, original } of changed) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
failingPath = destination;
|
||||
await operations.mkdir(dirname(destination));
|
||||
if (original !== undefined)
|
||||
await preserveSkillBackup(destination, `${destination}.rea.backup`);
|
||||
await operations.backup(destination, `${destination}.rea.backup`);
|
||||
}
|
||||
for (const { destination, content } of changed)
|
||||
await writeText(destination, content);
|
||||
for (const { destination, content } of changed)
|
||||
stage = "write";
|
||||
for (const file of changed) {
|
||||
failingPath = file.destination;
|
||||
// An atomic writer can publish and then reject; include the target before
|
||||
// entering it so rollback covers that ambiguous outcome.
|
||||
attempted.push(file);
|
||||
await operations.write(
|
||||
file.destination,
|
||||
file.content,
|
||||
file.original?.mode ?? 0o600,
|
||||
);
|
||||
}
|
||||
stage = "readback";
|
||||
for (const { destination, content } of changed) {
|
||||
failingPath = destination;
|
||||
if ((await readSkillFile(destination)) !== content)
|
||||
throw new Error(`skill readback mismatch: ${destination}`);
|
||||
return "installed";
|
||||
} catch (cause: unknown) {
|
||||
// Install failure preserves the original error outcome; report cause inline.
|
||||
void cause;
|
||||
try {
|
||||
await restoreSkillFiles(changed);
|
||||
} catch (restoreCause: unknown) {
|
||||
// Per-file backups remain beside changed files for operator recovery.
|
||||
void restoreCause;
|
||||
}
|
||||
return "failed";
|
||||
return ok("installed");
|
||||
} catch (cause: unknown) {
|
||||
failingPath = causePath(cause) ?? failingPath;
|
||||
const rollbackFailures = await restoreSkillFiles(attempted, operations);
|
||||
return err(
|
||||
new SetupSkillInstallError({
|
||||
path: failingPath,
|
||||
stage,
|
||||
reason: causeReason(cause),
|
||||
rollbackFailures,
|
||||
recoveryBackupPaths: attempted.flatMap(({ destination, original }) =>
|
||||
original === undefined ? [] : [`${destination}.rea.backup`],
|
||||
),
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
|
||||
import type { LinuxDistribution } from "./LinuxHopper.js";
|
||||
import type { SkillDestination } from "./SetupSkill.js";
|
||||
import type { SkillDestination, SkillInstallResult } from "./SetupSkill.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type {
|
||||
SetupFailureCode,
|
||||
@@ -70,9 +70,7 @@ export interface SetupHost {
|
||||
command: readonly string[],
|
||||
): Promise<ClientConfigurationInspection>;
|
||||
skillNeedsInstall(clientIds: readonly string[]): Promise<boolean>;
|
||||
installSkill(
|
||||
clientIds: readonly string[],
|
||||
): Promise<"installed" | "unchanged" | "failed">;
|
||||
installSkill(clientIds: readonly string[]): Promise<SkillInstallResult>;
|
||||
doctor(scope?: DoctorScope): Promise<Awaited<ReturnType<typeof runDoctor>>>;
|
||||
}
|
||||
/** Structured setup outcome carrying remediation instead of prompting. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
HermesClientPathError,
|
||||
resolveHermesClientHome,
|
||||
} from "./HermesClientHome.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** One supported client configuration location. */
|
||||
export interface SetupClient {
|
||||
@@ -642,6 +643,6 @@ export const clientSkillDirectories = (
|
||||
});
|
||||
}
|
||||
return [...destinations.values()].sort((left, right) =>
|
||||
left.directory.localeCompare(right.directory),
|
||||
compareUnicodeCodePoints(left.directory, right.directory),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Emitted NodeNext suffixes and the TypeScript source counterparts they name. */
|
||||
const EMITTED_SUFFIXES: ReadonlyArray<
|
||||
readonly [suffix: string, sourceSuffixes: readonly string[]]
|
||||
> = [
|
||||
[".js", [".ts", ".tsx", ".d.ts"]],
|
||||
[".mjs", [".mts", ".d.mts"]],
|
||||
[".cjs", [".cts", ".d.cts"]],
|
||||
[".jsx", [".tsx", ".d.ts"]],
|
||||
];
|
||||
|
||||
/**
|
||||
* Return TypeScript source counterparts for an explicit emitted filename.
|
||||
* Callers retain exact runtime-file precedence and must not use these for
|
||||
* directory/index, URL, HTML, or package target resolution.
|
||||
*/
|
||||
export const typescriptEmittedSourceCandidates = (
|
||||
sourcePath: string,
|
||||
candidate: string,
|
||||
): readonly string[] => {
|
||||
if (!/\.(?:ts|tsx|mts|cts)$/u.test(sourcePath)) return [];
|
||||
const emitted = EMITTED_SUFFIXES.find(([suffix]) =>
|
||||
candidate.endsWith(suffix),
|
||||
);
|
||||
if (emitted === undefined) return [];
|
||||
// Strip the matched suffix by length. posix.extname would miss dotfile
|
||||
// names such as ".js", whose whole basename is the emitted suffix itself.
|
||||
const stem = candidate.slice(0, candidate.length - emitted[0].length);
|
||||
return emitted[1].map((suffix) => `${stem}${suffix}`);
|
||||
};
|
||||
@@ -38,6 +38,7 @@ export interface WebModuleResolutionBatch {
|
||||
|
||||
/** Replaceable native resolution engine, with no provider protocol in callers. */
|
||||
export interface WebModuleResolutionPort {
|
||||
close?(): Promise<void>;
|
||||
resolve(
|
||||
input: {
|
||||
readonly importerUrl: string;
|
||||
|
||||
@@ -69,6 +69,10 @@ export class WebModuleTraceService {
|
||||
readonly resolver: WebModuleResolutionPort,
|
||||
) {}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.resolver.close?.() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
async trace(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { ok, type Result } from "../../domain/result.js";
|
||||
|
||||
/**
|
||||
* Complete class-inventory pages dominate raw-call provenance for focused
|
||||
* searches: one traversal embeds every examined class name regardless of the
|
||||
* match count. The summary keeps the pagination audit inline (totals, page
|
||||
* offsets and sizes, elided item counts) and links the retained record that
|
||||
* still carries the complete inventory.
|
||||
*/
|
||||
const elideInventoryText = (text: string): string | undefined => {
|
||||
let page: JsonValue;
|
||||
try {
|
||||
page = JSON.parse(text) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof page !== "object" ||
|
||||
page === null ||
|
||||
Array.isArray(page) ||
|
||||
!Array.isArray(page.items)
|
||||
)
|
||||
return undefined;
|
||||
const { items, ...audited } = page;
|
||||
return JSON.stringify({ ...audited, items_omitted: items.length });
|
||||
};
|
||||
|
||||
/** Replace inventoried page bodies with their pagination audit. */
|
||||
const summarizeInventoryCall = (call: JsonValue): JsonValue => {
|
||||
if (
|
||||
typeof call !== "object" ||
|
||||
call === null ||
|
||||
Array.isArray(call) ||
|
||||
call.operation !== "list_classes"
|
||||
)
|
||||
return call;
|
||||
const response = call.response;
|
||||
if (
|
||||
typeof response !== "object" ||
|
||||
response === null ||
|
||||
Array.isArray(response) ||
|
||||
!Array.isArray(response.content)
|
||||
)
|
||||
return call;
|
||||
let elided = false;
|
||||
const content = response.content.map((entry: JsonValue) => {
|
||||
if (
|
||||
typeof entry !== "object" ||
|
||||
entry === null ||
|
||||
Array.isArray(entry) ||
|
||||
typeof entry.text !== "string"
|
||||
)
|
||||
return entry;
|
||||
const summarized = elideInventoryText(entry.text);
|
||||
if (summarized === undefined) return entry;
|
||||
elided = true;
|
||||
return { ...entry, text: summarized };
|
||||
});
|
||||
return elided
|
||||
? { ...call, inventory_elided: true, response: { ...response, content } }
|
||||
: call;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retain the complete search Evidence — full raw provider calls included —
|
||||
* in the session record, then deliver a bounded summary Evidence inline.
|
||||
* Callers without a retaining session keep the complete inline delivery, so
|
||||
* complete CLI JSON output is unchanged. Coverage metadata stays truthful in
|
||||
* both copies: the examined inventory is never rewritten to matched-only.
|
||||
*/
|
||||
export const summarizeRetainedAndroidSearch = (
|
||||
evidence: Evidence,
|
||||
recordEvidence: EvidenceWriter["recordEvidence"] | undefined,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
if (recordEvidence === undefined) return ok(evidence);
|
||||
const recorded = recordEvidence(evidence);
|
||||
if (!recorded.ok) return recorded;
|
||||
const subject = evidence.subject;
|
||||
const raw = evidence.raw_result;
|
||||
const summarized: JsonValue =
|
||||
raw === null ||
|
||||
typeof raw !== "object" ||
|
||||
Array.isArray(raw) ||
|
||||
!Array.isArray(raw.calls)
|
||||
? raw
|
||||
: {
|
||||
...raw,
|
||||
complete_evidence_id: evidence.evidence_id,
|
||||
calls: raw.calls.map(summarizeInventoryCall),
|
||||
};
|
||||
return ok(
|
||||
createEvidence(
|
||||
subject === null
|
||||
? undefined
|
||||
: {
|
||||
path: subject.local_path,
|
||||
sha256: subject.digest.sha256,
|
||||
format: subject.format,
|
||||
...(subject.architecture === null
|
||||
? {}
|
||||
: { architecture: subject.architecture }),
|
||||
},
|
||||
evidence.provider,
|
||||
{
|
||||
operation: evidence.operation,
|
||||
parameters: evidence.parameters,
|
||||
result: evidence.normalized_result,
|
||||
rawResult: summarized,
|
||||
confidence: evidence.confidence,
|
||||
limitations: evidence.limitations,
|
||||
locations: evidence.locations,
|
||||
evidenceLinks: [evidence.evidence_id],
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
evaluateAnalysisProviderCandidate,
|
||||
type AnalysisProviderCandidateEvaluation,
|
||||
} from "./AnalysisProviderEvaluation.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Deterministic discovery truth for one configured deep provider. */
|
||||
export interface AnalysisProviderCandidateStatus {
|
||||
@@ -109,12 +110,14 @@ export class AnalysisProviderRegistry {
|
||||
}
|
||||
this.#providers = Object.freeze(
|
||||
[...providers].sort((left, right) =>
|
||||
left.identity().id.localeCompare(right.identity().id),
|
||||
compareUnicodeCodePoints(left.identity().id, right.identity().id),
|
||||
),
|
||||
);
|
||||
this.#byId = byId;
|
||||
this.#declaredOperations = Object.freeze(
|
||||
[...declaredOperations].sort((left, right) => left.localeCompare(right)),
|
||||
[...declaredOperations].sort((left, right) =>
|
||||
compareUnicodeCodePoints(left, right),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,63 @@ it("exports independent nested query, workflow and Evidence payloads", () => {
|
||||
expect(serializeAnalysisSnapshot(fresh.value)).toBe(expected);
|
||||
});
|
||||
|
||||
it("rebinds replayed target subjects while preserving child artifact subjects", () => {
|
||||
const cache = new AnalysisSnapshotCache();
|
||||
const target = ANALYSIS_SNAPSHOT_TARGET;
|
||||
const profile = ANALYSIS_SNAPSHOT_PROFILE;
|
||||
const relocatedTarget = { ...target, path: "/tmp/relocated-app" };
|
||||
const parameters = { address: "0x1000" };
|
||||
const locations = [{ kind: "address" as const, address: "0x1000" }];
|
||||
cache.record({
|
||||
target,
|
||||
profile,
|
||||
operation: "procedure_info",
|
||||
parameters,
|
||||
execution: createAnalysisExecution(
|
||||
{ name: "main" },
|
||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
{
|
||||
subject: target,
|
||||
rawResult: { provider: "raw" },
|
||||
locations,
|
||||
analysisProfile: profile,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const replayedTarget = cache.lookup(
|
||||
relocatedTarget,
|
||||
profile,
|
||||
"procedure_info",
|
||||
parameters,
|
||||
);
|
||||
expect(replayedTarget?.subject?.path).toBe(relocatedTarget.path);
|
||||
expect(replayedTarget?.rawResult).toEqual({ provider: "raw" });
|
||||
expect(replayedTarget?.locations).toEqual(locations);
|
||||
|
||||
const childSubject = {
|
||||
path: "/tmp/child.zip",
|
||||
sha256: "b".repeat(64),
|
||||
format: "zip" as const,
|
||||
};
|
||||
const childParameters = { address: "0x2000" };
|
||||
cache.record({
|
||||
target,
|
||||
profile,
|
||||
operation: "procedure_info",
|
||||
parameters: childParameters,
|
||||
execution: createAnalysisExecution(
|
||||
{ name: "child" },
|
||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
{ subject: childSubject, analysisProfile: profile },
|
||||
),
|
||||
});
|
||||
expect(
|
||||
cache.lookup(relocatedTarget, profile, "procedure_info", childParameters)
|
||||
?.subject,
|
||||
).toMatchObject(childSubject);
|
||||
});
|
||||
|
||||
describe("analysis snapshot cache capacity", () => {
|
||||
it("shares capacity across query kinds while allowing replacements and fresh partitions", () => {
|
||||
const cache = new AnalysisSnapshotCache();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../../domain/evidenceErrors.js";
|
||||
import { NoBinaryOpenError } from "../../domain/configurationErrors.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { rebindEvidenceSubjectTargetPath } from "../../domain/evidence.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
analysisQueryId,
|
||||
@@ -277,7 +278,21 @@ export class AnalysisSnapshotCache {
|
||||
);
|
||||
const cached = this.#entries.get(queryId);
|
||||
if (cached === undefined) return undefined;
|
||||
const subject = cached.execution.subject;
|
||||
const cachedSubject = cached.execution.subject;
|
||||
const subject =
|
||||
cachedSubject === null
|
||||
? null
|
||||
: rebindEvidenceSubjectTargetPath(
|
||||
{
|
||||
path: cachedSubject.path,
|
||||
sha256: cachedSubject.sha256,
|
||||
format: cachedSubject.format,
|
||||
...(cachedSubject.architecture === null
|
||||
? {}
|
||||
: { architecture: cachedSubject.architecture }),
|
||||
},
|
||||
target,
|
||||
);
|
||||
return structuredClone({
|
||||
result: cached.execution.result,
|
||||
rawResult: cached.execution.raw_result,
|
||||
@@ -288,21 +303,7 @@ export class AnalysisSnapshotCache {
|
||||
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.",
|
||||
],
|
||||
locations: cached.execution.locations,
|
||||
subject:
|
||||
subject === null
|
||||
? null
|
||||
: subject.architecture === null
|
||||
? {
|
||||
path: subject.path,
|
||||
sha256: subject.sha256,
|
||||
format: subject.format,
|
||||
}
|
||||
: {
|
||||
path: subject.path,
|
||||
sha256: subject.sha256,
|
||||
format: subject.format,
|
||||
architecture: subject.architecture,
|
||||
},
|
||||
subject,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,30 @@ export class BinarySession
|
||||
this.#providerRouter = providerRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast-path liveness gate (#1719): short-circuit only when the retained
|
||||
* client is genuinely alive. The probe reads the child's operation health
|
||||
* rather than the composite's unconditionally-ok `health`, so a dead child
|
||||
* falls through and a real client is started; an aborted caller leaves the
|
||||
* session untouched. Returns the fast-path result, or undefined to fall
|
||||
* through to the slow path.
|
||||
*/
|
||||
async #tryRetainedFastPath(
|
||||
retained: SessionBinding,
|
||||
target: BinaryTarget,
|
||||
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot">,
|
||||
): Promise<Result<BinaryTarget, AnalysisError> | undefined> {
|
||||
const live = await this.#retainedClientLive(retained.client, options);
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (!live) return undefined;
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) return imported;
|
||||
}
|
||||
return ok(target);
|
||||
}
|
||||
|
||||
/** Identify the provider producing evidence for this session. */
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity {
|
||||
const route = this.#currentRoute();
|
||||
@@ -247,12 +271,14 @@ export class BinarySession
|
||||
if (!pendingClosed.ok) return pendingClosed;
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (sameTarget && sameProfile) {
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) return imported;
|
||||
}
|
||||
return ok(target);
|
||||
const retained = sameTarget && sameProfile ? this.#active : undefined;
|
||||
if (retained !== undefined) {
|
||||
const fastPath = await this.#tryRetainedFastPath(
|
||||
retained,
|
||||
target,
|
||||
options,
|
||||
);
|
||||
if (fastPath !== undefined) return fastPath;
|
||||
}
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
@@ -703,6 +729,36 @@ export class BinarySession
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether a retained provider client is still live.
|
||||
*
|
||||
* On production routes the client is a CompositeProvider whose `health`
|
||||
* answers unconditionally ok without touching a child, so `health` alone
|
||||
* cannot see a dead child. `operationHealthSnapshot` reaches the
|
||||
* instantiated children instead. Only the known-live states allow the
|
||||
* fast path: `exited`/`unreachable` are dead, `unknown` is never
|
||||
* evidence of health (it is what a recorded but uncharacterized failure
|
||||
* reports), and `not_started` is a startup that never completed — in the
|
||||
* Hopper timeout case the client holds retained resources that block the
|
||||
* next `start()` until `close()` retries cleanup, so treating it as live
|
||||
* would skip the retire path and hand the wedged client to subsequent
|
||||
* analysis. The gate fails closed: without a snapshot signal, fall back
|
||||
* to the `health` admission probe.
|
||||
*/
|
||||
async #retainedClientLive(
|
||||
client: AnalysisClient,
|
||||
options: Pick<BinarySessionOpenOptions, "signal">,
|
||||
): Promise<boolean> {
|
||||
const snapshot = client.operationHealthSnapshot?.();
|
||||
if (snapshot !== undefined)
|
||||
return snapshot.state === "idle" || snapshot.state === "busy";
|
||||
try {
|
||||
return (await client.execute("health", {}, options)).ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async #restore(
|
||||
previous: SessionBinding | undefined,
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user