mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 13:56:47 -10:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc2ca71151 | ||
|
|
2cb29693c3 | ||
|
|
26c3e9e413 | ||
|
|
144843ccc2 | ||
|
|
4a01211f11 | ||
|
|
6a0ccf7eeb | ||
|
|
710fad8acf | ||
|
|
93b8b82db7 | ||
|
|
3f4aff2f22 | ||
|
|
8b89eee767 | ||
|
|
59f5502a40 | ||
|
|
2ac6d3efe2 | ||
|
|
365c3245e8 | ||
|
|
aba56df689 | ||
|
|
dec8d2d588 | ||
|
|
0e6943c9da | ||
|
|
20a282a181 | ||
|
|
f95bdbce76 | ||
|
|
8250bf2320 | ||
|
|
00c4f44a1c | ||
|
|
d3ab0faf52 | ||
|
|
6d60db1951 | ||
|
|
0354145a2a | ||
|
|
7bcefead69 | ||
|
|
ce9095d727 | ||
|
|
46c13b9981 | ||
|
|
85013da0ce | ||
|
|
cbc69537cd | ||
|
|
eb39a07966 | ||
|
|
4b840aa1fb | ||
|
|
423059a4de | ||
|
|
44b6384009 | ||
|
|
3f61303939 | ||
|
|
35ae7232bf | ||
|
|
76cd23d2e8 | ||
|
|
3e6a5cb20a | ||
|
|
1d343a88e5 | ||
|
|
df25bc8068 |
No files matched your search
@@ -377,8 +377,9 @@ unless the group overrides them. The grouped name union is the complete tool
|
||||
inventory; use `tools/list` for canonical tool schemas. The default summary keeps
|
||||
each provider candidate's availability and target-support status, code and
|
||||
reason; `omitted_fields` lists what it leaves out. Request `{"detail": "complete"}`
|
||||
only when you need provider diagnostics, candidate capability sets, or tool
|
||||
surface, effect and annotation flags; in those closed flag vocabularies, listed
|
||||
only when you need an unavailable candidate's remediation, provider
|
||||
diagnostics, candidate capability sets, or tool surface, effect and annotation
|
||||
flags; in those closed flag vocabularies, listed
|
||||
flags are true and omitted known flags are false. Provider candidate facts
|
||||
describe possible providers, while top-level session capabilities describe the
|
||||
current route and `analysis_provider_binding` names the selected provider. Do
|
||||
@@ -431,7 +432,10 @@ output also streams without a single MCP frame.
|
||||
When the retained parent ID is unknown, use `get_evidence_bundle` with
|
||||
`detail: "summary"` and exact filters for operation, target SHA-256,
|
||||
analysis-profile digest or canonical procedure address. Select its returned
|
||||
Evidence ID for the next view instead of requesting the complete bundle.
|
||||
Evidence ID for the next view instead of requesting the complete bundle. When
|
||||
that record has no supported view (for example an annotation), read it with
|
||||
`detail: "records"` and an `evidence_id` filter; `missing_evidence_links`
|
||||
names linked sources this session does not retain.
|
||||
`retention: "complete-record"` means the whole record is stored; native
|
||||
value-flow truncation and unknown metadata remain separate. For an exported
|
||||
portable bundle, use `rea inspect-evidence-bundle` with JSON containing `path`,
|
||||
|
||||
@@ -73,6 +73,14 @@ Parent Evidence ID plus source index preserves same-address
|
||||
alternatives. Follow `coverage.next_offset` only as far as the question needs;
|
||||
exhausting retained rows does not establish complete program discovery.
|
||||
|
||||
`batch_decompile` returns every body unless you select `detail: "summary"` when
|
||||
the connected tool advertises it. Use its parent Evidence ID with
|
||||
`view: {kind: "batch-decompile", collection: "items"}` for body-free rows and
|
||||
exact `where.indices`, `addresses`, `procedure_addresses` or `statuses`. Add
|
||||
`include_pseudocode: true` for several chosen bodies, or use
|
||||
`collection: "pseudocode"` with one row's `index` to page a large body. Batch
|
||||
limitations aggregate every item, so do not attribute them to one successful row.
|
||||
|
||||
Use `record_unknown` to track unresolved questions in the user's investigation
|
||||
and name the authority or environment still required. Supply supporting and
|
||||
contradicting evidence IDs. It updates the session registry directly.
|
||||
|
||||
@@ -169,7 +169,10 @@ leaving it, and `both` when the question needs both. Trace traversal includes
|
||||
containment relationships, so even a narrow seed can expand many nodes; a
|
||||
bidirectional trace can reach unrelated sibling modules. A module item or page
|
||||
is preferable when its observations already answer the question. Do not treat
|
||||
all reached nodes as participants in the feature.
|
||||
all reached nodes as participants in the feature. For a broad trace, pass
|
||||
`view: {"kind": "feature-trace", "collection": "paths"}` (or `summary`) to
|
||||
retain the complete trace and read terminal paths first; page `nodes` or `edges`
|
||||
with exact `where.node_ids` through `inspect_analysis_view` as needed.
|
||||
|
||||
Reuse retained parent Evidence when the server advertises it, or supply complete
|
||||
Evidence inline. Include Hopper or Ghidra Evidence only when its artifact digest
|
||||
|
||||
@@ -346,8 +346,9 @@ the complete tool inventory grouped by current status. Expand each group's
|
||||
overrides them; availability, reasons, remediation, and modes remain inline.
|
||||
Provider candidates keep availability and target-support status, code, and
|
||||
reason. `omitted_fields` names what the summary leaves out; `{"detail":
|
||||
"complete"}` adds provider diagnostics, candidate capability sets, and tool
|
||||
surface, `effects`, and `annotations`. Those arrays name true flags from fixed
|
||||
"complete"}` adds each unavailable candidate's `remediation`, provider
|
||||
diagnostics, candidate capability sets, and tool surface, `effects`, and
|
||||
`annotations`. Those arrays name true flags from fixed
|
||||
vocabularies, so an omitted known flag is false. `tools/list` remains the
|
||||
canonical source for full tool schemas when target or provider state changes.
|
||||
|
||||
|
||||
@@ -807,6 +807,8 @@ jobs:
|
||||
tests/boundary/mcp/nativeDemangleMcp.test.ts
|
||||
tests/boundary/mcp/nativeMachoSummaryMcp.test.ts
|
||||
tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts
|
||||
tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts
|
||||
tests/boundary/process/nativeUiVerifierLane.test.ts
|
||||
tests/boundary/cli/nativeSignatureCli.test.ts
|
||||
tests/boundary/cli/nativeMachoArm64_32Cli.test.ts
|
||||
tests/boundary/cli/nativeMachoEncryptionCli.test.ts
|
||||
|
||||
@@ -60,7 +60,7 @@ pin inspection sources; they are not included in the npm package.
|
||||
| ghidra-nativeaot | [MIT](https://github.com/Washi1337/ghidra-nativeaot/blob/effeb734fc570c32650f88b159608979dc7b423e/LICENSE.md) | Pinned inspection source; derived parser attribution above. |
|
||||
| JADX | [Apache-2.0](https://github.com/skylot/jadx/blob/v1.5.6/LICENSE) | The audited jadx-headless-mcp source uses JADX 1.5.6 for Android decompilation; the engine JAR is separately supplied. |
|
||||
| jadx-headless-mcp | [Apache-2.0](https://github.com/1013503897/jadx-headless-mcp/blob/5844800a486d2248fa949b74c9896285f5b54de2/LICENSE) | v0.7.1; pinned submodule and user-supplied JAR. |
|
||||
| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.986/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.986/NOTICE) | Bring your own `kuna`; REA verified 1.986 and never downloads, installs or bundles it. REA invokes it as a separate process. |
|
||||
| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.989/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.989/NOTICE) | Bring your own `kuna`; REA verified 1.989 and never downloads, installs or bundles it. REA invokes it as a separate process. |
|
||||
| Apktool | [Apache-2.0](https://github.com/iBotPeaches/Apktool/blob/master/LICENSE.md) | User-supplied Android resource decoder. |
|
||||
| Binwalk | [MIT](https://github.com/ReFirmLabs/binwalk/blob/4fdab3d464d97b68e0af9088df3f9e2e1545b21c/LICENSE) | v3.1.0; pinned submodule and user-supplied firmware engine. |
|
||||
| Unblob | [MIT](https://github.com/onekey-sec/unblob/blob/1fcc7a0a584a70a96c31f5a276c20944d199a089/LICENSE) | 26.6.4; pinned submodule and user-supplied firmware engine. |
|
||||
|
||||
@@ -693,6 +693,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
}
|
||||
row.addProperty("status", relocation.getStatus().toString());
|
||||
row.addProperty("type", relocation.getType());
|
||||
row.add("symbol", GSON.toJsonTree(relocation.getSymbolName()));
|
||||
row.add("values", GSON.toJsonTree(relocation.getValues() == null ? new long[0] : relocation.getValues()));
|
||||
byte[] original = relocation.getBytes();
|
||||
row.add("original_bytes_hex", original == null ? JsonNull.INSTANCE : GSON.toJsonTree(bytesHex(original)));
|
||||
@@ -2274,9 +2275,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
throw new RequestFailure("invalid_request", "mode must be literal or regex");
|
||||
}
|
||||
boolean caseSensitive = requireBoolean(params, "case_sensitive");
|
||||
List<InventoryItem> inventory = procedureSearch
|
||||
? procedures().stream().map(FunctionEntry::item).toList()
|
||||
: strings();
|
||||
ValueMatcher matcher;
|
||||
if (mode.equals("literal") && !caseSensitive) {
|
||||
if (!params.has("literal_ascii_candidate"))
|
||||
@@ -2290,7 +2288,26 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
: regexMatcher(expression, caseSensitive);
|
||||
}
|
||||
JsonArray items = new JsonArray();
|
||||
for (InventoryItem item : inventory) {
|
||||
if (procedureSearch) {
|
||||
// Search the names exact lookup accepts, keeping one row per
|
||||
// procedure under its primary name.
|
||||
for (FunctionEntry entry : procedures()) {
|
||||
monitor.checkCancelled();
|
||||
ProcedureNameMatch match = matchProcedureNames(entry, matcher);
|
||||
if (!match.matched()) {
|
||||
continue;
|
||||
}
|
||||
JsonObject item = resultItem(entry.item, null);
|
||||
if (!match.aliases().isEmpty()) {
|
||||
JsonArray aliases = new JsonArray();
|
||||
for (String alias : match.aliases()) aliases.add(alias);
|
||||
item.add("matched_aliases", aliases);
|
||||
}
|
||||
items.add(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
for (InventoryItem item : strings()) {
|
||||
monitor.checkCancelled();
|
||||
if (!matcher.matches(item.value)) {
|
||||
continue;
|
||||
@@ -2300,6 +2317,36 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one definition of the names that select a procedure, shared by exact
|
||||
* lookup and search: its primary name and every other symbol at its exact
|
||||
* entry, each in leaf or namespace-qualified spelling. A label inside the
|
||||
* body never names the procedure. Matching secondary symbols are reported
|
||||
* by their qualified names, sorted.
|
||||
*/
|
||||
private ProcedureNameMatch matchProcedureNames(FunctionEntry entry, ValueMatcher matcher) throws Exception {
|
||||
Function function = entry.function;
|
||||
boolean primary = spellingMatches(function.getName(), entry.item.value, matcher);
|
||||
Set<String> aliases = null;
|
||||
for (Symbol symbol : currentProgram.getSymbolTable().getSymbols(function.getEntryPoint())) {
|
||||
monitor.checkCancelled();
|
||||
String qualified = symbol.getName(true);
|
||||
if (!spellingMatches(symbol.getName(), qualified, matcher)) continue;
|
||||
if (qualified.equals(entry.item.value)) {
|
||||
primary = true;
|
||||
continue;
|
||||
}
|
||||
if (aliases == null) aliases = new TreeSet<>();
|
||||
aliases.add(qualified);
|
||||
}
|
||||
return new ProcedureNameMatch(primary, aliases == null ? List.of() : List.copyOf(aliases));
|
||||
}
|
||||
|
||||
/** A leaf is the suffix of its qualified spelling, so a literal found in it is found in both. */
|
||||
private static boolean spellingMatches(String leaf, String qualified, ValueMatcher matcher) {
|
||||
return matcher.matches(qualified) || (!leaf.equals(qualified) && matcher.matches(leaf));
|
||||
}
|
||||
|
||||
private List<InventoryItem> names() throws Exception {
|
||||
if (nameInventory != null) {
|
||||
return nameInventory;
|
||||
@@ -2402,26 +2449,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
// exact symbol name, even when every character is a hexadecimal digit.
|
||||
boolean explicitAddress = isExplicitReaAddress(value);
|
||||
if (!explicitAddress) {
|
||||
// Imported labels (for example Mach-O _main) can share a function
|
||||
// entry without becoming its primary symbol; search accepts the same.
|
||||
ValueMatcher selector = value::equals;
|
||||
List<Function> matches = new ArrayList<>();
|
||||
for (FunctionEntry entry : procedures()) {
|
||||
monitor.checkCancelled();
|
||||
Function function = entry.function;
|
||||
Symbol symbol = function.getSymbol();
|
||||
String qualified = symbol == null ? function.getName() : symbol.getName(true);
|
||||
boolean matchesName = function.getName().equals(value) || qualified.equals(value);
|
||||
if (!matchesName) {
|
||||
// Imported labels (for example Mach-O _main) can share a
|
||||
// function entry without becoming its primary symbol. Only
|
||||
// exact entries qualify; an interior label is not a selector.
|
||||
for (Symbol entrySymbol : currentProgram.getSymbolTable().getSymbols(function.getEntryPoint())) {
|
||||
monitor.checkCancelled();
|
||||
if (entrySymbol.getName().equals(value) || entrySymbol.getName(true).equals(value)) {
|
||||
matchesName = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matchesName) matches.add(function);
|
||||
if (matchProcedureNames(entry, selector).matched()) matches.add(entry.function);
|
||||
}
|
||||
if (matches.size() > 1)
|
||||
throw new RequestFailure("ambiguous", "Ghidra procedure name is ambiguous: " + value +
|
||||
@@ -5138,6 +5172,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private record AddressNameConflict(Namespace namespace, String leaf, List<Address> requesters, List<Address> holders) {}
|
||||
private record AddressNameFailure(String reason, AddressNameConflict conflict) {}
|
||||
private record FunctionEntry(Function function, InventoryItem item) {}
|
||||
private record ProcedureNameMatch(boolean primary, List<String> aliases) {
|
||||
boolean matched() {
|
||||
return primary || !aliases.isEmpty();
|
||||
}
|
||||
}
|
||||
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations, JsonObject relationshipCoverage, boolean decompilerFailed) {}
|
||||
private record CallBoundaryObservation(List<String> limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls) {}
|
||||
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
|
||||
|
||||
@@ -65,7 +65,10 @@ func observe(_ request: Request) async throws -> [String: Any] {
|
||||
guard SHA256.hash(data: executableBytes).map({ String(format: "%02x", $0) }).joined() == request.sha256 else {
|
||||
try fail("target-mismatch", "Running executable bytes differ from the active target digest")
|
||||
}
|
||||
guard let windows = CGWindowListCopyWindowInfo([.optionIncludingWindow], request.window_id) as? [[String: Any]],
|
||||
// .optionIncludingWindow omits a selected window while it is off screen, which
|
||||
// is not absence: select from every window, and let capture report what it can.
|
||||
guard let windows = (CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]])?
|
||||
.filter({ ($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value == request.window_id }),
|
||||
windows.count == 1, let window = windows.first,
|
||||
(window[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == request.pid,
|
||||
(window[kCGWindowLayer as String] as? NSNumber)?.intValue == 0,
|
||||
|
||||
@@ -15,6 +15,8 @@ struct ProcessIdentityObservation: Encodable {
|
||||
let effective_uid: UInt32?
|
||||
let lifecycle: String?
|
||||
let relation: ProcessRelationObservation?
|
||||
/** Display-only kernel accounting name (p_comm, at most MAXCOMLEN bytes). */
|
||||
let name: String?
|
||||
let reason: String?
|
||||
}
|
||||
|
||||
@@ -31,6 +33,7 @@ struct ProcessMetadata {
|
||||
let parentPid: Int32
|
||||
let processGroupId: Int32
|
||||
let pointerSize: Int
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct ProcessSnapshot {
|
||||
@@ -92,7 +95,8 @@ func readProcessSnapshot(
|
||||
lifecycle: process.kp_proc.p_stat == SZOMB ? "zombie" : "non-zombie",
|
||||
parentPid: process.kp_eproc.e_ppid,
|
||||
processGroupId: process.kp_eproc.e_pgid,
|
||||
pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4
|
||||
pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4,
|
||||
name: processName(process)
|
||||
)
|
||||
)
|
||||
},
|
||||
@@ -103,6 +107,13 @@ func readProcessSnapshot(
|
||||
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(ENOMEM)")
|
||||
}
|
||||
|
||||
/** Decode the NUL-terminated accounting name; invalid UTF-8 is replaced for display. */
|
||||
private func processName(_ process: kinfo_proc) -> String {
|
||||
withUnsafeBytes(of: process.kp_proc.p_comm) { bytes in
|
||||
String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
func readProcessIdentities(pids: [Int32]) -> [ProcessIdentityObservation] {
|
||||
readProcessIdentities(pids: pids, snapshot: readProcessSnapshot())
|
||||
}
|
||||
@@ -130,6 +141,7 @@ func readProcessIdentities(
|
||||
effective_uid: nil,
|
||||
lifecycle: nil,
|
||||
relation: nil,
|
||||
name: nil,
|
||||
reason: "not-in-snapshot"
|
||||
)
|
||||
}
|
||||
@@ -144,6 +156,7 @@ func readProcessIdentities(
|
||||
parent_pid: metadata.parentPid,
|
||||
process_group_id: metadata.processGroupId
|
||||
),
|
||||
name: metadata.name,
|
||||
reason: nil
|
||||
)
|
||||
}
|
||||
@@ -157,6 +170,7 @@ private func unavailableIdentity(pid: Int32, reason: String) -> ProcessIdentityO
|
||||
effective_uid: nil,
|
||||
lifecycle: nil,
|
||||
relation: nil,
|
||||
name: nil,
|
||||
reason: reason
|
||||
)
|
||||
}
|
||||
|
||||
@@ -257,12 +257,12 @@ beyond already-connected devices (`adb connect`) are not exposed yet.
|
||||
|
||||
## Real verification
|
||||
|
||||
`npm run verify:adb` exercises every operation against a live device. It
|
||||
`npm run verify:adb` exercises observation and transfer operations against a live device. It
|
||||
requires `adb` (or `REA_ADB_PATH`) and any attached emulator or phone;
|
||||
`--serial` selects one when several are attached, `--pull PACKAGE` pulls a
|
||||
real package and verifies the returned digests against the files on disk,
|
||||
and `--install-apk PATH` drives a full install → resolve → uninstall
|
||||
lifecycle with a lane-owned probe.
|
||||
real package and verifies the returned digests against the files on disk.
|
||||
The optional `--install-apk` lifecycle is unavailable until the selected APK
|
||||
identity and device cleanup ownership can be established safely.
|
||||
|
||||
Record: verified against adb 34.0.5 on Linux against an Android 14
|
||||
(API 34) x86_64 emulator — all operations above, a 224-package inventory,
|
||||
|
||||
@@ -424,8 +424,10 @@ rea inspect-native-exports ./libexample.so --detail summary --evidence_output ./
|
||||
without an export directory is a complete empty inventory.
|
||||
- ELF rows list defined `.dynsym` symbols with global, weak or GNU-unique
|
||||
binding and default or protected visibility. Each row keeps `value`
|
||||
(`st_value` as stored; bit 0 marks Thumb code on 32-bit ARM), `size`, `type`
|
||||
and `binding`. ELF string tables do not declare a text encoding. Names that
|
||||
(`st_value` as stored; bit 0 marks Thumb code on 32-bit ARM), `size`, `type`,
|
||||
`binding` and `visibility` (`"default"` or `"protected"`; `"unknown"` in older
|
||||
historical Evidence where visibility was not recorded). ELF string tables do
|
||||
not declare a text encoding. Names that
|
||||
contain non-ASCII bytes are decoded as UTF-8 when those bytes form valid
|
||||
UTF-8; `name_utf8_decodable` records whether the non-ASCII name bytes are
|
||||
valid UTF-8 and `name_bytes_base64` retains those exact bytes (`null` for
|
||||
|
||||
+31
@@ -561,6 +561,25 @@ be verified. See [MCP contracts](mcp-contracts.md) for session lifecycle and
|
||||
[Ghidra first-query deadlines](mcp-contracts.md#ghidra-first-query-deadlines-and-recovery)
|
||||
for import, client timeouts and recovery.
|
||||
|
||||
A one-shot command releases its provider session and REA runtime before it
|
||||
prints a failure, so it omits the same-session reuse and
|
||||
`update_analysis_settings` guidance an MCP connection receives. Its
|
||||
`details.limit` reports `scope: "rea-invocation"` and
|
||||
`applies_at: "next_invocation"`; set the named `environment` (or the per-call
|
||||
`parameter`) for a new invocation and rerun. For example, a Ghidra decompile
|
||||
deadline names `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS`.
|
||||
|
||||
`rea debug-with-gdb` and `rea debug-with-rizin` hold their debugger only for the
|
||||
command's own process. If cleanup cannot confirm the debugger stopped, they print
|
||||
`cleanup_incomplete` and exit nonzero without stopping it. `remediation.action`
|
||||
names the process, as does `retained_debugger_pids` in `details.diagnostics`, or
|
||||
in `details.diagnostics.cleanup_error.details.diagnostics` when the command also
|
||||
failed or was interrupted. Exiting closes the debugger's command and output pipes.
|
||||
REA does not establish what the debugger or its target does next, so check that
|
||||
process. An MCP server that shuts down likewise exits with status 1 instead of
|
||||
waiting on a debugger it could not stop. It names the process on stderr and as
|
||||
`retained_debugger_pids` in its error log.
|
||||
|
||||
If one-shot analysis fails and closing its session also fails, the original
|
||||
error details remain inline and `details.cleanup_error` describes the later
|
||||
close failure. Each diagnostic retains its own reported resources; the later
|
||||
@@ -1286,6 +1305,18 @@ new file. A publication that has committed can win a later cancellation; the
|
||||
after commit, the `cleanup_incomplete` error reports the same file as
|
||||
`details.published_path` and `details.bytes`.
|
||||
|
||||
When a failure retains a completed observation and no bundle committed,
|
||||
`details.source_closure` states what that observation cites. `publication` is
|
||||
`not_published`. `records` holds authentic source Evidence this invocation
|
||||
captured, within 64 KiB. `omitted` lists up to 64 other identities and
|
||||
`omitted_count` the total: `not_captured` means the invocation holds no such
|
||||
record, so nothing is shown for it or its ancestors; `not_inlined` means it was
|
||||
captured but is not in this response. `complete` is true only when every cited
|
||||
record is in `records` or embedded in the observation. If examining the closure
|
||||
itself fails, `collection_failure` names the error type and the closure state is
|
||||
unknown; the original failure is still returned. Nothing is retained after exit,
|
||||
and running the command again is a new observation.
|
||||
|
||||
When the reader of piped output closes early, as `rea ... | head` does, the
|
||||
CLI stops writing, prints no error and exits `141` (`128 + 13`, the status of a
|
||||
SIGPIPE-terminated writer). The operation itself may have completed; only its
|
||||
|
||||
@@ -45,6 +45,10 @@ the client's configured command or reproduce the client's environment. If doctor
|
||||
passes but the client disconnects, inspect the client's startup error and registered
|
||||
launcher; see [verification scope](client-verification.md#launch-environment-verification).
|
||||
|
||||
`rea doctor` has the same limit for provider checks, and reports a provider setting
|
||||
the registration's file does not carry as a
|
||||
[non-blocking unknown](installation.md#check-readiness-for-your-task).
|
||||
|
||||
## Runtime settings and client deadlines
|
||||
|
||||
`update_analysis_settings` changes curated defaults inside the running REA
|
||||
|
||||
@@ -222,6 +222,25 @@ Error copying, attribution, redaction, and delivery must preserve these facts.
|
||||
Human-readable remediation explains the same failure; clients should not need
|
||||
to parse that sentence to identify an editable setting.
|
||||
|
||||
`scope` and `applies_at` describe a continuing caller, such as an MCP
|
||||
connection. A one-shot invocation, such as a CLI command, has released its
|
||||
provider session and exited its runtime before its caller reads the failure,
|
||||
so its adapter projects with an explicit `"invocation"` caller lifetime.
|
||||
`details.limit` then keeps every other field and reports
|
||||
`scope: "rea-invocation"`, `applies_at: "next_invocation"` and the launch
|
||||
`environment` seed when one exists. `agent_editable: true` still holds, because
|
||||
the caller chooses that environment value or per-call `parameter` for the new
|
||||
invocation; `rea_restart_required: false` means no running REA process needs a
|
||||
restart. A producer keeps guidance that holds only while its caller retains the
|
||||
session, such as "the Ghidra session remains usable" or a same-session runtime
|
||||
update, in `continuingCallerGuidance`; the shared presentation adds it only for
|
||||
a continuing caller. The producer's own message and remediation must stay true
|
||||
after the session is released. A composed error records each embedded child
|
||||
failure through `childFailureDiagnostics`, which snapshots it at composition
|
||||
for both lifetimes when they differ, so cleanup history keeps its state and
|
||||
the final projection selects the caller's form. Failures retained inside
|
||||
Evidence keep the continuing form, so CLI and MCP Evidence stay identical.
|
||||
|
||||
Cache policy is part of enforcement. Successful reusable analysis can survive
|
||||
a budget update. Transient Ghidra decompile failures remain uncached so a raised
|
||||
deadline can recover on the same Program. Admission checks still apply to reused
|
||||
@@ -286,7 +305,9 @@ whether live repair is possible.
|
||||
## What agents do
|
||||
|
||||
Use `details.limit` for a direct runtime update when it names an editable
|
||||
setting. Follow `remediation.action` for per-call, fixed and external controls.
|
||||
setting with `scope: "rea-runtime"`. For `scope: "rea-invocation"`, set its
|
||||
`environment` or `parameter` for a new invocation instead.
|
||||
Follow `remediation.action` for per-call, fixed and external controls.
|
||||
Inspect settings only when bounds or application details are missing. Restart only when it says the value is
|
||||
read at startup. A sentence that a control does not raise the limit is not a
|
||||
request to change it.
|
||||
|
||||
@@ -201,6 +201,9 @@ neither result means the requested host operation occurred. The existing
|
||||
`openExternal`, `openPath`, and `trashItem` blocks reject their promises.
|
||||
The `trashItem` block prevents that call from reaching the OS trash operation;
|
||||
it does not intercept other ways the application can remove files.
|
||||
Process rejection and uncaught-exception events are observed at dispatch without
|
||||
adding exception handlers. The runtime and application retain their warning,
|
||||
handler, and fatal-exit policies, including for rejected shell-block promises.
|
||||
CLI SIGINT/SIGTERM requests cancellation and awaits owned-process cleanup.
|
||||
Window IDs, URLs, and cached titles come from one main-process Electron snapshot,
|
||||
so a crashed renderer does not require a successful renderer-side title query.
|
||||
|
||||
@@ -172,6 +172,11 @@ have no file backing. ELF mappings are observed
|
||||
without independent format-specific load-image verification, as the result's
|
||||
status and limitations state.
|
||||
|
||||
Relocation rows retain Ghidra's `symbol` value, including an empty string or
|
||||
null when returned by Ghidra. It is a provider observation, not proof of the
|
||||
runtime-resolved address. Older Evidence may not have recorded this field;
|
||||
a relocation page requesting it reports that uncertainty.
|
||||
|
||||
For large relocation inventories, select MCP `inspect_native_load_image` with
|
||||
`detail: "summary"`. It returns verification state, observed identity and counts,
|
||||
while retaining complete Evidence. Use `normalized_result.parent_evidence_id`
|
||||
|
||||
@@ -43,7 +43,8 @@ rea decompile /absolute/path/to/program main --json
|
||||
|
||||
If the registration cannot be read or validated, provider diagnostics retain its
|
||||
selected `registration_path` and the configuration owner's setting/constraint
|
||||
facts in `configuration_details.settings`, plus `configuration_remediation`.
|
||||
facts in `configuration_details.settings`. The availability's `remediation`, and
|
||||
each operation it makes unavailable, carry the configuration owner's repair.
|
||||
`configured` means a path was selected, not that it is valid or an IDA connection
|
||||
was established; failed registrations remain unavailable. Correct
|
||||
`REA_IDA_MCP_CONFIG` in the next CLI invocation's environment or the MCP server's
|
||||
|
||||
+38
-6
@@ -390,6 +390,20 @@ A scoped report has `scope.mode: "explicit"`. Its `scope_checks` determine
|
||||
`environment_healthy` summarizes the full audit. `--target` adds a target check,
|
||||
but the report remains audit-wide unless a scope option is also supplied.
|
||||
|
||||
Provider checks read the terminal running doctor. A client-started server may
|
||||
receive a different environment, and doctor cannot observe it. When an available
|
||||
provider relies on a setting that a registration's file does not record, or
|
||||
records with other text, doctor adds a non-blocking
|
||||
`registration_environment:<client>` check with `classification: "unknown"`. The
|
||||
setting names appear in that registration's `provider_settings`. Selecting the
|
||||
client or an affected provider places the check in `scope_checks`; it never
|
||||
changes `healthy`, because the client may still pass the setting on. Preview
|
||||
`rea setup --client <client-id> --skill=false --dry-run` from that terminal and
|
||||
approve it to record the settings, then restart the client. To see what the
|
||||
running server can use, call `binary_session` in the client and read
|
||||
`result.tool_availability`. For Hopper, only an explicit `HOPPER_LAUNCHER_PATH`
|
||||
is compared.
|
||||
|
||||
An invalid `REA_LOG_LEVEL` fails every scope, including one client or the
|
||||
installed skill, because the executable rejects it before any command. A
|
||||
selected client also fails for every other setting that MCP startup rejects,
|
||||
@@ -666,7 +680,8 @@ Hopper is separate commercial software with its own license. Its free demo has
|
||||
vendor-defined limits, and a paid license is optional. REA reuses any detected
|
||||
installation and preserves Hopper during uninstall.
|
||||
|
||||
The automated Hopper installer supports macOS 12+ and x86_64 hosts running
|
||||
The automated Hopper installer supports macOS 12+, subject to the minimum
|
||||
macOS version of the current Hopper release, and x86_64 hosts running
|
||||
Ubuntu 24.04+, Fedora 41+, Nobara 44+, Arch Linux, CachyOS, or Omarchy. The
|
||||
official Linux packages are x86_64-only, so setup does not offer Hopper on other
|
||||
Linux architectures; configure [Ghidra](#ghidra) there instead. REA itself runs on any
|
||||
@@ -689,12 +704,19 @@ for installation. Setup cannot prompt when its output is redirected or during a
|
||||
container image build. To approve Hopper installation without prompts, run
|
||||
`rea setup --yes --install-hopper`.
|
||||
|
||||
If the Linux package download receives an HTTP failure, setup includes the
|
||||
observed HTTP status in its `download_failed` diagnostic. The status does not
|
||||
establish the underlying cause or guarantee that retrying will succeed.
|
||||
If a Hopper request fails on macOS or Linux, the setup result's `download`
|
||||
field names the request (`release_feed`, `package` or `public_key`), its `url`,
|
||||
and the observed outcome `kind`: `http_status` with `httpStatus`, `timeout`,
|
||||
`network`, `response_too_large`, or `url_refused`. The remediation names the
|
||||
same URL and outcome. HTTP 404 or 410 means the file is missing, so an
|
||||
immediate retry is unlikely to help; 5xx, 408 and 429 indicate a temporary
|
||||
service failure. The status does not establish the underlying cause or guarantee
|
||||
that retrying will succeed. Doctor and Linux launches report the same request
|
||||
and status when publisher verification cannot download the current package.
|
||||
|
||||
Hopper metadata and package downloads require HTTPS throughout the redirect
|
||||
chain; a redirect to HTTP is refused before that destination is requested.
|
||||
chain; a redirect to HTTP or to a URL carrying credentials is refused before
|
||||
that destination is requested, and those credentials are not reported.
|
||||
|
||||
On macOS, approved setup downloads the official DMG, checks its published size
|
||||
and digest, validates the application bundle, and atomically installs it to
|
||||
@@ -702,6 +724,15 @@ and digest, validates the application bundle, and atomically installs it to
|
||||
operator can choose its demo mode or activate an existing license. Homebrew and
|
||||
administrator access are not used.
|
||||
|
||||
Each Hopper release declares its own minimum macOS version, so the setup plan
|
||||
cannot confirm support before approval. Setup compares this Mac's version with
|
||||
the release metadata's `deployment_target` before downloading the DMG, and with
|
||||
the signed application's `LSMinimumSystemVersion` before copying it. If this Mac
|
||||
is older, setup fails with `incompatible_host_version`, names both versions, and
|
||||
installs nothing. Update macOS, or install a Hopper release that supports this
|
||||
macOS version yourself and set `HOPPER_LAUNCHER_PATH`. An existing Hopper
|
||||
installation is not checked against the current release's minimum.
|
||||
|
||||
The Hopper launcher action used by REA creates a document from an executable;
|
||||
its supported command-line interface does not attach to an already-open
|
||||
document. While another live REA session owns the same target and loader
|
||||
@@ -1314,7 +1345,8 @@ fails with `provider_timeout` and that message; other Ghidra tools stay
|
||||
available. If the caller also cancels while the timeout settles, the session
|
||||
still closes and the message says so. Session reuse concerns an active MCP or
|
||||
library session; a standalone CLI command still closes its own session when it
|
||||
finishes. In MCP, `ghidra.decompile_timeout_seconds` can override the launch
|
||||
finishes, so its timeout names `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS` for a new
|
||||
invocation instead. In MCP, `ghidra.decompile_timeout_seconds` can override the launch
|
||||
value for newly accepted operations on the existing Program; explicit `null`
|
||||
removes the deadline. A transient timeout remains uncached so the same function
|
||||
can be retried after an update. See the
|
||||
|
||||
@@ -173,7 +173,14 @@ Results follow retained node order and default to offset 0 and limit 64. Supply
|
||||
to examine relationships from that node. Other typed seeds do not search labels.
|
||||
|
||||
The result contains the matching basis, complete reachable subgraph, terminal
|
||||
paths, authority summaries, and native handoffs. A handoff binds
|
||||
paths, authority summaries, and native handoffs. A bidirectional trace of a
|
||||
large application can exceed one MCP response. Over MCP, pass
|
||||
`view: {"kind":"summary"}` or a `feature-trace` page of `paths`, `nodes`,
|
||||
`edges`, `seed_matches` or `native_handoffs` to retain the complete trace in
|
||||
the session and return only selected rows with the trace's identity and
|
||||
coverage. Page the retained trace with `inspect_analysis_view`; an oversized
|
||||
complete reply offers its first `paths` page as a ready-to-call continuation.
|
||||
See [MCP contracts](mcp-contracts.md) for filters. A handoff binds
|
||||
the exact native artifact digest and requested exports from the application
|
||||
graph. Trace direction selects the reachable addons; requested
|
||||
exports for each selected addon come from the complete supplied graph, so an
|
||||
|
||||
@@ -876,8 +876,22 @@ and Evidence. If that expansion does not fit, the file node retains
|
||||
`static_analysis` and any admitted `semantic_module_analysis` observations,
|
||||
including their exact values and source locations. Use the public modules item
|
||||
view to read them. The graph marks those unexpanded relationships as unknown.
|
||||
If cumulative result capacity is exhausted, analysis stops; inventoried but
|
||||
unexamined files remain present with explicit unknown scopes.
|
||||
|
||||
The parent charges everything it retains against the heap that was free when
|
||||
analysis started, less a transfer reserve. Semantic facts and expanded
|
||||
relationships stop at half of that capacity, and a source refused there keeps
|
||||
its static facts with the refusal reported. Static facts can continue to three
|
||||
quarters of it while the live heap also has room for another copy of what it
|
||||
holds, so results charged for earlier sources do not cost later sources their
|
||||
static facts when the heap can still hold them. When that share runs short,
|
||||
relationships that were reserved but not yet expanded are given up before any
|
||||
static facts, and each affected file reports the omission.
|
||||
|
||||
If static facts themselves exhaust that share, or the parent's live heap cannot
|
||||
hold the next checkpoint, analysis stops; inventoried but unexamined files remain
|
||||
present with explicit unknown scopes. The failure reports the limit that applied
|
||||
in bytes, what was still available, the live heap in use and what the checkpoint
|
||||
needed.
|
||||
|
||||
### Large local JSON inputs
|
||||
|
||||
@@ -1002,7 +1016,12 @@ reason in the renderer observations.
|
||||
HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR
|
||||
line endings, preserving UTF-16 columns.
|
||||
Inline JavaScript in local HTML, meaning classic or module `<script>` bodies
|
||||
without a `src` attribute, is not analyzed. Each HTML file that has one gets an
|
||||
without a `src` attribute, is not analyzed. Embedded SVG scripts use `href`
|
||||
(or `xlink:href` when `href` is absent) instead: an existing reference, even
|
||||
an empty one, suppresses the inline body. SVG's `src` and HTML-only `language`
|
||||
attributes do not suppress its JavaScript body. SVG references and inline
|
||||
bodies share the same inert parsing and source coordinates as HTML scripts;
|
||||
template content stays inert. Each HTML file that has an inline body gets an
|
||||
unknown `parse-html-inline-scripts` scope with the exact body count and the
|
||||
script type and source range of the first 100 bodies; beyond that, coverage is
|
||||
truncated under the `html_inline_script_locations_per_file` limit, and the
|
||||
|
||||
@@ -65,8 +65,9 @@ confidence and basis, and does not prove a backend endpoint or reachability.
|
||||
Pages obey the shared 1 MiB projection budget; MCP transport applies its own
|
||||
wire-size guard. A byte-limited page returns a continuation; an indivisible
|
||||
oversized record or metadata returns a typed resource error. Select fewer
|
||||
fields or retrieve the complete retained parent through `get_evidence_bundle`,
|
||||
`export_evidence_bundle`, or the CLI's exported bundle. Full Evidence is never
|
||||
fields or retrieve the complete retained parent through `get_evidence_bundle`
|
||||
(`detail: "records"` with its `evidence_id`), `export_evidence_bundle`, or the
|
||||
CLI's exported bundle. Full Evidence is never
|
||||
silently truncated.
|
||||
|
||||
Collections are `summary`, `routes`, `occurrences`, `sources`, and `unknowns`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Kuna provider
|
||||
|
||||
[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. For a new installation, use Kuna 1.986, REA's recommended verified release. REA uses a `kuna` you supply; it does not download, install, update or bundle Kuna. REA accepts other releases, which it reports as unverified in `rea doctor` and in each analysis profile. The 1.986 real-provider checks ran on macOS arm64; Linux and other hosts have not been verified with that release.
|
||||
[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. For a new installation, use Kuna 1.989, REA's recommended verified release. REA uses a `kuna` you supply; it does not download, install, update or bundle Kuna. REA accepts other releases, which it reports as unverified in `rea doctor` and in each analysis profile. The verified-release flag compares the reported version; it does not authenticate the executable. The 1.989 real-provider checks ran on macOS arm64; Linux and other hosts have not been verified with that release.
|
||||
|
||||
Kuna is static: it reads the selected image and never executes it.
|
||||
|
||||
@@ -47,6 +47,8 @@ Windows is not supported yet. Supported targets are ELF and thin Mach-O executab
|
||||
| `xrefs` | Exact-address incoming reference sites, with forwarding aliases filtered explicitly |
|
||||
| `analyze_function` | Pseudocode and assembly from the export, plus call relationships; typed references, comments, strings and CFG remain unavailable |
|
||||
|
||||
Kuna's inventory gives some entries aliases, such as ELF versioned-symbol names and generated `sub_` names. Name selectors accept an alias that names one entry and list every entry a shared alias names. `search_procedures` matches aliases too: each entry appears once under its primary name, with the matching aliases in `matched_aliases`. `list_procedures` shows primary names only.
|
||||
|
||||
Typed procedure references, basic blocks, raw-memory tools, annotations and type recovery are not available from this provider. Use another provider for those operations.
|
||||
|
||||
## How answers are produced
|
||||
|
||||
+136
-6
@@ -318,6 +318,10 @@ offered for completed `analyze_function` results only after session recording is
|
||||
acknowledged; the complete result and its retention reference remain intact.
|
||||
Oversized native inventories likewise include a ready-to-call first
|
||||
`native-inventory` page. Reduce its limit or select fields when a row is large.
|
||||
An oversized `trace_application_feature` result includes a first `feature-trace`
|
||||
page of its retained terminal `paths`; page `nodes` or `edges` from there.
|
||||
An oversized `batch_decompile` result includes a first body-free
|
||||
`batch-decompile` page of its retained `items`; read bodies from there.
|
||||
|
||||
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.
|
||||
@@ -331,7 +335,26 @@ a provider. `retention: "complete-record"` describes stored Evidence, while
|
||||
`native_dossier.value_flow` independently reports truncation, unavailable or
|
||||
unknown metadata. Missing omission counts remain null. Summary output carries
|
||||
every matching record; actual transport admission still applies. Omitted
|
||||
`detail` preserves complete canonical bundle delivery; filters require summary.
|
||||
`detail` preserves complete canonical bundle delivery; filters require summary
|
||||
or records.
|
||||
|
||||
To read a discovered record without transferring the whole bundle, call
|
||||
`get_evidence_bundle` with `detail: "records"` and the same exact `filters`.
|
||||
The result (`kind: "evidence-record-selection"`) pages the matching complete
|
||||
records in canonical bundle (Evidence ID) order with `offset` (default 0) and
|
||||
`limit` (default 8), and reports `matching_records`, `total_retained_records`
|
||||
and `next_offset`. Each record is the retained Evidence itself, with an
|
||||
unchanged ID, subject digest, provider, profile and readback, so it works for
|
||||
any operation, including records that `inspect_analysis_view` does not support
|
||||
such as annotations. `missing_evidence_links` lists direct `evidence_links` of
|
||||
the returned records that this session or bundle does not retain; links are not
|
||||
traversed. A selection is not an Evidence bundle: it omits manifests and
|
||||
residual unknowns, cannot be imported, and describes historical observations
|
||||
rather than current provider or database state. `offset` and `limit` require
|
||||
`detail: "records"`. If one page exceeds the response budget, select fewer
|
||||
records or one `evidence_id`; a single record larger than one response still
|
||||
needs `export_evidence_bundle`, or `inspect_analysis_view` pages where its parent
|
||||
supports views.
|
||||
|
||||
Residual-unknown mutations reject missing Evidence or unknown references and
|
||||
unproven verified resolutions with nonretryable `invalid_request` diagnostics.
|
||||
@@ -367,8 +390,26 @@ that revision. If a list response overflows, retry on the same connection with
|
||||
the same filters and offset and a smaller limit; export if one head still cannot
|
||||
fit.
|
||||
|
||||
To find missing sources before a downstream analysis refuses them, read
|
||||
`missing_direct_links` from the summary, or call `get_evidence_bundle` with
|
||||
`detail: "links"`. The result (`kind: "evidence-link-inspection"`) transfers no
|
||||
record payload. `filters` select the starting records, while membership is
|
||||
checked against every record in the session or file, so a filtered-out source is
|
||||
`retained`, not missing. Rows (`evidence_id`, `linked_evidence_id`, `status`) are
|
||||
in canonical order, default to `link_status: "missing"` and page with `offset` and
|
||||
`limit` (default 64); `links` counts every inspected link regardless of that
|
||||
selection. `traversal: "direct"` (the default) reads the selected records' own
|
||||
links and sets `retained_closure` to null, because it makes no closure claim.
|
||||
`traversal: "retained-closure"` also follows retained links, as composed workflows
|
||||
such as `build_call_path` do, and reports `complete` or `incomplete`; anything
|
||||
beyond a missing link remains unknown. Missing means absent from this session or
|
||||
file, not nonexistent, and neither status describes analysis coverage. Importing
|
||||
a missing source changes this membership without changing any Evidence ID.
|
||||
|
||||
For a portable exported bundle, the CLI shares this projection:
|
||||
`rea inspect-evidence-bundle '{"path":"/tmp/evidence.json","detail":"summary","filters":{"operation":"analyze_function"}}' --format json`.
|
||||
`"detail":"records"` with an `evidence_id` filter returns that complete record,
|
||||
and `"detail":"links"` reads link membership.
|
||||
The CLI validates and materializes that local bundle under its existing memory
|
||||
budget; summary delivery does not make arbitrary large files cheap to import.
|
||||
|
||||
@@ -572,6 +613,69 @@ the working directory). Save its complete `--json` Evidence, then use
|
||||
`inspect-analysis-view` with an `evidence-file` source and the same view. Neither
|
||||
adapter executes the target or repeats analysis for retained queries.
|
||||
|
||||
`trace_application_feature` follows the same delivery contract. Omitting `view`
|
||||
returns the complete trace. `view: {"kind":"summary"}` or a `feature-trace` page
|
||||
retains the complete trace in the session first and returns only the selected
|
||||
facts; query further pages with `inspect_analysis_view` and the returned
|
||||
`parent_evidence_id`. The summary carries the trace identity (`trace_id`,
|
||||
`source_evidence_id`, `source_graph_id`, seed and direction), the producer's
|
||||
`summary` counts, `trace_coverage`, the traced subgraph's `graph_coverage`,
|
||||
`evidence_links` and any no-match `semantic_discovery`. A `feature-trace` page
|
||||
selects one `collection` in retained order and repeats that context:
|
||||
|
||||
- `paths`: exact terminal paths; `where.node_ids` selects paths visiting any of
|
||||
those nodes and `where.end_kinds` selects their terminal node kinds.
|
||||
- `nodes`: compact rows with identity, `seed`, `observation_count` and distinct
|
||||
authority/state/confidence `basis`; `where.node_ids` and `where.kinds` filter
|
||||
exactly, and `include_observations: true` adds each node's complete retained
|
||||
observations.
|
||||
- `edges`: exact relationships; `where.node_ids` matches either endpoint and
|
||||
`where.relations` the relation.
|
||||
- `seed_matches` and `native_handoffs`: the producer's exact rows.
|
||||
|
||||
Omitted filters select all; empty arrays select none. `offset` and `limit`
|
||||
default to 0 and 64. Page `coverage` counts the selected collection only; it
|
||||
never upgrades `trace_coverage`, and graph connectivity does not prove runtime
|
||||
reachability. The CLI keeps complete trace JSON; save it and use
|
||||
`inspect-analysis-view` with an `evidence-file` source.
|
||||
|
||||
### Selected batch decompile results
|
||||
|
||||
`batch_decompile` returns every pseudocode body by default. `detail: "summary"`
|
||||
retains the complete batch in the session first and returns its producer counts,
|
||||
`source_provider` (the composing workflow), `source_analysis_profile_digest`,
|
||||
`decompiling_provider`, procedure identity counts, total and largest
|
||||
`pseudocode` size, and `limitation_count`. A server that retains no Evidence
|
||||
refuses the summary before analysis. Further views use the returned
|
||||
`parent_evidence_id` through `inspect_analysis_view`, or a saved Evidence file or
|
||||
portable bundle, and never repeat decompilation.
|
||||
|
||||
A `batch-decompile` view selects one `collection` and repeats the batch counts
|
||||
and provider identities:
|
||||
|
||||
- `items`: rows in retained order with their original `index`, so duplicate
|
||||
selectors stay distinct. A row keeps the selector in `address`, its `procedure`
|
||||
identity and `status`, plus `pseudocode_code_units` or its exact `error` and
|
||||
`limitations`. `where.indices`, `where.addresses`, `where.procedure_addresses`
|
||||
and `where.statuses` filter exactly and combine; an item with unknown identity
|
||||
never matches `procedure_addresses`. Omitted filters select all, and empty
|
||||
arrays select none. `offset` and `limit` default to 0 and 64.
|
||||
`include_pseudocode: true` adds each selected body.
|
||||
- `pseudocode`: one `index`'s text in UTF-16 code-unit pages. `offset` and `limit`
|
||||
default to 0 and 16,384, and `coverage` measures that body. An offset inside a
|
||||
surrogate pair is refused, and a limit ending inside one stops before it. An
|
||||
error item has no text and is refused with a pointer to `items`.
|
||||
|
||||
Page `coverage` counts the selection only: exhausting it establishes nothing
|
||||
about functions outside the batch. Evidence `limitations` aggregate every item's
|
||||
provider observations and are not attributed to one successful item; only error
|
||||
items keep their own. A page over the response budget offers a smaller selection
|
||||
from the same `source`: fewer rows, or one body's `pseudocode` pages when a single
|
||||
row does not fit. An inline source offers none, because the record cannot be
|
||||
repeated in that reply; resend it with a smaller selection.
|
||||
`export_evidence_bundle` remains the complete alternative. The CLI has no batch command; save the Evidence and use
|
||||
`inspect-analysis-view` with an `evidence-file` source or `--evidence-bundle`.
|
||||
|
||||
Completed `inspect_artifact` and atomic `inventory_artifact` Evidence also
|
||||
support summary and inventory views. A summary reports the manifest's expected
|
||||
counts separately from available record counts, observed entry kinds, inventory
|
||||
@@ -742,13 +846,27 @@ requirements. Compare the union of group names with `tools/list` when checking
|
||||
that a client received the full catalog. An operation absent from all groups is
|
||||
missing from the complete inventory; it is not an implicit unavailable entry.
|
||||
|
||||
Availability also reflects the active provider client's dispatched requests. A
|
||||
refusal or provider failure observed for an operation keeps that operation
|
||||
unavailable until a request for it dispatched later succeeds. A provider adapter
|
||||
or process failure also marks that provider's other declared-available
|
||||
operations unavailable until any request to that provider dispatched later
|
||||
succeeds; their remediation states that the status was inferred rather than
|
||||
observed for them. Recovery is not proof that each operation works, and failures
|
||||
observed on other operations or providers remain. Outcomes are ordered by
|
||||
dispatch to the provider client rather than reply completion, so a late reply
|
||||
from an older request does not override a newer outcome. Request deadlines and
|
||||
cancellations neither mark nor clear availability. A new provider client starts
|
||||
without these outcomes.
|
||||
|
||||
`detail` selects the projection and the result echoes it. The default
|
||||
`summary` keeps open state, target identity, the binding, current-route
|
||||
`capabilities`, run, activity and health status, and each provider candidate's
|
||||
identity, selection, and availability and target-support `status`, `code`, and
|
||||
`reason`. It lists the fields it leaves out in `omitted_fields`: provider
|
||||
`diagnostics`, candidate `capabilities`, and each tool group's static `surface`,
|
||||
`effects`, and `annotations`. Omitted fields are not empty or false.
|
||||
`reason`. It lists the fields it leaves out in `omitted_fields`: an unavailable
|
||||
candidate's `remediation`, provider `diagnostics`, candidate `capabilities`, and
|
||||
each tool group's static `surface`, `effects`, and `annotations`. Omitted fields
|
||||
are not empty or false.
|
||||
`detail: "complete"` returns them inline, groups tools by those flags too, and
|
||||
has no `omitted_fields`. The output schema keys each result's candidate,
|
||||
tool-group and omission fields by its `detail`. CLI `capabilities` and
|
||||
@@ -801,7 +919,9 @@ set, `shared_limitations` is the ordered common prefix; append a group's ordered
|
||||
`effects` arrays use the same true-only closed-vocabulary rule as tool
|
||||
availability. Expand every group operation while retaining its provider
|
||||
identity, availability, reason, rejection code, effects, and ordered
|
||||
limitations.
|
||||
limitations. An unavailable group carries `remediation` when its readiness
|
||||
owner supplied a repair instruction; `tool_availability` uses that instruction
|
||||
rather than the failure description.
|
||||
|
||||
`binary_session.analysis_provider_candidates` is authoritative for deep-engine
|
||||
discovery. Each candidate's capability sets, returned with complete detail,
|
||||
@@ -876,6 +996,14 @@ is `unavailable` with a reason when ownership could not be revalidated, or
|
||||
at that bounded check. These are historical snapshots, not live process
|
||||
inventories, and do not claim that no short-lived descendant existed.
|
||||
|
||||
After a failed close retires the target, `binary_session.cleanup_retry` names
|
||||
`close_binary` only while that session still retains the cleanup bound to the
|
||||
close error. Target-dependent availability then gives the same conditional
|
||||
cleanup guidance before suggesting `open_binary`. The hint disappears after
|
||||
cleanup succeeds. A pending flag alone does not establish this retry: active
|
||||
targets, an in-flight initial close, and external-mutation blockers keep their
|
||||
own recovery and do not receive this hint.
|
||||
|
||||
`binary_session.cleanup_pending` reports whether the session retains a provider
|
||||
client, session file resource, or provider-candidate resource whose cleanup
|
||||
has not succeeded. It can be true with
|
||||
@@ -1351,7 +1479,9 @@ cursor outside a procedure is represented as
|
||||
procedure resolution returns typed unreadable output; provider errors never
|
||||
become absence from their message text. `inspect_address_context` requires an explicit address and
|
||||
returns name, procedure, comment, inline-comment, and bookmark facets;
|
||||
unsupported facets are local `unavailable` outcomes. Use `current_document`,
|
||||
unsupported facets are local `unavailable` outcomes. A facet whose input is
|
||||
rejected includes its canonical `error` projection, including any structured
|
||||
issues, alongside the other facet outcomes. Use `current_document`,
|
||||
`current_address`, and `current_procedure` for direct single-field lookups; use
|
||||
the aggregate tools when you need related context together.
|
||||
|
||||
|
||||
@@ -68,7 +68,10 @@ function renamed to `dead` remains selectable by name. Use the names returned
|
||||
by the inventory, including their namespaces and any platform symbol prefix.
|
||||
Ghidra also accepts secondary symbols at a function entry, such as an imported
|
||||
`_main` label. A label inside a function is not a procedure-name selector; use
|
||||
its address when selecting the containing function.
|
||||
its address when selecting the containing function. `search_procedures` matches
|
||||
the same names, in leaf or qualified spelling: the function appears once with
|
||||
its primary name as `value`, and `matched_aliases` lists the qualified names of
|
||||
the secondary symbols that matched.
|
||||
Overloads can share a fully qualified name; ambiguity errors return every
|
||||
matching entry address so the caller can select the intended function directly.
|
||||
|
||||
@@ -198,6 +201,11 @@ remains valid. This tool
|
||||
has no dedicated CLI command; the existing `decompile` command remains the
|
||||
single-procedure CLI operation.
|
||||
|
||||
`detail: "summary"` retains the complete batch and returns only its counts,
|
||||
identities and sizes. `inspect_analysis_view` then reads rows or one body from
|
||||
the retained record, an Evidence file or a portable bundle without repeating
|
||||
decompilation: see [selected batch results](mcp-contracts.md#selected-batch-decompile-results).
|
||||
|
||||
Each batch schedules one provider read at a time, but has no shared input-count
|
||||
or aggregate retained-output allowance. It retains every requested row and full
|
||||
pseudocode body through Evidence construction. The MCP response budget checks
|
||||
@@ -425,7 +433,12 @@ Within one value trace, each exact procedure selector is attempted at most once,
|
||||
including failed analyses. A failed seed returns the provider's original typed
|
||||
failure and corrective guidance; it cannot establish a trace or target identity.
|
||||
Failures while expanding a validated seed remain unknown observations and do not
|
||||
establish canonical function identity. A successful seed without a native def-use
|
||||
establish canonical function identity. A cancelled expansion read returns the
|
||||
cancellation. An expansion read whose provider cannot confirm cleanup of
|
||||
resources it owns stops the trace: the result is that provider's own
|
||||
`cleanup_incomplete` failure with its resources and any bound cleanup retry, and the work
|
||||
admitted before the stop is retained as partial Evidence with a located unknown
|
||||
at the stopping read. A successful seed without a native def-use
|
||||
model still returns its procedure and an explicit coverage unknown. A new trace
|
||||
can retry failed selectors.
|
||||
|
||||
@@ -434,8 +447,9 @@ page omits retained nodes, and the edges they source, before `offset` or after
|
||||
`next_offset`. Every page repeats `traversal`: `complete`, or `truncated` with
|
||||
`reasons` naming each observed cause: the `max_depth`, `max_functions`,
|
||||
`max_call_sites`, `max_nodes` or `max_edges` budget, the fixed
|
||||
`node_payload_bytes` allowance, `provider_pcode_truncated`, or
|
||||
`ambiguous_call_candidates`. Each cause also has a located unknown. A budget is
|
||||
`node_payload_bytes` allowance, `provider_pcode_truncated`,
|
||||
`ambiguous_call_candidates`, or, only in a stopped trace's partial Evidence,
|
||||
`cleanup_incomplete`. Each cause also has a located unknown. A budget is
|
||||
reported only after it rejects work, so an exactly filled budget is not
|
||||
truncation; a depth or candidate limit is reported only when no other path
|
||||
analyzes or queues that destination. `total_nodes` and `total_edges` count
|
||||
@@ -990,7 +1004,9 @@ with a path-specific gap and incomplete capture coverage. Null alone does not
|
||||
establish that an accessibility value was successfully read or absent.
|
||||
|
||||
An initial PID or window mismatch is `invalid_request` on `pid` or
|
||||
`window_id` and leaves provider availability intact. An unavailable executable
|
||||
`window_id` and leaves provider availability intact. A selected window that
|
||||
exists off screen is not a mismatch: selection succeeds and the permission and
|
||||
capture checks report their own outcome. An unavailable executable
|
||||
URL or unresolved executable path leaves identity unknown and reports
|
||||
`capability_unavailable`. Identity or window loss
|
||||
after the initial capture remains an observation failure with collected facts.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+46
-1186
File diff suppressed because it is too large.
Load diff
@@ -119,7 +119,7 @@ Then rerun this installer.",implemented,verified by shell branch inspection
|
||||
90,install.sh:version mismatch,Installed rea reports a different version,installed version ACTUAL does not match EXPECTED.,Low risk; gives exact integrity mismatch,Keep current copy,unchanged,verified by exact installer test
|
||||
90.5,@modelcontextprotocol/server:tool input validation,Caller sends arguments that violate a declared tool schema,"Canonical invalid_request with the SDK diagnostic plus recovery.kind=inspect_input_contract and a bounded instruction to read the advertised schema or call tools/list",Keeps field/type correction while telling the agent how to recover; diagnostics over 4 KiB stay on the existing retained-error path,Normalize ordinary validation failures and retain oversized diagnostics,implemented,verified by real in-memory and stdio MCP client/server calls with wrong argument types
|
||||
90.6,incur:unknown CLI command,Caller enters an unknown REA command,"'COMMAND' is not a command for 'rea'. Suggested command: rea --help",Low risk; identifies input and exact discovery action,Keep framework-generated copy,unchanged,verified by real packaged dispatcher invocation
|
||||
90.7,src/ghidra/GhidraProviderClient.ts:projectPrimarySessionError,One procedure's decompile reaches REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS,"Ghidra decompilation of {entry} exceeded the {N}-second limit set by REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS. Only this procedure's decompilation was stopped; the Ghidra session remains usable.",Low risk; names the setting and keeps the session,Keep current copy,implemented,provider projection test and real-provider lane
|
||||
90.7,src/ghidra/GhidraProviderClient.ts:projectPrimarySessionError,One procedure's decompile reaches REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS,"Ghidra decompilation of {entry} exceeded the {N}-second limit set by REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS. Only this procedure's decompilation was stopped. (MCP adds: The Ghidra session remains usable.)",Low risk; names the setting; only a continuing MCP caller is told the session remains usable,Keep current copy,implemented,"provider projection test, CLI/MCP fixture-bridge boundary test and real-provider lane"
|
||||
90.8,src/ghidra/GhidraFileOffsetFailure.ts:ghidraFileOffsetUnavailable,"address_to_file_offset receives a well-formed address that is unmapped, uninitialized, without an authoritative target-file source, ambiguous or beyond the exact JSON integer range",{bridge reason}. REA establishes no single exact original-file offset for this well-formed address and does not derive or choose one.,Previous copy called the input invalid and directed schema repair for a valid address,"Keep the bridge reason as the headline, point to inspect_native_load_image, omit recovery.kind=inspect_input_contract and record details.issues[0].observed.file_mapping",implemented,provider projection tests; MCP and CLI analysis path through the real Ghidra client with a simulated bridge; real Ghidra not run
|
||||
91,src/application/binaryDiagnostics/ByteEntropyService.ts (issues); src/domain/analysisErrorPresentation.ts (recovery),Invalid file or window,Expected a regular file.; Selected file could not be found.; Selected byte window starts beyond the end of the file.; Selected byte window extends beyond the end of the file.; File size exceeds exact numeric offset representation.,Invalid selection cannot produce complete entropy Evidence,"Read the declared input schema or CLI usage for inspect_byte_entropy; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.",unchanged,Executed CLI/stdio MCP: path/offset/length refusals and tiny + system-binary controls; shared canonical recovery; service/MCP tests check classification and fields; remaining listed errors source-inspected
|
||||
92,src/domain/analysisView/byteEntropyView.ts,Non-summary entropy view,Byte entropy Evidence supports the summary view; retrieve parent Evidence for the complete block series.,Unsupported view selection,Use the summary view or retrieve complete parent Evidence,implemented,typed input rejection
|
||||
|
||||
|
@@ -83,7 +83,8 @@ valid: partial results state whether WABT validation is unknown, rejected the
|
||||
module, or succeeded before a later inspection failure.
|
||||
CLI errors include this partial Evidence inline. MCP errors can return its
|
||||
`retained-evidence` reference in `details.partial_observation`; use
|
||||
`get_evidence_bundle` with `detail: "complete"` to retrieve the matching record.
|
||||
`get_evidence_bundle` with `detail: "records"` and that `evidence_id` filter to
|
||||
retrieve the matching record.
|
||||
|
||||
Core validation remains authoritative when an optional custom section cannot
|
||||
be interpreted. WABT warning output is preserved in `custom_section_warnings`;
|
||||
|
||||
@@ -87,7 +87,8 @@ example under Smart App Control or an App Control policy, reports failure stage
|
||||
invocation or invalid inspection uses `runtime_unavailable` and doctor
|
||||
classification `runtime_failure`; it does not
|
||||
mean Windows x64 itself is unsupported. Provider diagnostics retain the failure
|
||||
stage under `windows_native_failure`, together with the corresponding remediation.
|
||||
stage under `windows_native_failure`; the availability's `remediation` carries
|
||||
the corresponding repair.
|
||||
Self-test failures preserve the native operation and OS error text. Check the
|
||||
process's writable local NTFS temporary directory and the reported filesystem,
|
||||
DACL, Job Object or cleanup constraint, then rerun doctor after resolving it.
|
||||
|
||||
@@ -81,6 +81,51 @@
|
||||
// In this case, there is no squashed merge commit. This could be a simple
|
||||
// merge commit, a rebase merge commit, or a direct commit to the branch.
|
||||
// Fallback to fetching the list of commits from the REST API. In the future
|
||||
@@ -1154,15 +1154,40 @@
|
||||
* @returns {string} HTML URL of the new file
|
||||
*/
|
||||
async createFileOnNewBranch(filename, contents, newBranchName, baseBranchName) {
|
||||
- // create or update new branch to match base branch
|
||||
- await this.forkBranch(newBranchName, baseBranchName);
|
||||
- // use the single file upload API
|
||||
+ // Preserve existing notes until the replacement file is committed.
|
||||
+ // A failed write must not leave an open proposal pointing at a missing file.
|
||||
+ if (!(await this.getBranchSha(newBranchName))) {
|
||||
+ const baseSha = await this.getBranchSha(baseBranchName);
|
||||
+ if (!baseSha) {
|
||||
+ throw new Error(`Unable to find base branch: ${baseBranchName}`);
|
||||
+ }
|
||||
+ await this.createNewBranch(newBranchName, baseSha);
|
||||
+ }
|
||||
+ let existingSha;
|
||||
+ try {
|
||||
+ const { data } = await this.octokit.repos.getContent({
|
||||
+ owner: this.repository.owner,
|
||||
+ repo: this.repository.repo,
|
||||
+ path: filename,
|
||||
+ ref: newBranchName,
|
||||
+ });
|
||||
+ if (Array.isArray(data) || data.type !== 'file' || !data.sha) {
|
||||
+ throw new Error(`Expected a file at ${filename} on branch: ${newBranchName}`);
|
||||
+ }
|
||||
+ existingSha = data.sha;
|
||||
+ }
|
||||
+ catch (error) {
|
||||
+ if (!(error instanceof request_error_1.RequestError && error.status === 404)) {
|
||||
+ throw error;
|
||||
+ }
|
||||
+ }
|
||||
const { data: { content }, } = await this.octokit.repos.createOrUpdateFileContents({
|
||||
owner: this.repository.owner,
|
||||
repo: this.repository.repo,
|
||||
path: filename,
|
||||
// contents need to be base64 encoded
|
||||
- content: Buffer.from(contents, 'binary').toString('base64'),
|
||||
+ content: Buffer.from(contents, 'utf8').toString('base64'),
|
||||
+ ...(existingSha ? { sha: existingSha } : {}),
|
||||
message: 'Saving release notes',
|
||||
branch: newBranchName,
|
||||
});
|
||||
--- a/build/src/github.d.ts
|
||||
+++ b/build/src/github.d.ts
|
||||
@@ -47,6 +47,8 @@
|
||||
|
||||
@@ -60,11 +60,11 @@ function readinessLines(markdown) {
|
||||
let comment = false;
|
||||
for (const source of lines) {
|
||||
if (fence) {
|
||||
const closing = /^ {0,3}(`+|~+)\s*$/u.exec(source);
|
||||
if (
|
||||
new RegExp(
|
||||
`^ {0,3}${fence.character}{${fence.length},}\\s*$`,
|
||||
"u",
|
||||
).test(source)
|
||||
closing !== null &&
|
||||
closing[1][0] === fence.character &&
|
||||
closing[1].length >= fence.length
|
||||
)
|
||||
fence = undefined;
|
||||
continue;
|
||||
|
||||
@@ -76,6 +76,7 @@ const PATH_SCOPES = {
|
||||
"src/frida/frida-bindings.d.ts",
|
||||
"src/objdump/**",
|
||||
"src/cli/reverseEngineeringCommands.ts",
|
||||
"src/process/ownedDebuggerCleanupError.ts",
|
||||
"src/process/ownedDebuggerSession.ts",
|
||||
"src/rizin/**",
|
||||
"src/contracts/*ToolContracts.ts",
|
||||
@@ -103,6 +104,7 @@ const PATH_SCOPES = {
|
||||
"tests/boundary/providers/RizinInferiorTty.test.ts",
|
||||
"tests/boundary/providers/RizinTestInferiorTty.ts",
|
||||
"tests/boundary/process/ownedDebuggerSession.test.ts",
|
||||
"tests/boundary/cli/debuggerOneShotExit.test.ts",
|
||||
"tests/boundary/cli/gdbStartupCleanupCli.test.ts",
|
||||
"tests/boundary/cli/reverseEngineeringCommands.test.ts",
|
||||
"tests/boundary/mcp/cutter*.test.ts",
|
||||
@@ -163,6 +165,9 @@ const PATH_SCOPES = {
|
||||
"tests/boundary/mcp/nativeDemangleMcp.test.ts",
|
||||
"tests/boundary/mcp/nativeMachoSummaryMcp.test.ts",
|
||||
"tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts",
|
||||
"tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts",
|
||||
"tests/boundary/process/nativeUiVerifierLane.test.ts",
|
||||
"tests/conformance/native/ui-offscreen.swift",
|
||||
"tests/boundary/cli/nativeSignatureCli.test.ts",
|
||||
"tests/boundary/cli/nativeMachoArm64_32Cli.test.ts",
|
||||
"tests/boundary/cli/nativeMachoEncryptionCli.test.ts",
|
||||
|
||||
@@ -78,7 +78,6 @@ let correlationSequence = 0;
|
||||
|
||||
const shape = (value, depth = 0) => {
|
||||
if (depth > 2 || value === null) return value === null ? "null" : "nested";
|
||||
if (arrayIsArray(value)) return "array";
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
return "string";
|
||||
@@ -91,7 +90,12 @@ const shape = (value, depth = 0) => {
|
||||
case "function":
|
||||
return "function";
|
||||
case "object":
|
||||
return "object";
|
||||
try {
|
||||
return arrayIsArray(value) ? "array" : "object";
|
||||
} catch {
|
||||
// Revoked proxies retain their typeof identity but reject IsArray.
|
||||
return "object";
|
||||
}
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
@@ -1321,20 +1325,25 @@ try {
|
||||
boundaries.patchApplicationEffects(electron.app);
|
||||
boundaries.patchNativeAddonLoading();
|
||||
boundaries.patchChildProcess();
|
||||
process.once("uncaughtException", (cause, origin) => {
|
||||
recordRuntime("process-lifecycle", "uncaught-exception", "observed", {
|
||||
argument_shape_values: [cause, origin],
|
||||
error: true,
|
||||
});
|
||||
throw cause;
|
||||
});
|
||||
process.once("unhandledRejection", (reason, promise) => {
|
||||
recordRuntime("process-lifecycle", "unhandled-rejection", "observed", {
|
||||
argument_shape_values: [reason, promise],
|
||||
error: true,
|
||||
});
|
||||
throw reason;
|
||||
});
|
||||
// Observe dispatch without adding listeners: their presence changes Node's
|
||||
// rejection fallback and Electron's uncaught-exception listener-count policy.
|
||||
const processEmit = process.emit;
|
||||
process.emit = function observedProcessEmit(event, ...args) {
|
||||
if (this === process) {
|
||||
const kind =
|
||||
event === "unhandledRejection"
|
||||
? "unhandled-rejection"
|
||||
: event === "uncaughtExceptionMonitor"
|
||||
? "uncaught-exception"
|
||||
: null;
|
||||
if (kind !== null)
|
||||
recordRuntime("process-lifecycle", kind, "observed", {
|
||||
argument_shape_values: args,
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
return applyFunction(processEmit, this, arguments);
|
||||
};
|
||||
} catch (cause) {
|
||||
globalThis.__reaElectronActiveHookError = true;
|
||||
recordRuntime(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
/** Interpret request targets once, before a fixture routes or forwards them. */
|
||||
export const createBrowserFixtureHttpServer = (handleRequest) =>
|
||||
createServer((request, response) => {
|
||||
let target;
|
||||
try {
|
||||
// A leading // is an origin-form path, not a replacement authority.
|
||||
const requested = new URL(
|
||||
request.url?.startsWith("/")
|
||||
? `http://fixture.invalid${request.url}`
|
||||
: request.url,
|
||||
);
|
||||
if (requested.protocol !== "http:" && requested.protocol !== "https:")
|
||||
throw new TypeError("Unsupported fixture request protocol");
|
||||
target = { pathname: requested.pathname, search: requested.search };
|
||||
} catch {
|
||||
response.writeHead(400, {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
response.end("Invalid HTTP request target");
|
||||
return;
|
||||
}
|
||||
handleRequest(request, response, target);
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { once } from "node:events";
|
||||
import { createServer, request } from "node:http";
|
||||
import { request } from "node:http";
|
||||
import { connect } from "node:net";
|
||||
|
||||
import { createBrowserScenarioProvider } from "../../dist/composition/browserScenario.js";
|
||||
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
|
||||
import { PlaywrightScenarioServiceWorkers } from "../../dist/browser/PlaywrightScenarioServiceWorkers.js";
|
||||
import { createBrowserFixtureHttpServer } from "./browser-fixture-http.mjs";
|
||||
|
||||
const beforeDeadline = async (promise, label, timeoutMs = 2_000) => {
|
||||
let timer;
|
||||
@@ -26,7 +27,7 @@ const beforeDeadline = async (promise, label, timeoutMs = 2_000) => {
|
||||
|
||||
// Real loopback proxy: Playwright's first transport is forwarded to Chrome,
|
||||
// then only the policy owner's discovery/handshake is deliberately withheld.
|
||||
const startProxy = async (endpoint, mode, ownerOnly = false) => {
|
||||
export const startProxy = async (endpoint, mode, ownerOnly = false) => {
|
||||
const upstream = new URL(endpoint);
|
||||
const sockets = new Set();
|
||||
let versions = 0;
|
||||
@@ -47,38 +48,44 @@ const startProxy = async (endpoint, mode, ownerOnly = false) => {
|
||||
socket.once("close", () => closed());
|
||||
entered();
|
||||
};
|
||||
const server = createServer((incoming, response) => {
|
||||
const versionRequest = incoming.url.replace(/\/$/, "") === "/json/version";
|
||||
if (versionRequest) versions += 1;
|
||||
if (
|
||||
enabled &&
|
||||
mode === "http-discovery" &&
|
||||
versions === 2 &&
|
||||
versionRequest
|
||||
) {
|
||||
stall(incoming.socket);
|
||||
return;
|
||||
}
|
||||
const outgoing = request(new URL(incoming.url, upstream), (received) => {
|
||||
const chunks = [];
|
||||
received.on("data", (chunk) => chunks.push(chunk));
|
||||
received.on("end", () => {
|
||||
const content = Buffer.concat(chunks).toString("utf8");
|
||||
response.writeHead(received.statusCode, {
|
||||
"content-type": "application/json",
|
||||
const server = createBrowserFixtureHttpServer(
|
||||
(incoming, response, target) => {
|
||||
const versionRequest =
|
||||
target.pathname.replace(/\/$/, "") === "/json/version";
|
||||
if (versionRequest) versions += 1;
|
||||
if (
|
||||
enabled &&
|
||||
mode === "http-discovery" &&
|
||||
versions === 2 &&
|
||||
versionRequest
|
||||
) {
|
||||
stall(incoming.socket);
|
||||
return;
|
||||
}
|
||||
const destination = new URL(upstream);
|
||||
destination.pathname = target.pathname;
|
||||
destination.search = target.search;
|
||||
const outgoing = request(destination, (received) => {
|
||||
const chunks = [];
|
||||
received.on("data", (chunk) => chunks.push(chunk));
|
||||
received.on("end", () => {
|
||||
const content = Buffer.concat(chunks).toString("utf8");
|
||||
response.writeHead(received.statusCode, {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
response.end(
|
||||
content.replaceAll(
|
||||
`127.0.0.1:${upstream.port}`,
|
||||
`127.0.0.1:${server.address().port}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
response.end(
|
||||
content.replaceAll(
|
||||
`127.0.0.1:${upstream.port}`,
|
||||
`127.0.0.1:${server.address().port}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
outgoing.on("error", () => response.destroy());
|
||||
response.on("close", () => outgoing.destroy());
|
||||
outgoing.end();
|
||||
});
|
||||
outgoing.on("error", () => response.destroy());
|
||||
response.on("close", () => outgoing.destroy());
|
||||
outgoing.end();
|
||||
},
|
||||
);
|
||||
server.on("connection", (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { once } from "node:events";
|
||||
import { createServer } from "node:http";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
import { chromium } from "playwright-core";
|
||||
@@ -11,6 +10,7 @@ import { createBrowserScenarioProvider } from "../../dist/composition/browserSce
|
||||
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
|
||||
import { runScenarioCli } from "./browser-scenario-verifier.mjs";
|
||||
import { verifyScenarioPolicySetup } from "./browser-scenario-service-worker-setup-e2e.mjs";
|
||||
import { createBrowserFixtureHttpServer } from "./browser-fixture-http.mjs";
|
||||
|
||||
const diagnosticError = (error) =>
|
||||
error instanceof Error
|
||||
@@ -26,38 +26,38 @@ const diagnosticError = (error) =>
|
||||
}
|
||||
: { kind: typeof error };
|
||||
|
||||
const startSite = async () => {
|
||||
export const startSite = async () => {
|
||||
let otherOrigin;
|
||||
const server = createServer((request, response) => {
|
||||
const path = new URL(request.url, "http://localhost").pathname;
|
||||
if (path === "/sw.js") {
|
||||
response.setHeader("content-type", "application/javascript");
|
||||
response.end(`self.addEventListener("install", () => self.skipWaiting());
|
||||
const server = createBrowserFixtureHttpServer(
|
||||
(_request, response, { pathname: path }) => {
|
||||
if (path === "/sw.js") {
|
||||
response.setHeader("content-type", "application/javascript");
|
||||
response.end(`self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", event => event.waitUntil(clients.claim()));
|
||||
self.addEventListener("fetch", event => {
|
||||
if (new URL(event.request.url).pathname === "/probe")
|
||||
event.respondWith(new Response("worker"));
|
||||
});`);
|
||||
} else if (path === "/probe") {
|
||||
response.end("network");
|
||||
} else if (path === "/failed-navigation") {
|
||||
response.destroy();
|
||||
} else if (path === "/stalled-image.png") {
|
||||
response.writeHead(200, { "content-type": "image/png" });
|
||||
response.flushHeaders();
|
||||
} else if (path === "/pending-image" || path === "/pending-child") {
|
||||
response.setHeader("content-type", "text/html");
|
||||
response.end(
|
||||
'<!doctype html><img id="pending-image" src="/stalled-image.png">',
|
||||
);
|
||||
} else if (path === "/pending-frame") {
|
||||
response.setHeader("content-type", "text/html");
|
||||
response.end(
|
||||
'<!doctype html><iframe id="pending-frame" src="/pending-child"></iframe>',
|
||||
);
|
||||
} else {
|
||||
response.setHeader("content-type", "text/html");
|
||||
response.end(`<!doctype html><p id="state">ready</p>
|
||||
} else if (path === "/probe") {
|
||||
response.end("network");
|
||||
} else if (path === "/failed-navigation") {
|
||||
response.destroy();
|
||||
} else if (path === "/stalled-image.png") {
|
||||
response.writeHead(200, { "content-type": "image/png" });
|
||||
response.flushHeaders();
|
||||
} else if (path === "/pending-image" || path === "/pending-child") {
|
||||
response.setHeader("content-type", "text/html");
|
||||
response.end(
|
||||
'<!doctype html><img id="pending-image" src="/stalled-image.png">',
|
||||
);
|
||||
} else if (path === "/pending-frame") {
|
||||
response.setHeader("content-type", "text/html");
|
||||
response.end(
|
||||
'<!doctype html><iframe id="pending-frame" src="/pending-child"></iframe>',
|
||||
);
|
||||
} else {
|
||||
response.setHeader("content-type", "text/html");
|
||||
response.end(`<!doctype html><p id="state">ready</p>
|
||||
<a id="navigate" href="${otherOrigin}/">Navigate</a>
|
||||
${path === "/frame" ? `<iframe src="${otherOrigin}/"></iframe>` : ""}
|
||||
${
|
||||
@@ -72,8 +72,9 @@ const startSite = async () => {
|
||||
</script>`
|
||||
: ""
|
||||
}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
scenarioProfiles,
|
||||
} from "./browser-scenario-verifier.mjs";
|
||||
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
|
||||
import { createBrowserFixtureHttpServer } from "./browser-fixture-http.mjs";
|
||||
|
||||
const scenarioFor = (browser, origin) =>
|
||||
browserScenarioSchema.parse({
|
||||
@@ -149,28 +149,29 @@ const storagePage = (kind, port) => {
|
||||
</script></body></html>`;
|
||||
};
|
||||
|
||||
const startStorageSite = async () => {
|
||||
export const startStorageSite = async () => {
|
||||
let port = 0;
|
||||
const server = createServer((request, response) => {
|
||||
const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
|
||||
const kind =
|
||||
path === "/storage-main"
|
||||
? "main"
|
||||
: path === "/storage-frame"
|
||||
? "frame"
|
||||
: path === "/storage-debugger"
|
||||
? "debugger"
|
||||
: path === "/storage-top"
|
||||
? "top"
|
||||
: undefined;
|
||||
if (kind === undefined) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
response.end(storagePage(kind, port));
|
||||
});
|
||||
server.listen(0, "::");
|
||||
const server = createBrowserFixtureHttpServer(
|
||||
(_request, response, { pathname: path }) => {
|
||||
const kind =
|
||||
path === "/storage-main"
|
||||
? "main"
|
||||
: path === "/storage-frame"
|
||||
? "frame"
|
||||
: path === "/storage-debugger"
|
||||
? "debugger"
|
||||
: path === "/storage-top"
|
||||
? "top"
|
||||
: undefined;
|
||||
if (kind === undefined) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
response.end(storagePage(kind, port));
|
||||
},
|
||||
);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
port = server.address().port;
|
||||
return {
|
||||
|
||||
@@ -87,11 +87,50 @@ export async function verifyGhidraEntryAliases({
|
||||
(await call("procedure_info", { procedure: alias.value })).entrypoint,
|
||||
alias.address,
|
||||
);
|
||||
// Search matches the names exact lookup accepts, as one procedure row
|
||||
// under its primary name.
|
||||
const escaped = alias.value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
for (const query of [
|
||||
{ pattern: alias.value },
|
||||
{ pattern: `^${escaped}$`, mode: "regex", case_sensitive: true },
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
(await call("search_procedures", query))
|
||||
.filter(({ address }) => address === alias.address)
|
||||
.map(({ value, matched_aliases }) => ({ value, matched_aliases })),
|
||||
[{ value: canonical.value, matched_aliases: [alias.value] }],
|
||||
`Procedure search missed the entry alias for ${JSON.stringify(query)}`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
(await call("list_procedures")).every(
|
||||
(row) => row.matched_aliases === undefined,
|
||||
),
|
||||
"Listings must not report search matches",
|
||||
);
|
||||
const traced = await call("trace_feature", { query: alias.value });
|
||||
assert.ok(
|
||||
traced.matches.some(
|
||||
({ type, address, matched_aliases }) =>
|
||||
type === "procedure" &&
|
||||
address === alias.address &&
|
||||
matched_aliases?.includes(alias.value) === true,
|
||||
),
|
||||
"trace_feature missed the procedure its entry alias names",
|
||||
);
|
||||
const interior = names.find(
|
||||
({ value }) => value === `${prefix}rea_interior`,
|
||||
);
|
||||
assert.ok(interior);
|
||||
assert.notEqual(interior.address, alias.address);
|
||||
assert.ok(
|
||||
(await call("search_procedures", { pattern: interior.value })).every(
|
||||
({ address, matched_aliases = [] }) =>
|
||||
address !== interior.address &&
|
||||
!matched_aliases.includes(interior.value),
|
||||
),
|
||||
"An interior label must not match procedure search",
|
||||
);
|
||||
assert.equal(
|
||||
interior.symbol.type,
|
||||
"label",
|
||||
|
||||
@@ -68,6 +68,21 @@ export async function verifyGhidraNamespaceAnnotations({
|
||||
assert.equal(ambiguous.code, "invalid_request");
|
||||
for (const { address } of entries)
|
||||
assert.ok(JSON.stringify(ambiguous.details.issues).includes(address));
|
||||
// Search accepts the leaf spelling exact lookup accepts, keeping each
|
||||
// procedure as one row under its qualified primary name.
|
||||
const leafMatches = await call("search_procedures", {
|
||||
pattern: "^same$",
|
||||
mode: "regex",
|
||||
case_sensitive: true,
|
||||
});
|
||||
for (const { namespace, address } of entries)
|
||||
assert.deepEqual(
|
||||
leafMatches
|
||||
.filter((row) => row.address === address)
|
||||
.map(({ value }) => value),
|
||||
[`${namespace}::same`],
|
||||
`Leaf-name search missed ${namespace}::same`,
|
||||
);
|
||||
for (const { namespace, address } of entries) {
|
||||
const leaf = await call("annotate_native_function", {
|
||||
procedure: address,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseEvidence } from "../../dist/domain/evidence.js";
|
||||
import { PrivateRuntimeRoot } from "../../dist/process/PrivateRuntimeRoot.js";
|
||||
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
|
||||
import { createVerifierCommands } from "./verify-commands.mjs";
|
||||
|
||||
const source = fileURLToPath(
|
||||
new URL(
|
||||
"../../tests/conformance/ghidra/relocation-symbol.c",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
/** Verify named and unnamed ELF relocations through the shipped CLI and Ghidra. */
|
||||
export async function verifyGhidraRelocationSymbols({ clang, lld, env }) {
|
||||
const commands = await createVerifierCommands();
|
||||
let root;
|
||||
let commandsClosed = false;
|
||||
return withVerifierCleanup(async () => {
|
||||
root = await PrivateRuntimeRoot.create({ prefix: "rea-relocation-proof-" });
|
||||
const object = join(root.path, "relocation-symbol.o");
|
||||
const target = join(root.path, "relocation-symbol.so");
|
||||
const commandEnv = {
|
||||
...env,
|
||||
TMPDIR: root.path,
|
||||
TMP: root.path,
|
||||
TEMP: root.path,
|
||||
};
|
||||
const run = (command, args) =>
|
||||
commands.exec(command, args, {
|
||||
env: commandEnv,
|
||||
timeout: 120000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
await run(clang, [
|
||||
"--target=x86_64-linux-gnu",
|
||||
"-fPIC",
|
||||
"-O0",
|
||||
"-c",
|
||||
source,
|
||||
"-o",
|
||||
object,
|
||||
]);
|
||||
await run(lld, ["-shared", object, "-o", target]);
|
||||
const hash = async () =>
|
||||
createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
const before = await hash();
|
||||
const { stdout } = await run(process.execPath, [
|
||||
"scripts/rea.mjs",
|
||||
"inspect-native-load-image",
|
||||
target,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
]);
|
||||
const evidence = parseEvidence(JSON.parse(stdout));
|
||||
assert.equal(evidence.subject?.digest.sha256, before);
|
||||
assert.equal(evidence.provider.id, "ghidra");
|
||||
const report = evidence.normalized_result;
|
||||
assert.equal(report.status, "unsupported");
|
||||
const relocations = report.observations.relocations;
|
||||
assert.ok(
|
||||
relocations.some(({ symbol }) => symbol === "rea_external"),
|
||||
"The Ghidra relocation must retain its imported symbol",
|
||||
);
|
||||
assert.ok(
|
||||
relocations.some(({ symbol }) => symbol === ""),
|
||||
"The provider's empty symbol must remain distinct from an unrecorded field",
|
||||
);
|
||||
assert.equal(await hash(), before);
|
||||
return {
|
||||
source,
|
||||
target_sha256: before,
|
||||
named_symbol: "rea_external",
|
||||
empty_symbol: true,
|
||||
};
|
||||
}, [
|
||||
async () => {
|
||||
await commands.close();
|
||||
commandsClosed = true;
|
||||
},
|
||||
async () => {
|
||||
if (commandsClosed && root !== undefined) await root.close();
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export function applyReleasePleasePatch(directory?: string): Promise<void>;
|
||||
|
||||
export function describeReleaseProposalError(error: unknown): string;
|
||||
|
||||
export function releaseProposalSearchDepth(
|
||||
run: (command: string, args: string[]) => Promise<string>,
|
||||
sourceSha: string,
|
||||
|
||||
@@ -13,6 +13,15 @@ const patch = fileURLToPath(
|
||||
new URL("../../patches/release-please+17.3.0.patch", import.meta.url),
|
||||
);
|
||||
|
||||
export function describeReleaseProposalError(error) {
|
||||
if (!(error instanceof Error)) return String(error);
|
||||
const description = error.message || error.name || "Unknown error";
|
||||
const status = error.status;
|
||||
return Number.isInteger(status) && status >= 400 && status <= 599
|
||||
? `${description} (HTTP ${status})`
|
||||
: description;
|
||||
}
|
||||
|
||||
export async function applyReleasePleasePatch(
|
||||
directory = dirname(require.resolve("release-please/package.json")),
|
||||
) {
|
||||
|
||||
@@ -10,5 +10,10 @@ export function compareTestDiscovery(
|
||||
readonly file: string;
|
||||
readonly projects: readonly string[];
|
||||
}[];
|
||||
readonly wrongProjects: readonly {
|
||||
readonly file: string;
|
||||
readonly expected: string;
|
||||
readonly actual: string;
|
||||
}[];
|
||||
readonly discovered: number;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { relative, resolve } from "node:path";
|
||||
import { projectForTest } from "./test-projects.mjs";
|
||||
|
||||
/** Compare actual Vitest discovery with repository-owned test paths. */
|
||||
export const compareTestDiscovery = (expectedPaths, discovery, root) => {
|
||||
@@ -36,6 +37,14 @@ export const compareTestDiscovery = (expectedPaths, discovery, root) => {
|
||||
const projects = projectsByFile.get(file);
|
||||
return projects.length > 1 ? [{ file, projects: sorted(projects) }] : [];
|
||||
}),
|
||||
wrongProjects: sorted(expected).flatMap((file) => {
|
||||
const projects = projectsByFile.get(file);
|
||||
if (!projects || projects.length !== 1) return [];
|
||||
const expectedProject = projectForTest(file).name;
|
||||
return projects[0] === expectedProject
|
||||
? []
|
||||
: [{ file, expected: expectedProject, actual: projects[0] }];
|
||||
}),
|
||||
discovered: projectsByFile.size,
|
||||
};
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { appendFile } from "node:fs/promises";
|
||||
import { parseArgs, promisify } from "node:util";
|
||||
import {
|
||||
applyReleasePleasePatch,
|
||||
describeReleaseProposalError,
|
||||
releaseProposalSearchDepth,
|
||||
} from "./lib/release-proposal.mjs";
|
||||
|
||||
@@ -63,7 +64,7 @@ async function main() {
|
||||
|
||||
await main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`Release proposal failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
`Release proposal failed: ${describeReleaseProposalError(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -539,11 +539,13 @@ class WebsiteMaintenanceChecks(unittest.TestCase):
|
||||
self.assertIn(b"color: black", response.read())
|
||||
with self.assertRaises(HTTPError) as error:
|
||||
urlopen(base + prefix + "missing")
|
||||
self.assertEqual(error.exception.code, 404)
|
||||
with contextlib.closing(error.exception) as response:
|
||||
self.assertEqual(response.code, 404)
|
||||
if prefix != "/":
|
||||
with self.assertRaises(HTTPError) as error:
|
||||
urlopen(base + "/guide/")
|
||||
self.assertEqual(error.exception.code, 404)
|
||||
with contextlib.closing(error.exception) as response:
|
||||
self.assertEqual(response.code, 404)
|
||||
finally:
|
||||
server.shutdown()
|
||||
worker.join()
|
||||
|
||||
+9
-78
@@ -15,8 +15,10 @@ if (options.help) {
|
||||
process.stdout.write(
|
||||
"Usage: npm run verify:adb [-- --serial SERIAL --pull PACKAGE]\n" +
|
||||
"Requires a caller-selected adb binary (REA_ADB_PATH or PATH) and a\n" +
|
||||
"connected device or emulator. Verifies all five operations against\n" +
|
||||
"the live device; --pull acquires a real package and re-digests the\n" +
|
||||
"connected device or emulator. Verifies observation and transfer operations\n" +
|
||||
"against the live device. --install-apk is unavailable until APK identity\n" +
|
||||
"and device cleanup ownership can be established safely.\n" +
|
||||
"--pull acquires a real package and re-digests the\n" +
|
||||
"files on disk against the returned SHA-256 values.\n",
|
||||
);
|
||||
process.exit(0);
|
||||
@@ -356,79 +358,6 @@ try {
|
||||
`versionCode=${String(details.version_code)}`,
|
||||
);
|
||||
}
|
||||
if (options.installApk !== null && options.installApk !== undefined) {
|
||||
const probePackage = "com.rea.adb.probe";
|
||||
const installedProbe = await run("install_adb_package", {
|
||||
serial,
|
||||
apk_path: options.installApk,
|
||||
replace: false,
|
||||
});
|
||||
if (installedProbe !== null) {
|
||||
check(
|
||||
"install reports the APK digest",
|
||||
typeof installedProbe.sha256 === "string" &&
|
||||
installedProbe.sha256.length === 64,
|
||||
`${installedProbe.bytes} bytes`,
|
||||
);
|
||||
const resolved = await run("resolve_adb_packages", {
|
||||
serial,
|
||||
query: "rea.adb.probe",
|
||||
});
|
||||
if (resolved !== null)
|
||||
check(
|
||||
"resolve finds the installed probe uniquely",
|
||||
resolved.matches.length === 1 &&
|
||||
resolved.matches[0]?.package_name === probePackage,
|
||||
`${resolved.matches.length} match(es)`,
|
||||
);
|
||||
const started = await run("start_adb_app", {
|
||||
serial,
|
||||
package: probePackage,
|
||||
});
|
||||
if (started !== null)
|
||||
check(
|
||||
"app start resolves and launches the launcher activity",
|
||||
typeof started.component === "string" &&
|
||||
started.component.startsWith(probePackage),
|
||||
String(started.component),
|
||||
);
|
||||
const observedProcesses = await run("list_adb_processes", { serial });
|
||||
if (observedProcesses !== null)
|
||||
check(
|
||||
"started app appears in the process listing",
|
||||
observedProcesses.processes.some(
|
||||
(process) => process.name === probePackage,
|
||||
),
|
||||
);
|
||||
const stopped = await run("stop_adb_app", {
|
||||
serial,
|
||||
package: probePackage,
|
||||
});
|
||||
if (stopped !== null)
|
||||
check(
|
||||
"force-stop completes the dynamic-analysis run",
|
||||
stopped.package_name === probePackage,
|
||||
);
|
||||
const removed = await run("uninstall_adb_package", {
|
||||
serial,
|
||||
package: probePackage,
|
||||
});
|
||||
if (removed !== null)
|
||||
check(
|
||||
"uninstall completes the lifecycle",
|
||||
removed.package_name === probePackage,
|
||||
);
|
||||
const afterRemoval = await run("resolve_adb_packages", {
|
||||
serial,
|
||||
query: "rea.adb.probe",
|
||||
});
|
||||
if (afterRemoval !== null)
|
||||
check(
|
||||
"resolve reports zero matches after removal",
|
||||
afterRemoval.matches.length === 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(transferRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -459,7 +388,7 @@ if (failures > 0) {
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const parsed = { help: false, serial: null, pull: null, installApk: null };
|
||||
const parsed = { help: false, serial: null, pull: null };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--help" || argument === "-h") parsed.help = true;
|
||||
@@ -470,8 +399,10 @@ function parseArguments(argv) {
|
||||
parsed.pull = argv[index + 1] ?? null;
|
||||
index += 1;
|
||||
} else if (argument === "--install-apk") {
|
||||
parsed.installApk = argv[index + 1] ?? null;
|
||||
index += 1;
|
||||
process.stderr.write(
|
||||
"--install-apk is unavailable: APK identity and device cleanup ownership cannot be established safely\n",
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
process.stderr.write(`unknown argument: ${argument}\n`);
|
||||
process.exit(2);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
import { createVerifierCommands } from "./lib/verify-commands.mjs";
|
||||
|
||||
const root = await PrivateRuntimeRoot.create({ prefix: "rea-bun-verifier-" });
|
||||
const commands = await createVerifierCommands();
|
||||
let commands;
|
||||
const run = (command, args, options = {}) =>
|
||||
commands.exec(command, args, {
|
||||
timeout: 120_000,
|
||||
@@ -19,6 +19,7 @@ const run = (command, args, options = {}) =>
|
||||
// only after every owned command family has settled.
|
||||
let commandsClosed = false;
|
||||
await withVerifierCleanup(async () => {
|
||||
commands = await createVerifierCommands();
|
||||
const source = join(root.path, "hello.ts");
|
||||
await writeFile(source, 'console.log("REA_BUN_VERIFICATION");\n');
|
||||
const docker = process.argv.includes("--docker");
|
||||
@@ -104,7 +105,7 @@ await withVerifierCleanup(async () => {
|
||||
);
|
||||
}, [
|
||||
async () => {
|
||||
await commands.close();
|
||||
if (commands !== undefined) await commands.close();
|
||||
commandsClosed = true;
|
||||
},
|
||||
async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { createCatalogIdentity } from "../dist/catalogIdentity.js";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
TOOL_CONTRACTS,
|
||||
toolContract,
|
||||
} from "../dist/contracts/toolContracts.js";
|
||||
import { isPathWithinRoot } from "../dist/domain/localPath.js";
|
||||
import { createVerifierCommands } from "./lib/verify-commands.mjs";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
|
||||
@@ -306,7 +307,9 @@ await withVerifierCleanup(async () => {
|
||||
assert(
|
||||
parsed.plannedActions.every(
|
||||
(a) =>
|
||||
a.target === configPath || a.target.startsWith(skillDirectory),
|
||||
a.target === configPath ||
|
||||
(isAbsolute(a.target) &&
|
||||
isPathWithinRoot(skillDirectory, a.target)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isAbsolute, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
import { isPathWithinRoot } from "../dist/domain/localPath.js";
|
||||
import { createVerifierCommands } from "./lib/verify-commands.mjs";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
|
||||
@@ -54,6 +55,7 @@ const expectedNames = TOOL_CONTRACTS.map(
|
||||
).sort();
|
||||
let clientVersion = null;
|
||||
let expectedSkill;
|
||||
let skillCallId;
|
||||
let inventoryChecked = false;
|
||||
let catalogSize = null;
|
||||
let skillLoaded = false;
|
||||
@@ -180,10 +182,24 @@ await withVerifierCleanup(async () => {
|
||||
finalSeen = true;
|
||||
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
|
||||
}
|
||||
if (stage === 0)
|
||||
return call("activate_skill", { name: "reverse-engineer-anything" });
|
||||
if (stage === 0) {
|
||||
const request = call("activate_skill", {
|
||||
name: "reverse-engineer-anything",
|
||||
});
|
||||
skillCallId = request.tool_calls[0].id;
|
||||
return request;
|
||||
}
|
||||
assert(
|
||||
strings.some((text) => text.includes(expectedSkill)),
|
||||
skillCallId !== undefined &&
|
||||
expectedSkill !== undefined &&
|
||||
body.messages.some(
|
||||
(message) =>
|
||||
message.role === "tool" &&
|
||||
message.tool_call_id === skillCallId &&
|
||||
textValues(message.content).some((text) =>
|
||||
text.includes(expectedSkill),
|
||||
),
|
||||
),
|
||||
"The model must receive the complete activated skill body",
|
||||
);
|
||||
skillLoaded = true;
|
||||
@@ -322,8 +338,8 @@ await withVerifierCleanup(async () => {
|
||||
result.plannedActions.every(
|
||||
(action) =>
|
||||
action.target === configPath ||
|
||||
action.target === skillDirectory ||
|
||||
action.target.startsWith(skillDirectory + "/"),
|
||||
(isAbsolute(action.target) &&
|
||||
isPathWithinRoot(skillDirectory, action.target)),
|
||||
),
|
||||
"Refuse setup writes outside the disposable account's configuration and skill",
|
||||
);
|
||||
@@ -342,9 +358,16 @@ await withVerifierCleanup(async () => {
|
||||
assert.deepEqual(repeated.appliedActions, []);
|
||||
assert.equal(await readFile(configPath, "utf8"), configured);
|
||||
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
|
||||
expectedSkill = (await readFile(join(skillDirectory, "SKILL.md"), "utf8"))
|
||||
.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "")
|
||||
.trim();
|
||||
const installedSkill = await readFile(
|
||||
join(skillDirectory, "SKILL.md"),
|
||||
"utf8",
|
||||
);
|
||||
const frontmatter = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/u.exec(
|
||||
installedSkill,
|
||||
);
|
||||
assert(frontmatter, "Installed skill must have authored frontmatter");
|
||||
expectedSkill = installedSkill.slice(frontmatter[0].length).trim();
|
||||
assert(expectedSkill.length > 0, "Installed skill body must not be empty");
|
||||
const response = await saveVerifierCommandOutput(
|
||||
() =>
|
||||
commands.exec(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { advertisedInputJsonSchema } from "../dist/contracts/toolSchemaMetadata.js";
|
||||
import { presentAdvertisedInputSchema } from "../dist/contracts/compactInputSchema.js";
|
||||
import { compactPresentationForMcpInputSchemaProfile } from "../dist/config/mcpInputSchemaProfile.js";
|
||||
import { isPathWithinRoot } from "../dist/domain/localPath.js";
|
||||
import { createVerifierCommands } from "./lib/verify-commands.mjs";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
|
||||
@@ -300,8 +301,8 @@ await withVerifierCleanup(async () => {
|
||||
parsed.plannedActions.every(
|
||||
(a) =>
|
||||
a.target === configPath ||
|
||||
a.target === skillDirectory ||
|
||||
a.target.startsWith(skillDirectory + "/"),
|
||||
(isAbsolute(a.target) &&
|
||||
isPathWithinRoot(skillDirectory, a.target)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ let catalog;
|
||||
let patternCount = 0;
|
||||
let skillRequested = false;
|
||||
let skillLoaded = false;
|
||||
let expectedSkillBody;
|
||||
let evidenceSeen = false;
|
||||
let finalSeen = false;
|
||||
let artifactDigest;
|
||||
@@ -169,11 +170,34 @@ await withVerifierCleanup(async () => {
|
||||
const toolMessages = (body.messages ?? []).filter(
|
||||
(message) => message.role === "tool",
|
||||
);
|
||||
const serialized = JSON.stringify(toolMessages);
|
||||
skillLoaded ||=
|
||||
serialized.includes(
|
||||
'<skill_content name=\\"reverse-engineer-anything\\">',
|
||||
) && serialized.includes(dirname(skillPath));
|
||||
skillLoaded ||= toolMessages.some((message) => {
|
||||
if (
|
||||
typeof message.content !== "string" ||
|
||||
expectedSkillBody === undefined
|
||||
)
|
||||
return false;
|
||||
const open =
|
||||
'<skill_content name="reverse-engineer-anything">\n<skill_resources>\n';
|
||||
const start = message.content.indexOf(open);
|
||||
if (start < 0) return false;
|
||||
const resourcesStart = start + open.length;
|
||||
const instructions = `\n</skill_resources>\n\n<skill_instructions>\n${expectedSkillBody}\n</skill_instructions>\n</skill_content>`;
|
||||
const resourcesEnd = message.content.indexOf(
|
||||
instructions,
|
||||
resourcesStart,
|
||||
);
|
||||
if (resourcesEnd < 0) return false;
|
||||
const expectedPath = dirname(skillPath)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
const resources = message.content.slice(resourcesStart, resourcesEnd);
|
||||
return (
|
||||
!resources.includes("</skill_content>") &&
|
||||
!resources.includes("</skill_resources>") &&
|
||||
resources.includes(expectedPath)
|
||||
);
|
||||
});
|
||||
for (const message of toolMessages) {
|
||||
const found = findClientResult(
|
||||
message.content,
|
||||
@@ -281,11 +305,15 @@ await withVerifierCleanup(async () => {
|
||||
const configured = await readFile(configPath, "utf8");
|
||||
assert.deepEqual(withoutRea(configured), withoutRea(original));
|
||||
assert(configured.includes("!!js process.cwd()"));
|
||||
assert(
|
||||
(await readFile(skillPath, "utf8")).includes(
|
||||
"name: reverse-engineer-anything",
|
||||
),
|
||||
const installedSkill = await readFile(skillPath, "utf8");
|
||||
assert(installedSkill.includes("name: reverse-engineer-anything"));
|
||||
const frontmatter = /^---\r?\n[\s\S]*?^---(?:\r?\n|$)/mu.exec(
|
||||
installedSkill,
|
||||
);
|
||||
assert(frontmatter, "Installed skill must have its authored frontmatter");
|
||||
expectedSkillBody = installedSkill.slice(frontmatter[0].length).trim();
|
||||
assert(expectedSkillBody.length > 0);
|
||||
|
||||
lifecycle.push("setup");
|
||||
const repeated = await runRea(
|
||||
["setup", "--client", "dsh", "--yes"],
|
||||
|
||||
@@ -104,6 +104,10 @@ await withVerifierCleanup(
|
||||
}
|
||||
// The same checks apply to the default selection and each remaining ABI.
|
||||
const preferred = await run("inspect_dart_aot", { path: apk });
|
||||
assert.ok(
|
||||
abis.some((abi) => abi.abi === preferred.abi),
|
||||
"Default AOT selection must be one of the identified ABIs",
|
||||
);
|
||||
for (const abi of abis) {
|
||||
const aot =
|
||||
abi.abi === preferred.abi
|
||||
|
||||
@@ -2,7 +2,7 @@ import { basename } from "node:path";
|
||||
|
||||
import { FridaInstrumentationService } from "../dist/application/frida/FridaInstrumentationService.js";
|
||||
import { FridaInstrumentationManager } from "../dist/frida/FridaInstrumentationManager.js";
|
||||
import { isValidFridaRemoteAddress } from "../dist/contracts/fridaRemoteAddress.js";
|
||||
import { isValidFridaRemoteAddress } from "../dist/domain/fridaRemoteAddress.js";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
|
||||
const options = parseArguments(process.argv.slice(2));
|
||||
@@ -80,6 +80,10 @@ try {
|
||||
!Array.isArray(observed.messages)
|
||||
)
|
||||
throw new Error("Frida returned no message observation");
|
||||
if (!Number.isSafeInteger(observed.pid) || observed.pid <= 0)
|
||||
throw new Error("Frida returned no valid target PID");
|
||||
if (remote !== undefined && observed.pid !== options.pid)
|
||||
throw new Error("Frida observed a different target PID");
|
||||
const messageObserved = observed.messages.some(
|
||||
(message) =>
|
||||
typeof message === "object" &&
|
||||
|
||||
@@ -2,13 +2,14 @@ import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import {
|
||||
TOOL_CONTRACTS,
|
||||
toolContract,
|
||||
} from "../dist/contracts/toolContracts.js";
|
||||
import { isPathWithinRoot } from "../dist/domain/localPath.js";
|
||||
import { createVerifierCommands } from "./lib/verify-commands.mjs";
|
||||
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
@@ -65,6 +66,7 @@ let fixture;
|
||||
let passed = false;
|
||||
let evidenceSeen = false;
|
||||
let skillLoaded = false;
|
||||
let expectedSkillBody;
|
||||
let skillRequested = false;
|
||||
let finalSeen = false;
|
||||
let catalogSize = null;
|
||||
@@ -150,13 +152,21 @@ await withVerifierCleanup(async () => {
|
||||
if (args.includes("--dry-run")) {
|
||||
assert(parsed.plannedActions.some((a) => a.target === configPath));
|
||||
assert(
|
||||
parsed.plannedActions.some((a) =>
|
||||
a.target.startsWith(join(profile, ".agents", "skills") + sep),
|
||||
),
|
||||
parsed.plannedActions.some((a) => {
|
||||
const skills = join(profile, ".agents", "skills");
|
||||
return (
|
||||
isAbsolute(a.target) &&
|
||||
relative(skills, a.target) !== "" &&
|
||||
isPathWithinRoot(skills, a.target)
|
||||
);
|
||||
}),
|
||||
);
|
||||
assert(
|
||||
parsed.plannedActions.every((a) =>
|
||||
a.target.startsWith(profile + sep),
|
||||
parsed.plannedActions.every(
|
||||
(a) =>
|
||||
isAbsolute(a.target) &&
|
||||
relative(profile, a.target) !== "" &&
|
||||
isPathWithinRoot(profile, a.target),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -189,11 +199,15 @@ await withVerifierCleanup(async () => {
|
||||
assert.deepEqual(JSON.parse(repeated.stdout).appliedActions, []);
|
||||
assert.equal(await readFile(configPath, "utf8"), configured);
|
||||
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
|
||||
assert(
|
||||
(await readFile(skillPath, "utf8")).includes(
|
||||
"name: reverse-engineer-anything",
|
||||
),
|
||||
const installedSkill = await readFile(skillPath, "utf8");
|
||||
assert(installedSkill.includes("name: reverse-engineer-anything"));
|
||||
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?/u.exec(
|
||||
installedSkill,
|
||||
);
|
||||
assert(frontmatter, "Installed skill must have authored frontmatter");
|
||||
expectedSkillBody = (frontmatter[2] ?? "").trim();
|
||||
assert(expectedSkillBody.length > 0);
|
||||
|
||||
const target = join(lab, "fixture α测试");
|
||||
await mkdir(target);
|
||||
await writeFile(join(target, "app.js"), CLIENT_JAVASCRIPT_FIXTURE_SOURCE);
|
||||
@@ -268,16 +282,32 @@ await withVerifierCleanup(async () => {
|
||||
.flatMap((c) => c.parts ?? [])
|
||||
.filter((p) => p.functionResponse)
|
||||
.map((p) => p.functionResponse);
|
||||
skillLoaded ||= responses.some(
|
||||
(r) =>
|
||||
r.name === "activate_skill" &&
|
||||
r.response?.output?.includes(
|
||||
`<activated_skill name="reverse-engineer-anything">`,
|
||||
) &&
|
||||
r.response.output.includes(
|
||||
skillLoaded ||= responses.some((response) => {
|
||||
const output = response.response?.output;
|
||||
if (
|
||||
response.name !== "activate_skill" ||
|
||||
typeof output !== "string" ||
|
||||
expectedSkillBody === undefined
|
||||
)
|
||||
return false;
|
||||
const start = `<activated_skill name="reverse-engineer-anything">\n <instructions>\n ${expectedSkillBody}\n </instructions>\n\n <available_resources>\n `;
|
||||
const at = output.indexOf(start);
|
||||
if (at < 0) return false;
|
||||
const resourcesStart = at + start.length;
|
||||
const resourcesEnd = output.indexOf(
|
||||
"\n </available_resources>\n</activated_skill>",
|
||||
resourcesStart,
|
||||
);
|
||||
if (resourcesEnd < 0) return false;
|
||||
const resources = output.slice(resourcesStart, resourcesEnd);
|
||||
return (
|
||||
!resources.includes("</activated_skill>") &&
|
||||
!resources.includes("</available_resources>") &&
|
||||
resources.includes(
|
||||
join(profile, ".agents", "skills", "reverse-engineer-anything"),
|
||||
),
|
||||
);
|
||||
)
|
||||
);
|
||||
});
|
||||
const observed = findClientResult(
|
||||
responses,
|
||||
isClientJavaScriptEvidence,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isAbsolute, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
import { isPathWithinRoot } from "../dist/domain/localPath.js";
|
||||
import { createVerifierCommands } from "./lib/verify-commands.mjs";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
|
||||
@@ -402,8 +403,8 @@ enabled = false
|
||||
parsed.plannedActions.every(
|
||||
(action) =>
|
||||
action.target === configPath ||
|
||||
action.target === skillDirectory ||
|
||||
action.target.startsWith(skillDirectory + "/"),
|
||||
(isAbsolute(action.target) &&
|
||||
isPathWithinRoot(skillDirectory, action.target)),
|
||||
),
|
||||
"Refuse writes outside the owned profile/skill",
|
||||
);
|
||||
|
||||
@@ -373,6 +373,7 @@ try {
|
||||
}
|
||||
const report = {
|
||||
run_id: run.run_id,
|
||||
input_mode: inspectExisting ? "existing-captures" : "producer-attempt",
|
||||
status: primaryError || cleanupFailures.length ? "failed" : "passed",
|
||||
finished_at: new Date().toISOString(),
|
||||
...(primaryError
|
||||
@@ -386,8 +387,13 @@ const report = {
|
||||
verifier,
|
||||
producer_audit: producerAudit,
|
||||
scope:
|
||||
"Real Linux reverse HTTP/3, mitmproxy 12.2.3, aioquic 1.2.0 and guest cURL",
|
||||
"Linux reverse HTTP/3 capture verification for mitmproxy 12.2.3, aioquic 1.2.0 and guest cURL",
|
||||
limitations: [
|
||||
...(inspectExisting
|
||||
? [
|
||||
"Existing capture producer provenance was not authenticated by this attempt.",
|
||||
]
|
||||
: []),
|
||||
"No local/WireGuard, browser, QUIC v2, migration, 0-RTT, mTLS or replay claim.",
|
||||
"No HAR, interruption, certificate-failure or body-completeness coverage in this profile.",
|
||||
],
|
||||
|
||||
@@ -17,7 +17,7 @@ import { hashVerifierFile } from "./lib/verifier-file-resources.mjs";
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
console.log(
|
||||
"Usage: npm run verify:il2cpp:metadata -- [--metadata PATH]\nPATH is a caller-owned, unencrypted global-metadata.dat (version 24, 27, 29, 31 or 39). Without --metadata, only the advertised contract is verified.",
|
||||
"Usage: npm run verify:il2cpp:metadata -- [--metadata PATH]\nPATH is a caller-owned, unencrypted global-metadata.dat (version 24, 27, 29, 31 or 39). Without --metadata, only the MCP tool name is checked.",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -178,4 +178,8 @@ await withVerifierCleanup(async () => {
|
||||
);
|
||||
}
|
||||
}, [() => client.close(), () => transport.close(), () => resources.close()]);
|
||||
console.log("All asserted IL2CPP metadata CLI/MCP checks passed");
|
||||
console.log(
|
||||
path === null
|
||||
? "IL2CPP metadata MCP tool presence check passed"
|
||||
: "All asserted IL2CPP metadata CLI/MCP checks passed",
|
||||
);
|
||||
@@ -68,7 +68,9 @@ try {
|
||||
});
|
||||
if (!decompiled.ok) throw new Error(decompiled.error.message);
|
||||
const text = decompiled.value.normalized_result.text;
|
||||
if (!text.includes(target.marker))
|
||||
if (text.trim().length === 0)
|
||||
throw new Error("Decompiled pseudo-code was empty");
|
||||
if (target.marker !== undefined && !text.includes(target.marker))
|
||||
throw new Error(
|
||||
"Decompiled pseudo-code did not contain the fixture marker",
|
||||
);
|
||||
@@ -83,7 +85,7 @@ try {
|
||||
open_project_observed: openRefusal === null,
|
||||
...(openRefusal === null ? {} : { open_project_refusal: openRefusal }),
|
||||
unit_count: units.value.normalized_result.units.length,
|
||||
decompiled_marker_observed: true,
|
||||
decompiled_marker_observed: target.marker !== undefined,
|
||||
};
|
||||
}, [
|
||||
async () => {
|
||||
@@ -110,7 +112,7 @@ async function resolveTarget(selected, directory) {
|
||||
return {
|
||||
path: "/engine-side/target",
|
||||
itemAddress: selected.itemAddress,
|
||||
marker: selected.marker ?? "",
|
||||
marker: selected.marker,
|
||||
};
|
||||
await writeFile(
|
||||
join(directory, "Hello.java"),
|
||||
@@ -153,6 +155,8 @@ function parseArguments(args) {
|
||||
values.set(flag, value);
|
||||
index += 1;
|
||||
}
|
||||
if (values.get("--marker") === "")
|
||||
throw new Error("--marker must not be empty");
|
||||
return {
|
||||
help: false,
|
||||
...(values.has("--item-address")
|
||||
|
||||
@@ -245,7 +245,13 @@ try {
|
||||
scope,
|
||||
{ environment: process.env },
|
||||
);
|
||||
if (mismatch.ok || !mismatch.error.message.includes("target-mismatch"))
|
||||
// A selection failure carries its helper code in the issue, not in its message.
|
||||
if (
|
||||
mismatch.ok ||
|
||||
!mismatch.error.issues?.some(({ message }) =>
|
||||
message.startsWith("target-mismatch:"),
|
||||
)
|
||||
)
|
||||
throw new Error("Real helper did not reject changed target bytes");
|
||||
report = {
|
||||
ok: true,
|
||||
|
||||
@@ -2,9 +2,10 @@ import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
import { isPathWithinRoot } from "../dist/domain/localPath.js";
|
||||
import { createVerifierCommands } from "./lib/verify-commands.mjs";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
|
||||
@@ -314,8 +315,8 @@ await withVerifierCleanup(async () => {
|
||||
parsed.plannedActions.every(
|
||||
(action) =>
|
||||
action.target === configPath ||
|
||||
action.target === skillDirectory ||
|
||||
action.target.startsWith(skillDirectory + "/"),
|
||||
(isAbsolute(action.target) &&
|
||||
isPathWithinRoot(skillDirectory, action.target)),
|
||||
),
|
||||
"Refuse setup writes outside the owned config/skill",
|
||||
);
|
||||
|
||||
@@ -2,10 +2,11 @@ import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative, sep } from "node:path";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
import { isPathWithinRoot } from "../dist/domain/localPath.js";
|
||||
import { createVerifierCommands } from "./lib/verify-commands.mjs";
|
||||
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
@@ -49,19 +50,15 @@ const skillDirectory = join(
|
||||
"skills",
|
||||
"reverse-engineer-anything",
|
||||
);
|
||||
const isManagedSkillTarget = (target) => {
|
||||
const value = relative(skillDirectory, target);
|
||||
return (
|
||||
value === "" ||
|
||||
(value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value))
|
||||
);
|
||||
};
|
||||
assert.equal(
|
||||
isManagedSkillTarget(`${skillDirectory}-sibling/AGENTS.md`),
|
||||
isPathWithinRoot(skillDirectory, `${skillDirectory}-sibling/AGENTS.md`),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isManagedSkillTarget(join(skillDirectory, "..", "outside", "AGENTS.md")),
|
||||
isPathWithinRoot(
|
||||
skillDirectory,
|
||||
join(skillDirectory, "..", "outside", "AGENTS.md"),
|
||||
),
|
||||
false,
|
||||
);
|
||||
let clientVersion = null;
|
||||
@@ -271,7 +268,10 @@ await withVerifierCleanup(async () => {
|
||||
);
|
||||
assert(
|
||||
parsed.plannedActions.every(
|
||||
(a) => a.target === configPath || isManagedSkillTarget(a.target),
|
||||
(a) =>
|
||||
a.target === configPath ||
|
||||
(isAbsolute(a.target) &&
|
||||
isPathWithinRoot(skillDirectory, a.target)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -266,7 +266,6 @@ await withVerifierCleanup(async () => {
|
||||
extractedPluginMcp: true,
|
||||
targetFree: true,
|
||||
targetLifecycle: true,
|
||||
boundedRegexBridge: true,
|
||||
update,
|
||||
mcpStartup,
|
||||
mcpModuleLoading,
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
symlink,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { isPathWithinRoot } from "../dist/domain/localPath.js";
|
||||
import { createVerifierCommands } from "./lib/verify-commands.mjs";
|
||||
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
@@ -58,9 +59,10 @@ let clientVersion = null;
|
||||
let fixture;
|
||||
let passed = false;
|
||||
let evidenceSeen = false;
|
||||
let skillSeen = false;
|
||||
let finalSeen = false;
|
||||
let skillRequested = false;
|
||||
let skillCallId;
|
||||
let expectedSkillBody;
|
||||
let searchCallId;
|
||||
let analysisCallId;
|
||||
let artifactReadByAgent = false;
|
||||
@@ -133,8 +135,11 @@ await withVerifierCleanup(async () => {
|
||||
assert(
|
||||
result.plannedActions.every(
|
||||
(a) =>
|
||||
a.target.startsWith(profile + sep) ||
|
||||
a.target.startsWith(sharedSkills + sep),
|
||||
isAbsolute(a.target) &&
|
||||
((relative(profile, a.target) !== "" &&
|
||||
isPathWithinRoot(profile, a.target)) ||
|
||||
(relative(sharedSkills, a.target) !== "" &&
|
||||
isPathWithinRoot(sharedSkills, a.target))),
|
||||
),
|
||||
);
|
||||
await writeFile(
|
||||
@@ -162,6 +167,20 @@ await withVerifierCleanup(async () => {
|
||||
command: "unrelated-server",
|
||||
disabled: true,
|
||||
});
|
||||
const skillFile = join(
|
||||
sharedSkills,
|
||||
"reverse-engineer-anything",
|
||||
"SKILL.md",
|
||||
);
|
||||
const normalizedSkill = (await readFile(skillFile, "utf8"))
|
||||
.replace(/^\uFEFF/u, "")
|
||||
.replace(/\r\n|\r/gu, "\n");
|
||||
const frontmatter = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/u.exec(
|
||||
normalizedSkill,
|
||||
);
|
||||
assert(frontmatter, "Installed skill must have authored frontmatter");
|
||||
expectedSkillBody = frontmatter[2].trim();
|
||||
assert(expectedSkillBody.length > 0);
|
||||
clientPath = baseEnvironment.PATH;
|
||||
if (reducedPath) {
|
||||
assert.equal(
|
||||
@@ -200,9 +219,6 @@ await withVerifierCleanup(async () => {
|
||||
directory: lab,
|
||||
prefix: `${client}-${mode}`,
|
||||
onRequest: async ({ body, tools, requestIndex }) => {
|
||||
skillSeen ||= JSON.stringify(body.messages ?? []).includes(
|
||||
"reverse-engineer-anything",
|
||||
);
|
||||
const messages = (body.messages ?? []).map((message) => ({
|
||||
role: message.role,
|
||||
callId: message.tool_call_id,
|
||||
@@ -213,14 +229,14 @@ await withVerifierCleanup(async () => {
|
||||
.map((part) => part.text ?? "")
|
||||
.join("\n"),
|
||||
}));
|
||||
const messageText = messages.map((message) => message.text).join("\n");
|
||||
const toolResponseText = (callId) =>
|
||||
messages.find(
|
||||
(message) => message.role === "tool" && message.callId === callId,
|
||||
)?.text ?? "";
|
||||
selectedSkillLoaded ||= messageText.includes(
|
||||
join(sharedSkills, "reverse-engineer-anything"),
|
||||
);
|
||||
const expectedSkill = `Base directory for this skill: ${join(sharedSkills, "reverse-engineer-anything")}\nImportant: ALWAYS resolve absolute paths from this base directory when working with skills.\n\n${expectedSkillBody}\n`;
|
||||
selectedSkillLoaded ||=
|
||||
skillCallId !== undefined &&
|
||||
toolResponseText(skillCallId).includes(expectedSkill);
|
||||
const toolText = JSON.stringify(
|
||||
(body.messages ?? []).filter((m) => m.role === "tool"),
|
||||
);
|
||||
@@ -298,6 +314,7 @@ await withVerifierCleanup(async () => {
|
||||
} else if (skill && !skillRequested) {
|
||||
skillRequested = true;
|
||||
message = call(skill.name, { skill: "reverse-engineer-anything" });
|
||||
skillCallId = message.tool_calls[0].id;
|
||||
} else if (persisted && shell) {
|
||||
artifactPath = persisted[1];
|
||||
assert(
|
||||
|
||||
@@ -32,8 +32,6 @@ const runCli = async (...arguments_) => {
|
||||
return JSON.parse(stdout);
|
||||
};
|
||||
|
||||
const projectRoot = await mkdtemp(join(tmpdir(), "rea-cutter-smoke-"));
|
||||
const projectPath = join(projectRoot, "smoke.rzdb");
|
||||
const rizinQuote = (value) =>
|
||||
`"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||
const unavailableAnalysis = {
|
||||
@@ -44,16 +42,21 @@ const unavailableAnalysis = {
|
||||
},
|
||||
};
|
||||
const expectedPlatform = {
|
||||
linux: "linux",
|
||||
Linux: "linux",
|
||||
macOS: "darwin",
|
||||
Windows: "win32",
|
||||
}[process.env.EXPECTED_PLATFORM];
|
||||
if (expectedPlatform !== undefined)
|
||||
if (process.env.EXPECTED_PLATFORM !== undefined) {
|
||||
assert.ok(expectedPlatform, "Unrecognized Cutter runner platform.");
|
||||
assert.equal(
|
||||
process.platform,
|
||||
expectedPlatform,
|
||||
"Cutter runner did not match its declared platform.",
|
||||
);
|
||||
}
|
||||
const projectRoot = await mkdtemp(join(tmpdir(), "rea-cutter-smoke-"));
|
||||
const projectPath = join(projectRoot, "smoke.rzdb");
|
||||
try {
|
||||
const discovery = await runCli("list-cutter-sessions");
|
||||
assert.equal(
|
||||
|
||||
@@ -79,7 +79,11 @@ const output = {
|
||||
windows: capture.windows,
|
||||
verified:
|
||||
capture.coverage.status === "partial_attach" &&
|
||||
capture.actions.every(({ status }) => status === "completed") &&
|
||||
capture.actions.length === input.actions.length &&
|
||||
capture.actions.every(
|
||||
({ step_id, status }, index) =>
|
||||
step_id === input.actions[index].step_id && status === "completed",
|
||||
) &&
|
||||
capture.actions.some(
|
||||
({ kind, window_index }) =>
|
||||
kind === "renderer-reload" && window_index === 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { hasTypedSwitchEvidence } from "./verify-real-ghidra-switch-assertions.mjs";
|
||||
|
||||
import { access } from "node:fs/promises";
|
||||
import { lstat } from "node:fs/promises";
|
||||
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import { SUPPORTED_GHIDRA_VERSION } from "../dist/ghidra/GhidraReleasePolicy.js";
|
||||
@@ -437,10 +437,11 @@ export function summary(observed) {
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await access(path);
|
||||
await lstat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} catch (cause) {
|
||||
if (cause?.code === "ENOENT") return false;
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
requireMcpLifecycleResult,
|
||||
requireMcpOutput,
|
||||
requireMcpToolError,
|
||||
} from "./lib/mcp-verifier-results.mjs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -183,7 +184,8 @@ try {
|
||||
item.includes("did not attempt a decode"),
|
||||
),
|
||||
);
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
const tools = (await client.listTools()).tools;
|
||||
const advertised = tools.find(
|
||||
(tool) => tool.name === "inspect_native_instruction",
|
||||
);
|
||||
assert.ok(advertised?.outputSchema);
|
||||
@@ -198,6 +200,34 @@ try {
|
||||
nativeInstructionSchema.safeParse({ ...unlisted, status: "undecodable" })
|
||||
.success,
|
||||
);
|
||||
const contextDefinition = tools.find(
|
||||
(tool) => tool.name === "inspect_address_context",
|
||||
);
|
||||
assert.ok(contextDefinition?.outputSchema);
|
||||
const documents = await call("list_documents");
|
||||
assert.equal(documents.length, 1);
|
||||
const document = documents[0];
|
||||
for (const input of [
|
||||
{ address: fixture.entry, document: `${document}-not-a-program` },
|
||||
{ address: "not-an-address", document },
|
||||
]) {
|
||||
const direct = requireMcpToolError(
|
||||
await client.callTool({ name: "address_name", arguments: input }),
|
||||
);
|
||||
const response = await client.callTool(
|
||||
{ name: "inspect_address_context", arguments: input },
|
||||
{ timeout: 240000, toolDefinition: contextDefinition },
|
||||
);
|
||||
const context = requireMcpLifecycleResult(
|
||||
response,
|
||||
"inspect_address_context",
|
||||
);
|
||||
assert.equal(context.name.state, "unavailable");
|
||||
assert.equal(context.name.error.code, "invalid_request");
|
||||
assert.deepEqual(context.name.error.details.issues, direct.details.issues);
|
||||
assert.equal(context.name.reason, direct.message);
|
||||
assert.equal(context.name.remediation, direct.remediation.action);
|
||||
}
|
||||
assert.equal(
|
||||
(await call("resolve_native_call_targets", { address: unlistedAddress }))
|
||||
.status,
|
||||
@@ -357,6 +387,7 @@ try {
|
||||
clear_comments: true,
|
||||
cli_mcp_parity: true,
|
||||
},
|
||||
context_input_errors_preserved: true,
|
||||
original_unchanged: true,
|
||||
address_truncation_rejected: true,
|
||||
limitations: image.limitations,
|
||||
|
||||
@@ -125,6 +125,13 @@ try {
|
||||
assert.equal(image.module_bytes, fixture.module_bytes);
|
||||
assert.equal(image.entry.linear_address, expected.entry);
|
||||
assert.ok(image.checks.every((check) => check.matched));
|
||||
assert.ok(image.observations.relocations.length > 0);
|
||||
assert.ok(
|
||||
image.observations.relocations.every(
|
||||
({ symbol }) => symbol === null || typeof symbol === "string",
|
||||
),
|
||||
"Each real Ghidra relocation must record its symbol observation",
|
||||
);
|
||||
const cliImage = await cliEvidence("inspect-native-load-image");
|
||||
// Snapshot filenames are session-specific. Every measured byte/range/check is stable.
|
||||
const stableImage = (value) => ({
|
||||
|
||||
@@ -415,7 +415,7 @@ async function verifyRequestCancellationAndTimeout(client, entry) {
|
||||
}
|
||||
|
||||
async function verifyConcurrentRequests(client, entry, leaf) {
|
||||
const concurrent = await Promise.all([
|
||||
await Promise.all([
|
||||
functionCall(client, "procedure_info", {
|
||||
document: null,
|
||||
procedure: entry.address,
|
||||
@@ -425,9 +425,7 @@ async function verifyConcurrentRequests(client, entry, leaf) {
|
||||
address: leaf.address,
|
||||
}),
|
||||
]);
|
||||
if (concurrent.length !== 2)
|
||||
throw new Error("Ghidra serial request queue lost a concurrent result");
|
||||
return "serialized-two-results";
|
||||
return "two-concurrent-requests-completed";
|
||||
}
|
||||
|
||||
/** Prove dense jump-table completeness on the optimized jump-table fixture. */
|
||||
|
||||
@@ -375,8 +375,13 @@ try {
|
||||
JSON.stringify(report, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
const outcome = report.typed_failure
|
||||
? "MCP refusal"
|
||||
: mode === "default-native"
|
||||
? "native load image without metadata recovery"
|
||||
: "MCP metadata";
|
||||
console.log(
|
||||
`PASS real Ghidra NativeAOT ${oracle.runtime}/${mode}: MCP metadata, source identity and owned cleanup${report?.compute ? ", independent type/slot oracle and pseudocode" : ""}${report?.cli_metadata ? ", equivalent CLI" : ""}`,
|
||||
`PASS real Ghidra NativeAOT ${oracle.runtime}/${mode}: ${outcome}, source unchanged and runtime directory empty${report?.compute ? ", independent type/slot oracle and pseudocode" : ""}${report?.cli_metadata ? ", equivalent CLI" : ""}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(stderr);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "./verify-real-ghidra-inventory.mjs";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { hasTypedSwitchEvidence } from "./verify-real-ghidra-switch-assertions.mjs";
|
||||
import { verifyGhidraRelocationSymbols } from "./lib/real-ghidra-relocation-symbols.mjs";
|
||||
|
||||
import {
|
||||
verifyDenseDefaultReturn,
|
||||
@@ -281,6 +282,14 @@ try {
|
||||
}
|
||||
await writeFile(malformedPath, Buffer.from("not-a-binary\n", "utf8"));
|
||||
|
||||
const relocationSymbols = crossFormat
|
||||
? await verifyGhidraRelocationSymbols({
|
||||
clang,
|
||||
lld,
|
||||
env: process.env,
|
||||
})
|
||||
: null;
|
||||
|
||||
const debug = await verifyTarget(debugPath, "debug", expectedNativeTarget);
|
||||
assertDebugFixture(debug);
|
||||
const layout = await verifyTarget(
|
||||
@@ -331,6 +340,7 @@ try {
|
||||
? (crossResults[0]?.native_api_cli ?? null)
|
||||
: jumpTable.native_api_cli,
|
||||
native_value_e2e: debug.native_values,
|
||||
relocation_symbols: relocationSymbols,
|
||||
custom_target: custom === null ? null : summary(custom),
|
||||
cleanup: "complete",
|
||||
};
|
||||
|
||||
@@ -55,7 +55,8 @@ try {
|
||||
if (
|
||||
report.missing.length ||
|
||||
report.unexpected.length ||
|
||||
report.duplicates.length
|
||||
report.duplicates.length ||
|
||||
report.wrongProjects.length
|
||||
) {
|
||||
console.error(JSON.stringify(report, null, 2));
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -41,10 +41,18 @@ const [leftPath, rightPath] = await Promise.all([
|
||||
]);
|
||||
if (leftPath === rightPath)
|
||||
throw new Error("--left and --right must resolve to distinct paths");
|
||||
const [left, right] = await Promise.all([
|
||||
const analyses = await Promise.allSettled([
|
||||
analyze(options.left),
|
||||
analyze(options.right),
|
||||
]);
|
||||
const failures = analyses.filter((result) => result.status === "rejected");
|
||||
if (failures.length === 1) throw failures[0].reason;
|
||||
if (failures.length > 1)
|
||||
throw new AggregateError(
|
||||
failures.map((result) => result.reason),
|
||||
"Application analyses failed",
|
||||
);
|
||||
const [left, right] = analyses.map((result) => result.value);
|
||||
const comparison = compareApplicationVersionsEvidence({ left, right });
|
||||
if (!comparison.ok) throw comparison.error;
|
||||
const comparisonResult = comparison.value.result;
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, realpath } from "node:fs/promises";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { readStableArtifact } from "../../../dist/artifacts/readStableArtifact.js";
|
||||
import { ArtifactReaderFailure } from "../../../dist/artifacts/ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
ArtifactFileCleanupFailure,
|
||||
artifactFileCompletion,
|
||||
artifactFilePrimaryFacts,
|
||||
} from "../../../dist/artifacts/ArtifactFileOperation.js";
|
||||
|
||||
import { reconcileJavaScriptRuntimeEvidence } from "../../../dist/application/javascript/JavaScriptRuntimeReconciliationService.js";
|
||||
import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs";
|
||||
import {
|
||||
@@ -52,9 +61,11 @@ for (const encoded of options.mapping) {
|
||||
const mapping = parseMapping(encoded, staticLayers.length);
|
||||
staticLayers[mapping.layer].runtime_mappings.push(mapping.value);
|
||||
}
|
||||
const runtimeObservations = (
|
||||
await Promise.all(options["runtime-evidence"].map(readEvidenceFile))
|
||||
).flat();
|
||||
const runtimeObservations = [];
|
||||
for (const path of options["runtime-evidence"]) {
|
||||
const observations = await readEvidenceFile(path);
|
||||
for (const observation of observations) runtimeObservations.push(observation);
|
||||
}
|
||||
const reconciled = reconcileJavaScriptRuntimeEvidence({
|
||||
static_layers: staticLayers,
|
||||
runtime_observations: runtimeObservations,
|
||||
@@ -95,10 +106,47 @@ process.stdout.write(
|
||||
);
|
||||
|
||||
async function readEvidenceFile(path) {
|
||||
const bytes = await readFile(await realpath(path));
|
||||
if (bytes.byteLength > 64 * 1024 * 1024)
|
||||
throw new Error(`Runtime Evidence exceeds 64 MiB: ${path}`);
|
||||
const value = JSON.parse(bytes.toString("utf8"));
|
||||
const scope = new ArtifactResourceScope();
|
||||
let outcome;
|
||||
try {
|
||||
outcome = {
|
||||
ok: true,
|
||||
value: await readStableArtifact(
|
||||
await realpath(path),
|
||||
64 * 1024 * 1024,
|
||||
undefined,
|
||||
undefined,
|
||||
scope,
|
||||
),
|
||||
};
|
||||
} catch (cause) {
|
||||
outcome = { ok: false, cause };
|
||||
}
|
||||
try {
|
||||
await scope.close();
|
||||
} catch (cleanupCause) {
|
||||
if (!outcome.ok)
|
||||
throw new AggregateError(
|
||||
[outcome.cause, cleanupCause],
|
||||
"Verifier cleanup failed",
|
||||
{ cause: outcome.cause },
|
||||
);
|
||||
throw cleanupCause;
|
||||
}
|
||||
if (!outcome.ok && outcome.cause instanceof ArtifactFileCleanupFailure) {
|
||||
const completion = artifactFileCompletion(outcome.cause);
|
||||
outcome =
|
||||
completion === undefined
|
||||
? { ok: false, cause: artifactFilePrimaryFacts(outcome.cause).cause }
|
||||
: { ok: true, value: completion.value };
|
||||
}
|
||||
if (!outcome.ok) {
|
||||
const cause = outcome.cause;
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "limit")
|
||||
throw new Error(`Runtime Evidence exceeds 64 MiB: ${path}`, { cause });
|
||||
throw cause;
|
||||
}
|
||||
const value = JSON.parse(outcome.value.bytes.toString("utf8"));
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
|
||||
+21
-10
@@ -23,6 +23,12 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
||||
export const ADB_BINARY_ENV = "REA_ADB_PATH";
|
||||
|
||||
const VERSION_TIMEOUT_MS = 10_000;
|
||||
|
||||
const ADB_VERSION_REMEDIATION = `Set ${ADB_BINARY_ENV} to an Android SDK Platform-Tools adb binary whose \`adb version\` reports its version, or put one on PATH.`;
|
||||
/** Repair when the selected adb binary does not exist. */
|
||||
export const ADB_MISSING_REMEDIATION = `Set ${ADB_BINARY_ENV} to an absolute path to an existing adb binary.`;
|
||||
/** Repair when execution of the selected adb binary is denied. */
|
||||
export const ADB_NOT_EXECUTABLE_REMEDIATION = `Check file type and execute permission for ${ADB_BINARY_ENV} or select an executable adb binary.`;
|
||||
const VERSION_MAX_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
/** How the adb binary was selected for this provider instance. */
|
||||
@@ -61,6 +67,7 @@ export const resolveAdbBinary = async (
|
||||
throw new AdbConfigurationFailure(
|
||||
"not_absolute",
|
||||
`${ADB_BINARY_ENV} must be an absolute path to the adb binary`,
|
||||
`Set ${ADB_BINARY_ENV} to an absolute adb executable path, or unset it to use adb from PATH.`,
|
||||
{ configured_path: configured },
|
||||
);
|
||||
return {
|
||||
@@ -93,10 +100,10 @@ const resolveWindowsAdb = async (
|
||||
throw new AdbConfigurationFailure(
|
||||
failure.kind === "missing" ? "binary_missing" : "binary_unresolved",
|
||||
"The adb binary could not be selected from the Windows PATH/PATHEXT",
|
||||
`Set ${ADB_BINARY_ENV} to an absolute adb executable, or add it to PATH/PATHEXT.`,
|
||||
{
|
||||
selected_binary: "adb",
|
||||
path_source: "path",
|
||||
remediation: `Set ${ADB_BINARY_ENV} to an absolute adb executable, or add it to PATH/PATHEXT`,
|
||||
...(failure.kind === "invalid_extension"
|
||||
? { invalid_extension: failure.extension }
|
||||
: {}),
|
||||
@@ -123,10 +130,12 @@ export type AdbConfigurationFailureCode =
|
||||
| "binary_not_executable"
|
||||
| "version_unresolved";
|
||||
|
||||
/** Why the selected adb binary is unusable, with the owner's repair instruction. */
|
||||
export class AdbConfigurationFailure extends Error {
|
||||
constructor(
|
||||
readonly code: AdbConfigurationFailureCode,
|
||||
message: string,
|
||||
readonly remediation: string,
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
@@ -181,6 +190,7 @@ export const inspectAdbClient = async (
|
||||
throw new AdbConfigurationFailure(
|
||||
"version_unresolved",
|
||||
"The selected adb binary exited successfully but did not report a recognizable protocol or build version",
|
||||
ADB_VERSION_REMEDIATION,
|
||||
{
|
||||
selected_binary: selection.binary,
|
||||
path_source: selection.pathSource,
|
||||
@@ -237,10 +247,10 @@ export const inspectAdbClient = async (
|
||||
throw new AdbConfigurationFailure(
|
||||
"binary_missing",
|
||||
`The selected adb binary was not found: ${selection.binary}`,
|
||||
ADB_MISSING_REMEDIATION,
|
||||
{
|
||||
selected_binary: selection.binary,
|
||||
path_source: selection.pathSource,
|
||||
remediation: `Set ${ADB_BINARY_ENV} to an absolute path to an existing adb binary`,
|
||||
},
|
||||
{ cause },
|
||||
);
|
||||
@@ -248,17 +258,18 @@ export const inspectAdbClient = async (
|
||||
throw new AdbConfigurationFailure(
|
||||
"binary_not_executable",
|
||||
`Execution of the selected adb binary was denied (${failure.code}): ${selection.binary}`,
|
||||
ADB_NOT_EXECUTABLE_REMEDIATION,
|
||||
{
|
||||
selected_binary: selection.binary,
|
||||
path_source: selection.pathSource,
|
||||
filesystem_error: failure.code,
|
||||
remediation: `Check file type and execute permission for ${ADB_BINARY_ENV} or select an executable adb binary`,
|
||||
},
|
||||
{ cause },
|
||||
);
|
||||
throw new AdbConfigurationFailure(
|
||||
"version_unresolved",
|
||||
"The selected adb binary did not report a usable version",
|
||||
ADB_VERSION_REMEDIATION,
|
||||
{
|
||||
selected_binary: selection.binary,
|
||||
...(failure === undefined
|
||||
@@ -287,10 +298,10 @@ const rejectionCodeOf = (
|
||||
|
||||
export const adbConfigurationAvailability = (
|
||||
cause: AdbConfigurationFailure,
|
||||
): ProviderAvailability =>
|
||||
({
|
||||
status: "unavailable",
|
||||
code: rejectionCodeOf(cause.code),
|
||||
reason: cause.message,
|
||||
diagnostics: { ...cause.diagnostics },
|
||||
}) as ProviderAvailability;
|
||||
): ProviderAvailability => ({
|
||||
status: "unavailable",
|
||||
code: rejectionCodeOf(cause.code),
|
||||
reason: cause.message,
|
||||
remediation: cause.remediation,
|
||||
diagnostics: { ...cause.diagnostics },
|
||||
});
|
||||
@@ -591,7 +591,8 @@ it("reports a missing selected binary as a capability gap with recovery", async
|
||||
const availability = await provider.inspectAvailability();
|
||||
expect(availability).toMatchObject({ status: "unavailable" });
|
||||
if (availability.status !== "unavailable") return;
|
||||
expect(availability.diagnostics.remediation).toContain("REA_ADB_PATH");
|
||||
expect(availability.remediation).toContain("REA_ADB_PATH");
|
||||
expect(availability.diagnostics).not.toHaveProperty("remediation");
|
||||
const result = await provider.execute({
|
||||
operation: "list_adb_devices",
|
||||
input: {},
|
||||
|
||||
@@ -72,10 +72,10 @@ type ConfigurationFailureCode =
|
||||
| "open_options_invalid";
|
||||
|
||||
/**
|
||||
* Failure kinds tagged at the JADX configuration boundary. Consumers (the
|
||||
* doctor adapter) project the boundary's remediation and classification for
|
||||
* a kind; they must not reinterpret the phase/setting evidence to derive
|
||||
* them.
|
||||
* Failure kinds tagged at the JADX configuration boundary. Availability
|
||||
* carries the boundary's remediation for a kind and the doctor adapter
|
||||
* projects its classification; neither reinterprets the phase/setting
|
||||
* evidence to derive them.
|
||||
*/
|
||||
export type JadxFailureKind =
|
||||
| "windows_native_artifact"
|
||||
@@ -101,72 +101,52 @@ export const JADX_FAILURE_KINDS: readonly JadxFailureKind[] = [
|
||||
"unsupported_host",
|
||||
];
|
||||
|
||||
export interface JadxFailureOutcome {
|
||||
readonly remediation: string;
|
||||
readonly classification: DoctorProviderCheck["classification"];
|
||||
}
|
||||
/** The boundary-owned repair instruction for one tagged failure kind. */
|
||||
const jadxFailureRemediation = (kind: JadxFailureKind): string => {
|
||||
switch (kind) {
|
||||
case "windows_native_artifact":
|
||||
case "windows_native_runtime":
|
||||
return JADX_WINDOWS_NATIVE_REMEDIATION;
|
||||
case "java_runtime":
|
||||
case "java_home":
|
||||
return JADX_JAVA_RUNTIME_REMEDIATION;
|
||||
case "jvm_options":
|
||||
return JADX_JVM_OPTIONS_REMEDIATION;
|
||||
case "operation_timeout":
|
||||
return JADX_OPERATION_TIMEOUT_REMEDIATION;
|
||||
case "jar":
|
||||
return JADX_JAR_CONFIGURATION_REMEDIATION;
|
||||
case "java_probe":
|
||||
return JADX_JAVA_PROBE_REMEDIATION;
|
||||
case "unsupported_host":
|
||||
return JADX_UNSUPPORTED_HOST_REMEDIATION;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Project the boundary-owned remediation and classification for a failure
|
||||
* kind. A null kind (missing or corrupt tag) keeps JAR guidance, matching
|
||||
* the previous fallthrough for unclassified failures.
|
||||
* Project the doctor classification for a failure kind. A null kind (an
|
||||
* untagged readiness failure) is classified as configuration drift.
|
||||
*/
|
||||
export const projectJadxFailureOutcome = (
|
||||
export const jadxFailureClassification = (
|
||||
kind: JadxFailureKind | null,
|
||||
configured: boolean,
|
||||
): JadxFailureOutcome => {
|
||||
): DoctorProviderCheck["classification"] => {
|
||||
const drift = configured ? "config_drift" : "missing_analysis_engine";
|
||||
switch (kind) {
|
||||
case "windows_native_artifact":
|
||||
return {
|
||||
remediation: JADX_WINDOWS_NATIVE_REMEDIATION,
|
||||
classification: "missing_dependency",
|
||||
};
|
||||
case "windows_native_runtime":
|
||||
return {
|
||||
remediation: JADX_WINDOWS_NATIVE_REMEDIATION,
|
||||
classification: "runtime_failure",
|
||||
};
|
||||
case "java_runtime":
|
||||
return {
|
||||
remediation: JADX_JAVA_RUNTIME_REMEDIATION,
|
||||
classification: "missing_dependency",
|
||||
};
|
||||
case "java_home":
|
||||
return {
|
||||
remediation: JADX_JAVA_RUNTIME_REMEDIATION,
|
||||
classification: drift,
|
||||
};
|
||||
case "jvm_options":
|
||||
return {
|
||||
remediation: JADX_JVM_OPTIONS_REMEDIATION,
|
||||
classification: drift,
|
||||
};
|
||||
case "operation_timeout":
|
||||
return {
|
||||
remediation: JADX_OPERATION_TIMEOUT_REMEDIATION,
|
||||
classification: drift,
|
||||
};
|
||||
case "jar":
|
||||
return {
|
||||
remediation: JADX_JAR_CONFIGURATION_REMEDIATION,
|
||||
classification: drift,
|
||||
};
|
||||
case "java_probe":
|
||||
return {
|
||||
remediation: JADX_JAVA_PROBE_REMEDIATION,
|
||||
classification: drift,
|
||||
};
|
||||
return "missing_dependency";
|
||||
case "windows_native_runtime":
|
||||
return "runtime_failure";
|
||||
case "unsupported_host":
|
||||
return {
|
||||
remediation: JADX_UNSUPPORTED_HOST_REMEDIATION,
|
||||
classification: "unsupported_host",
|
||||
};
|
||||
return "unsupported_host";
|
||||
case "java_home":
|
||||
case "jvm_options":
|
||||
case "operation_timeout":
|
||||
case "jar":
|
||||
case "java_probe":
|
||||
case null:
|
||||
return {
|
||||
remediation: JADX_JAR_CONFIGURATION_REMEDIATION,
|
||||
classification: drift,
|
||||
};
|
||||
return drift;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,6 +207,7 @@ export const inspectJadxAvailability = async (
|
||||
status: "unavailable",
|
||||
code: cause.code,
|
||||
reason: cause.message,
|
||||
remediation: jadxFailureRemediation(cause.kind),
|
||||
diagnostics: { ...cause.diagnostics, failure_kind: cause.kind },
|
||||
};
|
||||
const prerequisite = ownedProcessPrerequisiteAvailability(cause);
|
||||
|
||||
@@ -8,9 +8,10 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
inspectJadxAvailability,
|
||||
JADX_FAILURE_KINDS,
|
||||
projectJadxFailureOutcome,
|
||||
jadxFailureClassification,
|
||||
type JadxFailureKind,
|
||||
} from "./JadxConfiguration.js";
|
||||
import { JADX_JAR_CONFIGURATION_REMEDIATION } from "./JadxRelease.js";
|
||||
|
||||
/** Project the validated JADX configuration into the doctor contract. */
|
||||
export const inspectJadxProvider = async (
|
||||
@@ -93,14 +94,14 @@ const configurationCheck = (
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
// The failure kind is tagged at the configuration boundary; project its
|
||||
// remediation and classification here instead of reinterpreting the
|
||||
// phase/setting evidence. Every failed check keeps a remediation.
|
||||
// Availability carries the configuration boundary's remediation and tags
|
||||
// the failure kind; project its classification here instead of
|
||||
// reinterpreting the phase/setting evidence. Every failed check keeps a
|
||||
// remediation.
|
||||
const kind =
|
||||
availability.status === "unavailable"
|
||||
? asFailureKind(availability.diagnostics["failure_kind"])
|
||||
: null;
|
||||
const outcome = projectJadxFailureOutcome(kind, configured);
|
||||
return {
|
||||
name: "configuration",
|
||||
ok: false,
|
||||
@@ -109,8 +110,11 @@ const configurationCheck = (
|
||||
availability.status === "available"
|
||||
? "Validated JADX configuration reported no JAR path."
|
||||
: availability.reason,
|
||||
remediation: outcome.remediation,
|
||||
classification: outcome.classification,
|
||||
remediation:
|
||||
availability.status === "available"
|
||||
? JADX_JAR_CONFIGURATION_REMEDIATION
|
||||
: availability.remediation,
|
||||
classification: jadxFailureClassification(kind, configured),
|
||||
...(kind === "java_probe" && availability.status === "unavailable"
|
||||
? { details: probeDetails(availability.diagnostics) }
|
||||
: kind !== null &&
|
||||
|
||||
@@ -271,9 +271,9 @@ describe("apktoolInvocation", () => {
|
||||
expect(apktoolConfigurationAvailability(failure)).toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "not_configured",
|
||||
remediation: expect.stringContaining(APKTOOL_COMMAND_ENV),
|
||||
diagnostics: {
|
||||
selected_command: "C:\\tools & more\\apktool.cmd",
|
||||
remediation: expect.stringContaining(APKTOOL_COMMAND_ENV),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,12 @@ const apktoolVersionProbeTimeoutGuidance = (): {
|
||||
remediationAction: `This ${String(VERSION_TIMEOUT_MS)} ms apktool version probe has no runtime override. decode_timeout_ms does not raise it. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
|
||||
});
|
||||
|
||||
const APKTOOL_VERSION_REMEDIATION = `Set ${APKTOOL_COMMAND_ENV} to an Apktool 2.7.0 or newer launcher whose --version prints its version, or put one on PATH.`;
|
||||
/** Repair when the selected apktool launcher does not exist. */
|
||||
export const APKTOOL_MISSING_REMEDIATION = `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script).`;
|
||||
/** Repair when execution of the selected apktool launcher is denied. */
|
||||
export const APKTOOL_NOT_EXECUTABLE_REMEDIATION = `Check file type and execute permission for ${APKTOOL_COMMAND_ENV} or select an executable apktool launcher.`;
|
||||
|
||||
/** How the apktool launcher was selected for this provider instance. */
|
||||
export interface ApktoolCommandSelection {
|
||||
readonly command: string;
|
||||
@@ -77,6 +83,7 @@ export const resolveApktoolCommand = async (
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"not_absolute",
|
||||
`${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`,
|
||||
`Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher path, or unset it to use apktool from PATH.`,
|
||||
{ configured_command: configured },
|
||||
);
|
||||
return { command: configured, commandSource: "environment" };
|
||||
@@ -106,6 +113,7 @@ export const resolveApktoolCommand = async (
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"command_unresolved",
|
||||
"Windows PATHEXT must contain filename extensions, not paths",
|
||||
`Correct PATHEXT so it lists filename extensions only, or set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher.`,
|
||||
{
|
||||
selected_command: "apktool",
|
||||
command_source: "path",
|
||||
@@ -116,6 +124,7 @@ export const resolveApktoolCommand = async (
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"command_unresolved",
|
||||
`The PATH-selected apktool launcher could not be inspected: ${failure.path}`,
|
||||
`Make the PATH-selected apktool launcher readable, or set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher.`,
|
||||
{
|
||||
selected_command: "apktool",
|
||||
command_source: "path",
|
||||
@@ -131,10 +140,10 @@ export const resolveApktoolCommand = async (
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"command_missing",
|
||||
"The apktool launcher was not found on the selected Windows PATH/PATHEXT",
|
||||
`Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher, or add its directory and extension to PATH and PATHEXT.`,
|
||||
{
|
||||
selected_command: "apktool",
|
||||
command_source: "path",
|
||||
remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher, or add its directory and extension to PATH and PATHEXT`,
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -148,10 +157,12 @@ export type ApktoolConfigurationFailureCode =
|
||||
| "version_unresolved"
|
||||
| "unsupported_version";
|
||||
|
||||
/** Why the selected apktool launcher is unusable, with the owner's repair instruction. */
|
||||
export class ApktoolConfigurationFailure extends Error {
|
||||
constructor(
|
||||
readonly code: ApktoolConfigurationFailureCode,
|
||||
message: string,
|
||||
readonly remediation: string,
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
@@ -287,10 +298,10 @@ export const inspectApktoolClient = async (
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"launcher_unsupported",
|
||||
`The selected apktool launcher cannot be run through cmd.exe: ${cause.message}`,
|
||||
`Set ${APKTOOL_COMMAND_ENV} to a launcher path without cmd.exe metacharacters (" % ^ ! & | < >), or to a native apktool executable.`,
|
||||
{
|
||||
selected_command: selection.command,
|
||||
command_source: selection.commandSource,
|
||||
remediation: `Set ${APKTOOL_COMMAND_ENV} to a launcher path without cmd.exe metacharacters (" % ^ ! & | < >), or to a native apktool executable`,
|
||||
},
|
||||
{ cause },
|
||||
);
|
||||
@@ -349,6 +360,7 @@ export const inspectApktoolClient = async (
|
||||
: new ApktoolConfigurationFailure(
|
||||
recordedFailure.code,
|
||||
recordedFailure.message,
|
||||
recordedFailure.remediation,
|
||||
{
|
||||
...recordedFailure.diagnostics,
|
||||
...(cancellation === undefined
|
||||
@@ -376,6 +388,7 @@ export const inspectApktoolClient = async (
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"version_unresolved",
|
||||
"The owned version command completed without an output observation",
|
||||
APKTOOL_VERSION_REMEDIATION,
|
||||
{ selected_command: selection.command },
|
||||
);
|
||||
if (!interpreted.ok) throw interpreted.error;
|
||||
@@ -393,6 +406,7 @@ const interpretApktoolVersion = (
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"version_unresolved",
|
||||
"The selected apktool launcher exited successfully but did not report a recognizable version",
|
||||
APKTOOL_VERSION_REMEDIATION,
|
||||
{
|
||||
selected_command: selection.command,
|
||||
command_source: selection.commandSource,
|
||||
@@ -406,12 +420,12 @@ const interpretApktoolVersion = (
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"unsupported_version",
|
||||
`The selected Apktool version ${identity.version} is older than the supported minimum 2.7.0`,
|
||||
`Use Apktool 2.7.0 or newer; see docs/apktool-resource-analysis.md.`,
|
||||
{
|
||||
selected_command: selection.command,
|
||||
command_source: selection.commandSource,
|
||||
reported_version: identity.version,
|
||||
minimum_version: "2.7.0",
|
||||
remediation: `Use Apktool 2.7.0 or newer; see docs/apktool-resource-analysis.md.`,
|
||||
},
|
||||
);
|
||||
return identity;
|
||||
@@ -434,13 +448,13 @@ const rejectionCodeOf = (
|
||||
|
||||
export const apktoolConfigurationAvailability = (
|
||||
cause: ApktoolConfigurationFailure,
|
||||
): ProviderAvailability =>
|
||||
({
|
||||
status: "unavailable",
|
||||
code: rejectionCodeOf(cause.code),
|
||||
reason: cause.message,
|
||||
diagnostics: { ...cause.diagnostics },
|
||||
}) as ProviderAvailability;
|
||||
): ProviderAvailability => ({
|
||||
status: "unavailable",
|
||||
code: rejectionCodeOf(cause.code),
|
||||
reason: cause.message,
|
||||
remediation: cause.remediation,
|
||||
diagnostics: { ...cause.diagnostics },
|
||||
});
|
||||
|
||||
/** Interpret the command owner's outcome, without inferring a new failure from a late signal. */
|
||||
const versionProbeFailure = (
|
||||
@@ -501,14 +515,13 @@ const versionProbeFailure = (
|
||||
cause.code === "EACCES"
|
||||
? `Execution of the selected apktool launcher was denied: ${selection.command}`
|
||||
: `The selected apktool launcher was not found: ${selection.command}`,
|
||||
cause.code === "EACCES"
|
||||
? APKTOOL_NOT_EXECUTABLE_REMEDIATION
|
||||
: APKTOOL_MISSING_REMEDIATION,
|
||||
{
|
||||
filesystem_error: cause.code,
|
||||
selected_command: selection.command,
|
||||
command_source: selection.commandSource,
|
||||
remediation:
|
||||
cause.code === "EACCES"
|
||||
? `Check file type and execute permission for ${APKTOOL_COMMAND_ENV} or select an executable apktool launcher`
|
||||
: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script)`,
|
||||
},
|
||||
{ cause },
|
||||
);
|
||||
@@ -535,6 +548,7 @@ const versionProbeFailure = (
|
||||
return new ApktoolConfigurationFailure(
|
||||
"version_unresolved",
|
||||
"The selected apktool launcher did not report a usable version",
|
||||
APKTOOL_VERSION_REMEDIATION,
|
||||
{
|
||||
selected_command: selection.command,
|
||||
...(observed === undefined
|
||||
|
||||
@@ -6,8 +6,13 @@ import type {
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
projectAnalysisError,
|
||||
type AnalysisErrorProjection,
|
||||
} from "../domain/analysisErrorProjection.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
@@ -23,6 +28,7 @@ type Facet =
|
||||
readonly state: "unavailable";
|
||||
readonly reason: string;
|
||||
readonly remediation: string;
|
||||
readonly error?: AnalysisErrorProjection;
|
||||
};
|
||||
|
||||
const parameters = (
|
||||
@@ -175,11 +181,23 @@ const facet = (
|
||||
const unavailableFacet = (
|
||||
error: AnalysisError | undefined,
|
||||
operation: AnalysisOperation,
|
||||
): Facet => ({
|
||||
state: "unavailable",
|
||||
reason: error?.message ?? `${operation} returned no result`,
|
||||
remediation: `Call ${operation} directly to inspect provider availability and diagnostics.`,
|
||||
});
|
||||
): Facet => {
|
||||
if (error instanceof AnalysisInputError) {
|
||||
const projected = projectAnalysisError(error);
|
||||
if (projected.code === "invalid_request")
|
||||
return {
|
||||
state: "unavailable",
|
||||
reason: projected.message,
|
||||
remediation: projected.remediation.action,
|
||||
error: projected,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "unavailable",
|
||||
reason: error?.message ?? `${operation} returned no result`,
|
||||
remediation: `Call ${operation} directly to inspect provider availability and diagnostics.`,
|
||||
};
|
||||
};
|
||||
|
||||
const isRequestLevelFailure = (error: AnalysisError): boolean =>
|
||||
error._tag === "AnalysisCancelledError" ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readArtifactInspectionSources } from "../domain/artifactInspection.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { tryParseEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Resolve the embedded publication closure without reinterpreting it in delivery adapters. */
|
||||
@@ -15,3 +16,128 @@ export const analysisEvidenceSources = (
|
||||
return err(embedded.error.retainPartialObservation(complete));
|
||||
return ok([...embedded.value, complete]);
|
||||
};
|
||||
|
||||
/** Matches the diagnostics allowance retained for other finalized one-shot work. */
|
||||
const SOURCE_CLOSURE_INLINE_BYTES = 64 * 1024;
|
||||
/** Identities beyond this stay counted; direct links remain on the observation. */
|
||||
const SOURCE_CLOSURE_LISTED_OMISSIONS = 64;
|
||||
const SOURCE_CLOSURE_RECOVERY =
|
||||
"No bundle was written and this invocation retains nothing after exit. Keep any records shown here, or run the operation again after following remediation; that is a new observation and may differ.";
|
||||
|
||||
/** What a failed one-shot delivery holds for one record its observation cites. */
|
||||
export type SourceClosureOmission = {
|
||||
readonly evidence_id: string;
|
||||
/**
|
||||
* `not_captured`: this invocation holds no such record, so none can be shown.
|
||||
* `not_inlined`: captured, but not part of this bounded failure response.
|
||||
*/
|
||||
readonly reason: "not_captured" | "not_inlined";
|
||||
};
|
||||
|
||||
/** Bounded failure handoff for a completed observation whose bundle never committed. */
|
||||
export type UndeliveredSourceClosure = {
|
||||
readonly publication: "not_published";
|
||||
/** Every cited record is in `records` or embedded in the observation itself. */
|
||||
readonly complete: boolean;
|
||||
readonly records: Evidence[];
|
||||
readonly omitted_count: number;
|
||||
readonly omitted: SourceClosureOmission[];
|
||||
readonly recovery: string;
|
||||
};
|
||||
|
||||
/** The closure state is unknown because examining it failed. */
|
||||
export type UnavailableSourceClosure = {
|
||||
readonly publication: "not_published";
|
||||
readonly complete: false;
|
||||
readonly collection_failure: string;
|
||||
readonly recovery: string;
|
||||
};
|
||||
|
||||
const collectSourceClosure = (
|
||||
observation: Evidence,
|
||||
captured: readonly Evidence[],
|
||||
options: { readonly inlineRecords: boolean },
|
||||
): UndeliveredSourceClosure | undefined => {
|
||||
const inspection = analysisEvidenceSources(observation);
|
||||
const embedded = new Map(
|
||||
(inspection.ok ? inspection.value : []).map((record) => [
|
||||
record.evidence_id,
|
||||
record,
|
||||
]),
|
||||
);
|
||||
const held = new Map(captured.map((record) => [record.evidence_id, record]));
|
||||
// Set iteration reaches entries added while it runs, which makes this a
|
||||
// deduplicating breadth-first queue: directly cited records spend the
|
||||
// allowance first, and each link costs one lookup however often it repeats.
|
||||
const closure = new Set([observation.evidence_id]);
|
||||
for (const evidenceId of observation.evidence_links) closure.add(evidenceId);
|
||||
for (const evidenceId of held.keys()) closure.add(evidenceId);
|
||||
const records: Evidence[] = [];
|
||||
const omitted: SourceClosureOmission[] = [];
|
||||
let omittedCount = 0;
|
||||
let inlineBytes = options.inlineRecords ? SOURCE_CLOSURE_INLINE_BYTES : 0;
|
||||
const omit = (
|
||||
evidenceId: string,
|
||||
reason: SourceClosureOmission["reason"],
|
||||
) => {
|
||||
omittedCount += 1;
|
||||
if (omitted.length < SOURCE_CLOSURE_LISTED_OMISSIONS)
|
||||
omitted.push({ evidence_id: evidenceId, reason });
|
||||
};
|
||||
for (const evidenceId of closure) {
|
||||
if (evidenceId === observation.evidence_id) continue;
|
||||
const inObservation = embedded.get(evidenceId);
|
||||
const record = inObservation ?? held.get(evidenceId);
|
||||
if (record === undefined) {
|
||||
omit(evidenceId, "not_captured");
|
||||
continue;
|
||||
}
|
||||
for (const link of record.evidence_links) closure.add(link);
|
||||
if (inObservation !== undefined) continue;
|
||||
const bytes = jsonValueByteLength(record, inlineBytes);
|
||||
if (bytes > inlineBytes) {
|
||||
omit(evidenceId, "not_inlined");
|
||||
continue;
|
||||
}
|
||||
records.push(record);
|
||||
inlineBytes -= bytes;
|
||||
}
|
||||
if (closure.size === 1) return undefined;
|
||||
return {
|
||||
publication: "not_published",
|
||||
complete: omittedCount === 0,
|
||||
records,
|
||||
omitted_count: omittedCount,
|
||||
omitted,
|
||||
recovery: SOURCE_CLOSURE_RECOVERY,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Describe the source closure of a completed observation after delivery failed
|
||||
* before commit. Only already captured records are reported, so an unresolved
|
||||
* link stays an explicit omission and its own ancestors remain unknown.
|
||||
*
|
||||
* This annotates a failure that must reach the caller, so it never throws. If
|
||||
* examining the closure fails, the state is reported as unknown, naming only
|
||||
* the bounded error type rather than a message that could quote analyzed data.
|
||||
*/
|
||||
export const undeliveredSourceClosure = (
|
||||
partialObservation: unknown,
|
||||
captured: readonly Evidence[],
|
||||
options: { readonly inlineRecords: boolean },
|
||||
): UndeliveredSourceClosure | UnavailableSourceClosure | undefined => {
|
||||
try {
|
||||
const observation = tryParseEvidence(partialObservation);
|
||||
return observation === undefined
|
||||
? undefined
|
||||
: collectSourceClosure(observation, captured, options);
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
publication: "not_published",
|
||||
complete: false,
|
||||
collection_failure: cause instanceof Error ? cause.name : "unknown",
|
||||
recovery: SOURCE_CLOSURE_RECOVERY,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -258,6 +258,11 @@ type CapabilityAvailability =
|
||||
readonly available: false;
|
||||
readonly reason: string;
|
||||
readonly availabilityCode?: ProviderRejectionCode;
|
||||
/**
|
||||
* Repair instruction from the readiness owner that made this operation
|
||||
* unavailable. Static capability facts omit it.
|
||||
*/
|
||||
readonly remediation?: string;
|
||||
};
|
||||
|
||||
export type CapabilityDescriptor = CapabilityAvailability & {
|
||||
@@ -304,7 +309,10 @@ export type ProviderAvailability =
|
||||
| {
|
||||
readonly status: "unavailable";
|
||||
readonly code: ProviderRejectionCode;
|
||||
/** What the owner observed; not a repair instruction. */
|
||||
readonly reason: string;
|
||||
/** The owner's repair instruction for this exact failure. */
|
||||
readonly remediation: string;
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
|
||||
|
||||
@@ -461,10 +461,11 @@ const resolveLayoutExecutable = async (
|
||||
|
||||
let name: string;
|
||||
try {
|
||||
name = hasBinaryPlistSignature(plist)
|
||||
? await (fileSystem.decodeBinaryPlist?.(plistPath) ??
|
||||
parseBinaryPlistExecutable(plist))
|
||||
: parseXmlPlistExecutable(decodeXmlPlistText(plist));
|
||||
name =
|
||||
hasBinaryPlistSignature(plist) &&
|
||||
fileSystem.decodeBinaryPlist !== undefined
|
||||
? await fileSystem.decodeBinaryPlist(plistPath)
|
||||
: executableName(decodeInfoPlistValue(plist));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new BinaryTargetError(
|
||||
@@ -546,12 +547,18 @@ const resolveLayoutExecutable = async (
|
||||
}
|
||||
};
|
||||
|
||||
/** Decode the top-level executable name with an XML parser, not a pattern. */
|
||||
const parseXmlPlistExecutable = (plist: string): string => {
|
||||
// An unrelated `__proto__` entry must not make the bundle unreadable.
|
||||
const { value } = parseXmlPropertyList(plist);
|
||||
return executableName(value);
|
||||
};
|
||||
/**
|
||||
* Decode one Info.plist's top-level value in-process, binary or XML, so no
|
||||
* host plist tool is required. Values are read with format-aware parsers, not
|
||||
* patterns; an unrelated `__proto__` entry must not make the bundle unreadable.
|
||||
*/
|
||||
export const decodeInfoPlistValue = (plist: Buffer): unknown =>
|
||||
hasBinaryPlistSignature(plist)
|
||||
? decodeBinaryPlistValue(
|
||||
plist,
|
||||
preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES),
|
||||
)
|
||||
: parseXmlPropertyList(decodeXmlPlistText(plist)).value;
|
||||
|
||||
/** The Info.plist decoded but declares no string CFBundleExecutable. */
|
||||
class UndeclaredBundleExecutable extends Error {}
|
||||
@@ -565,12 +572,6 @@ const executableName = (value: unknown): string => {
|
||||
|
||||
const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() });
|
||||
|
||||
/** Decode a binary plist in-process, so no host plist tool is required. */
|
||||
const parseBinaryPlistExecutable = (plist: Buffer): string => {
|
||||
const plan = preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES);
|
||||
return executableName(decodeBinaryPlistValue(plist, plan));
|
||||
};
|
||||
|
||||
/** Names are one local path component; backslash is literal on POSIX hosts. */
|
||||
const isSafeExecutableName = (name: string): boolean =>
|
||||
name.length > 0 &&
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { childFailureDiagnostics } from "../domain/analysisErrorProjection.js";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { access, realpath, stat, type FileHandle } from "node:fs/promises";
|
||||
@@ -607,14 +607,12 @@ const targetFileCleanupError = (
|
||||
: String(failure.cleanupCause),
|
||||
resources: [failure.path],
|
||||
},
|
||||
diagnostics: {
|
||||
...(primary === undefined
|
||||
...childFailureDiagnostics(
|
||||
primary === undefined ? {} : { primary_error: { error: primary } },
|
||||
completedTarget === undefined
|
||||
? {}
|
||||
: { primary_error: projectAnalysisError(primary) }),
|
||||
...(completedTarget === undefined
|
||||
? {}
|
||||
: { completed_target: completedTarget }),
|
||||
},
|
||||
: { completed_target: completedTarget },
|
||||
),
|
||||
},
|
||||
);
|
||||
bindRegularFileCleanup(failure, error);
|
||||
|
||||
@@ -114,6 +114,38 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it("retains completed scenario Evidence when cancellation arrives during capture", async () => {
|
||||
const caller = new AbortController();
|
||||
const reason = { requested: "stop after capture" };
|
||||
const provider: BrowserScenarioCapturePort = {
|
||||
identity: () => scenarioIdentity,
|
||||
captureScenario: async (received, options) => {
|
||||
expect(received).toBe(scenario);
|
||||
expect(options?.signal).toBe(caller.signal);
|
||||
caller.abort(reason);
|
||||
return ok(capture);
|
||||
},
|
||||
};
|
||||
const result = await captureBrowserScenario(provider, scenario, {
|
||||
signal: caller.signal,
|
||||
});
|
||||
if (result.ok) throw new Error("Expected late cancellation");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "capture_browser_scenario",
|
||||
provider: scenarioIdentity,
|
||||
confidence: "observed",
|
||||
authority: "controlled-replay",
|
||||
parameters: {
|
||||
browser: scenario.browser,
|
||||
start_url: scenario.start_url,
|
||||
actions: scenario.actions,
|
||||
},
|
||||
normalized_result: capture,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps startup cancellation and its incomplete capture", async () => {
|
||||
const reason = { requested: "stop during initial navigation" };
|
||||
const completed = scriptScenarioFixture();
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import type { CallRelationshipDirection } from "../contracts/enhancedInputs.js";
|
||||
import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js";
|
||||
import {
|
||||
cleanupStoppedTraversal,
|
||||
readCallRelationships,
|
||||
type CallRelationshipObserver,
|
||||
} from "./CallRelationshipTraversal.js";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperation,
|
||||
ExecutionOptions,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { parseRelatedAddresses } from "../domain/native/nativeObservation.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { EnhancedResult } from "./EnhancedToolTypes.js";
|
||||
import {
|
||||
failedWithPartialResult,
|
||||
type EnhancedResult,
|
||||
} from "./EnhancedToolTypes.js";
|
||||
import { resolveProcedureAddress } from "./ProcedureAddressResolution.js";
|
||||
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
|
||||
|
||||
@@ -22,13 +27,6 @@ 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;
|
||||
@@ -80,7 +78,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,
|
||||
callRelationship: CallRelationshipObserver,
|
||||
input: CallPathTraceInput,
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult => {
|
||||
@@ -123,7 +121,16 @@ export const traceCallPath = async (
|
||||
current,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
if (!expanded.ok) return expanded;
|
||||
if (!expanded.ok) {
|
||||
if (!expanded.error.cleanupIncomplete) return expanded;
|
||||
state.residual.add(
|
||||
cleanupStoppedTraversal(address, state.queue.length - state.queueIndex),
|
||||
);
|
||||
return failedWithPartialResult(
|
||||
expanded.error,
|
||||
projectTrace(resolvedInput, state),
|
||||
);
|
||||
}
|
||||
if (expanded.value) break;
|
||||
}
|
||||
if (isAborted(signal))
|
||||
@@ -148,44 +155,40 @@ const createTraceState = (input: CallPathTraceInput): TraceState => ({
|
||||
});
|
||||
|
||||
const expandAddress = async (
|
||||
call: RelationshipCall,
|
||||
call: CallRelationshipObserver,
|
||||
expansion: AddressExpansion,
|
||||
): Promise<Result<boolean, AnalysisError>> => {
|
||||
const { input, state, address, current, signal } = expansion;
|
||||
const { relation, operation: tool } =
|
||||
CALL_RELATIONSHIP_TRAVERSALS[input.direction];
|
||||
const result = await call(tool, { procedure: address }, signal, "provider");
|
||||
if (!result.ok) {
|
||||
if (
|
||||
result.error._tag === "AnalysisCancelledError" ||
|
||||
signal?.aborted === true
|
||||
)
|
||||
return result;
|
||||
}
|
||||
const read = await readCallRelationships(
|
||||
call,
|
||||
input.direction,
|
||||
address,
|
||||
signal,
|
||||
);
|
||||
// A provider failure delivered with cancellation remains the selected outcome.
|
||||
if (
|
||||
read.status === "stopped" ||
|
||||
(read.status === "unavailable" && isAborted(signal))
|
||||
)
|
||||
return err(read.error);
|
||||
if (isAborted(signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("trace_call_path", { cause: signal?.reason }),
|
||||
);
|
||||
if (!result.ok) {
|
||||
if (read.status !== "observed") {
|
||||
state.failures.push({
|
||||
address,
|
||||
error: projectAnalysisError(result.error),
|
||||
error: projectAnalysisError(read.error),
|
||||
});
|
||||
state.residual.add(`Call relationships were unavailable for ${address}.`);
|
||||
state.residual.add(
|
||||
`Call relationships were ${read.status} for ${address}.`,
|
||||
);
|
||||
return ok(false);
|
||||
}
|
||||
const related = parseRelatedAddresses(result.value.result, relation, tool);
|
||||
if (!related.ok) {
|
||||
state.failures.push({
|
||||
address,
|
||||
error: projectAnalysisError(related.error),
|
||||
});
|
||||
state.residual.add(`Call relationships were unreadable for ${address}.`);
|
||||
return ok(false);
|
||||
}
|
||||
for (const limitation of result.value.limitations)
|
||||
const { execution, related } = read;
|
||||
for (const limitation of execution.limitations)
|
||||
state.limitations.add(limitation);
|
||||
const coverage = result.value.relationshipCoverage ?? {
|
||||
const coverage = execution.relationshipCoverage ?? {
|
||||
status: "unknown",
|
||||
reason: "Provider did not report relationship coverage.",
|
||||
};
|
||||
@@ -204,7 +207,7 @@ const expandAddress = async (
|
||||
state.residual.add(
|
||||
`Call relationship coverage is unknown for ${address}: ${coverage.reason}`,
|
||||
);
|
||||
for (const relatedAddress of sortedUniqueAddresses(related.value)) {
|
||||
for (const relatedAddress of sortedUniqueAddresses(related)) {
|
||||
recordEdge(input, state, {
|
||||
address,
|
||||
relatedAddress,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisOperationPort,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { EnhancedTools } from "./EnhancedTools.js";
|
||||
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const tools = ["get_call_graph", "trace_call_path"] as const;
|
||||
const input = (tool: (typeof tools)[number]) =>
|
||||
tool === "get_call_graph"
|
||||
? { address: "0x1", direction: "forward" }
|
||||
: { start: "0x1", goal: "0x4", direction: "forward" };
|
||||
|
||||
const cleanupFailure = () =>
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
provider.id,
|
||||
"procedure_callees",
|
||||
"child failed",
|
||||
{ cleanup: { reason: "worker cleanup failed", resources: ["worker"] } },
|
||||
);
|
||||
|
||||
/** 0x1 calls 0x2 and 0x3; 0x2's read is supplied by the test. */
|
||||
const port = (
|
||||
reads: string[],
|
||||
second: (signal?: AbortSignal) => AnalysisError,
|
||||
): AnalysisOperationPort => ({
|
||||
execute: async (operation, parameters, options) => {
|
||||
const procedure = String(parameters.procedure);
|
||||
if (operation === "procedure_address")
|
||||
return ok(createAnalysisExecution(procedure, provider));
|
||||
reads.push(procedure);
|
||||
if (procedure === "0x2") return err(second(options?.signal));
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
procedure === "0x1" ? ["0x2", "0x3"] : ["0x4"],
|
||||
provider,
|
||||
{ relationshipCoverage: { status: "complete" } },
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
describe.each(tools)("%s cleanup ownership", (tool) => {
|
||||
it("returns the provider's cleanup failure so its owner resolves the outcome", async () => {
|
||||
const failure = cleanupFailure();
|
||||
let released = false;
|
||||
const lease = new AnalysisCleanupLease(async () => {
|
||||
if (!released) throw new Error("still owned");
|
||||
}, ["worker"]);
|
||||
lease.bindCleanupError(failure);
|
||||
failure.bindCleanupRetry({ operation: "close_binary" });
|
||||
const reads: string[] = [];
|
||||
|
||||
const result = await new EnhancedTools(port(reads, () => failure)).execute(
|
||||
tool,
|
||||
input(tool),
|
||||
);
|
||||
|
||||
if (result.ok) throw new Error("expected incomplete cleanup");
|
||||
expect(result.error).toBe(failure);
|
||||
expect("partialResult" in result).toBe(true);
|
||||
expect(reads).toEqual(["0x1", "0x2"]);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
recovery: { kind: "retry_cleanup", operation: "close_binary" },
|
||||
});
|
||||
await expect(lease.release()).rejects.toThrow("still owned");
|
||||
expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete");
|
||||
released = true;
|
||||
await lease.release();
|
||||
expect(projectAnalysisError(result.error).code).toBe(
|
||||
"capability_unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a cleanup failure primary when cancellation arrives with it", async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = cleanupFailure();
|
||||
const reads: string[] = [];
|
||||
|
||||
const result = await new EnhancedTools(
|
||||
port(reads, () => {
|
||||
controller.abort({ requested: "stop" });
|
||||
return failure;
|
||||
}),
|
||||
).execute(tool, input(tool), controller.signal);
|
||||
|
||||
if (result.ok) throw new Error("expected incomplete cleanup");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete");
|
||||
expect(reads).toEqual(["0x1", "0x2"]);
|
||||
});
|
||||
|
||||
it("stops at a provider cancellation whose own cleanup is unconfirmed", async () => {
|
||||
const controller = new AbortController();
|
||||
const reads: string[] = [];
|
||||
let cancellation: AnalysisCancelledError | undefined;
|
||||
|
||||
const result = await new EnhancedTools(
|
||||
port(reads, (signal) => {
|
||||
controller.abort();
|
||||
cancellation = new AnalysisCancelledError("procedure_callees", {
|
||||
cause: signal?.reason,
|
||||
cleanup: { reason: "cancel cleanup failed", resources: ["worker"] },
|
||||
});
|
||||
return cancellation;
|
||||
}),
|
||||
).execute(tool, input(tool), controller.signal);
|
||||
|
||||
if (result.ok) throw new Error("expected cancellation");
|
||||
expect(result.error).toBe(cancellation);
|
||||
expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete");
|
||||
expect(reads).toEqual(["0x1", "0x2"]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { CallRelationshipDirection } from "../contracts/enhancedInputs.js";
|
||||
import type { AnalysisOperation } from "./AnalysisProvider.js";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperation,
|
||||
ExecutionOptions,
|
||||
} from "./AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { parseRelatedAddresses } from "../domain/native/nativeObservation.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
/** The provider read and response relation for each supported traversal direction. */
|
||||
export const CALL_RELATIONSHIP_TRAVERSALS = {
|
||||
@@ -14,3 +23,65 @@ export const CALL_RELATIONSHIP_TRAVERSALS = {
|
||||
}
|
||||
>
|
||||
>;
|
||||
|
||||
/** One observed provider operation, as composed workflows dispatch it. */
|
||||
export type CallRelationshipObserver = (
|
||||
name: AnalysisOperation,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
selectorOrigin?: ExecutionOptions["selectorOrigin"],
|
||||
) => Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
|
||||
/**
|
||||
* One procedure's relationship read. An unavailable or unreadable read leaves
|
||||
* the rest of the traversal usable; a stopped read must end it.
|
||||
*/
|
||||
export type CallRelationshipRead =
|
||||
| {
|
||||
readonly status: "observed";
|
||||
readonly execution: AnalysisExecution;
|
||||
readonly related: readonly string[];
|
||||
}
|
||||
| { readonly status: "unavailable"; readonly error: AnalysisError }
|
||||
| { readonly status: "unreadable"; readonly error: AnalysisOutputError }
|
||||
| { readonly status: "stopped"; readonly error: AnalysisError };
|
||||
|
||||
/**
|
||||
* Whether a failed child read must end a traversal rather than become one of
|
||||
* its unknowns. A cancelled read ends it; so does a read whose provider cannot
|
||||
* confirm release of resources it owns, because that read has not finished and
|
||||
* dispatching more reads would build on that state.
|
||||
*/
|
||||
export const stopsTraversal = (error: AnalysisError): boolean =>
|
||||
error._tag === "AnalysisCancelledError" || error.cleanupIncomplete;
|
||||
|
||||
/** Read and parse one procedure's direct relationships for a traversal. */
|
||||
export const readCallRelationships = async (
|
||||
observe: CallRelationshipObserver,
|
||||
direction: CallRelationshipDirection,
|
||||
procedure: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CallRelationshipRead> => {
|
||||
const { operation, relation } = CALL_RELATIONSHIP_TRAVERSALS[direction];
|
||||
const execution = await observe(operation, { procedure }, signal, "provider");
|
||||
if (!execution.ok)
|
||||
return {
|
||||
status: stopsTraversal(execution.error) ? "stopped" : "unavailable",
|
||||
error: execution.error,
|
||||
};
|
||||
const related = parseRelatedAddresses(
|
||||
execution.value.result,
|
||||
relation,
|
||||
operation,
|
||||
);
|
||||
return related.ok
|
||||
? { status: "observed", execution: execution.value, related: related.value }
|
||||
: { status: "unreadable", error: related.error };
|
||||
};
|
||||
|
||||
/** The admitted traversal's caveat when unconfirmed cleanup stopped it. */
|
||||
export const cleanupStoppedTraversal = (
|
||||
procedure: string,
|
||||
unexpanded: number,
|
||||
): string =>
|
||||
`Traversal stopped at ${procedure} because the provider did not confirm cleanup of resources it owns; ${unexpanded === 1 ? "1 discovered procedure was" : `${unexpanded} discovered procedures were`} not expanded.`;
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
compactToolAvailability,
|
||||
} from "./CapabilityInventory.js";
|
||||
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import { cleanupRetryRemediation } from "../domain/analysisErrorRecovery.js";
|
||||
import {
|
||||
expandToolAvailability,
|
||||
toolAvailabilityInventory,
|
||||
@@ -52,6 +53,29 @@ const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
|
||||
v8InspectorObservationEnabled: true,
|
||||
};
|
||||
|
||||
it("adds the retaining close owner's prerequisite without guessing from a pending flag", () => {
|
||||
const retry = { operation: "close_binary" } as const;
|
||||
const input = { open: false, capabilities: [], cleanup_pending: true };
|
||||
const unavailable = buildCapabilityInventory(
|
||||
{ ...input, cleanup_retry: retry },
|
||||
enabledPolicy,
|
||||
).find(({ name }) => name === "procedure_assembly");
|
||||
expect(unavailable).toMatchObject({
|
||||
available: false,
|
||||
reason: "target_required",
|
||||
remediation: expect.stringContaining(cleanupRetryRemediation(retry)),
|
||||
});
|
||||
const unknownOwner = buildCapabilityInventory(input, enabledPolicy).find(
|
||||
({ name }) => name === "procedure_assembly",
|
||||
);
|
||||
const original = buildCapabilityInventory(
|
||||
{ open: false, capabilities: [] },
|
||||
enabledPolicy,
|
||||
).find(({ name }) => name === "procedure_assembly");
|
||||
expect(unknownOwner).toEqual(original);
|
||||
expect(unknownOwner?.reason).toBe("target_required");
|
||||
});
|
||||
|
||||
const status = (
|
||||
options: {
|
||||
readonly open?: boolean;
|
||||
@@ -184,6 +208,7 @@ describe("ADB and Apktool host availability", () => {
|
||||
status: "unavailable" as const,
|
||||
code: "not_configured" as const,
|
||||
reason: "REA_ADB_PATH is invalid",
|
||||
remediation: "Set REA_ADB_PATH to an absolute adb executable path.",
|
||||
diagnostics: {},
|
||||
};
|
||||
const readyInventory = buildCapabilityInventory(status(), {
|
||||
@@ -205,6 +230,7 @@ describe("ADB and Apktool host availability", () => {
|
||||
status: "unavailable",
|
||||
code: "unsupported_version",
|
||||
reason: "Apktool 2.6.3 is older than 2.7.0",
|
||||
remediation: "Repair the fixture provider.",
|
||||
diagnostics: {},
|
||||
},
|
||||
});
|
||||
@@ -227,6 +253,7 @@ describe("capability failure reasons", () => {
|
||||
status: "unavailable",
|
||||
code: "runtime_unavailable",
|
||||
reason: "The JADX native self-test failed.",
|
||||
remediation: "Repair the fixture provider.",
|
||||
diagnostics: {},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,6 +42,11 @@ import type { JebAnalysisAvailability } from "./jeb/JebAnalysisPort.js";
|
||||
import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js";
|
||||
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import {
|
||||
analysisCleanupRetrySchema,
|
||||
cleanupRetryRemediation,
|
||||
type AnalysisCleanupRetry,
|
||||
} from "../domain/analysisErrorRecovery.js";
|
||||
import { isBundleResourceOperation } from "./binary/BundleResourceSession.js";
|
||||
|
||||
const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
|
||||
@@ -50,6 +55,7 @@ const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
|
||||
|
||||
const statusSchema = z.object({
|
||||
open: z.boolean(),
|
||||
cleanup_retry: analysisCleanupRetrySchema.optional(),
|
||||
session_kind: z.literal("bundle_resources").optional(),
|
||||
kind: z.enum(["executable", "database", "archive", "artifact"]).optional(),
|
||||
format: z.string().optional(),
|
||||
@@ -61,6 +67,7 @@ const statusSchema = z.object({
|
||||
type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
|
||||
|
||||
type ProviderDescriptor = ProviderCapability;
|
||||
type UnavailableDescriptor = Extract<ProviderDescriptor, { available: false }>;
|
||||
|
||||
/** Optional integrations whose verified hosts exclude some platforms. */
|
||||
export const HOST_RESTRICTED_FEATURES = [
|
||||
@@ -134,6 +141,7 @@ type AvailabilityContext = {
|
||||
readonly name: string;
|
||||
readonly kind: ToolKind;
|
||||
readonly targetOpen: boolean;
|
||||
readonly cleanupRetry: AnalysisCleanupRetry | undefined;
|
||||
readonly resourceSession: boolean;
|
||||
readonly requiresActiveTarget: boolean;
|
||||
readonly targetKind:
|
||||
@@ -262,6 +270,7 @@ export const buildCapabilityInventory = (
|
||||
name: contract.name,
|
||||
kind: contract.kind,
|
||||
targetOpen: status.open,
|
||||
cleanupRetry: status.cleanup_retry,
|
||||
resourceSession: status.session_kind === "bundle_resources",
|
||||
requiresActiveTarget:
|
||||
"requiresActiveTarget" in contract &&
|
||||
@@ -575,7 +584,7 @@ const workflowAvailabilityFor = ({
|
||||
if (profile.reason !== "available") return profile;
|
||||
const limiter = policy.evmLimiterAvailability;
|
||||
if (limiter?.status === "unavailable")
|
||||
return { reason: "provider_missing", remediation: limiter.reason };
|
||||
return { reason: "provider_missing", remediation: limiter.remediation };
|
||||
return { reason: "available", remediation: null };
|
||||
}
|
||||
if (name === "inspect_recorded_crash")
|
||||
@@ -609,7 +618,7 @@ const workflowAvailabilityFor = ({
|
||||
? "provider_unavailable"
|
||||
: "provider_missing",
|
||||
remediation:
|
||||
policy.androidAnalysisAvailability?.reason ??
|
||||
policy.androidAnalysisAvailability?.remediation ??
|
||||
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
|
||||
};
|
||||
if (kind === "adb-provider")
|
||||
@@ -701,7 +710,7 @@ const ownerProbeAvailability = (
|
||||
: observation.code === "not_configured"
|
||||
? "provider_missing"
|
||||
: "provider_unavailable",
|
||||
remediation: observation.reason,
|
||||
remediation: observation.remediation,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -765,7 +774,10 @@ const hostAvailability = ({
|
||||
const descriptor = descriptors.get(name);
|
||||
return descriptor?.available === false &&
|
||||
descriptor.availability_code === "unsupported_host"
|
||||
? { reason: "unsupported_host", remediation: descriptor.reason }
|
||||
? {
|
||||
reason: "unsupported_host",
|
||||
remediation: descriptorRemediation(descriptor),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
@@ -775,13 +787,17 @@ const targetAvailability = ({
|
||||
targetKind,
|
||||
targetFormat,
|
||||
targetOpen,
|
||||
cleanupRetry,
|
||||
requiresActiveTarget,
|
||||
descriptors,
|
||||
}: AvailabilityContext): Availability | null => {
|
||||
if (!targetOpen && requiresActiveTarget)
|
||||
return {
|
||||
reason: "target_required",
|
||||
remediation: "Call open_binary with a supported local target.",
|
||||
remediation:
|
||||
cleanupRetry === undefined
|
||||
? "Call open_binary with a supported local target."
|
||||
: `${cleanupRetryRemediation(cleanupRetry)} After cleanup succeeds, call open_binary with a supported local target.`,
|
||||
};
|
||||
const descriptor = descriptors.get(name);
|
||||
if (
|
||||
@@ -791,7 +807,10 @@ const targetAvailability = ({
|
||||
(descriptor.availability_code === "target_kind_unsupported" ||
|
||||
descriptor.availability_code === "target_format_unsupported")
|
||||
)
|
||||
return { reason: "target_unsupported", remediation: descriptor.reason };
|
||||
return {
|
||||
reason: "target_unsupported",
|
||||
remediation: descriptorRemediation(descriptor),
|
||||
};
|
||||
if (
|
||||
targetKind !== undefined &&
|
||||
targetKind !== "executable" &&
|
||||
@@ -840,11 +859,19 @@ const providerAvailability = ({
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "provider_unavailable",
|
||||
remediation:
|
||||
descriptor.reason ?? "Choose another target or configured provider.",
|
||||
remediation: descriptorRemediation(descriptor),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Prefer the readiness owner's repair instruction. A static capability fact
|
||||
* declares no separate repair, so its reason remains the only guidance.
|
||||
*/
|
||||
const descriptorRemediation = (descriptor: UnavailableDescriptor): string =>
|
||||
descriptor.remediation ??
|
||||
descriptor.reason ??
|
||||
"Choose another target or configured provider.";
|
||||
|
||||
const composedAvailability = (
|
||||
name: string,
|
||||
descriptors: ReadonlyMap<string, ProviderDescriptor>,
|
||||
@@ -891,7 +918,8 @@ const composedAvailabilityFor = (
|
||||
(descriptor) => descriptor?.available === true,
|
||||
),
|
||||
unavailable: observations.find(
|
||||
(descriptor) => descriptor?.available === false,
|
||||
(descriptor): descriptor is UnavailableDescriptor =>
|
||||
descriptor?.available === false,
|
||||
),
|
||||
};
|
||||
})
|
||||
@@ -922,8 +950,7 @@ const composedAvailabilityFor = (
|
||||
unavailable.availability_code === "unsupported_host"
|
||||
? "unsupported_host"
|
||||
: "provider_unavailable",
|
||||
remediation:
|
||||
unavailable.reason ?? "Choose another target or configured provider.",
|
||||
remediation: descriptorRemediation(unavailable),
|
||||
},
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -127,6 +127,62 @@ export const clientRegistrationEnvironmentDetails = (
|
||||
return validatedDetails(environment, validation, platform);
|
||||
};
|
||||
|
||||
/** Setting names a registration does not carry as the value selected here. */
|
||||
export interface RecordedProviderSettings {
|
||||
/** Selected here and absent from the registration's recorded environment. */
|
||||
readonly unrecorded: readonly string[];
|
||||
/** Recorded with text other than the value selected here. */
|
||||
readonly differing: readonly string[];
|
||||
/** Recorded as an expression, or in a map REA could not read. */
|
||||
readonly unresolved: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare settings selected in this process with a registration's own file.
|
||||
* The server's environment also depends on what its client passes on, which is
|
||||
* not observable here, so a gap is never a claim about the effective value.
|
||||
* Returns only names: recorded values stay in the file that owns them.
|
||||
*/
|
||||
export const recordedProviderSettings = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
selected: Readonly<Record<string, string>>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): RecordedProviderSettings | undefined => {
|
||||
const names = Object.keys(selected).sort(compareUnicodeCodePoints);
|
||||
if (names.length === 0) return undefined;
|
||||
const details = clientRegistrationEnvironmentDetails(
|
||||
parsed,
|
||||
{},
|
||||
"fragment",
|
||||
platform,
|
||||
);
|
||||
if (!details.ok) return { unrecorded: [], differing: [], unresolved: names };
|
||||
const recorded = snapshotEnvironment(details.value.values, platform);
|
||||
// Expression names fold like every other environment name on this platform.
|
||||
const expressions = snapshotEnvironment(
|
||||
Object.fromEntries(
|
||||
[...details.value.unresolvedSettings].map((name) => [name, ""]),
|
||||
),
|
||||
platform,
|
||||
);
|
||||
const facts = {
|
||||
unrecorded: names.filter(
|
||||
(name) => recorded[name] === undefined && expressions[name] === undefined,
|
||||
),
|
||||
differing: names.filter(
|
||||
(name) =>
|
||||
recorded[name] !== undefined && recorded[name] !== selected[name],
|
||||
),
|
||||
unresolved: names.filter((name) => expressions[name] !== undefined),
|
||||
};
|
||||
return facts.unrecorded.length +
|
||||
facts.differing.length +
|
||||
facts.unresolved.length ===
|
||||
0
|
||||
? undefined
|
||||
: facts;
|
||||
};
|
||||
|
||||
/** Resolve the async provider-specific budget through its retained reader owner. */
|
||||
export const clientRegistrationEnvironmentDetailsWithBudget = async (
|
||||
parsed: ClientConfigurationDocument | undefined,
|
||||
|
||||
@@ -34,8 +34,14 @@ import {
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { dshDocument } from "./DshCordisPatch.js";
|
||||
import { clientRegistrationEnvironmentDetailsWithBudget } from "./ClientRegistrationEnvironment.js";
|
||||
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
|
||||
import {
|
||||
clientRegistrationEnvironmentDetailsWithBudget,
|
||||
recordedProviderSettings,
|
||||
} from "./ClientRegistrationEnvironment.js";
|
||||
import type {
|
||||
ProviderRequestBudgetResolver,
|
||||
RecordedProviderSettings,
|
||||
} from "./ClientRegistrationEnvironment.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
|
||||
import {
|
||||
@@ -52,6 +58,8 @@ interface ClientRegistrationStatusBase {
|
||||
readonly package_reference?: string;
|
||||
/** Non-blocking timeout guidance; caller-selected values remain aligned. */
|
||||
readonly advisory?: string;
|
||||
/** Provider settings this process selected that the registration's file does not carry. */
|
||||
readonly provider_settings?: RecordedProviderSettings;
|
||||
}
|
||||
|
||||
type RegistrationCommand = readonly [string, ...string[]];
|
||||
@@ -99,6 +107,8 @@ interface ClientRegistrationReadOptions {
|
||||
*/
|
||||
readonly evidence?: "readiness" | "ownership";
|
||||
readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
|
||||
/** Settings to compare with each registration's file; omitted skips that read. */
|
||||
readonly providerSettings?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/** Registration facts shared by diagnostics and update maintenance. */
|
||||
@@ -362,6 +372,13 @@ export const readClientRegistrationInspections = async (
|
||||
registrationEnvironment.value.timeoutInputs,
|
||||
)
|
||||
: undefined,
|
||||
options.providerSettings === undefined
|
||||
? undefined
|
||||
: recordedProviderSettings(
|
||||
parsed,
|
||||
options.providerSettings,
|
||||
options.platform,
|
||||
),
|
||||
),
|
||||
activation,
|
||||
commandFacts.identity,
|
||||
@@ -494,6 +511,7 @@ const configuredStatus = (
|
||||
policyRemediation?: string,
|
||||
packageReference?: string,
|
||||
advisory?: string,
|
||||
providerSettings?: RecordedProviderSettings,
|
||||
): ClientRegistrationStatus =>
|
||||
state === "aligned"
|
||||
? {
|
||||
@@ -506,6 +524,9 @@ const configuredStatus = (
|
||||
state,
|
||||
remediation: null,
|
||||
...(advisory === undefined ? {} : { advisory }),
|
||||
...(providerSettings === undefined
|
||||
? {}
|
||||
: { provider_settings: providerSettings }),
|
||||
}
|
||||
: {
|
||||
client: client.name,
|
||||
@@ -516,6 +537,9 @@ const configuredStatus = (
|
||||
: { package_reference: packageReference }),
|
||||
state,
|
||||
remediation: policyRemediation ?? remediation(client),
|
||||
...(providerSettings === undefined
|
||||
? {}
|
||||
: { provider_settings: providerSettings }),
|
||||
};
|
||||
|
||||
const timeoutAdvisory = (
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
AnalysisSnapshotMismatchError,
|
||||
type PublishedEvidenceFile,
|
||||
} from "../domain/evidenceErrors.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
@@ -60,6 +59,7 @@ import {
|
||||
composedLimitationBudget,
|
||||
executeComposedWorkflow,
|
||||
createWorkflowEvidence,
|
||||
retainPartialWorkflowEvidence,
|
||||
workflowSnapshotRecord,
|
||||
recordWorkflowUnknowns,
|
||||
} from "./WorkflowEvidence.js";
|
||||
@@ -74,6 +74,7 @@ import type { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
directAnalysisCleanupFailure,
|
||||
projectDirectAnalysisFailure,
|
||||
projectInvocationFailure,
|
||||
managedAnalysisCleanupFailure,
|
||||
withSessionCleanup,
|
||||
} from "./DirectAnalysisCleanup.js";
|
||||
@@ -119,7 +120,10 @@ const ARTIFACT_ANALYSIS_OPERATION_SET: ReadonlySet<string> = new Set(
|
||||
/**
|
||||
* Open one binary, execute one tool, and always release provider resources.
|
||||
* Unlike MCP mode, every CLI invocation is intentionally isolated and does not
|
||||
* retain a target or provider client for a subsequent command.
|
||||
* retain a target, provider session or runtime setting for a subsequent
|
||||
* command. Failures are projected for that invocation lifetime, so they never
|
||||
* offer same-session reuse or runtime updates; a new invocation's launch
|
||||
* environment or arguments carry any changed limit.
|
||||
*/
|
||||
export const runDirectAnalysis = async (
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
@@ -624,6 +628,12 @@ const executeAnalysisTool = async (input: {
|
||||
new EnhancedTools(observed).execute(tool, input.arguments, signal),
|
||||
composedLimitationBudget(tool),
|
||||
);
|
||||
retainPartialWorkflowEvidence(result, {
|
||||
target: input.openedTarget,
|
||||
operation: tool,
|
||||
parameters: input.evidenceParameters,
|
||||
upstreamProfile: evidenceProfile,
|
||||
});
|
||||
result = retainAnalysisResultSources(
|
||||
tool,
|
||||
(source) => session.recordEvidence(source),
|
||||
@@ -855,6 +865,6 @@ const cliError = (
|
||||
): JsonValue => ({
|
||||
error: "Analysis failed",
|
||||
...(callerInputPath === undefined
|
||||
? projectAnalysisError(error)
|
||||
? projectInvocationFailure(error)
|
||||
: projectDirectAnalysisFailure(error, callerInputPath)),
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
childFailureDiagnostics,
|
||||
projectAnalysisError,
|
||||
} from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
analysisErrorUserMessage,
|
||||
composedCallerGuidance,
|
||||
} from "../domain/analysisErrorPresentation.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
@@ -11,12 +18,21 @@ import {
|
||||
withEvidenceCallerInputPath,
|
||||
} from "../domain/evidence.js";
|
||||
|
||||
/**
|
||||
* Project a one-shot failure for its invocation, which has released its
|
||||
* provider session and runtime settings by the time the caller reads it.
|
||||
*/
|
||||
export const projectInvocationFailure = (
|
||||
error: AnalysisError,
|
||||
): ReturnType<typeof projectAnalysisError> =>
|
||||
projectAnalysisError(error, "invocation");
|
||||
|
||||
/** Add the actual outer request path to authenticated partial Evidence. */
|
||||
export const projectDirectAnalysisFailure = (
|
||||
error: AnalysisError,
|
||||
callerInputPath: string,
|
||||
): ReturnType<typeof projectAnalysisError> => {
|
||||
const projection = projectAnalysisError(error);
|
||||
const projection = projectInvocationFailure(error);
|
||||
const observation = tryParseEvidence(projection.details?.partial_observation);
|
||||
return observation === undefined
|
||||
? projection
|
||||
@@ -63,7 +79,7 @@ export const directAnalysisCleanupFailure = (
|
||||
): JsonValue => {
|
||||
const project = (failure: AnalysisError) =>
|
||||
callerInputPath === undefined
|
||||
? projectAnalysisError(failure)
|
||||
? projectInvocationFailure(failure)
|
||||
: projectDirectAnalysisFailure(failure, callerInputPath);
|
||||
const cleanup = project(error);
|
||||
if (!result.ok) {
|
||||
@@ -114,18 +130,20 @@ export const managedAnalysisCleanupFailure = (
|
||||
: failure,
|
||||
);
|
||||
}
|
||||
const cleanup = projectAnalysisError(error);
|
||||
const failure = new ProviderAdapterError(
|
||||
error instanceof ProviderAdapterError ? error.providerId : "rea",
|
||||
"close_binary",
|
||||
{
|
||||
cause: error,
|
||||
remediationAction: cleanup.remediation.action,
|
||||
cleanup: { reason: cleanup.message, resources: error.cleanupResources },
|
||||
diagnostics: {
|
||||
cleanup_error: cleanup,
|
||||
partial_observation: jsonValueSchema.parse(result.value),
|
||||
...composedCallerGuidance({ remediation: error }),
|
||||
cleanup: {
|
||||
reason: analysisErrorUserMessage(error, "invocation"),
|
||||
resources: error.cleanupResources,
|
||||
},
|
||||
...childFailureDiagnostics(
|
||||
{ cleanup_error: { error } },
|
||||
{ partial_observation: jsonValueSchema.parse(result.value) },
|
||||
),
|
||||
},
|
||||
);
|
||||
return err(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { createServerIdentity } from "../serverIdentity.js";
|
||||
import { silentLogger } from "../logger.js";
|
||||
@@ -12,6 +11,7 @@ import { catalogIdentityForToolFamilies } from "../catalogIdentity.js";
|
||||
import { compactPresentationForMcpInputSchemaProfile } from "../config/mcpInputSchemaProfile.js";
|
||||
import {
|
||||
directAnalysisCleanupFailure,
|
||||
projectInvocationFailure,
|
||||
withSessionCleanup,
|
||||
} from "./DirectAnalysisCleanup.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
@@ -25,7 +25,7 @@ export const runSessionStatus = async (
|
||||
logger: Logger = silentLogger,
|
||||
): Promise<JsonValue> => {
|
||||
const config = dependencies.readConfiguration();
|
||||
if (!config.ok) return { error: projectAnalysisError(config.error) };
|
||||
if (!config.ok) return { error: projectInvocationFailure(config.error) };
|
||||
const session = await dependencies.createBinarySession(config.value, logger);
|
||||
return withSessionCleanup<JsonValue>(
|
||||
session,
|
||||
|
||||
+104
-8
@@ -57,6 +57,7 @@ import {
|
||||
type UnhealthyClientRegistrationStatus,
|
||||
} from "./ClientRegistrationStatus.js";
|
||||
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
|
||||
import { registrationSpelledSettings } from "./SetupRegistrationEnvironment.js";
|
||||
import {
|
||||
collectDoctorDiagnostics,
|
||||
doctorHealthy,
|
||||
@@ -146,6 +147,7 @@ export interface DoctorHost {
|
||||
close?(): Promise<string | undefined>;
|
||||
clientRegistrations?(
|
||||
selectedClientIds?: readonly string[],
|
||||
providerSettings?: Readonly<Record<string, string>>,
|
||||
): Promise<readonly ClientRegistrationStatus[]>;
|
||||
ilspyCmdVersion?(path: string): Promise<string | undefined>;
|
||||
}
|
||||
@@ -276,18 +278,26 @@ const inspectDoctor = async (
|
||||
hopperPath,
|
||||
providerInspections,
|
||||
} = await collectDoctorDiagnostics(target, host);
|
||||
const identity = await collectDoctorIdentity(host, scope);
|
||||
const hopperAvailable =
|
||||
hopperPath !== undefined &&
|
||||
diagnosticChecks
|
||||
.filter(({ name }) => name.startsWith("hopper"))
|
||||
.every(({ ok }) => ok);
|
||||
const selections = providerSettingSelections(
|
||||
providerInspections,
|
||||
hopperAvailable && hopperPath === host.configuredHopperPath
|
||||
? hopperPath
|
||||
: undefined,
|
||||
);
|
||||
const identity = await collectDoctorIdentity(host, scope, selections);
|
||||
const checks = [
|
||||
...diagnosticChecks,
|
||||
...identity.checks,
|
||||
...registrationAdvisoryChecks(identity.value.registrations),
|
||||
...registrationEnvironmentChecks(identity.value.registrations, selections),
|
||||
];
|
||||
const providers = {
|
||||
hopperAvailable:
|
||||
hopperPath !== undefined &&
|
||||
checks
|
||||
.filter(({ name }) => name.startsWith("hopper"))
|
||||
.every(({ ok }) => ok),
|
||||
hopperAvailable,
|
||||
hopperConfigured: host.configuredHopperPath !== undefined,
|
||||
providerInspections,
|
||||
};
|
||||
@@ -329,6 +339,77 @@ const registrationAdvisoryChecks = (
|
||||
],
|
||||
);
|
||||
|
||||
/** Settings an available provider's checks in this report relied on. */
|
||||
interface ProviderSettingSelection {
|
||||
readonly provider: string;
|
||||
readonly settings: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
const providerSettingSelections = (
|
||||
inspections: readonly DoctorProviderInspection[] | undefined,
|
||||
configuredHopperPath: string | undefined,
|
||||
): readonly ProviderSettingSelection[] => [
|
||||
...(inspections ?? [])
|
||||
.filter(({ available }) => available)
|
||||
.map(({ id, registrationEnvironment }) => ({
|
||||
provider: id,
|
||||
settings: registrationEnvironment,
|
||||
})),
|
||||
// Startup probes the fixed and renamed locations on its own, so only an
|
||||
// explicit path is something this terminal selected for the server.
|
||||
...(configuredHopperPath === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
provider: "hopper",
|
||||
settings: { HOPPER_LAUNCHER_PATH: configuredHopperPath },
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
/**
|
||||
* A healthy provider check describes this terminal, not the server a client
|
||||
* starts. Report what the registration's file leaves out without gating health:
|
||||
* the client may still pass the setting on, which doctor cannot observe.
|
||||
*/
|
||||
const registrationEnvironmentChecks = (
|
||||
registrations: readonly ClientRegistrationStatus[],
|
||||
selections: readonly ProviderSettingSelection[],
|
||||
): readonly DoctorCheck[] =>
|
||||
registrations.flatMap(({ client, provider_settings: settings }) => {
|
||||
if (settings === undefined) return [];
|
||||
const { unrecorded, differing, unresolved } = settings;
|
||||
const names = [...unrecorded, ...differing, ...unresolved];
|
||||
return [
|
||||
{
|
||||
name: `registration_environment:${client}`,
|
||||
ok: true,
|
||||
classification: "unknown" as const,
|
||||
detail: [
|
||||
`Provider checks used this terminal's environment; what the server ${client} starts receives is unknown.`,
|
||||
...(unrecorded.length === 0
|
||||
? []
|
||||
: [`Its registration does not record ${unrecorded.join(", ")}.`]),
|
||||
...(differing.length === 0
|
||||
? []
|
||||
: [`It records other values for ${differing.join(", ")}.`]),
|
||||
...(unresolved.length === 0
|
||||
? []
|
||||
: [`REA could not resolve its ${unresolved.join(", ")}.`]),
|
||||
].join(" "),
|
||||
details: {
|
||||
effective_environment: "unknown",
|
||||
providers: selections
|
||||
.filter(({ settings: selected }) =>
|
||||
names.some((name) => selected[name] !== undefined),
|
||||
)
|
||||
.map(({ provider }) => provider),
|
||||
},
|
||||
remediation: `Preview rea setup --client ${client} --skill=false --dry-run from this terminal, apply it to record these settings and restart the client, or read binary_session result.tool_availability in the client.`,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const closeDoctorHost = async (
|
||||
host: DoctorHost,
|
||||
): Promise<string | AnalysisError | undefined> => {
|
||||
@@ -369,12 +450,19 @@ const doctorReportWithCleanup = (
|
||||
const collectDoctorIdentity = async (
|
||||
host: DoctorHost,
|
||||
scope: DoctorScopeReport,
|
||||
selections: readonly ProviderSettingSelection[],
|
||||
): Promise<{
|
||||
readonly checks: readonly DoctorCheck[];
|
||||
readonly value: DoctorIdentity;
|
||||
}> => {
|
||||
const installationPaths = (await host.installationPaths?.()) ?? [];
|
||||
const registrations = (await host.clientRegistrations?.(scope.clients)) ?? [];
|
||||
const registrations =
|
||||
(await host.clientRegistrations?.(
|
||||
scope.clients,
|
||||
Object.fromEntries(
|
||||
selections.flatMap(({ settings }) => Object.entries(settings)),
|
||||
),
|
||||
)) ?? [];
|
||||
const skillClientIds =
|
||||
scope.skill_clients ??
|
||||
(scope.clients.length > 0 ? scope.clients : undefined);
|
||||
@@ -786,12 +874,20 @@ export const systemDoctorHost = (
|
||||
throw failure;
|
||||
}
|
||||
},
|
||||
clientRegistrations: (selectedClientIds) =>
|
||||
clientRegistrations: (selectedClientIds, providerSettings) =>
|
||||
readClientRegistrationStatuses(homeDirectory, undefined, {
|
||||
platform,
|
||||
environment,
|
||||
resources,
|
||||
...(selectedClientIds === undefined ? {} : { selectedClientIds }),
|
||||
...(providerSettings === undefined
|
||||
? {}
|
||||
: {
|
||||
providerSettings: registrationSpelledSettings(
|
||||
providerSettings,
|
||||
environment,
|
||||
),
|
||||
}),
|
||||
...(resolveProviderRequestBudget === undefined
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
|
||||
import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js";
|
||||
|
||||
const inspection = (
|
||||
id: string,
|
||||
registrationEnvironment: Readonly<Record<string, string>>,
|
||||
available = true,
|
||||
) => ({
|
||||
id,
|
||||
configured: true,
|
||||
available,
|
||||
providerVersion: null,
|
||||
registrationEnvironment,
|
||||
checks: [],
|
||||
});
|
||||
const recordingHost = (overrides: Partial<DoctorHost>) => {
|
||||
const requested: Readonly<Record<string, string>>[] = [];
|
||||
return {
|
||||
requested,
|
||||
host: host({
|
||||
clientRegistrations: (_clients, providerSettings) => {
|
||||
requested.push(providerSettings ?? {});
|
||||
return Promise.resolve([
|
||||
{
|
||||
client: "cursor",
|
||||
config_path: "/home/user/.cursor/mcp.json",
|
||||
command: ["/current/rea", "mcp"],
|
||||
state: "aligned",
|
||||
remediation: null,
|
||||
provider_settings: {
|
||||
unrecorded: ["GHIDRA_INSTALL_DIR"],
|
||||
differing: [],
|
||||
unresolved: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
};
|
||||
const selectedProviders = {
|
||||
providerInspections: () =>
|
||||
Promise.resolve([
|
||||
inspection("ghidra", { GHIDRA_INSTALL_DIR: "/opt/ghidra" }),
|
||||
inspection("jadx", { REA_JADX_MCP_JAR: "/opt/jadx.jar" }),
|
||||
inspection("kuna", { REA_KUNA_COMMAND: "/opt/kuna" }, false),
|
||||
]),
|
||||
};
|
||||
const CHECK = "registration_environment:cursor";
|
||||
|
||||
it("compares only settings an available provider relied on", async () => {
|
||||
const { host: selectedHost, requested } = recordingHost(selectedProviders);
|
||||
await runDoctor(undefined, selectedHost);
|
||||
expect(requested).toEqual([
|
||||
{
|
||||
GHIDRA_INSTALL_DIR: "/opt/ghidra",
|
||||
REA_JADX_MCP_JAR: "/opt/jadx.jar",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ providers: ["ghidra"] }, true],
|
||||
[{ clients: ["cursor"] }, true],
|
||||
[{ clients: ["codex"] }, false],
|
||||
[{ providers: ["jadx"] }, false],
|
||||
[{ skill: true }, false],
|
||||
])("follows the selected scope %j", async (scope, selected) => {
|
||||
const report = await runDoctor(
|
||||
undefined,
|
||||
recordingHost(selectedProviders).host,
|
||||
scope,
|
||||
);
|
||||
const inScope = report.scope_checks.some(({ name }) => name === CHECK);
|
||||
expect(inScope).toBe(selected);
|
||||
expect(report.informational_checks.some(({ name }) => name === CHECK)).toBe(
|
||||
!selected,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not gate health or name unaffected providers", async () => {
|
||||
const report = await runDoctor(
|
||||
undefined,
|
||||
recordingHost(selectedProviders).host,
|
||||
{ providers: ["ghidra"] },
|
||||
);
|
||||
expect(report.healthy).toBe(true);
|
||||
expect(report.checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: CHECK,
|
||||
ok: true,
|
||||
classification: "unknown",
|
||||
details: { effective_environment: "unknown", providers: ["ghidra"] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/custom/Hopper", { HOPPER_LAUNCHER_PATH: "/custom/Hopper" }],
|
||||
// A default location is found again without this terminal's environment.
|
||||
[undefined, {}],
|
||||
])(
|
||||
"includes Hopper only for an explicit available path (%s)",
|
||||
async (configuredHopperPath, expected) => {
|
||||
const { host: selectedHost, requested } = recordingHost(
|
||||
configuredHopperPath === undefined ? {} : { configuredHopperPath },
|
||||
);
|
||||
await runDoctor(undefined, selectedHost);
|
||||
expect(requested).toEqual([expected]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not include an explicit Hopper path that fails its checks", async () => {
|
||||
const { host: selectedHost, requested } = recordingHost({
|
||||
configuredHopperPath: "/custom/missing",
|
||||
executable: () => Promise.resolve(false),
|
||||
});
|
||||
await runDoctor(undefined, selectedHost);
|
||||
expect(requested).toEqual([{}]);
|
||||
});
|
||||
|
||||
it("compares a caller selection as setup spells it", async () => {
|
||||
const home = await createTestTempDirectory("rea-doctor-spelling-");
|
||||
await writeFile(
|
||||
join(home, ".claude.json"),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
rea: {
|
||||
command: "/current/rea",
|
||||
args: ["mcp"],
|
||||
env: {
|
||||
GHIDRA_INSTALL_DIR: "/opt/ghidra",
|
||||
JAVA_HOME: "/selected/alias",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const system = systemDoctorHost({
|
||||
environment: { HOME: home, JAVA_HOME: "/selected/alias" },
|
||||
});
|
||||
const statuses = await system.clientRegistrations?.(["claude_code"], {
|
||||
GHIDRA_INSTALL_DIR: "/opt/ghidra",
|
||||
// The provider's observed home; setup records the caller's spelling.
|
||||
JAVA_HOME: "/observed/jdk",
|
||||
});
|
||||
await system.close?.();
|
||||
expect(statuses?.[0]?.client).toBe("claude_code");
|
||||
expect(statuses?.[0]).not.toHaveProperty("provider_settings");
|
||||
});
|
||||
|
||||
it("states each comparison result without a claim it did not establish", async () => {
|
||||
const report = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
clientRegistrations: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
client: "cursor",
|
||||
config_path: "/cursor.json",
|
||||
command: ["/current/rea", "mcp"],
|
||||
state: "aligned",
|
||||
remediation: null,
|
||||
provider_settings: {
|
||||
unrecorded: [],
|
||||
differing: ["JAVA_HOME"],
|
||||
unresolved: ["GHIDRA_INSTALL_DIR"],
|
||||
},
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
const detail = report.checks.find(({ name }) => name === CHECK)?.detail;
|
||||
expect(detail).toContain("what the server cursor starts receives is unknown");
|
||||
expect(detail).toContain("records other values for JAVA_HOME");
|
||||
expect(detail).toContain("could not resolve its GHIDRA_INSTALL_DIR");
|
||||
expect(detail).not.toContain("does not record");
|
||||
});
|
||||
@@ -72,6 +72,9 @@ export const scopeDoctorChecks = (
|
||||
input.registrations.find((candidate) => candidate.client === client),
|
||||
),
|
||||
);
|
||||
const environmentChecks = input.checks.filter((check) =>
|
||||
registrationEnvironmentInScope(check, input.scope),
|
||||
);
|
||||
const skillChecks = input.scope.skill
|
||||
? [selectedSkillCheck(input.skillState, input.checks)]
|
||||
: [];
|
||||
@@ -111,6 +114,7 @@ export const scopeDoctorChecks = (
|
||||
...hopperHostChecks,
|
||||
...selectedProviderChecks,
|
||||
...clientChecks,
|
||||
...environmentChecks,
|
||||
...skillChecks,
|
||||
];
|
||||
const selectedNames = new Set(scopeChecks.map(({ name }) => name));
|
||||
@@ -129,6 +133,22 @@ export const scopeDoctorChecks = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Follow the caller's question: a named client, or a provider it relies on. */
|
||||
const registrationEnvironmentInScope = (
|
||||
{ name, details }: DoctorCheck,
|
||||
scope: DoctorScopeReport,
|
||||
): boolean => {
|
||||
const [family, client] = name.split(":");
|
||||
if (family !== "registration_environment" || client === undefined)
|
||||
return false;
|
||||
const providers = details?.providers;
|
||||
return (
|
||||
scope.clients.includes(client) ||
|
||||
(Array.isArray(providers) &&
|
||||
scope.providers.some((provider) => providers.includes(provider)))
|
||||
);
|
||||
};
|
||||
|
||||
const isProviderCheck = (
|
||||
name: string,
|
||||
inspections: readonly DoctorProviderInspection[] | undefined,
|
||||
|
||||
Loaded 100 of 468 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user