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