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