Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 3b9b461924 fix(lint): see through type-only wrappers around bulk-append callees
rea/no-bulk-append-spread unwrapped only optional chains, so
`target.push!(...items)`, `(target.push as T)(...items)`, satisfies and
angle-bracket assertions reached the call with a non-member callee and
were not reported. Strip TypeScript wrapper and grouping nodes before
matching the method, and cover those forms and wrapped receivers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:03:52 +00:00
Kaoru0822 7d5a459987 Merge remote-tracking branch 'origin/main' into fix/8545-bulk-append-overflow 2026-11-01 17:02:31 +00:00
Kaoru0822andClaude Opus 5.5 85013da0ce fix(cli): describe one-shot timeouts for an invocation whose session has ended (#8535)
* fix(cli): describe one-shot timeouts for an invocation whose session has ended

A one-shot CLI analysis that reached a provider deadline told its caller
that the Ghidra session remained usable and reported a runtime-editable
limit applying to the next operation, although the command had already
released its provider session and runtime.

The shared error projection now takes an explicit caller lifetime.
Providers keep guidance that holds only for a continuing caller (session
reuse, same-session runtime updates) apart from the request outcome, and
the presentation adds it only for a continuing caller. One-shot owners
(runDirectAnalysis, runProviderAnalysis, finishAnalysisCommand and the
JavaScript application command) project for the invocation: details.limit
reports scope rea-invocation, applies_at next_invocation and the launch
environment seed, and the remediation leads with that setting for a new
invocation. MCP output keeps its runtime-update and same-session guidance.

Covers the Ghidra decompile timeout, decompiler failure, regex stack
exhaustion and startup timeout, the shared regex deadline, the Kuna
decompile timeout and the Hopper startup timeout.

Fixes #8311

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(cli): project composed child failures for the caller lifetime

Cleanup composition embedded its primary and cleanup failures as
continuing-caller projections, and baked the primary's message and the
cleanup's remediation into the composed error. A one-shot command whose
Ghidra startup timed out while shutdown also failed therefore still
reported a rea-runtime limit and update_analysis_settings in
details.diagnostics.primary_error.

childFailureDiagnostics now snapshots each embedded child at composition
for both lifetimes when its projection depends on the caller, so cleanup
history keeps its composition-time state and the final projection selects
the caller's form. composedCallerGuidance re-attributes message and
remediation with their continuing-only parts kept separate. Error copies
and redaction keep the invocation diagnostics. The Hopper startup timeout
now carries its continuing guidance on the error like the other producers.

Invocation limits keep agent_editable and rea_restart_required, with
values that hold for a new invocation, so only scope and applies_at
change and environment is added.

In existing-project mode a decompile timeout or a cancelled session now
says the next session copies and opens REA_GHIDRA_PROJECT_PATH again
rather than repeating import and analysis.

Tests compare delivered guidance with the owning formatters and constants
instead of freezing prose.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-01 08:57:32 -08:00
Kaoru0822andClaude Opus 5.5 cbc69537cd fix(discovery): carry provider repair instructions into capability discovery (#8532)
* fix(discovery): carry provider repair instructions into capability discovery

Provider readiness reported only a failure description, so capability
discovery copied that description into tool_availability remediation and
dropped repairs the owners already had: the JEB discovery-deadline setting,
the ADB/Apktool execute-permission repair kept in diagnostics, and the IDA
and Kuna configuration recovery.

Unavailable ProviderAvailability now requires the owner's remediation
next to its reason, and readiness-derived unavailable capability
descriptors carry it too. Every producer supplies it (ADB, Apktool, JADX,
JEB, Ghidra, Hopper, IDA, Kuna, pcap, owned-process prerequisites, profile
rejection); the inventory uses it verbatim, falling back to the reason
only for static capability facts that declare no repair. ADB/Apktool
configuration failures own remediation as a field instead of diagnostics,
and execution refusals project it as remediation.action. JADX doctor reads
the availability's remediation instead of re-deriving it.

binary_session complete candidate availability and unavailable capability
groups expose remediation (CLI capabilities/providers too); summary omits
it and lists it in omitted_fields. The superseded IDA
configuration_remediation and windows_native_failure.remediation
diagnostics are removed.

Fixes #8506

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(discovery): keep owner repairs through provider selection and capability maps

Address review findings on the readiness-remediation contract:

- Provider selection rejections carry the availability owner's
  remediation, CLI/MCP error details project it, and an explicit
  selection refused by readiness uses it as remediation.action. IDA's
  unreadable registration and Ghidra's Windows readiness repair no
  longer degrade to generic selection advice now that the diagnostics
  copies are gone.
- Ghidra's Windows readiness and existing-project capability maps carry
  the same repair as the availability.
- pcap readiness keeps the limiter owner's repair (now a typed
  remediationAction) and falls back to Wireshark guidance only when the
  failure has none.
- ADB/Apktool repairs are owner constants that are complete sentences.
  The execution refusal keeps its previous message-plus-repair recovery,
  which the compiled CLI contract asserts.
- Regression tests compare with the owning formatter or constant and
  add public MCP and CLI coverage for an explicit IDA selection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-01 08:57:11 -08:00
Kaoru0822andClaude Opus 5.5 eb39a07966 feat(evidence): read selected complete records without the whole bundle (#8528)
* feat(evidence): read selected complete records without the whole bundle

get_evidence_bundle could discover a record by exact ID, but reading it
required the complete canonical bundle: complete + filters was rejected,
and inspect_analysis_view refuses parents without a view, such as
annotations, pseudocode sources and native traces.

- Add detail: records, a page (offset, limit; default 0, 8) of the
  complete matching records in canonical bundle order. Records keep their
  original IDs, subjects, providers and readback; missing_evidence_links
  names direct links of the returned records that are not retained.
- The selection is labelled as historical and not an importable bundle;
  complete keeps returning the canonical bundle, and offset/limit
  require detail: records.
- Filters keep one owner shared by summary and records delivery, for the
  MCP session and the CLI's inspect-evidence-bundle.
- An oversized record page now advises a smaller limit or one
  evidence_id; whole-bundle overflow guidance mentions record reads.

Fixes #8307

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(evidence): compare record-selection guidance with its owners

Assert the record-selection limitations and oversized-page guidance
against their owning constants, and check the reported limit facts
separately, instead of pinning explanatory phrases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-01 08:56:58 -08:00
Kaoru0822andClaude Opus 5.5 4b840aa1fb fix(setup): report the failed Hopper request URL and HTTP status (#8526)
* fix(setup): report the failed Hopper request URL and HTTP status

Hopper download callers collapsed every transport failure into a bare
`ok: false`, so HTTP 404 and 503 from the macOS feed and DMG, the Linux
publisher feed, the public key and the launch-time package download produced
identical generic advice without the failed URL.

The shared downloader now returns a typed failure (url plus http_status,
timeout, network, response_too_large or url_refused) and keeps caller
cancellation as a rejection. Installers attribute it to the request
(release_feed, package or public_key); HopperReleaseError carries it, and
setup projects it once into `download` and a remediation naming the URL,
outcome and next step. Replaces the Linux-only "Package download returned
HTTP" special case.

Fixes #8503

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(setup): refuse credentialed Hopper URLs and keep known HTTP outcomes

Native Fetch rejects a URL with userinfo using a TypeError that echoes the
whole URL, so a credentialed redirect reached `download.detail`, setup
remediation and publisher messages as a network failure. The downloader now
refuses request and redirect URLs carrying credentials as
`url_refused: credentials` before requesting them, and reports the caller URL
through the shared userinfo-removing `sanitizeBrowserUrl`.

Releasing an unadmitted body no longer replaces the outcome it was released
for: an errored (truncated) body after HTTP 503, or a failed cancel at the
size limit, keeps `http_status` or `response_too_large`.

Tests compare delivered remediation with the owning formatters and assert
request, URL, status and recovery class independently.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-01 08:56:38 -08:00
Kaoru0822andClaude Opus 5.5 423059a4de feat(javascript): page retained feature traces through analysis views (#8522)
* feat(javascript): page retained feature traces through analysis views

A bidirectional trace_application_feature result can exceed one MCP
response, and inspect_analysis_view refused trace parents as unsupported,
so the only recovery was exporting the whole session.

- Add a shared feature-trace view owner: a summary with the trace's
  identity, producer counts and coverage, and pages of paths, nodes,
  edges, seed_matches and native_handoffs in retained order with exact
  filters. Node rows are compact unless observations are requested.
  Page coverage never upgrades trace coverage.
- Accept an optional MCP view on trace_application_feature that retains
  the complete trace before returning the selected facts; the CLI keeps
  complete JSON and reads saved traces with inspect-analysis-view.
- Offer a ready-to-call first paths page when a retained complete trace
  exceeds the MCP response budget.
- Share the distinct authority/state/confidence basis helper with
  module references.

Fixes #8296

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(javascript): name the retained parent when a selected view overflows

Review follow-up for the feature-trace views.

- Share one delivery owner between trace_application_feature and
  compare_source_to_bundle: stage the selected view against the
  completed parent, keep the established contract that a cancelled
  request commits no session facts, then commit sources and the parent.
- A selected view that exceeds the response budget now references the
  retained parent and offers the same selection at half its limit,
  instead of naming the view record, which cannot be a view parent.
- Cover two native handoffs paged one at a time, the oversized selected
  page, and cancellation after the trace completes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(javascript): bind feature-trace pages to their collection rows

Advertise each feature-trace page with exactly its own row schema, so a
paths page cannot validate with node, edge, seed-match or handoff rows.
The native-trace view in #8533 received the same review finding; the two
sibling views now share one strict page contract.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(javascript): mix valid rows from another collection into a trace page

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-01 08:56:05 -08:00
Kaoru0822andClaude Opus 5.5 44b6384009 fix(native): find procedures by the secondary names exact lookup accepts (#8520)
* fix(native): find procedures by the secondary names exact lookup accepts

search_procedures matched only each procedure's primary name, while exact
lookup also accepts Kuna inventory aliases (versioned-symbol and generated
sub_ names) and Ghidra symbols at an exact function entry. A search for a
name procedure_address resolves returned nothing, and trace_feature
silently missed the procedure.

Both providers now match the same names their exact lookup accepts. A
procedure still appears once under its primary name; the optional
matched_aliases field lists the secondary names that matched, so aliases
never become procedures of their own and list_procedures is unchanged.
The shared domain helper owns that row rule for Kuna and for the Ghidra
host-side Unicode folding pass. The Ghidra analysis profile records the
new search semantics so older snapshots cannot replay alias-blind results.

Fixes #8330

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ghidra): match leaf spellings of procedure names in search

procedure_address accepts each entry symbol's leaf or namespace-qualified
spelling, but procedure search tested only qualified names, so a regex
such as ^audit_alias$ missed a secondary symbol ns::audit_alias (and ^same$
missed a primary alpha::same) that exact lookup selects.

Lookup and search now share one bridge definition of the names that select
a procedure, so they cannot drift. Matching secondary symbols are still
reported by their qualified names; because a leaf ends its qualified
spelling, the host's Unicode literal pass stays exact over those names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-01 08:53:29 -08:00
Kaoru0822andClaude Opus 5.5 fb566c800c fix(analysis): append large inventories without exceeding call-argument limits
Spreading a data-sized collection into push, splice, Math.max/min or a
variadic helper turns each element into a call argument. Above roughly
125,000 elements V8 runs out of stack, so admitted inputs failed with
RangeError: managed/native verification exited with execution_failure,
import-reference-source with UNKNOWN, and DirectoryArtifactReader and
JavaScript export-binding selection threw.

Add appendAll in src/domain/arrayAppend.ts, which appends in place and in
order without expanding elements into arguments, and use it at every
production push/splice spread site. Replace the data-sized Math.max/min and
variadic loader-queue spreads with loops or array parameters. Add the
rea/no-bulk-append-spread lint rule so the pattern cannot return; the few
structurally bounded spreads keep disable comments naming their bound.

Fixes #8545

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:53:16 +00:00
Kaoru0822andClaude Opus 5.5 3f61303939 fix(session): clear provider-inferred unavailability after recovery (#8518)
* fix(session): clear provider-inferred unavailability after recovery

A provider adapter or process failure marked every routed operation of
that provider unavailable, but a later success cleared only the operation
that succeeded. Sibling operations, and composed tools built on them, kept
advertising the stale failure until each was retried individually.

Move runtime availability into one owner that records whether each entry
was observed for the operation or inferred from another operation's
provider-wide failure. A success clears its own failure and that provider's
inferred entries while keeping failures observed on other operations or
providers and declared refusals. Outcomes are ordered by dispatch to the
provider client, so an older in-flight success no longer erases a newer
failure while a request queued behind a failure still clears it.

Fixes #8404

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(session): keep newer successes when older failures complete last

Runtime availability kept one merged entry per operation and forgot
successful dispatches. An older provider-wide failure that completed after
a newer success re-inferred unavailability for siblings the newer success
had already proved working, and a superseded refusal could survive its own
later success.

Record only the newest failure and newest success dispatch per operation
and per provider, and project unavailability from them: an operation is
unavailable while its own newest failure is newer than its own newest
success, or, as inferred, while its provider's newest provider-wide failure
is newer than any success from that provider. The projection no longer
depends on reply completion order.

The recovery tests now bound held-reply acknowledgements, release and join
every held call in finally, and compare remediation with the owning
formatters instead of copying their wording.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-01 08:53:11 -08:00
Kaoru0822andClaude Opus 5.5 35ae7232bf fix(enhanced): stop call graph traversals at unconfirmed provider cleanup (#8516)
* fix(enhanced): stop call graph traversals at unconfirmed provider cleanup

A relationship read whose provider could not confirm cleanup of resources it
owns was projected into a graph node or call-path failure, the traversal kept
dispatching queued reads, and get_call_graph / trace_call_path returned
success with the cleanup_incomplete status buried inside the result.

Both traversals now read relationships through one shared owner,
readCallRelationships, which classifies a cancelled read or a read with
unconfirmed owned cleanup as terminal. On such a cleanup stop the workflow
returns the provider's own error as the outcome, so its cleanup owner, retry
binding and live resolution stay intact, and keeps the work admitted before
the stop as the workflow's normal result shape with a caveat naming the
stopped procedure and the unexpanded count. The composed-workflow layer
turns that admitted result into partial workflow Evidence on the failure,
and the MCP adapter retains it through the shared partial-error delivery.
Ordinary read failures still produce successful partial results.

Fixes #8369

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(enhanced): retain every carried partial Evidence on enhanced failures

Enhanced-tool failures used the shared partial-error delivery only when this
PR's workflow partial was attached. A failing read that already carried its
own partial Evidence, and analyze_function dossier failures, were projected
inline and never recorded with the session writer, so readback and bundle
export could not recover them. Every enhanced execution failure now goes
through one delivery owner that uses toPartialErrorToolResult with the
session writer; it still falls back to inline when no writer or no valid
Evidence exists.

Tests compare the traversal caveat against its owning formatter instead of
copying the sentence.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-01 08:52:53 -08:00
Kaoru0822andClaude Opus 5.5 76cd23d2e8 fix(process): remove unused Darwin helper cache type export (#8511)
Scheduled deep static checks on main fail knip because the
DarwinHelperCache type alias added in #8318 has no consumer. Remove
the alias; callers already use createDarwinHelperCache's inferred
return type.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-01 08:52:42 -08:00
morluto 3e6a5cb20a fix!: preserve address context input errors (#8419)
* fix: retain address context cleanup failures

* fix!: preserve address context input errors

BREAKING CHANGE: Strict clients that cached the previous full inspect_address_context output schema must refresh tools/list and accept the typed error field on unavailable facets.

* test: use session contract for address context
2026-11-02 00:52:27 +08:00
morluto 1d343a88e5 feat(kuna): verify release 1.988 (#8509) 2026-11-02 00:25:06 +08:00
morluto df25bc8068 fix(ghidra): retain relocation symbol observations (#8531)
* fix(ghidra): retain relocation symbol observations

* test(ghidra): verify named relocation symbols through CLI

* fix(ghidra): clarify verification and supervise test commands

* test(ghidra): share load verification guidance
2026-11-01 23:53:56 +08:00
264 changed files with 8479 additions and 1251 deletions

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.
+2
View File
@@ -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",
+1 -1
View File
@@ -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. |
+60 -21
View File
@@ -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) {}
+8
View File
@@ -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
+22 -1
View File
@@ -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.
+5
View File
@@ -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`
+2 -1
View File
@@ -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
View File
@@ -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
+8 -1
View File
@@ -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
+3 -2
View File
@@ -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`.
+3 -1
View File
@@ -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
View File
@@ -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.
+4 -1
View File
@@ -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.
+14
View File
@@ -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
+1 -1
View File
@@ -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
1 harm_rank location trigger current_copy user_risk proposed_replacement implementation_status verification_result
119
120
121
122
123
124
125
+2 -1
View File
@@ -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`;
+2 -1
View File
@@ -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.
+39
View File
@@ -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();
},
]);
}
+2
View File
@@ -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 },
});
},
};
},
};
+32 -1
View File
@@ -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,
+7
View File
@@ -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) => ({
+10
View File
@@ -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
View File
@@ -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 },
});
+2 -1
View File
@@ -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: {},
+2 -1
View File
@@ -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>();
+40 -59
View File
@@ -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);
+11 -7
View File
@@ -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 &&
+2 -1
View File
@@ -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;
};
+1 -1
View File
@@ -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),
},
});
});
+28 -14
View File
@@ -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
+2 -1
View File
@@ -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 ||
+23 -5
View File
@@ -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" ||
+8
View File
@@ -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>>;
};
+6 -8
View File
@@ -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);
+42 -39
View File
@@ -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"]);
});
});
+69 -1
View File
@@ -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: {},
},
});
+25 -10
View File
@@ -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;
+6 -3
View File
@@ -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)),
});
+27 -9
View File
@@ -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(
+2 -2
View File
@@ -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,
+3 -2
View File
@@ -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);
+17 -1
View File
@@ -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;
+33 -34
View File
@@ -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(
+3 -1
View File
@@ -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,
) => {
+191 -36
View File
@@ -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.";
}
};
+21 -2
View File
@@ -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 },
);
}
}
+3 -3
View File
@@ -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]);
};
+17 -1
View File
@@ -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"]);
});
+34 -33
View File
@@ -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",
+15 -15
View File
@@ -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,
+55 -19
View File
@@ -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);
},
);
+23 -9
View File
@@ -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", () => {
+10 -6
View File
@@ -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);
+9 -1
View File
@@ -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;
+4 -2
View File
@@ -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] }),
),
);
+15 -10
View File
@@ -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,
+14 -10
View File
@@ -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
+36 -3
View File
@@ -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":
+7 -5
View File
@@ -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({
+3
View File
@@ -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;
}
+10 -6
View File
@@ -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);
}
+63 -29
View File
@@ -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,
});
+14 -55
View File
@@ -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.",
}),
]);
});
+23 -6
View File
@@ -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(
+11 -7
View File
@@ -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" ||
+4 -2
View File
@@ -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
+3 -2
View File
@@ -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) {
+2 -1
View File
@@ -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(
+7 -10
View File
@@ -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;
+4 -3
View File
@@ -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);
+6 -5
View File
@@ -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 };
}
+2 -1
View File
@@ -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 = (
+5 -3
View File
@@ -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