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

Some files were not shown because too many files have changed in this diff Show More