mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
refactor!: enforce canonical evidence and provider boundaries
Deliver complete Evidence directly through MCP with required nullable analysis profiles and immutable per-server delivery settings. Bind selected environments at construction and require typed cleanup outcomes with retained retry ownership. Remove test-only routing, duplicate session and snapshot metadata, producer-field repairs, and ambiguous provider arguments. Separate artifact byte identity from occurrence classification and retain typed source-map and partial native facts across CLI, MCP, and verifier consumers. BREAKING CHANGE: obsolete Evidence envelopes, session aliases, incomplete snapshots, and missing normalized producer facts are no longer accepted.
This commit is contained in:
@@ -252,7 +252,7 @@ No request can cross a concurrent target or provider transition.
|
||||
canonical path, source digest, generic kind and format, selected architecture,
|
||||
and available architectures. Hopper loader arguments leave the domain model.
|
||||
Provider-owned databases use a generic analysis-database classification; their
|
||||
adapter determines support. A legacy `.hop` target is therefore accepted only
|
||||
adapter determines support. A `.hop` analysis database is therefore accepted only
|
||||
by Hopper without teaching the domain how to open it. Ghidra v1 continues to use
|
||||
private temporary projects and does not open user projects.
|
||||
|
||||
@@ -336,19 +336,19 @@ entries, so snapshots must be recaptured before they can be used as a cache.
|
||||
An embedded Evidence bundle can still be imported separately through the
|
||||
Evidence import flow; it is never promoted into an analysis cache.
|
||||
|
||||
### 8. Add profile commitments to new deep Evidence while preserving legacy records
|
||||
### 8. Commit concrete or explicitly unknown analysis profiles in Evidence
|
||||
|
||||
The optional `analysis_profile` field uses the canonical envelope above. Every
|
||||
newly created deep-analysis observation must include it, and its provider ID
|
||||
and version must match the record's provider. The field participates in the
|
||||
Evidence semantic ID.
|
||||
Every Evidence record includes `analysis_profile`, either the canonical profile
|
||||
envelope above or `null` when no profile was observed. Deep-analysis observations
|
||||
commit the resolved provider ID, version, and effective configuration in this
|
||||
profile. The field participates in the Evidence semantic ID.
|
||||
|
||||
Existing Evidence records without `analysis_profile` remain valid and keep
|
||||
their existing IDs. Absence on a legacy deep record means the analysis profile
|
||||
is unknown; REA does not infer or enrich it during import. Such evidence can be
|
||||
displayed and cited but cannot establish profile compatibility, seed snapshot
|
||||
replay, or support an "unchanged" conclusion that depends on identical analysis
|
||||
semantics.
|
||||
An explicitly unknown profile can be displayed and cited, but cannot establish
|
||||
profile compatibility, seed snapshot replay, or support an "unchanged"
|
||||
conclusion that depends on identical analysis semantics. Snapshot query entries
|
||||
must have matching profile-bound Evidence; imports reject missing or mismatched
|
||||
observations instead of silently dropping query entries. Both `entries` and
|
||||
`workflow_entries` are required arrays, including when empty.
|
||||
|
||||
Different providers or profile digests are never snapshot-compatible. A
|
||||
derived comparison may compare cross-provider normalized facts only when its
|
||||
@@ -407,12 +407,12 @@ means no dynamic provider has supplied a snapshot. Once one or more start,
|
||||
snapshots are not live inventories, and a point-in-time observation never proves
|
||||
historical absence.
|
||||
|
||||
The existing flat `provider`, `providers`, and `capabilities` status fields stay
|
||||
during the 1.x compatibility window. Their effective deep-operation entries
|
||||
come only from the active binding; target-free ambiguity is unavailable rather
|
||||
than arbitrarily collapsed. The new candidate fields are authoritative for
|
||||
selection. A future major release may remove the synthetic composite provider
|
||||
field after callers have migrated.
|
||||
Status exposes `analysis_provider_candidates` for configured deep providers and
|
||||
`analysis_provider_binding` for the selected concrete provider and profile.
|
||||
`capabilities` retains effective operation availability and exact provider
|
||||
provenance, including auxiliary operation families. There are no synthetic
|
||||
flat `provider` or `providers` aliases. Provider prompt completion uses the
|
||||
actual selectable candidates.
|
||||
|
||||
Selection failures extend the tagged `ProviderSelectionError` with a stable
|
||||
reason:
|
||||
@@ -448,26 +448,20 @@ snapshot's provider.
|
||||
- Tool names and normalized output contracts remain provider-neutral. REA does
|
||||
not add `ghidra_decompile` or `hopper_decompile` tools.
|
||||
|
||||
## Compatibility and migration
|
||||
## Provider and snapshot representations
|
||||
|
||||
| Existing behavior or data | Decision |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| No provider selector, only Hopper usable | `auto` selects the sole candidate; behavior remains equivalent |
|
||||
| No deep provider usable | Automatic open may remain unbound for auxiliary operations; deep capabilities explain why they are unavailable |
|
||||
| Hopper and Ghidra both usable, no preference | Open fails with `ambiguous`; installation order never becomes policy |
|
||||
| Existing Hopper environment settings | Remain supported as Hopper adapter configuration |
|
||||
| `open_binary` and CLI structured `loaderArgs` output | Retained as a deprecated Hopper-only compatibility projection for 1.x; the analysis profile is authoritative |
|
||||
| `BinaryTarget.loaderArgs` | Removed; Hopper derives them inside its adapter and commits normalized semantics in the profile |
|
||||
| Legacy `.hop` input | Classified generically as an analysis database and accepted only by Hopper |
|
||||
| Snapshot v1 | Not accepted as the current snapshot format; recapture before cache replay |
|
||||
| Existing Evidence without a profile | Accepted unchanged; profile compatibility is unknown |
|
||||
| New deep-analysis Evidence | Must include `analysis_profile`; its semantic ID commits the field |
|
||||
| Existing status fields | Retained for 1.x with additive binding and candidate fields |
|
||||
| Provider failure after selection | Returned from the selected provider; no transparent retry through another engine |
|
||||
|
||||
The deprecated `loaderArgs` response must never be read to select a provider or
|
||||
validate a snapshot. It is empty for non-Hopper bindings and may be removed only
|
||||
with a separately documented major contract change.
|
||||
| Behavior or data | Contract |
|
||||
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| No selector, one usable deep provider | `auto` selects the sole candidate |
|
||||
| No usable deep provider | Automatic open may remain unbound for auxiliary operations; deep capabilities explain their unavailability |
|
||||
| Several usable deep providers without a preference | Open fails with `ambiguous`; installation order never becomes policy |
|
||||
| Hopper environment settings | Adapter configuration committed into the typed analysis profile, including effective loader arguments |
|
||||
| `open_binary` output | Provider-neutral target identity only; no `loaderArgs` projection |
|
||||
| `.hop` input | Analysis database accepted only by Hopper |
|
||||
| Snapshots | Concrete provider/profile binding, required primitive and workflow entry arrays, and matching authenticated Evidence |
|
||||
| Evidence without an observed profile | Explicit `analysis_profile: null`; ineligible for profile-dependent replay |
|
||||
| Snapshot save receipt | `primitive_entries`, `workflow_entries`, and `evidence_records` counts without a duplicate `entries` alias |
|
||||
| Provider failure after selection | Returned from that provider, without transparent retry through another engine |
|
||||
|
||||
## Required behavior scenarios
|
||||
|
||||
|
||||
@@ -31,6 +31,14 @@ overall coverage becomes `partial` and a limitation explains the omission.
|
||||
Duplicate inventory pages are merged by artifact identity and occurrence path;
|
||||
their Evidence IDs remain attached as source Evidence.
|
||||
|
||||
Content nodes identify bytes by SHA-256 and report only content-derived formats.
|
||||
Each occurrence reports its own `artifact_kind`, `artifact_format`, and executable
|
||||
permission. Identical bytes can therefore appear as a script, resource, or native
|
||||
addon at different paths without overwriting another occurrence's role. Package
|
||||
formats such as APK and IPA belong to the occurrence; their ZIP bytes share a
|
||||
content format. Graph and directory identities use locale-independent Unicode
|
||||
ordering.
|
||||
|
||||
For IPA inventories, archive components outside every application root remain
|
||||
in the component lists. Their JavaScript and native candidates are grouped with
|
||||
other unrooted components, preserving the inventory projection's path pairing
|
||||
|
||||
+1
-2
@@ -110,8 +110,7 @@ cached result. `close_binary` accepts `snapshot_path` and optional
|
||||
leaves the session open so the caller can resolve the output failure.
|
||||
|
||||
The MCP save receipt reports `primitive_entries`, `workflow_entries`, and
|
||||
`evidence_records` separately; the existing `entries` field remains the
|
||||
primitive count. Zero primitive bindings can still accompany retained workflow
|
||||
`evidence_records` separately. Zero primitive bindings can still accompany retained workflow
|
||||
results and Evidence. These are cached observations, not a saved provider
|
||||
database: only eligible exact queries can reuse a result, and new or live
|
||||
queries can still require provider startup. The CLI uses the same snapshot
|
||||
|
||||
@@ -515,8 +515,9 @@ import and auto-analysis. The first Ghidra-backed query starts that work lazily.
|
||||
The provider startup deadline is 330,000 ms by default for import, analysis,
|
||||
bridge, and health readiness. Large binaries can need more: set
|
||||
`REA_GHIDRA_STARTUP_TIMEOUT_MS` to an integer between 1 and 2,147,483,647
|
||||
milliseconds in the server environment. An absent, empty, invalid or out-of-range
|
||||
value keeps the default. REA parses the supplied configuration environment and
|
||||
milliseconds in the server environment. An absent setting uses the default;
|
||||
empty, invalid or out-of-range supplied values fail configuration validation.
|
||||
REA parses the supplied configuration environment and
|
||||
passes the deadline to each provider client; a startup failure is returned by the query that triggered it,
|
||||
without exposing partial analysis. This deadline is separate from MCP transport
|
||||
initialization and the client's deadline for that individual tool call. A client
|
||||
|
||||
@@ -189,8 +189,8 @@ writeFileSync("trace-input.json", JSON.stringify({
|
||||
rea trace-application-feature ./trace-input.json --json
|
||||
```
|
||||
|
||||
MCP analysis returns an envelope containing `result`, `evidence_id`, and full
|
||||
`evidence`. If the connected server advertises retained references, reuse its
|
||||
MCP analysis returns the complete Evidence record, with the operation result
|
||||
in `normalized_result` and its identity in `evidence_id`. If the connected server advertises retained references, reuse its
|
||||
exact returned ID as `{"kind":"retained-evidence","evidence_id":"RETURNED_ID"}`
|
||||
in `application`, or `left`/`right` for comparisons. `RETURNED_ID` is a template,
|
||||
not a literal valid ID. Native Evidence arrays still use complete records.
|
||||
|
||||
+35
-32
@@ -2,7 +2,7 @@
|
||||
|
||||
## Generated catalog
|
||||
|
||||
Run `npm run build:cached` in a source checkout to generate the machine-readable
|
||||
Run `npm run docs:generate` in a source checkout to generate the machine-readable
|
||||
catalog at `docs/public/product-catalog.json`. Documentation deployments serve
|
||||
the same file at [/rea/product-catalog.json](/rea/product-catalog.json). PR CI retains it with the packaged
|
||||
skill and portable conformance projections in the `generated-docs` artifact.
|
||||
@@ -24,28 +24,28 @@ health transition leaves that catalog unchanged and does not emit
|
||||
`notifications/tools/list_changed`.
|
||||
|
||||
Advertised input and output schemas contain no reachable recursive references.
|
||||
The input compatibility profile limits nesting to ten object, array, and
|
||||
`anyOf`/`oneOf`/`allOf` levels, following local references per path. Property
|
||||
maps, reference definitions, and example data do not add schema levels. Tests
|
||||
check the complete catalog after SDK conversion and its generated counterpart.
|
||||
This is REA's local compatibility profile; individual model APIs can impose
|
||||
additional limits.
|
||||
Schemas share repeated definitions through schema-local references while
|
||||
preserving complete fields and validation rules. Input properties retain their
|
||||
descriptions and literal examples. The SDK advertises an object root for
|
||||
canonical object unions and retains each branch's requirements and exclusions.
|
||||
Tests validate the schemas after SDK conversion and compare advertised
|
||||
validation with actual calls. Individual model APIs can impose additional
|
||||
nesting limits; complete producer captures can exceed ten structural levels.
|
||||
|
||||
Input and output schemas share repeated definitions through schema-local
|
||||
references while preserving their complete fields and validation rules. Input
|
||||
properties retain their descriptions and literal examples. Root input unions
|
||||
keep their inline object presentation to avoid redundant branch nesting.
|
||||
Canonical Zod validation and the input compatibility profile continue to apply.
|
||||
`compare_web_captures` accepts exactly one of two input shapes:
|
||||
|
||||
For passive `compare_web_captures` inputs, pass each complete
|
||||
`inspect_web_page` result in `before.inspection` or `after.inspection`, with an
|
||||
optional complete `discover_webmcp_tools` result in the matching `webmcp` field.
|
||||
The input schema describes these producer-result objects as round-trip payloads.
|
||||
For scenario comparisons, pass complete `capture_browser_scenario` results in
|
||||
`before_scenario` and `after_scenario`. REA validates all nested fields with the
|
||||
original capture schemas; observation output schemas remain complete. Scenario
|
||||
authoring inputs and comparison normalization options retain their full
|
||||
advertised structure.
|
||||
- Passive: `before` and `after` each contain `inspection`, the complete
|
||||
`normalized_result` from `inspect_web_page`. Each may also contain `webmcp`,
|
||||
the complete `normalized_result` from `discover_webmcp_tools`, or null.
|
||||
- Scenario: `before_scenario` and `after_scenario` contain complete
|
||||
`normalized_result` objects from `capture_browser_scenario`. Optional
|
||||
`normalization` defaults to `{ "rules": [] }`.
|
||||
|
||||
The advertised schema includes complete nested capture fields and rejects
|
||||
incomplete pairs, mixed comparison families, and structurally malformed
|
||||
captures. Domain validation additionally checks relationships such as event
|
||||
sequence references and retained counts; JSON Schema does not express those
|
||||
cross-field invariants.
|
||||
|
||||
Call `binary_session` with `{}` and read `result.tool_availability` to choose a
|
||||
callable operation for the current target, provider, host, and negotiated client
|
||||
@@ -119,11 +119,11 @@ auto-analysis, bridge connection, and health readiness before returning analysis
|
||||
|
||||
These deadlines have different owners:
|
||||
|
||||
| Deadline | Owner and effect |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MCP initialize | The client bounds transport/REA connection startup, before any target query. |
|
||||
| Ghidra startup | REA allows 330,000 ms by default; `REA_GHIDRA_STARTUP_TIMEOUT_MS` accepts 1–2,147,483,647 ms. Invalid values keep the default. Startup failure is reported by the first provider query. |
|
||||
| Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. |
|
||||
| Deadline | Owner and effect |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MCP initialize | The client bounds transport/REA connection startup, before any target query. |
|
||||
| Ghidra startup | REA allows 330,000 ms by default; `REA_GHIDRA_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid supplied values fail configuration validation. Startup failure is reported by the first provider query. |
|
||||
| Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. |
|
||||
|
||||
For an already connected client using the pinned SDK, request options are the
|
||||
**second** argument of `callTool`:
|
||||
@@ -179,18 +179,21 @@ increasing a client deadline alone does not fix those failures.
|
||||
|
||||
## Tool results
|
||||
|
||||
Evidence-producing tools return `{ result, evidence_id, evidence }` in both
|
||||
text and structured content. `evidence` is the complete canonical Evidence
|
||||
record, including `normalized_result`, which equals `result`. The same record
|
||||
is retained in the session bundle. Read `result` directly, or pass `evidence`
|
||||
to a compatible comparison tool: `analyze_function` Evidence can be passed
|
||||
Evidence-producing tools return the complete canonical Evidence record in both
|
||||
text and structured content. Read `structuredContent.normalized_result` for the
|
||||
operation result and `structuredContent.evidence_id` for its identity. The same
|
||||
record is retained in the session bundle. `analysis_profile` is always present:
|
||||
a concrete profile object or `null`; either value participates in semantic
|
||||
identity. Records omitting this field are rejected. Pass the returned Evidence
|
||||
directly to a compatible comparison tool: `analyze_function` Evidence can be passed
|
||||
directly to `compare_functions`, and `inspect_artifact` Evidence to
|
||||
`compare_artifacts`. Use `get_evidence_bundle` when the task needs broader
|
||||
retained session history or an explicit bundle for transfer.
|
||||
|
||||
REA prepares complete MCP results within the pinned stdio client's 10 MiB
|
||||
receive-buffer budget, including both text and structured representations and
|
||||
room for the JSON-RPC envelope. If a result cannot fit, REA returns
|
||||
room for the JSON-RPC envelope. Response budget settings are captured at startup;
|
||||
restart or recreate the server to apply changes. If a result cannot fit, REA returns
|
||||
`resource_constraint` with `details.resource: "transport"` before constructing
|
||||
a document-sized string. Analysis Evidence remains complete in the current
|
||||
session. Its exact reference is reported in
|
||||
|
||||
@@ -10,8 +10,7 @@ verified manifest into one caller-selected, absent directory.
|
||||
For passive inspection, select `include_script_sources: true` (CLI:
|
||||
`--include-script-sources`). For scenarios, select
|
||||
`capture.network.response_body: true`. Save either the complete Evidence record
|
||||
or its normalized result as UTF-8 JSON. The compact MCP wrapper's `evidence`
|
||||
field contains the complete Evidence record.
|
||||
or its normalized result as UTF-8 JSON. MCP `structuredContent` contains the complete Evidence record.
|
||||
|
||||
```sh
|
||||
rea export-web-scripts /analysis/capture.json /analysis/exported-scripts --json
|
||||
|
||||
@@ -75,6 +75,9 @@ export async function withArtifactMcp(target, verify) {
|
||||
export async function artifactMcpResult(client, name, arguments_ = {}) {
|
||||
const result = await client.callTool({ name, arguments: arguments_ });
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
assert.ok(result.structuredContent?.result, "MCP omitted structured result");
|
||||
return result.structuredContent.result;
|
||||
assert.ok(
|
||||
result.structuredContent?.normalized_result,
|
||||
"MCP omitted structured result",
|
||||
);
|
||||
return result.structuredContent.normalized_result;
|
||||
}
|
||||
|
||||
@@ -48,8 +48,7 @@ export async function verifyBrowserDomDestinations(
|
||||
});
|
||||
assert.notEqual(result.isError, true, mcpTextValue(result));
|
||||
assertFormDestinations(
|
||||
parseEvidence(JSON.parse(mcpTextValue(result)).evidence)
|
||||
.normalized_result,
|
||||
parseEvidence(JSON.parse(mcpTextValue(result))).normalized_result,
|
||||
documentUrl,
|
||||
);
|
||||
return { cli: true, stdio_mcp: true, current_document_destinations: 4 };
|
||||
|
||||
@@ -105,9 +105,7 @@ export async function verifyBrowserModules(
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
const mcpEvidence = parseEvidence(
|
||||
JSON.parse(mcpTextValue(response)).evidence,
|
||||
);
|
||||
const mcpEvidence = parseEvidence(JSON.parse(mcpTextValue(response)));
|
||||
const mcpResult = assertTrace(mcpEvidence, oracle, site, scriptIndex);
|
||||
assert.deepEqual(mcpResult, cliResult, "CLI and MCP module traces differ");
|
||||
assert.equal(
|
||||
@@ -143,7 +141,7 @@ export async function verifyBrowserModules(
|
||||
},
|
||||
}),
|
||||
),
|
||||
).evidence,
|
||||
),
|
||||
);
|
||||
const result = webModuleTraceResultSchema.parse(
|
||||
withoutMap.normalized_result,
|
||||
@@ -236,7 +234,7 @@ export async function verifyBrowserModules(
|
||||
},
|
||||
});
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
evidence = parseEvidence(JSON.parse(mcpTextValue(response)).evidence);
|
||||
evidence = parseEvidence(JSON.parse(mcpTextValue(response)));
|
||||
}
|
||||
const result = webModuleTraceResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
|
||||
@@ -112,10 +112,7 @@ export async function verifyBrowserNetworkEvidence(
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
const evidence = parseEvidence({
|
||||
...result.structuredContent?.evidence,
|
||||
normalized_result: result.structuredContent?.result,
|
||||
});
|
||||
const evidence = parseEvidence(result.structuredContent);
|
||||
mcpProof = assertBrowserNetworkEvidence(
|
||||
browserScenarioCaptureSchema.parse(evidence.normalized_result),
|
||||
site.origin,
|
||||
@@ -130,10 +127,7 @@ export async function verifyBrowserNetworkEvidence(
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
assert.notEqual(connected.isError, true, JSON.stringify(connected));
|
||||
const connectedEvidence = parseEvidence({
|
||||
...connected.structuredContent?.evidence,
|
||||
normalized_result: connected.structuredContent?.result,
|
||||
});
|
||||
const connectedEvidence = parseEvidence(connected.structuredContent);
|
||||
assert.deepEqual(
|
||||
assertBrowserNetworkEvidence(
|
||||
browserScenarioCaptureSchema.parse(
|
||||
|
||||
@@ -8,7 +8,7 @@ import { chromium } from "playwright-core";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
import { PlaywrightBrowserScenarioProvider } from "../../dist/browser/PlaywrightBrowserScenarioProvider.js";
|
||||
import { createBrowserScenarioProvider } from "../../dist/composition/browserScenario.js";
|
||||
import { PlaywrightScenarioEvents } from "../../dist/browser/PlaywrightScenarioEvents.js";
|
||||
import { BrowserScenarioSecrets } from "../../dist/browser/BrowserScenarioSecrets.js";
|
||||
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
|
||||
@@ -53,9 +53,9 @@ const scenarioFor = (executable, origin, events) =>
|
||||
});
|
||||
|
||||
const capture = async (scenario) => {
|
||||
const result = await new PlaywrightBrowserScenarioProvider().captureScenario(
|
||||
scenario,
|
||||
);
|
||||
const result = await createBrowserScenarioProvider(
|
||||
process.env,
|
||||
).captureScenario(scenario);
|
||||
if (!result.ok) throw result.error;
|
||||
assert.ok(
|
||||
result.value.steps.every((step) => step.status === "completed"),
|
||||
@@ -163,10 +163,7 @@ const verifyTransports = async (executable, origin) => {
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
assert.notEqual(captured.isError, true, JSON.stringify(captured));
|
||||
const evidence = parseEvidence({
|
||||
...captured.structuredContent?.evidence,
|
||||
normalized_result: captured.structuredContent?.result,
|
||||
});
|
||||
const evidence = parseEvidence(captured.structuredContent);
|
||||
assertNetwork(
|
||||
browserScenarioCaptureSchema.parse(evidence.normalized_result),
|
||||
origin,
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function verifyBrowserRuntime(
|
||||
mcpTextValue(listenerResponse),
|
||||
);
|
||||
const mcpListenerProof = assertListeners(
|
||||
parseEvidence(JSON.parse(mcpTextValue(listenerResponse)).evidence),
|
||||
parseEvidence(JSON.parse(mcpTextValue(listenerResponse))),
|
||||
site,
|
||||
);
|
||||
const mcpProof = await executionMcp(
|
||||
@@ -231,7 +231,7 @@ async function executionMcp(
|
||||
await actionPromise;
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
return assertExecution(
|
||||
parseEvidence(JSON.parse(mcpTextValue(response)).evidence),
|
||||
parseEvidence(JSON.parse(mcpTextValue(response))),
|
||||
site,
|
||||
requireBlock,
|
||||
);
|
||||
|
||||
@@ -94,10 +94,7 @@ export async function verifyLargeScreenshotE2e(endpoint, origin) {
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
assert.notEqual(captured.isError, true, JSON.stringify(captured));
|
||||
const mcpEvidence = parseEvidence({
|
||||
...captured.structuredContent?.evidence,
|
||||
normalized_result: captured.structuredContent?.result,
|
||||
});
|
||||
const mcpEvidence = parseEvidence(captured.structuredContent);
|
||||
const mcpScreenshot = webScreenshotSchema.parse(
|
||||
mcpEvidence.normalized_result,
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function verifyBrowserScriptExport(
|
||||
const call = async (name, args) => {
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
assert.notEqual(result.isError, true, mcpTextValue(result));
|
||||
return parseEvidence(JSON.parse(mcpTextValue(result)).evidence);
|
||||
return parseEvidence(JSON.parse(mcpTextValue(result)));
|
||||
};
|
||||
const mcpCapture = await call("capture_browser_scenario", scenario);
|
||||
const mcpInput = await save("mcp", mcpCapture);
|
||||
|
||||
@@ -184,9 +184,7 @@ async function assertPublicPair(cli, client, oracle) {
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
const mcpEvidence = parseEvidence(
|
||||
JSON.parse(mcpTextValue(response)).evidence,
|
||||
);
|
||||
const mcpEvidence = parseEvidence(JSON.parse(mcpTextValue(response)));
|
||||
assert.deepEqual(assertPoint(mcpEvidence, oracle), expected);
|
||||
assert.equal(evidence.parameters.source_map.url, input.source_map.url);
|
||||
return expected;
|
||||
|
||||
@@ -15,14 +15,17 @@ import {
|
||||
} from "../../dist/domain/analysisSnapshot.js";
|
||||
import { createEvidence } from "../../dist/domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../dist/domain/evidenceBundle.js";
|
||||
import { requireMcpResult } from "./mcp-verifier-results.mjs";
|
||||
import {
|
||||
requireMcpLifecycleResult,
|
||||
requireMcpEvidenceResult,
|
||||
} from "./mcp-verifier-results.mjs";
|
||||
|
||||
/** Reject a valid older snapshot whose instruction-only collector omitted a real edge. */
|
||||
export async function verifyLegacyGhidraReferenceSnapshot(
|
||||
client,
|
||||
{ target, procedure, omittedEdge, entrypoint, env },
|
||||
) {
|
||||
const bundle = requireMcpResult(
|
||||
const bundle = requireMcpLifecycleResult(
|
||||
await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
@@ -97,6 +100,7 @@ export async function verifyLegacyGhidraReferenceSnapshot(
|
||||
},
|
||||
}),
|
||||
],
|
||||
workflow_entries: [],
|
||||
evidence_bundle: createEvidenceBundle([evidence]),
|
||||
});
|
||||
assert.equal(
|
||||
@@ -107,7 +111,7 @@ export async function verifyLegacyGhidraReferenceSnapshot(
|
||||
const path = join(env.TMPDIR, `legacy-reference-${randomUUID()}.json`);
|
||||
await writeFile(path, serializeAnalysisSnapshot(snapshot), { flag: "wx" });
|
||||
try {
|
||||
const session = requireMcpResult(
|
||||
const session = requireMcpLifecycleResult(
|
||||
await client.callTool({ name: "binary_session", arguments: {} }),
|
||||
"binary_session",
|
||||
);
|
||||
@@ -128,14 +132,14 @@ export async function verifyLegacyGhidraReferenceSnapshot(
|
||||
/without this snapshot.*fresh snapshot/u,
|
||||
);
|
||||
assert.deepEqual(
|
||||
requireMcpResult(
|
||||
requireMcpLifecycleResult(
|
||||
await client.callTool({ name: "binary_session", arguments: {} }),
|
||||
"binary_session",
|
||||
),
|
||||
session,
|
||||
"A rejected snapshot must preserve the selected session",
|
||||
);
|
||||
const retained = requireMcpResult(
|
||||
const retained = requireMcpEvidenceResult(
|
||||
await client.callTool({
|
||||
name: "analyze_function",
|
||||
arguments: { procedure },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { toolContract } from "../../dist/contracts/toolContracts.js";
|
||||
|
||||
/** Read the textual JSON projection from one MCP tool call. */
|
||||
export const mcpTextValue = (result) => {
|
||||
const text = result.content?.find((item) => item.type === "text")?.text;
|
||||
@@ -7,25 +9,58 @@ export const mcpTextValue = (result) => {
|
||||
|
||||
const jsonValue = (result) => JSON.parse(mcpTextValue(result));
|
||||
|
||||
/** Return the compact result projection from one successful MCP tool call. */
|
||||
export const requireMcpResult = (result, operation) => {
|
||||
if (result.isError === true) {
|
||||
const requireOutput = (result, operation, contract) => {
|
||||
if (result.isError === true)
|
||||
throw new Error(`${operation} failed: ${mcpTextValue(result)}`);
|
||||
}
|
||||
const value = jsonValue(result);
|
||||
if (value === null || typeof value !== "object" || !("result" in value)) {
|
||||
throw new Error(`${operation} omitted its result projection`);
|
||||
}
|
||||
return value.result;
|
||||
if (contract.name !== operation)
|
||||
throw new Error(`${operation} received the ${contract.name} contract`);
|
||||
return contract.outputSchema.parse(jsonValue(result));
|
||||
};
|
||||
|
||||
const operationResultKey = (contract) => {
|
||||
const shape = contract.outputSchema.shape;
|
||||
if (shape === undefined)
|
||||
throw new Error(`${contract.name} has an unsupported MCP output schema`);
|
||||
const evidence = Object.hasOwn(shape, "normalized_result");
|
||||
const lifecycle = Object.hasOwn(shape, "result");
|
||||
if (evidence === lifecycle)
|
||||
throw new Error(`${contract.name} has an unsupported MCP output schema`);
|
||||
return evidence ? "normalized_result" : "result";
|
||||
};
|
||||
|
||||
/** Validate an Evidence-producing tool output and return its normalized result. */
|
||||
export const requireMcpEvidenceResult = (result, operation) => {
|
||||
const contract = toolContract(operation);
|
||||
if (operationResultKey(contract) !== "normalized_result")
|
||||
throw new Error(`${operation} requires the lifecycle result extractor`);
|
||||
return requireOutput(result, operation, contract).normalized_result;
|
||||
};
|
||||
|
||||
/** Validate a lifecycle tool output and return its operation result. */
|
||||
export const requireMcpLifecycleResult = (result, operation) => {
|
||||
const contract = toolContract(operation);
|
||||
if (operationResultKey(contract) !== "result")
|
||||
throw new Error(`${operation} requires the Evidence result extractor`);
|
||||
return requireOutput(result, operation, contract).result;
|
||||
};
|
||||
|
||||
/** Project a mixed verifier call using its exact named canonical output schema. */
|
||||
export const requireMcpOperationResult = (
|
||||
result,
|
||||
operation,
|
||||
contract = toolContract(operation),
|
||||
) => {
|
||||
const key = operationResultKey(contract);
|
||||
return requireOutput(result, operation, contract)[key];
|
||||
};
|
||||
|
||||
/** Verify provider identity directly from the tool result. */
|
||||
export const requireEvidenceProvider = (result, operation, providerId) => {
|
||||
const evidence = jsonValue(result);
|
||||
if (
|
||||
evidence?.evidence?.provider?.id !== providerId ||
|
||||
evidence?.evidence?.analysis_profile?.provider?.id !== providerId ||
|
||||
typeof evidence.evidence.analysis_profile.provider.version !== "string"
|
||||
evidence?.provider?.id !== providerId ||
|
||||
evidence?.analysis_profile?.provider?.id !== providerId ||
|
||||
typeof evidence.analysis_profile.provider.version !== "string"
|
||||
) {
|
||||
throw new Error(`${operation} omitted its concrete provider provenance`);
|
||||
}
|
||||
@@ -38,10 +73,10 @@ export const requireWorkflowEvidenceProvider = (
|
||||
expected,
|
||||
) => {
|
||||
const evidence = jsonValue(result);
|
||||
const profile = evidence?.evidence?.analysis_profile;
|
||||
const profile = evidence?.analysis_profile;
|
||||
const upstream = profile?.parameters?.upstream_analysis_profile;
|
||||
if (
|
||||
evidence?.evidence?.provider?.id !== expected.workflowProviderId ||
|
||||
evidence?.provider?.id !== expected.workflowProviderId ||
|
||||
profile?.provider?.id !== expected.workflowProviderId ||
|
||||
upstream?.provider?.id !== expected.upstreamProviderId ||
|
||||
typeof upstream.provider.version !== "string"
|
||||
|
||||
@@ -179,12 +179,12 @@ async function verifyNativeValueAdapters(
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
const mcpEvidence = parseEvidence(result.structuredContent?.evidence);
|
||||
const mcpEvidence = parseEvidence(result.structuredContent);
|
||||
assert.equal(mcpEvidence.operation, "trace_native_values");
|
||||
assert.equal(mcpEvidence.provider.id, "rea-workflow");
|
||||
assert.deepEqual(mcpEvidence.analysis_profile, workflowProfile);
|
||||
const mcpGraph = verifyGraph(
|
||||
result.structuredContent?.result,
|
||||
result.structuredContent?.normalized_result,
|
||||
target,
|
||||
procedure,
|
||||
globalAddress,
|
||||
@@ -196,9 +196,7 @@ async function verifyNativeValueAdapters(
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
assert.notEqual(analyzed.isError, true, JSON.stringify(analyzed));
|
||||
const functionEvidence = parseEvidence(
|
||||
analyzed.structuredContent?.evidence,
|
||||
);
|
||||
const functionEvidence = parseEvidence(analyzed.structuredContent);
|
||||
assert.equal(functionEvidence.operation, "analyze_function");
|
||||
assert.equal(functionEvidence.provider.id, "ghidra");
|
||||
const functionProfile = analysisProfileSchema.parse(
|
||||
@@ -207,26 +205,24 @@ async function verifyNativeValueAdapters(
|
||||
assert.deepEqual(functionProfile, upstreamProfile);
|
||||
assert.deepEqual(
|
||||
functionEvidence.normalized_result,
|
||||
analyzed.structuredContent?.result,
|
||||
analyzed.structuredContent?.normalized_result,
|
||||
);
|
||||
const comparison = await client.callTool(
|
||||
{
|
||||
name: "compare_functions",
|
||||
arguments: {
|
||||
left: analyzed.structuredContent.evidence,
|
||||
right: analyzed.structuredContent.evidence,
|
||||
left: analyzed.structuredContent,
|
||||
right: analyzed.structuredContent,
|
||||
},
|
||||
},
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
assert.notEqual(comparison.isError, true, JSON.stringify(comparison));
|
||||
const comparisonEvidence = parseEvidence(
|
||||
comparison.structuredContent?.evidence,
|
||||
);
|
||||
const comparisonEvidence = parseEvidence(comparison.structuredContent);
|
||||
assert.equal(comparisonEvidence.normalized_result.status, "unchanged");
|
||||
assert.deepEqual(
|
||||
comparisonEvidence.normalized_result,
|
||||
comparison.structuredContent?.result,
|
||||
comparison.structuredContent?.normalized_result,
|
||||
);
|
||||
assert.ok(
|
||||
comparisonEvidence.evidence_links.includes(functionEvidence.evidence_id),
|
||||
|
||||
@@ -8,7 +8,7 @@ export async function verifyGhidraAddressBoundaries(client, entry, cli) {
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.notEqual(reply.isError, true, JSON.stringify(reply));
|
||||
return reply.structuredContent.result;
|
||||
return reply.structuredContent.normalized_result;
|
||||
};
|
||||
const before = await query("analyze_function", { procedure: entry });
|
||||
// The segmented parser used to discard the high 32 bits and alias this entry.
|
||||
|
||||
@@ -8,7 +8,10 @@ import { promisify } from "node:util";
|
||||
import Ajv from "ajv/dist/2020.js";
|
||||
import addFormats from "ajv-formats";
|
||||
import { analysisErrorProjectionSchema } from "../../dist/contracts/errorSchemas.js";
|
||||
import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs";
|
||||
import {
|
||||
mcpTextValue,
|
||||
requireMcpOperationResult,
|
||||
} from "./mcp-verifier-results.mjs";
|
||||
import { verifyLegacyGhidraReferenceSnapshot } from "./ghidra-reference-snapshot-e2e.mjs";
|
||||
import { verifyGhidraSnapshotLifecycle } from "./real-ghidra-snapshot-lifecycle.mjs";
|
||||
import { verifyGhidraTargetAdmission } from "./real-ghidra-target-admission.mjs";
|
||||
@@ -40,7 +43,7 @@ export async function verifyGhidraBoundaries(
|
||||
let rejectedCalls = 0;
|
||||
const call = async (name, args = {}) => {
|
||||
const reply = await client.callTool({ name, arguments: args }, options);
|
||||
const result = requireMcpResult(reply, name);
|
||||
const result = requireMcpOperationResult(reply, name);
|
||||
const validate = validators.get(name);
|
||||
assert.ok(validate, `${name} missing from catalog`);
|
||||
assert.ok(
|
||||
|
||||
@@ -161,6 +161,7 @@ export async function verifyGhidraNamespaceAnnotations({
|
||||
|
||||
async function requireNamespaceDemangler(env) {
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir: env.GHIDRA_INSTALL_DIR,
|
||||
...(env.JAVA_HOME === undefined ? {} : { javaHome: env.JAVA_HOME }),
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parseAnalysisSnapshot } from "../../dist/domain/analysisSnapshot.js";
|
||||
import { parseEvidenceBundle } from "../../dist/domain/evidenceBundle.js";
|
||||
import { requireMcpResult } from "./mcp-verifier-results.mjs";
|
||||
import { requireMcpOperationResult } from "./mcp-verifier-results.mjs";
|
||||
|
||||
/** Keep mutable Ghidra observations out of immutable snapshots across target opens. */
|
||||
export async function verifyGhidraSnapshotLifecycle(
|
||||
@@ -17,7 +17,7 @@ export async function verifyGhidraSnapshotLifecycle(
|
||||
let successfulCalls = 0;
|
||||
let rejectedCalls = 0;
|
||||
const call = async (name, args = {}) => {
|
||||
const result = requireMcpResult(
|
||||
const result = requireMcpOperationResult(
|
||||
await client.callTool({ name, arguments: args }, { timeout: 180000 }),
|
||||
name,
|
||||
);
|
||||
|
||||
@@ -6,7 +6,12 @@ import { basename, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import Ajv from "ajv/dist/2020.js";
|
||||
import addFormats from "ajv-formats";
|
||||
import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs";
|
||||
import {
|
||||
mcpTextValue,
|
||||
requireMcpEvidenceResult,
|
||||
requireMcpOperationResult,
|
||||
requireMcpLifecycleResult,
|
||||
} from "./mcp-verifier-results.mjs";
|
||||
import { verifyHopperCliSelectors } from "./real-hopper-cli-selectors.mjs";
|
||||
import { verifyLinuxHopperLaunchIsolation } from "./real-hopper-session-isolation.mjs";
|
||||
import { verifyHopperNavigationAndText } from "./real-hopper-navigation.mjs";
|
||||
@@ -44,7 +49,7 @@ export async function verifyHopperBoundaryContracts(
|
||||
const rejectedOperations = new Set();
|
||||
const call = async (name, args = {}) => {
|
||||
const reply = await client.callTool({ name, arguments: args }, options);
|
||||
const result = requireMcpResult(reply, name);
|
||||
const result = requireMcpOperationResult(reply, name);
|
||||
const validate = validators.get(name);
|
||||
assert.ok(validate, `${name} is missing from tools/list`);
|
||||
assert.ok(
|
||||
@@ -301,7 +306,7 @@ export async function verifyHopperLifecycleAndCli(client, options, targets) {
|
||||
const primary = join(directory, `primary-${suffix}`);
|
||||
const secondary = join(directory, `secondary-${suffix}`);
|
||||
const call = async (name, args = {}) =>
|
||||
requireMcpResult(
|
||||
requireMcpOperationResult(
|
||||
await client.callTool({ name, arguments: args }, options),
|
||||
name,
|
||||
);
|
||||
@@ -508,14 +513,14 @@ const verifyBatchCancellation = async (client, options, procedures) => {
|
||||
/aborted/u,
|
||||
);
|
||||
assert.ok(cancelled, "cancellation did not follow a real Hopper response");
|
||||
const recovered = requireMcpResult(
|
||||
const recovered = requireMcpEvidenceResult(
|
||||
await client.callTool({ name: "list_procedures", arguments: {} }, options),
|
||||
"list_procedures after cancellation",
|
||||
"list_procedures",
|
||||
);
|
||||
assert.deepEqual(recovered, procedures);
|
||||
const status = requireMcpResult(
|
||||
const status = requireMcpLifecycleResult(
|
||||
await client.callTool({ name: "binary_session", arguments: {} }, options),
|
||||
"binary_session after cancellation",
|
||||
"binary_session",
|
||||
);
|
||||
assert.equal(status.provider_operation_health.state, "idle");
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { requireMcpResult } from "./mcp-verifier-results.mjs";
|
||||
import { requireMcpEvidenceResult } from "./mcp-verifier-results.mjs";
|
||||
|
||||
export const verifyHopperFunctionBasics = async (
|
||||
client,
|
||||
options,
|
||||
procedure,
|
||||
) => {
|
||||
const containment = requireMcpResult(
|
||||
const containment = requireMcpEvidenceResult(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "resolve_containing_procedure",
|
||||
@@ -25,7 +25,7 @@ export const verifyHopperFunctionBasics = async (
|
||||
);
|
||||
}
|
||||
|
||||
const references = requireMcpResult(
|
||||
const references = requireMcpEvidenceResult(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "procedure_references",
|
||||
@@ -42,7 +42,7 @@ export const verifyHopperFunctionBasics = async (
|
||||
throw new Error("procedure_references returned an invalid result");
|
||||
}
|
||||
|
||||
const instructions = requireMcpResult(
|
||||
const instructions = requireMcpEvidenceResult(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "read_function_instructions",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { HOPPER_PROVIDER_IDENTITY } from "../../dist/hopper/HopperProvider.js";
|
||||
import {
|
||||
requireMcpResult,
|
||||
requireMcpEvidenceResult,
|
||||
requireEvidenceProvider,
|
||||
} from "./mcp-verifier-results.mjs";
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function verifyHopperInventories(client, options) {
|
||||
{ name: operation, arguments: {} },
|
||||
options,
|
||||
);
|
||||
const inventory = requireMcpResult(called, operation);
|
||||
const inventory = requireMcpEvidenceResult(called, operation);
|
||||
requireEvidenceProvider(called, operation, HOPPER_PROVIDER_IDENTITY.id);
|
||||
assert.ok(
|
||||
Array.isArray(inventory) && inventory.length > 0,
|
||||
@@ -30,7 +30,7 @@ export async function verifyHopperInventories(client, options) {
|
||||
);
|
||||
previous = address;
|
||||
}
|
||||
const filtered = requireMcpResult(
|
||||
const filtered = requireMcpEvidenceResult(
|
||||
await client.callTool(
|
||||
{
|
||||
name: operation,
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function verifyRealHopperFixture({
|
||||
},
|
||||
options,
|
||||
),
|
||||
"set_comment fixture marker",
|
||||
"set_comment",
|
||||
);
|
||||
if (commentSet !== true)
|
||||
throw new Error("Hopper did not read back the fixture comment marker");
|
||||
@@ -138,7 +138,7 @@ export async function verifyRealHopperFixture({
|
||||
},
|
||||
options,
|
||||
),
|
||||
"procedure_references native calls",
|
||||
"procedure_references",
|
||||
);
|
||||
assert.deepEqual(entryDossier.unresolved_calls, observed.unresolved_calls);
|
||||
for (const call of calls) {
|
||||
@@ -266,7 +266,7 @@ export async function resolveFixtureProcedure(
|
||||
},
|
||||
options,
|
||||
),
|
||||
`search_procedures ${expectedName}`,
|
||||
"search_procedures",
|
||||
);
|
||||
const exactMatches = Array.isArray(matches)
|
||||
? matches.filter(
|
||||
@@ -301,5 +301,5 @@ export const analyzeFixtureProcedure = async (
|
||||
},
|
||||
options,
|
||||
),
|
||||
`analyze_function ${procedure.name}`,
|
||||
"analyze_function",
|
||||
);
|
||||
|
||||
@@ -1254,8 +1254,7 @@ async function inspect(
|
||||
return value.error;
|
||||
}
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
envelope = value.evidence;
|
||||
assert.deepEqual(value.result, envelope.normalized_result);
|
||||
envelope = value;
|
||||
} else {
|
||||
let response;
|
||||
try {
|
||||
|
||||
@@ -118,7 +118,7 @@ try {
|
||||
undefined,
|
||||
`Unexpected public failure: ${JSON.stringify(value)}`,
|
||||
);
|
||||
const evidence = parseEvidence(mode === "cli" ? value : value.evidence);
|
||||
const evidence = parseEvidence(value);
|
||||
const report = evidence.normalized_result;
|
||||
assert.equal(report.diagnostics.truncated, false);
|
||||
assert.equal(evidence.provider.version, "[email protected]");
|
||||
@@ -157,7 +157,7 @@ try {
|
||||
})),
|
||||
evidence.raw_result.functions ?? [],
|
||||
);
|
||||
if (mode === "mcp") assert.deepEqual(value.result, report);
|
||||
if (mode === "mcp") assert.deepEqual(value.normalized_result, report);
|
||||
return report;
|
||||
};
|
||||
const ownershipProbe = join(root.path, "ownership-prerequisite.hex");
|
||||
|
||||
@@ -118,9 +118,14 @@ try {
|
||||
);
|
||||
const target = await parseBinaryTarget(executable);
|
||||
if (!target.ok) throw target.error;
|
||||
const observation = await observeNativeUi(target.value, "observe_native_ui", {
|
||||
...scope,
|
||||
});
|
||||
const observation = await observeNativeUi(
|
||||
target.value,
|
||||
"observe_native_ui",
|
||||
{
|
||||
...scope,
|
||||
},
|
||||
{ environment: process.env },
|
||||
);
|
||||
if (
|
||||
!observation.ok &&
|
||||
!/accessibility-denied|screen-recording-denied/u.test(
|
||||
@@ -150,6 +155,7 @@ try {
|
||||
{ kind: "wait", milliseconds: 200 },
|
||||
],
|
||||
},
|
||||
{ environment: process.env },
|
||||
);
|
||||
if (!scenario.ok) throw scenario.error;
|
||||
if (
|
||||
@@ -234,6 +240,7 @@ try {
|
||||
{ ...target.value, sha256: "0".repeat(64) },
|
||||
"observe_native_ui",
|
||||
scope,
|
||||
{ environment: process.env },
|
||||
);
|
||||
if (mismatch.ok || !mismatch.error.message.includes("target-mismatch"))
|
||||
throw new Error("Real helper did not reject changed target bytes");
|
||||
|
||||
@@ -8,13 +8,17 @@ export async function verifyPackageCapabilitiesAndSearch({ cli, environment }) {
|
||||
if (!Array.isArray(capabilities.capabilities))
|
||||
throw new Error("packaged capabilities CLI failed");
|
||||
const providers = json(await run(cli, ["providers", "--json"], environment));
|
||||
const candidates = providers.analysis_provider_candidates;
|
||||
if (
|
||||
!Array.isArray(providers.providers) ||
|
||||
providers.providers.some(({ id }) => typeof id !== "string") ||
|
||||
!Array.isArray(candidates) ||
|
||||
candidates.some(
|
||||
(candidate) =>
|
||||
typeof candidate?.provider?.id !== "string" ||
|
||||
candidate.selected !== false,
|
||||
) ||
|
||||
providers.analysis_provider_binding !== null ||
|
||||
providers.analysis_provider_candidates?.find(
|
||||
({ provider }) => provider?.id === "hopper",
|
||||
)?.target_support?.status !== "unknown"
|
||||
candidates.find(({ provider }) => provider?.id === "hopper")?.target_support
|
||||
?.status !== "unknown"
|
||||
)
|
||||
throw new Error("packaged providers CLI failed");
|
||||
if (process.platform !== "linux") {
|
||||
|
||||
@@ -96,7 +96,7 @@ const verifyMcpNonLinuxCurrentDocument = async (
|
||||
mcpOptions,
|
||||
current,
|
||||
) => {
|
||||
if (json(prompts.mcpText(current)).result !== "fixture")
|
||||
if (json(prompts.mcpText(current)).normalized_result !== "fixture")
|
||||
throw new Error("packaged MCP bridge call failed");
|
||||
const batch = await client.callTool(
|
||||
{
|
||||
@@ -105,7 +105,7 @@ const verifyMcpNonLinuxCurrentDocument = async (
|
||||
},
|
||||
mcpOptions,
|
||||
);
|
||||
const batchResult = json(prompts.mcpText(batch)).result;
|
||||
const batchResult = json(prompts.mcpText(batch)).normalized_result;
|
||||
if (
|
||||
batch.isError === true ||
|
||||
batchResult?.total !== 1 ||
|
||||
@@ -175,10 +175,10 @@ const verifyMcpInlineArtifactEvidence = async (
|
||||
mcpOptions,
|
||||
);
|
||||
assert.notEqual(inspected.isError, true, JSON.stringify(inspected));
|
||||
const source = parseEvidence(inspected.structuredContent?.evidence);
|
||||
const source = parseEvidence(inspected.structuredContent);
|
||||
assert.deepEqual(
|
||||
source.normalized_result,
|
||||
inspected.structuredContent?.result,
|
||||
inspected.structuredContent?.normalized_result,
|
||||
);
|
||||
assert.deepEqual(
|
||||
json(prompts.mcpText(inspected)),
|
||||
@@ -188,17 +188,17 @@ const verifyMcpInlineArtifactEvidence = async (
|
||||
{
|
||||
name: "compare_artifacts",
|
||||
arguments: {
|
||||
left: inspected.structuredContent.evidence,
|
||||
right: inspected.structuredContent.evidence,
|
||||
left: inspected.structuredContent,
|
||||
right: inspected.structuredContent,
|
||||
},
|
||||
},
|
||||
mcpOptions,
|
||||
);
|
||||
assert.notEqual(compared.isError, true, JSON.stringify(compared));
|
||||
const comparison = parseEvidence(compared.structuredContent?.evidence);
|
||||
const comparison = parseEvidence(compared.structuredContent);
|
||||
assert.deepEqual(
|
||||
comparison.normalized_result,
|
||||
compared.structuredContent?.result,
|
||||
compared.structuredContent?.normalized_result,
|
||||
);
|
||||
assert.ok(comparison.evidence_links.includes(source.evidence_id));
|
||||
const bundle = await client.callTool(
|
||||
@@ -219,12 +219,12 @@ const verifyMcpInlineArtifactEvidence = async (
|
||||
mcpOptions,
|
||||
);
|
||||
assert.notEqual(extracted.isError, true, JSON.stringify(extracted));
|
||||
const extraction = parseEvidence(extracted.structuredContent?.evidence);
|
||||
const extraction = parseEvidence(extracted.structuredContent);
|
||||
assert.deepEqual(
|
||||
extraction.normalized_result,
|
||||
extracted.structuredContent?.result,
|
||||
extracted.structuredContent?.normalized_result,
|
||||
);
|
||||
const materialized = extracted.structuredContent?.result;
|
||||
const materialized = extracted.structuredContent?.normalized_result;
|
||||
assert.equal(typeof materialized?.output_root, "string");
|
||||
outputRoot = materialized.output_root;
|
||||
assert.equal(materialized.containment_verified, true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
@@ -171,7 +172,14 @@ try {
|
||||
})
|
||||
).stdout,
|
||||
);
|
||||
assert.ok(JSON.stringify(providers).includes("ghidra"));
|
||||
assert.equal(providers.analysis_provider_binding, null);
|
||||
assert.ok(Array.isArray(providers.analysis_provider_candidates));
|
||||
const ghidra = providers.analysis_provider_candidates.find(
|
||||
({ provider }) => provider.id === "ghidra",
|
||||
);
|
||||
assert.ok(ghidra !== undefined);
|
||||
assert.equal(ghidra.selected, false);
|
||||
assert.equal(ghidra.target_support.status, "unknown");
|
||||
report.cli.providers = true;
|
||||
const inspected = await exec(
|
||||
process.execPath,
|
||||
@@ -210,12 +218,9 @@ try {
|
||||
{ timeout: 360_000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, `Packaged operation failed: ${name}`);
|
||||
const structured =
|
||||
result.structuredContent ??
|
||||
JSON.parse(result.content.find((item) => item.type === "text").text);
|
||||
const parsed = contract.outputSchema.parse(structured);
|
||||
const projected = requireMcpOperationResult(result, name, contract);
|
||||
report.mcpOperations.push(name);
|
||||
return parsed.result;
|
||||
return projected;
|
||||
};
|
||||
try {
|
||||
await client.connect(transport);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
|
||||
import { waitForBrowserDevtoolsPort } from "../dist/browser/BrowserProcessStartup.js";
|
||||
import { PlaywrightBrowserScenarioProvider } from "../dist/browser/PlaywrightBrowserScenarioProvider.js";
|
||||
import { createBrowserScenarioProvider } from "../dist/composition/browserScenario.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
listBrowserTargetsInputSchema,
|
||||
@@ -240,10 +240,9 @@ try {
|
||||
try {
|
||||
attachedScenario = await runScenarioCli(attachedScenarioInput);
|
||||
} catch (cliError) {
|
||||
const direct =
|
||||
await new PlaywrightBrowserScenarioProvider().captureScenario(
|
||||
attachedScenarioInput,
|
||||
);
|
||||
const direct = await createBrowserScenarioProvider(
|
||||
process.env,
|
||||
).captureScenario(attachedScenarioInput);
|
||||
if (!direct.ok) {
|
||||
const underlying = direct.error.cause;
|
||||
const details =
|
||||
@@ -274,16 +273,17 @@ try {
|
||||
throw new Error("Scenario attachment terminated its external browser");
|
||||
|
||||
const profilesBefore = await scenarioProfiles();
|
||||
const launchedScenario =
|
||||
await new PlaywrightBrowserScenarioProvider().captureScenario(
|
||||
browserScenario(
|
||||
{
|
||||
mode: "launch",
|
||||
executable_path: executable,
|
||||
},
|
||||
site.origin,
|
||||
),
|
||||
);
|
||||
const launchedScenario = await createBrowserScenarioProvider(
|
||||
process.env,
|
||||
).captureScenario(
|
||||
browserScenario(
|
||||
{
|
||||
mode: "launch",
|
||||
executable_path: executable,
|
||||
},
|
||||
site.origin,
|
||||
),
|
||||
);
|
||||
if (!launchedScenario.ok) throw launchedScenario.error;
|
||||
if (
|
||||
launchedScenario.value.browser.cleanup !== "terminated-owned-process" ||
|
||||
|
||||
@@ -50,7 +50,9 @@ const input = electronActiveObservationInputSchema.parse({
|
||||
],
|
||||
});
|
||||
const verifierRun = createVerifierRun();
|
||||
const result = await new PlaywrightElectronActiveProvider().capture(input);
|
||||
const result = await new PlaywrightElectronActiveProvider(process.env).capture(
|
||||
input,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
|
||||
const output = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
@@ -28,6 +29,7 @@ if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
"verify:ghidra:com prerequisite missing: GHIDRA_INSTALL_DIR (bring your own Ghidra).",
|
||||
);
|
||||
const installation = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir: process.env.GHIDRA_INSTALL_DIR,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
@@ -331,12 +333,15 @@ async function call(name, args = {}) {
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.notEqual(value.isError, true, JSON.stringify(value));
|
||||
if (name === "open_binary") return value.structuredContent.result;
|
||||
if (name === "close_binary") return value.structuredContent;
|
||||
const evidence = parseEvidence(value.structuredContent.evidence);
|
||||
const projected = requireMcpOperationResult(value, name);
|
||||
if (name === "open_binary" || name === "close_binary") return projected;
|
||||
const evidence = parseEvidence(value.structuredContent);
|
||||
assert.equal(evidence.subject.digest.sha256, sha256);
|
||||
assert.deepEqual(evidence.normalized_result, value.structuredContent.result);
|
||||
return value.structuredContent.result;
|
||||
assert.deepEqual(
|
||||
evidence.normalized_result,
|
||||
value.structuredContent.normalized_result,
|
||||
);
|
||||
return projected;
|
||||
}
|
||||
async function cli(command, address, options = []) {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
"verify:ghidra:dos prerequisite missing: GHIDRA_INSTALL_DIR (bring your own Ghidra).",
|
||||
);
|
||||
const installation = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir: process.env.GHIDRA_INSTALL_DIR,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
@@ -552,11 +554,8 @@ async function call(name, arguments_ = {}) {
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
assert.ok(
|
||||
result.structuredContent && "result" in result.structuredContent,
|
||||
`${name} omitted structured result`,
|
||||
);
|
||||
return result.structuredContent.result;
|
||||
assert.ok(result.structuredContent, `${name} omitted structured result`);
|
||||
return requireMcpOperationResult(result, name);
|
||||
}
|
||||
async function evidenceCall(name, arguments_) {
|
||||
const result = await client.callTool(
|
||||
@@ -564,10 +563,7 @@ async function evidenceCall(name, arguments_) {
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
const evidence = parseEvidence({
|
||||
...result.structuredContent?.evidence,
|
||||
normalized_result: result.structuredContent?.result,
|
||||
});
|
||||
const evidence = parseEvidence(result.structuredContent);
|
||||
assert.equal(evidence.subject.digest.sha256, sha256);
|
||||
assert.equal(evidence.provider.id, "ghidra");
|
||||
assertProfile(evidence.analysis_profile);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
@@ -392,12 +393,13 @@ async function call(name, args = {}) {
|
||||
{ timeout: 360000 },
|
||||
);
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
if (name === "close_binary") return response.structuredContent;
|
||||
const evidence = parseEvidence(response.structuredContent.evidence);
|
||||
const projected = requireMcpOperationResult(response, name);
|
||||
if (name === "close_binary") return projected;
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
assert.equal(evidence.subject.digest.sha256, sha256);
|
||||
assert.deepEqual(
|
||||
evidence.normalized_result,
|
||||
response.structuredContent.result,
|
||||
response.structuredContent.normalized_result,
|
||||
);
|
||||
return response.structuredContent.result;
|
||||
return projected;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
|
||||
import { toolContract } from "../dist/contracts/toolContracts.js";
|
||||
import { requireMcpResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const run = createVerifierRun();
|
||||
const installation = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir: process.env.GHIDRA_INSTALL_DIR,
|
||||
javaHome: process.env.JAVA_HOME,
|
||||
});
|
||||
@@ -156,5 +157,5 @@ async function call(name, arguments_) {
|
||||
);
|
||||
if (name === "analyze_swift_types")
|
||||
toolContract(name).outputSchema.parse(response.structuredContent);
|
||||
return requireMcpResult(response, name);
|
||||
return requireMcpOperationResult(response, name);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
@@ -74,6 +75,7 @@ if (!compilerOnly) {
|
||||
"Ghidra switch lane prerequisite missing: GHIDRA_INSTALL_DIR (bring your own)",
|
||||
);
|
||||
installation = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir: process.env.GHIDRA_INSTALL_DIR,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
@@ -237,15 +239,21 @@ async function verifyTarget(path, sha256, fixtures, variant) {
|
||||
const observed = await toolResult(client, "inspect_native_api", {
|
||||
procedure: procedure.address,
|
||||
});
|
||||
assertWorkflowIdentity(observed.evidence, sha256);
|
||||
assert.equal(observed.result.procedure.address, procedure.address);
|
||||
assertWorkflowIdentity(observed, sha256);
|
||||
assert.equal(
|
||||
observed.normalized_result.procedure.address,
|
||||
procedure.address,
|
||||
);
|
||||
const summary = assertSwitchBoundary(
|
||||
observed.result.boundary,
|
||||
observed.normalized_result.boundary,
|
||||
observationFixture,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...observed.result.residual_unknowns].sort(),
|
||||
expectedResidualUnknowns(observed.result.boundary, fixture).sort(),
|
||||
[...observed.normalized_result.residual_unknowns].sort(),
|
||||
expectedResidualUnknowns(
|
||||
observed.normalized_result.boundary,
|
||||
fixture,
|
||||
).sort(),
|
||||
"Residual unknowns must preserve unresolved ABI types and exactly the expected switch uncertainty",
|
||||
);
|
||||
const cli = await cliSwitchEvidence(
|
||||
@@ -259,20 +267,20 @@ async function verifyTarget(path, sha256, fixtures, variant) {
|
||||
assertSwitchBoundary(cli.normalized_result.boundary, observationFixture);
|
||||
assert.deepEqual(
|
||||
cli.normalized_result.boundary.jump_tables,
|
||||
observed.result.boundary.jump_tables,
|
||||
observed.normalized_result.boundary.jump_tables,
|
||||
"CLI/MCP switch mappings/defaults/data-source evidence differ",
|
||||
);
|
||||
assert.deepEqual(
|
||||
cli.normalized_result.residual_unknowns,
|
||||
observed.result.residual_unknowns,
|
||||
observed.normalized_result.residual_unknowns,
|
||||
);
|
||||
observations.push({
|
||||
name: fixture.name,
|
||||
...summary,
|
||||
cli_mcp_equal: true,
|
||||
limitations: observed.result.boundary.limitations,
|
||||
residual_unknowns: observed.result.residual_unknowns,
|
||||
jump_tables: observed.result.boundary.jump_tables,
|
||||
limitations: observed.normalized_result.boundary.limitations,
|
||||
residual_unknowns: observed.normalized_result.residual_unknowns,
|
||||
jump_tables: observed.normalized_result.boundary.jump_tables,
|
||||
});
|
||||
}
|
||||
return { observations, stderr };
|
||||
@@ -319,14 +327,15 @@ async function toolResult(client, name, arguments_ = {}) {
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
assert.ok(
|
||||
result.structuredContent && "result" in result.structuredContent,
|
||||
`${name} omitted structured result`,
|
||||
);
|
||||
assert.ok(result.structuredContent, `${name} omitted structured result`);
|
||||
return result.structuredContent;
|
||||
}
|
||||
async function call(client, name, arguments_ = {}) {
|
||||
return (await toolResult(client, name, arguments_)).result;
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: arguments_ },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
return requireMcpOperationResult(result, name);
|
||||
}
|
||||
|
||||
async function cliSwitchEvidence(entrypoint, path, procedure, env) {
|
||||
|
||||
@@ -48,6 +48,7 @@ if (installDir === undefined || !isAbsolute(installDir))
|
||||
"Set GHIDRA_INSTALL_DIR to the absolute root of Ghidra 12.1.4.",
|
||||
);
|
||||
const installation = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
@@ -94,6 +95,7 @@ if (!profile.ok || profile.value.profile === null)
|
||||
const client = new GhidraClient({
|
||||
platform: installation.platform,
|
||||
launcher: new GhidraHeadlessLauncher({
|
||||
environment: process.env,
|
||||
analyzeHeadlessPath: installation.analyzeHeadlessPath,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
@@ -112,6 +114,7 @@ const client = new GhidraClient({
|
||||
|
||||
let coordinates;
|
||||
let report;
|
||||
let primaryFailure;
|
||||
const observed = new Set();
|
||||
try {
|
||||
const started = await client.start();
|
||||
@@ -257,8 +260,18 @@ try {
|
||||
"no-gui-or-mutation-authority",
|
||||
],
|
||||
};
|
||||
} catch (cause) {
|
||||
primaryFailure = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
await client.close();
|
||||
const closed = await client.close();
|
||||
if (!closed.ok)
|
||||
throw primaryFailure === undefined
|
||||
? closed.error
|
||||
: new AggregateError(
|
||||
[primaryFailure, closed.error],
|
||||
"Ghidra verification and cleanup failed",
|
||||
);
|
||||
if (coordinates !== undefined) await assertCleanup(coordinates);
|
||||
}
|
||||
if (report === undefined)
|
||||
|
||||
@@ -50,6 +50,7 @@ if (installDir === undefined || !isAbsolute(installDir))
|
||||
"Set GHIDRA_INSTALL_DIR to the absolute root of an extracted Ghidra 12.1.4 release.",
|
||||
);
|
||||
const installation = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
@@ -442,6 +443,7 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
|
||||
|
||||
const client = new GhidraClient({
|
||||
launcher: new GhidraHeadlessLauncher({
|
||||
environment: process.env,
|
||||
analyzeHeadlessPath: installation.analyzeHeadlessPath,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
@@ -458,6 +460,7 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
|
||||
});
|
||||
|
||||
let runtimeCoordinates;
|
||||
let primaryFailure;
|
||||
try {
|
||||
const started = await client.start();
|
||||
if (!started.ok) throw started.error;
|
||||
@@ -539,8 +542,18 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
|
||||
native_api_cli: nativeApiCli,
|
||||
native_values: nativeValues,
|
||||
};
|
||||
} catch (cause) {
|
||||
primaryFailure = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
await client.close();
|
||||
const closed = await client.close();
|
||||
if (!closed.ok)
|
||||
throw primaryFailure === undefined
|
||||
? closed.error
|
||||
: new AggregateError(
|
||||
[primaryFailure, closed.error],
|
||||
"Ghidra verification and cleanup failed",
|
||||
);
|
||||
if (runtimeCoordinates !== undefined)
|
||||
await assertCleanup(runtimeCoordinates);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const root = await realpath(
|
||||
);
|
||||
const target = join(root, "deadline-fixture");
|
||||
let client;
|
||||
let primaryFailure;
|
||||
try {
|
||||
await copyFile(fixtures.primary.path, target);
|
||||
const parsed = await parseBinaryTarget(target);
|
||||
@@ -40,14 +41,27 @@ try {
|
||||
provider: HOPPER_PROVIDER_IDENTITY,
|
||||
});
|
||||
if (!resolved.ok) throw resolved.error;
|
||||
const image = resolved.value.profile?.parameters.prepared_image;
|
||||
const profile = resolved.value.profile;
|
||||
assert.notEqual(
|
||||
profile,
|
||||
null,
|
||||
"This lane requires a resolved Hopper launcher profile",
|
||||
);
|
||||
const loaderArgs = profile.parameters.loader?.arguments;
|
||||
assert.ok(
|
||||
Array.isArray(loaderArgs) &&
|
||||
loaderArgs.every((argument) => typeof argument === "string"),
|
||||
"The Hopper profile must report its effective loader arguments",
|
||||
);
|
||||
const image = profile.parameters.prepared_image;
|
||||
client = new HopperClient({
|
||||
launcher: new HopperApplicationLauncher({
|
||||
environment: process.env,
|
||||
launcherPath,
|
||||
targetPath: target,
|
||||
targetKind: "executable",
|
||||
launchMode: "native",
|
||||
loaderArgs: resolved.value.compatibility.loaderArgs,
|
||||
loaderArgs,
|
||||
...(image === undefined
|
||||
? {}
|
||||
: {
|
||||
@@ -102,7 +116,7 @@ try {
|
||||
const activityAfterDeadline = client.requestActivity();
|
||||
await call("health");
|
||||
assert.equal(client.requestActivity(), null);
|
||||
const closed = await client.closeWithOutcome();
|
||||
const closed = await client.close();
|
||||
if (!closed.ok) throw closed.error;
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
@@ -120,7 +134,19 @@ try {
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (cause) {
|
||||
primaryFailure = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
if (client !== undefined) await client.close();
|
||||
if (client !== undefined) {
|
||||
const closed = await client.close();
|
||||
if (!closed.ok)
|
||||
throw primaryFailure === undefined
|
||||
? closed.error
|
||||
: new AggregateError(
|
||||
[primaryFailure, closed.error],
|
||||
"Hopper verification and cleanup failed",
|
||||
);
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import Ajv from "ajv/dist/2020.js";
|
||||
import addFormats from "ajv-formats";
|
||||
import { mcpTextValue, requireMcpResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import {
|
||||
mcpTextValue,
|
||||
requireMcpOperationResult,
|
||||
} from "./lib/mcp-verifier-results.mjs";
|
||||
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
|
||||
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js";
|
||||
import { parseConfig } from "../dist/config.js";
|
||||
@@ -219,7 +222,7 @@ try {
|
||||
{ name, arguments: args },
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
const result = requireMcpResult(reply, name);
|
||||
const result = requireMcpOperationResult(reply, name);
|
||||
const schema = catalog.tools.find(
|
||||
(tool) => tool.name === name,
|
||||
)?.outputSchema;
|
||||
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
import {
|
||||
mcpTextValue,
|
||||
requireEvidenceProvider,
|
||||
requireMcpResult,
|
||||
requireMcpOperationResult,
|
||||
requireMcpLifecycleResult,
|
||||
requireMcpEvidenceResult,
|
||||
requireWorkflowEvidenceProvider,
|
||||
} from "./lib/mcp-verifier-results.mjs";
|
||||
import {
|
||||
@@ -64,7 +66,6 @@ const sessionsBefore = await snapshotHopperRuntime(
|
||||
);
|
||||
const ownedProcessIds = new Set();
|
||||
const textValue = mcpTextValue;
|
||||
const requireSuccessfulTool = requireMcpResult;
|
||||
|
||||
const requireOverview = (value, operation) => {
|
||||
if (
|
||||
@@ -87,7 +88,7 @@ const verifyRelationships = async (client, options, procedure) => {
|
||||
const related = {};
|
||||
for (const operation of ["procedure_callers", "procedure_callees"]) {
|
||||
related[operation] = requireAddressArray(
|
||||
requireSuccessfulTool(
|
||||
requireMcpEvidenceResult(
|
||||
await client.callTool(
|
||||
{ name: operation, arguments: { procedure } },
|
||||
options,
|
||||
@@ -98,7 +99,7 @@ const verifyRelationships = async (client, options, procedure) => {
|
||||
);
|
||||
}
|
||||
related.xrefs = requireAddressArray(
|
||||
requireSuccessfulTool(
|
||||
requireMcpEvidenceResult(
|
||||
await client.callTool(
|
||||
{ name: "xrefs", arguments: { address: procedure } },
|
||||
options,
|
||||
@@ -111,7 +112,7 @@ const verifyRelationships = async (client, options, procedure) => {
|
||||
};
|
||||
|
||||
const verifyByteAccess = async (client, options, firstAddress) => {
|
||||
const byteRead = requireSuccessfulTool(
|
||||
const byteRead = requireMcpEvidenceResult(
|
||||
await client.callTool(
|
||||
{ name: "read_bytes", arguments: { address: firstAddress, length: 16 } },
|
||||
options,
|
||||
@@ -127,7 +128,7 @@ const verifyByteAccess = async (client, options, firstAddress) => {
|
||||
) {
|
||||
throw new Error("read_bytes returned an invalid bounded byte window");
|
||||
}
|
||||
const mappedOffset = requireSuccessfulTool(
|
||||
const mappedOffset = requireMcpEvidenceResult(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "address_to_file_offset",
|
||||
@@ -151,7 +152,7 @@ const verifyByteAccess = async (client, options, firstAddress) => {
|
||||
};
|
||||
|
||||
const verifyCurrentTarget = async (client, options) => {
|
||||
const procedures = requireSuccessfulTool(
|
||||
const procedures = requireMcpEvidenceResult(
|
||||
await client.callTool({ name: "list_procedures", arguments: {} }, options),
|
||||
"list_procedures",
|
||||
);
|
||||
@@ -167,7 +168,7 @@ const verifyCurrentTarget = async (client, options) => {
|
||||
options,
|
||||
firstAddress,
|
||||
);
|
||||
const boundedResult = requireSuccessfulTool(
|
||||
const boundedResult = requireMcpEvidenceResult(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "batch_decompile",
|
||||
@@ -196,7 +197,7 @@ const verifyCurrentTarget = async (client, options) => {
|
||||
"batch_decompile",
|
||||
);
|
||||
const dossier = requireFunctionDossier(
|
||||
requireSuccessfulTool(
|
||||
requireMcpEvidenceResult(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "analyze_function",
|
||||
@@ -274,7 +275,10 @@ try {
|
||||
const fullSessionStatus = () =>
|
||||
client.callTool({ name: "binary_session", arguments: {} }, options);
|
||||
const initialSession = await fullSessionStatus();
|
||||
const initialStatus = requireMcpResult(initialSession, "binary_session");
|
||||
const initialStatus = requireMcpLifecycleResult(
|
||||
initialSession,
|
||||
"binary_session",
|
||||
);
|
||||
const listed = await client.listTools();
|
||||
const availableTools = initialStatus.tool_availability;
|
||||
if (!Array.isArray(availableTools))
|
||||
@@ -300,7 +304,7 @@ try {
|
||||
options,
|
||||
);
|
||||
if (opened.isError === true) throw new Error(textValue(opened));
|
||||
const firstStatus = requireMcpResult(
|
||||
const firstStatus = requireMcpLifecycleResult(
|
||||
await fullSessionStatus(),
|
||||
"binary_session",
|
||||
);
|
||||
@@ -312,7 +316,7 @@ try {
|
||||
{ name: "list_documents", arguments: {} },
|
||||
options,
|
||||
);
|
||||
requireSuccessfulTool(documents, "list_documents");
|
||||
requireMcpEvidenceResult(documents, "list_documents");
|
||||
requireBridgeProgress(progressUpdates);
|
||||
const rejectedProcedure = await client.callTool(
|
||||
{
|
||||
@@ -327,7 +331,7 @@ try {
|
||||
{ name: "binary_overview", arguments: {} },
|
||||
options,
|
||||
);
|
||||
requireSuccessfulTool(overview, "binary_overview");
|
||||
requireMcpEvidenceResult(overview, "binary_overview");
|
||||
const inventoryCounts = await verifyHopperInventories(client, options);
|
||||
requireEvidenceProvider(
|
||||
documents,
|
||||
@@ -343,9 +347,9 @@ try {
|
||||
options,
|
||||
);
|
||||
requireTruthfulMemoryRegions(
|
||||
requireSuccessfulTool(segments, "list_segments"),
|
||||
requireMcpEvidenceResult(segments, "list_segments"),
|
||||
);
|
||||
const firstDocuments = requireSuccessfulTool(documents, "list_documents");
|
||||
const firstDocuments = requireMcpEvidenceResult(documents, "list_documents");
|
||||
if (
|
||||
!Array.isArray(firstDocuments) ||
|
||||
firstDocuments.length === 0 ||
|
||||
@@ -356,10 +360,10 @@ try {
|
||||
client,
|
||||
options,
|
||||
firstDocuments,
|
||||
requireSuccessfulTool,
|
||||
requireMcpEvidenceResult,
|
||||
);
|
||||
const firstOverview = requireOverview(
|
||||
requireSuccessfulTool(overview, "binary_overview"),
|
||||
requireMcpEvidenceResult(overview, "binary_overview"),
|
||||
"binary_overview",
|
||||
);
|
||||
if (firstOverview.document !== currentDocument)
|
||||
@@ -372,7 +376,7 @@ try {
|
||||
options,
|
||||
document: currentDocument,
|
||||
oracle: fixtureTargets.oracle,
|
||||
normalizedResult: requireSuccessfulTool,
|
||||
normalizedResult: requireMcpEvidenceResult,
|
||||
});
|
||||
const boundaryContracts = await verifyHopperBoundaryContracts(
|
||||
client,
|
||||
@@ -385,7 +389,7 @@ try {
|
||||
options,
|
||||
);
|
||||
if (switched.isError === true) throw new Error(textValue(switched));
|
||||
const secondSession = requireMcpResult(
|
||||
const secondSession = requireMcpLifecycleResult(
|
||||
await fullSessionStatus(),
|
||||
"binary_session",
|
||||
);
|
||||
@@ -400,19 +404,19 @@ try {
|
||||
options,
|
||||
);
|
||||
const verifiedSecondOverview = requireOverview(
|
||||
requireSuccessfulTool(secondOverview, "binary_overview"),
|
||||
requireMcpEvidenceResult(secondOverview, "binary_overview"),
|
||||
"binary_overview after target switch",
|
||||
);
|
||||
const secondAnalysis = await verifyCurrentTarget(client, options);
|
||||
const documentsAfterTargetSwitch = requireMcpResult(
|
||||
const documentsAfterTargetSwitch = requireMcpEvidenceResult(
|
||||
await client.callTool({ name: "list_documents", arguments: {} }, options),
|
||||
"list_documents after target switch",
|
||||
"list_documents",
|
||||
);
|
||||
const secondDocument = await requireCurrentDocument(
|
||||
client,
|
||||
options,
|
||||
documentsAfterTargetSwitch,
|
||||
requireSuccessfulTool,
|
||||
requireMcpEvidenceResult,
|
||||
);
|
||||
if (verifiedSecondOverview.document !== secondDocument)
|
||||
throw new Error(
|
||||
@@ -432,9 +436,9 @@ try {
|
||||
);
|
||||
if (reopenedTarget.isError === true)
|
||||
throw new Error(textValue(reopenedTarget));
|
||||
const documentsAfterReopen = requireMcpResult(
|
||||
const documentsAfterReopen = requireMcpEvidenceResult(
|
||||
await client.callTool({ name: "list_documents", arguments: {} }, options),
|
||||
"list_documents after reopening target B",
|
||||
"list_documents",
|
||||
);
|
||||
if (
|
||||
!Array.isArray(documentsAfterReopen) ||
|
||||
@@ -446,7 +450,7 @@ try {
|
||||
const largeInventory = await openAndVerifyLargeFixture({
|
||||
client,
|
||||
options,
|
||||
normalizedResult: requireSuccessfulTool,
|
||||
normalizedResult: requireMcpEvidenceResult,
|
||||
path: largeTarget,
|
||||
expectedCount: fixtureTargets.largeOracle.symbolCount,
|
||||
symbolPrefix: fixtureTargets.largeOracle.symbolPrefix,
|
||||
@@ -471,7 +475,7 @@ try {
|
||||
let terminalInstructions = null;
|
||||
if (fixtureTargets.unicode !== undefined) {
|
||||
const call = async (name, args = {}) =>
|
||||
requireSuccessfulTool(
|
||||
requireMcpOperationResult(
|
||||
await client.callTool({ name, arguments: args }, options),
|
||||
name,
|
||||
);
|
||||
@@ -492,7 +496,7 @@ try {
|
||||
: { unicode: fixtureTargets.unicode.path }),
|
||||
ownedProcessIds,
|
||||
});
|
||||
const closedSession = requireMcpResult(
|
||||
const closedSession = requireMcpLifecycleResult(
|
||||
await fullSessionStatus(),
|
||||
"binary_session",
|
||||
);
|
||||
|
||||
@@ -144,11 +144,11 @@ const mcp = async (cliDossier) => {
|
||||
const analyzed = await call("analyze_function", {
|
||||
procedure: values.procedure,
|
||||
});
|
||||
const dossier = functionDossierSchema.parse(observe(analyzed.evidence));
|
||||
const dossier = functionDossierSchema.parse(observe(analyzed));
|
||||
assert.deepEqual(dossier.procedure, cliDossier.procedure);
|
||||
assert.equal(dossier.pseudocode, cliDossier.pseudocode);
|
||||
assert.deepEqual(dossier.assembly, cliDossier.assembly);
|
||||
assert.equal(analyzed.evidence_id, analyzed.evidence.evidence_id);
|
||||
assert.equal(analyzed.operation, "analyze_function");
|
||||
for (const [name, args] of [
|
||||
["list_procedures", {}],
|
||||
["list_strings", {}],
|
||||
@@ -165,7 +165,7 @@ const mcp = async (cliDossier) => {
|
||||
["xrefs", { address: dossier.procedure.address }],
|
||||
]) {
|
||||
const result = await call(name, args);
|
||||
observe(result.evidence);
|
||||
observe(result);
|
||||
}
|
||||
const invalid = await client.callTool({
|
||||
name: "xrefs",
|
||||
@@ -190,7 +190,7 @@ const mcp = async (cliDossier) => {
|
||||
};
|
||||
const cleanup = async () => {
|
||||
phase = "upstream lifecycle verification";
|
||||
const upstream = createIdaMcpConnection(config);
|
||||
const upstream = createIdaMcpConnection(config, process.env);
|
||||
try {
|
||||
await upstream.connect();
|
||||
if (config.mode === "attached") {
|
||||
|
||||
@@ -912,8 +912,8 @@ async function inspect(mode, input, errorCategory) {
|
||||
return value.error;
|
||||
}
|
||||
assert.notEqual(result.isError, true, mcpTextValue(result));
|
||||
assertSensitiveLimitations(value.evidence, input.sensitive_values ?? []);
|
||||
return value.result;
|
||||
assertSensitiveLimitations(value, input.sensitive_values ?? []);
|
||||
return value.normalized_result;
|
||||
}
|
||||
const args = [
|
||||
entrypoint,
|
||||
|
||||
@@ -136,8 +136,8 @@ try {
|
||||
);
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
const structured = response.structuredContent;
|
||||
assert.ok(structured !== undefined && structured.evidence !== undefined);
|
||||
const evidence = parseEvidence(structured.evidence);
|
||||
assert.ok(structured !== undefined);
|
||||
const evidence = parseEvidence(structured);
|
||||
assert.deepEqual(
|
||||
evidence.normalized_result,
|
||||
cliResults.get(task.name).normalized_result,
|
||||
@@ -156,7 +156,7 @@ try {
|
||||
cliResults.get(task.name).limitations,
|
||||
);
|
||||
assert.equal(evidence.confidence, cliResults.get(task.name).confidence);
|
||||
assert.deepEqual(structured.result, evidence.normalized_result);
|
||||
assert.deepEqual(structured.normalized_result, evidence.normalized_result);
|
||||
console.log(`PASS MCP/CLI parity ${task.name}`);
|
||||
}
|
||||
} catch (cause) {
|
||||
|
||||
@@ -224,7 +224,7 @@ export async function verifyAndroidFailureRecovery({
|
||||
);
|
||||
assert.notEqual(control.isError, true, JSON.stringify(control));
|
||||
assert.equal(
|
||||
control.structuredContent.result.package_name,
|
||||
control.structuredContent.normalized_result.package_name,
|
||||
fixture.package,
|
||||
);
|
||||
console.log("PASS MCP valid package after selector failures");
|
||||
|
||||
@@ -108,10 +108,10 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
|
||||
let targets;
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
targets = await call("list_electron_targets", { cdp_endpoint: endpoint });
|
||||
if (targets.result.targets.length > 0) break;
|
||||
if (targets.normalized_result.targets.length > 0) break;
|
||||
await pause(100);
|
||||
}
|
||||
const target = targets.result.targets.find(
|
||||
const target = targets.normalized_result.targets.find(
|
||||
(item) => item.file_path === join(root, "renderer.html"),
|
||||
);
|
||||
assert.ok(target, JSON.stringify(targets));
|
||||
@@ -122,7 +122,7 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
|
||||
observation_ms: 50,
|
||||
});
|
||||
assert.ok(
|
||||
page.result.scripts.items.some(
|
||||
page.normalized_result.scripts.items.some(
|
||||
(script) =>
|
||||
script.file_path === join(root, "renderer.js") &&
|
||||
script.source.included,
|
||||
@@ -134,13 +134,13 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
|
||||
const reconciled = await call("reconcile_javascript_runtime", {
|
||||
static_layers: [
|
||||
{
|
||||
analysis: staticAnalysis.evidence,
|
||||
analysis: staticAnalysis,
|
||||
runtime_mappings: [{ kind: "file-root", root }],
|
||||
},
|
||||
],
|
||||
runtime_observations: [page.evidence],
|
||||
runtime_observations: [page],
|
||||
});
|
||||
assert.ok(reconciled.result.runtime_captures.length > 0);
|
||||
assert.ok(reconciled.normalized_result.runtime_captures.length > 0);
|
||||
const cli = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
@@ -173,7 +173,7 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
|
||||
);
|
||||
const capture = active.isError
|
||||
? active.structuredContent.error.details.partial_observation.capture
|
||||
: active.structuredContent.result;
|
||||
: active.structuredContent.normalized_result;
|
||||
if (active.isError) {
|
||||
assert.equal(
|
||||
active.structuredContent.error.code,
|
||||
|
||||
@@ -149,13 +149,13 @@ try {
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const inspectedMcp = await call("inspect_firmware_regions", { path });
|
||||
const mcpEvidence = parseEvidence(inspectedMcp.evidence);
|
||||
const mcpEvidence = parseEvidence(inspectedMcp);
|
||||
assert.deepEqual(verifyRegions(mcpEvidence), cliRegions);
|
||||
const extractedMcp = await call("extract_firmware", {
|
||||
path,
|
||||
output_directory: join(root, "mcp-output"),
|
||||
});
|
||||
const mcpFiles = await verifyFiles(parseEvidence(extractedMcp.evidence));
|
||||
const mcpFiles = await verifyFiles(parseEvidence(extractedMcp));
|
||||
const contentIdentity = (result) =>
|
||||
result.files.map(({ relative_path, sha256, size }) => ({
|
||||
relative_path,
|
||||
@@ -168,7 +168,7 @@ try {
|
||||
output_directory: join(root, "selected-output"),
|
||||
range: { offset: oracle.offset, length: oracle.length },
|
||||
});
|
||||
const selectedFiles = await verifyFiles(parseEvidence(selected.evidence));
|
||||
const selectedFiles = await verifyFiles(parseEvidence(selected));
|
||||
assert.equal(selectedFiles.selection.offset, oracle.offset);
|
||||
assert.equal(selectedFiles.selection.length, oracle.length);
|
||||
const depth = await call("extract_firmware", {
|
||||
@@ -177,7 +177,7 @@ try {
|
||||
max_depth: 1,
|
||||
});
|
||||
const depthResult = firmwareResultSchemas.extract_firmware.parse(
|
||||
depth.result,
|
||||
depth.normalized_result,
|
||||
);
|
||||
assert.equal(depthResult.coverage, "partial");
|
||||
assert.ok(depthResult.depth_limited_paths.length > 0);
|
||||
@@ -192,7 +192,7 @@ try {
|
||||
output_directory: join(root, "ext4-output"),
|
||||
});
|
||||
const result = firmwareResultSchemas.extract_firmware.parse(
|
||||
response.result,
|
||||
response.normalized_result,
|
||||
);
|
||||
assert.ok(result.chunks.some((chunk) => chunk.handler === "extfs"));
|
||||
assert.equal(result.coverage, "complete");
|
||||
@@ -234,7 +234,7 @@ try {
|
||||
const dossier = await call("analyze_function", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
const evidence = parseEvidence(dossier.evidence);
|
||||
const evidence = parseEvidence(dossier);
|
||||
assert.equal(
|
||||
evidence.subject.digest.sha256,
|
||||
oracle.files["usr/bin/probe"].sha256,
|
||||
@@ -242,7 +242,7 @@ try {
|
||||
const pseudocode = await call("procedure_pseudo_code", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
assert.equal(parseEvidence(pseudocode.evidence).provider.id, "ghidra");
|
||||
assert.equal(parseEvidence(pseudocode).provider.id, "ghidra");
|
||||
assert.ok(
|
||||
JSON.stringify(evidence.normalized_result).includes("firmware_probe"),
|
||||
);
|
||||
|
||||
@@ -170,8 +170,8 @@ async function runMcp(name, arguments_) {
|
||||
true,
|
||||
JSON.stringify(response.structuredContent),
|
||||
);
|
||||
assert.ok(response.structuredContent?.result);
|
||||
return response.structuredContent.result;
|
||||
assert.ok(response.structuredContent?.normalized_result);
|
||||
return response.structuredContent.normalized_result;
|
||||
}
|
||||
|
||||
async function inspectorUrl(process_) {
|
||||
|
||||
@@ -45,7 +45,7 @@ const call = async (name, args) => {
|
||||
timeout: 150000,
|
||||
});
|
||||
assert.notEqual(result.isError, true, mcpTextValue(result));
|
||||
return parseEvidence(JSON.parse(mcpTextValue(result)).evidence);
|
||||
return parseEvidence(JSON.parse(mcpTextValue(result)));
|
||||
};
|
||||
const fingerprint = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const counts = [];
|
||||
|
||||
@@ -47,8 +47,8 @@ export async function connectRecordedCrash({ entrypoint, environment }) {
|
||||
return value.error;
|
||||
}
|
||||
assert.notEqual(response.isError, true, JSON.stringify(value));
|
||||
evidence = parseEvidence(value.evidence);
|
||||
assert.deepEqual(value.result, evidence.normalized_result);
|
||||
evidence = parseEvidence(value);
|
||||
assert.deepEqual(value.normalized_result, evidence.normalized_result);
|
||||
} else {
|
||||
assert.equal(mode, "cli");
|
||||
let response;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Evidence, comparison, and verification workflows
|
||||
|
||||
REA returns Evidence with each result. Read that result directly, cite its
|
||||
Evidence ID when another tool or your explanation needs a stable reference, and
|
||||
preserve authority, limitations, coverage, and residual unknowns. If a result
|
||||
Evidence-producing tools return the complete Evidence record directly in MCP
|
||||
`structuredContent` and CLI JSON. Read `normalized_result` for operation data,
|
||||
and cite `evidence_id` when another tool or your explanation needs a stable
|
||||
reference. Preserve authority, limitations, coverage, and residual unknowns. If a result
|
||||
is incomplete or paginated, continue only when the remaining data matters to
|
||||
the task; do not fetch a bundle or resource merely to read a result already
|
||||
returned inline. When complete `inspect_binary_layout` or
|
||||
|
||||
@@ -100,7 +100,10 @@ export class JadxMcpTransport implements Transport {
|
||||
|
||||
/** Verifies owned process exit before declaring the transport closed. */
|
||||
close(): Promise<void> {
|
||||
this.#closePromise ??= this.#stop();
|
||||
this.#closePromise ??= this.#stop().catch((cause: unknown) => {
|
||||
this.#closePromise = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
|
||||
+67
-51
@@ -14,6 +14,7 @@ import {
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -103,18 +104,23 @@ const executionError = (context: {
|
||||
});
|
||||
};
|
||||
|
||||
const cleanup = async <Value>(
|
||||
session: JadxSession | undefined,
|
||||
root: PrivateRuntimeRoot | undefined,
|
||||
previous: Result<Value, AnalysisError>,
|
||||
): Promise<Result<Value, AnalysisError>> => {
|
||||
interface PendingCleanup {
|
||||
session: JadxSession | undefined;
|
||||
root: PrivateRuntimeRoot | undefined;
|
||||
readonly previousError: AnalysisError | undefined;
|
||||
}
|
||||
|
||||
const cleanup = async (
|
||||
resources: PendingCleanup,
|
||||
): Promise<Result<void, AnalysisError>> => {
|
||||
const { session, root, previousError } = resources;
|
||||
const diagnostics = {
|
||||
previous_error: previous.ok
|
||||
? null
|
||||
: { tag: previous.error._tag, message: previous.error.message },
|
||||
previous_error:
|
||||
previousError === undefined ? null : projectAnalysisError(previousError),
|
||||
};
|
||||
try {
|
||||
await session?.close();
|
||||
resources.session = undefined;
|
||||
} catch (cause) {
|
||||
// Retain an uncertain workspace until the caller can resolve process ownership.
|
||||
return err(
|
||||
@@ -132,12 +138,13 @@ const cleanup = async <Value>(
|
||||
? (cause.diagnostics ?? null)
|
||||
: null,
|
||||
},
|
||||
{ cause },
|
||||
{ cause: previousError ?? cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await root?.close();
|
||||
resources.root = undefined;
|
||||
} catch (cause) {
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
@@ -147,11 +154,11 @@ const cleanup = async <Value>(
|
||||
...diagnostics,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
{ cause },
|
||||
{ cause: previousError ?? cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
return previous;
|
||||
return ok(undefined);
|
||||
};
|
||||
|
||||
interface RetainedSession {
|
||||
@@ -166,15 +173,13 @@ interface RetainedSession {
|
||||
/** One serialized, immutable APK session per REA instance, retired on idle or shutdown. */
|
||||
export class JadxProvider implements AndroidAnalysisPort {
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#cleanupFailure: AnalysisError | undefined;
|
||||
#pendingCleanup: PendingCleanup | undefined;
|
||||
#retained: RetainedSession | undefined;
|
||||
#idle: NodeJS.Timeout | undefined;
|
||||
readonly #shutdown = new AbortController();
|
||||
#closePromise: Promise<void> | undefined;
|
||||
constructor(
|
||||
readonly environment: Readonly<
|
||||
Record<string, string | undefined>
|
||||
> = process.env,
|
||||
readonly environment: Readonly<Record<string, string | undefined>>,
|
||||
readonly launcher?: JadxLauncher,
|
||||
) {}
|
||||
|
||||
@@ -209,11 +214,12 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
try {
|
||||
if (signal.aborted)
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
if (this.#cleanupFailure !== undefined) return err(this.#cleanupFailure);
|
||||
clearTimeout(this.#idle);
|
||||
if (this.#pendingCleanup !== undefined) {
|
||||
const retired = await this.#retire();
|
||||
if (!retired.ok) return retired;
|
||||
}
|
||||
const outcome = await this.#execute(target, request, { signal });
|
||||
if (!outcome.ok && outcome.error.cleanupIncomplete)
|
||||
this.#cleanupFailure = outcome.error;
|
||||
if (outcome.ok) this.#scheduleIdle();
|
||||
return outcome;
|
||||
} finally {
|
||||
@@ -225,29 +231,38 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
close(): Promise<void> {
|
||||
this.#shutdown.abort();
|
||||
clearTimeout(this.#idle);
|
||||
this.#closePromise ??= this.#tail.then(() => this.#retire());
|
||||
this.#closePromise ??= this.#tail
|
||||
.then(async () => {
|
||||
const retired = await this.#retire();
|
||||
if (!retired.ok) throw retired.error;
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
this.#closePromise = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
async #retire(): Promise<void> {
|
||||
if (this.#cleanupFailure !== undefined) throw this.#cleanupFailure;
|
||||
const retained = this.#retained;
|
||||
this.#retained = undefined;
|
||||
const result = await cleanup(
|
||||
retained?.session,
|
||||
retained?.root,
|
||||
ok(undefined),
|
||||
);
|
||||
if (!result.ok) {
|
||||
this.#cleanupFailure = result.error;
|
||||
throw result.error;
|
||||
async #retire(): Promise<Result<void, AnalysisError>> {
|
||||
if (this.#pendingCleanup === undefined && this.#retained !== undefined) {
|
||||
this.#pendingCleanup = {
|
||||
session: this.#retained.session,
|
||||
root: this.#retained.root,
|
||||
previousError: undefined,
|
||||
};
|
||||
this.#retained = undefined;
|
||||
}
|
||||
if (this.#pendingCleanup === undefined) return ok(undefined);
|
||||
const result = await cleanup(this.#pendingCleanup);
|
||||
if (result.ok) this.#pendingCleanup = undefined;
|
||||
return result;
|
||||
}
|
||||
|
||||
#scheduleIdle(): void {
|
||||
this.#idle = setTimeout(() => {
|
||||
const retirement = this.#tail.then(() => this.#retire());
|
||||
this.#tail = retirement.catch(() => undefined);
|
||||
this.#tail = this.#tail.then(async () => {
|
||||
await this.#retire();
|
||||
});
|
||||
}, 60_000);
|
||||
this.#idle.unref();
|
||||
}
|
||||
@@ -264,7 +279,6 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
: AbortSignal.any([options.signal, timeout]);
|
||||
let root: PrivateRuntimeRoot | undefined;
|
||||
let session: JadxSession | undefined;
|
||||
let outcome: Outcome;
|
||||
try {
|
||||
if (target.format !== "apk")
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
@@ -305,8 +319,10 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
this.environment.JAVA_HOME,
|
||||
],
|
||||
});
|
||||
if (this.#retained !== undefined && this.#retained.key !== key)
|
||||
await this.#retire();
|
||||
if (this.#retained !== undefined && this.#retained.key !== key) {
|
||||
const retired = await this.#retire();
|
||||
if (!retired.ok) return retired;
|
||||
}
|
||||
let retained = this.#retained;
|
||||
if (retained === undefined) {
|
||||
root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" });
|
||||
@@ -359,7 +375,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
root = retained.root;
|
||||
session = retained.session;
|
||||
const { snapshot } = retained;
|
||||
outcome = ok(
|
||||
const outcome = ok(
|
||||
await session.execute({
|
||||
request,
|
||||
target,
|
||||
@@ -373,20 +389,20 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
this.#retained = retained;
|
||||
return outcome;
|
||||
} catch (cause) {
|
||||
outcome = err(
|
||||
executionError({
|
||||
cause,
|
||||
request,
|
||||
timeout,
|
||||
signal: options?.signal,
|
||||
session,
|
||||
}),
|
||||
);
|
||||
const previousError = executionError({
|
||||
cause,
|
||||
request,
|
||||
timeout,
|
||||
signal: options?.signal,
|
||||
session,
|
||||
});
|
||||
// Admission can fail before selecting an existing session for execution.
|
||||
root ??= this.#retained?.root;
|
||||
session ??= this.#retained?.session;
|
||||
this.#retained = undefined;
|
||||
this.#pendingCleanup = { session, root, previousError };
|
||||
const retired = await this.#retire();
|
||||
return retired.ok ? err(previousError) : retired;
|
||||
}
|
||||
// An existing session also needs retirement when admission fails before selection.
|
||||
root ??= this.#retained?.root;
|
||||
session ??= this.#retained?.session;
|
||||
this.#retained = undefined;
|
||||
return cleanup(session, root, outcome);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import {
|
||||
type AnalysisOperationPort,
|
||||
} from "./AnalysisProvider.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
AnalysisProtocolError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
@@ -94,111 +92,6 @@ describe("analysis context queries: navigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis context queries: headless document resolution", () => {
|
||||
it("resolves a sole headless document when current-document selection is unavailable", async () => {
|
||||
const calls: string[] = [];
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: (operation) => {
|
||||
calls.push(operation);
|
||||
if (operation === "current_document")
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
operation,
|
||||
"headless sessions have no cursor-selected document",
|
||||
),
|
||||
),
|
||||
);
|
||||
if (operation === "list_documents")
|
||||
return Promise.resolve(
|
||||
ok(createAnalysisExecution(["fixture"], provider)),
|
||||
);
|
||||
if (operation === "resolve_containing_procedure")
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
query_address: "0x401000",
|
||||
found: false,
|
||||
procedure: null,
|
||||
reason: "not_in_procedure",
|
||||
},
|
||||
provider,
|
||||
),
|
||||
),
|
||||
);
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
operation,
|
||||
"unsupported",
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
inspectAddressContext(analysis, { address: "0x401000" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
document: "fixture",
|
||||
name: { state: "unavailable" },
|
||||
procedure: { state: "available" },
|
||||
comment: { state: "unavailable" },
|
||||
inline_comment: { state: "unavailable" },
|
||||
bookmarks: { state: "unavailable" },
|
||||
},
|
||||
});
|
||||
expect(calls.slice(0, 2)).toEqual(["current_document", "list_documents"]);
|
||||
});
|
||||
|
||||
it("does not use list-documents fallback for non-capability document errors", async () => {
|
||||
const failure = new AnalysisProtocolError(
|
||||
"current-document response malformed",
|
||||
);
|
||||
const calls: string[] = [];
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: (operation) => {
|
||||
calls.push(operation);
|
||||
return Promise.resolve(err(failure));
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
inspectAddressContext(analysis, { address: "0x401000" }),
|
||||
).resolves.toEqual(err(failure));
|
||||
expect(calls).toEqual(["current_document"]);
|
||||
});
|
||||
|
||||
it("requires exactly one listed document for a headless fallback", async () => {
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: (operation) =>
|
||||
Promise.resolve(
|
||||
operation === "current_document"
|
||||
? err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
operation,
|
||||
"no selected document",
|
||||
),
|
||||
)
|
||||
: ok(createAnalysisExecution(["one", "two"], provider)),
|
||||
),
|
||||
};
|
||||
|
||||
await expect(
|
||||
inspectAddressContext(analysis, { address: "0x401000" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: expect.any(AnalysisOutputError),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis context queries: address facets", () => {
|
||||
it("keeps unsupported address facets local and filters bookmarks", async () => {
|
||||
const bookmarkInputs: Readonly<Record<string, unknown>>[] = [];
|
||||
|
||||
@@ -102,13 +102,12 @@ export interface AnalysisClient extends AnalysisOperationPort {
|
||||
requestActivitySnapshots?(): readonly ProviderRequestActivitySnapshot[];
|
||||
/** Current operational health for the active provider bridge. */
|
||||
operationHealthSnapshot?(): ProviderOperationHealth | undefined;
|
||||
/** Close with a typed result when the provider can verify cleanup. */
|
||||
closeWithOutcome?(
|
||||
/** Close owned resources and retain failed cleanup for a later retry. */
|
||||
close(
|
||||
options?: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly retainDocument?: boolean;
|
||||
},
|
||||
): Promise<Result<null, AnalysisError>>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Per-client identity allocated before any provider side effect starts. */
|
||||
@@ -116,22 +115,15 @@ export interface AnalysisClientContext {
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
export type AnalysisClientFactory = (
|
||||
target: BinaryTarget,
|
||||
profile?: AnalysisProfileCommitment,
|
||||
context?: AnalysisClientContext,
|
||||
) => AnalysisClient;
|
||||
|
||||
export interface ProviderIdentity {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly version: string | null;
|
||||
}
|
||||
|
||||
/** Profile plus opaque compatibility output resolved before provider startup. */
|
||||
/** Typed analysis profile resolved before provider startup; null means unresolved. */
|
||||
export interface AnalysisProfileResolution {
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Cancellation context for side-effect-free provider profile resolution. */
|
||||
|
||||
@@ -100,11 +100,7 @@ const browserParameters = (
|
||||
result: BrowserEvidenceResult,
|
||||
): EvidenceObservation["parameters"] => {
|
||||
if (!("cdp_endpoint" in input)) {
|
||||
if (
|
||||
"before_scenario" in input &&
|
||||
input.before_scenario !== undefined &&
|
||||
input.after_scenario !== undefined
|
||||
)
|
||||
if ("before_scenario" in input)
|
||||
return {
|
||||
comparison_kind: "browser_scenario",
|
||||
before_browser: input.before_scenario.browser,
|
||||
@@ -112,15 +108,10 @@ const browserParameters = (
|
||||
before_start_origin: input.before_scenario.scenario.start_origin,
|
||||
after_start_origin: input.after_scenario.scenario.start_origin,
|
||||
normalization_sha256: commitBrowserScenarioNormalization(
|
||||
input.normalization ?? { rules: [] },
|
||||
input.normalization,
|
||||
).sha256,
|
||||
};
|
||||
if (
|
||||
input.before !== undefined &&
|
||||
input.after !== undefined &&
|
||||
"inspection" in input.before &&
|
||||
"inspection" in input.after
|
||||
)
|
||||
if ("inspection" in input.before && "inspection" in input.after)
|
||||
return {
|
||||
before_target_id: input.before.inspection.target.target_id,
|
||||
before_capture_ended_at:
|
||||
|
||||
@@ -2,38 +2,18 @@ import { canonicalDigest } from "../domain/comparisonSemantics.js";
|
||||
import type { ProviderIdentity } from "./AnalysisProvider.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import type { BrowserScenarioCapture } from "../domain/browserScenarioCapture.js";
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import {
|
||||
createEvidence,
|
||||
type Evidence,
|
||||
type EvidenceObservation,
|
||||
} from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js";
|
||||
|
||||
const browserScenarioParameters = (
|
||||
scenario: BrowserScenario,
|
||||
): EvidenceObservation["parameters"] => ({
|
||||
...jsonObjectSchema.parse(scenario),
|
||||
scenario_sha256: canonicalDigest(scenario, "Browser scenario"),
|
||||
browser_mode: scenario.browser.mode,
|
||||
...(scenario.browser.mode === "launch"
|
||||
? { browser_headless: scenario.browser.headless }
|
||||
: {}),
|
||||
start_url: sanitizeBrowserUrl(scenario.start_url.url),
|
||||
environment: scenario.environment,
|
||||
actions: scenario.actions.map(({ step_id, action }) => ({
|
||||
step_id,
|
||||
action,
|
||||
})),
|
||||
action_timeouts_ms: scenario.actions.map((item) =>
|
||||
"timeout_ms" in item ? (item.timeout_ms ?? null) : null,
|
||||
),
|
||||
secret_declarations: scenario.secrets.map(
|
||||
({ secret_id, environment_variable }) => ({
|
||||
secret_id,
|
||||
environment_variable,
|
||||
}),
|
||||
),
|
||||
capture: scenario.capture,
|
||||
});
|
||||
|
||||
/** Create Evidence without retaining resolved scenario secret values. */
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ExecutableFormatHint } from "../domain/dosCom.js";
|
||||
import { parseConfig } from "../config.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { EnhancedTools } from "./EnhancedTools.js";
|
||||
import { executeFunctionAnalysisEvidence } from "./FunctionAnalysisEvidence.js";
|
||||
@@ -132,7 +131,7 @@ export const runProviderAnalysis = async (
|
||||
|
||||
/** Execute managed metadata inspection in an isolated managed-only session. */
|
||||
export const runManagedProviderExecution = async (
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
dependencies: Pick<DirectAnalysisDependencies, "createManagedBinarySession">,
|
||||
path: string,
|
||||
tool: ManagedToolName,
|
||||
signal?: AbortSignal,
|
||||
@@ -189,18 +188,10 @@ const runAnalysis = async (
|
||||
readonly signal: AbortSignal;
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
readonly formatHint?: ExecutableFormatHint;
|
||||
/**
|
||||
* Environment the configuration is read from. Defaults to the process
|
||||
* environment so existing callers are unchanged, but a caller may supply
|
||||
* one — which is what lets the MCP path's injected environment reach this
|
||||
* code, and what makes configuration-driven behaviour testable without
|
||||
* mutating `process.env`.
|
||||
*/
|
||||
readonly environment?: Readonly<Record<string, string | undefined>>;
|
||||
},
|
||||
): Promise<JsonValue> => {
|
||||
const { logger, signal, snapshotPath } = options;
|
||||
const config = parseConfig(options.environment ?? process.env);
|
||||
const config = dependencies.readConfiguration();
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const session = dependencies.createBinarySession(config.value, logger);
|
||||
return withSessionCleanup(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { AppConfig } from "../config.js";
|
||||
import type { AppConfig, parseConfig } from "../config.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { BinarySession } from "./binary/BinarySession.js";
|
||||
|
||||
/** Existing one-shot session factories supplied by the production boundary. */
|
||||
/** Configuration and one-shot session factories supplied by the production boundary. */
|
||||
export interface DirectAnalysisDependencies {
|
||||
/** Read the caller-selected configuration without consulting ambient state. */
|
||||
readonly readConfiguration: () => ReturnType<typeof parseConfig>;
|
||||
readonly createBinarySession: (
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { parseConfig } from "../config.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { createServerIdentity } from "../serverIdentity.js";
|
||||
@@ -6,11 +5,13 @@ import { silentLogger, type Logger } from "../logger.js";
|
||||
import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js";
|
||||
|
||||
const runSessionStatus = async (
|
||||
dependencies: Pick<DirectAnalysisDependencies, "createBinarySession">,
|
||||
dependencies: Pick<
|
||||
DirectAnalysisDependencies,
|
||||
"createBinarySession" | "readConfiguration"
|
||||
>,
|
||||
logger: Logger = silentLogger,
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): Promise<JsonValue> => {
|
||||
const config = parseConfig(environment);
|
||||
const config = dependencies.readConfiguration();
|
||||
if (!config.ok) return { error: projectAnalysisError(config.error) };
|
||||
const session = dependencies.createBinarySession(config.value, logger);
|
||||
try {
|
||||
@@ -27,14 +28,18 @@ const runSessionStatus = async (
|
||||
|
||||
/** List binary-session provider candidates and auxiliary operation availability. */
|
||||
export const runProviderStatus = (
|
||||
dependencies: Pick<DirectAnalysisDependencies, "createBinarySession">,
|
||||
dependencies: Pick<
|
||||
DirectAnalysisDependencies,
|
||||
"createBinarySession" | "readConfiguration"
|
||||
>,
|
||||
logger: Logger = silentLogger,
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
) => runSessionStatus(dependencies, logger, environment);
|
||||
) => runSessionStatus(dependencies, logger);
|
||||
|
||||
/** List binary-session operation descriptors; this is not the full MCP catalog. */
|
||||
export const runCapabilityStatus = (
|
||||
dependencies: Pick<DirectAnalysisDependencies, "createBinarySession">,
|
||||
dependencies: Pick<
|
||||
DirectAnalysisDependencies,
|
||||
"createBinarySession" | "readConfiguration"
|
||||
>,
|
||||
logger: Logger = silentLogger,
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
) => runSessionStatus(dependencies, logger, environment);
|
||||
) => runSessionStatus(dependencies, logger);
|
||||
|
||||
@@ -7,6 +7,7 @@ export const createDoctorHostFixture = (
|
||||
overrides: Partial<DoctorHost> = {},
|
||||
): DoctorHost => ({
|
||||
platform: "darwin",
|
||||
homeDirectory: "/fixture/home",
|
||||
architecture: "x64",
|
||||
nodeVersion: "24.18.0",
|
||||
macosVersion: () => Promise.resolve("14.0"),
|
||||
|
||||
+10
-27
@@ -1,7 +1,7 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, readFile, readdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js";
|
||||
import { parseBinaryTarget } from "./BinaryTargetResolver.js";
|
||||
@@ -76,7 +76,7 @@ export interface DoctorHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly architecture: NodeJS.Architecture;
|
||||
readonly nodeVersion: string;
|
||||
readonly homeDirectory?: string;
|
||||
readonly homeDirectory: string;
|
||||
readonly configuredHopperPath?: string;
|
||||
readonly configuredIlspyCmdPath?: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
@@ -287,21 +287,6 @@ const installationState = (
|
||||
): DoctorIdentity["installations"]["state"] =>
|
||||
paths.length === 0 ? "unknown" : paths.length === 1 ? "single" : "multiple";
|
||||
|
||||
const homeFromEnvironment = (
|
||||
environment: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform,
|
||||
): string => {
|
||||
const selected =
|
||||
platform === "win32"
|
||||
? (environment.USERPROFILE ??
|
||||
(environment.HOMEDRIVE !== undefined &&
|
||||
environment.HOMEPATH !== undefined
|
||||
? `${environment.HOMEDRIVE}${environment.HOMEPATH}`
|
||||
: environment.HOME))
|
||||
: (environment.HOME ?? environment.USERPROFILE);
|
||||
return selected ?? homedir();
|
||||
};
|
||||
|
||||
/** Optional outer-adapter diagnostics composed without reversing dependencies. */
|
||||
export interface SystemDoctorHostOptions {
|
||||
readonly platform?: NodeJS.Platform;
|
||||
@@ -322,7 +307,7 @@ export const systemDoctorHost = (
|
||||
const architecture = options.architecture ?? process.arch;
|
||||
const environment = options.environment ?? process.env;
|
||||
const hostExecFileOutput = options.execFileOutput ?? execFileOutput;
|
||||
const homeDirectory = homeFromEnvironment(environment, platform);
|
||||
const homeDirectory = homeDirectoryFromEnvironment(environment, platform);
|
||||
const commandEnvironment = { env: environment };
|
||||
return {
|
||||
platform,
|
||||
@@ -451,8 +436,8 @@ export const systemDoctorHost = (
|
||||
};
|
||||
|
||||
const readMacosVersion = async (
|
||||
run: typeof execFileOutput = execFileOutput,
|
||||
options: { readonly env: NodeJS.ProcessEnv } = { env: process.env },
|
||||
run: typeof execFileOutput,
|
||||
options: { readonly env: NodeJS.ProcessEnv },
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
return (await run("sw_vers", ["-productVersion"], options)).stdout.trim();
|
||||
@@ -465,9 +450,9 @@ const readMacosVersion = async (
|
||||
|
||||
const executableAvailable = async (
|
||||
path: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
run: typeof execFileOutput = execFileOutput,
|
||||
options: { readonly env: NodeJS.ProcessEnv } = { env: process.env },
|
||||
platform: NodeJS.Platform,
|
||||
run: typeof execFileOutput,
|
||||
options: { readonly env: NodeJS.ProcessEnv },
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await access(path, constants.X_OK);
|
||||
@@ -490,9 +475,7 @@ const uncomposedLinuxDemoRuntimeCheck = (): Promise<DoctorCheck> =>
|
||||
remediation: "Run rea doctor through the production CLI adapter.",
|
||||
});
|
||||
|
||||
const manualHopperPaths = async (
|
||||
home: string = homedir(),
|
||||
): Promise<readonly string[]> => {
|
||||
const manualHopperPaths = async (home: string): Promise<readonly string[]> => {
|
||||
const paths: string[] = [];
|
||||
for (const root of ["/Applications", join(home, "Applications")]) {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import {
|
||||
SUPPORTED_NODE_VERSION_PROSE,
|
||||
supportsNodeVersion,
|
||||
@@ -126,7 +124,7 @@ const findHopper = async (host: DoctorHost): Promise<string | undefined> => {
|
||||
? [
|
||||
DEFAULT_HOPPER,
|
||||
SYSTEM_LINUX_HOPPER,
|
||||
linuxHopperLauncherPath(host.homeDirectory ?? homedir()),
|
||||
linuxHopperLauncherPath(host.homeDirectory),
|
||||
...(await host.manualHopperPaths()),
|
||||
]
|
||||
: [host.configuredHopperPath];
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evidenceInputSchema } from "../contracts/evidenceInputContracts.js";
|
||||
import { JAVASCRIPT_FEATURE_TRACE_EXAMPLE } from "../contracts/javascript/javascriptApplicationWorkflowExamples.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { EvidenceLedger } from "./investigation/EvidenceLedger.js";
|
||||
import { resolveEvidenceInput } from "./EvidenceInputResolver.js";
|
||||
|
||||
const evidence = JAVASCRIPT_FEATURE_TRACE_EXAMPLE.application;
|
||||
@@ -12,24 +10,6 @@ const reference = {
|
||||
};
|
||||
|
||||
describe("exact Evidence input resolution", () => {
|
||||
it("uses the session's detached canonical record without modifying it", () => {
|
||||
const ledger = new EvidenceLedger();
|
||||
expect(ledger.record(evidence).ok).toBe(true);
|
||||
const result = resolveEvidenceInput(reference, (id) => ledger.get(id));
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toEqual(evidence);
|
||||
result.value.limitations.push("caller-owned change");
|
||||
expect(ledger.get(evidence.evidence_id)).toEqual(evidence);
|
||||
});
|
||||
|
||||
it("leaves portable inline inputs independent of session lookup", () => {
|
||||
expect(
|
||||
resolveEvidenceInput(evidence, () => {
|
||||
throw new Error("unexpected lookup");
|
||||
}),
|
||||
).toEqual({ ok: true, value: evidence });
|
||||
});
|
||||
|
||||
it("reports a missing reference and an available recovery", () => {
|
||||
for (const lookup of [undefined, () => undefined]) {
|
||||
const result = resolveEvidenceInput(reference, lookup);
|
||||
@@ -64,17 +44,4 @@ describe("exact Evidence input resolution", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses only complete inline records or strict exact references", () => {
|
||||
expect(evidenceInputSchema.safeParse(reference).success).toBe(true);
|
||||
expect(evidenceInputSchema.safeParse(evidence).success).toBe(true);
|
||||
for (const invalid of [
|
||||
{ evidence_id: evidence.evidence_id },
|
||||
{ ...reference, kind: "another-session" },
|
||||
{ ...reference, evidence_id: "ev_short" },
|
||||
{ ...reference, session_id: "foreign" },
|
||||
{ ...reference, normalized_result: {} },
|
||||
])
|
||||
expect(evidenceInputSchema.safeParse(invalid).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,7 +177,7 @@ export const readLinuxDistribution = async (): Promise<
|
||||
|
||||
/** Download, verify, install, and read back the official Hopper Linux package. */
|
||||
export const installLinuxHopper = async (
|
||||
host: LinuxHopperInstallHost = systemLinuxHopperInstallHost(),
|
||||
host: LinuxHopperInstallHost = systemLinuxHopperInstallHost(process.env),
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly releases?: Readonly<
|
||||
@@ -225,7 +225,10 @@ export const installLinuxHopper = async (
|
||||
export const linuxHopperLauncherPath = (home: string): string =>
|
||||
join(home, ".local/share/rea/hopper/bin/Hopper");
|
||||
|
||||
const systemLinuxHopperInstallHost = (): LinuxHopperInstallHost => ({
|
||||
/** Compose package-manager and linker checks from the selected caller environment. */
|
||||
export const systemLinuxHopperInstallHost = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): LinuxHopperInstallHost => ({
|
||||
distribution: readLinuxDistribution,
|
||||
async download(url, options) {
|
||||
const response = await fetch(url, {
|
||||
@@ -239,11 +242,12 @@ const systemLinuxHopperInstallHost = (): LinuxHopperInstallHost => ({
|
||||
},
|
||||
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
|
||||
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
||||
installPackage: installSystemPackage,
|
||||
installPackage: (family, archive) =>
|
||||
installSystemPackage(family, archive, environment),
|
||||
async launcherStatus(path) {
|
||||
try {
|
||||
await access(path);
|
||||
const linked = await execFileOutput("ldd", [path]);
|
||||
const linked = await execFileOutput("ldd", [path], { env: environment });
|
||||
if (!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`))
|
||||
return "runtime_dependencies";
|
||||
return (await linuxHopperBinarySupported(path))
|
||||
@@ -284,6 +288,7 @@ export const linuxSharedLibrariesAvailable = (output: string): boolean =>
|
||||
const installSystemPackage = async (
|
||||
family: LinuxPackageFamily,
|
||||
archive: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
for (const command of linuxPackageManagerCommands(
|
||||
@@ -291,7 +296,9 @@ const installSystemPackage = async (
|
||||
archive,
|
||||
process.getuid?.() === 0,
|
||||
))
|
||||
await execFileAsync(command.executable, command.args);
|
||||
await execFileAsync(command.executable, command.args, {
|
||||
env: environment,
|
||||
});
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
// Package-manager failure is reported by the false return.
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { z } from "zod";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const RELEASES_URL =
|
||||
@@ -77,7 +78,10 @@ export interface MacHopperInstallHost {
|
||||
/** Download, verify, and install Hopper into the current user's Applications directory. */
|
||||
export const installMacHopper = async (
|
||||
options: { readonly replaceExisting?: boolean } = {},
|
||||
host: MacHopperInstallHost = systemMacHopperInstallHost(),
|
||||
host: MacHopperInstallHost = systemMacHopperInstallHost(
|
||||
homeDirectoryFromEnvironment(process.env, process.platform),
|
||||
process.env,
|
||||
),
|
||||
): Promise<MacHopperInstallResult> => {
|
||||
let temporary: string | undefined;
|
||||
let mounted = false;
|
||||
@@ -133,23 +137,35 @@ export const installMacHopper = async (
|
||||
}
|
||||
};
|
||||
|
||||
const systemMacHopperInstallHost = (): MacHopperInstallHost => ({
|
||||
/** Compose installer paths and subprocesses from one selected caller context. */
|
||||
export const systemMacHopperInstallHost = (
|
||||
homeDirectory: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): MacHopperInstallHost => ({
|
||||
download: downloadPackage,
|
||||
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")),
|
||||
createMountDirectory: (path) => mkdir(path),
|
||||
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
||||
async mount(packagePath, mountPath) {
|
||||
return commandSucceeds("hdiutil", [
|
||||
"attach",
|
||||
"-readonly",
|
||||
"-nobrowse",
|
||||
"-mountpoint",
|
||||
mountPath,
|
||||
packagePath,
|
||||
]);
|
||||
return commandSucceeds(
|
||||
"hdiutil",
|
||||
[
|
||||
"attach",
|
||||
"-readonly",
|
||||
"-nobrowse",
|
||||
"-mountpoint",
|
||||
mountPath,
|
||||
packagePath,
|
||||
],
|
||||
environment,
|
||||
);
|
||||
},
|
||||
async unmount(mountPath) {
|
||||
await commandSucceeds("hdiutil", ["detach", mountPath, "-force"]);
|
||||
await commandSucceeds(
|
||||
"hdiutil",
|
||||
["detach", mountPath, "-force"],
|
||||
environment,
|
||||
);
|
||||
},
|
||||
async appBundle(mountPath) {
|
||||
const entries = await readdir(mountPath, { withFileTypes: true });
|
||||
@@ -160,21 +176,24 @@ const systemMacHopperInstallHost = (): MacHopperInstallHost => ({
|
||||
},
|
||||
async validBundle(appPath) {
|
||||
if (
|
||||
!(await commandSucceeds("codesign", [
|
||||
"--verify",
|
||||
"--deep",
|
||||
"--strict",
|
||||
appPath,
|
||||
]))
|
||||
!(await commandSucceeds(
|
||||
"codesign",
|
||||
["--verify", "--deep", "--strict", appPath],
|
||||
environment,
|
||||
))
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
const identifier = (
|
||||
await execFileAsync("/usr/libexec/PlistBuddy", [
|
||||
"-c",
|
||||
"Print :CFBundleIdentifier",
|
||||
join(appPath, "Contents/Info.plist"),
|
||||
])
|
||||
await execFileAsync(
|
||||
"/usr/libexec/PlistBuddy",
|
||||
[
|
||||
"-c",
|
||||
"Print :CFBundleIdentifier",
|
||||
join(appPath, "Contents/Info.plist"),
|
||||
],
|
||||
{ env: environment },
|
||||
)
|
||||
).stdout.trim();
|
||||
return identifier === BUNDLE_IDENTIFIER;
|
||||
} catch (cause: unknown) {
|
||||
@@ -196,10 +215,10 @@ const systemMacHopperInstallHost = (): MacHopperInstallHost => ({
|
||||
async installBundle(source, destination, stage, replaceExisting) {
|
||||
const previous = `${stage}-previous`;
|
||||
try {
|
||||
await mkdir(join(homedir(), "Applications"), { recursive: true });
|
||||
await mkdir(join(homeDirectory, "Applications"), { recursive: true });
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
await rm(previous, { recursive: true, force: true });
|
||||
await execFileAsync("ditto", [source, stage]);
|
||||
await execFileAsync("ditto", [source, stage], { env: environment });
|
||||
await access(join(stage, "Contents/MacOS/hopper"));
|
||||
if (replaceExisting) await rename(destination, previous);
|
||||
await rename(stage, destination);
|
||||
@@ -231,10 +250,11 @@ const systemMacHopperInstallHost = (): MacHopperInstallHost => ({
|
||||
}
|
||||
},
|
||||
async openApplication(path) {
|
||||
await execFileAsync("open", [path]);
|
||||
await execFileAsync("open", [path], { env: environment });
|
||||
},
|
||||
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
||||
destination: () => join(homedir(), "Applications/Hopper Disassembler.app"),
|
||||
destination: () =>
|
||||
join(homeDirectory, "Applications/Hopper Disassembler.app"),
|
||||
});
|
||||
|
||||
const downloadPackage = async (url: string) => {
|
||||
@@ -260,9 +280,10 @@ const integrityMatches = (
|
||||
const commandSucceeds = async (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await execFileAsync(command, args);
|
||||
await execFileAsync(command, args, { env: environment });
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
// best-effort cleanup: optional command probing; failure means unavailable.
|
||||
|
||||
@@ -253,7 +253,7 @@ describe("setup workflow action selection", () => {
|
||||
|
||||
expect(result.status).toBe("ready");
|
||||
expect(host.configuredCommands).toEqual([
|
||||
setupRegistrationCommand("win32"),
|
||||
setupRegistrationCommand("win32", false),
|
||||
]);
|
||||
expect(setupRegistrationCommand("win32", false)).toEqual([
|
||||
process.execPath,
|
||||
|
||||
@@ -12,10 +12,13 @@ import type { LinuxDistribution } from "./LinuxHopper.js";
|
||||
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
|
||||
import { CATALOG_IDENTITY } from "../catalogIdentity.js";
|
||||
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
|
||||
import { setupRegistrationCommand } from "./SetupHost.js";
|
||||
|
||||
/** Recording setup host for service-level planning and recovery tests. */
|
||||
export class FakeSetupHost implements SetupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly homeDirectory = "/fixture/home";
|
||||
readonly registrationCommand: readonly string[];
|
||||
nodeVersion = "24.18.0";
|
||||
version: string | undefined = "14.5";
|
||||
distribution: LinuxDistribution | undefined;
|
||||
@@ -45,26 +48,24 @@ export class FakeSetupHost implements SetupHost {
|
||||
|
||||
constructor(platform: NodeJS.Platform = "darwin") {
|
||||
this.platform = platform;
|
||||
this.registrationCommand = setupRegistrationCommand(platform, false);
|
||||
}
|
||||
|
||||
macosVersion = (): Promise<string | undefined> =>
|
||||
Promise.resolve(this.version);
|
||||
linuxDistribution = (): Promise<LinuxDistribution | undefined> =>
|
||||
Promise.resolve(this.distribution);
|
||||
hopperPath = (): Promise<string | undefined> => Promise.resolve(this.hopper);
|
||||
initialSetupState = async (scope?: DoctorScope) => ({
|
||||
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
|
||||
providerEnvironment: await this.providerEnvironment(),
|
||||
doctor: await this.doctor(scope),
|
||||
});
|
||||
providerEnvironment = (): Promise<SetupProviderEnvironment> =>
|
||||
Promise.resolve({
|
||||
providerEnvironment: {
|
||||
...(this.hopper === undefined
|
||||
? {}
|
||||
: { HOPPER_LAUNCHER_PATH: this.hopper }),
|
||||
...(this.ghidra === undefined ? {} : { GHIDRA_INSTALL_DIR: this.ghidra }),
|
||||
...(this.javaHome === undefined ? {} : { JAVA_HOME: this.javaHome }),
|
||||
});
|
||||
},
|
||||
doctor: await this.doctor(scope),
|
||||
});
|
||||
installHopper = (
|
||||
replaceExisting: boolean,
|
||||
): Promise<SetupHopperInstallResult> => {
|
||||
|
||||
@@ -5,12 +5,6 @@ import { FakeSetupHost, options } from "./Setup.fixture.js";
|
||||
|
||||
it("uses the shared initial host snapshot for setup planning", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopperPath = () => {
|
||||
throw new Error("setup snapshot should provide the Hopper path");
|
||||
};
|
||||
host.providerEnvironment = () => {
|
||||
throw new Error("setup snapshot should provide provider settings");
|
||||
};
|
||||
host.initialSetupState = async (scope) => ({
|
||||
hopperPath: "/snapshot/Hopper",
|
||||
providerEnvironment: { HOPPER_LAUNCHER_PATH: "/snapshot/Hopper" },
|
||||
|
||||
@@ -21,8 +21,6 @@ import { discoverSetupState, planSetupActions } from "./SetupPlan.js";
|
||||
import {
|
||||
filterClientsNeedingConfigure,
|
||||
hostRemediation,
|
||||
initialProviderEnvironment,
|
||||
setupRegistrationCommand,
|
||||
systemSetupHost,
|
||||
} from "./SetupHost.js";
|
||||
|
||||
@@ -45,25 +43,15 @@ export const runSetup = async (
|
||||
);
|
||||
const unsupported = await hostRemediation(host, false);
|
||||
if (unsupported !== undefined) return fail(unsupported);
|
||||
const initialState = await host.initialSetupState?.(options.readinessScope);
|
||||
let hopperPath =
|
||||
initialState === undefined
|
||||
? await host.hopperPath()
|
||||
: initialState.hopperPath;
|
||||
let providerEnvironment =
|
||||
initialState === undefined
|
||||
? await initialProviderEnvironment(host, hopperPath)
|
||||
: initialState.providerEnvironment;
|
||||
const initialState = await host.initialSetupState(options.readinessScope);
|
||||
let { hopperPath, providerEnvironment } = initialState;
|
||||
const discovery = await discoverSetupState({
|
||||
host,
|
||||
providerEnvironment,
|
||||
forceHopperInstall: options.installHopper,
|
||||
proposeHopper:
|
||||
options.proposeHopper ?? (confirm !== undefined && !options.structured),
|
||||
doctorScope: options.readinessScope,
|
||||
...(initialState === undefined
|
||||
? {}
|
||||
: { initialDoctor: initialState.doctor }),
|
||||
initialDoctor: initialState.doctor,
|
||||
});
|
||||
clientStates = discovery.clientStates;
|
||||
const clientSelectionAllowed =
|
||||
@@ -91,7 +79,7 @@ export const runSetup = async (
|
||||
discovery,
|
||||
host,
|
||||
providerEnvironment,
|
||||
command: setupRegistrationCommand(host.platform),
|
||||
command: host.registrationCommand,
|
||||
clientIds: [],
|
||||
installSkill: offerSkillAction,
|
||||
});
|
||||
@@ -143,7 +131,7 @@ export const runSetup = async (
|
||||
discovery: planDiscovery,
|
||||
host,
|
||||
providerEnvironment,
|
||||
command: setupRegistrationCommand(host.platform),
|
||||
command: host.registrationCommand,
|
||||
clientIds: selectedClientIds,
|
||||
installSkill,
|
||||
});
|
||||
@@ -241,7 +229,7 @@ export const runSetup = async (
|
||||
host,
|
||||
detectedClients: selectedClients,
|
||||
providerEnvironment,
|
||||
command: setupRegistrationCommand(host.platform),
|
||||
command: host.registrationCommand,
|
||||
clients,
|
||||
appliedActions,
|
||||
...(options.onProgress === undefined
|
||||
@@ -333,7 +321,7 @@ const installHopperAction = async (input: {
|
||||
input.host,
|
||||
input.selectedClients,
|
||||
providerEnvironment,
|
||||
setupRegistrationCommand(input.host.platform),
|
||||
input.host.registrationCommand,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
@@ -9,8 +8,12 @@ import {
|
||||
supportsNodeVersion,
|
||||
} from "../domain/runtimeVersion.js";
|
||||
import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js";
|
||||
import { installLinuxHopper, readLinuxDistribution } from "./LinuxHopper.js";
|
||||
import { installMacHopper } from "./MacHopper.js";
|
||||
import {
|
||||
installLinuxHopper,
|
||||
readLinuxDistribution,
|
||||
systemLinuxHopperInstallHost,
|
||||
} from "./LinuxHopper.js";
|
||||
import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js";
|
||||
import { supportedClients, type SetupClient } from "./SupportedClients.js";
|
||||
import {
|
||||
canonicalSkillNeedsInstall,
|
||||
@@ -33,7 +36,7 @@ import type { DoctorScope } from "./Doctor.js";
|
||||
/** Resolve the executable and arguments used in a managed MCP registration. */
|
||||
export const setupRegistrationCommand = (
|
||||
platform: NodeJS.Platform,
|
||||
useNpmRunner: boolean = process.env.npm_command === "exec",
|
||||
useNpmRunner: boolean,
|
||||
): readonly string[] =>
|
||||
useNpmRunner
|
||||
? npxRegistrationCommand(platform)
|
||||
@@ -59,14 +62,6 @@ export const filterClientsNeedingConfigure = async (
|
||||
return detectedClients.filter((_, index) => needs[index]);
|
||||
};
|
||||
|
||||
export const initialProviderEnvironment = async (
|
||||
host: SetupHost,
|
||||
hopperPath: string | undefined,
|
||||
): Promise<SetupProviderEnvironment> => ({
|
||||
...(await host.providerEnvironment?.()),
|
||||
...(hopperPath === undefined ? {} : { HOPPER_LAUNCHER_PATH: hopperPath }),
|
||||
});
|
||||
|
||||
export const hostRemediation = async (
|
||||
host: SetupHost,
|
||||
installHopper: boolean,
|
||||
@@ -88,15 +83,22 @@ export const hostRemediation = async (
|
||||
|
||||
/** Production setup effects for Hopper, agent configuration, and the canonical skill directory. */
|
||||
export const systemSetupHost = (
|
||||
doctorHost: DoctorHost = systemDoctorHost(),
|
||||
selectedDoctorHost: DoctorHost | undefined = undefined,
|
||||
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
): SetupHost => {
|
||||
const doctorHost = selectedDoctorHost ?? systemDoctorHost({ environment });
|
||||
const platform = doctorHost.platform;
|
||||
const { homeDirectory } = doctorHost;
|
||||
return {
|
||||
platform,
|
||||
homeDirectory,
|
||||
registrationCommand: setupRegistrationCommand(
|
||||
platform,
|
||||
environment.npm_command === "exec",
|
||||
),
|
||||
nodeVersion: process.versions.node,
|
||||
macosVersion: () => doctorHost.macosVersion(),
|
||||
linuxDistribution: readLinuxDistribution,
|
||||
hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath,
|
||||
initialSetupState: async (
|
||||
scope?: DoctorScope,
|
||||
): Promise<SetupInitialState> => {
|
||||
@@ -116,25 +118,20 @@ export const systemSetupHost = (
|
||||
doctor: diagnosis,
|
||||
};
|
||||
},
|
||||
providerEnvironment: async () => {
|
||||
const diagnosis = await runDoctor(undefined, doctorHost);
|
||||
return {
|
||||
...providerRegistrationEnvironment(diagnosis.providerInspections ?? []),
|
||||
...(diagnosis.hopperPath === undefined
|
||||
? {}
|
||||
: { HOPPER_LAUNCHER_PATH: diagnosis.hopperPath }),
|
||||
};
|
||||
},
|
||||
installHopper: async (replaceExisting) => {
|
||||
const result =
|
||||
platform === "linux"
|
||||
? await installLinuxHopper()
|
||||
: await installMacHopper({ replaceExisting });
|
||||
? await installLinuxHopper(systemLinuxHopperInstallHost(environment))
|
||||
: await installMacHopper(
|
||||
{ replaceExisting },
|
||||
systemMacHopperInstallHost(homeDirectory, environment),
|
||||
);
|
||||
if (result.status === "installed") return result;
|
||||
return setupInstallFailure(result.reason);
|
||||
},
|
||||
detectedClients: () => detectClients(homedir()),
|
||||
supportedClients: () => Promise.resolve(supportedClients(homedir())),
|
||||
detectedClients: () => detectClients(homeDirectory, platform, environment),
|
||||
supportedClients: () =>
|
||||
Promise.resolve(supportedClients(homeDirectory, platform, environment)),
|
||||
configureClient: (client, providerEnvironment, command) =>
|
||||
client.format === "unsupported"
|
||||
? Promise.resolve({ status: "skipped" })
|
||||
@@ -146,8 +143,8 @@ export const systemSetupHost = (
|
||||
(aligned) => !aligned,
|
||||
),
|
||||
inspectClientConfiguration: inspectClientConfiguration,
|
||||
skillNeedsInstall: () => canonicalSkillNeedsInstall(homedir()),
|
||||
installSkill: () => installCanonicalSkill(homedir()),
|
||||
skillNeedsInstall: () => canonicalSkillNeedsInstall(homeDirectory),
|
||||
installSkill: () => installCanonicalSkill(homeDirectory),
|
||||
doctor: (scope) => runDoctor(undefined, doctorHost, scope),
|
||||
};
|
||||
};
|
||||
@@ -155,9 +152,11 @@ export const systemSetupHost = (
|
||||
/** Detect supported agents from their config files or stable installation markers. */
|
||||
export const detectClients = async (
|
||||
home: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
): Promise<readonly SetupClient[]> => {
|
||||
const detected: SetupClient[] = [];
|
||||
for (const candidate of supportedClients(home)) {
|
||||
for (const candidate of supportedClients(home, platform, environment)) {
|
||||
const [hasConfig, hasMarker] = await Promise.all([
|
||||
exists(candidate.configPath),
|
||||
candidate.markerPath === undefined ? false : exists(candidate.markerPath),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
@@ -15,7 +14,6 @@ import type {
|
||||
SetupProviderEnvironment,
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type { DoctorScope } from "./Doctor.js";
|
||||
import type { DoctorReport } from "./Doctor.js";
|
||||
|
||||
/** Read-only setup facts shared by client selection and preflight. */
|
||||
@@ -36,11 +34,9 @@ export const discoverSetupState = async (input: {
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
readonly forceHopperInstall: boolean;
|
||||
readonly proposeHopper: boolean;
|
||||
readonly doctorScope: DoctorScope | undefined;
|
||||
readonly initialDoctor?: DoctorReport;
|
||||
readonly initialDoctor: DoctorReport;
|
||||
}): Promise<SetupDiscovery> => {
|
||||
const initialDoctor =
|
||||
input.initialDoctor ?? (await input.host.doctor(input.doctorScope));
|
||||
const { initialDoctor } = input;
|
||||
const linuxHopperRepairNeeded = initialDoctor.checks.some(
|
||||
({ name, ok, detail }) =>
|
||||
!ok &&
|
||||
@@ -62,7 +58,7 @@ export const discoverSetupState = async (input: {
|
||||
(Object.keys(input.providerEnvironment).length === 0 ||
|
||||
linuxHopperRepairNeeded));
|
||||
const [clients, detectedClients, skillNeedsInstall] = await Promise.all([
|
||||
input.host.supportedClients?.() ?? input.host.detectedClients(),
|
||||
input.host.supportedClients(),
|
||||
input.host.detectedClients(),
|
||||
input.host.skillNeedsInstall(),
|
||||
]);
|
||||
@@ -148,12 +144,11 @@ export const planSetupActions = async (input: {
|
||||
const inspections = await Promise.all(
|
||||
selectedClients.map(async (client) => ({
|
||||
client,
|
||||
inspection:
|
||||
(await input.host.inspectClientConfiguration?.(
|
||||
client,
|
||||
input.providerEnvironment,
|
||||
input.command,
|
||||
)) ?? ({ status: "update" } as const),
|
||||
inspection: await input.host.inspectClientConfiguration(
|
||||
client,
|
||||
input.providerEnvironment,
|
||||
input.command,
|
||||
),
|
||||
})),
|
||||
);
|
||||
const invalid = inspections.find(
|
||||
@@ -191,6 +186,7 @@ export const planSetupActions = async (input: {
|
||||
return {
|
||||
plannedActions: setupPlan({
|
||||
platform: input.host.platform,
|
||||
homeDirectory: input.host.homeDirectory,
|
||||
installHopper: input.discovery.installHopper,
|
||||
installSkill: input.installSkill,
|
||||
clients: clientPlans,
|
||||
@@ -210,6 +206,7 @@ export const planSetupActions = async (input: {
|
||||
/** Describe every selected setup mutation before user approval. */
|
||||
const setupPlan = (input: {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly homeDirectory: string;
|
||||
readonly installHopper: boolean;
|
||||
readonly installSkill: boolean;
|
||||
readonly clients: readonly {
|
||||
@@ -229,7 +226,10 @@ const setupPlan = (input: {
|
||||
label: "Hopper deep-analysis provider",
|
||||
target:
|
||||
input.platform === "darwin"
|
||||
? join(homedir(), "Applications/Hopper Disassembler.app")
|
||||
? join(
|
||||
input.homeDirectory,
|
||||
"Applications/Hopper Disassembler.app",
|
||||
)
|
||||
: "system package manager",
|
||||
detail:
|
||||
input.platform === "linux"
|
||||
@@ -271,7 +271,11 @@ const setupPlan = (input: {
|
||||
id: "install_skill",
|
||||
kind: "install_skill" as const,
|
||||
label: "REA reverse-engineering skill",
|
||||
target: join(homedir(), ".agents/skills", PRODUCT_IDENTITY.skillName),
|
||||
target: join(
|
||||
input.homeDirectory,
|
||||
".agents/skills",
|
||||
PRODUCT_IDENTITY.skillName,
|
||||
),
|
||||
detail:
|
||||
"Install or update the bundled REA reverse-engineering skill and on-demand references.",
|
||||
external: false,
|
||||
|
||||
@@ -41,15 +41,15 @@ export type ClientConfigurationInspection =
|
||||
/** Effects required by the idempotent, non-blocking setup workflow. */
|
||||
export interface SetupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly homeDirectory: string;
|
||||
readonly registrationCommand: readonly string[];
|
||||
readonly nodeVersion: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
||||
hopperPath(): Promise<string | undefined>;
|
||||
initialSetupState?(scope?: DoctorScope): Promise<SetupInitialState>;
|
||||
initialSetupState(scope?: DoctorScope): Promise<SetupInitialState>;
|
||||
installHopper(replaceExisting: boolean): Promise<SetupHopperInstallResult>;
|
||||
providerEnvironment?(): Promise<SetupProviderEnvironment>;
|
||||
detectedClients(): Promise<readonly SetupClient[]>;
|
||||
supportedClients?(): Promise<readonly SetupClient[]>;
|
||||
supportedClients(): Promise<readonly SetupClient[]>;
|
||||
configureClient(
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
@@ -60,7 +60,7 @@ export interface SetupHost {
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<boolean>;
|
||||
inspectClientConfiguration?(
|
||||
inspectClientConfiguration(
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { copyFile, lstat, readFile, realpath, rm } from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
@@ -161,23 +161,30 @@ const removeAll = async (
|
||||
|
||||
/** Create uninstall effects contained to detected client configs and REA-owned paths. */
|
||||
export const systemUninstallHost = (
|
||||
home = homedir(),
|
||||
selectedHome: string | undefined = undefined,
|
||||
fileSystem: UninstallFileSystem = systemFileSystem,
|
||||
): UninstallHost => ({
|
||||
clients: () => Promise.resolve(supportedClients(home)),
|
||||
inspectClient: async (client) => {
|
||||
const read = await readClientConfiguration(client, fileSystem);
|
||||
return read.kind === "item" && read.item.status === "failed"
|
||||
? read.item
|
||||
: undefined;
|
||||
},
|
||||
removeClient: (client) => removeClient(client, fileSystem),
|
||||
removeSkill: () => removeManagedSkills(home, fileSystem),
|
||||
purgeData: async () => [
|
||||
await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem),
|
||||
await removeManagedPath(join(home, ".rea/state"), "state", fileSystem),
|
||||
],
|
||||
});
|
||||
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): UninstallHost => {
|
||||
const home =
|
||||
selectedHome ?? homeDirectoryFromEnvironment(environment, platform);
|
||||
return {
|
||||
clients: () =>
|
||||
Promise.resolve(supportedClients(home, platform, environment)),
|
||||
inspectClient: async (client) => {
|
||||
const read = await readClientConfiguration(client, fileSystem);
|
||||
return read.kind === "item" && read.item.status === "failed"
|
||||
? read.item
|
||||
: undefined;
|
||||
},
|
||||
removeClient: (client) => removeClient(client, fileSystem),
|
||||
removeSkill: () => removeManagedSkills(home, fileSystem),
|
||||
purgeData: async () => [
|
||||
await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem),
|
||||
await removeManagedPath(join(home, ".rea/state"), "state", fileSystem),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
type ClientConfigurationRead =
|
||||
| { readonly kind: "item"; readonly item: UninstallItem }
|
||||
|
||||
@@ -51,9 +51,12 @@ const setupPlanSchema = z.object({
|
||||
export const existingMaintenanceScope = async (
|
||||
home: string,
|
||||
entryPoint: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): Promise<MaintenanceScope> => {
|
||||
const registrations = await readClientRegistrationStatuses(home, entryPoint);
|
||||
const supported = supportedClients(home);
|
||||
const registrations = await readClientRegistrationStatuses(home, entryPoint, {
|
||||
environment,
|
||||
});
|
||||
const supported = supportedClients(home, process.platform, environment);
|
||||
const clients: string[] = [];
|
||||
for (const registration of registrations) {
|
||||
if (
|
||||
@@ -96,10 +99,11 @@ export const existingMaintenanceScope = async (
|
||||
export const planIntegrationMaintenance = async (
|
||||
home: string,
|
||||
entryPoint: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
execute: (command: readonly string[]) => Promise<Result<string, string>>,
|
||||
): Promise<IntegrationMaintenance> => {
|
||||
try {
|
||||
const scope = await existingMaintenanceScope(home, entryPoint);
|
||||
const scope = await existingMaintenanceScope(home, entryPoint, environment);
|
||||
if (scope.clients.length === 0 && !scope.skill)
|
||||
return { status: "current", plannedActions: [] };
|
||||
const command = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import spawn from "cross-spawn";
|
||||
@@ -26,6 +26,7 @@ export interface NpmInstallationHost {
|
||||
/** Run an updater subprocess with complete diagnostics and isolated machine output. */
|
||||
export const runUpdateCommand = (
|
||||
command: readonly string[],
|
||||
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
|
||||
output: UpdateOutput = "structured",
|
||||
): Promise<Result<string, string>> =>
|
||||
new Promise((resolveResult) => {
|
||||
@@ -34,7 +35,7 @@ export const runUpdateCommand = (
|
||||
resolveResult(err("No executable supplied."));
|
||||
return;
|
||||
}
|
||||
const environment = { ...process.env };
|
||||
const environment = { ...selectedEnvironment };
|
||||
// A fresh installed entry point must not inherit npx's invocation identity.
|
||||
delete environment.npm_command;
|
||||
const child = spawn(executable, args, {
|
||||
@@ -72,8 +73,9 @@ export const runUpdateCommand = (
|
||||
|
||||
const requireCommandOutput = async (
|
||||
command: readonly string[],
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): Promise<string> => {
|
||||
const result = await runUpdateCommand(command);
|
||||
const result = await runUpdateCommand(command, environment);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
return result.value;
|
||||
};
|
||||
@@ -174,35 +176,46 @@ export const npmReleaseLookup = (
|
||||
/** Create npm, registry, verification, and read-only maintenance effects. */
|
||||
export const systemUpdateHost = (
|
||||
packageRoot = fileURLToPath(new URL("../..", import.meta.url)),
|
||||
home = homedir(),
|
||||
home: string | undefined = undefined,
|
||||
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
): UpdateHost => ({
|
||||
installation: () =>
|
||||
detectNpmInstallation(packageRoot, {
|
||||
canonicalPath: realpath,
|
||||
globalRoot: () => requireCommandOutput(["npm", "root", "--global"]),
|
||||
globalPrefix: () => requireCommandOutput(["npm", "prefix", "--global"]),
|
||||
globalRoot: () =>
|
||||
requireCommandOutput(["npm", "root", "--global"], environment),
|
||||
globalPrefix: () =>
|
||||
requireCommandOutput(["npm", "prefix", "--global"], environment),
|
||||
}),
|
||||
latestVersion: async (installation) =>
|
||||
npmReleaseLookup(
|
||||
await runUpdateCommand(npmLatestVersionCommand(installation)),
|
||||
await runUpdateCommand(
|
||||
npmLatestVersionCommand(installation),
|
||||
environment,
|
||||
),
|
||||
),
|
||||
installVersion: async (installation, version, output) => {
|
||||
const result = await runUpdateCommand(
|
||||
updateInstallCommand(installation, version),
|
||||
environment,
|
||||
output,
|
||||
);
|
||||
return result.ok ? ok(undefined) : result;
|
||||
},
|
||||
installedVersion: (installation) =>
|
||||
runUpdateCommand([
|
||||
process.execPath,
|
||||
join(installation.packageRoot, "scripts", "rea.mjs"),
|
||||
"--version",
|
||||
]),
|
||||
runUpdateCommand(
|
||||
[
|
||||
process.execPath,
|
||||
join(installation.packageRoot, "scripts", "rea.mjs"),
|
||||
"--version",
|
||||
],
|
||||
environment,
|
||||
),
|
||||
planMaintenance: (installation) =>
|
||||
planIntegrationMaintenance(
|
||||
home,
|
||||
home ?? homeDirectoryFromEnvironment(environment, process.platform),
|
||||
join(installation.packageRoot, "scripts", "rea.mjs"),
|
||||
runUpdateCommand,
|
||||
environment,
|
||||
(command) => runUpdateCommand(command, environment),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -51,6 +51,8 @@ it("rejects source text changed across the artifact port while retaining the ori
|
||||
});
|
||||
it("rejects sparse resolver arrays rather than relabeling literal imports as computed unknowns", async () => {
|
||||
const artifacts = webModuleArtifactsFixture('import "./dep.js";');
|
||||
const resolutions: WebModuleResolution[] = [];
|
||||
resolutions.length = 1;
|
||||
const service = new WebModuleTraceService(
|
||||
{ load: () => Promise.resolve(ok(artifacts)) },
|
||||
{
|
||||
@@ -58,7 +60,7 @@ it("rejects sparse resolver arrays rather than relabeling literal imports as com
|
||||
Promise.resolve(
|
||||
ok({
|
||||
engine: { id: "fixture", version: "1" },
|
||||
resolutions: new Array<WebModuleResolution>(1),
|
||||
resolutions,
|
||||
diagnostics: [],
|
||||
rawResult: {},
|
||||
}),
|
||||
@@ -70,40 +72,6 @@ it("rejects sparse resolver arrays rather than relabeling literal imports as com
|
||||
if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
});
|
||||
describe("selected module trace workflow", () => {
|
||||
it("composes selected source, derived resolution and explicit computed/execution unknowns", async () => {
|
||||
const artifacts = webModuleArtifactsFixture();
|
||||
const service = new WebModuleTraceService(
|
||||
{ load: () => Promise.resolve(ok(artifacts)) },
|
||||
webModuleResolverFixture,
|
||||
);
|
||||
const response = await service.trace(args);
|
||||
if (!response.ok) throw response.error;
|
||||
const result = webModuleTraceResultSchema.parse(
|
||||
response.value.normalized_result,
|
||||
);
|
||||
expect(result.importer).toEqual({
|
||||
url: artifacts.manifest.scripts[0]?.url,
|
||||
basis: "reported-source-url",
|
||||
});
|
||||
expect(result.manifest.capture_completeness).toEqual(
|
||||
artifacts.manifest.capture_completeness,
|
||||
);
|
||||
expect(result.imports[0]?.resolution).toEqual({
|
||||
state: "resolved",
|
||||
url: "https://app.test/dep.js",
|
||||
});
|
||||
expect(result.imports[1]?.resolution).toEqual({
|
||||
state: "unknown",
|
||||
reason: "computed-specifier",
|
||||
});
|
||||
expect(result.imports.every((item) => item.execution === "unknown")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(response.value.subject).toMatchObject({
|
||||
local_path: artifacts.sourceFile.path,
|
||||
digest: { sha256: artifacts.sourceFile.sha256 },
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{ ...args, manifest_path: "relative.json" },
|
||||
{
|
||||
@@ -133,22 +101,6 @@ describe("selected module trace workflow", () => {
|
||||
});
|
||||
},
|
||||
);
|
||||
it("handles computed-only sources without acquiring the optional engine", async () => {
|
||||
const artifacts = webModuleArtifactsFixture("import(name)");
|
||||
const service = new WebModuleTraceService(
|
||||
{ load: () => Promise.resolve(ok(artifacts)) },
|
||||
{
|
||||
resolve: () => {
|
||||
throw new Error("engine must not start");
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await service.trace(args);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(
|
||||
webModuleTraceResultSchema.parse(result.value.normalized_result).engine,
|
||||
).toBeNull();
|
||||
});
|
||||
it("rejects incomplete resolution rather than fabricating unknown values", async () => {
|
||||
const artifacts = webModuleArtifactsFixture();
|
||||
const service = new WebModuleTraceService(
|
||||
|
||||
@@ -12,28 +12,6 @@ import type { WebSourceMapPort } from "./WebSourceLocationPorts.js";
|
||||
|
||||
const service = (data = webSourceLocationFixture(), port = decoder) =>
|
||||
new WebSourceLocationService({ load: () => Promise.resolve(ok(data)) }, port);
|
||||
it("joins byte identity and historical capture context while keeping selected authenticity/execution unknown", async () => {
|
||||
const data = webSourceLocationFixture();
|
||||
data.manifest.source_evidence_id = `ev_${"a".repeat(64)}`;
|
||||
const response = await service(data).trace(args);
|
||||
if (!response.ok) throw response.error;
|
||||
const result = webSourceLocationResultSchema.parse(
|
||||
response.value.normalized_result,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
source: { sha256: data.sourceFile.sha256 },
|
||||
source_map: {
|
||||
association: "caller-selected",
|
||||
sha256: data.sourceMap.file.sha256,
|
||||
},
|
||||
matches: [{ state: "mapped", content: { text: "original" } }],
|
||||
execution: "unknown",
|
||||
source_authenticity: "unknown",
|
||||
});
|
||||
expect(response.value.raw_result).toEqual({
|
||||
source_map_text: data.sourceMap.text,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{ ...args, manifest_path: "relative.json" },
|
||||
{ ...args, script_index: -1 },
|
||||
@@ -115,18 +93,6 @@ it.each(["digest", "url", "point"])(
|
||||
expect(response.error._tag).toBe("AnalysisOutputError");
|
||||
},
|
||||
);
|
||||
it("rejects a generated point outside retained text before decoding", async () => {
|
||||
const response = await service(undefined, {
|
||||
trace: () => {
|
||||
throw new Error("must not decode");
|
||||
},
|
||||
}).trace({ ...args, generated_position: { line: 2, column: 0 } });
|
||||
if (response.ok) throw new Error("expected invalid point");
|
||||
expect(projectAnalysisError(response.error)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { issues: [{ message: expect.stringContaining("outside") }] },
|
||||
});
|
||||
});
|
||||
it("honors cancellation both before acquisition and across decoder completion", async () => {
|
||||
const first = new AbortController();
|
||||
first.abort();
|
||||
|
||||
@@ -76,7 +76,7 @@ export const workflowSnapshotRecord = (
|
||||
evidence: Evidence,
|
||||
operation: AnalysisOperation,
|
||||
): WorkflowSnapshotRecordInput | undefined => {
|
||||
if (!("analysis_profile" in evidence)) return undefined;
|
||||
if (evidence.analysis_profile === null) return undefined;
|
||||
return {
|
||||
operation,
|
||||
parameters: evidence.parameters,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { err } from "../../domain/result.js";
|
||||
import type { AnalysisClient } from "../AnalysisProvider.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
import {
|
||||
analysisErrorWithCleanupFailure,
|
||||
closeAnalysisClient,
|
||||
} from "./AnalysisClientCleanup.js";
|
||||
|
||||
const clientClosingWith = (cause: unknown): AnalysisClient => ({
|
||||
execute: () => Promise.resolve(err(new ProviderCleanupError("x", [], {}))),
|
||||
@@ -43,3 +47,31 @@ it("still reports an untyped close rejection as the provider client", async () =
|
||||
details: { resources: ["provider-client"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the original explanation and collected output alongside cleanup uncertainty", () => {
|
||||
const primary = new ProviderAdapterError("fixture", "capture", {
|
||||
userMessage: "Select the running target process and retry the capture.",
|
||||
capturedOutput: {
|
||||
stdout: "partial observation",
|
||||
stderr: "native reason",
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
const cleanup = new ProviderCleanupError("fixture", ["owned-process"], {
|
||||
reason: "Process termination was not confirmed",
|
||||
});
|
||||
const combined = analysisErrorWithCleanupFailure(primary, cleanup);
|
||||
expect(projectAnalysisError(combined)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
message: primary.userMessage,
|
||||
details: {
|
||||
resources: ["owned-process"],
|
||||
captured_output: primary.capturedOutput,
|
||||
diagnostics: {
|
||||
primary_error: projectAnalysisError(primary),
|
||||
cleanup_error: projectAnalysisError(cleanup),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(combined.cause).toBe(primary);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { AnalysisClient, ExecutionOptions } from "../AnalysisProvider.js";
|
||||
|
||||
@@ -14,10 +14,7 @@ export const closeAnalysisClient = async (
|
||||
} = {},
|
||||
): Promise<Result<null, AnalysisError>> => {
|
||||
try {
|
||||
if (client.closeWithOutcome !== undefined)
|
||||
return await client.closeWithOutcome(options);
|
||||
await client.close();
|
||||
return ok(null);
|
||||
return await client.close(options);
|
||||
} catch (cause: unknown) {
|
||||
// A typed cleanup failure already names its leftover resources.
|
||||
if (cause instanceof AnalysisError && cause.cleanupIncomplete)
|
||||
@@ -45,6 +42,13 @@ export const analysisErrorWithCleanupFailure = (
|
||||
operation,
|
||||
{
|
||||
cause: primary,
|
||||
userMessage: projectAnalysisError(primary).message,
|
||||
...(primary.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: primary.capturedOutput }),
|
||||
...(primary.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: primary.partialObservation }),
|
||||
cleanup: {
|
||||
reason: projected.message,
|
||||
resources: cleanup.cleanupResources,
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface AnalysisProviderCandidateEvaluation {
|
||||
readonly candidate: AnalysisProviderCandidate;
|
||||
readonly status: AnalysisProviderCandidateStatus;
|
||||
readonly profile?: AnalysisProfileCommitment;
|
||||
readonly compatibility?: Readonly<Record<string, JsonValue>>;
|
||||
readonly profileError?: AnalysisError;
|
||||
}
|
||||
|
||||
@@ -87,7 +86,6 @@ export const evaluateAnalysisProviderCandidate = async (
|
||||
),
|
||||
);
|
||||
const profile = analysisProfileSchema.parse(resolved.value.profile);
|
||||
const compatibility = jsonObjectSchema.parse(resolved.value.compatibility);
|
||||
const identity = candidate.identity();
|
||||
if (
|
||||
profile.provider.id !== identity.id ||
|
||||
@@ -102,7 +100,7 @@ export const evaluateAnalysisProviderCandidate = async (
|
||||
"Resolved analysis profile has mismatched provider identity",
|
||||
),
|
||||
);
|
||||
return ok({ candidate, status, profile, compatibility });
|
||||
return ok({ candidate, status, profile });
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
return ok(
|
||||
|
||||
@@ -308,7 +308,6 @@ const candidate = (
|
||||
{ id, name: identity.name, version: "1" },
|
||||
{ fixture: id },
|
||||
),
|
||||
compatibility: {},
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -325,7 +324,7 @@ const candidate = (
|
||||
),
|
||||
),
|
||||
),
|
||||
close: () => Promise.resolve(),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -49,7 +49,6 @@ export interface AnalysisProviderBinding {
|
||||
readonly identity: ProviderIdentity & { readonly version: string };
|
||||
readonly selectionSource: "request" | "environment" | "auto-single-candidate";
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Complete result used for both a bound and intentionally unbound target. */
|
||||
@@ -247,10 +246,7 @@ export class AnalysisProviderRegistry {
|
||||
if (evaluated.profileError !== undefined)
|
||||
return err(evaluated.profileError);
|
||||
const statuses = replaceCandidate(baseline, evaluated.status);
|
||||
if (
|
||||
evaluated.profile === undefined ||
|
||||
evaluated.compatibility === undefined
|
||||
)
|
||||
if (evaluated.profile === undefined)
|
||||
return err(selectionError("provider_unavailable", providerId, statuses));
|
||||
return ok(
|
||||
selectedResult(
|
||||
@@ -259,7 +255,6 @@ export class AnalysisProviderRegistry {
|
||||
{
|
||||
...evaluated,
|
||||
profile: evaluated.profile,
|
||||
compatibility: evaluated.compatibility,
|
||||
},
|
||||
statuses,
|
||||
),
|
||||
@@ -312,10 +307,7 @@ export class AnalysisProviderRegistry {
|
||||
evaluation,
|
||||
): evaluation is AnalysisProviderCandidateEvaluation & {
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
} =>
|
||||
evaluation.profile !== undefined &&
|
||||
evaluation.compatibility !== undefined,
|
||||
} => evaluation.profile !== undefined,
|
||||
);
|
||||
if (usable.length === 0)
|
||||
return ok({
|
||||
@@ -363,7 +355,6 @@ const selectedResult = (
|
||||
source: AnalysisProviderBinding["selectionSource"],
|
||||
evaluated: AnalysisProviderCandidateEvaluation & {
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
},
|
||||
statuses: readonly AnalysisProviderCandidateStatus[],
|
||||
): AnalysisProviderSelection => {
|
||||
@@ -379,7 +370,6 @@ const selectedResult = (
|
||||
identity: structuredClone(evaluated.profile.provider),
|
||||
selectionSource: source,
|
||||
profile: structuredClone(evaluated.profile),
|
||||
compatibility: structuredClone(evaluated.compatibility),
|
||||
},
|
||||
candidates: replaceCandidate(statuses, selectedStatus),
|
||||
};
|
||||
|
||||
@@ -72,6 +72,7 @@ it("exports independent nested query, workflow and Evidence payloads", () => {
|
||||
}),
|
||||
createEvidence(target, REA_WORKFLOW_PROVIDER, {
|
||||
operation: "analyze_function",
|
||||
confidence: "derived",
|
||||
parameters,
|
||||
result,
|
||||
rawResult: result,
|
||||
@@ -91,7 +92,7 @@ it("exports independent nested query, workflow and Evidence payloads", () => {
|
||||
execution.result,
|
||||
execution.raw_result,
|
||||
]),
|
||||
...(exported.value.workflow_entries ?? []).flatMap(({ execution }) => [
|
||||
...exported.value.workflow_entries.flatMap(({ execution }) => [
|
||||
execution.result,
|
||||
execution.raw_result,
|
||||
]),
|
||||
@@ -186,6 +187,7 @@ describe("analysis snapshot cache capacity", () => {
|
||||
});
|
||||
const evidence = createEvidence(target, REA_WORKFLOW_PROVIDER, {
|
||||
operation: workflowInput.operation,
|
||||
confidence: "derived",
|
||||
parameters: workflowInput.parameters,
|
||||
result: execution.result,
|
||||
rawResult: execution.rawResult,
|
||||
|
||||
@@ -124,7 +124,7 @@ export class AnalysisSnapshotCache {
|
||||
if (!this.#entries.has(entry.query_id)) imported += 1;
|
||||
this.#entries.set(entry.query_id, structuredClone(entry));
|
||||
}
|
||||
for (const entry of snapshot.workflow_entries ?? []) {
|
||||
for (const entry of snapshot.workflow_entries) {
|
||||
if (!this.#workflowEntries.has(entry.query_id) && !this.#hasCapacity())
|
||||
continue;
|
||||
this.#workflowEntries.set(entry.query_id, structuredClone(entry));
|
||||
@@ -156,7 +156,7 @@ export class AnalysisSnapshotCache {
|
||||
target: snapshotTargetIdentity,
|
||||
binding,
|
||||
entries,
|
||||
...(workflows.length === 0 ? {} : { workflow_entries: workflows }),
|
||||
workflow_entries: workflows,
|
||||
evidence_bundle: evidenceBundle,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -60,7 +60,6 @@ interface SessionBinding {
|
||||
readonly target: BinaryTarget;
|
||||
readonly client: AnalysisClient;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
readonly route: SessionProviderRoute;
|
||||
readonly runId: string;
|
||||
}
|
||||
@@ -105,13 +104,13 @@ export class BinarySession
|
||||
/** Identify the provider producing evidence for this session. */
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity {
|
||||
const route = this.#currentRoute();
|
||||
let selected = route.identity;
|
||||
let selected = route.binding?.identity ?? route.identity;
|
||||
if (operation !== undefined) {
|
||||
const exact = route.capabilities?.get(operation)?.provider;
|
||||
const exact = route.capabilities.get(operation)?.provider;
|
||||
if (exact !== undefined) selected = exact;
|
||||
else if (ENHANCED_OPERATIONS.has(operation)) {
|
||||
const providers = new Map<string, ProviderIdentity>();
|
||||
for (const descriptor of route.capabilities?.values() ?? [])
|
||||
for (const descriptor of route.capabilities.values())
|
||||
if (
|
||||
descriptor.available &&
|
||||
OFFICIAL_OPERATIONS.has(descriptor.operation)
|
||||
@@ -157,11 +156,6 @@ export class BinarySession
|
||||
return structuredClone(profile);
|
||||
}
|
||||
|
||||
/** Return opaque adapter metadata retained for legacy open_binary output. */
|
||||
openCompatibility(): Readonly<Record<string, JsonValue>> {
|
||||
return structuredClone(this.#active?.compatibility ?? {});
|
||||
}
|
||||
|
||||
/** Observe runtime provider-health changes that affect discovery metadata. */
|
||||
onAvailabilityChanged(listener: () => void | Promise<void>): () => void {
|
||||
this.#availabilityListeners.add(listener);
|
||||
@@ -241,7 +235,7 @@ export class BinarySession
|
||||
const resolved = await resolve();
|
||||
if (!resolved.ok) return resolved;
|
||||
const { target, route, sameTarget } = resolved.value;
|
||||
const { profile, compatibility } = route;
|
||||
const { profile } = route;
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const activeProfile = this.#active?.profile;
|
||||
@@ -310,7 +304,6 @@ export class BinarySession
|
||||
target,
|
||||
client,
|
||||
profile,
|
||||
compatibility: structuredClone(compatibility),
|
||||
route,
|
||||
runId,
|
||||
};
|
||||
@@ -370,9 +363,8 @@ export class BinarySession
|
||||
return closed.ok
|
||||
? ok({
|
||||
...written.value,
|
||||
entries: snapshot.value.entries.length,
|
||||
primitive_entries: snapshot.value.entries.length,
|
||||
workflow_entries: snapshot.value.workflow_entries?.length ?? 0,
|
||||
workflow_entries: snapshot.value.workflow_entries.length,
|
||||
evidence_records: snapshot.value.evidence_bundle.records.length,
|
||||
})
|
||||
: closed;
|
||||
@@ -584,7 +576,7 @@ export class BinarySession
|
||||
parameters: arguments_,
|
||||
execution: profiled.value,
|
||||
});
|
||||
} else if (profiled.ok && capability?.effects.mutatesArtifact === true) {
|
||||
} else if (profiled.ok && capability.effects.mutatesArtifact) {
|
||||
this.invalidateSnapshot();
|
||||
}
|
||||
return profiled;
|
||||
@@ -699,7 +691,6 @@ export class BinarySession
|
||||
target: previous.target,
|
||||
client,
|
||||
profile: previous.profile,
|
||||
compatibility: previous.compatibility,
|
||||
route: previous.route,
|
||||
runId,
|
||||
};
|
||||
|
||||
@@ -9,12 +9,10 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js";
|
||||
* disjoint auxiliary operation families.
|
||||
*/
|
||||
export const composeBinarySession = (
|
||||
registry: AnalysisProviderRegistry | SessionProviderRouter,
|
||||
registry: AnalysisProviderRegistry,
|
||||
auxiliaryProviders: readonly AnalysisProvider[] = [],
|
||||
): BinarySession =>
|
||||
new BinarySession(
|
||||
registry instanceof SessionProviderRouter
|
||||
? registry
|
||||
: SessionProviderRouter.selectable(registry, auxiliaryProviders),
|
||||
SessionProviderRouter.selectable(registry, auxiliaryProviders),
|
||||
new InvestigationRecords(),
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ interface ActiveExecutionBinding {
|
||||
|
||||
export interface PreparedSessionExecution {
|
||||
readonly active: ActiveExecutionBinding;
|
||||
readonly capability: CapabilityDescriptor | undefined;
|
||||
readonly capability: CapabilityDescriptor;
|
||||
readonly profile: AnalysisProfileCommitment | undefined;
|
||||
readonly cacheable: boolean;
|
||||
readonly cached: AnalysisExecution | undefined;
|
||||
@@ -59,11 +59,8 @@ export const prepareSessionExecution = (
|
||||
lookupSnapshot,
|
||||
} = input;
|
||||
if (active === undefined) return err(new NoBinaryOpenError());
|
||||
const capability = active.route.capabilities?.get(operation);
|
||||
if (
|
||||
active.route.capabilities !== undefined &&
|
||||
capability?.available !== true
|
||||
) {
|
||||
const capability = active.route.capabilities.get(operation);
|
||||
if (capability?.available !== true) {
|
||||
const selectionError = unboundOperationError(operation, active.route);
|
||||
if (selectionError !== undefined) return err(selectionError);
|
||||
return err(
|
||||
@@ -76,8 +73,7 @@ export const prepareSessionExecution = (
|
||||
}
|
||||
const profile =
|
||||
active.profile !== null &&
|
||||
(capability === undefined ||
|
||||
capability.provider.id === active.profile.provider.id)
|
||||
capability.provider.id === active.profile.provider.id
|
||||
? active.profile
|
||||
: undefined;
|
||||
const cacheable =
|
||||
|
||||
@@ -21,8 +21,7 @@ import type {
|
||||
export interface SavedAnalysisSnapshot {
|
||||
readonly path: string;
|
||||
readonly bytes: number;
|
||||
readonly entries: number;
|
||||
/** Eligible primitive query bindings; also reported by the legacy entries field. */
|
||||
/** Eligible primitive query bindings. */
|
||||
readonly primitive_entries: number;
|
||||
/** Eligible composed workflow bindings, separate from primitive queries. */
|
||||
readonly workflow_entries: number;
|
||||
@@ -68,7 +67,6 @@ export interface BinarySessionPort
|
||||
recordWorkflowSnapshot(
|
||||
input: WorkflowSnapshotRecordInput,
|
||||
): Result<null, EvidenceIntegrityError>;
|
||||
openCompatibility(): Readonly<Record<string, JsonValue>>;
|
||||
onAvailabilityChanged?(listener: () => void | Promise<void>): () => void;
|
||||
onAnalysisSnapshotChanged?(listener: () => void | Promise<void>): () => void;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface WorkflowSnapshotRecordInput {
|
||||
>[0]["execution"];
|
||||
}
|
||||
|
||||
/** Binary snapshot owner and compatibility facade for composed investigation records. */
|
||||
/** Own binary snapshots and coordinate the composed investigation records. */
|
||||
export abstract class BinarySessionRecords {
|
||||
readonly #records: InvestigationRecords;
|
||||
readonly #snapshot = new AnalysisSnapshotCache();
|
||||
|
||||
@@ -36,10 +36,7 @@ export const binarySessionStatus = ({
|
||||
requestActivitySnapshots,
|
||||
providerOperationHealth,
|
||||
}: BinarySessionStatusInput): JsonValue => {
|
||||
const configuredProvider = router.configuredIdentity();
|
||||
const provider = providerSummary(configuredProvider);
|
||||
const providers = router.providerIdentities(route).map(providerSummary);
|
||||
const capabilities = [...(route.capabilities?.values() ?? [])]
|
||||
const capabilities = [...route.capabilities.values()]
|
||||
.filter(({ operation }) => operation !== "inventory_artifact")
|
||||
.sort((left, right) => left.operation.localeCompare(right.operation))
|
||||
.map((descriptor) => {
|
||||
@@ -75,8 +72,6 @@ export const binarySessionStatus = ({
|
||||
.map(capabilityStatus),
|
||||
}));
|
||||
const common = {
|
||||
provider,
|
||||
providers,
|
||||
capabilities,
|
||||
analysis_provider_binding: analysisProviderBinding,
|
||||
analysis_provider_candidates: analysisProviderCandidates,
|
||||
|
||||
@@ -5,15 +5,14 @@ import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisClientContext,
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
|
||||
/** Synthetic compatibility identity for a deterministic provider set. */
|
||||
export const compositeProviderIdentity = (
|
||||
/** Synthetic identity for a deterministic provider set. */
|
||||
const compositeProviderIdentity = (
|
||||
identities: readonly ProviderIdentity[],
|
||||
): ProviderIdentity => ({
|
||||
id: `composite:${identities
|
||||
@@ -29,19 +28,10 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
readonly #identity: ProviderIdentity;
|
||||
readonly #capabilities: readonly CapabilityDescriptor[];
|
||||
readonly #providerByOperation: ReadonlyMap<string, AnalysisProvider>;
|
||||
readonly #profileProvider: AnalysisProvider | undefined;
|
||||
|
||||
constructor(readonly providers: readonly AnalysisProvider[]) {
|
||||
if (providers.length === 0)
|
||||
throw new RangeError("CompositeProvider requires at least one provider");
|
||||
const profileProviders = providers.filter(
|
||||
({ resolveAnalysisProfile }) => resolveAnalysisProfile !== undefined,
|
||||
);
|
||||
if (profileProviders.length > 1)
|
||||
throw new TypeError(
|
||||
"CompositeProvider supports at most one target-bound analysis profile",
|
||||
);
|
||||
this.#profileProvider = profileProviders[0];
|
||||
this.#identity = Object.freeze(
|
||||
compositeProviderIdentity(
|
||||
providers.map((provider) => provider.identity()),
|
||||
@@ -77,16 +67,6 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
return this.#capabilities;
|
||||
}
|
||||
|
||||
resolveAnalysisProfile(
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
) {
|
||||
const resolve = this.#profileProvider?.resolveAnalysisProfile;
|
||||
return resolve === undefined
|
||||
? Promise.resolve(ok({ profile: null, compatibility: {} }))
|
||||
: resolve.call(this.#profileProvider, target, options);
|
||||
}
|
||||
|
||||
createClient(
|
||||
target: BinaryTarget,
|
||||
profile?: AnalysisProfileCommitment,
|
||||
@@ -104,9 +84,7 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
clients.set(provider, created);
|
||||
return created;
|
||||
};
|
||||
const closeWithOutcome: NonNullable<
|
||||
AnalysisClient["closeWithOutcome"]
|
||||
> = async (options) => {
|
||||
const close: AnalysisClient["close"] = async (options) => {
|
||||
const outcomes = await Promise.all(
|
||||
[...clients.entries()].map(([provider, client]) =>
|
||||
closeAnalysisClient(client, provider.identity().id, options),
|
||||
@@ -164,10 +142,7 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
closeWithOutcome,
|
||||
close: async () => {
|
||||
await closeWithOutcome();
|
||||
},
|
||||
close,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
} from "../AnalysisProvider.js";
|
||||
import { LazyAnalysisProvider } from "./LazyAnalysisProvider.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import { err } from "../../domain/result.js";
|
||||
import { err, ok as resultOk } from "../../domain/result.js";
|
||||
|
||||
const identity: ProviderIdentity = {
|
||||
id: "test-provider",
|
||||
@@ -24,7 +24,7 @@ const providerWithClient = (
|
||||
onCreate();
|
||||
return {
|
||||
execute: async (operation) => err(new AnalysisCancelledError(operation)),
|
||||
close: async () => undefined,
|
||||
close: async () => resultOk(null),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js";
|
||||
import { err } from "../../domain/result.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { ABORTED, waitForAbortable } from "./AbortablePromise.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
@@ -120,12 +120,12 @@ class LazyAnalysisClient implements AnalysisClient {
|
||||
return this.#client?.operationHealthSnapshot?.();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
close: AnalysisClient["close"] = async (options) => {
|
||||
this.#closed = true;
|
||||
if (this.#loading === undefined) return;
|
||||
if (this.#loading === undefined) return ok(null);
|
||||
const client = await this.#loading;
|
||||
await client.close();
|
||||
}
|
||||
return client.close(options);
|
||||
};
|
||||
|
||||
async #load(operation: string): Promise<AnalysisClient> {
|
||||
if (this.#closed)
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import type { AnalysisProviderSelector } from "../../contracts/providerSelection.js";
|
||||
import {
|
||||
analysisProfileSchema,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../../domain/analysisProfile.js";
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
import { ProviderSelectionError } from "../../domain/providerSelectionError.js";
|
||||
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisClientContext,
|
||||
AnalysisClientFactory,
|
||||
AnalysisProfileResolution,
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
@@ -29,18 +18,13 @@ import {
|
||||
type AnalysisProviderCandidateStatus,
|
||||
type AnalysisProviderSelection,
|
||||
} from "./AnalysisProviderRegistry.js";
|
||||
import { ABORTED, waitForAbortable } from "./AbortablePromise.js";
|
||||
import {
|
||||
CompositeProvider,
|
||||
compositeProviderIdentity,
|
||||
} from "./CompositeProvider.js";
|
||||
import { CompositeProvider } from "./CompositeProvider.js";
|
||||
|
||||
/** Immutable operation routes and binding metadata for one target transition. */
|
||||
export interface SessionProviderRoute {
|
||||
readonly identity: ProviderIdentity;
|
||||
readonly capabilities: ReadonlyMap<string, CapabilityDescriptor> | undefined;
|
||||
readonly capabilities: ReadonlyMap<string, CapabilityDescriptor>;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
readonly binding: AnalysisProviderBinding | null;
|
||||
readonly selection: AnalysisProviderSelection | undefined;
|
||||
createClient(
|
||||
@@ -49,69 +33,14 @@ export interface SessionProviderRoute {
|
||||
): AnalysisClient;
|
||||
}
|
||||
|
||||
interface SingleProviderRuntime {
|
||||
readonly kind: "single";
|
||||
readonly provider: AnalysisProvider | AnalysisClientFactory;
|
||||
readonly identity: ProviderIdentity;
|
||||
readonly capabilities: ReadonlyMap<string, CapabilityDescriptor> | undefined;
|
||||
readonly resolveProfile: (
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
) => Promise<Result<AnalysisProfileResolution, AnalysisError>>;
|
||||
}
|
||||
|
||||
interface SelectableProviderRuntime {
|
||||
readonly kind: "selectable";
|
||||
readonly registry: AnalysisProviderRegistry;
|
||||
readonly auxiliaryProviders: readonly AnalysisProvider[];
|
||||
readonly identity: ProviderIdentity;
|
||||
readonly initialRoute: SessionProviderRoute;
|
||||
}
|
||||
|
||||
/** Resolve either the compatibility single-provider seam or a selectable set. */
|
||||
/** Resolve one registry-selected deep provider and disjoint auxiliary families. */
|
||||
export class SessionProviderRouter {
|
||||
private constructor(
|
||||
private readonly runtime: SingleProviderRuntime | SelectableProviderRuntime,
|
||||
private readonly registry: AnalysisProviderRegistry,
|
||||
private readonly auxiliaryProviders: readonly AnalysisProvider[],
|
||||
private readonly route: SessionProviderRoute,
|
||||
) {}
|
||||
|
||||
/** Create routing for an embedding backed by one fixed provider or factory. */
|
||||
static single(
|
||||
provider: AnalysisProvider | AnalysisClientFactory,
|
||||
options: {
|
||||
readonly resolveAnalysisProfile?: (
|
||||
target: BinaryTarget,
|
||||
resolutionOptions?: AnalysisProfileResolutionOptions,
|
||||
) => Promise<Result<AnalysisProfileResolution, AnalysisError>>;
|
||||
} = {},
|
||||
): SessionProviderRouter {
|
||||
const identity =
|
||||
typeof provider === "function"
|
||||
? { id: "unidentified", name: "Unidentified provider", version: null }
|
||||
: provider.identity();
|
||||
const capabilities =
|
||||
typeof provider === "function"
|
||||
? undefined
|
||||
: capabilityMap(provider.capabilities());
|
||||
const resolveProfile =
|
||||
options.resolveAnalysisProfile ??
|
||||
(typeof provider === "function" ||
|
||||
provider.resolveAnalysisProfile === undefined
|
||||
? () => Promise.resolve(ok({ profile: null, compatibility: {} }))
|
||||
: (
|
||||
target: BinaryTarget,
|
||||
resolutionOptions?: AnalysisProfileResolutionOptions,
|
||||
) =>
|
||||
provider.resolveAnalysisProfile?.(target, resolutionOptions) ??
|
||||
Promise.resolve(ok({ profile: null, compatibility: {} })));
|
||||
return new SessionProviderRouter({
|
||||
kind: "single",
|
||||
provider,
|
||||
identity,
|
||||
capabilities,
|
||||
resolveProfile,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create selection-aware routing while keeping auxiliary families disjoint. */
|
||||
static selectable(
|
||||
registry: AnalysisProviderRegistry,
|
||||
@@ -129,56 +58,16 @@ export class SessionProviderRouter {
|
||||
`Deep and auxiliary providers both declare operation ${operation}`,
|
||||
);
|
||||
const initialRoute = selectableRoute(auxiliaryProviders, undefined);
|
||||
return new SessionProviderRouter({
|
||||
kind: "selectable",
|
||||
return new SessionProviderRouter(
|
||||
registry,
|
||||
auxiliaryProviders: [...auxiliaryProviders],
|
||||
identity:
|
||||
identities.length === 0
|
||||
? emptyProviderIdentity()
|
||||
: compositeProviderIdentity(identities),
|
||||
[...auxiliaryProviders],
|
||||
initialRoute,
|
||||
});
|
||||
}
|
||||
|
||||
/** Compatibility identity covering every configured provider. */
|
||||
configuredIdentity(): ProviderIdentity {
|
||||
return structuredClone(this.runtime.identity);
|
||||
}
|
||||
|
||||
/** Provider identities exposed through the flat 1.x compatibility field. */
|
||||
providerIdentities(route: SessionProviderRoute): readonly ProviderIdentity[] {
|
||||
if (this.runtime.kind === "single") {
|
||||
if (route.capabilities === undefined) return [route.identity];
|
||||
return uniqueProviderIdentities(
|
||||
[...route.capabilities.values()].map(({ provider }) => provider),
|
||||
);
|
||||
}
|
||||
const selected = route.binding?.identity;
|
||||
return uniqueProviderIdentities([
|
||||
...this.runtime.auxiliaryProviders.map((provider) => provider.identity()),
|
||||
...this.runtime.registry
|
||||
.identities()
|
||||
.map((identity) =>
|
||||
identity.id === selected?.id ? selected : identity,
|
||||
),
|
||||
]);
|
||||
);
|
||||
}
|
||||
|
||||
/** Target-free routes and discovery metadata. */
|
||||
initialRoute(): SessionProviderRoute {
|
||||
if (this.runtime.kind === "selectable") return this.runtime.initialRoute;
|
||||
const runtime = this.runtime;
|
||||
return {
|
||||
identity: structuredClone(runtime.identity),
|
||||
capabilities: runtime.capabilities,
|
||||
profile: null,
|
||||
compatibility: {},
|
||||
binding: null,
|
||||
selection: undefined,
|
||||
createClient: (target, context) =>
|
||||
createSingleProviderClient(runtime, target, undefined, context),
|
||||
};
|
||||
return this.route;
|
||||
}
|
||||
|
||||
/** Resolve target routes before any provider client is created. */
|
||||
@@ -188,64 +77,21 @@ export class SessionProviderRouter {
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<SessionProviderRoute, AnalysisError>> {
|
||||
const resolutionOptions = signal === undefined ? undefined : { signal };
|
||||
if (this.runtime.kind === "selectable") {
|
||||
const selected = await this.runtime.registry.select(
|
||||
target,
|
||||
providerId,
|
||||
resolutionOptions,
|
||||
);
|
||||
return selected.ok
|
||||
? ok(selectableRoute(this.runtime.auxiliaryProviders, selected.value))
|
||||
: selected;
|
||||
}
|
||||
const runtime = this.runtime;
|
||||
if (
|
||||
providerId !== undefined &&
|
||||
providerId !== "auto" &&
|
||||
providerId !== runtime.identity.id
|
||||
)
|
||||
return err(
|
||||
new ProviderSelectionError({
|
||||
reason: "unknown_provider",
|
||||
requestedProviderId: providerId,
|
||||
candidateIds: [runtime.identity.id],
|
||||
}),
|
||||
);
|
||||
const resolved = await waitForAbortable(
|
||||
runtime.resolveProfile(target, resolutionOptions),
|
||||
signal,
|
||||
const selected = await this.registry.select(
|
||||
target,
|
||||
providerId,
|
||||
resolutionOptions,
|
||||
);
|
||||
if (resolved === ABORTED)
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (!resolved.ok) return resolved;
|
||||
const normalized = normalizeProfileResolution(
|
||||
resolved.value,
|
||||
runtime.identity.id,
|
||||
);
|
||||
if (!normalized.ok) return normalized;
|
||||
return ok({
|
||||
identity: structuredClone(runtime.identity),
|
||||
capabilities: runtime.capabilities,
|
||||
profile: normalized.value.profile,
|
||||
compatibility: normalized.value.compatibility,
|
||||
binding: null,
|
||||
selection: undefined,
|
||||
createClient: (openedTarget, context) =>
|
||||
createSingleProviderClient(
|
||||
runtime,
|
||||
openedTarget,
|
||||
normalized.value.profile ?? undefined,
|
||||
context,
|
||||
),
|
||||
});
|
||||
return selected.ok
|
||||
? ok(selectableRoute(this.auxiliaryProviders, selected.value))
|
||||
: selected;
|
||||
}
|
||||
|
||||
/** Candidate status authoritative for the active or target-free route. */
|
||||
candidateStatuses(
|
||||
route: SessionProviderRoute,
|
||||
): readonly AnalysisProviderCandidateStatus[] {
|
||||
if (this.runtime.kind === "single") return [];
|
||||
return route.selection?.candidates ?? this.runtime.registry.candidates();
|
||||
return route.selection?.candidates ?? this.registry.candidates();
|
||||
}
|
||||
|
||||
/** Produce exact candidate rejections for an intentionally unbound route. */
|
||||
@@ -254,15 +100,12 @@ export class SessionProviderRouter {
|
||||
route: SessionProviderRoute,
|
||||
): ProviderSelectionError | undefined {
|
||||
if (
|
||||
this.runtime.kind !== "selectable" ||
|
||||
route.binding !== null ||
|
||||
route.selection === undefined
|
||||
route.selection === undefined ||
|
||||
!this.registry.declaredOperations().includes(operation)
|
||||
)
|
||||
return undefined;
|
||||
return this.runtime.registry.unboundOperationError(
|
||||
operation,
|
||||
route.selection,
|
||||
);
|
||||
return this.registry.unboundOperationError(operation, route.selection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,14 +119,17 @@ const selectableRoute = (
|
||||
...(binding === null ? [] : [binding.provider]),
|
||||
];
|
||||
const provider =
|
||||
providers.length === 0 ? undefined : new CompositeProvider(providers);
|
||||
binding === null && providers.length === 1
|
||||
? providers[0]
|
||||
: providers.length === 0
|
||||
? undefined
|
||||
: new CompositeProvider(providers);
|
||||
const identity = provider?.identity() ?? emptyProviderIdentity();
|
||||
const capabilities = capabilityMap(provider?.capabilities() ?? []);
|
||||
return {
|
||||
identity,
|
||||
capabilities,
|
||||
profile: binding?.profile ?? null,
|
||||
compatibility: structuredClone(binding?.compatibility ?? {}),
|
||||
binding,
|
||||
selection,
|
||||
createClient: (target, context) =>
|
||||
@@ -292,16 +138,6 @@ const selectableRoute = (
|
||||
};
|
||||
};
|
||||
|
||||
const createSingleProviderClient = (
|
||||
runtime: SingleProviderRuntime,
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment | undefined,
|
||||
context: AnalysisClientContext,
|
||||
): AnalysisClient =>
|
||||
typeof runtime.provider === "function"
|
||||
? runtime.provider(target, profile, context)
|
||||
: runtime.provider.createClient(target, profile, context);
|
||||
|
||||
const emptyClient = (identity: ProviderIdentity): AnalysisClient => ({
|
||||
execute: (operation) =>
|
||||
operation === "health"
|
||||
@@ -324,7 +160,7 @@ const emptyClient = (identity: ProviderIdentity): AnalysisClient => ({
|
||||
),
|
||||
),
|
||||
),
|
||||
close: () => Promise.resolve(),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
});
|
||||
|
||||
const emptyProviderIdentity = (): ProviderIdentity => ({
|
||||
@@ -338,13 +174,6 @@ const capabilityMap = (
|
||||
): ReadonlyMap<string, CapabilityDescriptor> =>
|
||||
new Map(capabilities.map((descriptor) => [descriptor.operation, descriptor]));
|
||||
|
||||
const uniqueProviderIdentities = (
|
||||
identities: readonly ProviderIdentity[],
|
||||
): readonly ProviderIdentity[] =>
|
||||
[...new Map(identities.map((identity) => [identity.id, identity])).values()]
|
||||
.sort((left, right) => left.id.localeCompare(right.id))
|
||||
.map((identity) => structuredClone(identity));
|
||||
|
||||
const assertDisjoint = (
|
||||
providers: readonly AnalysisProvider[],
|
||||
): ReadonlySet<string> => {
|
||||
@@ -370,24 +199,3 @@ const assertUniqueProviderIds = (
|
||||
ids.add(identity.id);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeProfileResolution = (
|
||||
resolution: AnalysisProfileResolution,
|
||||
providerId: string,
|
||||
): Result<AnalysisProfileResolution, ProviderAdapterError> => {
|
||||
try {
|
||||
return ok({
|
||||
profile:
|
||||
resolution.profile === null
|
||||
? null
|
||||
: analysisProfileSchema.parse(resolution.profile),
|
||||
compatibility: jsonObjectSchema.parse(resolution.compatibility),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new ProviderAdapterError(providerId, "resolve_analysis_profile", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user