mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b9b461924 | ||
|
|
7d5a459987 | ||
|
|
85013da0ce | ||
|
|
cbc69537cd | ||
|
|
eb39a07966 | ||
|
|
4b840aa1fb | ||
|
|
423059a4de | ||
|
|
44b6384009 | ||
|
|
fb566c800c | ||
|
|
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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -76,12 +76,14 @@
|
||||
{
|
||||
"files": ["src/**/*.ts"],
|
||||
"rules": {
|
||||
"rea/no-bulk-append-spread": "error",
|
||||
"typescript/no-misused-promises": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["tests/**/*.ts", "src/**/*.test.ts", "src/**/*.fixture.ts"],
|
||||
"rules": {
|
||||
"rea/no-bulk-append-spread": "off",
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": [
|
||||
"error",
|
||||
|
||||
@@ -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.988/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.988/NOTICE) | Bring your own `kuna`; REA verified 1.988 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) {}
|
||||
|
||||
@@ -561,6 +561,14 @@ 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`.
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-5
@@ -689,12 +689,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
|
||||
@@ -1314,7 +1321,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
|
||||
|
||||
@@ -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.988, 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.988 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
|
||||
|
||||
+72
-6
@@ -318,6 +318,8 @@ 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.
|
||||
|
||||
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 +333,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.
|
||||
@@ -369,6 +390,7 @@ fit.
|
||||
|
||||
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.
|
||||
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 +594,32 @@ 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.
|
||||
|
||||
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 +790,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 +863,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,
|
||||
@@ -1351,7 +1415,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.
|
||||
|
||||
|
||||
@@ -172,6 +172,15 @@ operation names, schemas or types; display-only selection remains allowed.
|
||||
`rea/no-fixture-root-cleanup` follows `const` fixture-root aliases into teardown
|
||||
deletion; mutable bindings and mixed root arrays remain outside its scope.
|
||||
|
||||
`rea/no-bulk-append-spread` rejects spread arguments to `push`, `unshift` and
|
||||
`splice` in production sources. Each element becomes a call argument, and V8
|
||||
bounds argument count by stack size (about 125,000 on a default main-thread
|
||||
stack), so a data-sized collection throws `RangeError`. Use `appendAll` from
|
||||
`src/domain/arrayAppend.ts`, or name the structural bound in a disable comment.
|
||||
The rule does not check receiver types or other variadic calls such as
|
||||
`Math.max` and `String.fromCharCode`. Reproduce such limits in a forked test
|
||||
project: worker threads default to a larger stack.
|
||||
|
||||
`rea/no-shared-module-mock` resolves Vitest import bindings before rejecting
|
||||
module mocks in projects without per-file isolation. The test-project definitions
|
||||
own isolation for Vitest and lint; the executable gate checks the resolved policy
|
||||
@@ -1576,6 +1585,11 @@ symbols and stripped on macOS arm64, including the Ghidra comparison and owned
|
||||
cleanup. Linux and other hosts have not been verified with 1.986; this check is
|
||||
not a repeat of the larger 1.833 matrix.
|
||||
|
||||
Kuna 1.988 passed the real-provider lane on macOS arm64 with the generated
|
||||
Mach-O fixture and caller-built symbol-bearing and stripped x86-64 ELF fixtures.
|
||||
Linux and other hosts have not been verified with 1.988; this is not a repeat
|
||||
of the larger 1.833 matrix.
|
||||
|
||||
## DOS Ghidra analysis
|
||||
|
||||
`npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import noBufferDeepEquality from "./no-buffer-deep-equality.mjs";
|
||||
import cancellationCause from "./cancellation-cause.mjs";
|
||||
|
||||
import noBulkAppendSpread from "./rules/no-bulk-append-spread.mjs";
|
||||
import noEvidenceJsonPreadmission from "./rules/no-evidence-json-preadmission.mjs";
|
||||
import noFixtureRootCleanup from "./rules/no-fixture-root-cleanup.mjs";
|
||||
import noPositionalToolContract from "./rules/no-positional-tool-contract.mjs";
|
||||
@@ -11,6 +12,7 @@ export default {
|
||||
rules: {
|
||||
"no-buffer-deep-equality": noBufferDeepEquality,
|
||||
"cancellation-cause": cancellationCause,
|
||||
"no-bulk-append-spread": noBulkAppendSpread,
|
||||
"no-evidence-json-preadmission": noEvidenceJsonPreadmission,
|
||||
"no-fixture-root-cleanup": noFixtureRootCleanup,
|
||||
"no-positional-tool-contract": noPositionalToolContract,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { RuleTester } from "oxlint/plugins-dev";
|
||||
declare const rule: Parameters<RuleTester["run"]>[1];
|
||||
export default rule;
|
||||
@@ -0,0 +1,57 @@
|
||||
const bulkMethods = new Set(["push", "unshift", "splice"]);
|
||||
|
||||
/** Syntax that leaves the evaluated callee or receiver unchanged. */
|
||||
const transparent = new Set([
|
||||
"ChainExpression",
|
||||
"ParenthesizedExpression",
|
||||
"TSAsExpression",
|
||||
"TSNonNullExpression",
|
||||
"TSSatisfiesExpression",
|
||||
"TSTypeAssertion",
|
||||
"TSInstantiationExpression",
|
||||
]);
|
||||
|
||||
const unwrap = (node) => {
|
||||
while (node && transparent.has(node.type)) node = node.expression;
|
||||
return node;
|
||||
};
|
||||
|
||||
const propertyName = (member) => {
|
||||
if (!member.computed && member.property.type === "Identifier")
|
||||
return member.property.name;
|
||||
if (member.computed && member.property.type === "Literal")
|
||||
return member.property.value;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Append collections element-wise instead of spreading them into call arguments.",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
spread:
|
||||
"Spreading into {{method}}() makes each element a call argument, and engines bound argument count by stack size, so a data-sized collection throws RangeError. Use appendAll from src/domain/arrayAppend.ts, or document the structural bound in a disable comment.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const callee = unwrap(node.callee);
|
||||
if (callee?.type !== "MemberExpression") return;
|
||||
const method = propertyName(callee);
|
||||
if (!bulkMethods.has(method)) return;
|
||||
for (const argument of node.arguments)
|
||||
if (argument.type === "SpreadElement")
|
||||
context.report({
|
||||
node: argument,
|
||||
messageId: "spread",
|
||||
data: { method },
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
+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: {},
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
jadxXrefsSchema,
|
||||
normalizeJadxText,
|
||||
} from "./JadxProtocol.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
/** The adapter consumes tool payloads, never provider RPC details outside this layer. */
|
||||
export interface JadxToolPort {
|
||||
@@ -336,7 +337,7 @@ const searchClasses = async (
|
||||
request.operation,
|
||||
"JADX class pagination is incomplete",
|
||||
);
|
||||
matches.push(...page.items);
|
||||
appendAll(matches, page.items);
|
||||
offset = page.next_offset;
|
||||
} while (offset < loaded.class_count);
|
||||
const occurrences = new Map<string, number>();
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { androidSnapshotCopy } from "./AndroidTargetSnapshot.js";
|
||||
import type { JadxSession } from "./JadxSession.js";
|
||||
import type { JadxMcpTransport } from "./JadxMcpTransport.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
interface FailureContext {
|
||||
readonly cause: unknown;
|
||||
@@ -280,7 +281,7 @@ const snapshotCloseFailures = (
|
||||
cause.outcome.kind === "failed" &&
|
||||
cause.outcome.cause instanceof RegularFileCleanupFailure
|
||||
)
|
||||
failures.push(...snapshotCloseFailures(cause.outcome.cause));
|
||||
appendAll(failures, snapshotCloseFailures(cause.outcome.cause));
|
||||
return failures;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
parseApktoolYml,
|
||||
parseManifestPackage,
|
||||
} from "./ApktoolDecodeOutput.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
const DECODE_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
||||
const MANIFEST_MAX_BYTES = 512 * 1024;
|
||||
@@ -656,7 +657,7 @@ export class ApktoolProvider {
|
||||
const projectedStrings = projection.ok
|
||||
? projection.value
|
||||
: projection.error.partial;
|
||||
limitations.push(...projectedStrings.limitations);
|
||||
appendAll(limitations, projectedStrings.limitations);
|
||||
const coverage =
|
||||
projectedStrings.coverage === "partial" ||
|
||||
manifestRead.truncated ||
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,62 @@ 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 };
|
||||
|
||||
/** 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 {
|
||||
// A provider that cannot confirm release of resources it owns has not
|
||||
// finished this read; dispatching more reads would build on that state.
|
||||
status:
|
||||
execution.error._tag === "AnalysisCancelledError" ||
|
||||
execution.error.cleanupIncomplete
|
||||
? "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.`;
|
||||
@@ -184,6 +184,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 +206,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 +229,7 @@ describe("capability failure reasons", () => {
|
||||
status: "unavailable",
|
||||
code: "runtime_unavailable",
|
||||
reason: "The JADX native self-test failed.",
|
||||
remediation: "Repair the fixture provider.",
|
||||
diagnostics: {},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,6 +61,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 = [
|
||||
@@ -575,7 +576,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 +610,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 +702,7 @@ const ownerProbeAvailability = (
|
||||
: observation.code === "not_configured"
|
||||
? "provider_missing"
|
||||
: "provider_unavailable",
|
||||
remediation: observation.reason,
|
||||
remediation: observation.remediation,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -765,7 +766,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;
|
||||
};
|
||||
|
||||
@@ -791,7 +795,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 +847,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 +906,8 @@ const composedAvailabilityFor = (
|
||||
(descriptor) => descriptor?.available === true,
|
||||
),
|
||||
unavailable: observations.find(
|
||||
(descriptor) => descriptor?.available === false,
|
||||
(descriptor): descriptor is UnavailableDescriptor =>
|
||||
descriptor?.available === false,
|
||||
),
|
||||
};
|
||||
})
|
||||
@@ -922,8 +938,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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type RegularFileOperationOutcome,
|
||||
} from "../filesystem/OwnedRegularFile.js";
|
||||
import { retryRegularFileCleanupFailures } from "./RegularFileRead.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
/** Completion and exact staging owner retained when publication cleanup fails. */
|
||||
export class ClientConfigurationPublicationFailure<
|
||||
@@ -72,7 +73,7 @@ export const retryClientConfigurationCleanup = async (
|
||||
let nested: unknown;
|
||||
if (current instanceof RegularFileCleanupFailure) {
|
||||
const retried = await retryRegularFileCleanupFailures(current, resources);
|
||||
failures.push(...retried.failures);
|
||||
appendAll(failures, retried.failures);
|
||||
for (const lease of retried.cleanupLeases) cleanupLeases.add(lease);
|
||||
if (retried.cleanup !== undefined) observations.push(retried.cleanup);
|
||||
primary = retried.primary;
|
||||
|
||||
@@ -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";
|
||||
@@ -74,6 +73,7 @@ import type { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
directAnalysisCleanupFailure,
|
||||
projectDirectAnalysisFailure,
|
||||
projectInvocationFailure,
|
||||
managedAnalysisCleanupFailure,
|
||||
withSessionCleanup,
|
||||
} from "./DirectAnalysisCleanup.js";
|
||||
@@ -119,7 +119,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,
|
||||
@@ -855,6 +858,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,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
DoctorHost,
|
||||
DoctorProviderInspection,
|
||||
} from "./Doctor.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
/** Ordered environment diagnostics consumed by the Doctor report assembler. */
|
||||
export interface DoctorDiagnostics {
|
||||
@@ -30,13 +31,13 @@ export const collectDoctorDiagnostics = async (
|
||||
checks.push(hopperCheck(host, hopperPath));
|
||||
const hopperTimeoutCheck = optionalHopperStartupTimeoutCheck(host);
|
||||
if (hopperTimeoutCheck !== undefined) checks.push(hopperTimeoutCheck);
|
||||
checks.push(...runtimeConfigurationChecks(host));
|
||||
appendAll(checks, runtimeConfigurationChecks(host));
|
||||
const hopperVersionCheck = await optionalHopperVersionCheck(host, hopperPath);
|
||||
if (hopperVersionCheck !== undefined) checks.push(hopperVersionCheck);
|
||||
|
||||
const providerInspections = await host.providerInspections?.();
|
||||
if (providerInspections !== undefined)
|
||||
checks.push(...providerInspections.flatMap(providerDoctorChecks));
|
||||
appendAll(checks, providerInspections.flatMap(providerDoctorChecks));
|
||||
|
||||
const ilspyCheck = await optionalIlspyCheck(host);
|
||||
if (ilspyCheck !== undefined) checks.push(ilspyCheck);
|
||||
|
||||
@@ -10,7 +10,23 @@ import {
|
||||
type EnhancedToolName,
|
||||
} from "../contracts/enhancedInputs.js";
|
||||
|
||||
export type EnhancedResult = Promise<Result<JsonValue, AnalysisError>>;
|
||||
/** A failure that stopped a workflow after it admitted part of its result. */
|
||||
export interface PartialEnhancedFailure {
|
||||
readonly ok: false;
|
||||
readonly error: AnalysisError;
|
||||
/** The workflow's own result shape, holding only work admitted before the stop. */
|
||||
readonly partialResult: JsonValue;
|
||||
}
|
||||
export type EnhancedOutcome =
|
||||
| Result<JsonValue, AnalysisError>
|
||||
| PartialEnhancedFailure;
|
||||
export type EnhancedResult = Promise<EnhancedOutcome>;
|
||||
|
||||
/** Keep the stopping failure primary while retaining the admitted result. */
|
||||
export const failedWithPartialResult = (
|
||||
error: AnalysisError,
|
||||
partialResult: JsonValue,
|
||||
): PartialEnhancedFailure => ({ ok: false, error, partialResult });
|
||||
export type TraceReference = {
|
||||
readonly target_address: string;
|
||||
readonly source_address: string;
|
||||
|
||||
@@ -2,7 +2,10 @@ import { permitsCapabilityFallback } from "./CapabilityFallback.js";
|
||||
import { withOverviewInventoryCoverage } from "../domain/native/inventoryCoverage.js";
|
||||
import type { NativeCallGraph } from "../domain/native/nativeCallGraph.js";
|
||||
import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js";
|
||||
import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js";
|
||||
import {
|
||||
cleanupStoppedTraversal,
|
||||
readCallRelationships,
|
||||
} from "./CallRelationshipTraversal.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
@@ -28,7 +31,6 @@ import {
|
||||
parseNativeInventoryCount,
|
||||
addressedValueSchema,
|
||||
} from "../domain/native/nativeInventory.js";
|
||||
import { parseRelatedAddresses } from "../domain/native/nativeObservation.js";
|
||||
import {
|
||||
parseSegments,
|
||||
projectBinaryOverview,
|
||||
@@ -59,6 +61,7 @@ import {
|
||||
} from "../domain/apple/machoTargetSelection.js";
|
||||
|
||||
import {
|
||||
failedWithPartialResult,
|
||||
invalidEnhancedInput,
|
||||
type EnhancedResult,
|
||||
type ValidatedEnhancedCall,
|
||||
@@ -615,8 +618,6 @@ export class EnhancedTools {
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
const { relation, operation: tool } =
|
||||
CALL_RELATIONSHIP_TRAVERSALS[input.direction];
|
||||
const limitations = new Set<string>();
|
||||
const resolved = await this.#observe(
|
||||
"procedure_address",
|
||||
@@ -635,40 +636,41 @@ export class EnhancedTools {
|
||||
];
|
||||
let queueIndex = 0;
|
||||
const graph: Record<string, JsonValue[]> = {};
|
||||
const sortedLimitations = () =>
|
||||
[...limitations].sort(compareUnicodeCodePoints);
|
||||
|
||||
while (queueIndex < queue.length) {
|
||||
const current = queue[queueIndex++];
|
||||
if (current === undefined) continue;
|
||||
const level = String(current.depth);
|
||||
graph[level] ??= [];
|
||||
|
||||
const result = await this.#observe(
|
||||
tool,
|
||||
{ procedure: current.address },
|
||||
const read = await readCallRelationships(
|
||||
this.#observe.bind(this),
|
||||
input.direction,
|
||||
current.address,
|
||||
signal,
|
||||
"provider",
|
||||
);
|
||||
if (!result.ok) {
|
||||
if (isAnalysisCancellation(result.error)) return result;
|
||||
graph[level].push({
|
||||
if (read.status === "stopped") {
|
||||
if (!read.error.cleanupIncomplete) return err(read.error);
|
||||
limitations.add(
|
||||
cleanupStoppedTraversal(current.address, queue.length - queueIndex),
|
||||
);
|
||||
return failedWithPartialResult(read.error, {
|
||||
graph,
|
||||
limitations: sortedLimitations(),
|
||||
});
|
||||
}
|
||||
const nodes = (graph[String(current.depth)] ??= []);
|
||||
if (read.status !== "observed") {
|
||||
nodes.push({
|
||||
address: current.address,
|
||||
status: "error",
|
||||
error: projectAnalysisError(result.error),
|
||||
error: projectAnalysisError(read.error),
|
||||
} satisfies NativeCallGraph["graph"][string][number]);
|
||||
continue;
|
||||
}
|
||||
const related = parseRelatedAddresses(result.value.result, relation);
|
||||
if (!related.ok) {
|
||||
graph[level].push({
|
||||
address: current.address,
|
||||
status: "error",
|
||||
error: projectAnalysisError(related.error),
|
||||
} satisfies NativeCallGraph["graph"][string][number]);
|
||||
continue;
|
||||
}
|
||||
for (const limitation of result.value.limitations)
|
||||
const { execution, related } = read;
|
||||
for (const limitation of execution.limitations)
|
||||
limitations.add(limitation);
|
||||
const reportedCoverage = result.value.relationshipCoverage;
|
||||
const reportedCoverage = execution.relationshipCoverage;
|
||||
const coverage = reportedCoverage ?? {
|
||||
status: "unknown" as const,
|
||||
reason: "Provider did not report relationship coverage.",
|
||||
@@ -676,7 +678,7 @@ export class EnhancedTools {
|
||||
const relationshipLimitations =
|
||||
reportedCoverage === undefined
|
||||
? [
|
||||
...result.value.limitations,
|
||||
...execution.limitations,
|
||||
"Provider did not report relationship coverage.",
|
||||
]
|
||||
: [
|
||||
@@ -692,26 +694,23 @@ export class EnhancedTools {
|
||||
const nodeLimitations = [...new Set(relationshipLimitations)].sort(
|
||||
compareUnicodeCodePoints,
|
||||
);
|
||||
graph[level].push({
|
||||
nodes.push({
|
||||
address: current.address,
|
||||
status: "ok",
|
||||
calls: [...related.value],
|
||||
calls: [...related],
|
||||
relationship_coverage: coverage,
|
||||
...(nodeLimitations.length === 0
|
||||
? {}
|
||||
: { limitations: nodeLimitations }),
|
||||
} satisfies NativeCallGraph["graph"][string][number]);
|
||||
for (const address of related.value) {
|
||||
for (const address of related) {
|
||||
if (!discovered.has(address)) {
|
||||
discovered.add(address);
|
||||
queue.push({ address, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok({
|
||||
graph,
|
||||
limitations: [...limitations].sort(compareUnicodeCodePoints),
|
||||
});
|
||||
return ok({ graph, limitations: sortedLimitations() });
|
||||
}
|
||||
|
||||
async #analyzeSwiftTypes(
|
||||
|
||||
@@ -292,8 +292,10 @@ const projectWrite = (
|
||||
export const inspectEvidenceBundleCommand = async (
|
||||
input: {
|
||||
readonly path: string;
|
||||
readonly detail: "complete" | "summary";
|
||||
readonly detail: "complete" | "summary" | "records";
|
||||
readonly filters?: EvidenceRecordFilters | undefined;
|
||||
readonly offset?: number | undefined;
|
||||
readonly limit?: number | undefined;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
|
||||
@@ -1,14 +1,51 @@
|
||||
/** Download result; HTTP failures may carry their observed status for diagnostics. */
|
||||
export interface HopperDownload {
|
||||
readonly ok: boolean;
|
||||
readonly bytes: Uint8Array;
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Why a URL was refused before any vendor response could be admitted. */
|
||||
export type HopperUrlRefusal =
|
||||
| "invalid_url"
|
||||
| "non_https"
|
||||
| "credentials"
|
||||
| "redirect_without_location"
|
||||
| "redirect_limit";
|
||||
|
||||
/**
|
||||
* Transport facts for one Hopper request that yielded no admitted bytes.
|
||||
* `url` is the caller-selected request with any URL userinfo removed, never a
|
||||
* redirect target. URLs carrying credentials are refused before any request,
|
||||
* so no transport detail can contain them.
|
||||
*/
|
||||
export type HopperDownloadFailure = { readonly url: string } & (
|
||||
| {
|
||||
readonly kind: "http_status";
|
||||
/** Absent when the response did not carry a valid HTTP failure status. */
|
||||
readonly httpStatus?: number;
|
||||
}
|
||||
| { readonly kind: "timeout"; readonly timeoutMs: number }
|
||||
| { readonly kind: "network"; readonly detail: string }
|
||||
| { readonly kind: "response_too_large"; readonly maxBytes: number }
|
||||
| { readonly kind: "url_refused"; readonly refusal: HopperUrlRefusal }
|
||||
);
|
||||
|
||||
/** Admitted response bytes, or the observed reason none were admitted. */
|
||||
export type HopperDownload = Result<Uint8Array, HopperDownloadFailure>;
|
||||
|
||||
/** The vendor request a Hopper workflow made; identifies the failed stage. */
|
||||
export type HopperVendorRequest = "release_feed" | "package" | "public_key";
|
||||
|
||||
/** A failed download attributed to the workflow stage that requested it. */
|
||||
export type HopperRequestFailure = HopperDownloadFailure & {
|
||||
readonly request: HopperVendorRequest;
|
||||
};
|
||||
|
||||
/** Maximum admitted vendor response bytes, shared by Hopper setup adapters. */
|
||||
export const HOPPER_DOWNLOAD_BYTES = 100_000_000;
|
||||
|
||||
/** Bound observed response bytes before retaining them, including chunked bodies. */
|
||||
/**
|
||||
* Bound observed response bytes before retaining them, including chunked bodies.
|
||||
* Expected transport outcomes are returned; caller cancellation still rejects
|
||||
* with the caller's abort reason so cancellation stays distinct from failure.
|
||||
*/
|
||||
export const downloadHopperPackage = async (
|
||||
url: string,
|
||||
options: {
|
||||
@@ -20,7 +57,6 @@ export const downloadHopperPackage = async (
|
||||
const timeoutMs = options.timeoutMs ?? 120_000;
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000)
|
||||
throw new RangeError("Invalid Hopper response time budget");
|
||||
const deadline = AbortSignal.timeout(timeoutMs);
|
||||
const maxBytes = options.maxBytes ?? HOPPER_DOWNLOAD_BYTES;
|
||||
if (
|
||||
!Number.isSafeInteger(maxBytes) ||
|
||||
@@ -28,37 +64,69 @@ export const downloadHopperPackage = async (
|
||||
maxBytes > HOPPER_DOWNLOAD_BYTES
|
||||
)
|
||||
throw new RangeError("Invalid Hopper response byte budget");
|
||||
const response = await fetchHopperResponse(
|
||||
url,
|
||||
options.signal === undefined
|
||||
? deadline
|
||||
: AbortSignal.any([options.signal, deadline]),
|
||||
);
|
||||
const refused = { ok: false, bytes: new Uint8Array() };
|
||||
if (response === undefined) return refused;
|
||||
const deadline = AbortSignal.timeout(timeoutMs);
|
||||
// Remove transport credentials once, where every failure is created.
|
||||
const reported = sanitizeBrowserUrl(url).url;
|
||||
try {
|
||||
return await readHopperResponse(
|
||||
url,
|
||||
reported,
|
||||
options.signal === undefined
|
||||
? deadline
|
||||
: AbortSignal.any([options.signal, deadline]),
|
||||
maxBytes,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (options.signal?.aborted === true) throw cause;
|
||||
if (deadline.aborted)
|
||||
return err({ url: reported, kind: "timeout", timeoutMs });
|
||||
// Fetch reports network errors (DNS, TLS, reset, truncated body) as TypeError.
|
||||
if (cause instanceof TypeError)
|
||||
return err({
|
||||
url: reported,
|
||||
kind: "network",
|
||||
detail: networkDetail(cause),
|
||||
});
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const readHopperResponse = async (
|
||||
requested: string,
|
||||
url: string,
|
||||
signal: AbortSignal,
|
||||
maxBytes: number,
|
||||
): Promise<HopperDownload> => {
|
||||
const fetched = await fetchHopperResponse(requested, signal);
|
||||
if (!fetched.ok)
|
||||
return err({ url, kind: "url_refused", refusal: fetched.error });
|
||||
const response = fetched.value;
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
return Number.isInteger(response.status) &&
|
||||
response.status >= 300 &&
|
||||
response.status <= 599
|
||||
? { ...refused, httpStatus: response.status }
|
||||
: refused;
|
||||
await releaseBody(response.body);
|
||||
return err(
|
||||
Number.isInteger(response.status) &&
|
||||
response.status >= 300 &&
|
||||
response.status <= 599
|
||||
? { url, kind: "http_status", httpStatus: response.status }
|
||||
: { url, kind: "http_status" },
|
||||
);
|
||||
}
|
||||
const tooLarge = { url, kind: "response_too_large", maxBytes } as const;
|
||||
if (Number(response.headers.get("content-length")) > maxBytes) {
|
||||
await response.body?.cancel();
|
||||
return refused;
|
||||
await releaseBody(response.body);
|
||||
return err(tooLarge);
|
||||
}
|
||||
if (response.body === null) return { ok: true, bytes: new Uint8Array() };
|
||||
if (response.body === null) return ok(new Uint8Array());
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) return { ok: true, bytes: Buffer.concat(chunks, bytes) };
|
||||
if (chunk.done) return ok(Buffer.concat(chunks, bytes));
|
||||
if (chunk.value.byteLength > maxBytes - bytes) {
|
||||
await reader.cancel();
|
||||
return refused;
|
||||
await releaseBody(reader);
|
||||
return err(tooLarge);
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
bytes += chunk.value.byteLength;
|
||||
@@ -68,6 +136,29 @@ export const downloadHopperPackage = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Release a body REA will not admit. A stream that already failed (for example
|
||||
* a truncated error body) has nothing left to release, and its error must not
|
||||
* replace the response outcome that was already observed.
|
||||
*/
|
||||
const releaseBody = async (
|
||||
body: { cancel(): Promise<void> } | null,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await body?.cancel();
|
||||
} catch (cause: unknown) {
|
||||
// best-effort cleanup: an errored stream is already released.
|
||||
void cause;
|
||||
}
|
||||
};
|
||||
|
||||
const networkDetail = (cause: TypeError): string => {
|
||||
const underlying = cause.cause instanceof Error ? cause.cause.message : "";
|
||||
return underlying === "" || underlying === cause.message
|
||||
? cause.message
|
||||
: `${cause.message}: ${underlying}`;
|
||||
};
|
||||
|
||||
// HTTP-redirect fetch admits at most 20 redirects; retain that existing Fetch
|
||||
// bound while checking each destination before the next request.
|
||||
const FETCH_REDIRECT_LIMIT = 20;
|
||||
@@ -76,31 +167,34 @@ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
const fetchHopperResponse = async (
|
||||
url: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Response | undefined> => {
|
||||
): Promise<Result<Response, HopperUrlRefusal>> => {
|
||||
let selected: URL;
|
||||
try {
|
||||
selected = new URL(url);
|
||||
} catch {
|
||||
return undefined;
|
||||
return err("invalid_url");
|
||||
}
|
||||
let requested = url;
|
||||
for (let redirects = 0; ; redirects++) {
|
||||
if (selected.protocol !== "https:") return undefined;
|
||||
if (selected.protocol !== "https:") return err("non_https");
|
||||
// Fetch cannot request userinfo and would echo it in its TypeError message.
|
||||
if (selected.username !== "" || selected.password !== "")
|
||||
return err("credentials");
|
||||
const response = await fetch(requested, {
|
||||
redirect: "manual",
|
||||
headers: { accept: "application/json", "user-agent": "rea-installer" },
|
||||
signal,
|
||||
});
|
||||
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
||||
if (!REDIRECT_STATUSES.has(response.status)) return ok(response);
|
||||
const location = response.headers.get("location");
|
||||
await response.body?.cancel();
|
||||
if (location === null || redirects === FETCH_REDIRECT_LIMIT)
|
||||
return undefined;
|
||||
await releaseBody(response.body);
|
||||
if (location === null) return err("redirect_without_location");
|
||||
if (redirects === FETCH_REDIRECT_LIMIT) return err("redirect_limit");
|
||||
let next: URL;
|
||||
try {
|
||||
next = new URL(location, selected);
|
||||
} catch {
|
||||
return undefined;
|
||||
return err("invalid_url");
|
||||
}
|
||||
// Fetch inherits the previous fragment when Location has none.
|
||||
if (!location.includes("#")) next.hash = selected.hash;
|
||||
@@ -108,3 +202,64 @@ const fetchHopperResponse = async (
|
||||
requested = next.href;
|
||||
}
|
||||
};
|
||||
|
||||
const requestSubjects: Readonly<Record<HopperVendorRequest, string>> = {
|
||||
release_feed: "Hopper release feed",
|
||||
package: "Hopper package",
|
||||
public_key: "Hopper public key",
|
||||
};
|
||||
|
||||
const refusals: Readonly<Record<HopperUrlRefusal, string>> = {
|
||||
invalid_url: "a request or redirect URL was not a valid absolute URL",
|
||||
non_https: "a request or redirect URL did not use HTTPS",
|
||||
credentials: "a request or redirect URL contained credentials",
|
||||
redirect_without_location: "a redirect response omitted its Location",
|
||||
redirect_limit: `the response redirected more than ${FETCH_REDIRECT_LIMIT} times`,
|
||||
};
|
||||
|
||||
/** One sentence naming the failed request, its URL and the observed outcome. */
|
||||
export const describeHopperRequestFailure = (
|
||||
failure: HopperRequestFailure,
|
||||
): string => {
|
||||
const subject = `The official ${requestSubjects[failure.request]} request to ${failure.url}`;
|
||||
switch (failure.kind) {
|
||||
case "http_status":
|
||||
return failure.httpStatus === undefined
|
||||
? `${subject} received an unsuccessful HTTP response without a valid status.`
|
||||
: `${subject} returned HTTP ${failure.httpStatus}.`;
|
||||
case "timeout":
|
||||
return `${subject} did not complete within ${failure.timeoutMs / 1000} seconds.`;
|
||||
case "network":
|
||||
return `${subject} failed with a network error (${failure.detail}).`;
|
||||
case "response_too_large":
|
||||
return `${subject} exceeded REA's ${failure.maxBytes}-byte response limit.`;
|
||||
case "url_refused":
|
||||
return `${subject} was refused because ${refusals[failure.refusal]}.`;
|
||||
}
|
||||
};
|
||||
|
||||
/** Next step for a failed request, owned with the transport facts that select it. */
|
||||
export const hopperRequestRecovery = (
|
||||
failure: HopperDownloadFailure,
|
||||
): string => {
|
||||
switch (failure.kind) {
|
||||
case "http_status": {
|
||||
const status = failure.httpStatus;
|
||||
if (status === 404 || status === 410)
|
||||
return "The vendor reports this file as missing, so an immediate retry is unlikely to help; check for a newer REA release, then retry.";
|
||||
if (
|
||||
status === 408 ||
|
||||
status === 429 ||
|
||||
(status !== undefined && status >= 500)
|
||||
)
|
||||
return "The vendor service reported a temporary failure; wait, then retry.";
|
||||
return "Check proxy, firewall or network policy for this request, then retry.";
|
||||
}
|
||||
case "timeout":
|
||||
case "network":
|
||||
return "Check network, DNS, proxy and TLS access to this URL, then retry.";
|
||||
case "response_too_large":
|
||||
case "url_refused":
|
||||
return "The vendor response did not meet REA's download limits; check for a newer REA release, then retry.";
|
||||
}
|
||||
};
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
type SafeJsonParseResult,
|
||||
} from "../domain/safeJson.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { HOPPER_DOWNLOAD_BYTES } from "./HopperDownload.js";
|
||||
import {
|
||||
describeHopperRequestFailure,
|
||||
HOPPER_DOWNLOAD_BYTES,
|
||||
type HopperRequestFailure,
|
||||
} from "./HopperDownload.js";
|
||||
import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js";
|
||||
|
||||
/** Vendor endpoints, shared by installation and existing-build inspection. */
|
||||
@@ -100,6 +104,8 @@ const suffixes = { deb: ".deb", rpm: ".rpm", arch: ".pkg.tar.xz" } as const;
|
||||
|
||||
export class HopperReleaseError extends Error {
|
||||
readonly _tag = "HopperReleaseError";
|
||||
/** The vendor request whose transport failure produced this error. */
|
||||
readonly download: HopperRequestFailure | undefined;
|
||||
constructor(
|
||||
readonly reason:
|
||||
| "release_metadata"
|
||||
@@ -112,10 +118,23 @@ export class HopperReleaseError extends Error {
|
||||
| "cleanup_failed"
|
||||
| "cache_missing",
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
options?: ErrorOptions & { readonly download?: HopperRequestFailure },
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = this._tag;
|
||||
this.download = options?.download;
|
||||
}
|
||||
|
||||
/** Describe a failed vendor request once, from the transport's observed facts. */
|
||||
static fromDownload(
|
||||
reason: HopperReleaseError["reason"],
|
||||
download: HopperRequestFailure,
|
||||
): HopperReleaseError {
|
||||
return new HopperReleaseError(
|
||||
reason,
|
||||
describeHopperRequestFailure(download),
|
||||
{ download },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { childFailureDiagnostics } from "../domain/analysisErrorProjection.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
JsonInputPhysicalReadFailure,
|
||||
@@ -170,7 +170,7 @@ const readJsonFileInput = async <Value>(
|
||||
cleanupPrimary: primary,
|
||||
...(primary === undefined
|
||||
? {}
|
||||
: { diagnostics: { primary_error: projectAnalysisError(primary) } }),
|
||||
: childFailureDiagnostics({ primary_error: { error: primary } })),
|
||||
});
|
||||
bindRegularFileCleanup(cause, error);
|
||||
return err(error);
|
||||
@@ -272,7 +272,7 @@ const jsonFileCleanupError = <Value>(
|
||||
),
|
||||
cleanupPrimary: primary,
|
||||
cleanup: cleanup.cleanup,
|
||||
diagnostics: { primary_error: projectAnalysisError(primary) },
|
||||
...childFailureDiagnostics({ primary_error: { error: primary } }),
|
||||
}).bindCleanupDependencies([primary, cleanup]);
|
||||
};
|
||||
|
||||
|
||||
@@ -76,7 +76,11 @@ class RecordingLinuxHost implements LinuxHopperInstallHost {
|
||||
Promise.resolve(ok(releasesFor(new Uint8Array([1, 2, 3]))[family]));
|
||||
download = (url: string): Promise<HopperDownload> => {
|
||||
this.downloads.push(url);
|
||||
return Promise.resolve({ ok: this.archiveOk, bytes: this.archive });
|
||||
return Promise.resolve(
|
||||
this.archiveOk
|
||||
? ok(this.archive)
|
||||
: err({ url, kind: "http_status", httpStatus: 503 }),
|
||||
);
|
||||
};
|
||||
createTemporaryDirectory = (): Promise<string> =>
|
||||
Promise.resolve("/tmp/rea test");
|
||||
@@ -394,7 +398,19 @@ describe("Linux Hopper installation failure classification", () => {
|
||||
...(scenario === "package manager failure"
|
||||
? { packageFailure: host.packageFailure }
|
||||
: {}),
|
||||
...(scenario === "package request failure"
|
||||
? {
|
||||
download: {
|
||||
request: "package",
|
||||
url: host.downloads[0],
|
||||
kind: "http_status",
|
||||
httpStatus: 503,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (scenario === "package request failure")
|
||||
expect(host.cleaned).toEqual([]);
|
||||
if (host.installed.length > 0)
|
||||
expect(host.cleaned).toEqual(["/tmp/rea test"]);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
downloadHopperPackage,
|
||||
type HopperDownload,
|
||||
type HopperRequestFailure,
|
||||
} from "./HopperDownload.js";
|
||||
import {
|
||||
inspectLinuxHopperPythonRuntime,
|
||||
@@ -244,6 +245,8 @@ export type LinuxHopperInstallResult =
|
||||
readonly pythonRuntimeFailure?: LinuxHopperPythonError;
|
||||
/** The operation that threw, retained separately from its outcome class. */
|
||||
readonly operationFailure?: LinuxHopperOperationFailure;
|
||||
/** The vendor request (feed, package or key) whose transport failure stopped setup. */
|
||||
readonly download?: HopperRequestFailure;
|
||||
/** Original caller cancellation, without flattening its value into text. */
|
||||
readonly cancellationReason?: unknown;
|
||||
} & { readonly cleanupFailure?: LinuxHopperTemporaryCleanupFailure });
|
||||
@@ -388,14 +391,12 @@ export const installLinuxHopper = async (
|
||||
options.signal,
|
||||
);
|
||||
if (!selected.ok)
|
||||
return {
|
||||
status: "failed",
|
||||
verificationFailure: selected.error,
|
||||
reason:
|
||||
selected.error.reason === "cancelled"
|
||||
? "cancelled"
|
||||
: "release_metadata",
|
||||
};
|
||||
return publisherFailure(
|
||||
selected.error,
|
||||
selected.error.reason === "cancelled"
|
||||
? "cancelled"
|
||||
: "release_metadata",
|
||||
);
|
||||
const release = selected.value;
|
||||
stage = "download";
|
||||
const archiveDownload = await host.download(release.filename, options);
|
||||
@@ -403,14 +404,7 @@ export const installLinuxHopper = async (
|
||||
return {
|
||||
status: "failed",
|
||||
reason: "download",
|
||||
...(archiveDownload.httpStatus === undefined
|
||||
? {}
|
||||
: {
|
||||
operationFailure: {
|
||||
operation: "download",
|
||||
detail: `Package download returned HTTP ${archiveDownload.httpStatus}.`,
|
||||
},
|
||||
}),
|
||||
download: { request: "package", ...archiveDownload.error },
|
||||
};
|
||||
stage = "archive_write";
|
||||
temporary = await host.createTemporaryDirectory();
|
||||
@@ -421,7 +415,7 @@ export const installLinuxHopper = async (
|
||||
`hopper.${distribution.packageFamily}`,
|
||||
);
|
||||
operationPath = archive;
|
||||
await host.writeArchive(archive, archiveDownload.bytes);
|
||||
await host.writeArchive(archive, archiveDownload.value);
|
||||
stage = "publisher_verification_failed";
|
||||
const verified = await host.verifyArchive(
|
||||
distribution.packageFamily,
|
||||
@@ -430,28 +424,24 @@ export const installLinuxHopper = async (
|
||||
options.signal,
|
||||
);
|
||||
if (!verified.ok)
|
||||
return {
|
||||
status: "failed",
|
||||
verificationFailure: verified.error,
|
||||
reason:
|
||||
verified.error.reason === "cache_missing"
|
||||
? "verification_unavailable"
|
||||
: verified.error.reason,
|
||||
};
|
||||
return publisherFailure(
|
||||
verified.error,
|
||||
verified.error.reason === "cache_missing"
|
||||
? "verification_unavailable"
|
||||
: verified.error.reason,
|
||||
);
|
||||
const retained = await host.retainVerifiedArchive(
|
||||
verified.value,
|
||||
archive,
|
||||
options.signal,
|
||||
);
|
||||
if (!retained.ok)
|
||||
return {
|
||||
status: "failed",
|
||||
verificationFailure: retained.error,
|
||||
reason:
|
||||
retained.error.reason === "cache_missing"
|
||||
? "verification_unavailable"
|
||||
: retained.error.reason,
|
||||
};
|
||||
return publisherFailure(
|
||||
retained.error,
|
||||
retained.error.reason === "cache_missing"
|
||||
? "verification_unavailable"
|
||||
: retained.error.reason,
|
||||
);
|
||||
if (isAborted(options.signal))
|
||||
return cancelledInstall(options.signal?.reason);
|
||||
stage = "authorization_or_package_manager";
|
||||
@@ -741,6 +731,17 @@ const installSystemPackage = async (
|
||||
const isAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
||||
/** Retain the publisher's error and any vendor request facts it observed. */
|
||||
const publisherFailure = (
|
||||
error: HopperReleaseError,
|
||||
reason: Exclude<HopperReleaseError["reason"], "cache_missing">,
|
||||
): LinuxHopperInstallResult => ({
|
||||
status: "failed",
|
||||
reason,
|
||||
verificationFailure: error,
|
||||
...(error.download === undefined ? {} : { download: error.download }),
|
||||
});
|
||||
|
||||
const cancelledInstall = (cause: unknown): LinuxHopperInstallResult => ({
|
||||
status: "failed",
|
||||
reason: "cancelled",
|
||||
|
||||
@@ -125,11 +125,11 @@ export class LinuxHopperPublisher {
|
||||
timeoutMs: RELEASE_METADATA_TIMEOUT_MS,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new HopperReleaseError(
|
||||
"release_metadata",
|
||||
"The official Hopper release feed is unavailable.",
|
||||
);
|
||||
const decoded = parseHopperReleaseJson(response.bytes);
|
||||
throw HopperReleaseError.fromDownload("release_metadata", {
|
||||
request: "release_feed",
|
||||
...response.error,
|
||||
});
|
||||
const decoded = parseHopperReleaseJson(response.value);
|
||||
if (!decoded.ok) throw decoded.cause;
|
||||
this.#feed = parseLinuxHopperReleases(decoded.value);
|
||||
}
|
||||
@@ -189,11 +189,11 @@ export class LinuxHopperPublisher {
|
||||
timeoutMs: RELEASE_METADATA_TIMEOUT_MS,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new HopperReleaseError(
|
||||
"verification_unavailable",
|
||||
"The official Hopper public key is unavailable.",
|
||||
);
|
||||
publicKey = decodeUtf8(response.bytes);
|
||||
throw HopperReleaseError.fromDownload("verification_unavailable", {
|
||||
request: "public_key",
|
||||
...response.error,
|
||||
});
|
||||
publicKey = decodeUtf8(response.value);
|
||||
}
|
||||
const packageSha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
// Every read still validates file identity, byte bounds and selected metadata.
|
||||
@@ -533,12 +533,12 @@ export class LinuxHopperPublisher {
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new HopperReleaseError(
|
||||
"download",
|
||||
"The official Hopper package is unavailable for publisher verification.",
|
||||
);
|
||||
throw HopperReleaseError.fromDownload("download", {
|
||||
request: "package",
|
||||
...response.error,
|
||||
});
|
||||
const archive = join(root, "archive");
|
||||
await writeFile(archive, response.bytes, { flag: "wx", mode: 0o600 });
|
||||
await writeFile(archive, response.value, { flag: "wx", mode: 0o600 });
|
||||
const verified = await this.#verifyArchive(
|
||||
family,
|
||||
selected.value,
|
||||
|
||||
@@ -2,6 +2,11 @@ import { createHash } from "node:crypto";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import type {
|
||||
HopperDownload,
|
||||
HopperDownloadFailure,
|
||||
} from "./HopperDownload.js";
|
||||
import {
|
||||
installMacHopper,
|
||||
type MacHopperBundleVerification,
|
||||
@@ -11,7 +16,8 @@ import {
|
||||
|
||||
class FakeMacHopperHost implements MacHopperInstallHost {
|
||||
readonly archive = new TextEncoder().encode("hopper-dmg");
|
||||
archiveOk = true;
|
||||
feedFailure: HopperDownloadFailure | undefined;
|
||||
packageFailure: HopperDownloadFailure | undefined;
|
||||
feedBytes: Uint8Array | undefined;
|
||||
packageDownloads = 0;
|
||||
mounts = 0;
|
||||
@@ -28,14 +34,15 @@ class FakeMacHopperHost implements MacHopperInstallHost {
|
||||
opens = 0;
|
||||
replaces = false;
|
||||
|
||||
download = (url: string) => {
|
||||
if (!url.includes("files-api")) this.packageDownloads += 1;
|
||||
download = (url: string): Promise<HopperDownload> => {
|
||||
const feed = url.includes("files-api");
|
||||
if (!feed) this.packageDownloads += 1;
|
||||
const failure = feed ? this.feedFailure : this.packageFailure;
|
||||
if (failure !== undefined) return Promise.resolve(err(failure));
|
||||
return Promise.resolve(
|
||||
url.includes("files-api")
|
||||
? {
|
||||
ok: true,
|
||||
bytes:
|
||||
this.feedBytes ??
|
||||
ok(
|
||||
feed
|
||||
? (this.feedBytes ??
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
"OS X": {
|
||||
@@ -47,9 +54,9 @@ class FakeMacHopperHost implements MacHopperInstallHost {
|
||||
: createHash("sha1").update(this.archive).digest("hex"),
|
||||
},
|
||||
}),
|
||||
),
|
||||
}
|
||||
: { ok: this.archiveOk, bytes: this.archive },
|
||||
))
|
||||
: this.archive,
|
||||
),
|
||||
);
|
||||
};
|
||||
createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test");
|
||||
@@ -99,6 +106,12 @@ class FakeMacHopperHost implements MacHopperInstallHost {
|
||||
destination = () => "/home/user/Applications/Hopper Disassembler.app";
|
||||
}
|
||||
|
||||
const feedText = async (host: FakeMacHopperHost): Promise<string> => {
|
||||
const feed = await host.download("files-api");
|
||||
if (!feed.ok) throw new Error("Expected the fixture feed");
|
||||
return new TextDecoder().decode(feed.value);
|
||||
};
|
||||
|
||||
describe("macOS Hopper installation", () => {
|
||||
it("installs, verifies, opens, and cleans the official package", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
@@ -273,8 +286,7 @@ it("retains the exact DMG temporary root with its retry capability", async () =>
|
||||
|
||||
it("refuses malformed UTF-8 release metadata before downloading or mounting the package", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
const feed = await host.download("files-api");
|
||||
const text = new TextDecoder().decode(feed.bytes);
|
||||
const text = await feedText(host);
|
||||
// Replacement decoding would produce valid JSON and hide the malformed transport byte.
|
||||
host.feedBytes = new Uint8Array([
|
||||
...new TextEncoder().encode('{"vendor_note":"'),
|
||||
@@ -294,10 +306,10 @@ it.each([false, true])(
|
||||
"accepts a genuine replacement character in release metadata (BOM=%s)",
|
||||
async (bom) => {
|
||||
const host = new FakeMacHopperHost();
|
||||
const feed = await host.download("files-api");
|
||||
const text = new TextDecoder()
|
||||
.decode(feed.bytes)
|
||||
.replace('{"OS X":{', '{"OS X":{"vendor_note":"雪�",');
|
||||
const text = (await feedText(host)).replace(
|
||||
'{"OS X":{',
|
||||
'{"OS X":{"vendor_note":"雪�",',
|
||||
);
|
||||
host.feedBytes = new TextEncoder().encode((bom ? "\uFEFF" : "") + text);
|
||||
expect(await installMacHopper({}, host)).toMatchObject({
|
||||
status: "installed",
|
||||
@@ -310,8 +322,7 @@ it.each([false, true])(
|
||||
|
||||
it("refuses release URLs that normalize outside the public channel before package download", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
const feed = await host.download("files-api");
|
||||
const row = JSON.parse(new TextDecoder().decode(feed.bytes));
|
||||
const row = JSON.parse(await feedText(host));
|
||||
row["OS X"].filename =
|
||||
"https://www.hopperapp.com:443/downloader/public/%2e%2e/private/Hopper-demo.dmg";
|
||||
host.feedBytes = new TextEncoder().encode(JSON.stringify(row));
|
||||
@@ -323,3 +334,28 @@ it("refuses release URLs that normalize outside the public channel before packag
|
||||
expect(host.packageDownloads).toBe(0);
|
||||
expect(host.mounts).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["feed", "package"] as const)(
|
||||
"attributes a failed %s request to its stage before any mount",
|
||||
async (stage) => {
|
||||
const host = new FakeMacHopperHost();
|
||||
const failure = {
|
||||
url: "https://fixture.invalid/request",
|
||||
kind: "timeout",
|
||||
timeoutMs: 120_000,
|
||||
} as const;
|
||||
if (stage === "feed") host.feedFailure = failure;
|
||||
else host.packageFailure = failure;
|
||||
expect(await installMacHopper({}, host)).toEqual({
|
||||
status: "failed",
|
||||
reason: stage === "feed" ? "release_metadata" : "download",
|
||||
download: {
|
||||
request: stage === "feed" ? "release_feed" : "package",
|
||||
...failure,
|
||||
},
|
||||
});
|
||||
expect(host.packageDownloads).toBe(stage === "feed" ? 0 : 1);
|
||||
expect(host.mounts).toBe(0);
|
||||
expect(host.cleanups).toBe(0);
|
||||
},
|
||||
);
|
||||
@@ -18,7 +18,11 @@ import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"
|
||||
import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
|
||||
import { macDeveloperIdRequirement } from "../process/MacCodeSignature.js";
|
||||
import { macHopperUserApplicationPath } from "./MacHopperPaths.js";
|
||||
import { downloadHopperPackage } from "./HopperDownload.js";
|
||||
import {
|
||||
downloadHopperPackage,
|
||||
type HopperDownload,
|
||||
type HopperRequestFailure,
|
||||
} from "./HopperDownload.js";
|
||||
import {
|
||||
HOPPER_RELEASES_URL,
|
||||
HOPPER_DOWNLOAD_PREFIX,
|
||||
@@ -97,14 +101,14 @@ export type MacHopperInstallResult =
|
||||
readonly status: "failed";
|
||||
readonly reason: MacHopperInstallFailureReason;
|
||||
readonly diagnostic?: string;
|
||||
/** The vendor request (feed or package) whose transport failure stopped setup. */
|
||||
readonly download?: HopperRequestFailure;
|
||||
readonly cleanupFailure?: string;
|
||||
};
|
||||
|
||||
/** External effects used by the bounded macOS Hopper installer. */
|
||||
export interface MacHopperInstallHost {
|
||||
download(
|
||||
url: string,
|
||||
): Promise<{ readonly ok: boolean; readonly bytes: Uint8Array }>;
|
||||
download(url: string): Promise<HopperDownload>;
|
||||
createTemporaryDirectory(): Promise<string>;
|
||||
createMountDirectory(path: string): Promise<void>;
|
||||
writePackage(path: string, bytes: Uint8Array): Promise<void>;
|
||||
@@ -144,17 +148,27 @@ export const installMacHopper = async (
|
||||
try {
|
||||
result = await (async (): Promise<MacHopperInstallResult> => {
|
||||
const metadata = await host.download(HOPPER_RELEASES_URL);
|
||||
if (!metadata.ok) return { status: "failed", reason: "release_metadata" };
|
||||
const decoded = parseHopperReleaseJson(metadata.bytes);
|
||||
if (!metadata.ok)
|
||||
return {
|
||||
status: "failed",
|
||||
reason: "release_metadata",
|
||||
download: { request: "release_feed", ...metadata.error },
|
||||
};
|
||||
const decoded = parseHopperReleaseJson(metadata.value);
|
||||
if (!decoded.ok) throw decoded.cause;
|
||||
const parsed = parseMacHopperReleases(decoded.value);
|
||||
if (!parsed.ok) return { status: "failed", reason: "release_metadata" };
|
||||
const release = parsed.value;
|
||||
installStage = "download";
|
||||
const archive = await host.download(release.filename);
|
||||
if (!archive.ok) return { status: "failed", reason: "download" };
|
||||
if (!archive.ok)
|
||||
return {
|
||||
status: "failed",
|
||||
reason: "download",
|
||||
download: { request: "package", ...archive.error },
|
||||
};
|
||||
installStage = "integrity";
|
||||
if (!hopperArchiveIntegrityMatches(archive.bytes, release))
|
||||
if (!hopperArchiveIntegrityMatches(archive.value, release))
|
||||
return { status: "failed", reason: "integrity" };
|
||||
|
||||
installStage = "destination_inspection";
|
||||
@@ -167,7 +181,7 @@ export const installMacHopper = async (
|
||||
const packagePath = posix.join(temporary, "hopper.dmg");
|
||||
const mountPath = posix.join(temporary, "mounted");
|
||||
await host.createMountDirectory(mountPath);
|
||||
await host.writePackage(packagePath, archive.bytes);
|
||||
await host.writePackage(packagePath, archive.value);
|
||||
if (!(await host.mount(packagePath, mountPath)))
|
||||
return { status: "failed", reason: "mount" };
|
||||
mounted = true;
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "./ReconstructionObligationOriginalCases.js";
|
||||
import { obligationLedgerEvidenceLinks } from "./ReconstructionObligationLedgerProjection.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
type Diagnostic = ReconstructionObligation["diagnostics"][number];
|
||||
type Binding = NonNullable<ReconstructionObligation["binding"]>;
|
||||
@@ -210,13 +211,15 @@ const evaluateObligation = (
|
||||
);
|
||||
if (binding !== null)
|
||||
evaluateBinding(candidate, binding, context, diagnostics);
|
||||
diagnostics.push(
|
||||
...originalCaseDiagnostics(candidate, originalCases, context.evidence),
|
||||
appendAll(
|
||||
diagnostics,
|
||||
originalCaseDiagnostics(candidate, originalCases, context.evidence),
|
||||
);
|
||||
evaluateAuthority(candidate, originalCases, context, diagnostics);
|
||||
evaluateDisposition(candidate, diagnostics);
|
||||
diagnostics.push(
|
||||
...contradictionDiagnostics(
|
||||
appendAll(
|
||||
diagnostics,
|
||||
contradictionDiagnostics(
|
||||
candidate.obligation_id,
|
||||
context.contradictions,
|
||||
context.evidence,
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
ReferenceSourceRead,
|
||||
} from "../reference/ReferenceSourceReaderTypes.js";
|
||||
import { typescriptEmittedSourceCandidates } from "./TypeScriptEmittedSourceCandidates.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
export interface ParsedReferenceSourceEntries {
|
||||
readonly entries: HistoricalSourceGraphInput["entries"];
|
||||
@@ -473,8 +474,9 @@ const resolveInternalSpecifier = (
|
||||
// Retain present runtime files first; substitute only a missing counterpart.
|
||||
// Counterparts cover explicit filenames, not directory/index resolution.
|
||||
if (!directoryReference)
|
||||
candidates.push(
|
||||
...typescriptEmittedSourceCandidates(fromPath, normalized).map(
|
||||
appendAll(
|
||||
candidates,
|
||||
typescriptEmittedSourceCandidates(fromPath, normalized).map(
|
||||
({ path }) => path,
|
||||
),
|
||||
);
|
||||
@@ -564,7 +566,7 @@ const appendFile = (
|
||||
limitations: [...(parsed?.limitations ?? [])],
|
||||
});
|
||||
if (parsed === undefined) return;
|
||||
output.limitations.push(...parsed.limitations);
|
||||
appendAll(output.limitations, parsed.limitations);
|
||||
for (const relationship of parsed.relationships) {
|
||||
const { resolutionStrategy, resolutionBasePath, ...graphRelationship } =
|
||||
relationship;
|
||||
@@ -598,7 +600,7 @@ const appendFile = (
|
||||
resolution: resolved.resolution,
|
||||
});
|
||||
}
|
||||
output.parseFailures.push(...parsed.parse_failures);
|
||||
appendAll(output.parseFailures, parsed.parse_failures);
|
||||
};
|
||||
|
||||
const appendReadEntry = (
|
||||
|
||||
@@ -243,6 +243,53 @@ describe("optional Hopper installation failures", () => {
|
||||
expect(result.remediation).toContain("injected skill write failure");
|
||||
expect(host.skillInstalls).toBe(1);
|
||||
});
|
||||
|
||||
it("returns the failed Hopper request facts beside its code, including after a later failure", async () => {
|
||||
const download = {
|
||||
request: "package",
|
||||
url: "https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg",
|
||||
kind: "http_status",
|
||||
httpStatus: 503,
|
||||
} as const;
|
||||
const ready = new FakeSetupHost();
|
||||
ready.hopperInstallSucceeds = false;
|
||||
ready.hopperInstallFailure = {
|
||||
status: "failed",
|
||||
code: "download_failed",
|
||||
remediation: "Package returned HTTP 503.",
|
||||
download,
|
||||
};
|
||||
expect(await runSetup(options(true, true), ready)).toMatchObject({
|
||||
status: "needs_human",
|
||||
code: "download_failed",
|
||||
remediation: "Package returned HTTP 503.",
|
||||
download,
|
||||
});
|
||||
|
||||
const skillFailure = new FakeSetupHost();
|
||||
skillFailure.hopperInstallSucceeds = false;
|
||||
skillFailure.hopperInstallFailure = ready.hopperInstallFailure;
|
||||
skillFailure.skill = "failed";
|
||||
skillFailure.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
||||
expect(
|
||||
await runSetup(
|
||||
{
|
||||
...options(true, true),
|
||||
clientIds: ["cursor"],
|
||||
skillClientIds: ["cursor"],
|
||||
},
|
||||
skillFailure,
|
||||
),
|
||||
).toMatchObject({ code: "skill_install_failed", download });
|
||||
});
|
||||
|
||||
it("omits request facts when the Hopper failure observed none", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopperInstallSucceeds = false;
|
||||
expect(await runSetup(options(true, true), host)).not.toHaveProperty(
|
||||
"download",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configured Linux Hopper selection", () => {
|
||||
|
||||
@@ -7,7 +7,10 @@ import type {
|
||||
SetupProviderEnvironment,
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type { SetupHopperInstallResult } from "./SetupInstallFailure.js";
|
||||
import type {
|
||||
SetupHopperInstallFailure,
|
||||
SetupHopperInstallResult,
|
||||
} from "./SetupInstallFailure.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type {
|
||||
DoctorCheck,
|
||||
@@ -38,6 +41,11 @@ export class FakeSetupHost implements SetupHost {
|
||||
ghidra: string | undefined;
|
||||
javaHome: string | undefined;
|
||||
hopperInstallSucceeds = true;
|
||||
hopperInstallFailure: SetupHopperInstallFailure = {
|
||||
status: "failed",
|
||||
code: "download_failed",
|
||||
remediation: "Download failed.",
|
||||
};
|
||||
skill: "installed" | "unchanged" | "failed" = "installed";
|
||||
clients: readonly SetupClient[] = [];
|
||||
availableClients: readonly SetupClient[] | undefined;
|
||||
@@ -99,11 +107,7 @@ export class FakeSetupHost implements SetupHost {
|
||||
launcherPath: this.hopper,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: "failed",
|
||||
code: "download_failed",
|
||||
remediation: "Download failed.",
|
||||
});
|
||||
return Promise.resolve(this.hopperInstallFailure);
|
||||
};
|
||||
detectedClients = (): Promise<readonly SetupClient[]> =>
|
||||
Promise.resolve(this.clients);
|
||||
|
||||
@@ -372,6 +372,7 @@ const runSetupWorkflow = async (
|
||||
remediation:
|
||||
combineRemediation(hopperFailure.remediation, result.remediation) ??
|
||||
hopperFailure.remediation,
|
||||
...hopperDownload(hopperFailure),
|
||||
};
|
||||
if (clientFailure !== undefined)
|
||||
return withHopperFailure(await fail(clientFailure));
|
||||
@@ -407,10 +408,17 @@ const runSetupWorkflow = async (
|
||||
doctor,
|
||||
clientStates,
|
||||
...(remediation === undefined ? {} : { remediation }),
|
||||
...(hopperFailure === undefined ? {} : { code: hopperFailure.code }),
|
||||
...(hopperFailure === undefined
|
||||
? {}
|
||||
: { code: hopperFailure.code, ...hopperDownload(hopperFailure) }),
|
||||
};
|
||||
};
|
||||
|
||||
const hopperDownload = (
|
||||
failure: SetupHopperInstallFailure,
|
||||
): Pick<SetupResult, "download"> =>
|
||||
failure.download === undefined ? {} : { download: failure.download };
|
||||
|
||||
const installHopperAction = async (input: {
|
||||
readonly host: SetupHost;
|
||||
readonly options: SetupOptions;
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
registrationWriteBlocker,
|
||||
type SetupClient,
|
||||
} from "./SupportedClients.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanupFailure<string> {
|
||||
constructor(
|
||||
@@ -728,8 +729,9 @@ const registrationOwnershipBlocker = (
|
||||
value: parsed.legacyServers[name],
|
||||
paths: [client.configPath],
|
||||
});
|
||||
entries.push(
|
||||
...shadowedClientServers(original, parsed, name).legacyServers.map(
|
||||
appendAll(
|
||||
entries,
|
||||
shadowedClientServers(original, parsed, name).legacyServers.map(
|
||||
(value) => ({ value, paths: [client.configPath] }),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -227,17 +227,20 @@ export const systemSetupHost = (
|
||||
}
|
||||
if (result.status === "installed")
|
||||
return { status: "installed", launcherPath: result.launcherPath };
|
||||
const failure = setupInstallFailure(
|
||||
result.reason,
|
||||
result.packageFailure,
|
||||
"linux",
|
||||
);
|
||||
const failure = setupInstallFailure(result.reason, {
|
||||
packageFailure: result.packageFailure,
|
||||
platform: "linux",
|
||||
download: result.download,
|
||||
});
|
||||
// A failed vendor request is already described by its owned facts.
|
||||
const detail =
|
||||
result.verificationFailure?.message ??
|
||||
result.pythonRuntimeFailure?.message ??
|
||||
(result.operationFailure === undefined
|
||||
result.download !== undefined
|
||||
? undefined
|
||||
: `${result.operationFailure.operation}${result.operationFailure.path === undefined ? "" : ` at ${result.operationFailure.path}`}: ${result.operationFailure.systemCode === undefined ? "" : `${result.operationFailure.systemCode}: `}${result.operationFailure.detail}`);
|
||||
: (result.verificationFailure?.message ??
|
||||
result.pythonRuntimeFailure?.message ??
|
||||
(result.operationFailure === undefined
|
||||
? undefined
|
||||
: `${result.operationFailure.operation}${result.operationFailure.path === undefined ? "" : ` at ${result.operationFailure.path}`}: ${result.operationFailure.systemCode === undefined ? "" : `${result.operationFailure.systemCode}: `}${result.operationFailure.detail}`));
|
||||
return detail !== undefined
|
||||
? {
|
||||
...failure,
|
||||
@@ -264,7 +267,9 @@ export const systemSetupHost = (
|
||||
? {}
|
||||
: { cleanupFailure: result.cleanupFailure }),
|
||||
};
|
||||
const failure = setupInstallFailure(result.reason);
|
||||
const failure = setupInstallFailure(result.reason, {
|
||||
download: result.download,
|
||||
});
|
||||
const diagnostic = setupInstallerDiagnostic(result.diagnostic);
|
||||
return {
|
||||
...failure,
|
||||
|
||||
@@ -25,11 +25,13 @@ describe("macOS Hopper setup failure projection", () => {
|
||||
describe("Linux package-manager setup failure projection", () => {
|
||||
it("quotes the failing command and a sanitized bounded diagnostic", () => {
|
||||
const result = setupInstallFailure("authorization_or_package_manager", {
|
||||
command: 'pkexec pacman -U "/tmp/hopper package.pkg.tar.xz"',
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "\u001b[31merror:\u001b[0m package conflict\nrefused",
|
||||
outputTruncated: false,
|
||||
packageFailure: {
|
||||
command: 'pkexec pacman -U "/tmp/hopper package.pkg.tar.xz"',
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "\u001b[31merror:\u001b[0m package conflict\nrefused",
|
||||
outputTruncated: false,
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
status: "failed",
|
||||
@@ -55,11 +57,13 @@ describe("Linux package-manager setup failure projection", () => {
|
||||
|
||||
it("keeps truncated package output within its excerpt budget", () => {
|
||||
const result = setupInstallFailure("authorization_or_package_manager", {
|
||||
command: "pacman -U hopper.pkg.tar.xz",
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: `${"warning ".repeat(100)}final package conflict`,
|
||||
outputTruncated: true,
|
||||
packageFailure: {
|
||||
command: "pacman -U hopper.pkg.tar.xz",
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: `${"warning ".repeat(100)}final package conflict`,
|
||||
outputTruncated: true,
|
||||
},
|
||||
});
|
||||
if (result.status !== "failed") throw new Error("expected failure");
|
||||
const excerpt = result.remediation
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
|
||||
import {
|
||||
describeHopperRequestFailure,
|
||||
hopperRequestRecovery,
|
||||
type HopperRequestFailure,
|
||||
} from "./HopperDownload.js";
|
||||
import { HOPPER_LINUX_PRIMARY_FINGERPRINT } from "./HopperReleaseFeed.js";
|
||||
import type { LinuxPackageInstallFailure } from "./LinuxHopper.js";
|
||||
|
||||
@@ -33,6 +38,8 @@ export type SetupHopperInstallResult =
|
||||
readonly status: "failed";
|
||||
readonly code: SetupFailureCode;
|
||||
readonly remediation: string;
|
||||
/** The vendor request whose observed transport failure produced `code`. */
|
||||
readonly download?: HopperRequestFailure;
|
||||
};
|
||||
|
||||
export type SetupHopperInstallFailure = Extract<
|
||||
@@ -104,11 +111,37 @@ const packageManagerRemediation = (
|
||||
return `The system package-manager command failed (${failure.command}; ${status}); ${observed} Resolve the reported problem, grant the authorization the command needs, or install the planned Hopper package and Linux runtime dependencies manually, then rerun rea setup.`;
|
||||
};
|
||||
|
||||
/** Map provider installer failures to stable setup codes and safe recovery. */
|
||||
/**
|
||||
* Map provider installer failures to stable setup codes and safe recovery. A
|
||||
* failed vendor request replaces the stage's generic advice with its observed
|
||||
* URL and outcome, and stays structured for programmatic callers.
|
||||
*/
|
||||
export const setupInstallFailure = (
|
||||
reason: HopperInstallFailureReason,
|
||||
packageFailure?: LinuxPackageInstallFailure,
|
||||
platform: NodeJS.Platform = "darwin",
|
||||
context: {
|
||||
readonly packageFailure?: LinuxPackageInstallFailure | undefined;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly download?: HopperRequestFailure | undefined;
|
||||
} = {},
|
||||
): SetupHopperInstallFailure => {
|
||||
const failure = stageFailure(
|
||||
reason,
|
||||
context.packageFailure,
|
||||
context.platform ?? "darwin",
|
||||
);
|
||||
return context.download === undefined
|
||||
? failure
|
||||
: {
|
||||
...failure,
|
||||
remediation: `${describeHopperRequestFailure(context.download)} ${hopperRequestRecovery(context.download)}`,
|
||||
download: context.download,
|
||||
};
|
||||
};
|
||||
|
||||
const stageFailure = (
|
||||
reason: HopperInstallFailureReason,
|
||||
packageFailure: LinuxPackageInstallFailure | undefined,
|
||||
platform: NodeJS.Platform,
|
||||
): SetupHopperInstallFailure => {
|
||||
switch (reason) {
|
||||
case "unsupported_host":
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type SkillFileSnapshot,
|
||||
type SkillPublicationCleanupOwner,
|
||||
} from "./SkillFilePublication.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
export const MANAGED_SKILL_FILES = [
|
||||
"SKILL.md",
|
||||
@@ -272,7 +273,7 @@ export const existingSkillDestinations = async (
|
||||
try {
|
||||
const pathIssues = await skillDirectoryIssues([destination]);
|
||||
if (pathIssues.some(({ kind }) => kind === "unreadable")) {
|
||||
issues.push(...pathIssues);
|
||||
appendAll(issues, pathIssues);
|
||||
continue;
|
||||
}
|
||||
if (pathIssues.length > 0) continue;
|
||||
@@ -478,7 +479,7 @@ const inspectCanonicalSkill = async (
|
||||
const { path: root } = destination;
|
||||
const pathIssues = await skillDirectoryIssues([destination]);
|
||||
if (pathIssues.length > 0) {
|
||||
issues.push(...pathIssues);
|
||||
appendAll(issues, pathIssues);
|
||||
if (failureCause === undefined) failureCause = pathIssues;
|
||||
installedDestination = true;
|
||||
canonical = false;
|
||||
@@ -521,7 +522,7 @@ const inspectCanonicalSkill = async (
|
||||
if (clientIds !== undefined || rootHasFiles || rootIssues.length > 0) {
|
||||
installedDestination ||= rootHasFiles;
|
||||
canonical = canonical && rootCanonical;
|
||||
issues.push(...rootIssues);
|
||||
appendAll(issues, rootIssues);
|
||||
if (!rootCanonical && rootIssues.length === 0)
|
||||
issues.push({
|
||||
client: destination.client,
|
||||
@@ -695,8 +696,9 @@ export const installCanonicalSkill = async (
|
||||
}
|
||||
}
|
||||
const cleanupOwners = [...publications, ...preparedCleanupOwners];
|
||||
rollbackFailures.push(
|
||||
...(await closeSkillPublications(cleanupOwners, resources)),
|
||||
appendAll(
|
||||
rollbackFailures,
|
||||
await closeSkillPublications(cleanupOwners, resources),
|
||||
);
|
||||
return err(
|
||||
new SetupSkillInstallError({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ClientConfigurationWriteProblem } from "./ClientConfigurationPublication.js";
|
||||
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
|
||||
import type { LinuxDistribution } from "./LinuxHopper.js";
|
||||
import type { HopperRequestFailure } from "./HopperDownload.js";
|
||||
import type { HopperReleaseError } from "./HopperReleaseFeed.js";
|
||||
import type {
|
||||
SkillDestination,
|
||||
@@ -159,6 +160,8 @@ export interface SetupResult {
|
||||
readonly clientStates: readonly SetupClientState[];
|
||||
readonly remediation?: string;
|
||||
readonly code?: SetupFailureCode;
|
||||
/** The Hopper vendor request (stage, URL and observed outcome) whose failure stopped installation. */
|
||||
readonly download?: HopperRequestFailure;
|
||||
/** Identified resources still retained after finalization; absent after confirmed release. */
|
||||
readonly cleanup?: AnalysisCleanupObservation | undefined;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
redirectedWriteBlocker,
|
||||
type MsixPackageView,
|
||||
} from "./MsixPackageStorage.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
interface ManagedPathStats {
|
||||
readonly uid?: number;
|
||||
@@ -253,7 +254,7 @@ const removeAll = async (
|
||||
];
|
||||
}
|
||||
items.push(await host.removeSkill());
|
||||
if (purgeData) items.push(...(await host.purgeData()));
|
||||
if (purgeData) appendAll(items, await host.purgeData());
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -1175,16 +1176,18 @@ const purgeSelectedData = async (
|
||||
// Resolve selections before a managed namespace removal can delete cwd.
|
||||
// Keep the existing result and removal order after planning those facts.
|
||||
items.unshift(
|
||||
// oxlint-disable-next-line rea/no-bulk-append-spread -- one item per literal name.
|
||||
...(await purgeManagedData(appendCallerPath(home, ".rea"), fileSystem, [
|
||||
"cache",
|
||||
"state",
|
||||
])),
|
||||
);
|
||||
for (const directory of directories)
|
||||
items.push(
|
||||
...(await purgeManagedData(dirname(directory), fileSystem, [
|
||||
appendAll(
|
||||
items,
|
||||
await purgeManagedData(dirname(directory), fileSystem, [
|
||||
basename(directory),
|
||||
])),
|
||||
]),
|
||||
);
|
||||
return items;
|
||||
};
|
||||
@@ -1538,8 +1541,9 @@ const collapseUninstallItems = (
|
||||
for (const next of items) {
|
||||
const { name, actions, ...action } = next;
|
||||
const group = grouped.get(name) ?? [];
|
||||
group.push(
|
||||
...(actions !== undefined && actions.length > 0 ? actions : [action]),
|
||||
appendAll(
|
||||
group,
|
||||
actions !== undefined && actions.length > 0 ? actions : [action],
|
||||
);
|
||||
grouped.set(name, group);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
AnalysisOperation,
|
||||
AnalysisOperationPort,
|
||||
} from "./AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ok, type Result } from "../domain/result.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { EnhancedResult } from "./EnhancedToolTypes.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { WorkflowSnapshotRecordInput } from "./binary/BinarySessionRecords.js";
|
||||
import {
|
||||
@@ -70,21 +70,26 @@ export const composedLimitationBudget = (
|
||||
): ComposedLimitationBudget | undefined =>
|
||||
operation === "decompile_all" ? DECOMPILE_ALL_LIMITATIONS : undefined;
|
||||
|
||||
/** What one composed call answered, or admitted before a failure stopped it. */
|
||||
export interface ComposedWorkflowObservation {
|
||||
readonly result: JsonValue;
|
||||
readonly limitations: readonly string[];
|
||||
readonly cachePolicy?: "live";
|
||||
readonly evidenceLinks?: readonly string[];
|
||||
}
|
||||
|
||||
/** A composed outcome; only a stopped failure carries its admitted observation. */
|
||||
export type ComposedWorkflowResult =
|
||||
AnalysisResultWithSources<ComposedWorkflowObservation> & {
|
||||
readonly partial?: ComposedWorkflowObservation;
|
||||
};
|
||||
|
||||
/** Retain the limits of exactly the observations used by one composed call. */
|
||||
export const executeComposedWorkflow = async (
|
||||
analysis: AnalysisOperationPort,
|
||||
execute: (
|
||||
observed: AnalysisOperationPort,
|
||||
) => Promise<Result<JsonValue, AnalysisError>>,
|
||||
execute: (observed: AnalysisOperationPort) => EnhancedResult,
|
||||
budget?: ComposedLimitationBudget,
|
||||
): Promise<
|
||||
AnalysisResultWithSources<{
|
||||
readonly result: JsonValue;
|
||||
readonly limitations: readonly string[];
|
||||
readonly cachePolicy?: "live";
|
||||
readonly evidenceLinks?: readonly string[];
|
||||
}>
|
||||
> => {
|
||||
): Promise<ComposedWorkflowResult> => {
|
||||
const limitations = new Map<string, SourceLimitation>();
|
||||
const evidenceLinks = new Set<string>();
|
||||
const sourceLedger = new EvidenceLedger();
|
||||
@@ -149,24 +154,32 @@ export const executeComposedWorkflow = async (
|
||||
.then(() => execute(observed))
|
||||
.finally(() => workScope.clear());
|
||||
const sources = sourceLedger.recordsForAnalysis();
|
||||
if (!result.ok) return withEvidenceSources(result, sources);
|
||||
if (!result.ok && !("partialResult" in result))
|
||||
return withEvidenceSources(result, sources);
|
||||
for (const source of sources) evidenceLinks.add(source.evidence_id);
|
||||
const observation = (value: JsonValue): ComposedWorkflowObservation => ({
|
||||
result: value,
|
||||
limitations: [
|
||||
...renderSourceLimitations([...limitations.values()]),
|
||||
...(omittedObservations === 0
|
||||
? []
|
||||
: [
|
||||
`${omittedObservations} per-item provider limitation observation${omittedObservations === 1 ? " was" : "s were"} omitted from this answer; ${budget?.retainedIn}.`,
|
||||
]),
|
||||
],
|
||||
...(cachePolicy === undefined ? {} : { cachePolicy }),
|
||||
...(evidenceLinks.size === 0
|
||||
? {}
|
||||
: { evidenceLinks: [...evidenceLinks].sort(compareUnicodeCodePoints) }),
|
||||
});
|
||||
return withEvidenceSources(
|
||||
ok({
|
||||
result: result.value,
|
||||
limitations: [
|
||||
...renderSourceLimitations([...limitations.values()]),
|
||||
...(omittedObservations === 0
|
||||
? []
|
||||
: [
|
||||
`${omittedObservations} per-item provider limitation observation${omittedObservations === 1 ? " was" : "s were"} omitted from this answer; ${budget?.retainedIn}.`,
|
||||
]),
|
||||
],
|
||||
...(cachePolicy === undefined ? {} : { cachePolicy }),
|
||||
...(evidenceLinks.size === 0
|
||||
? {}
|
||||
: { evidenceLinks: [...evidenceLinks].sort(compareUnicodeCodePoints) }),
|
||||
}),
|
||||
result.ok
|
||||
? ok(observation(result.value))
|
||||
: {
|
||||
ok: false as const,
|
||||
error: result.error,
|
||||
partial: observation(result.partialResult),
|
||||
},
|
||||
sources,
|
||||
);
|
||||
};
|
||||
@@ -206,6 +219,27 @@ export const createWorkflowEvidence = (input: {
|
||||
: {}),
|
||||
});
|
||||
|
||||
/** Retain a stopped workflow's admitted observation as partial workflow Evidence on its failure. */
|
||||
export const retainPartialWorkflowEvidence = (
|
||||
outcome: ComposedWorkflowResult,
|
||||
context: Pick<
|
||||
Parameters<typeof createWorkflowEvidence>[0],
|
||||
"target" | "operation" | "parameters" | "upstreamProfile"
|
||||
>,
|
||||
): void => {
|
||||
if (outcome.ok || outcome.partial === undefined) return;
|
||||
const evidence = createWorkflowEvidence({
|
||||
...context,
|
||||
result: outcome.partial.result,
|
||||
upstreamLimitations: outcome.partial.limitations,
|
||||
...(outcome.partial.evidenceLinks === undefined
|
||||
? {}
|
||||
: { upstreamEvidenceLinks: outcome.partial.evidenceLinks }),
|
||||
});
|
||||
// A partial observation the failing read already reported stays its own.
|
||||
outcome.error.retainPartialObservation(evidence);
|
||||
};
|
||||
|
||||
const batchProcedureLocations = (result: JsonValue): EvidenceLocation[] => {
|
||||
if (
|
||||
result === null ||
|
||||
|
||||
@@ -67,6 +67,38 @@ const view = async (report: NativeLoadImage, request: unknown) => {
|
||||
return analysisViewResultSchema.parse(result.value.normalized_result);
|
||||
};
|
||||
|
||||
it("distinguishes recorded, absent, and historical unrecorded relocation symbols", async () => {
|
||||
const report = source();
|
||||
report.observations.relocations[0]!.symbol = "external_symbol";
|
||||
report.observations.relocations[1]!.symbol = null;
|
||||
const request = { kind: "native-load-image", collection: "relocations" };
|
||||
const current = await view(report, request);
|
||||
if (current.kind !== "native-load-image") throw new Error("Expected page");
|
||||
if (current.items.collection !== "relocations")
|
||||
throw new Error("Expected relocations");
|
||||
expect(current.items.rows).toMatchObject([
|
||||
{ record: { symbol: "external_symbol" } },
|
||||
{ record: { symbol: null } },
|
||||
]);
|
||||
expect(current.unknowns.join(" ")).not.toContain("symbol was not recorded");
|
||||
|
||||
report.observations.relocations[1]!.symbol = "";
|
||||
const emptyName = await view(report, request);
|
||||
if (emptyName.kind !== "native-load-image") throw new Error("Expected page");
|
||||
if (emptyName.items.collection !== "relocations")
|
||||
throw new Error("Expected relocations");
|
||||
expect(emptyName.items.rows?.[1]?.record).toMatchObject({ symbol: "" });
|
||||
expect(emptyName.unknowns.join(" ")).not.toContain("symbol was not recorded");
|
||||
|
||||
delete report.observations.relocations[0]!.symbol;
|
||||
const historical = await view(report, request);
|
||||
if (historical.kind !== "native-load-image") throw new Error("Expected page");
|
||||
expect(historical.items.rows?.[0]?.record).not.toHaveProperty("symbol");
|
||||
expect(historical.unknowns.join(" ")).toContain("symbol was not recorded");
|
||||
const omitted = await view(report, { ...request, fields: ["address"] });
|
||||
expect(omitted.unknowns.join(" ")).not.toContain("symbol was not recorded");
|
||||
});
|
||||
|
||||
it("summarizes measured ELF facts without upgrading unsupported verification", async () => {
|
||||
const report = source();
|
||||
const parent = evidence(report);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { analysisErrorRemediationAction } from "../../domain/analysisErrorPresentation.js";
|
||||
|
||||
/** Profile-resolution truth for one discovered provider candidate. */
|
||||
export interface AnalysisProviderCandidateEvaluation {
|
||||
@@ -76,9 +77,15 @@ export const evaluateAnalysisProviderCandidate = async (
|
||||
},
|
||||
},
|
||||
}
|
||||
: rejectedProfile(candidate, status, error.message, {
|
||||
error_tag: error._tag,
|
||||
});
|
||||
: rejectedProfile(
|
||||
candidate,
|
||||
status,
|
||||
{
|
||||
reason: error.message,
|
||||
remediation: analysisErrorRemediationAction(error),
|
||||
},
|
||||
{ error_tag: error._tag },
|
||||
);
|
||||
return ok({ ...rejection, profileError: error });
|
||||
}
|
||||
if (signalIsAborted(signal))
|
||||
@@ -88,11 +95,10 @@ export const evaluateAnalysisProviderCandidate = async (
|
||||
try {
|
||||
if (resolved.value.profile === null)
|
||||
return ok(
|
||||
rejectedProfile(
|
||||
candidate,
|
||||
status,
|
||||
"Provider version or analysis profile could not be resolved",
|
||||
),
|
||||
rejectedProfile(candidate, status, {
|
||||
reason: "Provider version or analysis profile could not be resolved",
|
||||
remediation: PROFILE_REMEDIATION,
|
||||
}),
|
||||
);
|
||||
const profile = analysisProfileSchema.parse(resolved.value.profile);
|
||||
const identity = candidate.identity();
|
||||
@@ -103,11 +109,10 @@ export const evaluateAnalysisProviderCandidate = async (
|
||||
profile.provider.version !== identity.version)
|
||||
)
|
||||
return ok(
|
||||
rejectedProfile(
|
||||
candidate,
|
||||
status,
|
||||
"Resolved analysis profile has mismatched provider identity",
|
||||
),
|
||||
rejectedProfile(candidate, status, {
|
||||
reason: "Resolved analysis profile has mismatched provider identity",
|
||||
remediation: PROFILE_REMEDIATION,
|
||||
}),
|
||||
);
|
||||
return ok({
|
||||
candidate,
|
||||
@@ -120,11 +125,10 @@ export const evaluateAnalysisProviderCandidate = async (
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
return ok(
|
||||
rejectedProfile(
|
||||
candidate,
|
||||
status,
|
||||
"Provider returned an invalid analysis profile",
|
||||
),
|
||||
rejectedProfile(candidate, status, {
|
||||
reason: "Provider returned an invalid analysis profile",
|
||||
remediation: PROFILE_REMEDIATION,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -132,10 +136,13 @@ export const evaluateAnalysisProviderCandidate = async (
|
||||
const signalIsAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
||||
const PROFILE_REMEDIATION =
|
||||
"Run rea doctor to check this provider's installation and version, then reopen the target, or select another analysis provider.";
|
||||
|
||||
const rejectedProfile = (
|
||||
candidate: AnalysisProviderCandidate,
|
||||
status: AnalysisProviderCandidateStatus,
|
||||
reason: string,
|
||||
rejection: { readonly reason: string; readonly remediation: string },
|
||||
diagnostics: Readonly<Record<string, JsonValue>> = {},
|
||||
): AnalysisProviderCandidateEvaluation => ({
|
||||
candidate,
|
||||
@@ -144,7 +151,7 @@ const rejectedProfile = (
|
||||
availability: {
|
||||
status: "unavailable",
|
||||
code: "version_unresolved",
|
||||
reason,
|
||||
...rejection,
|
||||
diagnostics: jsonObjectSchema.parse({
|
||||
...status.availability.diagnostics,
|
||||
...diagnostics,
|
||||
|
||||
@@ -93,6 +93,7 @@ it("uses one owner readiness snapshot for candidate availability and capabilitie
|
||||
status: "unavailable",
|
||||
code: "runtime_unavailable",
|
||||
reason: "native inspection failed",
|
||||
remediation: "Repair the fixture provider.",
|
||||
diagnostics: { failure_stage: "self-test" },
|
||||
},
|
||||
capabilities: [capability(base.identity())],
|
||||
@@ -462,6 +463,7 @@ describe("analysis provider registry: rejection origins", () => {
|
||||
status: "unavailable",
|
||||
code: "open_options_invalid",
|
||||
reason: "Configured registration is invalid",
|
||||
remediation: "Repair the fixture provider.",
|
||||
diagnostics: { registration_path: "/configured/alpha.json" },
|
||||
}),
|
||||
},
|
||||
@@ -727,6 +729,7 @@ const candidate = (
|
||||
status: "unavailable",
|
||||
code: options.unconfigured ? "not_configured" : "runtime_missing",
|
||||
reason: `${id} runtime is unavailable`,
|
||||
remediation: "Repair the fixture provider.",
|
||||
diagnostics: { executable_path: `/opt/${id}/bin/analyze` },
|
||||
},
|
||||
inspectTargetSupport: (target): ProviderTargetSupport =>
|
||||
|
||||
@@ -914,6 +914,7 @@ const selectedRejection = (
|
||||
origin,
|
||||
code: rejection.code,
|
||||
reason: rejection.reason,
|
||||
...("remediation" in rejection ? { remediation: rejection.remediation } : {}),
|
||||
// ProviderSelectionError owns the single defensive diagnostic copy.
|
||||
diagnostics: rejection.diagnostics,
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
|
||||
import { recordEvidenceSources } from "../investigation/recordEvidenceSources.js";
|
||||
import { BinarySessionRecords } from "./BinarySessionRecords.js";
|
||||
import { binarySessionStatus } from "./BinarySessionStatus.js";
|
||||
import { SessionRuntimeAvailability } from "./SessionRuntimeAvailability.js";
|
||||
import { isSourceEvidenceRetainable } from "./AnalysisSnapshotCache.js";
|
||||
import type { BinarySessionDetail } from "../../contracts/sessionStatusContract.js";
|
||||
import {
|
||||
@@ -197,10 +198,7 @@ export class BinarySession
|
||||
readonly #bundleResourceFactory: BundleResourceClientFactory | undefined;
|
||||
#activeBundleResources: ActiveBundleResourceSession | undefined;
|
||||
#pendingBundleResourceCleanup: ActiveBundleResourceSession[] = [];
|
||||
readonly #runtimeUnavailability = new Map<
|
||||
string,
|
||||
{ readonly reason: string }
|
||||
>();
|
||||
readonly #runtimeAvailability = new SessionRuntimeAvailability();
|
||||
readonly #availabilityListeners = new Set<() => void | Promise<void>>();
|
||||
|
||||
constructor(
|
||||
@@ -1252,7 +1250,7 @@ export class BinarySession
|
||||
}),
|
||||
route: this.#currentRoute(),
|
||||
router: this.#providerRouter,
|
||||
runtimeUnavailability: this.#runtimeUnavailability,
|
||||
runtimeUnavailability: this.#runtimeAvailability.entries,
|
||||
runId: run?.runId,
|
||||
runtimeLineageSnapshots: client?.runtimeLineageSnapshots?.() ?? [],
|
||||
requestActivitySnapshots: client?.requestActivitySnapshots?.() ?? [],
|
||||
@@ -1414,6 +1412,7 @@ export class BinarySession
|
||||
// A failed reply cannot prove that a dispatched metadata write rolled back.
|
||||
// Stop replay before dispatch, including reads overlapping the write.
|
||||
if (capability.effects.mutatesArtifact) this.invalidateSnapshot();
|
||||
const dispatch = this.#runtimeAvailability.dispatch();
|
||||
const call = active.client.execute(name, arguments_, options);
|
||||
const result = unrecordedExecution(await call);
|
||||
const profiled = bindExecutionTarget(
|
||||
@@ -1442,7 +1441,15 @@ export class BinarySession
|
||||
this.#active === active
|
||||
)
|
||||
this.#active.nativeSourceAdmitted = true;
|
||||
this.#observeRuntimeAvailability(name, execution);
|
||||
if (
|
||||
this.#runtimeAvailability.observe(
|
||||
dispatch,
|
||||
capability,
|
||||
active.route.capabilities.values(),
|
||||
execution,
|
||||
)
|
||||
)
|
||||
this.#emitAvailabilityChanged();
|
||||
// Source facts belong to the observation, even when this query cannot be
|
||||
// replayed (for example a Ghidra procedure read without a document selector).
|
||||
if (execution.ok && execution.value.sourceEvidence !== undefined) {
|
||||
@@ -1486,56 +1493,8 @@ export class BinarySession
|
||||
return execution;
|
||||
}
|
||||
|
||||
#observeRuntimeAvailability(
|
||||
operation: AnalysisOperation,
|
||||
result: Result<AnalysisExecution, AnalysisError>,
|
||||
): void {
|
||||
if (result.ok) {
|
||||
if (this.#markRuntimeAvailable(operation))
|
||||
this.#emitAvailabilityChanged();
|
||||
return;
|
||||
}
|
||||
if (result.error._tag === "AnalysisCapabilityUnavailableError") {
|
||||
if (this.#markRuntimeUnavailable(operation, result.error.message))
|
||||
this.#emitAvailabilityChanged();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
[
|
||||
"ProviderAdapterError",
|
||||
"HopperProcessError",
|
||||
"HopperStartError",
|
||||
].includes(result.error._tag)
|
||||
) {
|
||||
const capabilities = this.#active?.route.capabilities;
|
||||
const providerId = capabilities?.get(operation)?.provider.id;
|
||||
let changed = false;
|
||||
for (const descriptor of capabilities?.values() ?? [])
|
||||
if (providerId !== undefined && descriptor.provider.id === providerId)
|
||||
changed =
|
||||
this.#markRuntimeUnavailable(
|
||||
descriptor.operation,
|
||||
`${result.error._tag}: ${result.error.message}`,
|
||||
) || changed;
|
||||
if (changed) this.#emitAvailabilityChanged();
|
||||
}
|
||||
}
|
||||
|
||||
#markRuntimeAvailable(operation: string): boolean {
|
||||
return this.#runtimeUnavailability.delete(operation);
|
||||
}
|
||||
|
||||
#markRuntimeUnavailable(operation: string, reason: string): boolean {
|
||||
const current = this.#runtimeUnavailability.get(operation);
|
||||
if (current?.reason === reason) return false;
|
||||
this.#runtimeUnavailability.set(operation, { reason });
|
||||
return true;
|
||||
}
|
||||
|
||||
#clearRuntimeAvailability(): void {
|
||||
if (this.#runtimeUnavailability.size === 0) return;
|
||||
this.#runtimeUnavailability.clear();
|
||||
this.#emitAvailabilityChanged();
|
||||
if (this.#runtimeAvailability.clear()) this.#emitAvailabilityChanged();
|
||||
}
|
||||
|
||||
#emitAvailabilityChanged(): void {
|
||||
|
||||
@@ -668,7 +668,8 @@ describe("binary session summary projection", () => {
|
||||
availability: {
|
||||
status: "unavailable",
|
||||
code: "not_configured",
|
||||
reason: "Set PROVIDER_B_HOME; see docs/provider-b.md.",
|
||||
reason: "PROVIDER_B_HOME is not set.",
|
||||
remediation: "Set PROVIDER_B_HOME; see docs/provider-b.md.",
|
||||
diagnostics: { checks: [{ name: "configuration", ok: false }] },
|
||||
},
|
||||
targetSupport: {
|
||||
@@ -726,7 +727,7 @@ describe("binary session summary projection", () => {
|
||||
availability: {
|
||||
status: "unavailable",
|
||||
code: "not_configured",
|
||||
reason: "Set PROVIDER_B_HOME; see docs/provider-b.md.",
|
||||
reason: "PROVIDER_B_HOME is not set.",
|
||||
},
|
||||
target_support: {
|
||||
status: "unsupported",
|
||||
@@ -737,6 +738,13 @@ describe("binary session summary projection", () => {
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(raw)).not.toContain("diagnostics");
|
||||
// Complete detail keeps the owner's repair; summary omits it.
|
||||
expect(
|
||||
complete.analysis_provider_candidates[1]?.availability,
|
||||
).toMatchObject({
|
||||
remediation: "Set PROVIDER_B_HOME; see docs/provider-b.md.",
|
||||
});
|
||||
expect(JSON.stringify(raw)).not.toContain("PROVIDER_B_HOME;");
|
||||
expect(
|
||||
analysisProviderCandidate
|
||||
.array()
|
||||
@@ -775,3 +783,68 @@ it("narrows summary capabilities without inventing omitted candidate facts", asy
|
||||
reason: "No target is open.",
|
||||
});
|
||||
});
|
||||
|
||||
it("groups readiness repairs exactly and drops one replaced by a runtime observation", async () => {
|
||||
const unavailable = (
|
||||
operation: CapabilityDescriptor["operation"],
|
||||
remediation: string,
|
||||
): CapabilityDescriptor => {
|
||||
const declared = descriptor(providerA, operation, [], {
|
||||
available: false,
|
||||
reason: "Configured registration is invalid.",
|
||||
});
|
||||
if (declared.available) throw new Error("Expected an unavailable fixture");
|
||||
return { ...declared, remediation };
|
||||
};
|
||||
const status = await project(
|
||||
[
|
||||
unavailable("procedure_info", "Correct REA_FIXTURE_CONFIG."),
|
||||
unavailable("read_bytes", "Correct REA_FIXTURE_CONFIG."),
|
||||
unavailable("list_names", "Unset REA_FIXTURE_CACHE."),
|
||||
unavailable("xrefs", "Correct REA_FIXTURE_CONFIG."),
|
||||
],
|
||||
[],
|
||||
new Map([["xrefs", { reason: "Runtime operation failure" }]]),
|
||||
);
|
||||
const groups = status.capabilities.flatMap(({ groups }) => groups);
|
||||
expect(groups).toEqual([
|
||||
expect.objectContaining({
|
||||
operations: ["list_names"],
|
||||
remediation: "Unset REA_FIXTURE_CACHE.",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
operations: ["procedure_info", "read_bytes"],
|
||||
reason: "Configured registration is invalid.",
|
||||
remediation: "Correct REA_FIXTURE_CONFIG.",
|
||||
}),
|
||||
expect.not.objectContaining({ remediation: expect.anything() }),
|
||||
]);
|
||||
expect(groups[2]).toMatchObject({
|
||||
operations: ["xrefs"],
|
||||
reason: "Runtime operation failure",
|
||||
});
|
||||
const inventory = new Map(
|
||||
buildCapabilityInventory(
|
||||
{ open: true, kind: "executable", capabilities: status.capabilities },
|
||||
{ processCaptureEnabled: true },
|
||||
).map((entry) => [entry.name, entry]),
|
||||
);
|
||||
expect(inventory.get("procedure_info")?.remediation).toBe(
|
||||
"Correct REA_FIXTURE_CONFIG.",
|
||||
);
|
||||
// A static or runtime-only refusal has no separate repair to prefer.
|
||||
expect(inventory.get("xrefs")?.remediation).toBe("Runtime operation failure");
|
||||
// A scoped registration narrows operations without dropping their repair.
|
||||
const scoped = projectedStatusSchema.parse(
|
||||
restrictAdvertisedCapabilities(
|
||||
status,
|
||||
(operation) => operation === "procedure_info",
|
||||
),
|
||||
);
|
||||
expect(scoped.capabilities.flatMap(({ groups }) => groups)).toEqual([
|
||||
expect.objectContaining({
|
||||
operations: ["procedure_info"],
|
||||
remediation: "Correct REA_FIXTURE_CONFIG.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -93,11 +93,7 @@ export const binarySessionStatus = async ({
|
||||
const unavailable = runtimeUnavailability.get(descriptor.operation);
|
||||
return unavailable === undefined
|
||||
? descriptor
|
||||
: {
|
||||
...descriptor,
|
||||
available: false as const,
|
||||
reason: unavailable.reason,
|
||||
};
|
||||
: runtimeUnavailable(descriptor, unavailable.reason);
|
||||
});
|
||||
const capabilities = providerCapabilitySets(routeCapabilities);
|
||||
const analysisProviderBinding =
|
||||
@@ -178,6 +174,19 @@ export const binarySessionStatus = async ({
|
||||
};
|
||||
};
|
||||
|
||||
/** A runtime observation replaces the declared state and its repair instruction. */
|
||||
const runtimeUnavailable = (
|
||||
descriptor: CapabilityDescriptor,
|
||||
reason: string,
|
||||
): CapabilityDescriptor => {
|
||||
if (descriptor.available) {
|
||||
const { availabilityCode: _available, ...declared } = descriptor;
|
||||
return { ...declared, available: false, reason };
|
||||
}
|
||||
const { remediation: _superseded, ...declared } = descriptor;
|
||||
return { ...declared, reason };
|
||||
};
|
||||
|
||||
/** Capability surfaces a registration may narrow without hiding provider facts. */
|
||||
const advertisedCapabilitySurfaces = z.object({
|
||||
capabilities: z.array(providerCapabilitySet),
|
||||
@@ -338,7 +347,11 @@ type CapabilityProjection = Omit<
|
||||
"operation" | "available" | "reason"
|
||||
> & { readonly operation: string } & (
|
||||
| { readonly available: true; readonly reason: null }
|
||||
| { readonly available: false; readonly reason: string }
|
||||
| {
|
||||
readonly available: false;
|
||||
readonly reason: string;
|
||||
readonly remediation?: string;
|
||||
}
|
||||
);
|
||||
type ProviderCapabilityGroup = ProviderCapabilitySet["groups"][number];
|
||||
|
||||
@@ -388,6 +401,9 @@ export const providerCapabilitySets = (
|
||||
available: false as const,
|
||||
reason: descriptor.reason,
|
||||
availability_code: availabilityCode,
|
||||
...(descriptor.remediation === undefined
|
||||
? {}
|
||||
: { remediation: descriptor.remediation }),
|
||||
};
|
||||
const key = JSON.stringify([
|
||||
effects,
|
||||
@@ -395,6 +411,7 @@ export const providerCapabilitySets = (
|
||||
status.available,
|
||||
status.reason,
|
||||
status.availability_code,
|
||||
status.available ? null : (status.remediation ?? null),
|
||||
]);
|
||||
const group = groups.get(key);
|
||||
if (group === undefined)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import type { CapabilityDescriptor } from "../AnalysisProvider.js";
|
||||
|
||||
/** Failures of the provider adapter or process rather than of one request. */
|
||||
const PROVIDER_WIDE_FAILURES: ReadonlySet<string> = new Set([
|
||||
"ProviderAdapterError",
|
||||
"HopperProcessError",
|
||||
"HopperStartError",
|
||||
]);
|
||||
|
||||
interface Failure {
|
||||
readonly dispatch: number;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
interface ProviderFailure extends Failure {
|
||||
readonly operation: string;
|
||||
}
|
||||
|
||||
/** Newest failure and newest success, both by dispatch, for one owner. */
|
||||
interface Outcomes<F extends Failure> {
|
||||
failure: F | undefined;
|
||||
succeeded: number;
|
||||
}
|
||||
|
||||
type RoutedOperation = Pick<
|
||||
CapabilityDescriptor,
|
||||
"operation" | "provider" | "available"
|
||||
>;
|
||||
|
||||
/** Reason recorded for an operation's own refusal or provider-wide failure. */
|
||||
export const runtimeFailureReason = (
|
||||
failure: AnalysisError,
|
||||
): string | undefined => {
|
||||
if (failure._tag === "AnalysisCapabilityUnavailableError")
|
||||
return failure.message;
|
||||
return PROVIDER_WIDE_FAILURES.has(failure._tag)
|
||||
? `${failure._tag}: ${failure.message}`
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Explain unavailability inferred for an operation no request failed for. */
|
||||
export const inferredUnavailabilityReason = (
|
||||
providerId: string,
|
||||
failure: Pick<ProviderFailure, "operation" | "reason">,
|
||||
): string =>
|
||||
`Inferred from the ${failure.operation} failure (${failure.reason}). Retry after repairing the provider; a later successful ${providerId} request clears this.`;
|
||||
|
||||
/**
|
||||
* Own the runtime unavailability overlaid on the active route's declared
|
||||
* capabilities.
|
||||
*
|
||||
* An operation is unavailable while its newest refusal or provider-wide
|
||||
* failure was dispatched after its newest success. A declared-available
|
||||
* operation is also unavailable, as inferred, while its provider's newest
|
||||
* provider-wide failure was dispatched after any success from that provider.
|
||||
* One success therefore returns inferred siblings to their declared
|
||||
* availability without proving each is healthy, and keeps failures observed
|
||||
* for other operations.
|
||||
*
|
||||
* Only the newest dispatch of each outcome matters, so the projection does not
|
||||
* depend on the order in which overlapping replies complete. Dispatch is the
|
||||
* latest order this session owns; it does not establish how a provider
|
||||
* interleaves overlapping requests.
|
||||
*/
|
||||
export class SessionRuntimeAvailability {
|
||||
readonly #operations = new Map<string, Outcomes<Failure>>();
|
||||
readonly #providers = new Map<string, Outcomes<ProviderFailure>>();
|
||||
#projected: ReadonlyMap<string, { readonly reason: string }> = new Map();
|
||||
#dispatched = 0;
|
||||
|
||||
/** Current runtime unavailability keyed by operation. */
|
||||
get entries(): ReadonlyMap<string, { readonly reason: string }> {
|
||||
return this.#projected;
|
||||
}
|
||||
|
||||
/** Order one request immediately before it reaches the provider client. */
|
||||
dispatch(): number {
|
||||
this.#dispatched += 1;
|
||||
return this.#dispatched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one dispatched request's outcome against the route it ran on and
|
||||
* report whether visible availability changed.
|
||||
*/
|
||||
observe(
|
||||
dispatch: number,
|
||||
capability: RoutedOperation,
|
||||
route: Iterable<RoutedOperation>,
|
||||
result: Result<unknown, AnalysisError>,
|
||||
): boolean {
|
||||
const operation = outcomes(this.#operations, capability.operation);
|
||||
const provider = outcomes(this.#providers, capability.provider.id);
|
||||
if (result.ok) {
|
||||
operation.succeeded = Math.max(operation.succeeded, dispatch);
|
||||
provider.succeeded = Math.max(provider.succeeded, dispatch);
|
||||
// A success can only remove unavailability.
|
||||
if (this.#projected.size === 0) return false;
|
||||
} else {
|
||||
const reason = runtimeFailureReason(result.error);
|
||||
if (reason === undefined) return false;
|
||||
operation.failure = newer(operation.failure, { dispatch, reason });
|
||||
if (PROVIDER_WIDE_FAILURES.has(result.error._tag))
|
||||
provider.failure = newer(provider.failure, {
|
||||
dispatch,
|
||||
reason,
|
||||
operation: capability.operation,
|
||||
});
|
||||
}
|
||||
return this.#project(route);
|
||||
}
|
||||
|
||||
/** Forget runtime outcomes when the active provider client is replaced. */
|
||||
clear(): boolean {
|
||||
this.#operations.clear();
|
||||
this.#providers.clear();
|
||||
if (this.#projected.size === 0) return false;
|
||||
this.#projected = new Map();
|
||||
return true;
|
||||
}
|
||||
|
||||
#project(route: Iterable<RoutedOperation>): boolean {
|
||||
const projected = new Map<string, { readonly reason: string }>();
|
||||
for (const descriptor of route) {
|
||||
const reason = this.#unavailableReason(descriptor);
|
||||
if (reason !== undefined) projected.set(descriptor.operation, { reason });
|
||||
}
|
||||
const changed =
|
||||
projected.size !== this.#projected.size ||
|
||||
[...projected].some(
|
||||
([operation, { reason }]) =>
|
||||
this.#projected.get(operation)?.reason !== reason,
|
||||
);
|
||||
this.#projected = projected;
|
||||
return changed;
|
||||
}
|
||||
|
||||
#unavailableReason(descriptor: RoutedOperation): string | undefined {
|
||||
const operation = this.#operations.get(descriptor.operation);
|
||||
if (
|
||||
operation?.failure !== undefined &&
|
||||
operation.failure.dispatch > operation.succeeded
|
||||
)
|
||||
return operation.failure.reason;
|
||||
// A declared refusal already explains its operation and is never dispatched.
|
||||
if (!descriptor.available) return undefined;
|
||||
const provider = this.#providers.get(descriptor.provider.id);
|
||||
return provider?.failure !== undefined &&
|
||||
provider.failure.dispatch > provider.succeeded
|
||||
? inferredUnavailabilityReason(descriptor.provider.id, provider.failure)
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const outcomes = <F extends Failure>(
|
||||
owners: Map<string, Outcomes<F>>,
|
||||
key: string,
|
||||
): Outcomes<F> => {
|
||||
const existing = owners.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const created: Outcomes<F> = { failure: undefined, succeeded: 0 };
|
||||
owners.set(key, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const newer = <F extends Failure>(current: F | undefined, next: F): F =>
|
||||
current !== undefined && current.dispatch > next.dispatch ? current : next;
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
parseViewEvidence,
|
||||
} from "./environmentSource.js";
|
||||
import { prepareServedState } from "./environmentState.js";
|
||||
import { appendAll } from "../../domain/arrayAppend.js";
|
||||
|
||||
/** Operation name the build command's own refusals report; each stage keeps its own. */
|
||||
export const ENVIRONMENT_BUILD_OPERATION = "environment-build";
|
||||
@@ -318,10 +319,8 @@ export const buildEnvironment = async (
|
||||
name,
|
||||
operation: ENVIRONMENT_EXPORT_OPERATION,
|
||||
});
|
||||
warnings.push(
|
||||
...credentialScanWarnings(recorded.value),
|
||||
...savedCredentialWarnings(capture.parameters),
|
||||
);
|
||||
appendAll(warnings, credentialScanWarnings(recorded.value));
|
||||
appendAll(warnings, savedCredentialWarnings(capture.parameters));
|
||||
|
||||
const packageSource: EnvironmentPackageSource =
|
||||
input.packageTarball === undefined
|
||||
|
||||
@@ -2,19 +2,26 @@ import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
selectEvidenceRecords,
|
||||
summarizeEvidenceBundle,
|
||||
type EvidenceBundleSummary,
|
||||
type EvidenceRecordFilters,
|
||||
type EvidenceRecordSelection,
|
||||
} from "../../domain/evidenceRecordSummary.js";
|
||||
|
||||
/** Select complete bundle delivery or discovery metadata without analysis payloads. */
|
||||
/** Select complete bundle delivery, discovery metadata, or a page of complete selected records. */
|
||||
export const inspectEvidenceBundle = (
|
||||
bundle: EvidenceBundle,
|
||||
options: {
|
||||
readonly detail: "complete" | "summary";
|
||||
readonly detail: "complete" | "summary" | "records";
|
||||
readonly filters?: EvidenceRecordFilters | undefined;
|
||||
readonly offset?: number | undefined;
|
||||
readonly limit?: number | undefined;
|
||||
},
|
||||
): Result<EvidenceBundle | EvidenceBundleSummary, AnalysisInputError> => {
|
||||
): Result<
|
||||
EvidenceBundle | EvidenceBundleSummary | EvidenceRecordSelection,
|
||||
AnalysisInputError
|
||||
> => {
|
||||
if (options.detail === "complete" && options.filters !== undefined)
|
||||
return err(
|
||||
new AnalysisInputError("get_evidence_bundle", undefined, [
|
||||
@@ -22,11 +29,36 @@ export const inspectEvidenceBundle = (
|
||||
path: ["filters"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Record filters require detail: summary; complete returns the canonical bundle.",
|
||||
expected: "detail: summary",
|
||||
"Record filters require detail: summary or records; complete returns the canonical bundle.",
|
||||
expected: "detail: summary or records",
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (options.detail !== "records") {
|
||||
const paging = (["offset", "limit"] as const).filter(
|
||||
(field) => options[field] !== undefined,
|
||||
);
|
||||
if (paging.length > 0)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
"get_evidence_bundle",
|
||||
undefined,
|
||||
paging.map((field) => ({
|
||||
path: [field],
|
||||
reason: "invalid_value" as const,
|
||||
message: `${field} pages complete records and requires detail: records.`,
|
||||
expected: "detail: records",
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (options.detail === "records")
|
||||
return ok(
|
||||
selectEvidenceRecords(bundle, options.filters, {
|
||||
offset: options.offset,
|
||||
limit: options.limit,
|
||||
}),
|
||||
);
|
||||
return ok(
|
||||
options.detail === "summary"
|
||||
? summarizeEvidenceBundle(bundle, options.filters)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { uniqueSorted } from "../../domain/canonicalOrdering.js";
|
||||
import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import { ok, type Result } from "../../domain/result.js";
|
||||
import { appendAll } from "../../domain/arrayAppend.js";
|
||||
|
||||
/** Follow exact session references without exporting unrelated records or Unknowns. */
|
||||
export const resolveInvestigationEvidenceClosure = (
|
||||
@@ -30,7 +31,7 @@ export const resolveInvestigationEvidenceClosure = (
|
||||
);
|
||||
if (!evidence.ok) return evidence;
|
||||
visited.add(evidenceId);
|
||||
pending.push(...evidence.value.evidence_links);
|
||||
appendAll(pending, evidence.value.evidence_links);
|
||||
}
|
||||
return ok(uniqueSorted([...visited]));
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
addSemanticGraphUnknown,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import { appendAll } from "../../domain/arrayAppend.js";
|
||||
|
||||
/** Connect retained reads to writes without claiming assignment values or order. */
|
||||
export function* projectSemanticAssignments(
|
||||
@@ -35,8 +36,9 @@ export function* projectSemanticAssignments(
|
||||
);
|
||||
// Compound assignments consume the previous value as well as the RHS.
|
||||
if (flow.operator !== "=")
|
||||
reads.push(
|
||||
...readsWithin(flow.target).filter((node) =>
|
||||
appendAll(
|
||||
reads,
|
||||
readsWithin(flow.target).filter((node) =>
|
||||
sourceRangesEqual(node.identity.source_range, flow.target),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -91,6 +91,7 @@ import type {
|
||||
} from "../../domain/javascript/javascriptSemanticFileProjection.js";
|
||||
import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||
import { projectElectronSemanticCallPaths } from "./JavaScriptSemanticGraphElectronProjection.js";
|
||||
import { appendAll } from "../../domain/arrayAppend.js";
|
||||
|
||||
type ApplicationGraph = Pick<JavaScriptApplicationGraph, "graph_id" | "nodes">;
|
||||
|
||||
@@ -276,7 +277,7 @@ export const createJavaScriptSemanticGraphProjection = (
|
||||
unknownCeiling,
|
||||
unknownsBefore + fileUnknownCeiling,
|
||||
);
|
||||
fingerprints.push(...(yield* projectFileSteps(file, ir, state, paths)));
|
||||
appendAll(fingerprints, yield* projectFileSteps(file, ir, state, paths));
|
||||
// Nodes were dropped only if the budget actually blocked creation. A file
|
||||
// that exactly fills its share ends with a zero budget without dropping
|
||||
// anything, so the remaining budget alone cannot decide truncation.
|
||||
@@ -453,7 +454,7 @@ export const createJavaScriptSemanticGraphProjection = (
|
||||
for (const unknown of newUnknowns)
|
||||
state.unknowns.set(unknown.unknown_id, unknown);
|
||||
for (const root of projection.roots) state.roots.add(root);
|
||||
fingerprints.push(...projection.fingerprints);
|
||||
appendAll(fingerprints, projection.fingerprints);
|
||||
if (projection.nodesTruncated) {
|
||||
truncatedFiles += 1;
|
||||
noteSemanticCeilingReached(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
nativeCallClassificationSchema,
|
||||
type NativeCallReference,
|
||||
} from "../../domain/native/nativeCallReference.js";
|
||||
import { appendAll } from "../../domain/arrayAppend.js";
|
||||
|
||||
/** Detailed or partial observations and the untyped fallback share one owner. */
|
||||
export const NATIVE_CALL_ROUTE_SOURCES = {
|
||||
@@ -140,8 +141,9 @@ export const readNativeCallRoutes = async (
|
||||
outcome,
|
||||
});
|
||||
if (execution.ok) {
|
||||
limitations.push(
|
||||
...limitationsFrom(
|
||||
appendAll(
|
||||
limitations,
|
||||
limitationsFrom(
|
||||
NATIVE_CALL_ROUTE_SOURCES.references,
|
||||
execution.value.provider,
|
||||
execution.value.limitations,
|
||||
@@ -168,14 +170,15 @@ export const readNativeCallRoutes = async (
|
||||
// Missing detailed reference flags do not invalidate observed targetless
|
||||
// calls. Keep their producing provider even if another provider supplies
|
||||
// the fallback callee inventory.
|
||||
unknowns.push(
|
||||
...parsed.data.unresolved_calls.map((call) => ({
|
||||
appendAll(
|
||||
unknowns,
|
||||
parsed.data.unresolved_calls.map((call) => ({
|
||||
...call,
|
||||
provider: execution.value.provider,
|
||||
})),
|
||||
);
|
||||
const references = parsed.data.references;
|
||||
routes.push(...observedCallRoutes(references, execution.value.provider));
|
||||
appendAll(routes, observedCallRoutes(references, execution.value.provider));
|
||||
if (
|
||||
references.every(({ kind }) => kind.available) &&
|
||||
(parsed.data.reference_kinds_available || references.length > 0)
|
||||
@@ -189,8 +192,9 @@ export const readNativeCallRoutes = async (
|
||||
options,
|
||||
);
|
||||
if (!callees.ok) return finish(err(callees.error));
|
||||
limitations.push(
|
||||
...limitationsFrom(
|
||||
appendAll(
|
||||
limitations,
|
||||
limitationsFrom(
|
||||
NATIVE_CALL_ROUTE_SOURCES.callees,
|
||||
callees.value.provider,
|
||||
callees.value.limitations,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
nativeInvestigationTraceSchema,
|
||||
traceNativeInvestigationGraph,
|
||||
joinInterfaceBuilderDispatch,
|
||||
type NativeInvestigationGraph,
|
||||
} from "../../domain/native/nativeInvestigationGraph.js";
|
||||
import { nativeDispatchMetadataResultSchema } from "../../domain/native/objcSwiftMetadata.js";
|
||||
import { inspectNativeDispatch } from "./NativeDispatchMetadataInspection.js";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { appendAll } from "../../domain/arrayAppend.js";
|
||||
|
||||
type Input = z.output<typeof enhancedInputSchemas.trace_native_ui_action>;
|
||||
|
||||
@@ -91,7 +93,7 @@ export const traceNativeUiAction = async (
|
||||
);
|
||||
|
||||
let graph = emptyGraph(targetSha256, metadata.provider);
|
||||
const coverage = [];
|
||||
const coverage: NativeInvestigationGraph["coverage"][number][] = [];
|
||||
if (uiExecution.ok) {
|
||||
const decoded = interfaceBuilderAnalysisSchema.safeParse(
|
||||
uiExecution.value.result,
|
||||
@@ -194,7 +196,7 @@ export const traceNativeUiAction = async (
|
||||
}
|
||||
}
|
||||
if (selected.nodeId === null) {
|
||||
coverage.push(...graph.coverage);
|
||||
appendAll(coverage, graph.coverage);
|
||||
const trace = {
|
||||
target_sha256: graph.target_sha256,
|
||||
provider: graph.provider,
|
||||
@@ -458,7 +460,7 @@ const expandNativeCalls = async (input: {
|
||||
"provider",
|
||||
);
|
||||
examined += 1;
|
||||
sourceLimitations.push(...execution.limitations);
|
||||
appendAll(sourceLimitations, execution.limitations);
|
||||
if (
|
||||
!execution.outcome.ok &&
|
||||
(execution.outcome.error._tag === "AnalysisCancelledError" ||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type SourceLimitation,
|
||||
} from "../SourceLimitations.js";
|
||||
import { createValueTraceBudget } from "./NativeValueTraceBudget.js";
|
||||
import { appendAll } from "../../domain/arrayAppend.js";
|
||||
|
||||
/** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */
|
||||
export const traceNativeValues = async (
|
||||
@@ -277,8 +278,9 @@ export const traceNativeValues = async (
|
||||
),
|
||||
),
|
||||
);
|
||||
sourceLimitations.push(
|
||||
...targets.data.limitations.map((limitation) => ({
|
||||
appendAll(
|
||||
sourceLimitations,
|
||||
targets.data.limitations.map((limitation) => ({
|
||||
provider: resolved.value.provider,
|
||||
operation: "resolve_native_call_targets" as const,
|
||||
limitation,
|
||||
|
||||
@@ -73,6 +73,7 @@ export const ownedProcessPrerequisiteAvailability = (
|
||||
status: "unavailable",
|
||||
code: "runtime_unavailable",
|
||||
reason: `${refusal.reason}. ${REMEDIATION}`,
|
||||
remediation: REMEDIATION,
|
||||
diagnostics: {
|
||||
stage: "ownership_preparation",
|
||||
prerequisite_provider_id: refusal.providerId,
|
||||
@@ -85,6 +86,7 @@ export const ownedProcessPrerequisiteAvailability = (
|
||||
status: "unavailable",
|
||||
code: "runtime_unavailable",
|
||||
reason: `${darwin.message}. ${DARWIN_REMEDIATION}`,
|
||||
remediation: DARWIN_REMEDIATION,
|
||||
diagnostics: {
|
||||
stage: "ownership_preparation",
|
||||
...(darwin.compilerOutcome === undefined
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
AnalysisPartialObservation,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
const RETAINED_METADATA_BYTES = 32 * 1024 * 1024;
|
||||
/**
|
||||
@@ -293,7 +294,7 @@ const scanDirectory = async (
|
||||
if (coverage.examinedEntries !== undefined)
|
||||
entries = coverage.examinedEntries;
|
||||
if (coverage.status !== "complete") partial = true;
|
||||
limitations.push(...coverage.limitations);
|
||||
appendAll(limitations, coverage.limitations);
|
||||
},
|
||||
})) {
|
||||
entries++;
|
||||
@@ -318,7 +319,7 @@ const scanDirectory = async (
|
||||
continue;
|
||||
}
|
||||
if (conventions === undefined) continue;
|
||||
row.facts.push(...conventions.facts);
|
||||
appendAll(row.facts, conventions.facts);
|
||||
const managedRoot = conventions.unityManagedDirectory;
|
||||
if (!probeSelected(entry)) continue;
|
||||
if (budget.bytes >= budget.maximum) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { AnalysisCleanupLease } from "./AnalysisCleanupLease.js";
|
||||
import { decodeDirectoryEntryName } from "../filesystem/decodeDirectoryEntryName.js";
|
||||
import { resolveArtifactSourcePath } from "./ArtifactSelectedSource.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
export type DirectoryArtifactEntry = ArtifactEntry & {
|
||||
readonly kind: "file" | "directory" | "symlink";
|
||||
@@ -298,7 +299,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
}
|
||||
if (traversal.kind === "failed") throw traversal.cause;
|
||||
childDirectories.reverse();
|
||||
pending.push(...childDirectories);
|
||||
appendAll(pending, childDirectories);
|
||||
}
|
||||
|
||||
async #finishDirectory(
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ArtifactReaderFailure,
|
||||
artifactFixedLimitFailure,
|
||||
} from "../ArtifactReader.js";
|
||||
import { appendAll } from "../../domain/arrayAppend.js";
|
||||
|
||||
/** Exact trailer written by Bun's standalone module graph serializer. */
|
||||
export const BUN_TRAILER = Buffer.from("\n---- Bun! ----\n");
|
||||
@@ -502,8 +503,9 @@ const machOClaims = async (
|
||||
if (!read.ok) break;
|
||||
const { command: cmd, offset, size } = read.value;
|
||||
if (cmd === LC_SEGMENT_64 || cmd === LC_SEGMENT)
|
||||
claims.push(
|
||||
...segmentClaims(
|
||||
appendAll(
|
||||
claims,
|
||||
segmentClaims(
|
||||
commands.subarray(offset, offset + size),
|
||||
littleEndian,
|
||||
cmd === LC_SEGMENT_64,
|
||||
@@ -577,14 +579,9 @@ const fatClaims = async (
|
||||
for (const slice of parsed.slices) {
|
||||
abortIfNeeded(signal);
|
||||
if (parsed.rangeIssues[slice.index] !== null) continue;
|
||||
claims.push(
|
||||
...(await machOClaims(
|
||||
handle,
|
||||
fileSize,
|
||||
slice.offset,
|
||||
slice.size,
|
||||
signal,
|
||||
)),
|
||||
appendAll(
|
||||
claims,
|
||||
await machOClaims(handle, fileSize, slice.offset, slice.size, signal),
|
||||
);
|
||||
}
|
||||
return claims;
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
observeNodeSeaFuseMatches,
|
||||
type NodeSeaFuseObservation,
|
||||
} from "./NodeSeaBlob.js";
|
||||
import { appendAll } from "../../domain/arrayAppend.js";
|
||||
|
||||
const LC_SEGMENT = 0x1;
|
||||
const LC_SEGMENT_64 = 0x19;
|
||||
@@ -226,7 +227,7 @@ const locateFatMachO = async (
|
||||
const found: BlobRange[] = [];
|
||||
for (const [index, slice] of header.slices.entries()) {
|
||||
if (slice === undefined || header.rangeIssues[index] != null) continue;
|
||||
found.push(...(await locateThinMachO(carrier, slice.offset, slice.size)));
|
||||
appendAll(found, await locateThinMachO(carrier, slice.offset, slice.size));
|
||||
}
|
||||
return found;
|
||||
};
|
||||
@@ -396,7 +397,7 @@ const locateElfNotes = async (
|
||||
found.length > 0,
|
||||
);
|
||||
if (scanned === undefined) opaque = true;
|
||||
else found.push(...scanned);
|
||||
else appendAll(found, scanned);
|
||||
}
|
||||
// The runtime locates the blob through PT_NOTE segments. Injectors can
|
||||
// leave stale or misaligned SHT_NOTE entries, so section notes are only a
|
||||
@@ -444,7 +445,7 @@ const locateElfNotes = async (
|
||||
found.length > 0,
|
||||
);
|
||||
if (scanned === undefined) opaque = true;
|
||||
else found.push(...scanned);
|
||||
else appendAll(found, scanned);
|
||||
}
|
||||
if (found.length > 0 && opaque)
|
||||
throw formatFailure(NODE_SEA_ELF_TABLE_TRUNCATED);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
XHTML_NAMESPACE,
|
||||
} from "./CdpCaptureDomNamespaces.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
export type CapturedResource = Omit<
|
||||
WebPageInspection["resources"][number],
|
||||
@@ -403,8 +404,8 @@ export function* captureDom(
|
||||
pendingMetadata,
|
||||
completeness,
|
||||
);
|
||||
urls.push(...metadata.urls);
|
||||
agentHints.push(...metadata.agentHints);
|
||||
appendAll(urls, metadata.urls);
|
||||
appendAll(agentHints, metadata.agentHints);
|
||||
excludedUrls += metadata.excludedUrls;
|
||||
unresolvedSvgNodes += metadata.unresolvedSvgNodes;
|
||||
pendingMetadata.length = 0;
|
||||
@@ -412,8 +413,8 @@ export function* captureDom(
|
||||
}
|
||||
if (pendingMetadata.length > 0) {
|
||||
const metadata = yield* captureDomMetadata(pendingMetadata, completeness);
|
||||
urls.push(...metadata.urls);
|
||||
agentHints.push(...metadata.agentHints);
|
||||
appendAll(urls, metadata.urls);
|
||||
appendAll(agentHints, metadata.agentHints);
|
||||
excludedUrls += metadata.excludedUrls;
|
||||
unresolvedSvgNodes += metadata.unresolvedSvgNodes;
|
||||
pendingMetadata.length = 0;
|
||||
@@ -499,7 +500,7 @@ function* captureDomMetadata(
|
||||
);
|
||||
}
|
||||
}
|
||||
agentHints.push(...metadata.agentHints);
|
||||
appendAll(agentHints, metadata.agentHints);
|
||||
}
|
||||
return { urls, agentHints, unresolvedSvgNodes, excludedUrls };
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
type CapturedScript,
|
||||
type NetworkState,
|
||||
} from "./CdpCaptureEventTypes.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
export const handleExecutionContextCreated = (
|
||||
state: CdpCaptureEventsState,
|
||||
@@ -434,7 +435,7 @@ export const handleResponseReceived = (
|
||||
state.allowedOrigins,
|
||||
);
|
||||
state.responseMetadata.push(metadata.response);
|
||||
state.agentHints.push(...metadata.agentHints);
|
||||
appendAll(state.agentHints, metadata.agentHints);
|
||||
};
|
||||
|
||||
export const handleLoadingFinished = (
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
scenarioCommandTimeoutMs,
|
||||
withScenarioCommandDeadline,
|
||||
} from "./ScenarioCommandDeadline.js";
|
||||
import { appendAll } from "../domain/arrayAppend.js";
|
||||
|
||||
const breakpointResponse = z.object({ breakpointId: z.string() });
|
||||
const scriptIdentifierResponse = z.object({ identifier: z.string() });
|
||||
@@ -281,8 +282,9 @@ export class PlaywrightScenarioStorage {
|
||||
): void {
|
||||
for (const { origin, entries } of blocks) {
|
||||
const seed = this.#seeds.get(origin) ?? { local: [], session: [] };
|
||||
seed[kind].push(
|
||||
...entries.map(({ name, value }): [string, string] => [
|
||||
appendAll(
|
||||
seed[kind],
|
||||
entries.map(({ name, value }): [string, string] => [
|
||||
name,
|
||||
secrets.value(value),
|
||||
]),
|
||||
@@ -553,7 +555,7 @@ export class PlaywrightScenarioStorage {
|
||||
if (origin !== undefined && origin !== "") return origin;
|
||||
break;
|
||||
}
|
||||
pending.push(...(current.childFrames ?? []));
|
||||
appendAll(pending, current.childFrames ?? []);
|
||||
}
|
||||
throw new Error(
|
||||
"The browser did not establish the private storage seed frame's security origin.",
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
WEB_NETWORK_CAPTURE_LIMITS,
|
||||
webNetworkCapturePayloadSchema,
|
||||
} from "../../domain/webNetworkCapture.js";
|
||||
import { appendAll } from "../../domain/arrayAppend.js";
|
||||
|
||||
const replySchema = z.discriminatedUnion("ok", [
|
||||
z.strictObject({
|
||||
@@ -160,5 +161,5 @@ const excludeNormalizedNumbers = (
|
||||
projection.numeric_literals = projection.numeric_literals.filter(
|
||||
(number) => !excluded.has(number.pointer),
|
||||
);
|
||||
projection.redactions.push(...redaction.redactions);
|
||||
appendAll(projection.redactions, redaction.redactions);
|
||||
};
|
||||
Loaded 100 of 264 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user