Merge commit '5100a1a1a56b810c8246b8c7ed19ff42c4d9dbe1' into fix/ci-duplicate-boundaries-1603
This commit is contained in:
commit
3c794a5cc0
120 files changed
+5518
-1142
No files matched your search
@@ -2,7 +2,7 @@
|
||||
name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "36"
|
||||
version: "37"
|
||||
---
|
||||
|
||||
# REA
|
||||
@@ -25,57 +25,18 @@ a skill installed from repository main may describe capabilities absent from
|
||||
an older npm release. Keep complete inline Evidence when that server does not
|
||||
advertise retained references.
|
||||
|
||||
When tools are absent or registration is stale:
|
||||
If tools are absent, a call is blocked by the client, or a required provider is
|
||||
unavailable, read [connection and recovery](references/connection-and-recovery.md).
|
||||
Diagnose the observed blocker, continue useful supported work, and resume the
|
||||
original task after repair. Client approval, shell PATH, registration and engine
|
||||
availability have different remedies; avoid repeating setup for all of them.
|
||||
|
||||
1. Diagnose without changing files. For Codex, run
|
||||
`npx -y rea-agents@latest doctor --client codex --json`. Substitute the current
|
||||
supported client: `claude_code`, `claude_desktop`, `codex`, `cursor`,
|
||||
`gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`,
|
||||
`commandcode`, `qwen_code`, `vscode`, `grok_build`, `omp`, `pi`, `hermes`, or `grok_bot`. `grok_bot` has no local
|
||||
file registration: doctor reports the Grok Bot chat step, and setup does not
|
||||
write `mcp.json`. If the client is unknown, use `doctor --json` and
|
||||
inspect its registration results before choosing a setup scope.
|
||||
2. Distinguish the reason. Missing, malformed, or stale registration needs a
|
||||
scoped configuration repair. An aligned registration with no tools in the
|
||||
active session needs a restart/reconnection; doctor cannot prove that the
|
||||
current agent has connected. A missing provider affects only tasks requiring
|
||||
that provider: static JavaScript inspection needs neither Hopper nor Ghidra,
|
||||
and Android inspection has separate bring-your-own JADX/Java prerequisites.
|
||||
3. For a configuration repair, prepare the read-only plan:
|
||||
`npx -y rea-agents@latest setup --client codex --dry-run --json`.
|
||||
Use the current client's ID, show its exact proposed paths, backups, and
|
||||
changes, and obtain approval before setup writes configuration or installs
|
||||
Hopper. Setup normally installs the matching bundled skill too; include that
|
||||
replacement in the reviewed plan. After approval, apply the same scope with
|
||||
`npx -y rea-agents@latest setup --client codex --yes`. Add `--install-hopper`
|
||||
only if that separate installation was needed and explicitly approved.
|
||||
4. Restart/reconnect the affected agent. Verify that REA tools actually appear
|
||||
in the session, then resume the original investigation. If they remain
|
||||
absent, inspect the client's MCP launch error rather than repeating setup.
|
||||
|
||||
REA setup never installs or upgrades Node.js, npm, Homebrew, Java, Ghidra, IDA,
|
||||
JADX, Binwalk, or Unblob. Use existing prerequisites; do not install unrelated
|
||||
software to repair
|
||||
MCP registration. For an unsupported client, use manual stdio registration
|
||||
with a version-pinned `rea-agents` package or continue through the CLI.
|
||||
|
||||
A concrete CLI fallback for an operator-supplied JavaScript tree or ASAR is:
|
||||
|
||||
```bash
|
||||
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json > app-evidence.json
|
||||
jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' app-evidence.json > app-view.json
|
||||
npx -y rea-agents@latest inspect-analysis-view app-view.json
|
||||
```
|
||||
|
||||
No MCP registration or native engine is required. The CLI returns the complete
|
||||
Evidence record directly, and a real application can produce hundreds of
|
||||
megabytes, so save it to a file rather than reading it into context. Start from
|
||||
the summary view, then request a module page
|
||||
(`{"kind": "page", "collection": "modules", "offset": 0, "limit": 32}`) or one
|
||||
module the same way, or pass the saved Evidence inline to
|
||||
`trace-application-feature`. Read graph, limitations, and unknowns with the
|
||||
same care as an MCP result. This fallback does not establish that MCP is
|
||||
configured. Native CLI tasks still require their selected engine.
|
||||
Prefer an existing `rea` CLI. When none is installed and REA package acquisition
|
||||
is within the authorized scope, use `npx -y rea-agents@latest` in its place.
|
||||
Configuration repair starts with a read-only setup plan; show paths, backups and
|
||||
skill replacement before applying authorized changes. Preserve existing approval
|
||||
and target-execution boundaries. REA setup never installs or upgrades unrelated
|
||||
software, including Node.js, npm, Homebrew, Java, Ghidra, IDA, JADX, Binwalk or Unblob.
|
||||
|
||||
## Route the target first
|
||||
|
||||
@@ -140,9 +101,11 @@ after opening; IDA does not supply `binary_overview`. Consult session availabili
|
||||
when composing broader workflows. Modern direct callers and unsupported dossier
|
||||
facets remain unknown; live IDA results are not replayed from snapshots.
|
||||
|
||||
If the app is missing, ask which app to inspect. Resolve a human-readable app
|
||||
name to one clear installed artifact when possible; ask only when matches are
|
||||
ambiguous. Never choose an example app on the user's behalf.
|
||||
If the user says only "my app" without a name, path, or target already selected
|
||||
for this task, ask which app before searching for artifacts. Paths in unrelated
|
||||
saved reports and nearby test fixtures do not select the user's target. Resolve
|
||||
an actual human-readable app name to one clear installed artifact when possible;
|
||||
ask when matches are ambiguous. Never choose an example app on the user's behalf.
|
||||
|
||||
In a target-free session, use `open_binary` to bind any archive/package or
|
||||
native target whose analysis tool operates on the active target. Do not call an
|
||||
@@ -157,7 +120,11 @@ For a JavaScript application or ELF layout whose size is unknown or large,
|
||||
call `analyze_javascript_application` or `inspect_binary_layout` with
|
||||
`"detail": "summary"`, then inspect the retained `parent_evidence_id` with
|
||||
`inspect_analysis_view`.
|
||||
Do not repeat an identical tool call. Make a focused follow-up only when the
|
||||
Page module identities and select the returned `node_id` before requesting an
|
||||
item; a single file path can represent several graph roles. Do not repeat an
|
||||
identical analysis or rejected call without a changed input or resolved blocker.
|
||||
Recheck session state when a lifecycle change makes that status useful.
|
||||
Make a focused follow-up only when the
|
||||
returned result leaves a specific question unanswered.
|
||||
|
||||
If MCP reports `resource_constraint` with `details.resource: "transport"`, call
|
||||
@@ -168,6 +135,14 @@ session to a selected path. The analysis remains complete in the ledger; avoid
|
||||
repeating it merely to request the same oversized response. Complete CLI JSON
|
||||
output also streams without a single MCP frame.
|
||||
|
||||
A client can also truncate a successful tool result without a REA transport
|
||||
error. Treat that preview as incomplete; use the retained parent ID for smaller
|
||||
views or export when the full record is needed. Do not parse the preview as
|
||||
canonical JSON or repeat the producer. For JavaScript, read the
|
||||
[module-view and trace recipes](references/javascript-applications.md) before
|
||||
drilling into a large graph; a broad bidirectional trace can expand unrelated
|
||||
containment relationships even after summary analysis.
|
||||
|
||||
Every conclusion must distinguish observations, inferences, and unknowns. Cite
|
||||
Evidence IDs, preserve limitations and incomplete coverage, and never imply
|
||||
that static analysis observed execution. Runtime requests execute the declared
|
||||
@@ -175,7 +150,9 @@ target and lifecycle; do not broaden the target or action beyond those fields.
|
||||
|
||||
Within the user's requested investigation, call available analysis tools
|
||||
directly. REA does not require permission grants or per-call approval flags.
|
||||
Follow the declared request scope and the host's actual access requirements.
|
||||
The agent client can independently require approval. Follow the declared request
|
||||
scope and the host's actual access requirements; use the recovery guide for an
|
||||
observed client-policy rejection.
|
||||
|
||||
## Plan broader investigations
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Connection and task recovery
|
||||
|
||||
Read this guide only for an observed connection, command, approval or provider
|
||||
blocker. Keep the user's target and question in view. Explain what prevented the
|
||||
operation and the next useful step; after repair, continue the investigation.
|
||||
|
||||
Commands below use an installed `rea`. If none is available, use the package
|
||||
invocation in `SKILL.md` when acquiring REA is authorized. Follow the connected
|
||||
server's schemas and the installed CLI's help rather than assuming repository
|
||||
main and an older release expose the same arguments.
|
||||
|
||||
## Choose the remedy from the observed failure
|
||||
|
||||
| Observation | Meaning and next step |
|
||||
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| No REA tools in the active session | Inspect registration with `rea doctor --client codex --json`. A skill installation alone does not register MCP. |
|
||||
| Missing, malformed or stale registration | Prepare a scoped setup plan; apply only authorized changes with backups. |
|
||||
| Aligned registration, but no connected tools | Restart/reconnect the client. Doctor checks registration; it cannot establish the current agent's connection. Inspect the client's launch error if reconnection fails. |
|
||||
| `MCP tool call requires approval, but approval policy is never` | Codex rejected the call before REA executed it. Do not install an engine or repeat setup to solve this client-policy decision. See the approval section below. |
|
||||
| REA returns `provider_unavailable` or an unavailable tool entry | Read that operation's provider diagnostics or `binary_session.result.tool_availability`. Resolve only the prerequisite needed for the task; a complete tool catalog does not imply every operation is available. |
|
||||
| `rea` is missing in a child shell, or Node is incompatible | Inspect the actual shell PATH and launcher/runtime. A prefix installation may be absent from a login shell's PATH; this does not establish a broken MCP registration. |
|
||||
|
||||
If the client cannot launch its shell/sandbox helper or read the installed skill,
|
||||
inspect its launch diagnostic. For example, Codex can refuse helper aliases when
|
||||
CODEX_HOME is under `/tmp`; use an authorized profile outside the OS temporary
|
||||
directory. REA setup cannot repair the client's executor. Preserve the sandbox
|
||||
instead of silently disabling it to make an analysis command work.
|
||||
|
||||
Static JavaScript and ordinary ZIP inventory need neither Hopper nor Ghidra.
|
||||
HAR inspection is offline and does not require a live browser. Native
|
||||
decompilation, Android analysis and firmware extraction have their own
|
||||
bring-your-own prerequisites. Use supported inventory/static evidence when it
|
||||
answers part of the question, and name the unavailable evidence separately.
|
||||
Do not substitute another target. Label observations from ordinary shell tools
|
||||
as such; only actual REA output supplies REA Evidence.
|
||||
|
||||
## Repair registration when needed
|
||||
|
||||
For Codex:
|
||||
|
||||
```bash
|
||||
rea doctor --client codex --json
|
||||
rea setup --client codex --dry-run --json
|
||||
```
|
||||
|
||||
Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`,
|
||||
`cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`,
|
||||
`copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `grok_build`, `omp`, `pi`,
|
||||
`hermes`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
|
||||
scope. `grok_bot` uses a chat registration step rather than local `mcp.json`.
|
||||
For unsupported clients, use version-pinned manual stdio registration or CLI.
|
||||
|
||||
Show the plan's exact paths, backups, changes and bundled-skill replacement.
|
||||
Obtain approval for writes when that concrete scope is not already authorized,
|
||||
then apply the same scope with `rea setup --client codex --yes`. Add
|
||||
`--install-hopper` only when separately needed and explicitly approved. Do not
|
||||
install or upgrade unrelated prerequisites. Restart/reconnect, verify tools
|
||||
actually appear, and resume the original task. If they remain absent, inspect
|
||||
the launch failure rather than looping through setup.
|
||||
|
||||
## Codex tool approval is separate from REA readiness
|
||||
|
||||
REA tools have no permission-grant handshake. Codex can still require approval
|
||||
because an operation creates owned resources, launches a helper or has cleanup
|
||||
effects. An advertised tool or successful doctor result does not override that
|
||||
client policy. `workspace-write` alone does not resolve an MCP approval
|
||||
rejection, and `approval_policy = "never"` does not mean every MCP tool is
|
||||
preapproved.
|
||||
|
||||
When interactive approval is available, use the client's approval flow for the
|
||||
requested operation. In an unattended run, continue through an authorized CLI
|
||||
workflow if the execution environment permits it, or report the blocked
|
||||
operation and the approval needed to proceed. Do not retry the same rejected
|
||||
call, silently broaden approvals, or rewrite MCP effects as read-only.
|
||||
|
||||
For an operator who explicitly wants to preapprove named operations in a
|
||||
disposable Codex profile, current Codex supports per-tool configuration. For
|
||||
example, opening and closing an analysis session can be scoped independently:
|
||||
|
||||
```toml
|
||||
[mcp_servers.rea.tools.open_binary]
|
||||
approval_mode = "approve"
|
||||
|
||||
[mcp_servers.rea.tools.close_binary]
|
||||
approval_mode = "approve"
|
||||
```
|
||||
|
||||
Use the actual server ID and only the operations whose effects were approved;
|
||||
this example does not approve inspection, extraction or runtime execution.
|
||||
Show the proposed config edit and preserve unrelated entries and backups before
|
||||
writing. Managed policy can constrain client settings. Consult the
|
||||
[Codex configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference)
|
||||
for the installed client's supported settings. This is an optional recovery
|
||||
choice, not a prerequisite for every REA task.
|
||||
|
||||
## Continue through an installed CLI
|
||||
|
||||
Check the command environment only if the launcher fails:
|
||||
|
||||
```bash
|
||||
command -v rea
|
||||
command -v node
|
||||
node --version
|
||||
rea --version
|
||||
```
|
||||
|
||||
Use the public launcher at its known installation path with a supported existing
|
||||
Node on PATH when the child shell loses a prefix installation. Avoid internal
|
||||
`dist/cli.js` entrypoints or global toolchain changes. MCP may have its own valid
|
||||
launcher even when a shell cannot resolve `rea`.
|
||||
|
||||
For a user-supplied JavaScript tree or ASAR, select output files in the authorized
|
||||
workspace and save complete Evidence before reading a view:
|
||||
|
||||
```bash
|
||||
rea analyze-javascript-application /absolute/path/to/app --json > app-evidence.json
|
||||
jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' app-evidence.json > app-view.json
|
||||
rea inspect-analysis-view app-view.json
|
||||
```
|
||||
|
||||
These commands do not register MCP or install an analysis engine. CLI JavaScript
|
||||
JSON is complete and can be very large; redirect it rather than loading it into
|
||||
context. Use an existing JSON processor if `jq` is absent, without installing
|
||||
one just for this recipe. Inspect a module page or item using the same inline
|
||||
source and the [JavaScript recipes](javascript-applications.md). Preserve graph
|
||||
coverage, limitations and unknowns. Native CLI analysis still requires its
|
||||
selected engine, and all CLI operations retain the host's execution permissions.
|
||||
@@ -1,20 +1,90 @@
|
||||
# JavaScript and Electron application artifacts
|
||||
|
||||
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
|
||||
extracted tree. The complete result, graph, and Evidence context are returned
|
||||
inline when they fit the MCP frame. Source-map contents are part of the static analysis. Start from its
|
||||
findings, coverage, unknowns, and graph context, then make a focused follow-up
|
||||
only when a specific question remains unanswered.
|
||||
extracted tree. When size is unknown or large and the connected schema advertises
|
||||
`detail`, request `detail: "summary"`. This returns an `inspect_analysis_view`
|
||||
Evidence record with a distinct ID and a `normalized_result.parent_evidence_id`
|
||||
for the complete retained application analysis. Source-map contents are part of
|
||||
that static analysis. Read findings, coverage, limitations and unknowns, then
|
||||
follow a specific unanswered question. Older servers without `detail` return
|
||||
the complete application Evidence inline when it fits.
|
||||
|
||||
## Read a module without repeating analysis
|
||||
|
||||
Use the actual returned parent ID, on the same connection, as `source.evidence_id`.
|
||||
The following IDs are placeholders to replace with returned values. When the
|
||||
module's node ID is not already known, page identities before selecting an item.
|
||||
Pages return only node IDs, paths and roles; choose a useful page size and follow
|
||||
`coverage.next_offset` when another page matters:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"kind": "retained-evidence",
|
||||
"evidence_id": "ev_ACTUAL_PARENT_ID"
|
||||
},
|
||||
"view": { "kind": "page", "collection": "modules", "offset": 0, "limit": 16 }
|
||||
}
|
||||
```
|
||||
|
||||
Select the relevant page entry by its exact `node_id` and recorded role:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"kind": "retained-evidence",
|
||||
"evidence_id": "ev_ACTUAL_PARENT_ID"
|
||||
},
|
||||
"view": {
|
||||
"kind": "item",
|
||||
"collection": "modules",
|
||||
"selector": { "node_id": "jag_node_ACTUAL_NODE_ID" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Module pages include asset, bundled and source module roles, so one path can
|
||||
identify several entries. Use `selector.path` only when uniqueness is established;
|
||||
the returned node ID avoids needing a complete inventory to prove uniqueness. On ambiguity,
|
||||
select the matching node ID from the page or the error's candidates. Follow
|
||||
`next_offset` when another page matters. An item exposes that module node's
|
||||
recorded observations, identity, hashes, exports and source ranges; it does not
|
||||
include every child syntax observation, related edge or original source file.
|
||||
An absent item fact is not evidence of absence from the whole application.
|
||||
|
||||
## Trace only the remaining question
|
||||
|
||||
Use `trace_application_feature` on the parent application Evidence for one
|
||||
literal node ID, route, string, API, IPC channel, module, or native export.
|
||||
Select `incoming` for relationships leading to the seed, `outgoing` for those
|
||||
leaving it, and `both` when the question needs both. Trace traversal includes
|
||||
containment relationships, so even a narrow seed can expand many nodes; a
|
||||
bidirectional trace can reach unrelated sibling modules. A module item or page
|
||||
is preferable when its observations already answer the question. Do not treat
|
||||
all reached nodes as participants in the feature.
|
||||
|
||||
Reuse retained parent Evidence when the server advertises it, or supply complete
|
||||
Evidence inline. Include Hopper or Ghidra Evidence only when its artifact digest
|
||||
matches exactly.
|
||||
|
||||
## Recover an oversized display
|
||||
|
||||
An oversized response reports `resource_constraint` and
|
||||
`details.resource: "transport"`. Reuse
|
||||
`details.reported_limits.evidence_reference` with `inspect_analysis_view` for a
|
||||
summary, one module, or a stable page of module identities. Use
|
||||
`trace_application_feature` once a module, route, or string seed is known.
|
||||
summary, one module, or a stable page of module identities.
|
||||
`export_evidence_bundle` writes the complete canonical session to a
|
||||
caller-selected file. These workflows keep the original Evidence and coverage;
|
||||
a broad follow-up can also exceed framing.
|
||||
|
||||
A client can truncate a successful result inside its text envelope without a
|
||||
REA `resource_constraint`. Treat a truncation notice or incomplete JSON as a
|
||||
display limitation; do not claim the full record was examined. Reuse the known
|
||||
parent ID for smaller views, or export complete Evidence to a selected file
|
||||
while the session remains open. If the parent ID was lost from the preview,
|
||||
use the prior analysis summary or explicit ledger entries to recover it; do
|
||||
not invent an ID or rerun analysis just to reproduce the same display.
|
||||
|
||||
BrowserWindow preferences, preload and contextBridge surfaces, IPC
|
||||
registrations, utility processes, and native binding requests are static syntax
|
||||
observations. Only a unique exact literal IPC channel match supports an inferred
|
||||
@@ -22,13 +92,6 @@ pairing. Dynamic or ambiguous channels remain unresolved. A requested `.node`
|
||||
member is not a verified native export. Never claim runtime reachability,
|
||||
registration, defaults, or policy enforcement from static analysis.
|
||||
|
||||
Use `trace_application_feature` on existing application Evidence for one
|
||||
literal node ID, route, string, API, IPC channel, module, or native export.
|
||||
Choose a direction and reuse the returned application Evidence ID when the
|
||||
connected server advertises retained references, or supply complete Evidence
|
||||
inline. Include
|
||||
Hopper or Ghidra Evidence only when its artifact digest matches exactly.
|
||||
|
||||
For version comparison, analyze each version once, then call
|
||||
`compare_application_versions`. Accept only its digest, source-map, structural
|
||||
fingerprint, or non-module semantic matches. Module ordinals and minified names
|
||||
|
||||
@@ -142,6 +142,18 @@ listed after the table because its connector is not one of these files:
|
||||
| Pi | `pi` |
|
||||
| Hermes | `hermes` |
|
||||
|
||||
For GitHub Copilot CLI 1.0.95, `gpt-4.1` model metadata can block ordinary
|
||||
chat with REA's full input-schema profile before sending a model request
|
||||
(`compaction_static_context_blocked`; [#1554](https://github.com/morluto/rea/issues/1554)).
|
||||
The existing compact profile passed native chat and analysis with the complete
|
||||
tool inventory on Linux using a loopback model fixture. After setup, add
|
||||
`"REA_MCP_INPUT_SCHEMA_PROFILE": "compact"` to the `env` object of the
|
||||
`mcpServers.rea` entry in Copilot's `mcp-config.json`, preserving its other
|
||||
settings, then restart the client. Compact advertisements can reduce nested
|
||||
schema constraints; REA still validates complete canonical inputs. Full-profile
|
||||
`gpt-5.4` metadata is also verified locally. Live model API acceptance and
|
||||
Windows remain unverified; see [native client verification](testing.md).
|
||||
|
||||
For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md),
|
||||
setup writes a `type: "stdio"` entry under `mcpServers.rea` in the user-level
|
||||
`~/.pi/agent/mcp.json` and installs the bundled skill under `~/.agents/skills`.
|
||||
|
||||
@@ -114,6 +114,12 @@ from launch, must be ordered, and must fall within `timeout_ms`. Inputs marked
|
||||
Environment overrides are recorded in the scenario commitment; inherited
|
||||
values are not copied into Evidence.
|
||||
|
||||
Terminal dimensions must satisfy `columns × (rows + scrollback) ≤ 1,000,000`.
|
||||
Every scheduled resize uses the same selected scrollback and must fit this
|
||||
working-buffer budget. REA rejects oversized scenarios before launching the
|
||||
command; it never clamps the requested dimensions. This limit is independent
|
||||
of `limits.output_bytes`, which bounds retained terminal observations.
|
||||
|
||||
The capture contains raw PTY output chunks and rendered terminal states,
|
||||
interaction dispatch outcomes, exit reason, sampled process-tree observations,
|
||||
and settlement status. When filesystem paths are selected, REA records
|
||||
|
||||
+64
-4
@@ -160,10 +160,21 @@ chat or `REA_VERIFY_RUNTIME_ROOT` for a production-only installed package.
|
||||
The native offline BYOK adapter uses a loopback OpenAI completions/SSE fixture
|
||||
and `gpt-5.4` model metadata; model inference and token usage are synthetic.
|
||||
Set `REA_VERIFY_COPILOT_MODEL` to examine another model configuration. In the
|
||||
verified client, `gpt-4.1` blocks the complete catalog before HTTP with
|
||||
verified client, `gpt-4.1` blocks the full input-schema profile before HTTP with
|
||||
`compaction_static_context_blocked`, even when requesting a larger BYOK prompt
|
||||
capacity. The effective capacity is unknown; this lane does not establish a
|
||||
fix for that client/model limit. It preserves the complete catalog and schemas.
|
||||
capacity. The effective capacity is unknown. The default verifier preserves
|
||||
the complete catalog and full schemas. To verify the existing compact profile:
|
||||
|
||||
```bash
|
||||
REA_VERIFY_COPILOT_MODEL=gpt-4.1 REA_VERIFY_COPILOT_SCHEMA_PROFILE=compact npm run verify:copilot-client -- chat
|
||||
REA_VERIFY_COPILOT_MODEL=gpt-4.1 REA_VERIFY_COPILOT_SCHEMA_PROFILE=compact npm run verify:copilot-client -- call
|
||||
```
|
||||
|
||||
Both native workflows passed with the complete tool inventory. The verifier selects the
|
||||
profile only in its disposable Copilot registration and records it in the
|
||||
receipt. Compact advertisements can reduce nested validation structure; the
|
||||
server still validates complete canonical inputs. This is a tested alternate
|
||||
workflow, not a fix for the full-profile client/model limit tracked in #1554.
|
||||
Live model APIs, native Windows and default OS-home skill discovery remain
|
||||
unverified.
|
||||
|
||||
@@ -1053,6 +1064,39 @@ real local Codex CLI with:
|
||||
npm run verify:agent
|
||||
```
|
||||
|
||||
The lane runs scenarios sequentially in standalone Codex (`--no-daemon`) with a
|
||||
disposable HOME and CODEX_HOME under `~/.cache/rea-agent-evaluations/`. Set
|
||||
`REA_AGENT_EVAL_ROOT` to choose another parent directory outside the OS temporary
|
||||
directory: current Codex refuses helper aliases under `/tmp`, preventing shell
|
||||
commands and skill reads even if MCP works. Each owned run directory is removed
|
||||
after verification unless fixture retention is requested. The lane
|
||||
installs the packaged skill and MCP registration through public `rea setup`,
|
||||
rather than copying a skill into a project that also inherits the caller's
|
||||
skills and configuration. Only authentication is copied, when present, and it
|
||||
is removed even with `REA_AGENT_EVAL_KEEP_FIXTURES=true`. No user configuration,
|
||||
rules, plugins or history is copied; shell snapshots and additional agents are
|
||||
disabled. Existing API-key environment authentication remains available.
|
||||
The native missing-provider scenario preapproves only `open_binary` and
|
||||
`close_binary` in the disposable client so it reaches REA's actual provider
|
||||
boundary instead of stopping at Codex approval. This does not validate a real
|
||||
native engine or authorize arbitrary runtime execution.
|
||||
|
||||
Select a smaller run with `REA_AGENT_EVAL_SCENARIOS`, a comma-separated list of
|
||||
scenario IDs. `asar-zh` exercises the packaged desktop rubric from a Chinese
|
||||
request. `javascript-module-view` exercises summary analysis, module paging and
|
||||
item inspection in a tree with unrelated modules; its routing and text checks
|
||||
remain heuristic, so review the retained transcript for selector choice and
|
||||
coverage claims. Set `REA_CODEX_CLI` to an installed Codex executable and
|
||||
`REA_AGENT_EVAL_MODEL` to an account-supported model. Use an external resource
|
||||
limit for the whole process tree when testing on a constrained host; a Node
|
||||
heap limit alone does not bound client and child-process memory.
|
||||
|
||||
`missing-target` leaves desktop fixtures visible without selecting one in the
|
||||
request. It requires zero REA calls and a structured request for an app name or
|
||||
artifact path. Its `expectedFirstTool` is `null`, and
|
||||
`targetClarificationPassed` grades that limited routing outcome independently
|
||||
of analysis prose heuristics. Analyzing a nearby example cannot pass this case.
|
||||
|
||||
The packaged desktop and parser-comparison scenarios now assess a closed set of
|
||||
known fixture claims. Each final answer must be a strict JSON manifest containing
|
||||
exactly the requested claim IDs, values, Evidence IDs, and Evidence authority and
|
||||
@@ -1077,9 +1121,21 @@ navigation-context, and address-context scenarios have no factual rubric and
|
||||
remain `not_assessed`; the managed workflow requires both artifact and member
|
||||
inspection to answer its type and entry-point question.
|
||||
|
||||
The closed factual scenarios request complete producer Evidence on the initial
|
||||
analysis so their source selectors can authenticate all configured facts.
|
||||
Summary/page/item workflows are exercised separately by `javascript-module-view`;
|
||||
its heuristic gate does not establish full-producer factual correctness.
|
||||
|
||||
All scenarios retain routing, workflow, validation, repetition, process-exit,
|
||||
and token-use gates. Configured factual scenarios additionally require a factual
|
||||
pass. Their answer-text heuristics are diagnostic and do not affect the gate.
|
||||
Validation counts include complete text-only REA `invalid_request` envelopes
|
||||
and failed SDK calls whose arguments violate the named current REA contract.
|
||||
Client approval rejections and provider unavailability remain distinct failures;
|
||||
truncated JSON previews are not parsed into invented error codes.
|
||||
Repeated `binary_session` observations separated by a successful `open_binary`
|
||||
or `close_binary` are treated as lifecycle verification. An unchanged retry,
|
||||
including one after a failed lifecycle call, still counts as repetition.
|
||||
Scenarios without a rubric retain the legacy text-heuristic gate.
|
||||
`answerTermCoverageMet` checks case-insensitive substrings,
|
||||
`epistemicCuePresent` checks keywords, and `finalCitesEvidence` checks only an ID's
|
||||
@@ -1087,7 +1143,11 @@ presence. These metrics can still accept fabricated prose and must not be read
|
||||
as factual assessments. Set `REA_AGENT_EVAL_TRANSCRIPT_DIR` to retain complete
|
||||
tool results and final answers for review.
|
||||
|
||||
Report schema version 3 changes the top-level `factualCorrectness` from a constant
|
||||
Report schema version 4 adds negative routing scenarios with nullable
|
||||
`expectedFirstTool` and a `targetClarificationPassed` outcome. Consumers must
|
||||
allow an expected absence of REA calls; this does not make an analysis scenario
|
||||
pass without its required tools. Version 3 changed the top-level
|
||||
`factualCorrectness` from a constant
|
||||
string to an assessment summary with status, scope, and assessed/passed/failed/
|
||||
not-assessed scenario counts. Scenario records include the factual assessment and
|
||||
configured claim IDs. The evaluation scope is
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { delimiter, dirname, join, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { z } from "zod";
|
||||
|
||||
import { evaluateCodexEvents } from "../dist/evaluation/CodexAgentEval.js";
|
||||
import {
|
||||
@@ -15,32 +24,95 @@ import {
|
||||
agentEvaluationPassed,
|
||||
summarizeFactualCorrectness,
|
||||
} from "../dist/evaluation/AgentEvaluationReport.js";
|
||||
import { MCP_STARTUP_POLICY } from "../dist/mcpStartupPolicy.js";
|
||||
import { PRODUCT_IDENTITY } from "../dist/identity.js";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const verifierRun = createVerifierRun();
|
||||
const evaluationRoot = await mkdtemp(join(tmpdir(), "rea-agent-eval-"));
|
||||
const fixtureRoot = join(evaluationRoot, "targets");
|
||||
const skillDestination = join(
|
||||
evaluationRoot,
|
||||
".agents/skills/reverse-engineer-anything",
|
||||
);
|
||||
const timeoutMs = Number(process.env.REA_AGENT_EVAL_TIMEOUT_MS ?? 480_000);
|
||||
const codex = process.env.REA_CODEX_CLI ?? "codex";
|
||||
const optionalModel = process.env.REA_AGENT_EVAL_MODEL;
|
||||
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 10_000)
|
||||
throw new Error("REA_AGENT_EVAL_TIMEOUT_MS must be an integer >= 10000");
|
||||
const codexVersion = await readCommandVersion(codex);
|
||||
// Codex refuses helper aliases when CODEX_HOME is under the OS temp directory.
|
||||
// Keep the disposable account in a private cache, then remove the owned run.
|
||||
const evaluationParent = resolve(
|
||||
process.env.REA_AGENT_EVAL_ROOT ??
|
||||
join(homedir(), ".cache", "rea-agent-evaluations"),
|
||||
);
|
||||
await mkdir(evaluationParent, { recursive: true, mode: 0o700 });
|
||||
const evaluationRoot = await mkdtemp(join(evaluationParent, "run-"));
|
||||
const fixtureRoot = join(evaluationRoot, "targets");
|
||||
const account = join(evaluationRoot, "account");
|
||||
const codexHome = join(evaluationRoot, "codex");
|
||||
const authPath = join(codexHome, "auth.json");
|
||||
const skillDestination = join(
|
||||
account,
|
||||
".agents/skills/reverse-engineer-anything",
|
||||
);
|
||||
const evaluationEnvironment = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([name]) => !/^(?:REA_|CODEX_|GHIDRA_|HOPPER_|IDA_)/u.test(name),
|
||||
),
|
||||
),
|
||||
HOME: account,
|
||||
USERPROFILE: account,
|
||||
CODEX_HOME: codexHome,
|
||||
XDG_CONFIG_HOME: join(account, ".config"),
|
||||
XDG_DATA_HOME: join(account, ".local/share"),
|
||||
XDG_CACHE_HOME: join(account, ".cache"),
|
||||
REA_PROCESS_RUN_ID: verifierRun.run_id,
|
||||
PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ""}`,
|
||||
};
|
||||
|
||||
try {
|
||||
await mkdir(fixtureRoot, { recursive: true });
|
||||
await cp(
|
||||
join(repositoryRoot, "skills/reverse-engineer-anything"),
|
||||
skillDestination,
|
||||
{ recursive: true },
|
||||
const codexVersion = await readCommandVersion(codex);
|
||||
await Promise.all([
|
||||
mkdir(fixtureRoot, { recursive: true }),
|
||||
mkdir(account),
|
||||
mkdir(codexHome, { mode: 0o700 }),
|
||||
]);
|
||||
// Reuse authentication only. User config, plugins, rules, history and skills
|
||||
// must not make a fresh installed-skill trial pass accidentally.
|
||||
try {
|
||||
await copyFile(
|
||||
join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "auth.json"),
|
||||
authPath,
|
||||
);
|
||||
await chmod(authPath, 0o600);
|
||||
} catch (cause) {
|
||||
if (cause?.code !== "ENOENT") throw cause;
|
||||
// Environment/API-key authentication can work without a local auth file.
|
||||
}
|
||||
await writeFile(
|
||||
join(codexHome, "config.toml"),
|
||||
'approval_policy = "never"\nsandbox_mode = "read-only"\nweb_search = "disabled"\n[features]\nmulti_agent = false\nshell_snapshot = false\n[history]\npersistence = "none"\n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
for (const approval of ["--dry-run", "--yes"]) {
|
||||
await runProcess(
|
||||
process.execPath,
|
||||
[
|
||||
join(repositoryRoot, "scripts/rea.mjs"),
|
||||
"setup",
|
||||
"--client",
|
||||
"codex",
|
||||
approval,
|
||||
"--json",
|
||||
],
|
||||
evaluationRoot,
|
||||
120_000,
|
||||
evaluationEnvironment,
|
||||
);
|
||||
}
|
||||
const installedSkill = await readFile(
|
||||
join(skillDestination, "SKILL.md"),
|
||||
"utf8",
|
||||
);
|
||||
if (!installedSkill.includes(` version: "${PRODUCT_IDENTITY.skillVersion}"`))
|
||||
throw new Error("Setup did not install the current packaged REA skill");
|
||||
const requestedScenarioIds = new Set(
|
||||
(process.env.REA_AGENT_EVAL_SCENARIOS ?? "")
|
||||
.split(",")
|
||||
@@ -57,7 +129,11 @@ try {
|
||||
expectedFirstTool: "open_binary",
|
||||
requiresEvidence: false,
|
||||
requiredAnswerTermGroups: [["unavailable", "could not", "provider"]],
|
||||
prompt: `Explain what the shipped native program at ${targets.native} does. Base the answer on artifact analysis, and clearly state any facts that remain unavailable.`,
|
||||
// The verifier authorizes these named session operations, not arbitrary
|
||||
// writes or runtime execution. Missing-engine behavior stays observable.
|
||||
approvedTools: ["open_binary", "close_binary"],
|
||||
requiredToolSubsequence: ["open_binary", "close_binary"],
|
||||
prompt: `Explain what static artifact evidence establishes about the shipped native program at ${targets.native}. Do not execute the target or install tools. Clearly state unavailable provider evidence, and close any analysis session you opened before finishing.`,
|
||||
},
|
||||
{
|
||||
id: "asar",
|
||||
@@ -70,6 +146,38 @@ try {
|
||||
],
|
||||
prompt: `Explain how the desktop application at ${targets.javascript} exposes APIs to its renderer. Base the answer on the shipped application artifact, and state what remains unknown.`,
|
||||
},
|
||||
{
|
||||
id: "asar-zh",
|
||||
fixtureScenarioId: "asar",
|
||||
expectedFirstTool: "analyze_javascript_application",
|
||||
requiresEvidence: true,
|
||||
requiredAnswerTermGroups: [["profileapi"], ["profile:read"]],
|
||||
prompt: `請靜態分析已交付的 Electron 程式 ${targets.javascript},說明 renderer 如何透過 preload 讀取 profile,以及 main handler 的關係。不要執行目標或安裝分析引擎;引用完整的產出 Evidence,保留 runtime 未知事項。`,
|
||||
},
|
||||
{
|
||||
id: "javascript-module-view",
|
||||
expectedFirstTool: "analyze_javascript_application",
|
||||
requiresEvidence: true,
|
||||
requiredToolSubsequence: [
|
||||
"analyze_javascript_application",
|
||||
"inspect_analysis_view",
|
||||
"inspect_analysis_view",
|
||||
],
|
||||
requiredAnswerTermGroups: [
|
||||
["main.js"],
|
||||
["node", "identity"],
|
||||
["observations", "recorded"],
|
||||
],
|
||||
prompt: `Inspect the recorded module identity, exports and observations for main.js in the shipped JavaScript tree ${targets.largeJavascript}. Use REA without directly reading target files or executing the target. Keep results focused on that module, cite Evidence, and state the limits of what its module view establishes.`,
|
||||
},
|
||||
{
|
||||
id: "missing-target",
|
||||
expectedFirstTool: null,
|
||||
requiresEvidence: false,
|
||||
requiredAnswerTermGroups: [],
|
||||
prompt:
|
||||
'Reverse engineer my desktop app and explain how it saves user settings. Use static analysis; do not install software. End with one JSON object containing status ("needs_target" or "analyzed"), requested_input ("app_name_or_artifact_path" or null), and question (a string or null).',
|
||||
},
|
||||
{
|
||||
id: "javascript-export-shape",
|
||||
expectedFirstTool: "analyze_javascript_application",
|
||||
@@ -145,12 +253,14 @@ try {
|
||||
const results = [];
|
||||
for (const scenario of scenarios) {
|
||||
process.stderr.write(`Running Codex agent evaluation: ${scenario.id}\n`);
|
||||
const fixtureClaims = agentFixtureClaims(scenario.id, targets);
|
||||
const answerInstructions = agentFixtureAnswerInstructions(scenario.id);
|
||||
const fixtureId = scenario.fixtureScenarioId ?? scenario.id;
|
||||
const fixtureClaims = agentFixtureClaims(fixtureId, targets);
|
||||
const answerInstructions = agentFixtureAnswerInstructions(fixtureId);
|
||||
const execution = await runCodex(
|
||||
answerInstructions.length === 0
|
||||
? scenario.prompt
|
||||
: `${scenario.prompt}\n\n${answerInstructions}`,
|
||||
: `${scenario.prompt}\n\nThis closed factual rubric needs complete producer Evidence in the transcript. Request complete detail on each initial application analysis when supported; summary or view records alone are not accepted by this rubric.\n\n${answerInstructions}`,
|
||||
scenario.approvedTools ?? [],
|
||||
);
|
||||
const transcriptDirectory = process.env.REA_AGENT_EVAL_TRANSCRIPT_DIR;
|
||||
if (transcriptDirectory !== undefined) {
|
||||
@@ -175,6 +285,12 @@ try {
|
||||
...(fixtureClaims === undefined ? {} : { fixtureClaims }),
|
||||
},
|
||||
);
|
||||
// Fixtures are deliberately visible beside prior investigation artifacts.
|
||||
// Their existence does not identify the unnamed app in this request.
|
||||
const targetClarificationPassed =
|
||||
scenario.expectedFirstTool === null
|
||||
? validTargetClarification(metrics.finalMessage)
|
||||
: undefined;
|
||||
const result = {
|
||||
id: scenario.id,
|
||||
expectedFirstTool: scenario.expectedFirstTool,
|
||||
@@ -185,6 +301,9 @@ try {
|
||||
exitCode: execution.exitCode,
|
||||
stderr: execution.stderr,
|
||||
...metrics,
|
||||
...(targetClarificationPassed === undefined
|
||||
? {}
|
||||
: { targetClarificationPassed }),
|
||||
};
|
||||
results.push(result);
|
||||
process.stderr.write(
|
||||
@@ -194,13 +313,20 @@ try {
|
||||
|
||||
const factualCorrectness = summarizeFactualCorrectness(results);
|
||||
const summary = {
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
evaluationScope: "routing_workflow_and_configured_fixture_claims",
|
||||
factualCorrectness,
|
||||
verifier_run: await completeVerifierRun(verifierRun),
|
||||
codex,
|
||||
codexVersion,
|
||||
model: optionalModel ?? null,
|
||||
installation: {
|
||||
method: "rea_setup_codex",
|
||||
isolatedHome: true,
|
||||
isolatedCodexHome: true,
|
||||
skillVersion: PRODUCT_IDENTITY.skillVersion,
|
||||
authentication: "caller_auth_only_removed_after_run",
|
||||
},
|
||||
scenarios: results,
|
||||
totals: {
|
||||
scenarios: results.length,
|
||||
@@ -253,12 +379,32 @@ try {
|
||||
`Agent routing/workflow/answer checks failed: ${failed.map(({ id }) => id).join(", ")}`,
|
||||
);
|
||||
} finally {
|
||||
// Even a deliberately retained fixture directory must never retain auth.
|
||||
await rm(authPath, { force: true });
|
||||
if (process.env.REA_AGENT_EVAL_KEEP_FIXTURES !== "true")
|
||||
await rm(evaluationRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function createTargets(root, includeManaged) {
|
||||
const fixtures = await createAgentEvaluationFixtures(root, repositoryRoot);
|
||||
const largeJavascript = join(root, "large-app");
|
||||
await mkdir(join(largeJavascript, "lib"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(largeJavascript, "package.json"),
|
||||
'{"name":"large-app","main":"main.js"}\n',
|
||||
),
|
||||
writeFile(
|
||||
join(largeJavascript, "main.js"),
|
||||
'const { ipcMain } = require("electron");\nipcMain.handle("profile:read", (_event, id) => ({ id }));\n',
|
||||
),
|
||||
...Array.from({ length: 180 }, (_, index) =>
|
||||
writeFile(
|
||||
join(largeJavascript, "lib", `formatter-${String(index)}.js`),
|
||||
`export const format = (value) => String(value).trim();\n`,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const managedOutput = join(root, "managed-output");
|
||||
if (includeManaged) {
|
||||
@@ -292,15 +438,29 @@ async function createTargets(root, includeManaged) {
|
||||
return {
|
||||
native: "/bin/true",
|
||||
...fixtures,
|
||||
largeJavascript,
|
||||
managed: join(managedOutput, "AgentEval.dll"),
|
||||
};
|
||||
}
|
||||
|
||||
async function runCodex(prompt) {
|
||||
function validTargetClarification(message) {
|
||||
const schema = z.strictObject({
|
||||
status: z.literal("needs_target"),
|
||||
requested_input: z.literal("app_name_or_artifact_path"),
|
||||
question: z.string().refine((value) => value.trim().length > 0),
|
||||
});
|
||||
try {
|
||||
return schema.safeParse(JSON.parse(message)).success;
|
||||
} catch (cause) {
|
||||
if (cause instanceof SyntaxError) return false;
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
async function runCodex(prompt, approvedTools) {
|
||||
const arguments_ = [
|
||||
"--no-daemon",
|
||||
"exec",
|
||||
"--ignore-user-config",
|
||||
"--ignore-rules",
|
||||
"--ephemeral",
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
@@ -312,18 +472,16 @@ async function runCodex(prompt) {
|
||||
evaluationRoot,
|
||||
"-c",
|
||||
'approval_policy="never"',
|
||||
"-c",
|
||||
`mcp_servers.rea.command=${JSON.stringify(process.execPath)}`,
|
||||
"-c",
|
||||
`mcp_servers.rea.args=${JSON.stringify([join(repositoryRoot, "scripts/rea.mjs"), "mcp"])}`,
|
||||
"-c",
|
||||
`mcp_servers.rea.startup_timeout_sec=${String(MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)}`,
|
||||
...approvedTools.flatMap((name) => [
|
||||
"-c",
|
||||
`mcp_servers.rea.tools.${name}.approval_mode="approve"`,
|
||||
]),
|
||||
...(optionalModel === undefined ? [] : ["--model", optionalModel]),
|
||||
prompt,
|
||||
];
|
||||
const child = spawn(codex, arguments_, {
|
||||
cwd: evaluationRoot,
|
||||
env: process.env,
|
||||
env: evaluationEnvironment,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const events = [];
|
||||
@@ -362,9 +520,16 @@ async function runCodex(prompt) {
|
||||
return { events, exitCode, stderr: stderr.join("").slice(0, 32_768) };
|
||||
}
|
||||
|
||||
async function runProcess(command, arguments_, cwd, timeout) {
|
||||
async function runProcess(
|
||||
command,
|
||||
arguments_,
|
||||
cwd,
|
||||
timeout,
|
||||
env = process.env,
|
||||
) {
|
||||
const child = spawn(command, arguments_, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
|
||||
@@ -22,6 +22,12 @@ const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||
const command = process.env.REA_VERIFY_COPILOT_COMMAND ?? "copilot";
|
||||
const mode = process.argv[2] ?? "call";
|
||||
const model = process.env.REA_VERIFY_COPILOT_MODEL ?? "gpt-5.4";
|
||||
const inputSchemaProfile =
|
||||
process.env.REA_VERIFY_COPILOT_SCHEMA_PROFILE ?? "full";
|
||||
assert(
|
||||
["full", "compact"].includes(inputSchemaProfile),
|
||||
"REA_VERIFY_COPILOT_SCHEMA_PROFILE must be full or compact",
|
||||
);
|
||||
assert(
|
||||
["chat", "call"].includes(mode),
|
||||
"Usage: verify-copilot-client.mjs [chat|call]",
|
||||
@@ -311,6 +317,16 @@ try {
|
||||
assert.deepEqual(JSON.parse(repeated.stdout).appliedActions, []);
|
||||
assert.equal(await readFile(configPath, "utf8"), configured);
|
||||
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
|
||||
if (inputSchemaProfile === "compact") {
|
||||
// Select the existing presentation in this disposable native-client profile.
|
||||
const customized = JSON.parse(configured);
|
||||
assert(customized.mcpServers.rea);
|
||||
customized.mcpServers.rea.env = {
|
||||
...customized.mcpServers.rea.env,
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: inputSchemaProfile,
|
||||
};
|
||||
await writeFile(configPath, JSON.stringify(customized, null, 2));
|
||||
}
|
||||
const installedSkill = await readFile(
|
||||
join(skillDirectory, "SKILL.md"),
|
||||
"utf8",
|
||||
@@ -408,6 +424,7 @@ try {
|
||||
clientVersion,
|
||||
mode,
|
||||
model,
|
||||
inputSchemaProfile,
|
||||
passed: failure === undefined,
|
||||
artifacts: lab,
|
||||
runtimeRoot,
|
||||
@@ -421,7 +438,9 @@ try {
|
||||
skillDiscovery:
|
||||
"native COPILOT_HOME MCP configuration and caller-added isolated skill directory; default OS-home discovery unverified",
|
||||
capacityBoundary:
|
||||
"gpt-4.1 model configuration blocks the full catalog before HTTP even with a ten-million-token requested prompt override; gpt-5.4 is the verified local fixture configuration",
|
||||
inputSchemaProfile === "compact"
|
||||
? "Explicit compact profile: complete inventory and canonical server validation; advertised schemas may be reduced. This does not fix the full-profile gpt-4.1 context boundary."
|
||||
: "gpt-4.1 model configuration blocks the full catalog before HTTP even with a ten-million-token requested prompt override; gpt-5.4 is the verified full-profile local fixture configuration",
|
||||
catalogSize,
|
||||
schemasChecked,
|
||||
spillReadRequested,
|
||||
|
||||
@@ -20,10 +20,12 @@ export const snapshotAndroidTarget = async (
|
||||
sha256: string,
|
||||
root: string,
|
||||
operation: AndroidOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> => {
|
||||
const snapshot = join(root, "target.apk");
|
||||
try {
|
||||
await copyAndroidSnapshot(path, snapshot, sha256);
|
||||
await copyAndroidSnapshot(path, snapshot, sha256, signal);
|
||||
signal?.throwIfAborted();
|
||||
await chmod(snapshot, 0o400);
|
||||
return snapshot;
|
||||
} catch (cause: unknown) {
|
||||
@@ -44,10 +46,12 @@ export const snapshotAndroidEngine = async (
|
||||
sha256: string,
|
||||
root: string,
|
||||
operation: AndroidOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ path: string; sha256: string }> => {
|
||||
const path = join(root, "engine.jar");
|
||||
try {
|
||||
const observed = await copyAndroidSnapshot(source, path, sha256);
|
||||
const observed = await copyAndroidSnapshot(source, path, sha256, signal);
|
||||
signal?.throwIfAborted();
|
||||
await chmod(path, 0o400);
|
||||
return { path, sha256: observed };
|
||||
} catch (cause: unknown) {
|
||||
@@ -65,15 +69,22 @@ const copyAndroidSnapshot = async (
|
||||
sourcePath: string,
|
||||
snapshotPath: string,
|
||||
expectedSha256: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> => {
|
||||
const source = await openRegularFile(sourcePath, { symlinks: "follow" });
|
||||
signal?.throwIfAborted();
|
||||
const source = await openRegularFile(sourcePath, {
|
||||
symlinks: "follow",
|
||||
signal,
|
||||
});
|
||||
let destination: FileHandle | undefined;
|
||||
let inputStream: ReadStream | undefined;
|
||||
let outputStream: WriteStream | undefined;
|
||||
let removeSnapshot = false;
|
||||
let copiedBytes = 0;
|
||||
const hash = createHash("sha256");
|
||||
try {
|
||||
const initial = await source.stat();
|
||||
signal?.throwIfAborted();
|
||||
const digest = new Transform({
|
||||
transform(
|
||||
chunk: Buffer,
|
||||
@@ -95,13 +106,15 @@ const copyAndroidSnapshot = async (
|
||||
});
|
||||
try {
|
||||
destination = await open(snapshotPath, "wx", 0o600);
|
||||
signal?.throwIfAborted();
|
||||
inputStream = source.createReadStream({
|
||||
start: 0,
|
||||
end: initial.size,
|
||||
autoClose: false,
|
||||
});
|
||||
outputStream = destination.createWriteStream({ autoClose: false });
|
||||
await pipeline(inputStream, digest, outputStream);
|
||||
await pipeline(inputStream, digest, outputStream, { signal });
|
||||
signal?.throwIfAborted();
|
||||
await verifySourceState(source, sourcePath, initial, copiedBytes);
|
||||
const observedSha256 = hash.digest("hex");
|
||||
if (observedSha256 !== expectedSha256)
|
||||
@@ -110,8 +123,7 @@ const copyAndroidSnapshot = async (
|
||||
);
|
||||
return observedSha256;
|
||||
} catch (cause: unknown) {
|
||||
if (destination !== undefined)
|
||||
await rm(snapshotPath, { force: true }).catch(() => undefined);
|
||||
removeSnapshot = destination !== undefined;
|
||||
throw cause;
|
||||
}
|
||||
} finally {
|
||||
@@ -120,7 +132,12 @@ const copyAndroidSnapshot = async (
|
||||
// descriptor closure, after the admitted source state has been verified.
|
||||
inputStream?.destroy();
|
||||
outputStream?.destroy();
|
||||
await Promise.all([source.close(), destination?.close()]);
|
||||
try {
|
||||
await Promise.all([source.close(), destination?.close()]);
|
||||
} finally {
|
||||
if (removeSnapshot)
|
||||
await rm(snapshotPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ export const inspectJadxAvailability = async (
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProviderAvailability> => {
|
||||
try {
|
||||
const configuration = await readJadxConfiguration(environment, signal);
|
||||
const configuration = await readJadxConfigurationInputs(
|
||||
environment,
|
||||
signal,
|
||||
);
|
||||
await validateJadxConfigurationContents(configuration, environment, signal);
|
||||
return {
|
||||
status: "available",
|
||||
code: null,
|
||||
@@ -77,52 +81,83 @@ export const inspectJadxAvailability = async (
|
||||
}
|
||||
};
|
||||
|
||||
/** Resolve caller-selected tools and preserve the same prerequisite diagnostics. */
|
||||
export const resolveJadxConfiguration = async (
|
||||
/** Resolve caller-selected paths and JVM options without probing their contents. */
|
||||
export const resolveJadxConfigurationInputs = async (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
operation: AndroidOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JadxConfiguration> => {
|
||||
try {
|
||||
return await readJadxConfiguration(environment, signal);
|
||||
return await readJadxConfigurationInputs(environment, signal);
|
||||
} catch (cause) {
|
||||
if (signal?.aborted === true) throw cause;
|
||||
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
|
||||
const output = cause.diagnostics;
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
operation,
|
||||
cause.message,
|
||||
{
|
||||
cause,
|
||||
userMessage: cause.message,
|
||||
...(typeof output.stdout === "string" &&
|
||||
typeof output.stderr === "string"
|
||||
? {
|
||||
capturedOutput: {
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
truncated: output.output_truncated === true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
throw projectConfigurationFailure(operation, cause);
|
||||
}
|
||||
};
|
||||
|
||||
const readJadxConfiguration = async (
|
||||
/** Probe Java and archive prerequisites before starting a new engine session. */
|
||||
export const validateJadxConfiguration = async (
|
||||
configuration: JadxConfiguration,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
operation: AndroidOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await validateJadxConfigurationContents(configuration, environment, signal);
|
||||
} catch (cause) {
|
||||
if (signal?.aborted === true) throw cause;
|
||||
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
|
||||
throw projectConfigurationFailure(operation, cause);
|
||||
}
|
||||
};
|
||||
|
||||
const readJadxConfigurationInputs = async (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JadxConfiguration> => {
|
||||
signal?.throwIfAborted();
|
||||
requireSupportedHost();
|
||||
const jar = await resolveJar(environment);
|
||||
const java = await resolveJava(environment, signal);
|
||||
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
|
||||
const jvmArguments = resolveJvmArguments(environment);
|
||||
await inspectJavaRuntime(java, environment, signal);
|
||||
await requireEngineArchive(jar, signal);
|
||||
return { jar, java, jvmArguments };
|
||||
signal?.throwIfAborted();
|
||||
return { jar, java, jvmArguments: resolveJvmArguments(environment) };
|
||||
};
|
||||
|
||||
const validateJadxConfigurationContents = async (
|
||||
configuration: JadxConfiguration,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
signal?.throwIfAborted();
|
||||
await inspectJavaRuntime(configuration.java, environment, signal);
|
||||
await requireEngineArchive(configuration.jar, signal);
|
||||
};
|
||||
|
||||
const projectConfigurationFailure = (
|
||||
operation: AndroidOperation,
|
||||
cause: JadxConfigurationFailure,
|
||||
): AnalysisCapabilityUnavailableError => {
|
||||
const output = cause.diagnostics;
|
||||
return new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
operation,
|
||||
cause.message,
|
||||
{
|
||||
cause,
|
||||
userMessage: cause.message,
|
||||
...(typeof output.stdout === "string" && typeof output.stderr === "string"
|
||||
? {
|
||||
capturedOutput: {
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
truncated: output.output_truncated === true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const requireEngineArchive = async (
|
||||
|
||||
@@ -27,7 +27,8 @@ import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
inspectJadxAvailability,
|
||||
resolveJadxConfiguration,
|
||||
resolveJadxConfigurationInputs,
|
||||
validateJadxConfiguration,
|
||||
} from "./JadxConfiguration.js";
|
||||
import type { JadxLauncher } from "./JadxMcpTransport.js";
|
||||
|
||||
@@ -333,7 +334,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
request.operation,
|
||||
`Target ${target.path} is ${target.format}; provide one standalone APK.`,
|
||||
);
|
||||
const configuration = await resolveJadxConfiguration(
|
||||
const configuration = await resolveJadxConfigurationInputs(
|
||||
this.environment,
|
||||
request.operation,
|
||||
signal,
|
||||
@@ -382,18 +383,26 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
}
|
||||
let retained = this.#retained;
|
||||
if (retained === undefined) {
|
||||
await validateJadxConfiguration(
|
||||
configuration,
|
||||
this.environment,
|
||||
request.operation,
|
||||
signal,
|
||||
);
|
||||
root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" });
|
||||
const engine = await snapshotAndroidEngine(
|
||||
configuration.jar,
|
||||
jarHash,
|
||||
root.path,
|
||||
request.operation,
|
||||
signal,
|
||||
);
|
||||
const snapshot = await snapshotAndroidTarget(
|
||||
target.path,
|
||||
target.sha256,
|
||||
root.path,
|
||||
request.operation,
|
||||
signal,
|
||||
);
|
||||
const bridge = join(root.path, "ReaJadxBridge.java");
|
||||
await copyFile(BRIDGE_SOURCE, bridge);
|
||||
|
||||
@@ -5,27 +5,22 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { compareBundles } from "../domain/bundleComparison.js";
|
||||
import { EvidenceLedger } from "./investigation/EvidenceLedger.js";
|
||||
import {
|
||||
readEvidenceBundle,
|
||||
writeEvidenceBundle,
|
||||
} from "./EvidenceBundleFiles.js";
|
||||
|
||||
/** Validate and merge one bundle using the session Evidence ledger. */
|
||||
/** Validate a standalone bundle and report its complete record/revision counts. */
|
||||
export const importEvidenceBundleCommand = async (
|
||||
path: string,
|
||||
): Promise<Result<JsonValue, AnalysisError>> => {
|
||||
const loaded = await readEvidenceBundle(path);
|
||||
if (!loaded.ok) return loaded;
|
||||
const ledger = createLedger();
|
||||
const imported = ledger.import(loaded.value);
|
||||
return imported.ok
|
||||
? ok({
|
||||
imported: imported.value.recordsAdded,
|
||||
unknowns_added: imported.value.unknownsAdded,
|
||||
total: ledger.export().records.length,
|
||||
})
|
||||
: imported;
|
||||
return ok({
|
||||
imported: loaded.value.records.length,
|
||||
unknowns_added: loaded.value.unknowns.length,
|
||||
total: loaded.value.records.length,
|
||||
});
|
||||
};
|
||||
|
||||
/** Validate a source bundle and atomically export canonical bytes. */
|
||||
@@ -64,8 +59,6 @@ export const compareEvidenceBundlesCommand = async (input: {
|
||||
}
|
||||
};
|
||||
|
||||
const createLedger = (): EvidenceLedger => new EvidenceLedger();
|
||||
|
||||
const projectWrite = (
|
||||
bundle: EvidenceBundle,
|
||||
written: Awaited<ReturnType<typeof writeEvidenceBundle>>,
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../contracts/javascript/javascriptRuntimeReconciliationExample.js";
|
||||
import {
|
||||
createJavaScriptSemanticGraph,
|
||||
createJavaScriptSemanticGraphNode,
|
||||
JavaScriptSemanticEvidenceContextRegistry,
|
||||
} from "../domain/javascript/javascriptSemanticGraph.js";
|
||||
import { proofEvidence } from "./ReconstructionObligationLedger.fixture.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
@@ -171,3 +179,115 @@ describe("reconstruction obligation candidates", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps each semantic candidate bound to its own file and authority context", () => {
|
||||
const contexts = new JavaScriptSemanticEvidenceContextRegistry();
|
||||
const base = javascriptApplicationAnalysisResultSchema.parse(
|
||||
JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result,
|
||||
);
|
||||
const sourceRecords = [
|
||||
proofEvidence("first-file"),
|
||||
proofEvidence("second-file"),
|
||||
];
|
||||
const module = createJavaScriptSemanticGraphNode(
|
||||
{
|
||||
kind: "module",
|
||||
identity: {
|
||||
artifact_sha256: base.root_artifact_sha256,
|
||||
module_path: "app.js",
|
||||
source_range: null,
|
||||
role_key: "module",
|
||||
},
|
||||
function_node_id: null,
|
||||
application_node_ids: [],
|
||||
label: "app.js",
|
||||
properties: {},
|
||||
evidence: artifactEvidence(base.root_artifact_sha256, "app.js"),
|
||||
},
|
||||
contexts,
|
||||
);
|
||||
const requests = sourceRecords.flatMap((source, fileIndex) =>
|
||||
[1, 2].map((line) => {
|
||||
const sha256 = String(fileIndex + 4).repeat(64);
|
||||
const path = `file-${String(fileIndex)}.js`;
|
||||
return createJavaScriptSemanticGraphNode(
|
||||
{
|
||||
kind: "request",
|
||||
identity: {
|
||||
artifact_sha256: sha256,
|
||||
module_path: path,
|
||||
source_range: {
|
||||
start: { line, column: 0 },
|
||||
end: { line, column: 8 },
|
||||
},
|
||||
role_key: `request-${String(line)}`,
|
||||
},
|
||||
function_node_id: null,
|
||||
application_node_ids: [],
|
||||
label: `${path}:${String(line)}`,
|
||||
properties: {},
|
||||
evidence: {
|
||||
...artifactEvidence(sha256, path, "ast-static-analysis"),
|
||||
evidence_ids: [source.evidence_id],
|
||||
},
|
||||
},
|
||||
contexts,
|
||||
);
|
||||
}),
|
||||
);
|
||||
const { graph_id: _graphId, ...semantic } = base.semantic_graph;
|
||||
const semanticGraph = createJavaScriptSemanticGraph({
|
||||
...semantic,
|
||||
root_node_ids: [module.node_id],
|
||||
nodes: [module, ...requests],
|
||||
evidence_contexts: contexts.contexts,
|
||||
});
|
||||
const example = JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE;
|
||||
const application = createEvidence(
|
||||
{
|
||||
path: base.input_path,
|
||||
sha256: base.root_artifact_sha256,
|
||||
format: base.format,
|
||||
},
|
||||
example.provider,
|
||||
{
|
||||
predicateType: example.predicate_type,
|
||||
operation: example.operation,
|
||||
parameters: example.parameters,
|
||||
result: jsonValueSchema.parse({ ...base, semantic_graph: semanticGraph }),
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
evidenceLinks: sourceRecords.map(({ evidence_id }) => evidence_id),
|
||||
},
|
||||
);
|
||||
const generated = deriveReconstructionObligationCandidates(
|
||||
createEvidenceBundle([...sourceRecords, application]),
|
||||
[],
|
||||
);
|
||||
expect(generated.limitations).toEqual([]);
|
||||
expect(generated.candidates).toHaveLength(requests.length);
|
||||
for (const [index, node] of requests.entries()) {
|
||||
const source = sourceRecords[Math.floor(index / 2)];
|
||||
expect(
|
||||
generated.candidates.find(
|
||||
({ target }) => target.semantic_node_id === node.node_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
title: node.label,
|
||||
family: "request",
|
||||
source_state: "candidate",
|
||||
target: {
|
||||
artifact_sha256: node.identity.artifact_sha256,
|
||||
semantic_node_id: node.node_id,
|
||||
},
|
||||
authority_references: [
|
||||
{
|
||||
evidence_id: source?.evidence_id,
|
||||
authority: "controlled-replay",
|
||||
state: "candidate",
|
||||
location: `${semanticGraph.graph_id}/node/${node.node_id}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
ReconstructionObligation,
|
||||
ReviewedReconstructionObligation,
|
||||
} from "../domain/reconstructionObligationLedgerSchemas.js";
|
||||
import { resolveJavaScriptSemanticEvidence } from "../domain/javascript/javascriptSemanticGraph.js";
|
||||
import { parseApplicationGraphEvidence } from "./javascript/JavaScriptApplicationEvidenceGraph.js";
|
||||
import {
|
||||
applicationObligationPolicy,
|
||||
@@ -115,14 +114,21 @@ const deriveApplicationCandidates = (
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (source.semanticGraph !== null)
|
||||
if (source.semanticGraph !== null) {
|
||||
const evidenceContexts = new Map(
|
||||
source.semanticGraph.evidence_contexts.map((context) => [
|
||||
context.context_id,
|
||||
context,
|
||||
]),
|
||||
);
|
||||
for (const node of source.semanticGraph.nodes) {
|
||||
const candidatePolicy = semanticObligationPolicy(node.kind);
|
||||
if (candidatePolicy === undefined) continue;
|
||||
const evidenceContext = resolveJavaScriptSemanticEvidence(
|
||||
source.semanticGraph,
|
||||
node.evidence,
|
||||
);
|
||||
const evidenceContext = evidenceContexts.get(node.evidence.context_id);
|
||||
if (evidenceContext === undefined)
|
||||
throw new TypeError(
|
||||
"Semantic evidence reference names an absent context",
|
||||
);
|
||||
addCandidate(
|
||||
candidates,
|
||||
generatedCandidate({
|
||||
@@ -144,6 +150,7 @@ const deriveApplicationCandidates = (
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (source.graph.coverage.status !== "complete")
|
||||
limitations.add(
|
||||
`Application graph ${source.graph.graph_id} coverage is ${source.graph.coverage.status}; omitted candidates remain unknown.`,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { clientSkillDirectories } from "./SupportedClients.js";
|
||||
|
||||
export const MANAGED_SKILL_FILES = [
|
||||
"SKILL.md",
|
||||
"references/connection-and-recovery.md",
|
||||
"references/native-and-artifacts.md",
|
||||
"references/javascript-applications.md",
|
||||
"references/android-applications.md",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../../../tests/fixtures/analysisView.js";
|
||||
import {
|
||||
inspectAnalysisView,
|
||||
inspectAnalysisViewValidated,
|
||||
summarizeRetainedAnalysis,
|
||||
} from "./AnalysisViewService.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
@@ -58,6 +59,35 @@ it.each(["/fixtures/unknown.exe", ""])(
|
||||
},
|
||||
);
|
||||
|
||||
it("reports inline Evidence identity failures at the complete source path", () => {
|
||||
const parent = analysisViewLayoutEvidence();
|
||||
const input = {
|
||||
source: {
|
||||
kind: "inline",
|
||||
evidence: { ...parent, evidence_id: `ev_${"0".repeat(64)}` },
|
||||
},
|
||||
view: { kind: "summary" },
|
||||
} as const;
|
||||
|
||||
for (const result of [
|
||||
inspectAnalysisView(input),
|
||||
inspectAnalysisViewValidated(input),
|
||||
])
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisInputError",
|
||||
issues: [
|
||||
{
|
||||
path: ["source", "evidence", "evidence_id"],
|
||||
reason: "invalid_value",
|
||||
message: "Evidence semantic identifier does not match its record",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("projects a small native view from an authenticated >10 MiB retained dossier", () => {
|
||||
const original = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const parent = createEvidence(
|
||||
|
||||
@@ -132,8 +132,11 @@ const resolveAnalysisViewSource = (
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
cause.issues,
|
||||
source.evidence,
|
||||
cause.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "evidence", ...issue.path],
|
||||
})),
|
||||
{ source },
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -355,7 +355,7 @@ const semanticNodeFor = (roleKey: string) => {
|
||||
unpacked: false,
|
||||
text: { included: true, value: "" },
|
||||
};
|
||||
const state = createSemanticGraphProjectionState({ nodes: [] });
|
||||
const state = createSemanticGraphProjectionState();
|
||||
return constructSemanticGraphNode(
|
||||
file,
|
||||
{
|
||||
@@ -565,7 +565,7 @@ it("does not mark a file truncated when it exactly fills its budget", () => {
|
||||
it("only records node loss when the budget rejects a new identity", () => {
|
||||
// The truncation signal must record blocked node creation, not a zero
|
||||
// remaining budget: a file using exactly its share drops nothing.
|
||||
const state = createSemanticGraphProjectionState({ nodes: [] });
|
||||
const state = createSemanticGraphProjectionState();
|
||||
state.fileNodeBudget = 2;
|
||||
state.fileNodesDropped = false;
|
||||
const first = addSemanticGraphNode(state, semanticNodeFor("first"));
|
||||
|
||||
@@ -148,7 +148,7 @@ export interface JavaScriptSemanticGraphProjection {
|
||||
/** Accumulate semantic nodes while each source IR is still file-local. */
|
||||
export const createJavaScriptSemanticGraphProjection =
|
||||
(): JavaScriptSemanticGraphProjection => {
|
||||
const state = emptyState({ nodes: [] });
|
||||
const state = emptyState();
|
||||
const fingerprints: JavaScriptSemanticFingerprint[] = [];
|
||||
let truncatedFiles = 0;
|
||||
const projectSourceSteps = function* (
|
||||
|
||||
@@ -28,7 +28,6 @@ export interface SemanticGraphProjectionState {
|
||||
readonly unknowns: Map<string, JavaScriptSemanticGraphUnknown>;
|
||||
readonly evidenceContexts: JavaScriptSemanticEvidenceContextRegistry;
|
||||
readonly roots: Set<string>;
|
||||
readonly applicationNodeIdsByLocation: ReadonlyMap<string, readonly string[]>;
|
||||
/**
|
||||
* Remaining node budget for the file currently being projected.
|
||||
*
|
||||
@@ -69,32 +68,29 @@ export interface SemanticRelationConstructionInput {
|
||||
readonly properties?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Create empty projection state with structural application-node mappings. */
|
||||
export const createSemanticGraphProjectionState = (
|
||||
applicationGraph: Pick<JavaScriptApplicationGraph, "nodes">,
|
||||
): SemanticGraphProjectionState => {
|
||||
const index = indexApplicationNodes(applicationGraph.nodes);
|
||||
return {
|
||||
/** Create empty projection state; application-node links are bound at finish. */
|
||||
export const createSemanticGraphProjectionState =
|
||||
(): SemanticGraphProjectionState => ({
|
||||
nodes: new Map(),
|
||||
relations: new Map(),
|
||||
unknowns: new Map(),
|
||||
evidenceContexts: new JavaScriptSemanticEvidenceContextRegistry(),
|
||||
roots: new Set(),
|
||||
applicationNodeIdsByLocation: index.identifiers,
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
/** Bind retained semantic identities after all application observations exist. */
|
||||
export const bindSemanticGraphApplicationNodes = (
|
||||
state: SemanticGraphProjectionState,
|
||||
applicationGraph: Pick<JavaScriptApplicationGraph, "nodes">,
|
||||
): void => {
|
||||
const index = indexApplicationNodes(applicationGraph.nodes).identifiers;
|
||||
const applicationNodeIdsByLocation = indexApplicationNodes(
|
||||
applicationGraph.nodes,
|
||||
);
|
||||
for (const [identifier, node] of state.nodes) {
|
||||
state.nodes.set(identifier, {
|
||||
...node,
|
||||
application_node_ids: [
|
||||
...(index.get(
|
||||
...(applicationNodeIdsByLocation.get(
|
||||
applicationLocationKey(
|
||||
node.identity.artifact_sha256,
|
||||
node.identity.module_path,
|
||||
@@ -122,7 +118,7 @@ export const constructSemanticGraphNode = (
|
||||
role_key: input.roleKey,
|
||||
},
|
||||
function_node_id: input.functionNodeId,
|
||||
application_node_ids: matchingApplicationNodeIds(file, input, state),
|
||||
application_node_ids: [],
|
||||
label: input.label === null ? null : javascriptDisplayText(input.label),
|
||||
properties: input.properties ?? {},
|
||||
evidence: observedSemanticEvidence(file, input.location),
|
||||
@@ -247,9 +243,7 @@ export const addSemanticFallbackRoot = (
|
||||
|
||||
const indexApplicationNodes = (
|
||||
nodes: JavaScriptApplicationGraph["nodes"],
|
||||
): {
|
||||
readonly identifiers: ReadonlyMap<string, readonly string[]>;
|
||||
} => {
|
||||
): ReadonlyMap<string, readonly string[]> => {
|
||||
const identifiers = new Map<string, Set<string>>();
|
||||
for (const node of nodes) {
|
||||
for (const observation of node.observations) {
|
||||
@@ -280,19 +274,9 @@ const indexApplicationNodes = (
|
||||
return [key, sorted];
|
||||
}),
|
||||
);
|
||||
return { identifiers: indexed };
|
||||
return indexed;
|
||||
};
|
||||
|
||||
const matchingApplicationNodeIds = (
|
||||
file: JavaScriptArtifactFile,
|
||||
input: SemanticNodeConstructionInput,
|
||||
state: SemanticGraphProjectionState,
|
||||
): string[] => [
|
||||
...(state.applicationNodeIdsByLocation.get(
|
||||
applicationLocationKey(file.sha256, file.path, input.location),
|
||||
) ?? []),
|
||||
];
|
||||
|
||||
const addApplicationNodeIdentifier = (
|
||||
identifiers: Map<string, Set<string>>,
|
||||
key: string,
|
||||
|
||||
@@ -60,6 +60,30 @@ describe("English Unicode case collisions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains every spelling in a high-cardinality folded sibling group", () => {
|
||||
const count = 8_192;
|
||||
const variants = caseVariants("abcdefghijklm", count);
|
||||
const { limitation, byPath } = annotate(variants);
|
||||
const ordered = [...variants].sort();
|
||||
const first = ordered[0];
|
||||
const second = ordered[1];
|
||||
const original = variants[0];
|
||||
if (first === undefined || second === undefined || original === undefined) {
|
||||
throw new Error("Expected at least two case variants");
|
||||
}
|
||||
expect(limitation).toBe(ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION);
|
||||
expect(byPath.size).toBe(count);
|
||||
expect(byPath.get(first)).toEqual([
|
||||
`Logical path ${first} collides under English (en-US) Unicode case folding with ${second} and ${String(count - 2)} other spellings; ${NOTE}`,
|
||||
]);
|
||||
expect(byPath.get(original)?.[0]).toContain(
|
||||
`with ${first} and ${String(count - 2)} other spellings;`,
|
||||
);
|
||||
expect(
|
||||
[...byPath.values()].every((limitations) => limitations.length === 1),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stays linear for large case-variant subtrees", () => {
|
||||
const count = 5_000;
|
||||
const paths = Array.from({ length: count }, (_, index) => [
|
||||
@@ -81,3 +105,12 @@ describe("English Unicode case collisions", () => {
|
||||
expect(byPath.get(deep)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const caseVariants = (lowercase: string, count: number): string[] =>
|
||||
Array.from({ length: count }, (_, mask) =>
|
||||
[...lowercase]
|
||||
.map((character, index) =>
|
||||
(mask & (1 << index)) === 0 ? character : character.toUpperCase(),
|
||||
)
|
||||
.join(""),
|
||||
);
|
||||
@@ -177,12 +177,12 @@ const englishUnicodeCaseCollisions = (
|
||||
if (path.length === 0 || path === ".") continue;
|
||||
let node = root;
|
||||
for (const part of path.split("/")) {
|
||||
const folded = englishCaseFold(part);
|
||||
const spellings = node.spellingsByFold.get(folded);
|
||||
if (spellings === undefined) node.spellingsByFold.set(folded, [part]);
|
||||
else if (!spellings.includes(part)) spellings.push(part);
|
||||
let child = node.bySpelling.get(part);
|
||||
if (child === undefined) {
|
||||
const folded = englishCaseFold(part);
|
||||
const spellings = node.spellingsByFold.get(folded);
|
||||
if (spellings === undefined) node.spellingsByFold.set(folded, [part]);
|
||||
else spellings.push(part);
|
||||
child = emptyCaseNode();
|
||||
node.bySpelling.set(part, child);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { isAbsolute, join, relative, sep } from "node:path";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
|
||||
import { getRawHeader, listPackage, statFile, uncache } from "@electron/asar";
|
||||
import { listPackage, statFile, uncache } from "@electron/asar";
|
||||
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
@@ -41,6 +41,11 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
#archiveSize: number | undefined;
|
||||
#headerSize: number | undefined;
|
||||
readonly #entries = new Map<string, AsarEntryState>();
|
||||
readonly #unpackedHandles = new Map<UnpackedFileHandle, string>();
|
||||
readonly #unpackedHandleClosePromises = new Map<
|
||||
UnpackedFileHandle,
|
||||
Promise<void>
|
||||
>();
|
||||
#snapshot: { readonly path: string; readonly sha256: string } | undefined;
|
||||
#snapshotRoot: string | undefined;
|
||||
|
||||
@@ -65,6 +70,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
try {
|
||||
const admitted = await admitAsarHeader(headerHandle);
|
||||
this.#archiveSize = admitted.archiveBytes;
|
||||
this.#headerSize = admitted.headerBytes;
|
||||
} finally {
|
||||
await headerHandle.close();
|
||||
}
|
||||
@@ -72,7 +78,6 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
paths = listPackage(snapshotPath, { isPack: false }).sort((left, right) =>
|
||||
left.localeCompare(right, "en"),
|
||||
);
|
||||
this.#headerSize = getRawHeader(snapshotPath).headerSize;
|
||||
const archiveMetadata = await lstat(snapshotPath);
|
||||
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
|
||||
throw new ArtifactReaderFailure(
|
||||
@@ -156,32 +161,8 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
);
|
||||
const { metadata } = state;
|
||||
const producedEntry = state.entry;
|
||||
if (producedEntry.unpacked) {
|
||||
const handle = await this.#openUnpackedEntry(producedEntry);
|
||||
try {
|
||||
const observed = await handle.stat();
|
||||
abortIfNeeded(signal);
|
||||
if (!observed.isFile())
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`ASAR unpacked entry is not a regular file: ${producedEntry.path}`,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
await handle.close().catch(() => undefined);
|
||||
throw asarFailure(this.path, `read ${producedEntry.path}`, cause);
|
||||
}
|
||||
const source = handle.createReadStream({
|
||||
start: 0,
|
||||
autoClose: true,
|
||||
});
|
||||
return readValidatedAsarEntry(
|
||||
source,
|
||||
undefined,
|
||||
producedEntry.path,
|
||||
this.path,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
if (producedEntry.unpacked)
|
||||
return this.#openUnpackedStream(producedEntry, signal);
|
||||
const archiveSize = this.#archiveSize;
|
||||
const headerSize = this.#headerSize;
|
||||
const offset =
|
||||
@@ -315,22 +296,39 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
let cleanupFailure: ArtifactReaderFailure | undefined;
|
||||
for (const [handle, path] of this.#unpackedHandles) {
|
||||
try {
|
||||
await this.#closeUnpackedHandle(handle);
|
||||
} catch (cause: unknown) {
|
||||
cleanupFailure = ArtifactReaderFailure.withCleanup(
|
||||
cleanupFailure ?? cause,
|
||||
ArtifactReaderFailure.cleanupObservation(cause, path),
|
||||
);
|
||||
}
|
||||
}
|
||||
const root = this.#snapshotRoot;
|
||||
if (root !== undefined) {
|
||||
try {
|
||||
await this.removeSnapshot(root);
|
||||
if (this.#snapshotRoot === root) this.#snapshotRoot = undefined;
|
||||
} catch (cause: unknown) {
|
||||
throw ArtifactReaderFailure.withCleanup(cause, {
|
||||
reason: `Could not remove ASAR snapshot: ${root}`,
|
||||
resources: [root],
|
||||
});
|
||||
cleanupFailure = ArtifactReaderFailure.withCleanup(
|
||||
cleanupFailure ?? cause,
|
||||
{
|
||||
reason: `Could not remove ASAR snapshot: ${root}`,
|
||||
resources: [root],
|
||||
},
|
||||
);
|
||||
}
|
||||
this.#snapshotRoot = undefined;
|
||||
}
|
||||
if (this.#snapshot !== undefined) uncache(this.#snapshot.path);
|
||||
this.#snapshot = undefined;
|
||||
this.#resetArchiveState();
|
||||
this.#entries.clear();
|
||||
if (root === undefined || this.#snapshotRoot === undefined) {
|
||||
if (this.#snapshot !== undefined) uncache(this.#snapshot.path);
|
||||
this.#snapshot = undefined;
|
||||
this.#resetArchiveState();
|
||||
this.#entries.clear();
|
||||
}
|
||||
if (cleanupFailure !== undefined) throw cleanupFailure;
|
||||
}
|
||||
|
||||
#snapshotPath(): string {
|
||||
@@ -346,8 +344,12 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
return [];
|
||||
}
|
||||
|
||||
async #openUnpackedEntry(entry: ArtifactEntry): Promise<UnpackedFileHandle> {
|
||||
async #openUnpackedStream(
|
||||
entry: ArtifactEntry,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Readable> {
|
||||
const unpackedRoot = `${this.path}.unpacked`;
|
||||
let handle: UnpackedFileHandle | undefined;
|
||||
try {
|
||||
const rootMetadata = await lstat(unpackedRoot);
|
||||
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink())
|
||||
@@ -374,22 +376,18 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
"path",
|
||||
`ASAR unpacked entry is not a regular file: ${entry.path}`,
|
||||
);
|
||||
const handle = await this.openUnpackedFile(
|
||||
handle = await this.openUnpackedFile(
|
||||
canonical,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
const openedMetadata = await handle
|
||||
.stat()
|
||||
.catch(async (cause: unknown) => {
|
||||
await handle.close().catch(() => undefined);
|
||||
throw cause;
|
||||
});
|
||||
const openedHandle = handle;
|
||||
this.#unpackedHandles.set(openedHandle, canonical);
|
||||
const openedMetadata = await openedHandle.stat();
|
||||
if (
|
||||
!openedMetadata.isFile() ||
|
||||
openedMetadata.dev !== pathMetadata.dev ||
|
||||
openedMetadata.ino !== pathMetadata.ino
|
||||
) {
|
||||
await handle.close();
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`ASAR unpacked entry changed before read: ${entry.path}`,
|
||||
@@ -402,25 +400,54 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
},
|
||||
);
|
||||
}
|
||||
return handle;
|
||||
abortIfNeeded(signal);
|
||||
const source = openedHandle.createReadStream({
|
||||
start: 0,
|
||||
autoClose: false,
|
||||
});
|
||||
source.once("close", () => {
|
||||
void this.#closeUnpackedHandle(openedHandle).catch(() => undefined);
|
||||
});
|
||||
return readValidatedAsarEntry(
|
||||
source,
|
||||
undefined,
|
||||
entry.path,
|
||||
this.path,
|
||||
signal,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure) throw cause;
|
||||
if (entry.unpacked && isMissingFile(cause))
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`ASAR unpacked entry bytes are unavailable: ${entry.path}`,
|
||||
{ cause },
|
||||
{
|
||||
logicalPath: entry.path,
|
||||
declaredSha256: entry.declaredSha256,
|
||||
calculatedSha256: null,
|
||||
unpacked: true,
|
||||
},
|
||||
);
|
||||
throw asarFailure(this.path, `read ${entry.path}`, cause);
|
||||
let failure = asarUnpackedEntryFailure(this.path, entry, cause);
|
||||
if (handle !== undefined && this.#unpackedHandles.has(handle)) {
|
||||
try {
|
||||
await this.#closeUnpackedHandle(handle);
|
||||
} catch (cleanupCause: unknown) {
|
||||
failure = ArtifactReaderFailure.withCleanup(
|
||||
failure,
|
||||
ArtifactReaderFailure.cleanupObservation(
|
||||
cleanupCause,
|
||||
this.#unpackedHandles.get(handle) ?? entry.path,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
async #closeUnpackedHandle(handle: UnpackedFileHandle): Promise<void> {
|
||||
if (!this.#unpackedHandles.has(handle)) return;
|
||||
const pending = this.#unpackedHandleClosePromises.get(handle);
|
||||
if (pending !== undefined) return pending;
|
||||
const closing = Promise.resolve()
|
||||
.then(() => handle.close())
|
||||
.then(() => {
|
||||
this.#unpackedHandles.delete(handle);
|
||||
})
|
||||
.finally(() => this.#unpackedHandleClosePromises.delete(handle));
|
||||
this.#unpackedHandleClosePromises.set(handle, closing);
|
||||
return closing;
|
||||
}
|
||||
|
||||
#resetArchiveState(): void {
|
||||
this.#archiveSize = undefined;
|
||||
this.#headerSize = undefined;
|
||||
@@ -442,6 +469,27 @@ type OpenAsarUnpackedFile = (
|
||||
flags: number,
|
||||
) => Promise<UnpackedFileHandle>;
|
||||
|
||||
const asarUnpackedEntryFailure = (
|
||||
archivePath: string,
|
||||
entry: ArtifactEntry,
|
||||
cause: unknown,
|
||||
): ArtifactReaderFailure => {
|
||||
if (cause instanceof ArtifactReaderFailure) return cause;
|
||||
if (entry.unpacked && isMissingFile(cause))
|
||||
return new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`ASAR unpacked entry bytes are unavailable: ${entry.path}`,
|
||||
{ cause },
|
||||
{
|
||||
logicalPath: entry.path,
|
||||
declaredSha256: entry.declaredSha256,
|
||||
calculatedSha256: null,
|
||||
unpacked: true,
|
||||
},
|
||||
);
|
||||
return asarFailure(archivePath, `read ${entry.path}`, cause);
|
||||
};
|
||||
|
||||
const copyArtifactEntry = (entry: ArtifactEntry): ArtifactEntry => ({
|
||||
...entry,
|
||||
limitations: [...entry.limitations],
|
||||
|
||||
+129
-42
@@ -1,12 +1,11 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
realpath,
|
||||
rm,
|
||||
rmdir,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
@@ -19,6 +18,19 @@ import {
|
||||
normalizeArtifactPath,
|
||||
} from "./ArtifactPaths.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { removeOwnedTree } from "./SafeOutputTreeCleanup.js";
|
||||
import {
|
||||
assertHandleIdentity,
|
||||
assertFilePathIdentity,
|
||||
assertPathIdentity,
|
||||
isAbsent,
|
||||
pathHasIdentity,
|
||||
readDirectoryIdentity,
|
||||
readFileIdentity,
|
||||
replacedDirectory,
|
||||
type DirectoryIdentity,
|
||||
type FileIdentity,
|
||||
} from "./SafeOutputTreeIdentity.js";
|
||||
|
||||
/** One file durably written to an operation-owned output tree. */
|
||||
export interface SafeOutputFile {
|
||||
@@ -36,10 +48,18 @@ export type SafeOutputCleanup =
|
||||
readonly residualPaths: readonly [string, ...string[]];
|
||||
};
|
||||
|
||||
/** Symlink-resistant materialization in an exclusively owned, initially absent tree. */
|
||||
/**
|
||||
* Materialize files in a tree created by this operation.
|
||||
*
|
||||
* Path identities are revalidated around operations, but Node has no portable
|
||||
* descriptor-relative traversal; a syscall-boundary pathname race remains.
|
||||
*/
|
||||
export class SafeOutputTree {
|
||||
readonly #registry = new ArtifactPathRegistry();
|
||||
readonly #outputRoot: string;
|
||||
readonly #rootIdentity: DirectoryIdentity;
|
||||
readonly #nestedDirectories = new Map<string, DirectoryIdentity>();
|
||||
readonly #ownedFiles = new Map<string, FileIdentity>();
|
||||
#published = false;
|
||||
#cleanup: SafeOutputCleanup = {
|
||||
status: "not-required",
|
||||
@@ -47,9 +67,11 @@ export class SafeOutputTree {
|
||||
|
||||
private constructor(
|
||||
outputRoot: string,
|
||||
rootIdentity: DirectoryIdentity,
|
||||
private readonly platform: NodeJS.Platform,
|
||||
) {
|
||||
this.#outputRoot = outputRoot;
|
||||
this.#rootIdentity = rootIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,29 +113,45 @@ export class SafeOutputTree {
|
||||
);
|
||||
throw cause;
|
||||
});
|
||||
let rootIdentity: DirectoryIdentity | undefined;
|
||||
try {
|
||||
rootIdentity = await readDirectoryIdentity(canonicalOutput);
|
||||
if (platform !== "win32") {
|
||||
const stagingHandle = await open(
|
||||
canonicalOutput,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY,
|
||||
);
|
||||
try {
|
||||
await assertHandleIdentity(
|
||||
stagingHandle,
|
||||
rootIdentity,
|
||||
canonicalOutput,
|
||||
);
|
||||
await stagingHandle.chmod(0o700);
|
||||
} finally {
|
||||
await stagingHandle.close();
|
||||
}
|
||||
}
|
||||
return new SafeOutputTree(canonicalOutput, platform);
|
||||
await assertPathIdentity(canonicalOutput, rootIdentity);
|
||||
return new SafeOutputTree(canonicalOutput, rootIdentity, platform);
|
||||
} catch (cause: unknown) {
|
||||
let removalFailure: unknown;
|
||||
let ownedRootAtPath = false;
|
||||
try {
|
||||
await rm(canonicalOutput, { recursive: true, force: true });
|
||||
ownedRootAtPath =
|
||||
rootIdentity !== undefined &&
|
||||
(await pathHasIdentity(canonicalOutput, rootIdentity));
|
||||
if (ownedRootAtPath) await rmdir(canonicalOutput);
|
||||
else
|
||||
removalFailure = new Error(
|
||||
"Extraction output root identity changed during setup",
|
||||
);
|
||||
} catch (cleanupCause: unknown) {
|
||||
removalFailure = cleanupCause;
|
||||
}
|
||||
let absent = false;
|
||||
try {
|
||||
absent = await isAbsent(canonicalOutput);
|
||||
absent = ownedRootAtPath && (await isAbsent(canonicalOutput));
|
||||
} catch (cleanupCause: unknown) {
|
||||
removalFailure ??= cleanupCause;
|
||||
}
|
||||
@@ -122,7 +160,7 @@ export class SafeOutputTree {
|
||||
reason:
|
||||
removalFailure instanceof Error
|
||||
? removalFailure.message
|
||||
: "Extraction output root remains after setup failure",
|
||||
: "Extraction output root ownership could not be verified after setup failure",
|
||||
resources: [canonicalOutput],
|
||||
});
|
||||
throw cause;
|
||||
@@ -153,7 +191,11 @@ export class SafeOutputTree {
|
||||
);
|
||||
const path = normalizeArtifactPath(relativePath);
|
||||
this.#registry.add(path, "file");
|
||||
const destination = await this.#prepareParent(path);
|
||||
const lineage = await this.#prepareParent(path);
|
||||
const parent = lineage.at(-1);
|
||||
if (parent === undefined) throw replacedDirectory(this.#outputRoot);
|
||||
const fileName = path.slice(path.lastIndexOf("/") + 1);
|
||||
const destination = join(parent.path, fileName);
|
||||
const handle = await open(
|
||||
destination,
|
||||
constants.O_CREAT |
|
||||
@@ -162,20 +204,24 @@ export class SafeOutputTree {
|
||||
constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
).catch(async (cause: unknown) => {
|
||||
await throwIfDestinationCaseCollision(
|
||||
dirname(destination),
|
||||
path,
|
||||
cause,
|
||||
);
|
||||
await throwIfDestinationCaseCollision(parent.path, path, cause);
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Could not exclusively create extraction path: ${path}`,
|
||||
{ cause },
|
||||
);
|
||||
});
|
||||
const fileIdentity = await readFileIdentity(handle, destination).catch(
|
||||
async (cause: unknown) => {
|
||||
await handle.close().catch(() => undefined);
|
||||
throw cause;
|
||||
},
|
||||
);
|
||||
this.#ownedFiles.set(destination, fileIdentity);
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
try {
|
||||
await this.#assertLineage(lineage);
|
||||
for await (const raw of source) {
|
||||
abortIfNeeded(signal);
|
||||
const chunk = streamChunkToBuffer(raw);
|
||||
@@ -199,9 +245,13 @@ export class SafeOutputTree {
|
||||
"integrity",
|
||||
`Extracted content disagrees with inventory: ${path}`,
|
||||
);
|
||||
await this.#assertLineage(lineage);
|
||||
await assertFilePathIdentity(destination, fileIdentity);
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
const readback = await hashFile(destination, bytes, signal);
|
||||
await this.#assertLineage(lineage);
|
||||
await assertFilePathIdentity(destination, fileIdentity);
|
||||
if (readback.sha256 !== sha256 || readback.bytes !== bytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
@@ -226,6 +276,7 @@ export class SafeOutputTree {
|
||||
/** Sync the owned output tree and prevent further writes through this instance. */
|
||||
async commit(): Promise<void> {
|
||||
this.#assertWritable();
|
||||
await this.#assertOwnedDirectories();
|
||||
// Windows has no directory fsync; file contents are already synced in write().
|
||||
if (this.platform === "win32") {
|
||||
this.#published = true;
|
||||
@@ -241,6 +292,7 @@ export class SafeOutputTree {
|
||||
this.#outputRoot,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY,
|
||||
);
|
||||
await assertHandleIdentity(output, this.#rootIdentity, this.#outputRoot);
|
||||
await output.sync();
|
||||
await parent.sync();
|
||||
this.#published = true;
|
||||
@@ -257,14 +309,26 @@ export class SafeOutputTree {
|
||||
|
||||
/** Remove only this operation's unsealed tree and verify absence. */
|
||||
async rollback(): Promise<SafeOutputCleanup> {
|
||||
if (this.#published) return structuredClone(this.#cleanup);
|
||||
if (this.#published || this.#cleanup.status === "complete")
|
||||
return structuredClone(this.#cleanup);
|
||||
this.#cleanup = {
|
||||
status: "incomplete",
|
||||
residualPaths: [basename(this.#outputRoot)],
|
||||
};
|
||||
let removalFailure: unknown;
|
||||
try {
|
||||
await rm(this.#outputRoot, { recursive: true, force: true });
|
||||
await this.#assertOwnedDirectories();
|
||||
await removeOwnedTree({
|
||||
outputRoot: this.#outputRoot,
|
||||
rootIdentity: this.#rootIdentity,
|
||||
directories: this.#nestedDirectories,
|
||||
files: this.#ownedFiles,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
removalFailure = cause;
|
||||
}
|
||||
const absent = await isAbsent(this.#outputRoot);
|
||||
const absent =
|
||||
removalFailure === undefined && (await isAbsent(this.#outputRoot));
|
||||
this.#cleanup = absent
|
||||
? { status: "complete", residualPaths: [] }
|
||||
: {
|
||||
@@ -289,36 +353,61 @@ export class SafeOutputTree {
|
||||
return structuredClone(this.#cleanup);
|
||||
}
|
||||
|
||||
async #prepareParent(relativePath: string): Promise<string> {
|
||||
async #prepareParent(
|
||||
relativePath: string,
|
||||
): Promise<readonly DirectoryLineageEntry[]> {
|
||||
const parts = relativePath.split("/");
|
||||
const fileName = parts.pop();
|
||||
if (fileName === undefined)
|
||||
if (parts.pop() === undefined)
|
||||
throw new ArtifactReaderFailure("path", "Invalid extraction path");
|
||||
let current = this.#outputRoot;
|
||||
let logicalParent = "";
|
||||
const lineage: DirectoryLineageEntry[] = [
|
||||
{ path: this.#outputRoot, identity: this.#rootIdentity },
|
||||
];
|
||||
if (parts.length === 0)
|
||||
await assertPathIdentity(this.#outputRoot, this.#rootIdentity);
|
||||
for (const part of parts) {
|
||||
const parentDirectory = current;
|
||||
const parent = lineage.at(-1);
|
||||
if (parent === undefined) throw replacedDirectory(this.#outputRoot);
|
||||
const logicalPath =
|
||||
logicalParent.length === 0 ? part : `${logicalParent}/${part}`;
|
||||
current = join(current, part);
|
||||
await assertPathIdentity(parent.path, parent.identity);
|
||||
let created = true;
|
||||
await mkdir(current, { mode: 0o700 }).catch((cause: unknown) => {
|
||||
if (!isAlreadyExists(cause)) throw cause;
|
||||
created = false;
|
||||
});
|
||||
if (!created)
|
||||
await throwIfDestinationCaseCollision(parentDirectory, logicalPath);
|
||||
const metadata = await lstat(current);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||||
await throwIfDestinationCaseCollision(parentDirectory, logicalPath);
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Unsafe extraction parent: ${relativePath}`,
|
||||
);
|
||||
if (!created) {
|
||||
await throwIfDestinationCaseCollision(parent.path, logicalPath);
|
||||
const identity = this.#nestedDirectories.get(current);
|
||||
if (identity === undefined) throw replacedDirectory(current);
|
||||
await assertPathIdentity(current, identity);
|
||||
} else {
|
||||
const identity = await readDirectoryIdentity(current);
|
||||
this.#nestedDirectories.set(current, identity);
|
||||
}
|
||||
await assertPathIdentity(parent.path, parent.identity);
|
||||
const identity = this.#nestedDirectories.get(current);
|
||||
if (identity === undefined) throw replacedDirectory(current);
|
||||
await assertPathIdentity(current, identity);
|
||||
lineage.push({ path: current, identity });
|
||||
logicalParent = logicalPath;
|
||||
}
|
||||
return join(current, fileName);
|
||||
return lineage;
|
||||
}
|
||||
|
||||
async #assertLineage(
|
||||
lineage: readonly DirectoryLineageEntry[],
|
||||
): Promise<void> {
|
||||
for (const { path, identity } of lineage)
|
||||
await assertPathIdentity(path, identity);
|
||||
}
|
||||
|
||||
async #assertOwnedDirectories(): Promise<void> {
|
||||
await assertPathIdentity(this.#outputRoot, this.#rootIdentity);
|
||||
for (const [path, identity] of this.#nestedDirectories)
|
||||
await assertPathIdentity(path, identity);
|
||||
}
|
||||
|
||||
#assertWritable(): void {
|
||||
@@ -327,9 +416,19 @@ export class SafeOutputTree {
|
||||
"integrity",
|
||||
"Extraction tree is already committed",
|
||||
);
|
||||
if (this.#cleanup.status !== "not-required")
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Extraction tree cleanup has already started",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type DirectoryLineageEntry = {
|
||||
readonly path: string;
|
||||
readonly identity: DirectoryIdentity;
|
||||
};
|
||||
|
||||
const hashFile = async (
|
||||
path: string,
|
||||
maximum: number,
|
||||
@@ -376,18 +475,6 @@ const writeAll = async (
|
||||
}
|
||||
};
|
||||
|
||||
const isAbsent = async (path: string): Promise<boolean> =>
|
||||
lstat(path).then(
|
||||
() => false,
|
||||
(cause: unknown) => {
|
||||
if (isNotFound(cause)) return true;
|
||||
throw cause;
|
||||
},
|
||||
);
|
||||
|
||||
const isNotFound = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||
|
||||
const isAlreadyExists = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readdir, rmdir, unlink } from "node:fs/promises";
|
||||
import { dirname, sep } from "node:path";
|
||||
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import {
|
||||
assertFilePathIdentity,
|
||||
assertPathIdentity,
|
||||
replacedDirectory,
|
||||
type DirectoryIdentity,
|
||||
type FileIdentity,
|
||||
} from "./SafeOutputTreeIdentity.js";
|
||||
|
||||
/** Remove only tracked files and empty directories, preserving successful progress. */
|
||||
export const removeOwnedTree = async (input: {
|
||||
readonly outputRoot: string;
|
||||
readonly rootIdentity: DirectoryIdentity;
|
||||
readonly directories: Map<string, DirectoryIdentity>;
|
||||
readonly files: Map<string, FileIdentity>;
|
||||
}): Promise<void> => {
|
||||
const { outputRoot, rootIdentity, directories, files } = input;
|
||||
const filesByDirectory = new Map<string, [string, FileIdentity][]>();
|
||||
for (const entry of files) {
|
||||
const directory = dirname(entry[0]);
|
||||
const group = filesByDirectory.get(directory) ?? [];
|
||||
group.push(entry);
|
||||
filesByDirectory.set(directory, group);
|
||||
}
|
||||
const postorder = [...directories].sort(
|
||||
([left], [right]) =>
|
||||
right.slice(outputRoot.length).split(sep).length -
|
||||
left.slice(outputRoot.length).split(sep).length,
|
||||
);
|
||||
for (const [path, identity] of postorder) {
|
||||
await removeFiles(path, identity, filesByDirectory.get(path) ?? [], files);
|
||||
const parentPath = dirname(path);
|
||||
const parentIdentity =
|
||||
parentPath === outputRoot ? rootIdentity : directories.get(parentPath);
|
||||
if (parentIdentity === undefined) throw replacedDirectory(parentPath);
|
||||
await assertPathIdentity(parentPath, parentIdentity);
|
||||
await assertPathIdentity(path, identity);
|
||||
await requireEmpty(path);
|
||||
await rmdir(path);
|
||||
directories.delete(path);
|
||||
}
|
||||
await removeFiles(
|
||||
outputRoot,
|
||||
rootIdentity,
|
||||
filesByDirectory.get(outputRoot) ?? [],
|
||||
files,
|
||||
);
|
||||
await assertPathIdentity(outputRoot, rootIdentity);
|
||||
await requireEmpty(outputRoot);
|
||||
await rmdir(outputRoot);
|
||||
};
|
||||
|
||||
const removeFiles = async (
|
||||
directory: string,
|
||||
identity: DirectoryIdentity,
|
||||
filesInDirectory: readonly (readonly [string, FileIdentity])[],
|
||||
retainedFiles: Map<string, FileIdentity>,
|
||||
): Promise<void> => {
|
||||
for (const [path, fileIdentity] of filesInDirectory) {
|
||||
await assertPathIdentity(directory, identity);
|
||||
await assertFilePathIdentity(path, fileIdentity);
|
||||
await unlink(path);
|
||||
retainedFiles.delete(path);
|
||||
}
|
||||
};
|
||||
|
||||
const requireEmpty = async (directory: string): Promise<void> => {
|
||||
if ((await readdir(directory)).length > 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Unowned entries remain in extraction directory: ${directory}`,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { BigIntStats } from "node:fs";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
|
||||
export type DirectoryIdentity = Pick<BigIntStats, "dev" | "ino">;
|
||||
export type FileIdentity = DirectoryIdentity;
|
||||
|
||||
export const readDirectoryIdentity = async (
|
||||
path: string,
|
||||
): Promise<DirectoryIdentity> => {
|
||||
const metadata = await lstat(path, { bigint: true });
|
||||
if (!metadata.isDirectory()) throw replacedDirectory(path);
|
||||
return { dev: metadata.dev, ino: metadata.ino };
|
||||
};
|
||||
|
||||
export const assertPathIdentity = async (
|
||||
path: string,
|
||||
expected: DirectoryIdentity,
|
||||
): Promise<void> => {
|
||||
const metadata = await lstat(path, { bigint: true });
|
||||
if (
|
||||
!metadata.isDirectory() ||
|
||||
metadata.dev !== expected.dev ||
|
||||
metadata.ino !== expected.ino
|
||||
)
|
||||
throw replacedDirectory(path);
|
||||
};
|
||||
|
||||
export const assertHandleIdentity = async (
|
||||
handle: FileHandle,
|
||||
expected: DirectoryIdentity,
|
||||
path: string,
|
||||
): Promise<void> => {
|
||||
const metadata = await handle.stat({ bigint: true });
|
||||
if (
|
||||
!metadata.isDirectory() ||
|
||||
metadata.dev !== expected.dev ||
|
||||
metadata.ino !== expected.ino
|
||||
)
|
||||
throw replacedDirectory(path);
|
||||
};
|
||||
|
||||
export const readFileIdentity = async (
|
||||
handle: FileHandle,
|
||||
path: string,
|
||||
): Promise<FileIdentity> => {
|
||||
const metadata = await handle.stat({ bigint: true });
|
||||
if (!metadata.isFile())
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Extraction file identity changed: ${path}`,
|
||||
);
|
||||
return { dev: metadata.dev, ino: metadata.ino };
|
||||
};
|
||||
|
||||
export const assertFilePathIdentity = async (
|
||||
path: string,
|
||||
expected: FileIdentity,
|
||||
): Promise<void> => {
|
||||
const metadata = await lstat(path, { bigint: true });
|
||||
if (
|
||||
!metadata.isFile() ||
|
||||
metadata.dev !== expected.dev ||
|
||||
metadata.ino !== expected.ino
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Extraction file identity changed: ${path}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const pathHasIdentity = async (
|
||||
path: string,
|
||||
expected: DirectoryIdentity,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await assertPathIdentity(path, expected);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
if (isNotFound(cause) || cause instanceof ArtifactReaderFailure)
|
||||
return false;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
export const isAbsent = async (path: string): Promise<boolean> =>
|
||||
lstat(path).then(
|
||||
() => false,
|
||||
(cause: unknown) => {
|
||||
if (isNotFound(cause)) return true;
|
||||
throw cause;
|
||||
},
|
||||
);
|
||||
|
||||
export const replacedDirectory = (path: string): ArtifactReaderFailure =>
|
||||
new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Extraction directory identity changed: ${path}`,
|
||||
);
|
||||
|
||||
const isNotFound = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||
@@ -90,20 +90,14 @@ export const materializeArtifactInventory = async (
|
||||
const selectedIds = new Set(
|
||||
activeOccurrences.map(({ occurrence_id: id }) => id),
|
||||
);
|
||||
const occurrences = new Map<string, ArtifactOccurrence>();
|
||||
const neededNodes = new Set<string>();
|
||||
collectOccurrences(
|
||||
snapshot.occurrences,
|
||||
selectedIds,
|
||||
occurrences,
|
||||
neededNodes,
|
||||
const neededNodes = new Set(
|
||||
activeOccurrences.map(({ artifact_id: id }) => id),
|
||||
);
|
||||
const nodes = new Map<string, ArtifactNode>();
|
||||
collectNodes(snapshot.nodes, neededNodes, nodes);
|
||||
const inventory: LoadedInventory = {
|
||||
for (const node of snapshot.nodes)
|
||||
if (neededNodes.has(node.artifact_id)) nodes.set(node.artifact_id, node);
|
||||
const inventory: ExtractionInventory = {
|
||||
manifest: snapshot.manifest,
|
||||
occurrences,
|
||||
nodes,
|
||||
integrityContradictions: snapshot.integrity_contradictions.filter(
|
||||
({ occurrence_id: id }) => selectedIds.has(id),
|
||||
),
|
||||
@@ -144,7 +138,7 @@ export const materializeArtifactInventory = async (
|
||||
"format",
|
||||
`Selected occurrence is not an extractable regular child file: ${occurrence.logical_path} (${occurrence.occurrence_id})`,
|
||||
);
|
||||
const node = inventory.nodes.get(occurrence.artifact_id);
|
||||
const node = nodes.get(occurrence.artifact_id);
|
||||
if (node === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
@@ -183,7 +177,7 @@ const materializeSelection = async ({
|
||||
}: {
|
||||
readonly input: ArtifactExtractionInput;
|
||||
readonly sourcePath: string;
|
||||
readonly inventory: LoadedInventory;
|
||||
readonly inventory: ExtractionInventory;
|
||||
readonly selected: readonly SelectedOccurrence[];
|
||||
readonly signal: AbortSignal | undefined;
|
||||
}): Promise<ArtifactExtractionResult> => {
|
||||
@@ -298,7 +292,7 @@ const materializeSelection = async ({
|
||||
|
||||
const createExtractionResult = (
|
||||
input: ArtifactExtractionInput,
|
||||
inventory: LoadedInventory,
|
||||
inventory: ExtractionInventory,
|
||||
selected: readonly SelectedOccurrence[],
|
||||
extracted: readonly ExtractedOccurrence[],
|
||||
): ArtifactExtractionResult => {
|
||||
@@ -333,35 +327,11 @@ const createExtractionResult = (
|
||||
});
|
||||
};
|
||||
|
||||
interface LoadedInventory {
|
||||
interface ExtractionInventory {
|
||||
readonly manifest: ArtifactGraphManifest;
|
||||
readonly occurrences: ReadonlyMap<string, ArtifactOccurrence>;
|
||||
readonly nodes: ReadonlyMap<string, ArtifactNode>;
|
||||
readonly integrityContradictions: readonly IntegrityContradiction[];
|
||||
}
|
||||
|
||||
const collectOccurrences = (
|
||||
items: readonly ArtifactOccurrence[],
|
||||
selected: ReadonlySet<string>,
|
||||
output: Map<string, ArtifactOccurrence>,
|
||||
neededNodes: Set<string>,
|
||||
): void => {
|
||||
for (const item of items) {
|
||||
if (!selected.has(item.occurrence_id)) continue;
|
||||
output.set(item.occurrence_id, item);
|
||||
if (item.artifact_id !== null) neededNodes.add(item.artifact_id);
|
||||
}
|
||||
};
|
||||
|
||||
const collectNodes = (
|
||||
items: readonly ArtifactNode[],
|
||||
selected: ReadonlySet<string>,
|
||||
output: Map<string, ArtifactNode>,
|
||||
): void => {
|
||||
for (const item of items)
|
||||
if (selected.has(item.artifact_id)) output.set(item.artifact_id, item);
|
||||
};
|
||||
|
||||
const ZIP_FORMATS = ["ipa", "apk", "msix", "appx", "zip"] as const;
|
||||
|
||||
const isZipFormat = (
|
||||
|
||||
@@ -295,23 +295,53 @@ export const createArtifactEdges = (
|
||||
return edges;
|
||||
};
|
||||
|
||||
export const nearestParent = (
|
||||
path: string,
|
||||
occurrences: ReadonlyMap<string, MutableOccurrence>,
|
||||
interface ArtifactParentIndex {
|
||||
readonly children: Map<string, ArtifactParentIndex>;
|
||||
occurrence?: MutableOccurrence;
|
||||
}
|
||||
|
||||
/** Resolve strict observed ancestors without rebuilding every path prefix. */
|
||||
export const resolveOccurrenceParents = (
|
||||
occurrences: readonly MutableOccurrence[],
|
||||
byPath: ReadonlyMap<string, MutableOccurrence>,
|
||||
expandedContainerIds: ReadonlySet<string>,
|
||||
): MutableOccurrence | undefined => {
|
||||
const parts = path.split("/");
|
||||
while (parts.length > 1) {
|
||||
parts.pop();
|
||||
const candidate = occurrences.get(parts.join("/"));
|
||||
): void => {
|
||||
const root: ArtifactParentIndex = { children: new Map() };
|
||||
for (const occurrence of byPath.values()) {
|
||||
if (
|
||||
candidate !== undefined &&
|
||||
(candidate.entry_kind === "directory" ||
|
||||
expandedContainerIds.has(candidate.occurrence_id))
|
||||
occurrence.entry_kind !== "directory" &&
|
||||
!expandedContainerIds.has(occurrence.occurrence_id)
|
||||
)
|
||||
return candidate;
|
||||
continue;
|
||||
let branch = root;
|
||||
for (const segment of occurrence.logical_path.split("/")) {
|
||||
let child = branch.children.get(segment);
|
||||
if (child === undefined) {
|
||||
child = { children: new Map() };
|
||||
branch.children.set(segment, child);
|
||||
}
|
||||
branch = child;
|
||||
}
|
||||
branch.occurrence = occurrence;
|
||||
}
|
||||
for (const occurrence of occurrences) {
|
||||
const path = occurrence.logical_path;
|
||||
let branch = root;
|
||||
let parent: MutableOccurrence | undefined;
|
||||
// Walk parent segments only. Stop scanning the path once no observed
|
||||
// directory or expanded container can be an ancestor.
|
||||
for (let start = 0; ;) {
|
||||
const slash = path.indexOf("/", start);
|
||||
if (slash < 0) break;
|
||||
const segment = path.slice(start, slash);
|
||||
const child = branch.children.get(segment);
|
||||
if (child === undefined) break;
|
||||
branch = child;
|
||||
parent = branch.occurrence ?? parent;
|
||||
start = slash + 1;
|
||||
}
|
||||
occurrence.parent_occurrence_id = parent?.occurrence_id ?? null;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
createArtifactNode,
|
||||
createOccurrence,
|
||||
noteUnreadContainerSuffix,
|
||||
nearestParent,
|
||||
resolveOccurrenceParents,
|
||||
type MutableOccurrence,
|
||||
} from "./ArtifactGraphConstruction.js";
|
||||
import {
|
||||
@@ -91,13 +91,11 @@ export const scanReader = async (
|
||||
await visitArtifactEntries(context, reader, "");
|
||||
// Archive directories may appear after their children. Resolve containment
|
||||
// against the complete index before directory identities are materialized.
|
||||
for (const occurrence of occurrences)
|
||||
occurrence.parent_occurrence_id =
|
||||
nearestParent(
|
||||
occurrence.logical_path,
|
||||
context.occurrenceByPath,
|
||||
context.expandedContainerIds,
|
||||
)?.occurrence_id ?? null;
|
||||
resolveOccurrenceParents(
|
||||
occurrences,
|
||||
context.occurrenceByPath,
|
||||
context.expandedContainerIds,
|
||||
);
|
||||
return {
|
||||
nodes,
|
||||
occurrences,
|
||||
|
||||
@@ -27,8 +27,7 @@ export const ingestResponseBodyShape = (
|
||||
invalidResponseBodyShape(state, requestId);
|
||||
return;
|
||||
}
|
||||
const inferred = inferBodyShape(decoded);
|
||||
updateResponseBodyShape(state, requestId, inferred.shape);
|
||||
updateResponseBodyShape(state, requestId, inferJsonShape(decoded));
|
||||
};
|
||||
|
||||
export const requestBodyShape = (
|
||||
@@ -40,10 +39,10 @@ export const requestBodyShape = (
|
||||
const body = cdpStringValue(request.postData);
|
||||
if (!isJsonContentType(recordValue(request.headers)) || body === undefined)
|
||||
return { status: "unavailable", request: null, response: null };
|
||||
const inferred = inferBodyShape(body);
|
||||
const shape = inferJsonShape(body);
|
||||
return {
|
||||
status: inferred.shape === null ? "unavailable" : "included",
|
||||
request: inferred.shape,
|
||||
status: shape === null ? "unavailable" : "included",
|
||||
request: shape,
|
||||
response: null,
|
||||
};
|
||||
};
|
||||
@@ -68,10 +67,6 @@ export const updateResponseBodyShape = (
|
||||
});
|
||||
};
|
||||
|
||||
const inferBodyShape = (text: string): { readonly shape: JsonShape | null } => {
|
||||
return { shape: inferJsonShape(text) };
|
||||
};
|
||||
|
||||
const invalidResponseBodyShape = (
|
||||
state: CdpCaptureEventsState,
|
||||
requestId: string,
|
||||
|
||||
@@ -51,18 +51,22 @@ export const closeCdpTargetSession = async (
|
||||
): Promise<void> => {
|
||||
const { connection, sessionId } = targetSession;
|
||||
const cleanup = new AbortController();
|
||||
const domainSignal =
|
||||
signal === undefined
|
||||
? cleanup.signal
|
||||
: AbortSignal.any([cleanup.signal, signal]);
|
||||
const timeout = setTimeout(() => cleanup.abort(), 1_000);
|
||||
timeout.unref();
|
||||
try {
|
||||
if (signal?.aborted !== true) {
|
||||
for (const domain of enabledDomains) {
|
||||
if (cleanup.signal.aborted) break;
|
||||
if (domainSignal.aborted) break;
|
||||
try {
|
||||
await connection.send(
|
||||
`${domain}.disable`,
|
||||
{},
|
||||
sessionId,
|
||||
cleanup.signal,
|
||||
domainSignal,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort domain cleanup; transport close is the definitive boundary.
|
||||
|
||||
@@ -365,20 +365,30 @@ const ingestWebMcpEvent = (options: WebMcpIngestOptions): void => {
|
||||
transientFrames,
|
||||
resolvedTransientFrames,
|
||||
} = options;
|
||||
if (
|
||||
event.method !== "WebMCP.toolsAdded" &&
|
||||
event.method !== "WebMCP.toolsRemoved"
|
||||
)
|
||||
return;
|
||||
const params = recordValue(event.params);
|
||||
if (params === undefined) return;
|
||||
if (params === undefined) {
|
||||
completeness.exclude("webmcp_tools", "invalid_protocol_value");
|
||||
return;
|
||||
}
|
||||
if (event.method === "WebMCP.toolsRemoved") {
|
||||
for (const removed of recordsValue(params.tools)) {
|
||||
for (const removed of webMcpEventTools(params.tools, completeness)) {
|
||||
const name = cdpStringValue(removed.name);
|
||||
const frameId = cdpStringValue(removed.frameId);
|
||||
if (name === undefined || frameId === undefined) continue;
|
||||
if (name === undefined || frameId === undefined) {
|
||||
completeness.exclude("webmcp_tools", "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
for (const [key, tool] of tools)
|
||||
if (tool.name === name && tool.frame_id === frameId) tools.delete(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.method !== "WebMCP.toolsAdded") return;
|
||||
for (const declared of recordsValue(params.tools)) {
|
||||
for (const declared of webMcpEventTools(params.tools, completeness)) {
|
||||
const frameId = cdpStringValue(declared.frameId);
|
||||
if (frameId !== undefined && transientFrames.has(frameId)) {
|
||||
if (!resolvedTransientFrames.has(frameId)) continue;
|
||||
@@ -391,6 +401,26 @@ const ingestWebMcpEvent = (options: WebMcpIngestOptions): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const webMcpEventTools = (
|
||||
value: unknown,
|
||||
completeness: CdpCaptureCompleteness,
|
||||
): UnknownRecord[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
completeness.exclude("webmcp_tools", "invalid_protocol_value");
|
||||
return [];
|
||||
}
|
||||
const records: UnknownRecord[] = [];
|
||||
for (const item of value) {
|
||||
const record = recordValue(item);
|
||||
if (record === undefined) {
|
||||
completeness.exclude("webmcp_tools", "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
return records;
|
||||
};
|
||||
|
||||
const normalizeTool = (
|
||||
value: UnknownRecord,
|
||||
input: DiscoverWebMcpToolsInput,
|
||||
@@ -403,12 +433,11 @@ const normalizeTool = (
|
||||
const frameId = cdpStringValue(value.frameId);
|
||||
const name = cdpStringValue(value.name);
|
||||
const frame = frameId === undefined ? undefined : frames.get(frameId);
|
||||
if (
|
||||
frameId === undefined ||
|
||||
name === undefined ||
|
||||
frame?.origin === null ||
|
||||
frame === undefined
|
||||
) {
|
||||
if (frameId === undefined || name === undefined) {
|
||||
completeness.exclude("webmcp_tools", "invalid_protocol_value");
|
||||
return undefined;
|
||||
}
|
||||
if (frame?.origin === null || frame === undefined) {
|
||||
completeness.exclude("webmcp_tools", "out_of_target_scope");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -94,9 +94,9 @@ export const captureWebExecution = async (
|
||||
sources.belongsToDocument(id),
|
||||
);
|
||||
const sample =
|
||||
raw === undefined
|
||||
? undefined
|
||||
: normalizePreciseCoverage(raw, sources, retained);
|
||||
parsed?.success === true
|
||||
? normalizePreciseCoverage(parsed.data, sources, retained)
|
||||
: undefined;
|
||||
await session.options.progress?.report({
|
||||
phase: "browser_execution",
|
||||
completed: 2,
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
import type { z } from "zod";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import type { WebExecution } from "../../domain/webExecution.js";
|
||||
import type { WebRuntimeSource } from "../../domain/webRuntime.js";
|
||||
import { preciseCoverageSchema } from "./CdpRuntimeProtocol.js";
|
||||
import type { preciseCoverageSchema } from "./CdpRuntimeProtocol.js";
|
||||
import type { CdpRuntimeSources } from "./CdpRuntimeSources.js";
|
||||
|
||||
/** Validate source-range bounds against exact retained UTF-16 text without flattening nested counts. */
|
||||
export const normalizePreciseCoverage = (
|
||||
raw: unknown,
|
||||
parsed: z.infer<typeof preciseCoverageSchema>,
|
||||
sources: CdpRuntimeSources,
|
||||
retained: readonly WebRuntimeSource[],
|
||||
): {
|
||||
readonly coverage: WebExecution["coverage"];
|
||||
readonly timestamp: number;
|
||||
} => {
|
||||
const parsed = preciseCoverageSchema.safeParse(raw);
|
||||
if (!parsed.success)
|
||||
throw new AnalysisOutputError(
|
||||
sources.session.operation,
|
||||
`Malformed precise coverage: ${parsed.error.message}`,
|
||||
);
|
||||
const retainedSources = new Map(
|
||||
retained.map(({ script_id, source }) => [script_id, source]),
|
||||
);
|
||||
const scripts: WebExecution["coverage"]["scripts"] = [];
|
||||
let excluded = 0;
|
||||
for (const script of parsed.data.result) {
|
||||
for (const script of parsed.result) {
|
||||
if (!sources.belongsToDocument(script.scriptId)) {
|
||||
excluded += 1;
|
||||
continue;
|
||||
}
|
||||
const source = retained.find(
|
||||
(item) => item.script_id === script.scriptId,
|
||||
)?.source;
|
||||
const source = retainedSources.get(script.scriptId);
|
||||
scripts.push({
|
||||
script_id: script.scriptId,
|
||||
reported_url: sources.sourceUrl(script.scriptId, script.url),
|
||||
@@ -57,7 +53,7 @@ export const normalizePreciseCoverage = (
|
||||
});
|
||||
}
|
||||
return {
|
||||
timestamp: parsed.data.timestamp,
|
||||
timestamp: parsed.timestamp,
|
||||
coverage: {
|
||||
state: "captured",
|
||||
reason: null,
|
||||
|
||||
@@ -20,8 +20,12 @@ it.each([
|
||||
const stderr =
|
||||
"FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\r\n";
|
||||
const cause = new OwnedCommandFailure("process", "Decoder failed", {
|
||||
stdout: { text: "", bytes: 0 },
|
||||
stderr: { text: stderr, bytes: Buffer.byteLength(stderr) },
|
||||
stdout: { text: "", bytes: 0, observedBytes: 0 },
|
||||
stderr: {
|
||||
text: stderr,
|
||||
bytes: Buffer.byteLength(stderr),
|
||||
observedBytes: Buffer.byteLength(stderr),
|
||||
},
|
||||
exitCode,
|
||||
signal,
|
||||
});
|
||||
@@ -70,8 +74,12 @@ it.each([
|
||||
format: "har",
|
||||
});
|
||||
const cause = new OwnedCommandFailure("process", "Decoder failed", {
|
||||
stdout: { text: "", bytes: 0 },
|
||||
stderr: { text: stderr, bytes: Buffer.byteLength(stderr) },
|
||||
stdout: { text: "", bytes: 0, observedBytes: 0 },
|
||||
stderr: {
|
||||
text: stderr,
|
||||
bytes: Buffer.byteLength(stderr),
|
||||
observedBytes: Buffer.byteLength(stderr),
|
||||
},
|
||||
exitCode,
|
||||
signal,
|
||||
});
|
||||
@@ -128,8 +136,16 @@ it("retains actual command status and both diagnostic streams when cleanup also
|
||||
"process",
|
||||
"Observed command failure",
|
||||
{
|
||||
stdout: { text: "original provider output", bytes: 24 },
|
||||
stderr: { text: "private-marker provider reason", bytes: 30 },
|
||||
stdout: {
|
||||
text: "original provider output",
|
||||
bytes: 24,
|
||||
observedBytes: 24,
|
||||
},
|
||||
stderr: {
|
||||
text: "private-marker provider reason",
|
||||
bytes: 30,
|
||||
observedBytes: 30,
|
||||
},
|
||||
exitCode: 2,
|
||||
signal: null,
|
||||
},
|
||||
|
||||
@@ -94,7 +94,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
),
|
||||
session(
|
||||
"capture_process_scenario",
|
||||
"Run one caller-selected command under a PTY and return process capture Evidence with residual unknowns. A command name resolves through the inherited PATH; the working directory defaults to the caller's current directory; host environment variables are inherited with scenario overrides. Environment keys cannot contain '=' or NUL, and REA_PROCESS_RUN_ID is reserved for process ownership. Filesystem snapshots are opt-in through filesystem_observation_paths. The target runs with the current user's permissions; this is not a security sandbox.",
|
||||
"Run one caller-selected command under a PTY and return process capture Evidence with residual unknowns. The renderer admits at most 1,000,000 combined terminal cells, computed as columns × (rows + scrollback); every resize uses the selected scrollback and must fit the same budget. Supply a smaller terminal shape when that product is too large; dimensions are never clamped. A command name resolves through the inherited PATH; the working directory defaults to the caller's current directory; host environment variables are inherited with scenario overrides. Environment keys cannot contain '=' or NUL, and REA_PROCESS_RUN_ID is reserved for process ownership. Filesystem snapshots are opt-in through filesystem_observation_paths. The target runs with the current user's permissions; this is not a security sandbox.",
|
||||
processScenarioSchema,
|
||||
),
|
||||
session(
|
||||
|
||||
@@ -25,6 +25,7 @@ export const resolveTreePath = async (
|
||||
let hops = 0;
|
||||
while (pending.length > 0) {
|
||||
const segment = pending.shift() ?? "";
|
||||
if (segment === ".") continue;
|
||||
if (segment === "..") {
|
||||
if (resolved.length === 0) return { kind: "escapes" };
|
||||
resolved.pop();
|
||||
@@ -50,8 +51,12 @@ export const resolveTreePath = async (
|
||||
: { kind: "not-file" };
|
||||
};
|
||||
|
||||
const segmentsOf = (path: string): string[] =>
|
||||
path.split("/").filter((segment) => segment !== "" && segment !== ".");
|
||||
const segmentsOf = (path: string): string[] => {
|
||||
const segments = path.split("/").filter((segment) => segment !== "");
|
||||
// a trailing slash requires a directory, just like a final dot component.
|
||||
if (path.endsWith("/")) segments.push(".");
|
||||
return segments;
|
||||
};
|
||||
|
||||
export const directoryOf = (path: string): string => {
|
||||
const index = path.lastIndexOf("/");
|
||||
|
||||
@@ -250,6 +250,49 @@ describe("dyld path expansion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps directory requirements in symlink targets", async () => {
|
||||
const names = [
|
||||
"@loader_path/alias-slash",
|
||||
"@loader_path/alias-dot",
|
||||
"@loader_path/alias-directory/lib.dylib",
|
||||
];
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView(
|
||||
{
|
||||
[MAIN]: executable({
|
||||
dependencies: names.map((name) => dependency(name)),
|
||||
}),
|
||||
"Contents/MacOS/lib.dylib": parsed(slice()),
|
||||
},
|
||||
{
|
||||
"Contents/MacOS/alias-slash": "lib.dylib/",
|
||||
"Contents/MacOS/alias-dot": "lib.dylib/.",
|
||||
"Contents/MacOS/alias-directory": "./",
|
||||
},
|
||||
),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(
|
||||
trace.edges.map(({ install_name, candidates, resolution }) => ({
|
||||
install_name,
|
||||
outcome: candidates[0]?.outcome,
|
||||
resolution,
|
||||
})),
|
||||
).toEqual(
|
||||
names.map((name, index) => ({
|
||||
install_name: name,
|
||||
outcome: index < 2 ? "absent" : "resolved",
|
||||
resolution:
|
||||
index < 2
|
||||
? { status: "unresolved", image: null }
|
||||
: { status: "resolved", image: "Contents/MacOS/lib.dylib" },
|
||||
})),
|
||||
);
|
||||
expect(
|
||||
trace.findings.map(({ kind, edge_index }) => [kind, edge_index]),
|
||||
).toEqual([0, 1].map((index) => ["required-load-unresolved", index]));
|
||||
});
|
||||
|
||||
describe("dyld resolution outcomes: deriving path and weak-load outcomes", () => {
|
||||
it("keeps paths outside the root undetermined and in-root fallbacks conditional", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
compatibleSlice,
|
||||
directoryOf,
|
||||
expandPrefix,
|
||||
joinPath,
|
||||
resolveTreePath,
|
||||
type Expansion,
|
||||
} from "./dyldPaths.js";
|
||||
@@ -275,7 +274,7 @@ const candidateTemplates = (
|
||||
path:
|
||||
base.scope === "outside"
|
||||
? `${base.path.replace(/\/+$/u, "")}/${rest}`
|
||||
: joinPath(base.path, rest),
|
||||
: `${base.path}${base.path === "" ? "" : "/"}${rest}`,
|
||||
};
|
||||
return { expansion, source: "rpath" as const, rpath, rpathOwner: owner };
|
||||
}),
|
||||
|
||||
@@ -99,6 +99,8 @@ const chunk = z.strictObject({
|
||||
export interface FirmwarePartialObservation {
|
||||
readonly kind: "firmware";
|
||||
readonly result: JsonValue;
|
||||
/** Completed producer data, artifact identity and analysis metadata. */
|
||||
readonly provenance?: JsonValue;
|
||||
}
|
||||
|
||||
/** Portable results retain reported boundaries, unknowns and extraction lineage. */
|
||||
|
||||
@@ -16,8 +16,17 @@ export abstract class HopperError extends AnalysisError {}
|
||||
export type HopperLauncherOutcome = {
|
||||
readonly exit_code: number | null;
|
||||
readonly signal: string | null;
|
||||
readonly stdout: { readonly text: string; readonly bytes: number };
|
||||
readonly stderr: { readonly text: string; readonly bytes: number };
|
||||
/** Total bytes drained from the stream; retained_bytes reports captured bytes. */
|
||||
readonly stdout: {
|
||||
readonly text: string;
|
||||
readonly bytes: number;
|
||||
readonly retained_bytes: number;
|
||||
};
|
||||
readonly stderr: {
|
||||
readonly text: string;
|
||||
readonly bytes: number;
|
||||
readonly retained_bytes: number;
|
||||
};
|
||||
readonly output_closed: boolean;
|
||||
readonly diagnostic_truncated: boolean;
|
||||
};
|
||||
|
||||
@@ -62,9 +62,7 @@ const classifyLayer = (
|
||||
options.reconciliationComplete &&
|
||||
layer.graph.coverage.status === "complete" &&
|
||||
scopedEntities.length > 0 &&
|
||||
scopedEntities.every(({ capture }) =>
|
||||
captureScriptsComplete(capture.evidence.evidence_id, runtimeEntities),
|
||||
);
|
||||
scopedEntities.every(({ capture }) => capture.scriptsCompleteWithinScope);
|
||||
const states = nodes.map((node) => {
|
||||
const runtimeNodeIds = uniqueSorted(direct.get(node.node_id) ?? []);
|
||||
if (runtimeNodeIds.length > 0)
|
||||
@@ -134,13 +132,6 @@ const append = (
|
||||
else current.push(value);
|
||||
};
|
||||
|
||||
const captureScriptsComplete = (
|
||||
evidenceId: string,
|
||||
entities: readonly RuntimeReconciliationEntity[],
|
||||
): boolean =>
|
||||
entities.find(({ capture }) => capture.evidence.evidence_id === evidenceId)
|
||||
?.capture.scriptsCompleteWithinScope === true;
|
||||
|
||||
const isLoadStateNode = (
|
||||
node: ApplicationNode,
|
||||
): node is ApplicationNode & {
|
||||
|
||||
@@ -212,8 +212,11 @@ const graphCoverage = (
|
||||
|
||||
const mergeNodes = (nodes: readonly ApplicationNode[]): ApplicationNode[] => {
|
||||
const grouped = new Map<string, ApplicationNode[]>();
|
||||
for (const node of nodes)
|
||||
grouped.set(node.node_id, [...(grouped.get(node.node_id) ?? []), node]);
|
||||
for (const node of nodes) {
|
||||
const group = grouped.get(node.node_id);
|
||||
if (group === undefined) grouped.set(node.node_id, [node]);
|
||||
else group.push(node);
|
||||
}
|
||||
const merged = [...grouped.values()].map((group) => {
|
||||
const first = group[0];
|
||||
if (first === undefined) throw new TypeError("Empty JAG node group");
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
import { onlyBinding } from "./javascriptSemanticAnalysis.fixture.js";
|
||||
|
||||
it("bounds ancestry reads for repeated deep lexical references", () => {
|
||||
const ancestryReads = (depth: number): number => {
|
||||
const original = WeakMap.prototype.get;
|
||||
let reads = 0;
|
||||
const get = vi.spyOn(WeakMap.prototype, "get").mockImplementation(function (
|
||||
this: WeakMap<object, unknown>,
|
||||
key,
|
||||
) {
|
||||
const value: unknown = original.call(this, key);
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"type" in value &&
|
||||
typeof value.type === "string"
|
||||
)
|
||||
reads++;
|
||||
return value;
|
||||
});
|
||||
try {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`const a=1; module.exports.value=${"a+(".repeat(depth)}a${")".repeat(depth)};`,
|
||||
);
|
||||
expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 });
|
||||
expect(onlyBinding(ir, "a").value).toEqual({
|
||||
status: "literal",
|
||||
value: 1,
|
||||
});
|
||||
return reads;
|
||||
} finally {
|
||||
get.mockRestore();
|
||||
}
|
||||
};
|
||||
|
||||
const smaller = ancestryReads(80);
|
||||
const larger = ancestryReads(160);
|
||||
expect(smaller).toBeGreaterThan(0);
|
||||
expect(larger).toBeLessThan(3 * smaller);
|
||||
});
|
||||
@@ -444,6 +444,7 @@ function* collectDefinitionsSteps(
|
||||
}[] = [];
|
||||
yield* traverseJavaScriptAstSteps(program, {
|
||||
enter: (node, parent, readAncestors) => {
|
||||
if (parent !== null) state.parentsByNode.set(node, parent);
|
||||
let parentScope = currentSemanticScope(stack);
|
||||
if (
|
||||
parent !== null &&
|
||||
@@ -493,7 +494,10 @@ function* collectDefinitionsSteps(
|
||||
});
|
||||
}
|
||||
const scope = currentSemanticScope(stack);
|
||||
state.scopeByNode.set(node, scope);
|
||||
// Other nodes inherit the nearest boundary through their AST parents.
|
||||
// Avoid a second full-tree index for the same lexical relationship.
|
||||
if (parent === null || openedScopes.has(node))
|
||||
state.scopeByNode.set(node, scope);
|
||||
collectSemanticCallable({
|
||||
node,
|
||||
parent,
|
||||
|
||||
@@ -428,4 +428,10 @@ const enclosingFunction = (
|
||||
const semanticCallSiteId = (
|
||||
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
|
||||
): string =>
|
||||
`call:${t.isNewExpression(node) ? "construct" : "call"}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`;
|
||||
// Join once so retained IDs do not keep a chain of intermediate strings.
|
||||
[
|
||||
"call",
|
||||
t.isNewExpression(node) ? "construct" : "call",
|
||||
String(node.start ?? -1),
|
||||
String(node.end ?? -1),
|
||||
].join(":");
|
||||
@@ -19,15 +19,11 @@ import {
|
||||
JAVASCRIPT_SEMANTIC_RELATION_FAMILIES,
|
||||
JAVASCRIPT_SEMANTIC_RELATION_FAMILY,
|
||||
javaScriptSemanticFingerprintInputSchema,
|
||||
javaScriptSemanticFingerprintSchema,
|
||||
javaScriptSemanticGraphInputSchema,
|
||||
javaScriptSemanticGraphRecordSchema,
|
||||
javaScriptSemanticNodeInputSchema,
|
||||
javaScriptSemanticNodeSchema,
|
||||
javaScriptSemanticRelationInputSchema,
|
||||
javaScriptSemanticRelationSchema,
|
||||
javaScriptSemanticUnknownInputSchema,
|
||||
javaScriptSemanticUnknownSchema,
|
||||
type JavaScriptSemanticGraphInput,
|
||||
type JavaScriptSemanticGraphNode,
|
||||
type JavaScriptSemanticGraphRelation,
|
||||
@@ -132,10 +128,10 @@ export const createJavaScriptSemanticGraphNode = (
|
||||
application_node_ids: uniqueSorted(parsed.application_node_ids),
|
||||
evidence: evidenceContexts.intern(parsed.evidence),
|
||||
};
|
||||
return javaScriptSemanticNodeSchema.parse({
|
||||
return {
|
||||
...semantic,
|
||||
node_id: javaScriptSemanticNodeId(semantic),
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/** Normalize one semantic relationship and derive its exact identifier. */
|
||||
@@ -148,10 +144,10 @@ export const createJavaScriptSemanticGraphRelation = (
|
||||
...parsed,
|
||||
evidence: evidenceContexts.intern(parsed.evidence),
|
||||
};
|
||||
return javaScriptSemanticRelationSchema.parse({
|
||||
return {
|
||||
...semantic,
|
||||
relation_id: `jsrg_relation_${digestCanonicalValue(semantic, "JavaScript semantic graph")}`,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/** Normalize one unresolved semantic frontier and derive its identifier. */
|
||||
@@ -166,10 +162,10 @@ export const createJavaScriptSemanticGraphUnknown = (
|
||||
candidate_node_ids: uniqueSorted(parsed.candidate_node_ids),
|
||||
evidence: evidenceContexts.intern(parsed.evidence),
|
||||
};
|
||||
return javaScriptSemanticUnknownSchema.parse({
|
||||
return {
|
||||
...semantic,
|
||||
unknown_id: `jsrg_unknown_${digestCanonicalValue(semantic, "JavaScript semantic graph")}`,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/** Normalize one function fingerprint and derive its component commitment. */
|
||||
@@ -191,7 +187,7 @@ export const createJavaScriptSemanticFingerprint = (
|
||||
semantic.components,
|
||||
"JavaScript semantic graph",
|
||||
);
|
||||
return javaScriptSemanticFingerprintSchema.parse({
|
||||
return {
|
||||
...semantic,
|
||||
fingerprint_sha256: fingerprintSha256,
|
||||
fingerprint_id: `jsrg_fingerprint_${digestCanonicalValue(
|
||||
@@ -202,7 +198,7 @@ export const createJavaScriptSemanticFingerprint = (
|
||||
},
|
||||
"JavaScript semantic graph",
|
||||
)}`,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
type GraphRecord = z.infer<typeof javaScriptSemanticGraphRecordSchema>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
||||
import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js";
|
||||
import {
|
||||
JAVASCRIPT_SEMANTIC_RELATION_FAMILY,
|
||||
JAVASCRIPT_SEMANTIC_NODE_KINDS,
|
||||
JAVASCRIPT_SEMANTIC_RELATION_FAMILIES,
|
||||
JAVASCRIPT_SEMANTIC_RELATIONS,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
parseJavaScriptSemanticGraph,
|
||||
serializeJavaScriptSemanticGraph,
|
||||
} from "./javascriptSemanticGraphSerialization.js";
|
||||
import { queryJavaScriptSemanticGraph } from "./javascriptSemanticQuery.js";
|
||||
|
||||
const SHA = "a".repeat(64);
|
||||
const JAG_ID = `jag_${"b".repeat(64)}`;
|
||||
@@ -140,6 +142,44 @@ it("accepts semantic module paths longer than the former schema ceiling", () =>
|
||||
).toBe(modulePath);
|
||||
});
|
||||
|
||||
it("captures caller-owned node identity, properties, and provenance", () => {
|
||||
const contexts = new JavaScriptSemanticEvidenceContextRegistry();
|
||||
const input = {
|
||||
kind: "literal",
|
||||
identity: {
|
||||
artifact_sha256: SHA,
|
||||
module_path: "bundle.js",
|
||||
source_range: null,
|
||||
role_key: "literal",
|
||||
},
|
||||
function_node_id: null,
|
||||
application_node_ids: [],
|
||||
label: "literal",
|
||||
properties: { nested: ["original"] },
|
||||
evidence: evidence(),
|
||||
};
|
||||
const captured = createJavaScriptSemanticGraphNode(input, contexts);
|
||||
const expected = structuredClone(captured);
|
||||
const expectedEvidence = structuredClone(input.evidence);
|
||||
|
||||
input.identity.module_path = "replaced.js";
|
||||
input.properties.nested[0] = "replaced";
|
||||
input.evidence.limitations.push("later mutation");
|
||||
if (input.evidence.location.available)
|
||||
input.evidence.location.value = {
|
||||
kind: "artifact-path",
|
||||
path: "replaced.js",
|
||||
};
|
||||
|
||||
expect(captured).toEqual(expected);
|
||||
expect(
|
||||
resolveJavaScriptSemanticEvidence(
|
||||
{ evidence_contexts: contexts.contexts },
|
||||
captured.evidence,
|
||||
),
|
||||
).toEqual(expectedEvidence);
|
||||
});
|
||||
|
||||
const fixtureInput = (withUnknown = false): JavaScriptSemanticGraphInput => {
|
||||
const evidenceContexts = new JavaScriptSemanticEvidenceContextRegistry();
|
||||
const module = node("module", "module", {}, evidenceContexts);
|
||||
@@ -285,6 +325,104 @@ const registryForGraph = (
|
||||
return registry;
|
||||
};
|
||||
|
||||
const graphWithCandidateRelations = (): JavaScriptSemanticGraph => {
|
||||
const base = fixtureGraph();
|
||||
const evidenceContexts = registryForGraph(base);
|
||||
const relations = base.relations.map(
|
||||
({ relation_id: _relationId, ...relation }) =>
|
||||
createJavaScriptSemanticGraphRelation(
|
||||
{
|
||||
...relation,
|
||||
resolution: "candidate",
|
||||
evidence: resolveJavaScriptSemanticEvidence(base, relation.evidence),
|
||||
},
|
||||
evidenceContexts,
|
||||
),
|
||||
);
|
||||
const binding = base.nodes.find(({ label }) => label === "binding");
|
||||
const callable = base.nodes.find(({ label }) => label === "function");
|
||||
if (binding === undefined || callable === undefined)
|
||||
throw new Error("Expected candidate-cycle endpoints");
|
||||
for (const [source, target] of [
|
||||
[binding, callable],
|
||||
[callable, binding],
|
||||
] as const)
|
||||
relations.push(
|
||||
createJavaScriptSemanticGraphRelation(
|
||||
{
|
||||
source_node_id: source.node_id,
|
||||
target_node_id: target.node_id,
|
||||
relation: "owns",
|
||||
resolution: "candidate",
|
||||
properties: {},
|
||||
evidence: evidence("inferred"),
|
||||
},
|
||||
evidenceContexts,
|
||||
),
|
||||
);
|
||||
|
||||
const { graph_id: _graphId, ...input } = base;
|
||||
return createJavaScriptSemanticGraph({
|
||||
...input,
|
||||
evidence_contexts: evidenceContexts.contexts,
|
||||
relations,
|
||||
coverage: {
|
||||
...input.coverage,
|
||||
families: input.coverage.families.map((family) => ({
|
||||
...family,
|
||||
retained_relations: relations.filter(
|
||||
({ relation }) =>
|
||||
JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation] === family.family,
|
||||
).length,
|
||||
})),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("counts candidate edges by query direction and relation filter", () => {
|
||||
const graph = graphWithCandidateRelations();
|
||||
const binding = graph.nodes.find(({ label }) => label === "binding");
|
||||
if (binding === undefined) throw new Error("Expected binding seed");
|
||||
const query = (
|
||||
direction: "forward-influence" | "backward-provenance",
|
||||
allowedRelations: (typeof JAVASCRIPT_SEMANTIC_RELATIONS)[number][],
|
||||
) =>
|
||||
queryJavaScriptSemanticGraph(graph, {
|
||||
seed: { kind: "semantic-node", node_id: binding.node_id },
|
||||
direction,
|
||||
allowed_relations: allowedRelations,
|
||||
});
|
||||
|
||||
expect(query("forward-influence", ["captures"]).status).toBe("ambiguous");
|
||||
expect(query("forward-influence", ["defines"]).status).toBe("found");
|
||||
expect(query("backward-provenance", ["defines"]).status).toBe("ambiguous");
|
||||
expect(query("backward-provenance", ["captures"]).status).toBe("found");
|
||||
});
|
||||
|
||||
it("counts candidate ownership cycles once and traverses them finitely", () => {
|
||||
const graph = graphWithCandidateRelations();
|
||||
const binding = graph.nodes.find(({ label }) => label === "binding");
|
||||
if (binding === undefined) throw new Error("Expected binding seed");
|
||||
const result = queryJavaScriptSemanticGraph(graph, {
|
||||
seed: { kind: "semantic-node", node_id: binding.node_id },
|
||||
direction: "ownership",
|
||||
allowed_relations: ["owns"],
|
||||
include_ambiguous_dynamic_edges: true,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ambiguous");
|
||||
expect(result.relations).toHaveLength(2);
|
||||
expect(result.summary.traversed_nodes).toBe(2);
|
||||
expect(result.summary.traversed_relations).toBe(2);
|
||||
expect(
|
||||
queryJavaScriptSemanticGraph(graph, {
|
||||
seed: { kind: "semantic-node", node_id: binding.node_id },
|
||||
direction: "ownership",
|
||||
allowed_relations: ["constructs-request"],
|
||||
}).status,
|
||||
).toBe("found");
|
||||
});
|
||||
|
||||
it("defines every required relation family and canonicalizes records", () => {
|
||||
expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("argument-to-parameter");
|
||||
expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("detaches-task");
|
||||
|
||||
@@ -316,6 +316,20 @@ it("returns every relevant unknown inline", () => {
|
||||
expect(result.unknowns).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not admit a semantic-node seed absent from the graph", () => {
|
||||
const result = queryJavaScriptSemanticGraph(fixtureGraph(), {
|
||||
seed: { kind: "semantic-node", node_id: `jsrg_node_${"f".repeat(64)}` },
|
||||
direction: "forward-influence",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "no-match",
|
||||
seed_node_ids: [],
|
||||
nodes: [],
|
||||
summary: { total_seed_matches: 0, traversed_nodes: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the complete deterministic forward influence result inline", () => {
|
||||
const graph = fixtureGraph();
|
||||
const input = {
|
||||
|
||||
@@ -40,11 +40,6 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): Generator<void, void> {
|
||||
const parents = state.parentsByNode;
|
||||
yield* traverseJavaScriptAstSteps(program, {
|
||||
enter: (node, parent) => {
|
||||
if (parent !== null) parents.set(node, parent);
|
||||
},
|
||||
});
|
||||
const normalizePath = observableMutationPaths(state);
|
||||
const returnedReferences = createSemanticReturnedReferences(state);
|
||||
const recordedEffects = new Set<string>();
|
||||
@@ -670,10 +665,10 @@ type MutationObservableOrigin =
|
||||
| "opaque";
|
||||
|
||||
interface MutationObserverNode {
|
||||
readonly origins: Set<MutationObservableOrigin>;
|
||||
readonly sources: Set<MutationObserverNode>;
|
||||
readonly dependents: Set<MutationObserverNode>;
|
||||
readonly projections: Map<string, MutationObserverNode>;
|
||||
origins?: Set<MutationObservableOrigin>;
|
||||
sources?: Set<MutationObserverNode>;
|
||||
dependents?: Set<MutationObserverNode>;
|
||||
projections?: Map<string, MutationObserverNode>;
|
||||
readonly read?: {
|
||||
readonly source: MutationObserverNode;
|
||||
readonly key: PropertyPath[number];
|
||||
@@ -700,22 +695,17 @@ const observableMutationPaths = (
|
||||
queued.add(node);
|
||||
pending.push(node);
|
||||
};
|
||||
const create = (
|
||||
read?: MutationObserverNode["read"],
|
||||
): MutationObserverNode => ({
|
||||
origins: new Set(),
|
||||
sources: new Set(),
|
||||
dependents: new Set(),
|
||||
projections: new Map(),
|
||||
...(read === undefined ? {} : { read }),
|
||||
});
|
||||
const create = (read?: MutationObserverNode["read"]): MutationObserverNode =>
|
||||
// Most bindings have no object origin or property projection. Materialize
|
||||
// each collection only when it contains a fact or relationship.
|
||||
read === undefined ? {} : { read };
|
||||
const link = (
|
||||
source: MutationObserverNode,
|
||||
target: MutationObserverNode,
|
||||
): void => {
|
||||
if (target.sources.has(source)) return;
|
||||
target.sources.add(source);
|
||||
source.dependents.add(target);
|
||||
if (target.sources?.has(source) === true) return;
|
||||
(target.sources ??= new Set()).add(source);
|
||||
(source.dependents ??= new Set()).add(target);
|
||||
schedule(target);
|
||||
};
|
||||
const expressionNode = (node: t.Node): MutationObserverNode => {
|
||||
@@ -736,11 +726,11 @@ const observableMutationPaths = (
|
||||
const identity = JSON.stringify(
|
||||
key === null || typeof key === "object" ? key : String(key),
|
||||
);
|
||||
let selected = observer.projections.get(identity);
|
||||
let selected = observer.projections?.get(identity);
|
||||
if (selected === undefined) {
|
||||
selected = create({ source: observer, key });
|
||||
observer.projections.set(identity, selected);
|
||||
observer.dependents.add(selected);
|
||||
(observer.projections ??= new Map()).set(identity, selected);
|
||||
(observer.dependents ??= new Set()).add(selected);
|
||||
schedule(selected);
|
||||
}
|
||||
observer = selected;
|
||||
@@ -760,7 +750,7 @@ const observableMutationPaths = (
|
||||
// Preserve exact paths where copies, defaults or iteration need their
|
||||
// reference-specific projections; an opaque origin prevents pruning.
|
||||
if (binding.referenceInitializers.length > 0)
|
||||
observer.origins.add("opaque");
|
||||
(observer.origins ??= new Set()).add("opaque");
|
||||
for (const initializer of binding.referenceInitializers) {
|
||||
for (const source of [
|
||||
initializer,
|
||||
@@ -777,7 +767,7 @@ const observableMutationPaths = (
|
||||
if (selected === undefined) break;
|
||||
const { expression, observer } = selected;
|
||||
if (t.isObjectExpression(expression) || t.isArrayExpression(expression))
|
||||
observer.origins.add(expression);
|
||||
(observer.origins ??= new Set()).add(expression);
|
||||
else if (t.isIdentifier(expression)) {
|
||||
const binding = resolveSemanticBindingState(
|
||||
state,
|
||||
@@ -787,11 +777,12 @@ const observableMutationPaths = (
|
||||
if (binding !== undefined) {
|
||||
const origin = bindingNodes.get(binding.bindingId);
|
||||
if (origin !== undefined) link(origin, observer);
|
||||
} else if (expression.name === "Array") observer.origins.add("array");
|
||||
} else if (expression.name === "Array")
|
||||
(observer.origins ??= new Set()).add("array");
|
||||
else if (
|
||||
["globalThis", "global", "window", "self"].includes(expression.name)
|
||||
)
|
||||
observer.origins.add("ambient");
|
||||
(observer.origins ??= new Set()).add("ambient");
|
||||
} else if (
|
||||
t.isMemberExpression(expression) ||
|
||||
t.isOptionalMemberExpression(expression)
|
||||
@@ -818,21 +809,21 @@ const observableMutationPaths = (
|
||||
queued.delete(observer);
|
||||
let changed = false;
|
||||
const add = (origin: MutationObservableOrigin): void => {
|
||||
if (observer.origins.has(origin)) return;
|
||||
observer.origins.add(origin);
|
||||
if (observer.origins?.has(origin) === true) return;
|
||||
(observer.origins ??= new Set()).add(origin);
|
||||
changed = true;
|
||||
};
|
||||
for (const source of observer.sources)
|
||||
for (const origin of source.origins) add(origin);
|
||||
for (const source of observer.sources ?? [])
|
||||
for (const origin of source.origins ?? []) add(origin);
|
||||
if (observer.read !== undefined) {
|
||||
const { source, key } = observer.read;
|
||||
if (key === null || typeof key === "object") {
|
||||
// Dynamic keys and copied-slot masks retain their original paths.
|
||||
// Enumerating their exclusion combinations would recreate the growth
|
||||
// that the mutation collector's selection union already prevents.
|
||||
if (source.origins.size > 0) add("opaque");
|
||||
if ((source.origins?.size ?? 0) > 0) add("opaque");
|
||||
} else
|
||||
for (const origin of source.origins) {
|
||||
for (const origin of source.origins ?? []) {
|
||||
if (typeof origin === "string") {
|
||||
if (origin === "array-prototype" || origin === "opaque")
|
||||
add(origin);
|
||||
@@ -869,7 +860,7 @@ const observableMutationPaths = (
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
for (const dependent of observer.dependents) schedule(dependent);
|
||||
for (const dependent of observer.dependents ?? []) schedule(dependent);
|
||||
}
|
||||
};
|
||||
settle();
|
||||
@@ -881,15 +872,16 @@ const observableMutationPaths = (
|
||||
if (effect === "escape") {
|
||||
observer = project(observer, path);
|
||||
settle();
|
||||
return observer.origins.size === 0 ? null : path;
|
||||
return (observer.origins?.size ?? 0) === 0 ? null : path;
|
||||
}
|
||||
if (observer.origins.size === 0) return path.length === 0 ? path : [null];
|
||||
if ((observer.origins?.size ?? 0) === 0)
|
||||
return path.length === 0 ? path : [null];
|
||||
for (let offset = 0; offset < path.length; offset++) {
|
||||
const key = path[offset];
|
||||
if (key === undefined) break;
|
||||
observer = project(observer, [key]);
|
||||
settle();
|
||||
if (observer.origins.size === 0 && offset + 1 < path.length)
|
||||
if ((observer.origins?.size ?? 0) === 0 && offset + 1 < path.length)
|
||||
return [...path.slice(0, offset + 1), null];
|
||||
}
|
||||
return path;
|
||||
|
||||
@@ -179,7 +179,13 @@ const addObjectOperation = (
|
||||
output: JavaScriptSemanticObjectOperation[],
|
||||
): void => {
|
||||
output.push({
|
||||
objectOperationId: `object:${input.kind}:${String(input.node.start ?? -1)}:${String(input.node.end ?? -1)}`,
|
||||
// These high-volume IDs must not retain intermediate concatenations.
|
||||
objectOperationId: [
|
||||
"object",
|
||||
input.kind,
|
||||
String(input.node.start ?? -1),
|
||||
String(input.node.end ?? -1),
|
||||
].join(":"),
|
||||
kind: input.kind,
|
||||
location: range(input.node),
|
||||
ownerCallableId: input.ownerCallableId,
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
JavaScriptSemanticGraphNode,
|
||||
JavaScriptSemanticGraphRelation,
|
||||
} from "./javascriptSemanticGraphSchemas.js";
|
||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
import type { JavaScriptSemanticGraphUnknown } from "./javascriptSemanticGraphSchemas.js";
|
||||
import { isJavaScriptSemanticOwnershipRelation as ownershipRelation } from "./javascriptSemanticQueryRelations.js";
|
||||
import {
|
||||
@@ -30,10 +29,6 @@ interface Traversal {
|
||||
readonly modules: Set<string>;
|
||||
}
|
||||
|
||||
interface SeedAdmission {
|
||||
readonly nodeIds: string[];
|
||||
}
|
||||
|
||||
interface QueryResult {
|
||||
readonly retainedNodes: JavaScriptSemanticGraphNode[];
|
||||
readonly retainedRelations: JavaScriptSemanticGraphRelation[];
|
||||
@@ -47,17 +42,15 @@ export const queryJavaScriptSemanticGraph = (
|
||||
const input = javaScriptSemanticQueryInputSchema.parse(rawInput);
|
||||
const queryId = queryIdentifier(graph, input);
|
||||
const seeds = resolveSeeds(graph, input);
|
||||
const admission = admitSeeds(graph, seeds);
|
||||
const retainedSeeds = admission.nodeIds;
|
||||
const nodes = new Map(graph.nodes.map((node) => [node.node_id, node]));
|
||||
const adjacency = buildAdjacency(graph.relations, input);
|
||||
const traversal = traverse(graph, retainedSeeds, adjacency);
|
||||
const traversal = traverse(nodes, seeds, adjacency);
|
||||
const result = createQueryResult(graph, traversal);
|
||||
const allRelevantUnknowns = relevantUnknownFrontiers(
|
||||
const relevantUnknowns = relevantUnknownFrontiers(
|
||||
graph,
|
||||
traversal.nodeIds,
|
||||
input,
|
||||
);
|
||||
const relevantUnknowns = allRelevantUnknowns;
|
||||
const candidateRelations = relevantCandidateRelationCount(
|
||||
graph,
|
||||
traversal.nodeIds,
|
||||
@@ -87,7 +80,7 @@ export const queryJavaScriptSemanticGraph = (
|
||||
seed: input.seed,
|
||||
direction: input.direction,
|
||||
status: assessment.status,
|
||||
seed_node_ids: retainedSeeds,
|
||||
seed_node_ids: seeds,
|
||||
evidence_contexts: graph.evidence_contexts.filter(({ context_id }) =>
|
||||
retainedContextIds.has(context_id),
|
||||
),
|
||||
@@ -101,7 +94,7 @@ export const queryJavaScriptSemanticGraph = (
|
||||
traversed_relations: traversal.relationIds.size,
|
||||
traversed_functions: traversal.functionIds.size,
|
||||
traversed_modules: traversal.modules.size,
|
||||
relevant_unknowns: allRelevantUnknowns.length,
|
||||
relevant_unknowns: relevantUnknowns.length,
|
||||
},
|
||||
coverage: assessment.coverage,
|
||||
limitations: assessment.limitations,
|
||||
@@ -121,20 +114,6 @@ const createQueryResult = (
|
||||
return { retainedNodes, retainedRelations };
|
||||
};
|
||||
|
||||
const admitSeeds = (
|
||||
graph: JavaScriptSemanticGraph,
|
||||
seeds: readonly string[],
|
||||
): SeedAdmission => {
|
||||
const nodes = new Map(graph.nodes.map((node) => [node.node_id, node]));
|
||||
const retained: string[] = [];
|
||||
for (const nodeId of seeds) {
|
||||
const node = nodes.get(nodeId);
|
||||
if (node === undefined) continue;
|
||||
retained.push(nodeId);
|
||||
}
|
||||
return { nodeIds: retained };
|
||||
};
|
||||
|
||||
const buildAdjacency = (
|
||||
relations: readonly JavaScriptSemanticGraphRelation[],
|
||||
input: JavaScriptSemanticQueryInput,
|
||||
@@ -153,13 +132,6 @@ const buildAdjacency = (
|
||||
continue;
|
||||
addDirectedEntries(adjacency, relation, input.direction);
|
||||
}
|
||||
for (const entries of adjacency.values())
|
||||
entries.sort((left, right) =>
|
||||
compareUnicodeCodePoints(
|
||||
`${left.relation.relation_id}\0${left.nextNodeId}`,
|
||||
`${right.relation.relation_id}\0${right.nextNodeId}`,
|
||||
),
|
||||
);
|
||||
return adjacency;
|
||||
};
|
||||
|
||||
@@ -222,11 +194,10 @@ const addEntry = (
|
||||
};
|
||||
|
||||
const traverse = (
|
||||
graph: JavaScriptSemanticGraph,
|
||||
nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
|
||||
seeds: readonly string[],
|
||||
adjacency: ReadonlyMap<string, TraversalEntry[]>,
|
||||
): Traversal => {
|
||||
const nodes = new Map(graph.nodes.map((node) => [node.node_id, node]));
|
||||
const nodeIds = new Set(seeds);
|
||||
const relationIds = new Set<string>();
|
||||
const functionIds = new Set<string>();
|
||||
@@ -289,15 +260,32 @@ const relevantCandidateRelationCount = (
|
||||
nodeIds: ReadonlySet<string>,
|
||||
input: JavaScriptSemanticQueryInput,
|
||||
): number => {
|
||||
const adjacency = buildAdjacency(
|
||||
graph.relations.filter(({ resolution }) => resolution === "candidate"),
|
||||
{ ...input, include_ambiguous_dynamic_edges: true },
|
||||
);
|
||||
const relevant = new Set<string>();
|
||||
for (const nodeId of nodeIds)
|
||||
for (const { relation } of adjacency.get(nodeId) ?? [])
|
||||
relevant.add(relation.relation_id);
|
||||
return relevant.size;
|
||||
const allowed =
|
||||
input.allowed_relations === undefined
|
||||
? null
|
||||
: new Set(input.allowed_relations);
|
||||
let relevant = 0;
|
||||
for (const relation of graph.relations) {
|
||||
if (
|
||||
relation.resolution !== "candidate" ||
|
||||
(allowed !== null && !allowed.has(relation.relation))
|
||||
)
|
||||
continue;
|
||||
const sourceIsRelevant = nodeIds.has(relation.source_node_id);
|
||||
const targetIsRelevant = nodeIds.has(relation.target_node_id);
|
||||
if (
|
||||
(input.direction === "callers" &&
|
||||
relation.relation === "calls" &&
|
||||
targetIsRelevant) ||
|
||||
(input.direction === "ownership" &&
|
||||
ownershipRelation(relation.relation) &&
|
||||
(sourceIsRelevant || targetIsRelevant)) ||
|
||||
(input.direction === "backward-provenance" && targetIsRelevant) ||
|
||||
(input.direction === "forward-influence" && sourceIsRelevant)
|
||||
)
|
||||
relevant += 1;
|
||||
}
|
||||
return relevant;
|
||||
};
|
||||
|
||||
const expectedMatchesFor = (
|
||||
|
||||
@@ -83,7 +83,14 @@ const semanticReturnSiteId = (
|
||||
callableId: string,
|
||||
location: JavaScriptSemanticReturnSite["location"],
|
||||
): string =>
|
||||
`${callableId}:return:${String(location.start.line)}:${String(location.start.column)}:${String(location.end.line)}:${String(location.end.column)}`;
|
||||
[
|
||||
callableId,
|
||||
"return",
|
||||
String(location.start.line),
|
||||
String(location.start.column),
|
||||
String(location.end.line),
|
||||
String(location.end.column),
|
||||
].join(":");
|
||||
|
||||
/** Link exports to callables only when lexical resolution is unique and exact. */
|
||||
export const resolveSemanticModuleCallables = (
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface JavaScriptSemanticAnalysisState {
|
||||
readonly parentsByNode: WeakMap<t.Node, t.Node>;
|
||||
readonly scopes: JavaScriptSemanticScopeState[];
|
||||
readonly scopesById: Map<string, JavaScriptSemanticScopeState>;
|
||||
/** Scope boundaries and descendant scopes resolved on demand. */
|
||||
readonly scopeByNode: WeakMap<t.Node, JavaScriptSemanticScopeState>;
|
||||
readonly bindingsById: Map<string, JavaScriptSemanticBindingState>;
|
||||
readonly callables: JavaScriptSemanticCallable[];
|
||||
@@ -94,7 +95,7 @@ export const resolveSemanticBindingState = (
|
||||
node: t.Node,
|
||||
name: string,
|
||||
): JavaScriptSemanticBindingState | undefined => {
|
||||
let scope = state.scopeByNode.get(node);
|
||||
let scope = semanticScopeForNode(state, node);
|
||||
while (scope !== undefined) {
|
||||
const binding = scope.bindings.get(name);
|
||||
if (binding !== undefined) return binding;
|
||||
@@ -113,7 +114,7 @@ export const semanticResolutionBlocked = (
|
||||
node: t.Node,
|
||||
name: string,
|
||||
): boolean => {
|
||||
let scope = state.scopeByNode.get(node);
|
||||
let scope = semanticScopeForNode(state, node);
|
||||
while (scope !== undefined) {
|
||||
if (scope.bindings.has(name)) return false;
|
||||
if (!scope.bindingsComplete) return true;
|
||||
@@ -125,6 +126,28 @@ export const semanticResolutionBlocked = (
|
||||
return false;
|
||||
};
|
||||
|
||||
const semanticScopeForNode = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
node: t.Node,
|
||||
): JavaScriptSemanticScopeState | undefined => {
|
||||
let candidate: t.Node | undefined = node;
|
||||
while (candidate !== undefined) {
|
||||
const scope = state.scopeByNode.get(candidate);
|
||||
if (scope !== undefined) {
|
||||
// Repeated references in a deep expression share this ancestry. Cache
|
||||
// the traversed path without eagerly indexing nodes never resolved.
|
||||
let descendant: t.Node | undefined = node;
|
||||
while (descendant !== undefined && descendant !== candidate) {
|
||||
state.scopeByNode.set(descendant, scope);
|
||||
descendant = state.parentsByNode.get(descendant);
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
candidate = state.parentsByNode.get(candidate);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Resolve one name starting from an explicitly selected lexical scope. */
|
||||
export const resolveSemanticBindingFromScope = (
|
||||
scope: JavaScriptSemanticScopeState,
|
||||
|
||||
+47
-38
@@ -33,6 +33,15 @@ export const jsonShapeSchema = z.object({
|
||||
});
|
||||
export type JsonShape = z.infer<typeof jsonShapeSchema>;
|
||||
|
||||
const arrayElementKey = Symbol("array-element");
|
||||
|
||||
interface ShapeNode {
|
||||
readonly path: JsonShapePathSegment[];
|
||||
readonly types: Set<JsonValueType>;
|
||||
observations: number;
|
||||
children?: Map<string | typeof arrayElementKey, ShapeNode>;
|
||||
}
|
||||
|
||||
/** Parse approved JSON and immediately discard values after iterative shape inference. */
|
||||
export const inferJsonShape = (text: string): JsonShape | null => {
|
||||
let root: unknown;
|
||||
@@ -43,48 +52,52 @@ export const inferJsonShape = (text: string): JsonShape | null => {
|
||||
void cause;
|
||||
return null;
|
||||
}
|
||||
const properties = new Map<
|
||||
string,
|
||||
{
|
||||
readonly path: JsonShapePathSegment[];
|
||||
readonly types: Set<JsonValueType>;
|
||||
observations: number;
|
||||
}
|
||||
>();
|
||||
const properties: ShapeNode[] = [];
|
||||
const rootShape: ShapeNode = {
|
||||
path: [],
|
||||
types: new Set(),
|
||||
observations: 0,
|
||||
};
|
||||
// Each structural path owns one accumulator, shared by all matching array rows.
|
||||
const childShape = (
|
||||
parent: ShapeNode,
|
||||
key: string | typeof arrayElementKey,
|
||||
): ShapeNode => {
|
||||
const children = (parent.children ??= new Map());
|
||||
const existing = children.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const node: ShapeNode = {
|
||||
path: [
|
||||
...parent.path,
|
||||
typeof key === "string"
|
||||
? { kind: "property", name: key }
|
||||
: { kind: "array-element" },
|
||||
],
|
||||
types: new Set(),
|
||||
observations: 0,
|
||||
};
|
||||
children.set(key, node);
|
||||
properties.push(node);
|
||||
return node;
|
||||
};
|
||||
const pending: Array<{
|
||||
readonly value: unknown;
|
||||
readonly path: JsonShapePathSegment[];
|
||||
readonly depth: number;
|
||||
}> = [{ value: root, path: [], depth: 0 }];
|
||||
readonly shape: ShapeNode;
|
||||
}> = [{ value: root, shape: rootShape }];
|
||||
let nodeCount = 0;
|
||||
let maxDepthObserved = 0;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
nodeCount += 1;
|
||||
maxDepthObserved = Math.max(maxDepthObserved, current.depth);
|
||||
if (current.path.length > 0) {
|
||||
const key = JSON.stringify(current.path);
|
||||
const type = jsonValueType(current.value);
|
||||
const existing = properties.get(key);
|
||||
if (existing === undefined)
|
||||
properties.set(key, {
|
||||
path: current.path,
|
||||
types: new Set([type]),
|
||||
observations: 1,
|
||||
});
|
||||
else {
|
||||
existing.types.add(type);
|
||||
existing.observations += 1;
|
||||
}
|
||||
}
|
||||
maxDepthObserved = Math.max(maxDepthObserved, current.shape.path.length);
|
||||
current.shape.types.add(jsonValueType(current.value));
|
||||
current.shape.observations += 1;
|
||||
if (Array.isArray(current.value)) {
|
||||
if (current.value.length === 0) continue;
|
||||
const shape = childShape(current.shape, arrayElementKey);
|
||||
for (let index = current.value.length - 1; index >= 0; index -= 1)
|
||||
pending.push({
|
||||
value: current.value[index],
|
||||
path: [...current.path, { kind: "array-element" }],
|
||||
depth: current.depth + 1,
|
||||
});
|
||||
pending.push({ value: current.value[index], shape });
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(current.value)) continue;
|
||||
@@ -93,18 +106,14 @@ export const inferJsonShape = (text: string): JsonShape | null => {
|
||||
const entry = allEntries[index];
|
||||
if (entry === undefined) continue;
|
||||
const [name, value] = entry;
|
||||
const path: JsonShapePathSegment[] = [
|
||||
...current.path,
|
||||
{ kind: "property", name },
|
||||
];
|
||||
pending.push({ value, path, depth: current.depth + 1 });
|
||||
pending.push({ value, shape: childShape(current.shape, name) });
|
||||
}
|
||||
}
|
||||
return jsonShapeSchema.parse({
|
||||
root_type: jsonValueType(root),
|
||||
node_count: nodeCount,
|
||||
max_depth_observed: maxDepthObserved,
|
||||
properties: [...properties.values()]
|
||||
properties: properties
|
||||
.map((value) => ({
|
||||
path: value.path,
|
||||
types: [...value.types].sort(),
|
||||
|
||||
@@ -134,6 +134,7 @@ export const projectManagedApplicationGraph = (
|
||||
const parsed = parseManagedInputs(input);
|
||||
const artifactIdentity = chooseArtifact(parsed);
|
||||
assertSameArtifact(parsed, artifactIdentity.sha256);
|
||||
assertConsistentModuleIdentity(parsed);
|
||||
const evidenceLinks = [
|
||||
parsed.artifact?.evidence.evidence_id,
|
||||
parsed.members?.evidence.evidence_id,
|
||||
@@ -258,6 +259,52 @@ const assertSameArtifact = (
|
||||
);
|
||||
};
|
||||
|
||||
/** Reject contradictory known module identities while leaving null fields unknown. */
|
||||
const assertConsistentModuleIdentity = (
|
||||
parsed: ParsedManagedGraphInput,
|
||||
): void => {
|
||||
const mvidClaims = [
|
||||
parsed.artifact?.result.module?.mvid,
|
||||
parsed.members?.result.module?.mvid,
|
||||
parsed.members?.result.identity_scope.requires_mvid,
|
||||
parsed.boundaries?.result.module?.mvid,
|
||||
parsed.boundaries?.result.identity_scope.requires_mvid,
|
||||
]
|
||||
.filter((mvid): mvid is string => mvid !== undefined && mvid !== null)
|
||||
.map((mvid) => mvid.toLowerCase());
|
||||
if (new Set(mvidClaims).size > 1)
|
||||
throw new TypeError(
|
||||
"Managed graph inputs report conflicting known module MVIDs or token scopes",
|
||||
);
|
||||
|
||||
const modules = [
|
||||
parsed.artifact?.result.module,
|
||||
parsed.members?.result.module,
|
||||
parsed.boundaries?.result.module,
|
||||
].filter(
|
||||
(module): module is NonNullable<ManagedArtifactInspection["module"]> =>
|
||||
module !== undefined && module !== null,
|
||||
);
|
||||
for (const field of ["name", "generation", "token", "row_offset"] as const) {
|
||||
const values = new Set(modules.map((module) => module[field]));
|
||||
if (values.size > 1)
|
||||
throw new TypeError(
|
||||
`Managed graph inputs report conflicting module ${field} values`,
|
||||
);
|
||||
}
|
||||
for (const field of ["enc_id", "enc_base_id"] as const) {
|
||||
const values = new Set(
|
||||
modules
|
||||
.map((module) => module[field]?.toLowerCase())
|
||||
.filter((value): value is string => value !== undefined),
|
||||
);
|
||||
if (values.size > 1)
|
||||
throw new TypeError(
|
||||
`Managed graph inputs report conflicting module ${field} values`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const omittedCounts = (parsed: ParsedManagedGraphInput) =>
|
||||
assessManagedGraphOmissions({
|
||||
artifact: parsed.artifact?.result ?? null,
|
||||
@@ -288,4 +335,9 @@ const projectionLimitations = (
|
||||
...(omissions.partialInput
|
||||
? ["At least one supplied managed Evidence record has partial coverage."]
|
||||
: []),
|
||||
...(omissions.unscopedTokenRelationships
|
||||
? [
|
||||
"Managed boundary tokens were not linked to member nodes because matching module MVID scope was not established.",
|
||||
]
|
||||
: []),
|
||||
];
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ManagedMemberInspection,
|
||||
ManagedNativeBoundaryInspection,
|
||||
} from "./managedArtifact.js";
|
||||
import { managedTokenScopesMatch } from "./managedInspectionEvidence.js";
|
||||
|
||||
type ManagedCoverageState = "complete" | "partial" | "unavailable";
|
||||
|
||||
@@ -17,6 +18,7 @@ export interface ManagedGraphCoverageSources {
|
||||
/** Whether any supplied source has incomplete coverage. */
|
||||
export interface ManagedGraphProjectionOmissions {
|
||||
readonly partialInput: boolean;
|
||||
readonly unscopedTokenRelationships: boolean;
|
||||
}
|
||||
|
||||
const completeCoverage = (): ApplicationGraphEvidence["coverage"] => ({
|
||||
@@ -43,6 +45,17 @@ export const managedSourceCoverage = (
|
||||
export const assessManagedGraphOmissions = (
|
||||
sources: ManagedGraphCoverageSources,
|
||||
): ManagedGraphProjectionOmissions => ({
|
||||
unscopedTokenRelationships:
|
||||
sources.members !== null &&
|
||||
sources.boundaries !== null &&
|
||||
(sources.boundaries.pinvoke_imports.some(
|
||||
({ member_token }) => member_token !== null,
|
||||
) ||
|
||||
sources.boundaries.native_implementations.length > 0) &&
|
||||
!managedTokenScopesMatch(
|
||||
sources.members.identity_scope.requires_mvid,
|
||||
sources.boundaries.identity_scope.requires_mvid,
|
||||
),
|
||||
partialInput: [
|
||||
sources.members?.coverage.state,
|
||||
sources.boundaries?.coverage.state,
|
||||
@@ -54,7 +67,7 @@ export const assessManagedGraphOmissions = (
|
||||
export const managedGraphEvidenceCoverage = (
|
||||
omissions: ManagedGraphProjectionOmissions,
|
||||
): ApplicationGraphEvidence["coverage"] =>
|
||||
omissions.partialInput
|
||||
omissions.partialInput || omissions.unscopedTokenRelationships
|
||||
? {
|
||||
status: "partial",
|
||||
truncated: false,
|
||||
@@ -67,7 +80,8 @@ export const managedGraphEvidenceCoverage = (
|
||||
export const managedGraphResultCoverage = (
|
||||
omissions: ManagedGraphProjectionOmissions,
|
||||
) => ({
|
||||
status: omissions.partialInput
|
||||
? ("partial" as const)
|
||||
: ("complete-within-inputs" as const),
|
||||
status:
|
||||
omissions.partialInput || omissions.unscopedTokenRelationships
|
||||
? ("partial" as const)
|
||||
: ("complete-within-inputs" as const),
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import type { ApplicationGraphEvidence } from "../javascript/javascriptApplicati
|
||||
import type { ApplicationNode } from "../javascript/javascriptApplicationGraphSchemas.js";
|
||||
import { managedSourceCoverage } from "./managedApplicationGraphCoverage.js";
|
||||
import type { Evidence } from "../evidence.js";
|
||||
import { managedTokenScopesMatch } from "./managedInspectionEvidence.js";
|
||||
import {
|
||||
type ManagedArtifactInspection,
|
||||
type ManagedMemberInspection,
|
||||
@@ -106,7 +107,7 @@ export const addArtifactIdentityNodes = (
|
||||
identity: artifactLocalIdentity(
|
||||
state.artifactSha256,
|
||||
"managed-module",
|
||||
module.mvid ?? module.token,
|
||||
module.mvid?.toLowerCase() ?? module.token,
|
||||
),
|
||||
observations: [
|
||||
{
|
||||
@@ -138,25 +139,32 @@ interface ManagedModuleSource {
|
||||
const selectModuleSource = (
|
||||
parsed: ParsedManagedGraphInput,
|
||||
): ManagedModuleSource | null => {
|
||||
if (parsed.artifact?.result.module != null)
|
||||
return {
|
||||
module: parsed.artifact.result.module,
|
||||
evidence: parsed.artifact.evidence,
|
||||
coverageState: parsed.artifact.result.coverage.state,
|
||||
};
|
||||
if (parsed.members?.result.module != null)
|
||||
return {
|
||||
module: parsed.members.result.module,
|
||||
evidence: parsed.members.evidence,
|
||||
coverageState: parsed.members.result.coverage.state,
|
||||
};
|
||||
if (parsed.boundaries?.result.module != null)
|
||||
return {
|
||||
module: parsed.boundaries.result.module,
|
||||
evidence: parsed.boundaries.evidence,
|
||||
coverageState: parsed.boundaries.result.coverage.state,
|
||||
};
|
||||
return null;
|
||||
const sources = [
|
||||
parsed.artifact?.result.module == null
|
||||
? null
|
||||
: {
|
||||
module: parsed.artifact.result.module,
|
||||
evidence: parsed.artifact.evidence,
|
||||
coverageState: parsed.artifact.result.coverage.state,
|
||||
},
|
||||
parsed.members?.result.module == null
|
||||
? null
|
||||
: {
|
||||
module: parsed.members.result.module,
|
||||
evidence: parsed.members.evidence,
|
||||
coverageState: parsed.members.result.coverage.state,
|
||||
},
|
||||
parsed.boundaries?.result.module == null
|
||||
? null
|
||||
: {
|
||||
module: parsed.boundaries.result.module,
|
||||
evidence: parsed.boundaries.evidence,
|
||||
coverageState: parsed.boundaries.result.coverage.state,
|
||||
},
|
||||
].filter((source): source is ManagedModuleSource => source !== null);
|
||||
return (
|
||||
sources.find(({ module }) => module.mvid !== null) ?? sources[0] ?? null
|
||||
);
|
||||
};
|
||||
|
||||
/** Add managed type, method, and field nodes from member evidence. */
|
||||
@@ -332,11 +340,17 @@ export const addBoundaryNodes = (
|
||||
boundaryInput.evidence,
|
||||
boundaries.coverage.state,
|
||||
);
|
||||
const canJoinMemberTokens =
|
||||
parsed.members !== null &&
|
||||
managedTokenScopesMatch(
|
||||
parsed.members.result.identity_scope.requires_mvid,
|
||||
boundaries.identity_scope.requires_mvid,
|
||||
);
|
||||
for (const pinvoke of pinvokes) {
|
||||
const node = pinvokeNode(state, pinvoke, sourceEvidence);
|
||||
state.nodes.push(node);
|
||||
const method =
|
||||
pinvoke.member_token === null
|
||||
pinvoke.member_token === null || !canJoinMemberTokens
|
||||
? undefined
|
||||
: state.methodNodes.get(pinvoke.member_token);
|
||||
const memberEvidence =
|
||||
@@ -370,7 +384,10 @@ export const addBoundaryNodes = (
|
||||
sourceEvidence,
|
||||
);
|
||||
state.nodes.push(node);
|
||||
const owner = state.methodNodes.get(implementation.token) ?? artifactNode;
|
||||
const owner =
|
||||
(canJoinMemberTokens
|
||||
? state.methodNodes.get(implementation.token)
|
||||
: undefined) ?? artifactNode;
|
||||
addContainsEdge(state, owner, node, {
|
||||
kind: "declares-managed-native-implementation",
|
||||
coverage: implementationCoverage,
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { parseEvidence, type Evidence } from "../evidence.js";
|
||||
import type { z } from "zod";
|
||||
|
||||
/** Build-local metadata tokens join only under a known, matching module MVID. */
|
||||
export const managedTokenScopesMatch = (
|
||||
leftMvid: string | null,
|
||||
rightMvid: string | null,
|
||||
): boolean =>
|
||||
leftMvid !== null &&
|
||||
rightMvid !== null &&
|
||||
leftMvid.toLowerCase() === rightMvid.toLowerCase();
|
||||
|
||||
/**
|
||||
* Parse managed inspection Evidence and bind its artifact to any available subject.
|
||||
* A null subject remains valid and means there is no digest to cross-check.
|
||||
*/
|
||||
export const parseManagedInspectionEvidence = <
|
||||
Result extends { readonly artifact: { readonly sha256: string } },
|
||||
Result extends {
|
||||
readonly artifact: { readonly sha256: string };
|
||||
readonly identity_scope?: {
|
||||
readonly requires_artifact_sha256: string;
|
||||
readonly requires_mvid: string | null;
|
||||
};
|
||||
readonly module?: { readonly mvid: string | null } | null;
|
||||
},
|
||||
>(
|
||||
rawEvidence: unknown,
|
||||
operation: string,
|
||||
@@ -22,5 +38,20 @@ export const parseManagedInspectionEvidence = <
|
||||
throw new TypeError(
|
||||
`Managed Evidence ${operation} (${evidence.evidence_id}) subject SHA-256 ${subjectSha256} does not match normalized artifact SHA-256 ${artifactSha256}`,
|
||||
);
|
||||
const scope = result.identity_scope;
|
||||
if (scope !== undefined && scope.requires_artifact_sha256 !== artifactSha256)
|
||||
throw new TypeError(
|
||||
`Managed Evidence ${operation} (${evidence.evidence_id}) token scope SHA-256 ${scope.requires_artifact_sha256} does not match normalized artifact SHA-256 ${artifactSha256}`,
|
||||
);
|
||||
if (
|
||||
scope?.requires_mvid !== undefined &&
|
||||
scope.requires_mvid !== null &&
|
||||
result.module?.mvid !== undefined &&
|
||||
result.module.mvid !== null &&
|
||||
scope.requires_mvid.toLowerCase() !== result.module.mvid.toLowerCase()
|
||||
)
|
||||
throw new TypeError(
|
||||
`Managed Evidence ${operation} (${evidence.evidence_id}) token scope MVID ${scope.requires_mvid} does not match module MVID ${result.module.mvid}`,
|
||||
);
|
||||
return { evidence, result };
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseProcessScenario,
|
||||
PROCESS_TERMINAL_CELL_BUDGET,
|
||||
processScenarioSchema,
|
||||
} from "./processScenario.js";
|
||||
|
||||
it("admits the default and the exact combined terminal-cell budget", () => {
|
||||
expect(parseProcessScenario({ executable: "/bin/true" }).terminal).toEqual({
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
scrollback: 1_000,
|
||||
});
|
||||
|
||||
expect(
|
||||
processScenarioSchema.safeParse({
|
||||
executable: "/bin/true",
|
||||
terminal: {
|
||||
columns: 1,
|
||||
rows: 1,
|
||||
scrollback: PROCESS_TERMINAL_CELL_BUDGET - 1,
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an oversized initial buffer without clamping caller dimensions", () => {
|
||||
const input = {
|
||||
executable: "/bin/true",
|
||||
terminal: { columns: 1_000, rows: 1, scrollback: 1_000 },
|
||||
};
|
||||
const parsed = processScenarioSchema.safeParse(input);
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) throw new Error("expected terminal capacity rejection");
|
||||
expect(parsed.error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "custom",
|
||||
path: ["terminal"],
|
||||
message: expect.stringContaining(
|
||||
`${String(PROCESS_TERMINAL_CELL_BUDGET)} renderer cells`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the selected scrollback budget to every scheduled resize", () => {
|
||||
const parsed = processScenarioSchema.safeParse({
|
||||
executable: "/bin/true",
|
||||
events: [{ type: "resize", at_ms: 10, columns: 1_000, rows: 1 }],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) throw new Error("expected resize capacity rejection");
|
||||
expect(parsed.error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "custom",
|
||||
path: ["events", 0],
|
||||
message: expect.stringContaining("resize event at 10 ms"),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
processScenarioSchema.safeParse({
|
||||
executable: "/bin/true",
|
||||
terminal: { columns: 1, rows: 1, scrollback: 0 },
|
||||
events: [{ type: "resize", at_ms: 10, columns: 1_000, rows: 1 }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -3,6 +3,12 @@ import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
|
||||
const positiveBudget = z.number().int().safe().positive();
|
||||
/**
|
||||
* Maximum xterm backing cells for one visible-plus-scrollback buffer. Each
|
||||
* xterm BufferLine stores three Uint32 words per cell (12 MB at this cap),
|
||||
* before row objects, serialization strings, and retained frame copies.
|
||||
*/
|
||||
export const PROCESS_TERMINAL_CELL_BUDGET = 1_000_000;
|
||||
const timedEventBase = { at_ms: z.number().int().safe().nonnegative() };
|
||||
const reservedRunIdEnvironmentName = "REA_PROCESS_RUN_ID";
|
||||
const ENVIRONMENT_NAME_CHARACTER = "[^=\\x00]";
|
||||
@@ -41,6 +47,14 @@ const childProcessString = z
|
||||
/^[^\x00]*$/u,
|
||||
"Values passed to operating-system APIs cannot contain NUL",
|
||||
);
|
||||
|
||||
const fitsProcessTerminalCellBudget = (
|
||||
columns: number,
|
||||
rows: number,
|
||||
scrollback: number,
|
||||
): boolean =>
|
||||
rows + scrollback <= Math.floor(PROCESS_TERMINAL_CELL_BUDGET / columns);
|
||||
|
||||
export const normalizationSchema = z.object({
|
||||
paths: z.boolean(),
|
||||
pids: z.boolean(),
|
||||
@@ -131,8 +145,11 @@ export const processScenarioSchema = z
|
||||
.strictObject({
|
||||
columns: z.number().int().min(1).max(65_535).default(80),
|
||||
rows: z.number().int().min(1).max(65_535).default(24),
|
||||
scrollback: z.number().int().min(0).default(1_000),
|
||||
scrollback: z.number().int().safe().nonnegative().default(1_000),
|
||||
})
|
||||
.describe(
|
||||
"The visible terminal and scrollback together must use at most 1,000,000 xterm renderer cells: columns × (rows + scrollback). Resize events use the same scrollback and limit.",
|
||||
)
|
||||
.default({ columns: 80, rows: 24, scrollback: 1_000 }),
|
||||
events: z
|
||||
.array(
|
||||
@@ -204,6 +221,18 @@ export const processScenarioSchema = z
|
||||
}),
|
||||
})
|
||||
.superRefine((scenario, context) => {
|
||||
if (
|
||||
!fitsProcessTerminalCellBudget(
|
||||
scenario.terminal.columns,
|
||||
scenario.terminal.rows,
|
||||
scenario.terminal.scrollback,
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["terminal"],
|
||||
message: `terminal columns × (rows + scrollback) must not exceed ${String(PROCESS_TERMINAL_CELL_BUDGET)} renderer cells`,
|
||||
});
|
||||
for (let index = 1; index < scenario.events.length; index += 1) {
|
||||
const event = scenario.events[index];
|
||||
const previous = scenario.events[index - 1];
|
||||
@@ -215,7 +244,20 @@ export const processScenarioSchema = z
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const event of scenario.events) {
|
||||
for (const [index, event] of scenario.events.entries()) {
|
||||
if (
|
||||
event.type === "resize" &&
|
||||
!fitsProcessTerminalCellBudget(
|
||||
event.columns,
|
||||
event.rows,
|
||||
scenario.terminal.scrollback,
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["events", index],
|
||||
message: `resize event at ${String(event.at_ms)} ms exceeds the ${String(PROCESS_TERMINAL_CELL_BUDGET)}-cell renderer budget with the selected scrollback`,
|
||||
});
|
||||
if (event.at_ms > scenario.timeout_ms) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
|
||||
@@ -3,6 +3,9 @@ export type FixtureCorrectness = "passed" | "failed" | "not_assessed";
|
||||
|
||||
/** Measurements consumed by the evaluation runner's release gate. */
|
||||
export interface AgentEvaluationGateResult {
|
||||
/** Explicit null requires a target clarification with no REA calls. */
|
||||
readonly expectedFirstTool?: string | null;
|
||||
readonly targetClarificationPassed?: boolean;
|
||||
readonly exitCode: number;
|
||||
readonly naturalUse: boolean;
|
||||
readonly correctFirstTool: boolean;
|
||||
@@ -20,16 +23,20 @@ export const agentEvaluationPassed = (
|
||||
result: AgentEvaluationGateResult,
|
||||
): boolean =>
|
||||
result.exitCode === 0 &&
|
||||
result.naturalUse &&
|
||||
(result.expectedFirstTool === null
|
||||
? !result.naturalUse
|
||||
: result.naturalUse) &&
|
||||
result.correctFirstTool &&
|
||||
result.repeatedCallCount === 0 &&
|
||||
result.inputValidationFailureCount === 0 &&
|
||||
result.requiredToolSubsequenceMet &&
|
||||
result.inputTokens > 0 &&
|
||||
(result.factualCorrectness === "passed" ||
|
||||
(result.factualCorrectness === "not_assessed" &&
|
||||
result.answerHeuristicsMet &&
|
||||
result.epistemicCuePresent));
|
||||
(result.expectedFirstTool === null
|
||||
? result.targetClarificationPassed === true
|
||||
: result.factualCorrectness === "passed" ||
|
||||
(result.factualCorrectness === "not_assessed" &&
|
||||
result.answerHeuristicsMet &&
|
||||
result.epistemicCuePresent));
|
||||
|
||||
/** Keep assessed fixture outcomes and scenarios without truth oracles distinct. */
|
||||
export const summarizeFactualCorrectness = (
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type KnownAnswerAssessment,
|
||||
} from "./KnownAnswerEvaluation.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
|
||||
/** One REA MCP invocation observed in a Codex JSONL transcript. */
|
||||
interface CodexMcpCall {
|
||||
@@ -72,7 +73,35 @@ const evidenceIdsFrom = (value: unknown): readonly string[] => {
|
||||
return [...new Set(encoded.match(/ev_[a-f0-9]{64}/gu) ?? [])];
|
||||
};
|
||||
|
||||
const toolResultFailed = (item: Record<string, unknown>): boolean => {
|
||||
const toolResultError = (
|
||||
item: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined => {
|
||||
const result = record(item.result ?? item.output);
|
||||
const structured = record(
|
||||
result?.structured_content ?? result?.structuredContent,
|
||||
);
|
||||
const direct = record(structured?.error ?? result?.error ?? item.error);
|
||||
if (direct !== undefined || structured !== undefined) return direct;
|
||||
// Codex preserves some REA failures only as one complete JSON text block.
|
||||
// A client-truncated preview or SDK prose is not a structured error record.
|
||||
const content = result?.content;
|
||||
if (!Array.isArray(content) || content.length !== 1) return undefined;
|
||||
const block = record(content[0]);
|
||||
if (block?.type !== "text" || typeof block.text !== "string")
|
||||
return undefined;
|
||||
try {
|
||||
return record(record(JSON.parse(block.text))?.error);
|
||||
} catch (cause) {
|
||||
if (cause instanceof SyntaxError || cause instanceof RangeError)
|
||||
return undefined;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const toolResultFailed = (
|
||||
item: Record<string, unknown>,
|
||||
error: Record<string, unknown> | undefined,
|
||||
): boolean => {
|
||||
if (
|
||||
item.status === "failed" ||
|
||||
(item.error !== undefined && item.error !== null)
|
||||
@@ -82,16 +111,11 @@ const toolResultFailed = (item: Record<string, unknown>): boolean => {
|
||||
const structured = record(
|
||||
result?.structured_content ?? result?.structuredContent,
|
||||
);
|
||||
return result?.isError === true || structured?.error !== undefined;
|
||||
};
|
||||
|
||||
const toolResultErrorCode = (item: Record<string, unknown>): string | null => {
|
||||
const result = record(item.result ?? item.output);
|
||||
const structured = record(
|
||||
result?.structured_content ?? result?.structuredContent,
|
||||
return (
|
||||
result?.isError === true ||
|
||||
structured?.error !== undefined ||
|
||||
error !== undefined
|
||||
);
|
||||
const error = record(structured?.error ?? result?.error ?? item.error);
|
||||
return textValue(error?.code) ?? null;
|
||||
};
|
||||
|
||||
const callFromItem = (value: unknown): CodexMcpCall | undefined => {
|
||||
@@ -99,14 +123,15 @@ const callFromItem = (value: unknown): CodexMcpCall | undefined => {
|
||||
if (item?.type !== "mcp_tool_call") return undefined;
|
||||
const tool = textValue(item.tool) ?? textValue(item.name);
|
||||
if (tool === undefined) return undefined;
|
||||
const error = toolResultError(item);
|
||||
return {
|
||||
id: textValue(item.id) ?? null,
|
||||
server: textValue(item.server) ?? textValue(item.server_name) ?? null,
|
||||
tool,
|
||||
arguments: item.arguments ?? item.input ?? {},
|
||||
evidenceIds: evidenceIdsFrom(item.result ?? item.output),
|
||||
error: toolResultFailed(item),
|
||||
errorCode: toolResultErrorCode(item),
|
||||
error: toolResultFailed(item, error),
|
||||
errorCode: textValue(error?.code) ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -171,7 +196,7 @@ const consumeEvent = (state: EvaluationState, value: unknown): void => {
|
||||
/** Measure agent routing, repetition, model usage, and explicitly limited answer heuristics. */
|
||||
export const evaluateCodexEvents = (
|
||||
events: readonly unknown[],
|
||||
expectedFirstTool: string,
|
||||
expectedFirstTool: string | null,
|
||||
options: {
|
||||
readonly requireEvidence?: boolean;
|
||||
readonly requiredAnswerTermGroups?: readonly (readonly string[])[];
|
||||
@@ -194,16 +219,31 @@ export const evaluateCodexEvents = (
|
||||
({ server }) => server === null || server.toLowerCase() === "rea",
|
||||
);
|
||||
const signatures = new Map<string, number>();
|
||||
let targetRevision = 0;
|
||||
for (const call of reaCalls) {
|
||||
const signature = `${call.tool}:${JSON.stringify(canonicalValue(call.arguments))}`;
|
||||
// A status read before and after a successful target lifecycle change is
|
||||
// useful verification, not a duplicate analysis or unchanged failed retry.
|
||||
const context =
|
||||
call.tool === "binary_session" ? `${String(targetRevision)}:` : "";
|
||||
const signature = `${context}${call.tool}:${JSON.stringify(canonicalValue(call.arguments))}`;
|
||||
signatures.set(signature, (signatures.get(signature) ?? 0) + 1);
|
||||
if (
|
||||
!call.error &&
|
||||
(call.tool === "open_binary" || call.tool === "close_binary")
|
||||
)
|
||||
targetRevision += 1;
|
||||
}
|
||||
const repeatedCallCount = [...signatures.values()].reduce(
|
||||
(total, count) => total + Math.max(0, count - 1),
|
||||
0,
|
||||
);
|
||||
const inputValidationFailureCount = reaCalls.filter(
|
||||
({ errorCode }) => errorCode === "invalid_request",
|
||||
(call) =>
|
||||
call.errorCode === "invalid_request" ||
|
||||
(call.error &&
|
||||
TOOL_CONTRACTS.find(
|
||||
({ name }) => name === call.tool,
|
||||
)?.inputSchema.safeParse(call.arguments).success === false),
|
||||
).length;
|
||||
const requiredToolSubsequenceMet = containsOrderedSubsequence(
|
||||
reaCalls.filter(({ error }) => !error).map(({ tool }) => tool),
|
||||
@@ -234,7 +274,7 @@ export const evaluateCodexEvents = (
|
||||
);
|
||||
return {
|
||||
naturalUse: reaCalls.length > 0,
|
||||
correctFirstTool: reaCalls[0]?.tool === expectedFirstTool,
|
||||
correctFirstTool: (reaCalls[0]?.tool ?? null) === expectedFirstTool,
|
||||
firstTool: reaCalls[0]?.tool ?? null,
|
||||
reaCalls,
|
||||
repeatedCallCount,
|
||||
|
||||
@@ -59,8 +59,6 @@ export const evmInterfaceFailure = (
|
||||
message: cause.message,
|
||||
exit_code: cause.snapshot?.exitCode ?? null,
|
||||
signal: cause.snapshot?.signal ?? null,
|
||||
stdout: cause.snapshot?.stdout.text ?? null,
|
||||
stderr: cause.snapshot?.stderr.text ?? null,
|
||||
},
|
||||
},
|
||||
{ operation: "inspect_evm_interface", cause },
|
||||
|
||||
@@ -401,28 +401,8 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
|
||||
}
|
||||
if (processOwner !== undefined) {
|
||||
this.#pendingCleanup = { root, process: processOwner };
|
||||
root = undefined;
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
EVMOLE_PROVIDER_IDENTITY.id,
|
||||
[
|
||||
processOwner.launch.ownership?.runId ?? "owned-worker",
|
||||
...(this.#pendingCleanup.root === undefined
|
||||
? []
|
||||
: [this.#pendingCleanup.root.path]),
|
||||
],
|
||||
{
|
||||
reason: "Owned EVM worker cleanup could not be confirmed",
|
||||
previous_error: result.ok
|
||||
? null
|
||||
: projectAnalysisError(result.error),
|
||||
...(retainedOutput === undefined
|
||||
? {}
|
||||
: { captured_output: { ...retainedOutput } }),
|
||||
},
|
||||
{ operation: OPERATION },
|
||||
),
|
||||
);
|
||||
// The command failure already owns this cleanup diagnostic and its resources.
|
||||
return result;
|
||||
}
|
||||
if (root !== undefined) {
|
||||
try {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { firmwareRequestSchema } from "../domain/firmware/firmwareAnalysis.js";
|
||||
import { finishFirmwareWorkspace } from "./FirmwareProvider.js";
|
||||
|
||||
const request = firmwareRequestSchema.parse({
|
||||
operation: "extract_firmware",
|
||||
input: {
|
||||
path: "/tmp/firmware.bin",
|
||||
output_directory: "/tmp/extracted",
|
||||
},
|
||||
});
|
||||
|
||||
const execution = ok({
|
||||
result: { files: ["kept"] },
|
||||
rawResult: null,
|
||||
provider: { id: "unblob", name: "unblob", version: "1" },
|
||||
limitations: [],
|
||||
locations: [],
|
||||
subject: null,
|
||||
});
|
||||
|
||||
describe("firmware workspace cleanup", () => {
|
||||
it("returns the completed extraction beside a cleanup failure and does not delete the leaked root", async () => {
|
||||
const close = vi.fn(async () => {
|
||||
throw new Error("cleanup failed");
|
||||
});
|
||||
const finished = await finishFirmwareWorkspace(
|
||||
{ path: "/tmp/runtime", close },
|
||||
execution,
|
||||
"unblob",
|
||||
request,
|
||||
);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
if (finished.ok || !(finished.error instanceof ProviderAdapterError))
|
||||
throw new Error("Expected cleanup failure");
|
||||
expect(finished.error.cleanupResources).toEqual([
|
||||
"/tmp/runtime",
|
||||
"/tmp/extracted",
|
||||
]);
|
||||
expect(finished.error.partialObservation).toEqual({
|
||||
kind: "firmware",
|
||||
result: { files: ["kept"] },
|
||||
});
|
||||
expect(finished.error.diagnostics).toEqual({ reason: "cleanup failed" });
|
||||
expect(projectAnalysisError(finished.error).details).toMatchObject({
|
||||
partial_observation: {
|
||||
kind: "firmware",
|
||||
result: { files: ["kept"] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not replay a cleanup failure for a later workspace", async () => {
|
||||
const failed = await finishFirmwareWorkspace(
|
||||
{
|
||||
path: "/tmp/runtime",
|
||||
close: async () => {
|
||||
throw new Error("cleanup failed");
|
||||
},
|
||||
},
|
||||
execution,
|
||||
"unblob",
|
||||
request,
|
||||
);
|
||||
expect(failed.ok).toBe(false);
|
||||
const next = await finishFirmwareWorkspace(
|
||||
{ path: "/tmp/next", close: async () => undefined },
|
||||
execution,
|
||||
"unblob",
|
||||
request,
|
||||
);
|
||||
expect(next).toBe(execution);
|
||||
});
|
||||
|
||||
it("leaves an uncertain workspace recorded and does not remove it", async () => {
|
||||
const close = vi.fn(async () => undefined);
|
||||
const cleanup = new ProviderCleanupError(
|
||||
"unblob",
|
||||
["/tmp/runtime"],
|
||||
{ reason: "ownership uncertain" },
|
||||
{ operation: "extract_firmware" },
|
||||
);
|
||||
const outcome = err(cleanup);
|
||||
const finished = await finishFirmwareWorkspace(
|
||||
{ path: "/tmp/runtime", close },
|
||||
outcome,
|
||||
"unblob",
|
||||
request,
|
||||
);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(finished).toBe(outcome);
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,16 @@ import { z } from "zod";
|
||||
import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnalysisPort.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisExecution,
|
||||
type ExecutionOptions,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import {
|
||||
firmwareResultSchemas,
|
||||
type FirmwareRequest,
|
||||
type FirmwarePartialObservation,
|
||||
} from "../domain/firmware/firmwareAnalysis.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
||||
@@ -42,6 +46,37 @@ import {
|
||||
} from "./FirmwareReports.js";
|
||||
|
||||
type Outcome = Awaited<ReturnType<FirmwareAnalysisPort["execute"]>>;
|
||||
interface PendingFirmwareCleanup {
|
||||
supervisor?: ProviderProcessSupervisor;
|
||||
readonly root: PrivateRuntimeRoot;
|
||||
readonly engine: string;
|
||||
readonly operation: string;
|
||||
}
|
||||
|
||||
interface FirmwareCleanupAttempt {
|
||||
readonly failure: ProviderCleanupError | undefined;
|
||||
readonly processUnverified: boolean;
|
||||
}
|
||||
|
||||
const combineCleanupFailures = (
|
||||
failures: readonly ProviderCleanupError[],
|
||||
): ProviderCleanupError | undefined => {
|
||||
if (failures.length < 2) return failures[0];
|
||||
return new ProviderCleanupError(
|
||||
"firmware",
|
||||
failures.flatMap((failure) => [...failure.cleanupResources]),
|
||||
{
|
||||
reason: "Multiple owned firmware resources could not be cleaned",
|
||||
failures: failures.map((failure) => ({
|
||||
engine: failure.providerId,
|
||||
operation: failure.operation,
|
||||
resources: [...failure.cleanupResources],
|
||||
reason: failure.diagnostics?.reason ?? failure.message,
|
||||
})),
|
||||
},
|
||||
{ operation: "close_firmware", cause: new AggregateError(failures) },
|
||||
);
|
||||
};
|
||||
const waitForPredecessor = (
|
||||
predecessor: Promise<void>,
|
||||
operation: string,
|
||||
@@ -67,14 +102,7 @@ const limitations = [
|
||||
export class FirmwareProvider implements FirmwareAnalysisPort {
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#closed = false;
|
||||
#pendingCleanup:
|
||||
| {
|
||||
supervisor?: ProviderProcessSupervisor;
|
||||
root: PrivateRuntimeRoot;
|
||||
engine: string;
|
||||
operation: string;
|
||||
}
|
||||
| undefined;
|
||||
readonly #pendingCleanup = new Map<string, PendingFirmwareCleanup>();
|
||||
constructor(
|
||||
readonly environment: Readonly<
|
||||
Record<string, string | undefined>
|
||||
@@ -107,53 +135,71 @@ export class FirmwareProvider implements FirmwareAnalysisPort {
|
||||
try {
|
||||
if (this.#closed)
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
try {
|
||||
await this.#retryCleanup();
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderCleanupError("firmware", [], {
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const cleanup = await this.#retryCleanup();
|
||||
if (cleanup.processUnverified && cleanup.failure !== undefined)
|
||||
return err(cleanup.failure);
|
||||
return await this.#execute(request, options);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry identity-bound cleanup before relinquishing private workspaces. */
|
||||
/** Retry every retained owner, reporting failures without discarding other roots. */
|
||||
async close(): Promise<void> {
|
||||
this.#closed = true;
|
||||
await this.#tail;
|
||||
await this.#retryCleanup();
|
||||
const cleanup = await this.#retryCleanup();
|
||||
if (cleanup.failure !== undefined) throw cleanup.failure;
|
||||
}
|
||||
|
||||
async #retryCleanup(): Promise<void> {
|
||||
const pending = this.#pendingCleanup;
|
||||
if (pending === undefined) return;
|
||||
const stopped = await pending.supervisor?.stop();
|
||||
if (stopped?.status === "incomplete")
|
||||
throw new ProviderCleanupError(
|
||||
pending.engine,
|
||||
[pending.root.path],
|
||||
{ reason: stopped.reason },
|
||||
{ operation: pending.operation },
|
||||
);
|
||||
delete pending.supervisor;
|
||||
try {
|
||||
await pending.root.close();
|
||||
} catch (cause: unknown) {
|
||||
throw new ProviderCleanupError(
|
||||
pending.engine,
|
||||
[pending.root.path],
|
||||
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
||||
{ operation: pending.operation, cause },
|
||||
);
|
||||
async #retryCleanup(): Promise<FirmwareCleanupAttempt> {
|
||||
const failures: ProviderCleanupError[] = [];
|
||||
let processUnverified = false;
|
||||
for (const [path, pending] of this.#pendingCleanup) {
|
||||
try {
|
||||
const stopped = await pending.supervisor?.stop();
|
||||
if (stopped?.status === "incomplete") {
|
||||
processUnverified = true;
|
||||
failures.push(
|
||||
new ProviderCleanupError(
|
||||
pending.engine,
|
||||
[path],
|
||||
{ reason: stopped.reason },
|
||||
{ operation: pending.operation },
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
delete pending.supervisor;
|
||||
} catch (cause: unknown) {
|
||||
processUnverified = true;
|
||||
failures.push(
|
||||
new ProviderCleanupError(
|
||||
pending.engine,
|
||||
[path],
|
||||
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
||||
{ operation: pending.operation, cause },
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await pending.root.close();
|
||||
this.#pendingCleanup.delete(path);
|
||||
} catch (cause: unknown) {
|
||||
// A verified stopped process cannot use this root. Keep ownership for
|
||||
// later removal without blocking a new operation's disjoint workspace.
|
||||
failures.push(
|
||||
new ProviderCleanupError(
|
||||
pending.engine,
|
||||
[path],
|
||||
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
||||
{ operation: pending.operation, cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
this.#pendingCleanup = undefined;
|
||||
return { failure: combineCleanupFailures(failures), processUnverified };
|
||||
}
|
||||
|
||||
async #execute(
|
||||
@@ -192,12 +238,12 @@ export class FirmwareProvider implements FirmwareAnalysisPort {
|
||||
environment: this.environment,
|
||||
signal,
|
||||
retainCleanup: (supervisor) => {
|
||||
this.#pendingCleanup = {
|
||||
this.#pendingCleanup.set(ownedRoot.path, {
|
||||
supervisor,
|
||||
root: ownedRoot,
|
||||
engine: engineName,
|
||||
operation: request.operation,
|
||||
};
|
||||
});
|
||||
},
|
||||
...(this.launcher === undefined ? {} : { launcher: this.launcher }),
|
||||
...(outputBudget === undefined
|
||||
@@ -386,12 +432,17 @@ export class FirmwareProvider implements FirmwareAnalysisPort {
|
||||
engineName,
|
||||
request,
|
||||
);
|
||||
if (!finished.ok && finished.error.cleanupIncomplete && root !== undefined)
|
||||
this.#pendingCleanup ??= {
|
||||
if (
|
||||
!finished.ok &&
|
||||
finished.error.cleanupIncomplete &&
|
||||
root !== undefined &&
|
||||
!this.#pendingCleanup.has(root.path)
|
||||
)
|
||||
this.#pendingCleanup.set(root.path, {
|
||||
root,
|
||||
engine: engineName,
|
||||
operation: request.operation,
|
||||
};
|
||||
});
|
||||
return finished;
|
||||
}
|
||||
}
|
||||
@@ -429,32 +480,53 @@ export const finishFirmwareWorkspace = async (
|
||||
try {
|
||||
await root?.close();
|
||||
} catch (cause: unknown) {
|
||||
const cleanup = new ProviderCleanupError(
|
||||
engineName,
|
||||
[
|
||||
root?.path ?? "unknown",
|
||||
...(outcome.ok && request.operation === "extract_firmware"
|
||||
? [request.input.output_directory]
|
||||
: []),
|
||||
],
|
||||
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
||||
{
|
||||
cause,
|
||||
operation: request.operation,
|
||||
...(outcome.ok
|
||||
? {
|
||||
partialObservation: completedFirmwareObservation(outcome.value),
|
||||
}
|
||||
: outcome.error.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: outcome.error.partialObservation }),
|
||||
},
|
||||
);
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
engineName,
|
||||
[
|
||||
root?.path ?? "unknown",
|
||||
...(outcome.ok && request.operation === "extract_firmware"
|
||||
? [request.input.output_directory]
|
||||
: []),
|
||||
],
|
||||
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
||||
{
|
||||
cause,
|
||||
operation: request.operation,
|
||||
...(outcome.ok
|
||||
? {
|
||||
partialObservation: {
|
||||
kind: "firmware",
|
||||
result: outcome.value.result,
|
||||
},
|
||||
}
|
||||
: outcome.error.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: outcome.error.partialObservation }),
|
||||
},
|
||||
),
|
||||
outcome.ok
|
||||
? cleanup
|
||||
: analysisErrorWithCleanupFailure(
|
||||
outcome.error,
|
||||
cleanup,
|
||||
request.operation,
|
||||
),
|
||||
);
|
||||
}
|
||||
return outcome;
|
||||
};
|
||||
|
||||
const completedFirmwareObservation = (
|
||||
execution: AnalysisExecution,
|
||||
): FirmwarePartialObservation => ({
|
||||
kind: "firmware",
|
||||
result: execution.result,
|
||||
provenance: jsonValueSchema.parse({
|
||||
raw_result: execution.rawResult,
|
||||
provider: execution.provider,
|
||||
subject: execution.subject,
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
...(execution.analysisProfile === undefined
|
||||
? {}
|
||||
: { analysis_profile: execution.analysisProfile }),
|
||||
}),
|
||||
});
|
||||
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
|
||||
serverSdkVersion: "2.3.1",
|
||||
clientSdkVersion: "2.3.1",
|
||||
coreSdkVersion: "2.3.1",
|
||||
skillVersion: "36",
|
||||
skillVersion: "37",
|
||||
} as const;
|
||||
@@ -24,7 +24,10 @@ import type {
|
||||
} from "./GhidraClientTypes.js";
|
||||
import type { GhidraInventoryOperation } from "./GhidraInventoryValues.js";
|
||||
import type { GhidraFunctionOperation } from "./GhidraFunctionValues.js";
|
||||
import { createGhidraDiagnostics } from "./GhidraDiagnostics.js";
|
||||
import {
|
||||
createGhidraDiagnostics,
|
||||
GHIDRA_PROCESS_DIAGNOSTIC_BYTES,
|
||||
} from "./GhidraDiagnostics.js";
|
||||
import { GhidraLaunchError, type GhidraLaunch } from "./GhidraLauncher.js";
|
||||
import { GhidraResponseBuffer } from "./GhidraResponseBuffer.js";
|
||||
import { GhidraResponseRouter } from "./GhidraResponseRouter.js";
|
||||
@@ -432,6 +435,7 @@ export class GhidraClient {
|
||||
if (partialLaunch !== undefined) {
|
||||
this.#launch = partialLaunch;
|
||||
this.#process = new ProviderProcessSupervisor(partialLaunch, {
|
||||
maxDiagnosticBytes: GHIDRA_PROCESS_DIAGNOSTIC_BYTES,
|
||||
onDiagnostic: (event) => this.#onProcessDiagnostic(event),
|
||||
});
|
||||
}
|
||||
@@ -444,6 +448,7 @@ export class GhidraClient {
|
||||
}
|
||||
this.#launch = launched.value;
|
||||
this.#process = new ProviderProcessSupervisor(launched.value, {
|
||||
maxDiagnosticBytes: GHIDRA_PROCESS_DIAGNOSTIC_BYTES,
|
||||
onDiagnostic: (event) => this.#onProcessDiagnostic(event),
|
||||
});
|
||||
const connected = await this.#connect(endpoint, deadline);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { redactCapturedTransportCredential } from "../process/ProviderDiagnosticRedaction.js";
|
||||
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
|
||||
import type { GhidraLaunch } from "./GhidraLauncher.js";
|
||||
import type { GhidraTransportKind } from "./GhidraTransport.js";
|
||||
|
||||
/** Aggregate in-memory retention budget for this provider's launcher output. */
|
||||
export const GHIDRA_PROCESS_DIAGNOSTIC_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Inputs needed to project bounded diagnostics for one Ghidra runtime. */
|
||||
export interface GhidraDiagnosticsOptions {
|
||||
readonly targetPath: string;
|
||||
@@ -31,10 +35,6 @@ export const createGhidraDiagnostics = (
|
||||
Object.keys(options.previous).length > 0
|
||||
)
|
||||
return options.previous;
|
||||
const redacted = (value: string): string =>
|
||||
options.token === undefined
|
||||
? value
|
||||
: value.replaceAll(options.token, "[REDACTED]");
|
||||
return {
|
||||
target_path: options.targetPath,
|
||||
target_sha256: options.targetSha256,
|
||||
@@ -67,16 +67,19 @@ export const createGhidraDiagnostics = (
|
||||
: {
|
||||
exit_code: options.snapshot.exitCode ?? null,
|
||||
exit_signal: options.snapshot.signal ?? null,
|
||||
stdout: boundedStream(options.snapshot.stdout, redacted),
|
||||
stderr: boundedStream(options.snapshot.stderr, redacted),
|
||||
diagnostic_truncated: options.snapshot.diagnosticTruncated === true,
|
||||
stdout: boundedStream(options.snapshot.stdout, options.token),
|
||||
stderr: boundedStream(options.snapshot.stderr, options.token),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const boundedStream = (
|
||||
stream: ProviderProcessSnapshot["stdout"],
|
||||
redact: (value: string) => string,
|
||||
token: string | undefined,
|
||||
): JsonValue => ({
|
||||
text: redact(stream.text),
|
||||
bytes: stream.bytes,
|
||||
text: redactCapturedTransportCredential(stream, token, "[REDACTED]"),
|
||||
// Preserve the historical total-byte counter; text is now bounded separately.
|
||||
bytes: stream.observedBytes,
|
||||
retained_bytes: stream.bytes,
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import { silentLogger } from "../logger.js";
|
||||
import type { Logger } from "pino";
|
||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
|
||||
import { redactCapturedTransportCredential } from "../process/ProviderDiagnosticRedaction.js";
|
||||
import { ProviderRunLineage } from "../process/ProviderRunLineage.js";
|
||||
import {
|
||||
type ProviderProcessDiagnostic,
|
||||
@@ -64,6 +65,9 @@ import {
|
||||
responseResult,
|
||||
} from "./protocol.js";
|
||||
|
||||
/** Aggregate in-memory retention budget for this provider's launcher output. */
|
||||
export const HOPPER_PROCESS_DIAGNOSTIC_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 30_000;
|
||||
const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir();
|
||||
|
||||
@@ -609,15 +613,27 @@ export class HopperClient {
|
||||
const snapshot = this.#resources.processSupervisor?.snapshot();
|
||||
const token = this.#token;
|
||||
if (snapshot === undefined) return undefined;
|
||||
const redact = (text: string): string =>
|
||||
token === undefined
|
||||
? text
|
||||
: text.replaceAll(token, "[redacted transport credential]");
|
||||
return {
|
||||
exit_code: snapshot.exitCode ?? null,
|
||||
signal: snapshot.signal ?? null,
|
||||
stdout: { ...snapshot.stdout, text: redact(snapshot.stdout.text) },
|
||||
stderr: { ...snapshot.stderr, text: redact(snapshot.stderr.text) },
|
||||
stdout: {
|
||||
text: redactCapturedTransportCredential(
|
||||
snapshot.stdout,
|
||||
token,
|
||||
"[redacted transport credential]",
|
||||
),
|
||||
bytes: snapshot.stdout.observedBytes,
|
||||
retained_bytes: snapshot.stdout.bytes,
|
||||
},
|
||||
stderr: {
|
||||
text: redactCapturedTransportCredential(
|
||||
snapshot.stderr,
|
||||
token,
|
||||
"[redacted transport credential]",
|
||||
),
|
||||
bytes: snapshot.stderr.observedBytes,
|
||||
retained_bytes: snapshot.stderr.bytes,
|
||||
},
|
||||
output_closed: outputClosed,
|
||||
diagnostic_truncated: snapshot.diagnosticTruncated === true,
|
||||
};
|
||||
@@ -743,6 +759,7 @@ export class HopperClient {
|
||||
|
||||
#attachLauncher(launch: BridgeLaunch): void {
|
||||
this.#resources.processSupervisor = new ProviderProcessSupervisor(launch, {
|
||||
maxDiagnosticBytes: HOPPER_PROCESS_DIAGNOSTIC_BYTES,
|
||||
onDiagnostic: (event) => this.#onLauncherDiagnostic(launch, event),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -262,11 +262,17 @@ export const createDarwinProcessRunTokenReader = (
|
||||
|
||||
const read = async (
|
||||
processes: readonly ProcessTableEntry[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReadonlyMap<number, ProcessRunTokenObservation>> => {
|
||||
signal?.throwIfAborted();
|
||||
if (processes.length === 0) return new Map();
|
||||
const binary = await compile();
|
||||
const binary = await compile(signal);
|
||||
const pids = processes.map(({ pid }) => String(pid));
|
||||
const { stdout } = await execFileOutput(binary, pids, { timeout: 15_000 });
|
||||
const { stdout } = await execFileOutput(binary, pids, {
|
||||
timeout: 15_000,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
const decoded = safeParseJson(stdout);
|
||||
if (!decoded.ok)
|
||||
throw new Error(
|
||||
|
||||
@@ -377,7 +377,7 @@ describe("process identity during token validation", () => {
|
||||
expect(reusedDuringFinalRead).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed when a candidate PID changes identity during token reading", async () => {
|
||||
it("fails closed when candidate PIDs change identity during token reading", async () => {
|
||||
let identityReads = 0;
|
||||
const process: ProcessTableEntry = {
|
||||
pid: 911,
|
||||
@@ -386,8 +386,10 @@ describe("process identity during token validation", () => {
|
||||
state: "S",
|
||||
command: "reused-during-token-read",
|
||||
};
|
||||
const processes = [process, { ...process, pid: 912, processGroupId: 912 }];
|
||||
const listProcesses = vi.fn(async () => processes);
|
||||
const host: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve([process]),
|
||||
listProcesses,
|
||||
environment: () => Promise.resolve({}),
|
||||
processIdentities: (entries) => {
|
||||
identityReads += 1;
|
||||
@@ -414,21 +416,24 @@ describe("process identity during token validation", () => {
|
||||
};
|
||||
|
||||
await expect(
|
||||
verifyNoTokenOwnedProcesses("run-token", host, [
|
||||
{ pid: process.pid, identity: "old-start" },
|
||||
]),
|
||||
verifyNoTokenOwnedProcesses(
|
||||
"run-token",
|
||||
host,
|
||||
processes.map(({ pid }) => ({ pid, identity: "old-start" })),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
cleaned: false,
|
||||
reason: "process identity could not be revalidated",
|
||||
failures: [
|
||||
{
|
||||
pid: process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic:
|
||||
"process identity changed or became unavailable during token validation",
|
||||
},
|
||||
],
|
||||
failures: processes.map(({ pid }) => ({
|
||||
pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic:
|
||||
"process identity changed or became unavailable during token validation",
|
||||
})),
|
||||
});
|
||||
// Initial observation, final token classification, then one fresh recheck
|
||||
// shared by both changed identities.
|
||||
expect(listProcesses).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
type ProcessOwnershipHost,
|
||||
} from "./ProcessOwnership.js";
|
||||
import {
|
||||
observeOwnedProcessGroups,
|
||||
observeOwnedProcessLineage,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
import { host, ownership } from "./ProcessOwnership.fixture.js";
|
||||
|
||||
const processes = [100, 101].map((pid) => ({
|
||||
pid,
|
||||
parentPid: pid === 100 ? 1 : 100,
|
||||
processGroupId: 100,
|
||||
state: "S",
|
||||
command: "fixture",
|
||||
}));
|
||||
|
||||
describe("multi-group ownership observation", () => {
|
||||
it("observes multiple groups from one process snapshot and token batch", async () => {
|
||||
const rows = [
|
||||
...processes,
|
||||
{
|
||||
pid: 200,
|
||||
parentPid: 1,
|
||||
processGroupId: 200,
|
||||
state: "S",
|
||||
command: "other-owned-group",
|
||||
},
|
||||
{
|
||||
pid: 300,
|
||||
parentPid: 1,
|
||||
processGroupId: 300,
|
||||
state: "Z",
|
||||
command: "[exited]",
|
||||
},
|
||||
];
|
||||
const listProcesses = vi.fn(() => Promise.resolve(rows));
|
||||
const environment = vi.fn(() => Promise.resolve({}));
|
||||
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
|
||||
(members) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
members.map(({ pid }) => [
|
||||
pid,
|
||||
{
|
||||
state: "readable" as const,
|
||||
runId: pid === 200 ? "other-run" : ownership.runId,
|
||||
},
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
observeOwnedProcessGroups(ownership.runId, [100, 200, 300], {
|
||||
listProcesses,
|
||||
environment,
|
||||
runTokens,
|
||||
signalGroup: vi.fn(),
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
new Map([
|
||||
[100, { state: "alive" }],
|
||||
[
|
||||
200,
|
||||
{ state: "unverifiable", reason: "process ownership did not match" },
|
||||
],
|
||||
[300, { state: "empty" }],
|
||||
]),
|
||||
);
|
||||
expect(listProcesses).toHaveBeenCalledTimes(1);
|
||||
expect(runTokens).toHaveBeenCalledTimes(1);
|
||||
expect(runTokens).toHaveBeenCalledWith(
|
||||
rows.filter(({ state }) => state !== "Z"),
|
||||
);
|
||||
expect(environment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks unavailable members individually before classifying their groups", async () => {
|
||||
const rows = [
|
||||
...processes,
|
||||
{
|
||||
pid: 200,
|
||||
parentPid: 1,
|
||||
processGroupId: 200,
|
||||
state: "S",
|
||||
command: "unreadable-group",
|
||||
},
|
||||
];
|
||||
const listProcesses = vi
|
||||
.fn<ProcessOwnershipHost["listProcesses"]>()
|
||||
.mockResolvedValueOnce(rows)
|
||||
.mockResolvedValueOnce(rows.filter(({ pid }) => pid !== 101))
|
||||
.mockResolvedValueOnce(rows);
|
||||
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
|
||||
(members) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
members.map(({ pid }) => [
|
||||
pid,
|
||||
pid === 101 || pid === 200
|
||||
? {
|
||||
state: "unavailable" as const,
|
||||
reason: `unreadable-${String(pid)}`,
|
||||
}
|
||||
: { state: "readable" as const, runId: ownership.runId },
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
observeOwnedProcessGroups(ownership.runId, [100, 200], {
|
||||
listProcesses,
|
||||
environment: vi.fn(() => Promise.resolve({})),
|
||||
runTokens,
|
||||
signalGroup: vi.fn(),
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
new Map([
|
||||
[100, { state: "alive" }],
|
||||
[
|
||||
200,
|
||||
{
|
||||
state: "unverifiable",
|
||||
reason:
|
||||
"process ownership could not be revalidated for PID 200: unreadable-200",
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
expect(listProcesses).toHaveBeenCalledTimes(3);
|
||||
expect(runTokens).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-group observation short-circuit", () => {
|
||||
it("stops missing-row fallbacks once every live group is unverifiable", async () => {
|
||||
const listProcesses = vi.fn(() => Promise.resolve(processes));
|
||||
const environment = vi.fn((pid: number) =>
|
||||
Promise.resolve({
|
||||
REA_PROCESS_RUN_ID: pid === 100 ? "other-run" : ownership.runId,
|
||||
}),
|
||||
);
|
||||
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
|
||||
() => Promise.resolve(new Map()),
|
||||
);
|
||||
|
||||
await expect(
|
||||
observeOwnedProcessGroups(ownership.runId, [100], {
|
||||
listProcesses,
|
||||
environment,
|
||||
runTokens,
|
||||
signalGroup: vi.fn(),
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
new Map([
|
||||
[
|
||||
100,
|
||||
{ state: "unverifiable", reason: "process ownership did not match" },
|
||||
],
|
||||
]),
|
||||
);
|
||||
expect(runTokens).toHaveBeenCalledTimes(1);
|
||||
expect(environment.mock.calls).toEqual([[100]]);
|
||||
expect(listProcesses).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("batched ownership observation", () => {
|
||||
it("observes group and lineage with one batch each and falls back only for missing rows", async () => {
|
||||
const { adapter } = host({
|
||||
100: { REA_PROCESS_RUN_ID: ownership.runId },
|
||||
101: { REA_PROCESS_RUN_ID: ownership.runId },
|
||||
});
|
||||
const environment = vi.fn(adapter.environment);
|
||||
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
new Map([[100, { state: "readable", runId: ownership.runId }]]),
|
||||
),
|
||||
);
|
||||
const observedHost = { ...adapter, environment, runTokens };
|
||||
await expect(
|
||||
observeOwnedProcessGroups(
|
||||
ownership.runId,
|
||||
[ownership.processGroupId],
|
||||
observedHost,
|
||||
),
|
||||
).resolves.toEqual(
|
||||
new Map([[ownership.processGroupId, { state: "alive" }]]),
|
||||
);
|
||||
await expect(
|
||||
observeOwnedProcessLineage(ownership, observedHost),
|
||||
).resolves.toMatchObject({
|
||||
status: "verified",
|
||||
lineage: {
|
||||
descendants: [{ pid: 101, parentPid: 100, processGroupId: 100 }],
|
||||
},
|
||||
});
|
||||
expect(runTokens.mock.calls).toEqual([[processes], [processes]]);
|
||||
expect(environment.mock.calls).toEqual([[101], [101]]);
|
||||
});
|
||||
|
||||
it.each(["batch", "environment"] as const)(
|
||||
"preserves cancellation during %s reads, including successful completion races",
|
||||
async (boundary) => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancelled ownership read");
|
||||
const listProcesses = vi.fn(() => Promise.resolve(processes));
|
||||
const environment = vi.fn<ProcessOwnershipHost["environment"]>(
|
||||
async (_pid, signal) => {
|
||||
expect(signal).toBe(controller.signal);
|
||||
controller.abort(reason);
|
||||
return { REA_PROCESS_RUN_ID: ownership.runId };
|
||||
},
|
||||
);
|
||||
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
|
||||
async (_processes, signal) => {
|
||||
expect(signal).toBe(controller.signal);
|
||||
if (boundary === "batch") controller.abort(reason);
|
||||
return new Map();
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
observeOwnedProcessGroups(
|
||||
ownership.runId,
|
||||
[ownership.processGroupId],
|
||||
{ listProcesses, environment, runTokens, signalGroup: vi.fn() },
|
||||
controller.signal,
|
||||
),
|
||||
).rejects.toBe(reason);
|
||||
expect(listProcesses).toHaveBeenCalledTimes(1);
|
||||
expect(environment).toHaveBeenCalledTimes(boundary === "batch" ? 0 : 1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([100, 101])(
|
||||
"rechecks unavailable batch rows for exited PID %i without overriding them through fallback",
|
||||
async (exitedPid) => {
|
||||
const listProcesses = vi
|
||||
.fn<ProcessOwnershipHost["listProcesses"]>()
|
||||
.mockResolvedValueOnce(processes)
|
||||
.mockResolvedValue(processes.filter(({ pid }) => pid !== exitedPid));
|
||||
const environment = vi.fn(() =>
|
||||
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
|
||||
);
|
||||
const observedHost: ProcessOwnershipHost = {
|
||||
listProcesses,
|
||||
environment,
|
||||
signalGroup: vi.fn(),
|
||||
runTokens: () =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
processes.map(({ pid }) => [
|
||||
pid,
|
||||
pid === exitedPid
|
||||
? { state: "unavailable" as const, reason: "process exited" }
|
||||
: { state: "readable" as const, runId: ownership.runId },
|
||||
]),
|
||||
),
|
||||
),
|
||||
};
|
||||
const result = await observeOwnedProcessLineage(ownership, observedHost);
|
||||
if (exitedPid === 100)
|
||||
expect(result).toMatchObject({
|
||||
status: "unavailable",
|
||||
reason: "owned launcher exited during lineage validation",
|
||||
});
|
||||
else
|
||||
expect(result).toMatchObject({
|
||||
status: "verified",
|
||||
lineage: { descendants: [] },
|
||||
});
|
||||
expect(environment).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("token sweep batch failures", () => {
|
||||
it("cleans token-owned groups when a failed batch has readable fallback observations", async () => {
|
||||
const { adapter, signalGroup } = host({
|
||||
100: { REA_PROCESS_RUN_ID: ownership.runId },
|
||||
101: { REA_PROCESS_RUN_ID: ownership.runId },
|
||||
});
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(
|
||||
{ ...ownership, sweepTokenOwnedProcesses: true, captureBaseline: [] },
|
||||
{
|
||||
...adapter,
|
||||
runTokens: () =>
|
||||
Promise.reject(new Error("batch reader unavailable")),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ cleaned: true, signaled: true });
|
||||
expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]);
|
||||
});
|
||||
|
||||
it("uses fallback ownership when the initial batch fails and preserves both failures through retries", async () => {
|
||||
const signalGroup = vi.fn();
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve(processes),
|
||||
environment: (pid) =>
|
||||
pid === 100
|
||||
? Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId })
|
||||
: Promise.reject(new Error("individual unreadable")),
|
||||
runTokens: () => Promise.reject(new Error("batch reader unavailable")),
|
||||
signalGroup,
|
||||
};
|
||||
const result = await cleanupOwnedProcessGroup(
|
||||
{ ...ownership, sweepTokenOwnedProcesses: true, captureBaseline: [] },
|
||||
adapter,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
cleaned: false,
|
||||
failures: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pid: 101,
|
||||
diagnostic:
|
||||
"individual unreadable; run-token batch failed: batch reader unavailable",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+93
-103
@@ -7,6 +7,7 @@ import {
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
|
||||
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
|
||||
import { readProcessRunTokens } from "./ProcessRunTokenObservations.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -90,10 +91,14 @@ export interface ProcessOwnershipHost {
|
||||
close?(): Promise<void>;
|
||||
/** List current processes; startup inspection may be cancelled by the caller. */
|
||||
listProcesses(signal?: AbortSignal): Promise<readonly ProcessTableEntry[]>;
|
||||
environment(pid: number): Promise<Readonly<Record<string, string>>>;
|
||||
environment(
|
||||
pid: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Readonly<Record<string, string>>>;
|
||||
/** Read only run-token values in one host operation when the OS supports it. */
|
||||
runTokens?(
|
||||
processes: readonly ProcessTableEntry[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReadonlyMap<number, ProcessRunTokenObservation>>;
|
||||
/** Read stable identities; omit the signal during cleanup so it can finish. */
|
||||
processIdentities?(
|
||||
@@ -623,6 +628,7 @@ const processOwnershipFailures = async (
|
||||
expectedIdentities.has(pid),
|
||||
);
|
||||
const observed = await host.processIdentities(expectedMembers);
|
||||
const identityFailures: ProcessOwnershipValidationFailure[] = [];
|
||||
for (const member of expectedMembers) {
|
||||
const expected = expectedIdentities.get(member.pid);
|
||||
const current = observed.get(member.pid);
|
||||
@@ -631,8 +637,7 @@ const processOwnershipFailures = async (
|
||||
current?.state !== "readable" ||
|
||||
current.identity !== expected
|
||||
) {
|
||||
if (await processIsGone(host, member.pid)) continue;
|
||||
failures.push({
|
||||
identityFailures.push({
|
||||
pid: member.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic:
|
||||
@@ -640,30 +645,9 @@ const processOwnershipFailures = async (
|
||||
});
|
||||
}
|
||||
}
|
||||
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
|
||||
}
|
||||
for (const member of members) {
|
||||
try {
|
||||
if ((await host.environment(member.pid)).REA_PROCESS_RUN_ID !== runId)
|
||||
failures.push({ pid: member.pid, reason: "run-token-mismatch" });
|
||||
} catch (cause: unknown) {
|
||||
try {
|
||||
const live = liveProcesses(await host.listProcesses());
|
||||
if (!live.some(({ pid }) => pid === member.pid)) continue;
|
||||
} catch (recheckCause: unknown) {
|
||||
failures.push({
|
||||
pid: member.pid,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic: `${errorMessage(cause)}; process liveness recheck failed: ${errorMessage(recheckCause)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
failures.push({
|
||||
pid: member.pid,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic: errorMessage(cause),
|
||||
});
|
||||
}
|
||||
}
|
||||
failures.push(...(await processRunTokenFailures(members, runId, host)));
|
||||
if (
|
||||
failures.length === 0 &&
|
||||
expectedIdentities.size > 0 &&
|
||||
@@ -673,6 +657,7 @@ const processOwnershipFailures = async (
|
||||
expectedIdentities.has(pid),
|
||||
);
|
||||
const observed = await host.processIdentities(expectedMembers);
|
||||
const identityFailures: ProcessOwnershipValidationFailure[] = [];
|
||||
for (const member of expectedMembers) {
|
||||
const expected = expectedIdentities.get(member.pid);
|
||||
const current = observed.get(member.pid);
|
||||
@@ -682,18 +667,72 @@ const processOwnershipFailures = async (
|
||||
current.identity === expected
|
||||
)
|
||||
continue;
|
||||
if (await processIsGone(host, member.pid)) continue;
|
||||
failures.push({
|
||||
identityFailures.push({
|
||||
pid: member.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic:
|
||||
"process identity changed or became unavailable during token validation",
|
||||
});
|
||||
}
|
||||
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
|
||||
}
|
||||
return failures;
|
||||
};
|
||||
|
||||
const processRunTokenFailures = async (
|
||||
members: readonly ProcessTableEntry[],
|
||||
runId: string,
|
||||
host: ProcessOwnershipHost,
|
||||
): Promise<readonly ProcessOwnershipValidationFailure[]> => {
|
||||
const failures: ProcessOwnershipValidationFailure[] = [];
|
||||
for await (const { process: member, observation } of readProcessRunTokens(
|
||||
host,
|
||||
members,
|
||||
)) {
|
||||
if (observation.state === "readable") {
|
||||
if (observation.runId !== runId)
|
||||
failures.push({ pid: member.pid, reason: "run-token-mismatch" });
|
||||
continue;
|
||||
}
|
||||
failures.push({
|
||||
pid: member.pid,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic: observation.reason,
|
||||
});
|
||||
}
|
||||
return recheckOwnershipFailures(failures, host);
|
||||
};
|
||||
|
||||
/**
|
||||
* Take a fresh liveness snapshot after each ownership-read phase. Only an
|
||||
* absent PID resolves an unreadable observation; a readable token mismatch
|
||||
* remains a failure. Never reuse this snapshot across identity/token reads.
|
||||
*/
|
||||
const recheckOwnershipFailures = async (
|
||||
failures: readonly ProcessOwnershipValidationFailure[],
|
||||
host: ProcessOwnershipHost,
|
||||
): Promise<readonly ProcessOwnershipValidationFailure[]> => {
|
||||
if (!failures.some(({ reason }) => reason !== "run-token-mismatch"))
|
||||
return failures;
|
||||
try {
|
||||
const live = new Set(
|
||||
liveProcesses(await host.listProcesses()).map(({ pid }) => pid),
|
||||
);
|
||||
return failures.filter(
|
||||
({ pid, reason }) => reason === "run-token-mismatch" || live.has(pid),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return failures.map((failure) =>
|
||||
failure.reason === "run-token-mismatch"
|
||||
? failure
|
||||
: {
|
||||
...failure,
|
||||
diagnostic: `${failure.diagnostic}; process liveness recheck failed: ${errorMessage(cause)}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface TokenOwnedProcessScan {
|
||||
readonly owned: readonly ProcessTableEntry[];
|
||||
readonly failures: readonly ProcessOwnershipValidationFailure[];
|
||||
@@ -767,18 +806,19 @@ const scanTokenOwnedProcesses = async (
|
||||
const failures: ProcessOwnershipValidationFailure[] = [];
|
||||
if (captureBaseline !== undefined && host.processIdentities !== undefined) {
|
||||
const stableCandidates: ProcessTableEntry[] = [];
|
||||
const identityFailures: ProcessOwnershipValidationFailure[] = [];
|
||||
for (const process of candidates) {
|
||||
if (candidateIdentities.get(process.pid)?.state === "readable") {
|
||||
stableCandidates.push(process);
|
||||
continue;
|
||||
}
|
||||
if (!(await processIsGone(host, process.pid)))
|
||||
failures.push({
|
||||
pid: process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic: "process identity was unavailable during token scan",
|
||||
});
|
||||
identityFailures.push({
|
||||
pid: process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic: "process identity was unavailable during token scan",
|
||||
});
|
||||
}
|
||||
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
|
||||
candidates = stableCandidates;
|
||||
}
|
||||
const owned: ProcessTableEntry[] = [];
|
||||
@@ -791,26 +831,14 @@ const scanTokenOwnedProcesses = async (
|
||||
identity?.state === "readable" ? identity.identity : null,
|
||||
);
|
||||
};
|
||||
let bulkTokens: ReadonlyMap<number, ProcessRunTokenObservation> | undefined;
|
||||
if (host.runTokens !== undefined)
|
||||
bulkTokens = await host.runTokens(candidates);
|
||||
const unreadable: UnreadableTokenCandidate[] = [];
|
||||
for (const process of candidates) {
|
||||
const bulkToken = bulkTokens?.get(process.pid);
|
||||
if (bulkToken !== undefined) {
|
||||
if (bulkToken.state === "readable") {
|
||||
if (bulkToken.runId === runId) recordOwned(process);
|
||||
continue;
|
||||
}
|
||||
unreadable.push({ process, diagnostic: bulkToken.reason });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if ((await host.environment(process.pid)).REA_PROCESS_RUN_ID === runId)
|
||||
recordOwned(process);
|
||||
} catch (cause: unknown) {
|
||||
unreadable.push({ process, diagnostic: errorMessage(cause) });
|
||||
}
|
||||
for await (const { process, observation } of readProcessRunTokens(
|
||||
host,
|
||||
candidates,
|
||||
)) {
|
||||
if (observation.state === "readable") {
|
||||
if (observation.runId === runId) recordOwned(process);
|
||||
} else unreadable.push({ process, diagnostic: observation.reason });
|
||||
}
|
||||
const remaining = await settleUnreadableTokenCandidates(
|
||||
unreadable,
|
||||
@@ -876,6 +904,7 @@ const scanTokenOwnedProcesses = async (
|
||||
if (candidates.length > 0 && host.processIdentities !== undefined) {
|
||||
const afterRead = await host.processIdentities(candidates);
|
||||
const stillOwned: ProcessTableEntry[] = [];
|
||||
const identityFailures: ProcessOwnershipValidationFailure[] = [];
|
||||
const ownedPids = new Set(owned.map(({ pid }) => pid));
|
||||
for (const process of candidates) {
|
||||
const before = candidateIdentities.get(process.pid);
|
||||
@@ -888,14 +917,14 @@ const scanTokenOwnedProcesses = async (
|
||||
if (ownedPids.has(process.pid)) stillOwned.push(process);
|
||||
continue;
|
||||
}
|
||||
if (await processIsGone(host, process.pid)) continue;
|
||||
failures.push({
|
||||
identityFailures.push({
|
||||
pid: process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic:
|
||||
"process identity changed or became unavailable during token validation",
|
||||
});
|
||||
}
|
||||
failures.push(...(await recheckOwnershipFailures(identityFailures, host)));
|
||||
return {
|
||||
owned: stillOwned,
|
||||
failures,
|
||||
@@ -936,31 +965,20 @@ const settleUnreadableTokenCandidates = async (
|
||||
void cause;
|
||||
live = undefined;
|
||||
}
|
||||
let observations:
|
||||
| ReadonlyMap<number, ProcessRunTokenObservation>
|
||||
| undefined;
|
||||
if (host.runTokens !== undefined) {
|
||||
try {
|
||||
observations = await host.runTokens(
|
||||
remaining.map(({ process }) => process),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
observations = undefined;
|
||||
}
|
||||
}
|
||||
const stillUnreadable: UnreadableTokenCandidate[] = [];
|
||||
for (const candidate of remaining) {
|
||||
const pid = candidate.process.pid;
|
||||
if (live !== undefined && !live.has(pid)) continue;
|
||||
const observation =
|
||||
observations?.get(pid) ?? (await readEnvironmentRunToken(host, pid));
|
||||
const candidates = remaining
|
||||
.map(({ process }) => process)
|
||||
.filter(({ pid }) => live === undefined || live.has(pid));
|
||||
for await (const { process, observation } of readProcessRunTokens(
|
||||
host,
|
||||
candidates,
|
||||
)) {
|
||||
if (observation.state === "readable") {
|
||||
if (observation.runId === runId) recordOwned(candidate.process);
|
||||
if (observation.runId === runId) recordOwned(process);
|
||||
continue;
|
||||
}
|
||||
stillUnreadable.push({
|
||||
process: candidate.process,
|
||||
process,
|
||||
diagnostic: observation.reason,
|
||||
});
|
||||
}
|
||||
@@ -968,31 +986,3 @@ const settleUnreadableTokenCandidates = async (
|
||||
}
|
||||
return remaining;
|
||||
};
|
||||
|
||||
const readEnvironmentRunToken = async (
|
||||
host: ProcessOwnershipHost,
|
||||
pid: number,
|
||||
): Promise<ProcessRunTokenObservation> => {
|
||||
try {
|
||||
return {
|
||||
state: "readable",
|
||||
runId: (await host.environment(pid)).REA_PROCESS_RUN_ID,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
return { state: "unavailable", reason: errorMessage(cause) };
|
||||
}
|
||||
};
|
||||
|
||||
const processIsGone = async (
|
||||
host: ProcessOwnershipHost,
|
||||
pid: number,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
return !liveProcesses(await host.listProcesses()).some(
|
||||
(process) => process.pid === pid,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type WindowsProcessTreeHost,
|
||||
} from "./ProcessOwnership.js";
|
||||
import {
|
||||
observeOwnedProcessGroup,
|
||||
observeOwnedProcessGroups,
|
||||
observeOwnedProcessLineage,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
import { host, ownership } from "./ProcessOwnership.fixture.js";
|
||||
@@ -115,6 +115,101 @@ describe("owned process-group cleanup validation: ownership and lineage", () =>
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("owned process-group cleanup liveness rechecks", () => {
|
||||
it("rechecks all unreadable members after the token phase in one fresh snapshot", async () => {
|
||||
const members = [100, 101, 102, 103].map((pid) => ({
|
||||
pid,
|
||||
parentPid: pid === 100 ? 1 : 100,
|
||||
processGroupId: 100,
|
||||
state: "S",
|
||||
command: "fixture",
|
||||
}));
|
||||
const tokenReads: number[] = [];
|
||||
const listProcesses = vi.fn(async () => {
|
||||
if (tokenReads.length > 0)
|
||||
expect(tokenReads).toEqual([100, 101, 102, 103]);
|
||||
return members;
|
||||
});
|
||||
const signalGroup = vi.fn();
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses,
|
||||
environment: async (pid) => {
|
||||
tokenReads.push(pid);
|
||||
throw new Error(`unreadable ${pid}`);
|
||||
},
|
||||
signalGroup,
|
||||
};
|
||||
await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual(
|
||||
{
|
||||
cleaned: false,
|
||||
reason:
|
||||
"process ownership token could not be read for 4 live process(es): other_unavailable=4; live candidates 100=other_unavailable, 101=other_unavailable, 102=other_unavailable, 103=other_unavailable",
|
||||
failures: members.map(({ pid }) => ({
|
||||
pid,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic: `unreadable ${pid}`,
|
||||
})),
|
||||
},
|
||||
);
|
||||
expect(listProcesses).toHaveBeenCalledTimes(2);
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"retains mismatches and fails closed when the liveness recheck fails: %s",
|
||||
async (recheckFails) => {
|
||||
const members = [100, 101, 102].map((pid) => ({
|
||||
pid,
|
||||
parentPid: pid === 100 ? 1 : 100,
|
||||
processGroupId: 100,
|
||||
state: "S",
|
||||
command: "fixture",
|
||||
}));
|
||||
const listProcesses = vi
|
||||
.fn<ProcessOwnershipHost["listProcesses"]>()
|
||||
.mockResolvedValueOnce(members);
|
||||
if (recheckFails)
|
||||
listProcesses.mockRejectedValue(new Error("process table denied"));
|
||||
else listProcesses.mockResolvedValue([]);
|
||||
const signalGroup = vi.fn();
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses,
|
||||
environment: async (pid) => {
|
||||
if (pid === 101) return { REA_PROCESS_RUN_ID: "other-run" };
|
||||
throw new Error(`unreadable ${pid}`);
|
||||
},
|
||||
signalGroup,
|
||||
};
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(ownership, adapter),
|
||||
).resolves.toEqual({
|
||||
cleaned: false,
|
||||
reason: "process tree contains an unowned or PID-reused process",
|
||||
failures: recheckFails
|
||||
? [
|
||||
{
|
||||
pid: 100,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic:
|
||||
"unreadable 100; process liveness recheck failed: process table denied",
|
||||
},
|
||||
{ pid: 101, reason: "run-token-mismatch" },
|
||||
{
|
||||
pid: 102,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic:
|
||||
"unreadable 102; process liveness recheck failed: process table denied",
|
||||
},
|
||||
]
|
||||
: [{ pid: 101, reason: "run-token-mismatch" }],
|
||||
});
|
||||
expect(listProcesses).toHaveBeenCalledTimes(2);
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts a member that exits during ownership revalidation", async () => {
|
||||
const liveLauncher = {
|
||||
pid: 100,
|
||||
@@ -160,7 +255,12 @@ describe("owned process-group cleanup validation: exited members", () => {
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
observeOwnedProcessGroup(ownership, adapter, controller.signal),
|
||||
observeOwnedProcessGroups(
|
||||
ownership.runId,
|
||||
[ownership.processGroupId],
|
||||
adapter,
|
||||
controller.signal,
|
||||
),
|
||||
).rejects.toBe(reason);
|
||||
});
|
||||
it("does not inspect a process group after observation was cancelled", async () => {
|
||||
@@ -177,7 +277,12 @@ describe("owned process-group cleanup validation: exited members", () => {
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
observeOwnedProcessGroup(ownership, adapter, controller.signal),
|
||||
observeOwnedProcessGroups(
|
||||
ownership.runId,
|
||||
[ownership.processGroupId],
|
||||
adapter,
|
||||
controller.signal,
|
||||
),
|
||||
).rejects.toBe(reason);
|
||||
});
|
||||
it("ignores exited zombie members during live ownership checks", async () => {
|
||||
@@ -229,9 +334,13 @@ describe("owned process-group cleanup validation: exited members", () => {
|
||||
environment,
|
||||
signalGroup: vi.fn(),
|
||||
};
|
||||
expect(await observeOwnedProcessGroup(ownership, adapter)).toEqual({
|
||||
state: "empty",
|
||||
});
|
||||
expect(
|
||||
await observeOwnedProcessGroups(
|
||||
ownership.runId,
|
||||
[ownership.processGroupId],
|
||||
adapter,
|
||||
),
|
||||
).toEqual(new Map([[ownership.processGroupId, { state: "empty" }]]));
|
||||
expect(environment).not.toHaveBeenCalled();
|
||||
});
|
||||
it("is idempotent when the owned group has already exited", async () => {
|
||||
@@ -278,6 +387,125 @@ describe("owned process-group cleanup validation: exited members", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("owned process-group cleanup token batches", () => {
|
||||
const root = {
|
||||
pid: 100,
|
||||
parentPid: 1,
|
||||
processGroupId: 100,
|
||||
state: "S",
|
||||
command: "fixture",
|
||||
};
|
||||
|
||||
it("batches validation and falls back only for a missing member row", async () => {
|
||||
const child = { ...root, pid: 101, parentPid: root.pid };
|
||||
const runTokens = vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Map([
|
||||
[root.pid, { state: "readable" as const, runId: ownership.runId }],
|
||||
]),
|
||||
),
|
||||
);
|
||||
const environment = vi.fn(() =>
|
||||
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
|
||||
);
|
||||
const signalGroup = vi.fn();
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve([root, child]),
|
||||
environment,
|
||||
runTokens,
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual(
|
||||
{ cleaned: true, signaled: true },
|
||||
);
|
||||
expect(runTokens).toHaveBeenCalledTimes(2);
|
||||
expect(runTokens).toHaveBeenNthCalledWith(1, [root, child]);
|
||||
expect(environment.mock.calls).toEqual([[child.pid], [child.pid]]);
|
||||
expect(signalGroup).toHaveBeenCalledWith(root.processGroupId, "SIGKILL");
|
||||
});
|
||||
|
||||
it("keeps explicit unreadable rows fail-closed without environment fallback", async () => {
|
||||
const environment = vi.fn(() =>
|
||||
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
|
||||
);
|
||||
const signalGroup = vi.fn();
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve([root]),
|
||||
environment,
|
||||
runTokens: () =>
|
||||
Promise.resolve(
|
||||
new Map([
|
||||
[
|
||||
root.pid,
|
||||
{ state: "unavailable" as const, reason: "fixture unreadable" },
|
||||
],
|
||||
]),
|
||||
),
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(ownership, adapter),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: false,
|
||||
failures: [
|
||||
{
|
||||
pid: root.pid,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic: "fixture unreadable",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(environment).not.toHaveBeenCalled();
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the environment reader when the batch operation throws", async () => {
|
||||
const environment = vi.fn(() =>
|
||||
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
|
||||
);
|
||||
const signalGroup = vi.fn();
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve([root]),
|
||||
environment,
|
||||
runTokens: () => Promise.reject(new Error("batch reader unavailable")),
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual(
|
||||
{ cleaned: true, signaled: true },
|
||||
);
|
||||
expect(environment).toHaveBeenCalledTimes(2);
|
||||
expect(signalGroup).toHaveBeenCalledWith(root.processGroupId, "SIGKILL");
|
||||
});
|
||||
|
||||
it("retains both failures when batch and individual token reads fail", async () => {
|
||||
const signalGroup = vi.fn();
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve([root]),
|
||||
environment: () => Promise.reject(new Error("individual unreadable")),
|
||||
runTokens: () => Promise.reject(new Error("batch reader unavailable")),
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(ownership, adapter),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: false,
|
||||
failures: [
|
||||
{
|
||||
pid: root.pid,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic:
|
||||
"individual unreadable; run-token batch failed: batch reader unavailable",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Windows P0 process-tree cleanup", () => {
|
||||
it("reports whether taskkill signaled or found an exited tree", async () => {
|
||||
const terminated: WindowsProcessTreeHost = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
|
||||
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
|
||||
import { execFileOutput } from "./ExecFileOutput.js";
|
||||
import { createDarwinProcessRunTokenReader } from "./DarwinProcessRunTokenReader.js";
|
||||
import { readProcessRunTokens } from "./ProcessRunTokenObservations.js";
|
||||
import type {
|
||||
OwnedProcessGroup,
|
||||
ProcessGroupObservation,
|
||||
@@ -15,49 +16,78 @@ import type {
|
||||
ProcessTableEntry,
|
||||
} from "./ProcessOwnership.js";
|
||||
|
||||
/** Observe one group without signaling it, failing closed on identity doubt. */
|
||||
export const observeOwnedProcessGroup = async (
|
||||
ownership: OwnedProcessGroup,
|
||||
/** Observe multiple groups from one process snapshot and one token batch. */
|
||||
export const observeOwnedProcessGroups = async (
|
||||
runId: string,
|
||||
processGroupIds: readonly number[],
|
||||
host: ProcessOwnershipHost = systemProcessOwnershipHost,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProcessGroupObservation> => {
|
||||
): Promise<ReadonlyMap<number, ProcessGroupObservation>> => {
|
||||
signal?.throwIfAborted();
|
||||
let members: readonly ProcessTableEntry[];
|
||||
const groupIds = [...new Set(processGroupIds)];
|
||||
if (groupIds.length === 0) return new Map();
|
||||
let processes: readonly ProcessTableEntry[];
|
||||
try {
|
||||
members = (await host.listProcesses(signal)).filter(
|
||||
({ processGroupId }) => processGroupId === ownership.processGroupId,
|
||||
);
|
||||
processes = liveProcesses(await host.listProcesses(signal));
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
state: "unverifiable",
|
||||
const failure = {
|
||||
state: "unverifiable" as const,
|
||||
reason: `process group could not be inspected: ${errorMessage(cause)}`,
|
||||
};
|
||||
return new Map(groupIds.map((groupId) => [groupId, failure]));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const liveMembers = liveProcesses(members);
|
||||
if (liveMembers.length === 0) return { state: "empty" };
|
||||
for (const member of liveMembers) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const runId = (await host.environment(member.pid)).REA_PROCESS_RUN_ID;
|
||||
signal?.throwIfAborted();
|
||||
if (runId !== ownership.runId)
|
||||
return {
|
||||
const membersByGroup = new Map(
|
||||
groupIds.map((groupId) => [groupId, [] as ProcessTableEntry[]]),
|
||||
);
|
||||
for (const process of processes)
|
||||
membersByGroup.get(process.processGroupId)?.push(process);
|
||||
const observations = new Map<number, ProcessGroupObservation>(
|
||||
groupIds.map((groupId) => [
|
||||
groupId,
|
||||
(membersByGroup.get(groupId)?.length ?? 0) > 0
|
||||
? { state: "alive" }
|
||||
: { state: "empty" },
|
||||
]),
|
||||
);
|
||||
const liveMembers = groupIds.flatMap(
|
||||
(groupId) => membersByGroup.get(groupId) ?? [],
|
||||
);
|
||||
const pendingGroups = new Set(
|
||||
groupIds.filter(
|
||||
(groupId) => (membersByGroup.get(groupId)?.length ?? 0) > 0,
|
||||
),
|
||||
);
|
||||
for await (const { process: member, observation } of readProcessRunTokens(
|
||||
host,
|
||||
liveMembers,
|
||||
signal,
|
||||
)) {
|
||||
if (!pendingGroups.has(member.processGroupId)) continue;
|
||||
if (observation.state === "readable") {
|
||||
if (observation.runId !== runId) {
|
||||
observations.set(member.processGroupId, {
|
||||
state: "unverifiable",
|
||||
reason: "process ownership did not match",
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
});
|
||||
pendingGroups.delete(member.processGroupId);
|
||||
}
|
||||
} else {
|
||||
const gone = await processIsGone(host, member.pid);
|
||||
signal?.throwIfAborted();
|
||||
if (!(await processIsGone(host, member.pid))) {
|
||||
return {
|
||||
if (!gone) {
|
||||
observations.set(member.processGroupId, {
|
||||
state: "unverifiable",
|
||||
reason: `process ownership could not be revalidated for PID ${member.pid}: ${errorMessage(cause)}`,
|
||||
};
|
||||
reason: `process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
|
||||
});
|
||||
pendingGroups.delete(member.processGroupId);
|
||||
}
|
||||
}
|
||||
if (pendingGroups.size === 0) break;
|
||||
}
|
||||
return { state: "alive" };
|
||||
signal?.throwIfAborted();
|
||||
return observations;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -88,18 +118,18 @@ export const observeOwnedProcessLineage = async (
|
||||
return unavailableLineage(ownership, identityFailure);
|
||||
const descendants = descendantsOf(launcher.pid, processes);
|
||||
const verifiedDescendants: ProcessTableEntry[] = [];
|
||||
for (const member of [launcher, ...descendants]) {
|
||||
try {
|
||||
if (
|
||||
(await host.environment(member.pid)).REA_PROCESS_RUN_ID !==
|
||||
ownership.runId
|
||||
)
|
||||
for await (const { process: member, observation } of readProcessRunTokens(
|
||||
host,
|
||||
[launcher, ...descendants],
|
||||
)) {
|
||||
if (observation.state === "readable") {
|
||||
if (observation.runId !== ownership.runId)
|
||||
return unavailableLineage(
|
||||
ownership,
|
||||
"process lineage contains an unowned or PID-reused process",
|
||||
);
|
||||
if (member.pid !== launcher.pid) verifiedDescendants.push(member);
|
||||
} catch (cause: unknown) {
|
||||
} else {
|
||||
if (await processIsGone(host, member.pid)) {
|
||||
if (member.pid === launcher.pid)
|
||||
return unavailableLineage(
|
||||
@@ -110,7 +140,7 @@ export const observeOwnedProcessLineage = async (
|
||||
}
|
||||
return unavailableLineage(
|
||||
ownership,
|
||||
`process ownership could not be revalidated for PID ${member.pid}: ${errorMessage(cause)}`,
|
||||
`process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -330,18 +360,21 @@ export const createSystemProcessOwnershipHost = (
|
||||
}
|
||||
},
|
||||
listProcesses,
|
||||
async environment(pid) {
|
||||
async environment(pid, signal) {
|
||||
signal?.throwIfAborted();
|
||||
if (platform === "linux")
|
||||
return parseProcessEnvironment(
|
||||
await readFile(`/proc/${pid}/environ`, "utf8"),
|
||||
await readFile(`/proc/${pid}/environ`, { encoding: "utf8", signal }),
|
||||
);
|
||||
if (platform === "darwin") {
|
||||
const process = (await listProcesses()).find(
|
||||
const process = (await listProcesses(signal)).find(
|
||||
(entry) => entry.pid === pid,
|
||||
);
|
||||
if (process === undefined)
|
||||
throw new Error(`process ${String(pid)} is not live`);
|
||||
const observation = (await darwinTokens?.read([process]))?.get(pid);
|
||||
const observation = (await darwinTokens?.read([process], signal))?.get(
|
||||
pid,
|
||||
);
|
||||
if (observation === undefined || observation.state === "unavailable")
|
||||
throw new Error(
|
||||
observation?.reason ?? "process run token could not be read",
|
||||
@@ -355,6 +388,7 @@ export const createSystemProcessOwnershipHost = (
|
||||
["eww", "-p", String(pid)],
|
||||
{
|
||||
env: hostEnvironment,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
);
|
||||
const observedEnvironment: Record<string, string> = {};
|
||||
@@ -366,11 +400,13 @@ export const createSystemProcessOwnershipHost = (
|
||||
}
|
||||
return observedEnvironment;
|
||||
},
|
||||
async runTokens(processes) {
|
||||
async runTokens(processes, signal) {
|
||||
signal?.throwIfAborted();
|
||||
if (platform !== "darwin" || processes.length === 0) return new Map();
|
||||
try {
|
||||
return (await darwinTokens?.read(processes)) ?? new Map();
|
||||
return (await darwinTokens?.read(processes, signal)) ?? new Map();
|
||||
} catch (cause: unknown) {
|
||||
if (isExpectedAbort(cause, signal)) throw cause;
|
||||
void cause;
|
||||
return new Map(
|
||||
processes.map(({ pid }) => [
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
ProcessOwnershipHost,
|
||||
ProcessRunTokenObservation,
|
||||
ProcessTableEntry,
|
||||
} from "./ProcessOwnership.js";
|
||||
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
/** Read only ownership tokens, using per-process fallback for missing batch rows. */
|
||||
export async function* readProcessRunTokens(
|
||||
host: ProcessOwnershipHost,
|
||||
processes: readonly ProcessTableEntry[],
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<{
|
||||
readonly process: ProcessTableEntry;
|
||||
readonly observation: ProcessRunTokenObservation;
|
||||
}> {
|
||||
signal?.throwIfAborted();
|
||||
if (processes.length === 0) return;
|
||||
let batch: ReadonlyMap<number, ProcessRunTokenObservation> | undefined;
|
||||
let batchFailure: string | undefined;
|
||||
if (host.runTokens !== undefined) {
|
||||
try {
|
||||
batch =
|
||||
signal === undefined
|
||||
? await host.runTokens(processes)
|
||||
: await host.runTokens(processes, signal);
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
batchFailure = errorMessage(cause);
|
||||
}
|
||||
}
|
||||
for (const process of processes) {
|
||||
signal?.throwIfAborted();
|
||||
let observation = batch?.get(process.pid);
|
||||
if (observation === undefined) {
|
||||
try {
|
||||
const environment =
|
||||
signal === undefined
|
||||
? await host.environment(process.pid)
|
||||
: await host.environment(process.pid, signal);
|
||||
observation = {
|
||||
state: "readable",
|
||||
runId: environment.REA_PROCESS_RUN_ID,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
const reason = errorMessage(cause);
|
||||
observation = {
|
||||
state: "unavailable",
|
||||
reason:
|
||||
batchFailure === undefined
|
||||
? reason
|
||||
: `${reason}; run-token batch failed: ${batchFailure}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
yield { process, observation };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ProviderProcessSnapshot } from "./ProviderProcess.js";
|
||||
|
||||
/** Redact explicit transport credentials from complete and budget-cut output. */
|
||||
export const redactCapturedTransportCredential = (
|
||||
stream: Pick<
|
||||
ProviderProcessSnapshot["stdout"],
|
||||
"text" | "bytes" | "observedBytes"
|
||||
>,
|
||||
token: string | undefined,
|
||||
replacement: string,
|
||||
): string => {
|
||||
if (token === undefined || token.length === 0) return stream.text;
|
||||
|
||||
const { text } = stream;
|
||||
const cutOff = stream.observedBytes > stream.bytes;
|
||||
if (cutOff && !text.endsWith(token)) {
|
||||
const maximumPrefixLength = Math.min(text.length, token.length - 1);
|
||||
for (
|
||||
let prefixLength = maximumPrefixLength;
|
||||
prefixLength > 0;
|
||||
prefixLength -= 1
|
||||
) {
|
||||
if (text.endsWith(token.slice(0, prefixLength)))
|
||||
return `${text
|
||||
.slice(0, text.length - prefixLength)
|
||||
.replaceAll(token, replacement)}${replacement}`;
|
||||
}
|
||||
}
|
||||
|
||||
return text.replaceAll(token, replacement);
|
||||
};
|
||||
@@ -76,11 +76,16 @@ export interface ProviderProcessSnapshot {
|
||||
readonly diagnosticTruncated?: boolean;
|
||||
readonly stdout: {
|
||||
readonly text: string;
|
||||
/** Bytes retained in text, limited by maxDiagnosticBytes when configured. */
|
||||
readonly bytes: number;
|
||||
/** All bytes drained from this stream since construction or resetOutput. */
|
||||
readonly observedBytes: number;
|
||||
};
|
||||
readonly stderr: {
|
||||
readonly text: string;
|
||||
/** Bytes retained in text, limited by maxDiagnosticBytes when configured. */
|
||||
readonly bytes: number;
|
||||
readonly observedBytes: number;
|
||||
};
|
||||
readonly exitCode: number | null | undefined;
|
||||
readonly signal: NodeJS.Signals | null | undefined;
|
||||
@@ -268,8 +273,8 @@ export class ProviderProcessSupervisor {
|
||||
snapshot(): ProviderProcessSnapshot {
|
||||
return {
|
||||
...(this.#diagnosticTruncated ? { diagnosticTruncated: true } : {}),
|
||||
stdout: this.#stdout.snapshot(),
|
||||
stderr: this.#stderr.snapshot(),
|
||||
stdout: this.#stdout.snapshot(this.#stdoutObservedBytes),
|
||||
stderr: this.#stderr.snapshot(this.#stderrObservedBytes),
|
||||
exitCode: this.#exitObservation?.code,
|
||||
signal: this.#exitObservation?.signal,
|
||||
};
|
||||
@@ -488,11 +493,12 @@ class ProcessOutputCapture {
|
||||
this.#chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
|
||||
snapshot(): ProviderProcessSnapshot["stdout"] {
|
||||
snapshot(observedBytes: number): ProviderProcessSnapshot["stdout"] {
|
||||
const text = Buffer.concat(this.#chunks, this.#bytes).toString("utf8");
|
||||
return {
|
||||
text,
|
||||
bytes: this.#bytes,
|
||||
observedBytes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type ProcessOwnershipBaseline,
|
||||
} from "../ProcessOwnership.js";
|
||||
import {
|
||||
observeOwnedProcessGroup,
|
||||
observeOwnedProcessGroups,
|
||||
prepareProcessOwnershipInspection,
|
||||
systemProcessOwnershipHost,
|
||||
} from "../ProcessOwnershipObservation.js";
|
||||
@@ -616,21 +616,20 @@ export const observeSettlement = async (
|
||||
while (!deadlineReached) {
|
||||
onLiveProgress?.();
|
||||
assertNotCancelled(signal);
|
||||
const observations = await Promise.all(
|
||||
[...new Set(processGroupIds)].map((processGroupId) =>
|
||||
observeOwnedProcessGroup(
|
||||
{ runId, leaderPid: processGroupId, processGroupId },
|
||||
undefined,
|
||||
signal,
|
||||
),
|
||||
),
|
||||
const observations = await observeOwnedProcessGroups(
|
||||
runId,
|
||||
processGroupIds,
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
assertNotCancelled(signal);
|
||||
if (observations.some(({ state }) => state === "unverifiable")) {
|
||||
if (
|
||||
[...observations.values()].some(({ state }) => state === "unverifiable")
|
||||
) {
|
||||
recordEvent("lifecycle", 1);
|
||||
return { state: "unverifiable", elapsed_ms: Date.now() - started };
|
||||
}
|
||||
if (observations.every(({ state }) => state === "empty")) {
|
||||
if ([...observations.values()].every(({ state }) => state === "empty")) {
|
||||
consecutiveEmpty += 1;
|
||||
if (consecutiveEmpty >= 2) {
|
||||
recordEvent("lifecycle", 1);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { stat, truncate, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { snapshotAndroidTarget } from "../../../src/android/AndroidTargetSnapshot.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"cancels an in-flight APK snapshot and removes its partial output",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-android-snapshot-cancel-");
|
||||
const source = join(root, "large.apk");
|
||||
const snapshot = join(root, "target.apk");
|
||||
await writeFile(source, "");
|
||||
// A sparse extent keeps fixture setup cheap while ensuring the copy cannot
|
||||
// finish before the test observes its first output bytes and aborts it.
|
||||
await truncate(source, 64 * 1024 * 1024 * 1024);
|
||||
|
||||
const controller = new AbortController();
|
||||
const pending = snapshotAndroidTarget(
|
||||
source,
|
||||
"0".repeat(64),
|
||||
root,
|
||||
"inspect_android_package",
|
||||
controller.signal,
|
||||
);
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
stat(snapshot).then(
|
||||
({ size }) => size > 0,
|
||||
() => false,
|
||||
),
|
||||
{ timeout: 10_000, interval: 10 },
|
||||
)
|
||||
.toBe(true);
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
await expect(stat(snapshot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
controller.abort();
|
||||
await pending.catch(() => undefined);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -5,7 +5,8 @@ import { expect, it as test } from "vitest";
|
||||
|
||||
import {
|
||||
inspectJadxAvailability,
|
||||
resolveJadxConfiguration,
|
||||
resolveJadxConfigurationInputs,
|
||||
validateJadxConfiguration,
|
||||
} from "../../../src/android/JadxConfiguration.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
@@ -14,6 +15,18 @@ import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
const resolveConfiguration = async (
|
||||
environment: Parameters<typeof resolveJadxConfigurationInputs>[0],
|
||||
operation: Parameters<typeof resolveJadxConfigurationInputs>[1],
|
||||
) => {
|
||||
const configuration = await resolveJadxConfigurationInputs(
|
||||
environment,
|
||||
operation,
|
||||
);
|
||||
await validateJadxConfiguration(configuration, environment, operation);
|
||||
return configuration;
|
||||
};
|
||||
|
||||
it("rejects malformed or unrelated engine archives with JAR-specific recovery", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-invalid-jar-");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
@@ -29,7 +42,7 @@ it("rejects malformed or unrelated engine archives with JAR-specific recovery",
|
||||
diagnostics: { jar_path: await realpath(jar), phase: "jar-inspection" },
|
||||
});
|
||||
await expect(
|
||||
resolveJadxConfiguration(environment, "inspect_android_package"),
|
||||
resolveConfiguration(environment, "inspect_android_package"),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisCapabilityUnavailableError",
|
||||
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
|
||||
@@ -45,7 +58,7 @@ it("rejects an explicit JAVA_HOME without an executable java binary", async () =
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
resolveConfiguration(
|
||||
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
|
||||
"inspect_android_package",
|
||||
),
|
||||
@@ -62,7 +75,7 @@ it("accepts a full JDK and canonicalizes the selected JAR", async () => {
|
||||
await writeJadxJarInventory(jar);
|
||||
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
resolveConfiguration(
|
||||
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
|
||||
"inspect_android_package",
|
||||
),
|
||||
@@ -78,7 +91,7 @@ it("configures heap and visible processors independently without forcing either"
|
||||
const jar = join(root, "engine.jar");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeJadxJarInventory(jar);
|
||||
const configuration = await resolveJadxConfiguration(
|
||||
const configuration = await resolveConfiguration(
|
||||
{
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
@@ -92,7 +105,7 @@ it("configures heap and visible processors independently without forcing either"
|
||||
"-XX:ActiveProcessorCount=4",
|
||||
]);
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
resolveConfiguration(
|
||||
{
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
@@ -107,7 +120,7 @@ it("configures heap and visible processors independently without forcing either"
|
||||
for (const name of ["REA_JADX_HEAP_MIB", "REA_JADX_ACTIVE_PROCESSOR_COUNT"])
|
||||
for (const value of ["", "0", "-1", "1.5", "8g", " 4", "9007199254740992"])
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
resolveConfiguration(
|
||||
{
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
@@ -127,7 +140,7 @@ it("distinguishes a runnable JRE from a missing Java executable", async () => {
|
||||
await writeJadxJarInventory(jar);
|
||||
const jreHome = await createFakeJdk(root, false);
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
resolveConfiguration(
|
||||
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: jreHome },
|
||||
"inspect_android_package",
|
||||
),
|
||||
@@ -175,7 +188,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated"
|
||||
);
|
||||
await chmod(java, 0o700);
|
||||
|
||||
const failure = await resolveJadxConfiguration(
|
||||
const failure = await resolveConfiguration(
|
||||
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
|
||||
"inspect_android_package",
|
||||
).then(
|
||||
@@ -241,7 +254,7 @@ it("distinguishes an unreported Java version from an unsupported old version", a
|
||||
);
|
||||
await chmod(join(oldHome, "bin", "java"), 0o700);
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
resolveConfiguration(
|
||||
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: oldHome },
|
||||
"inspect_android_package",
|
||||
),
|
||||
|
||||
@@ -294,6 +294,15 @@ describe("CdpBrowserProvider: WebMCP discovery and completeness", () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.value.completeness.excluded).toContainEqual(
|
||||
expect.objectContaining({
|
||||
section: "webmcp_tools",
|
||||
reason: "out_of_target_scope",
|
||||
}),
|
||||
);
|
||||
expect(result.value.completeness.unavailable_sections).not.toContain(
|
||||
"webmcp_tools",
|
||||
);
|
||||
const serialized = JSON.stringify(result.value);
|
||||
expect(serialized).toContain("tool-secret");
|
||||
expect(serialized).toContain("tool-source-secret");
|
||||
@@ -354,7 +363,9 @@ describe("CdpBrowserProvider: WebMCP discovery and completeness", () => {
|
||||
"WebMCP.invokeTool",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CdpBrowserProvider: WebMCP replay inventory", () => {
|
||||
it("returns every WebMCP registration in the replay", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
webMcpTools: true,
|
||||
@@ -410,7 +421,98 @@ describe("CdpBrowserProvider: WebMCP discovery and completeness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CdpBrowserProvider WebMCP inventory completeness", () => {
|
||||
describe("CdpBrowserProvider WebMCP malformed replay completeness", () => {
|
||||
it.each(["toolsAdded", "toolsRemoved"] as const)(
|
||||
"keeps malformed %s replay entries from proving WebMCP equality",
|
||||
async (malformedEvent) => {
|
||||
let malformed = false;
|
||||
const registration = {
|
||||
name: "read_item",
|
||||
frameId: "frame-main",
|
||||
inputSchema: { type: "object" },
|
||||
};
|
||||
const browser = await startFakeCdpBrowser({
|
||||
commandEvents: (command) => {
|
||||
if (command.method !== "WebMCP.enable") return undefined;
|
||||
const events: {
|
||||
readonly method: string;
|
||||
readonly params: unknown;
|
||||
readonly sessionId?: string;
|
||||
}[] = [
|
||||
{
|
||||
method: "WebMCP.toolsAdded",
|
||||
...(command.sessionId === undefined
|
||||
? {}
|
||||
: { sessionId: command.sessionId }),
|
||||
params: {
|
||||
tools:
|
||||
malformed && malformedEvent === "toolsAdded"
|
||||
? [registration, null]
|
||||
: [registration],
|
||||
},
|
||||
},
|
||||
];
|
||||
if (malformed && malformedEvent === "toolsRemoved")
|
||||
events.push({
|
||||
method: "WebMCP.toolsRemoved",
|
||||
...(command.sessionId === undefined
|
||||
? {}
|
||||
: { sessionId: command.sessionId }),
|
||||
params: { tools: [{ name: registration.name }, null] },
|
||||
});
|
||||
return events;
|
||||
},
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const provider = new CdpBrowserProvider();
|
||||
const input = {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 0,
|
||||
};
|
||||
const inspection = await provider.inspectPage(
|
||||
inspectWebPageInputSchema.parse(input),
|
||||
);
|
||||
if (!inspection.ok) throw inspection.error;
|
||||
const discover = async () => {
|
||||
const result = await provider.discoverWebMcpTools(
|
||||
discoverWebMcpToolsInputSchema.parse(input),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
};
|
||||
const before = await discover();
|
||||
expect(before.tools.items).toHaveLength(1);
|
||||
expect(before.completeness.unavailable_sections).not.toContain(
|
||||
"webmcp_tools",
|
||||
);
|
||||
|
||||
malformed = true;
|
||||
const after = await discover();
|
||||
expect(after.tools.items).toHaveLength(1);
|
||||
expect(after.completeness.unavailable_sections).toContain("webmcp_tools");
|
||||
expect(after.completeness.excluded).toContainEqual(
|
||||
expect.objectContaining({
|
||||
section: "webmcp_tools",
|
||||
reason: "invalid_protocol_value",
|
||||
}),
|
||||
);
|
||||
const comparison = compareWebCaptures(
|
||||
compareWebCapturesInputSchema.parse({
|
||||
before: { inspection: inspection.value, webmcp: before },
|
||||
after: { inspection: inspection.value, webmcp: after },
|
||||
}),
|
||||
);
|
||||
expect(comparison.dimensions.webmcp).toMatchObject({
|
||||
status: "unknown",
|
||||
total_changes: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("CdpBrowserProvider WebMCP schema comparison", () => {
|
||||
it("compares full untrusted declarations independently of object-key order", async () => {
|
||||
const declaration: JsonValue = {
|
||||
type: "object",
|
||||
|
||||
@@ -47,6 +47,7 @@ it.each(["Page.disable", "Target.detachFromTarget"])(
|
||||
expect(methods).toContain("Target.detachFromTarget");
|
||||
expect(methods).not.toContain("Target.closeTarget");
|
||||
expect(methods).not.toContain("Browser.close");
|
||||
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -76,6 +77,11 @@ it("releases cleanup when cancellation arrives after capture completes", async (
|
||||
expect(browser.commands.map(({ method }) => method)).toContain(
|
||||
"Target.detachFromTarget",
|
||||
);
|
||||
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).not.toContain("Target.closeTarget");
|
||||
expect(methods).not.toContain("Browser.close");
|
||||
expect((await fetch(`${browser.endpoint}/json/version`)).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("closes an Electron inspection session when cleanup is cancelled", async () => {
|
||||
@@ -122,11 +128,11 @@ it("closes an Electron inspection session when cleanup is cancelled", async () =
|
||||
operation: "inspect_electron_page",
|
||||
},
|
||||
});
|
||||
expect(browser.commands.map(({ method }) => method)).toContain(
|
||||
"Page.disable",
|
||||
);
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).not.toContain("Page.disable");
|
||||
expect(methods).toContain("Target.detachFromTarget");
|
||||
expect(methods).not.toContain("Target.closeTarget");
|
||||
expect(methods).not.toContain("Browser.close");
|
||||
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
||||
expect((await fetch(`${browser.endpoint}/json/version`)).ok).toBe(true);
|
||||
});
|
||||
@@ -39,6 +39,68 @@ const equivalentBodies = [
|
||||
},
|
||||
];
|
||||
|
||||
it("captures all repeated array rows and empty arrays without retaining payload values", async () => {
|
||||
const count = 6_000;
|
||||
const body = JSON.stringify(
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
value: index % 2 === 0 ? index : "payload-value-excluded",
|
||||
nested: index % 2 === 0 ? [] : [true, null],
|
||||
empty: [],
|
||||
"*": { "": "payload-value-excluded" },
|
||||
})),
|
||||
);
|
||||
const before = captureBody("base64 response", body);
|
||||
const shape = before.network.requests[0]?.body_shapes.response;
|
||||
const element = { kind: "array-element" };
|
||||
const property = (name: string) => ({ kind: "property", name });
|
||||
expect(shape).toMatchObject({
|
||||
root_type: "array",
|
||||
node_count: count * 7 + 1,
|
||||
max_depth_observed: 3,
|
||||
properties: [
|
||||
{ path: [element], types: ["object"], observations: count },
|
||||
{
|
||||
path: [element, property("*")],
|
||||
types: ["object"],
|
||||
observations: count,
|
||||
},
|
||||
{
|
||||
path: [element, property("*"), property("")],
|
||||
types: ["string"],
|
||||
observations: count,
|
||||
},
|
||||
{
|
||||
path: [element, property("empty")],
|
||||
types: ["array"],
|
||||
observations: count,
|
||||
},
|
||||
{
|
||||
path: [element, property("nested")],
|
||||
types: ["array"],
|
||||
observations: count,
|
||||
},
|
||||
{
|
||||
path: [element, property("nested"), element],
|
||||
types: ["boolean", "null"],
|
||||
observations: count,
|
||||
},
|
||||
{
|
||||
path: [element, property("value")],
|
||||
types: ["number", "string"],
|
||||
observations: count,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(shape)).not.toContain("payload-value-excluded");
|
||||
const after = captureBody("base64 response", body);
|
||||
expect(
|
||||
(await compareCaptures(before, after)).dimensions.network,
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
total_changes: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe.each<BodySource>(["request", "response", "base64 response"])(
|
||||
"CDP %s JSON shape comparison",
|
||||
(source) => {
|
||||
|
||||
@@ -188,6 +188,107 @@ describe("runtime window and producer granularity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("joins a reordered multi-script coverage sample by identity and preserves unavailable bounds", async () => {
|
||||
const texts = new Map(
|
||||
Array.from({ length: 256 }, (_, index) => [
|
||||
`script-${index}`,
|
||||
`// 🔥\r\n${" ".repeat(index)}function f(){return ${index};}`,
|
||||
]),
|
||||
);
|
||||
const { browser, provider, execution } = await fixture({
|
||||
commandEvents: (command, origin) =>
|
||||
command.method === "Debugger.enable"
|
||||
? [...texts.keys(), "foreign-script"].map((scriptId) => ({
|
||||
...(command.sessionId === undefined
|
||||
? {}
|
||||
: { sessionId: command.sessionId }),
|
||||
method: "Debugger.scriptParsed",
|
||||
params: {
|
||||
scriptId,
|
||||
url: `${origin}/shared.js`,
|
||||
executionContextId: scriptId === "foreign-script" ? 99 : 1,
|
||||
startLine: 0,
|
||||
startColumn: 0,
|
||||
endLine: 1,
|
||||
endColumn: 0,
|
||||
},
|
||||
}))
|
||||
: undefined,
|
||||
commandError: (command) =>
|
||||
command.method === "Debugger.getScriptSource" &&
|
||||
command.params.scriptId === "script-128"
|
||||
? { code: -32000, message: "Selected source is unavailable" }
|
||||
: undefined,
|
||||
commandResult: (command, origin) => {
|
||||
if (command.method === "Debugger.getScriptSource")
|
||||
return { scriptSource: texts.get(String(command.params.scriptId)) };
|
||||
if (command.method !== "Profiler.takePreciseCoverage") return undefined;
|
||||
return {
|
||||
timestamp: 13,
|
||||
result: [...texts]
|
||||
.reverse()
|
||||
.map(([scriptId, text]) => ({
|
||||
scriptId,
|
||||
url: `${origin}/shared.js`,
|
||||
functions: [
|
||||
{
|
||||
functionName: "f",
|
||||
isBlockCoverage: true,
|
||||
ranges: [
|
||||
{ startOffset: 0, endOffset: text.length, count: 1 },
|
||||
{ startOffset: 8, endOffset: 9, count: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}))
|
||||
.concat([
|
||||
{
|
||||
scriptId: "foreign-script",
|
||||
url: `${origin}/shared.js`,
|
||||
functions: [],
|
||||
},
|
||||
]),
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await provider.observeExecution(execution);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.coverage.excluded_scripts).toBe(1);
|
||||
expect(
|
||||
result.value.coverage.scripts.map(({ script_id }) => script_id),
|
||||
).toEqual([...texts.keys()].reverse());
|
||||
for (const script of result.value.coverage.scripts) {
|
||||
expect(script.functions[0]?.ranges).toEqual([
|
||||
{
|
||||
start_offset: 0,
|
||||
end_offset: texts.get(script.script_id)?.length,
|
||||
count: 1,
|
||||
source_bounds:
|
||||
script.script_id === "script-128" ? "unknown" : "verified",
|
||||
},
|
||||
{
|
||||
start_offset: 8,
|
||||
end_offset: 9,
|
||||
count: 0,
|
||||
source_bounds:
|
||||
script.script_id === "script-128" ? "unknown" : "verified",
|
||||
},
|
||||
]);
|
||||
}
|
||||
expect(
|
||||
result.value.sources.find(({ script_id }) => script_id === "script-128")
|
||||
?.source,
|
||||
).toMatchObject({ state: "unavailable" });
|
||||
expect(
|
||||
browser.commands.some(
|
||||
({ method }) => method === "Profiler.stopPreciseCoverage",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
browser.commands.some(({ method }) => method === "Target.detachFromTarget"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("runtime source and listener identity", () => {
|
||||
it("retains same-URL script identities, UTF-16 bounds, nested zero counts and actual cleanup", async () => {
|
||||
const { browser, provider, execution } = await fixture();
|
||||
|
||||
@@ -5,6 +5,59 @@ import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution
|
||||
import { inspectWebEventListenersInputSchema } from "../../../src/domain/webEventListeners.js";
|
||||
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";
|
||||
|
||||
it("invalidates retained sources when their execution context is reused during another source read", async () => {
|
||||
let emit: ((event: CdpEvent) => void) | undefined;
|
||||
const browser = await startRuntimeBrowser({
|
||||
commandResult: (command, origin) => {
|
||||
if (
|
||||
command.method === "Debugger.getScriptSource" &&
|
||||
command.params.scriptId === "script-b"
|
||||
)
|
||||
emit?.({
|
||||
sessionId: "session-1",
|
||||
method: "Runtime.executionContextCreated",
|
||||
params: {
|
||||
context: {
|
||||
id: 1,
|
||||
uniqueId: "replacement-context",
|
||||
origin,
|
||||
name: "",
|
||||
auxData: {
|
||||
frameId: "runtime-main",
|
||||
isDefault: true,
|
||||
type: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
emit = browser.emitEvent;
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 5,
|
||||
}),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.coverage.scripts).toEqual([]);
|
||||
expect(result.value.coverage.excluded_scripts).toBe(3);
|
||||
expect(result.value.script_inventory.main_document_scripts).toBe(0);
|
||||
for (const scriptId of ["script-a", "script-b"])
|
||||
expect(
|
||||
result.value.sources.find(({ script_id }) => script_id === scriptId),
|
||||
).toMatchObject({ frame_id: null, source: { state: "excluded" } });
|
||||
expect(browser.commands.map(({ method }) => method)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Profiler.stopPreciseCoverage",
|
||||
"Target.detachFromTarget",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(
|
||||
["listeners", "execution"].flatMap((operation) =>
|
||||
["source_read", "other_source_read", "final_assertion"].map((phase) => ({
|
||||
|
||||
@@ -252,6 +252,65 @@ describe("trace-dylib-resolution CLI", () => {
|
||||
);
|
||||
});
|
||||
|
||||
cliTest(
|
||||
"retains directory requirements when resolving dylib install names",
|
||||
async ({ cli }) => {
|
||||
const directory = await createTestTempDirectory("rea-dylib-path-cli-");
|
||||
const names = [
|
||||
"@loader_path/lib/libhelper.dylib/",
|
||||
"@loader_path/lib/libhelper.dylib/.",
|
||||
"@rpath/libhelper.dylib/",
|
||||
"@rpath/libhelper.dylib/.",
|
||||
"@rpath/",
|
||||
"@loader_path/lib/./libhelper.dylib",
|
||||
"@loader_path/lib//libhelper.dylib",
|
||||
];
|
||||
await writeFiles(directory, {
|
||||
tool: machoImage({
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
rpathCommand("@loader_path/lib/libhelper.dylib"),
|
||||
rpathCommand("@loader_path/lib"),
|
||||
...names.map((name) => dylibCommand(LC.LOAD_DYLIB, name)),
|
||||
],
|
||||
}),
|
||||
"lib/libhelper.dylib": machoImage({
|
||||
fileType: FILE_TYPE.dylib,
|
||||
commands: [buildVersionCommand(1)],
|
||||
}),
|
||||
});
|
||||
const result = await cli.run({
|
||||
arguments: ["trace-dylib-resolution", join(directory, "tool"), "--json"],
|
||||
environment: ENVIRONMENT,
|
||||
});
|
||||
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
||||
const trace = dylibResolutionResultSchema.parse(
|
||||
parseEvidence(result.json).normalized_result,
|
||||
);
|
||||
expect(
|
||||
trace.edges.map(({ install_name, candidates, resolution }) => ({
|
||||
install_name,
|
||||
outcome: candidates[0]?.outcome,
|
||||
resolution,
|
||||
})),
|
||||
).toEqual(
|
||||
names.map((name, index) => ({
|
||||
install_name: name,
|
||||
outcome: index < 5 ? "absent" : "resolved",
|
||||
resolution:
|
||||
index < 5
|
||||
? { status: "unresolved", image: null }
|
||||
: { status: "resolved", image: "lib/libhelper.dylib" },
|
||||
})),
|
||||
);
|
||||
expect(
|
||||
trace.findings.map(({ kind, edge_index }) => [kind, edge_index]),
|
||||
).toEqual(
|
||||
[0, 1, 2, 3, 4].map((index) => ["required-load-unresolved", index]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.getuid?.() === 0)(
|
||||
"trace-dylib-resolution permissions",
|
||||
() => {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import {
|
||||
createResidualUnknown,
|
||||
updateResidualUnknown,
|
||||
} from "../../../src/domain/residualUnknown.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const revisionBundle = () => {
|
||||
const records = ["open", "investigate"].map((operation) =>
|
||||
createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ operation, parameters: {}, result: { observed: operation } },
|
||||
),
|
||||
);
|
||||
const [opened, investigated] = records;
|
||||
if (opened === undefined || investigated === undefined)
|
||||
throw new Error("Missing mutation Evidence");
|
||||
const details = {
|
||||
severity: "high" as const,
|
||||
supporting_evidence_ids: [],
|
||||
contradicting_evidence_ids: [],
|
||||
required_authority: null,
|
||||
required_confidence: "observed" as const,
|
||||
required_environment: null,
|
||||
recommended_probes: [],
|
||||
relationships: [],
|
||||
};
|
||||
const initial = createResidualUnknown(
|
||||
{ question: "How is this input handled?", domain: "protocol", ...details },
|
||||
opened.evidence_id,
|
||||
null,
|
||||
);
|
||||
const revised = updateResidualUnknown(
|
||||
initial,
|
||||
{
|
||||
unknown_id: initial.unknown_id,
|
||||
expected_revision: initial.revision,
|
||||
status: "investigating",
|
||||
...details,
|
||||
resolution: null,
|
||||
},
|
||||
investigated.evidence_id,
|
||||
);
|
||||
return createEvidenceBundle(records, [initial, revised]);
|
||||
};
|
||||
|
||||
cliTest(
|
||||
"reports standalone import counts for every unknown revision",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-evidence-import-");
|
||||
const path = join(root, "bundle.json");
|
||||
await writeFile(path, JSON.stringify(revisionBundle()));
|
||||
const result = await cli.run({
|
||||
arguments: ["evidence-import", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.json).toEqual({ imported: 2, unknowns_added: 2, total: 2 });
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"rejects altered Evidence and incomplete unknown history before reporting counts",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-evidence-import-invalid-");
|
||||
const path = join(root, "bundle.json");
|
||||
const bundle = revisionBundle();
|
||||
for (const invalid of [
|
||||
{
|
||||
...bundle,
|
||||
records: bundle.records.map((record) => ({
|
||||
...record,
|
||||
normalized_result: "altered",
|
||||
})),
|
||||
},
|
||||
{ ...bundle, unknowns: bundle.unknowns.slice(1) },
|
||||
]) {
|
||||
await writeFile(path, JSON.stringify(invalid));
|
||||
const result = await cli.run({
|
||||
arguments: ["evidence-import", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "evidence_integrity_mismatch",
|
||||
});
|
||||
expect(result.json).not.toHaveProperty("imported");
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -7,6 +7,7 @@ import { projectAnalysisError } from "../../../src/domain/analysisErrorProjectio
|
||||
import { evmInterfaceSchema } from "../../../src/domain/evm/evmInterface.js";
|
||||
import { EvmoleInterfaceProvider } from "../../../src/evm/EvmoleInterfaceProvider.js";
|
||||
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
||||
import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js";
|
||||
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
|
||||
import {
|
||||
@@ -128,31 +129,75 @@ it.runIf(!unsupportedHost)(
|
||||
async () => {
|
||||
const { path } = await fixture();
|
||||
let ownedPath = "";
|
||||
let failCleanup = true;
|
||||
let launches = 0;
|
||||
const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
|
||||
launches += 1;
|
||||
ownedPath = spawn.cwd ?? "";
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: ["-e", 'process.stdout.write("x".repeat(1048577))'],
|
||||
});
|
||||
const cleanupOwned =
|
||||
launched.cleanup ??
|
||||
(() => cleanupOwnedProcessGroup(launched.ownership));
|
||||
return {
|
||||
...launched,
|
||||
cleanup: async () => {
|
||||
await launched.cleanup?.();
|
||||
throw new Error("injected post-cleanup reporting failure");
|
||||
const cleaned = await cleanupOwned();
|
||||
if (failCleanup)
|
||||
throw new Error("injected post-cleanup reporting failure");
|
||||
return cleaned;
|
||||
},
|
||||
};
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
failCleanup = false;
|
||||
await provider.close();
|
||||
});
|
||||
const result = await provider.inspect({ path, encoding: "hex" });
|
||||
if (result.ok) throw new Error("Expected compounded lifecycle failure");
|
||||
expect(result.error).toMatchObject({
|
||||
cleanupIncomplete: true,
|
||||
diagnostics: {
|
||||
reason: "injected post-cleanup reporting failure",
|
||||
previous_error: { failure_kind: "output-limit" },
|
||||
captured_output: { truncated: true, stderr: "" },
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
// Inspect the retained error chain without diffing a megabyte of stdout.
|
||||
const cleanup = z
|
||||
.object({
|
||||
reason: z.string(),
|
||||
previous_error: z
|
||||
.object({
|
||||
failure_kind: z.string(),
|
||||
stdout: z.unknown().optional(),
|
||||
stderr: z.unknown().optional(),
|
||||
})
|
||||
.transform(({ failure_kind, stdout, stderr }) => ({
|
||||
failure_kind,
|
||||
output_repeated: stdout !== undefined || stderr !== undefined,
|
||||
})),
|
||||
captured_output: z.object({
|
||||
truncated: z.boolean(),
|
||||
stderr: z.literal(""),
|
||||
stdout: z.string().transform((value) => Buffer.byteLength(value)),
|
||||
}),
|
||||
})
|
||||
.parse(projectAnalysisError(result.error).details?.["diagnostics"]);
|
||||
expect(cleanup).toEqual({
|
||||
reason: "injected post-cleanup reporting failure",
|
||||
previous_error: {
|
||||
failure_kind: "output-limit",
|
||||
output_repeated: false,
|
||||
},
|
||||
captured_output: { truncated: true, stderr: "", stdout: 1048576 },
|
||||
});
|
||||
expect(result.error.cleanupResources).toContain(ownedPath);
|
||||
await access(ownedPath);
|
||||
const blocked = await provider.inspect({ path, encoding: "hex" });
|
||||
expect(blocked.ok).toBe(false);
|
||||
expect(launches).toBe(1);
|
||||
await expect(provider.close()).rejects.toMatchObject({
|
||||
cleanupIncomplete: true,
|
||||
});
|
||||
failCleanup = false;
|
||||
await provider.close();
|
||||
await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -69,6 +69,35 @@ describe("artifact inventory entry order", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves deep ZIP members without inventing missing directories", async () => {
|
||||
const root = await createTestTempDirectory("rea-deep-archive-parent-");
|
||||
const archive = join(root, "deep.zip");
|
||||
// Fits the ZIP filename field while exceeding the formerly expensive depth.
|
||||
const members = ["data.txt", "second.txt", "third.bin"].map(
|
||||
(name) => `observed/${"d/".repeat(30_000)}${name}`,
|
||||
);
|
||||
await writeOrderedZip(archive, [...members, "observed/", "unrelated/"]);
|
||||
const inventory = await inventoryArtifact(archive);
|
||||
expect(artifactParentPaths(inventory)).toEqual({
|
||||
".": null,
|
||||
observed: ".",
|
||||
unrelated: ".",
|
||||
...Object.fromEntries(members.map((member) => [member, "observed"])),
|
||||
});
|
||||
const observedParent = artifactOccurrenceAt(inventory, "observed");
|
||||
for (const member of members) {
|
||||
const occurrence = artifactOccurrenceAt(inventory, member);
|
||||
expect(occurrence.hash_status).toBe("verified");
|
||||
expect(inventory.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
occurrence_id: occurrence.occurrence_id,
|
||||
parent_artifact_id: observedParent.artifact_id,
|
||||
relation: "contains",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
entries: ["pkg/sub/data.txt"],
|
||||
@@ -129,6 +158,61 @@ describe("entry-order validation and reader controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retains high-cardinality case-folded directories and their descendants", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-entry-order-case-capacity-",
|
||||
);
|
||||
const archive = join(root, "case-capacity.zip");
|
||||
const variants = Array.from({ length: 256 }, (_, mask) =>
|
||||
[..."abcdefgh"]
|
||||
.map((character, index) =>
|
||||
(mask & (1 << index)) === 0 ? character : character.toUpperCase(),
|
||||
)
|
||||
.join(""),
|
||||
);
|
||||
await writeOrderedZip(
|
||||
archive,
|
||||
variants.flatMap((variant) => [variant + "/", variant + "/entry.txt"]),
|
||||
);
|
||||
|
||||
const inventory = await inventoryArtifact(archive);
|
||||
const directories = variants.map((variant) =>
|
||||
artifactOccurrenceAt(inventory, variant),
|
||||
);
|
||||
const descendants = variants.map((variant) =>
|
||||
artifactOccurrenceAt(inventory, `${variant}/entry.txt`),
|
||||
);
|
||||
const ordered = [...variants].sort();
|
||||
const first = ordered[0];
|
||||
const second = ordered[1];
|
||||
|
||||
expect(inventory.occurrences).toHaveLength(1 + variants.length * 2);
|
||||
expect(
|
||||
new Set(inventory.occurrences.map(({ logical_path }) => logical_path))
|
||||
.size,
|
||||
).toBe(1 + variants.length * 2);
|
||||
expect(
|
||||
new Set(
|
||||
[...directories, ...descendants].map(
|
||||
({ occurrence_id }) => occurrence_id,
|
||||
),
|
||||
).size,
|
||||
).toBe(variants.length * 2);
|
||||
expect(
|
||||
directories.every(({ limitations }) => limitations.length === 1),
|
||||
).toBe(true);
|
||||
expect(
|
||||
descendants.every(({ limitations }) => limitations.length === 1),
|
||||
).toBe(true);
|
||||
expect(
|
||||
directories.find(({ logical_path }) => logical_path === first)
|
||||
?.limitations[0],
|
||||
).toContain(`with ${second} and 254 other spellings;`);
|
||||
expect(descendants[0]?.limitations[0]).toContain(
|
||||
`is under ${variants[0]}, which collides under English (en-US) Unicode case folding with ${first} and 254 other spellings;`,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves ordinary directory and ASAR structure", async () => {
|
||||
const root = await createTestTempDirectory("rea-entry-order-readers-");
|
||||
const directory = join(root, "source");
|
||||
|
||||
@@ -305,19 +305,22 @@ describe("ASAR read failures", () => {
|
||||
errno: -5,
|
||||
syscall: "fstat",
|
||||
});
|
||||
const closeError = Object.assign(new Error("device close failed"), {
|
||||
code: "EIO",
|
||||
errno: -5,
|
||||
syscall: "close",
|
||||
});
|
||||
let closeCount = 0;
|
||||
const reader = new AsarArtifactReader(archive, async (path, flags) => {
|
||||
const handle = await openFile(path, flags);
|
||||
expect(await matchingDescriptorCount(identity)).toBeGreaterThan(0);
|
||||
let statCount = 0;
|
||||
return {
|
||||
async stat() {
|
||||
statCount += 1;
|
||||
if (statCount === 2) throw ioError;
|
||||
return handle.stat();
|
||||
throw ioError;
|
||||
},
|
||||
async close() {
|
||||
closeCount += 1;
|
||||
if (closeCount === 1) throw closeError;
|
||||
await handle.close();
|
||||
},
|
||||
createReadStream: (options) => handle.createReadStream(options),
|
||||
@@ -337,73 +340,320 @@ describe("ASAR read failures", () => {
|
||||
message: expect.stringContaining(
|
||||
`Could not read ${entry.path} ASAR at ${archive}: device stat failed`,
|
||||
),
|
||||
cause: { code: "EIO", errno: -5, syscall: "fstat" },
|
||||
cause: {
|
||||
reason: "io",
|
||||
cause: { code: "EIO", errno: -5, syscall: "fstat" },
|
||||
},
|
||||
cleanup: {
|
||||
resources: [unpackedPath],
|
||||
reason: "device close failed",
|
||||
},
|
||||
});
|
||||
expect(closeCount).toBe(1);
|
||||
expect(await matchingDescriptorCount(identity)).toBeGreaterThan(0);
|
||||
await reader.close();
|
||||
expect(closeCount).toBe(2);
|
||||
await waitForNoMatchingDescriptor(identity);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["missing", "replaced"] as const)(
|
||||
"reads a captured zero-length member when the original container is %s",
|
||||
async (change) => {
|
||||
const root = await createTestTempDirectory("rea-asar-empty-member-");
|
||||
const source = join(root, "source");
|
||||
const archive = join(root, "fixture.asar");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "empty.js"), "");
|
||||
await createPackageWithOptions(source, archive, {});
|
||||
const reader = new AsarArtifactReader(archive);
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"retries cleanup when cancellation arrives immediately after unpacked stat",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-asar-unpacked-abort-stat-");
|
||||
const source = join(root, "source");
|
||||
const archive = join(root, "fixture.asar");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "small.js"), "module.exports = 1;\n");
|
||||
await createPackageWithOptions(source, archive, { unpack: "small.js" });
|
||||
const unpackedPath = join(`${archive}.unpacked`, "small.js");
|
||||
const identity = await lstat(unpackedPath);
|
||||
const controller = new AbortController();
|
||||
let closeCount = 0;
|
||||
const reader = new AsarArtifactReader(archive, async (path, flags) => {
|
||||
const handle = await openFile(path, flags);
|
||||
return {
|
||||
async stat() {
|
||||
const observed = await handle.stat();
|
||||
controller.abort();
|
||||
return observed;
|
||||
},
|
||||
async close() {
|
||||
closeCount += 1;
|
||||
if (closeCount === 1) throw new Error("first close failed");
|
||||
await handle.close();
|
||||
},
|
||||
createReadStream: (options) => handle.createReadStream(options),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
const entry = entries.find(({ path }) => path === "empty.js");
|
||||
if (entry === undefined) throw new Error("Expected empty ASAR member");
|
||||
if (change === "missing") await rm(archive);
|
||||
else await writeFile(archive, "replacement container");
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
const entry = entries.find(({ path }) => path === "small.js");
|
||||
if (entry === undefined) throw new Error("Expected unpacked ASAR member");
|
||||
|
||||
expect(await buffer(await reader.open(entry))).toHaveLength(0);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
},
|
||||
await expect(reader.open(entry, controller.signal)).rejects.toMatchObject(
|
||||
{
|
||||
reason: "cancelled",
|
||||
cleanup: {
|
||||
resources: [unpackedPath],
|
||||
reason: "first close failed",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(closeCount).toBe(1);
|
||||
expect(await matchingDescriptorCount(identity)).toBeGreaterThan(0);
|
||||
await reader.close();
|
||||
expect(closeCount).toBe(2);
|
||||
await waitForNoMatchingDescriptor(identity);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"preserves an unpacked identity failure when closing its handle fails",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-asar-unpacked-identity-");
|
||||
const source = join(root, "source");
|
||||
const archive = join(root, "fixture.asar");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "small.js"), "module.exports = 1;\n");
|
||||
await createPackageWithOptions(source, archive, { unpack: "small.js" });
|
||||
const unpackedPath = join(`${archive}.unpacked`, "small.js");
|
||||
const identity = await lstat(unpackedPath);
|
||||
const closeError = new Error("first close failed");
|
||||
let closeCount = 0;
|
||||
const reader = new AsarArtifactReader(archive, async (path, flags) => {
|
||||
const handle = await openFile(path, flags);
|
||||
return {
|
||||
async stat() {
|
||||
const observed = await handle.stat();
|
||||
return Object.assign(Object.create(observed), {
|
||||
ino: observed.ino + 1,
|
||||
});
|
||||
},
|
||||
async close() {
|
||||
closeCount += 1;
|
||||
if (closeCount === 1) throw closeError;
|
||||
await handle.close();
|
||||
},
|
||||
createReadStream: (options) => handle.createReadStream(options),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
const entry = entries.find(({ path }) => path === "small.js");
|
||||
if (entry === undefined) throw new Error("Expected unpacked ASAR member");
|
||||
|
||||
await expect(reader.open(entry)).rejects.toMatchObject({
|
||||
name: "ArtifactReaderFailure",
|
||||
reason: "integrity",
|
||||
message: expect.stringContaining(
|
||||
`ASAR unpacked entry changed before read: ${entry.path}`,
|
||||
),
|
||||
cleanup: {
|
||||
resources: [unpackedPath],
|
||||
reason: "first close failed",
|
||||
},
|
||||
});
|
||||
expect(closeCount).toBe(1);
|
||||
expect(await matchingDescriptorCount(identity)).toBeGreaterThan(0);
|
||||
await reader.close();
|
||||
expect(closeCount).toBe(2);
|
||||
await waitForNoMatchingDescriptor(identity);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"retains unpacked handles and snapshots across combined cleanup failures",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-asar-combined-cleanup-");
|
||||
const source = join(root, "source");
|
||||
const archive = join(root, "fixture.asar");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "small.js"), "module.exports = 1;\n");
|
||||
await createPackageWithOptions(source, archive, { unpack: "small.js" });
|
||||
const unpackedPath = join(`${archive}.unpacked`, "small.js");
|
||||
const identity = await lstat(unpackedPath);
|
||||
const closeError = new Error("unpacked close denied");
|
||||
const snapshotError = new Error("snapshot removal denied");
|
||||
let closeCount = 0;
|
||||
const snapshotPaths: string[] = [];
|
||||
const reader = new AsarArtifactReader(
|
||||
archive,
|
||||
async (path, flags) => {
|
||||
const handle = await openFile(path, flags);
|
||||
return {
|
||||
async stat() {
|
||||
throw Object.assign(new Error("device stat failed"), {
|
||||
code: "EIO",
|
||||
errno: -5,
|
||||
syscall: "fstat",
|
||||
});
|
||||
},
|
||||
async close() {
|
||||
closeCount += 1;
|
||||
if (closeCount < 3) throw closeError;
|
||||
await handle.close();
|
||||
},
|
||||
createReadStream: (options) => handle.createReadStream(options),
|
||||
};
|
||||
},
|
||||
async (path) => {
|
||||
snapshotPaths.push(path);
|
||||
if (snapshotPaths.length === 1) throw snapshotError;
|
||||
await rm(path, { recursive: true, force: true });
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
const entry = entries.find(({ path }) => path === "small.js");
|
||||
if (entry === undefined) throw new Error("Expected unpacked ASAR member");
|
||||
|
||||
await expect(reader.open(entry)).rejects.toMatchObject({
|
||||
reason: "io",
|
||||
cleanup: { resources: [unpackedPath] },
|
||||
});
|
||||
expect(closeCount).toBe(1);
|
||||
|
||||
const cleanupFailure = await reader.close().then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
expect(cleanupFailure).toMatchObject({
|
||||
cleanup: {
|
||||
resources: [unpackedPath, expect.any(String)],
|
||||
reason: expect.stringContaining("unpacked close denied"),
|
||||
},
|
||||
});
|
||||
const snapshotRoot = snapshotPaths[0];
|
||||
if (snapshotRoot === undefined)
|
||||
throw new Error("Expected owned ASAR snapshot root");
|
||||
expect(snapshotPaths).toEqual([snapshotRoot]);
|
||||
expect(cleanupFailure).toMatchObject({
|
||||
cleanup: { resources: [unpackedPath, snapshotRoot] },
|
||||
});
|
||||
expect(closeCount).toBe(2);
|
||||
expect(await matchingDescriptorCount(identity)).toBeGreaterThan(0);
|
||||
expect((await lstat(snapshotRoot)).isDirectory()).toBe(true);
|
||||
|
||||
await reader.close();
|
||||
expect(closeCount).toBe(3);
|
||||
expect(snapshotPaths).toEqual([snapshotRoot, snapshotRoot]);
|
||||
await waitForNoMatchingDescriptor(identity);
|
||||
await expect(lstat(snapshotRoot)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"releases unpacked handles after stream completion and cancellation",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-asar-unpacked-stream-");
|
||||
const source = join(root, "source");
|
||||
const archive = join(root, "fixture.asar");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "small.js"), "module.exports = 1;\n");
|
||||
await createPackageWithOptions(source, archive, { unpack: "small.js" });
|
||||
const identity = await lstat(join(`${archive}.unpacked`, "small.js"));
|
||||
const reader = new AsarArtifactReader(archive);
|
||||
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
const entry = entries.find(({ path }) => path === "small.js");
|
||||
if (entry === undefined) throw new Error("Expected unpacked ASAR member");
|
||||
expect((await buffer(await reader.open(entry))).toString()).toBe(
|
||||
"module.exports = 1;\n",
|
||||
);
|
||||
await waitForNoMatchingDescriptor(identity);
|
||||
|
||||
const controller = new AbortController();
|
||||
const output = await reader.open(entry, controller.signal);
|
||||
const reading = buffer(output);
|
||||
controller.abort();
|
||||
await expect(reading).rejects.toMatchObject({ reason: "cancelled" });
|
||||
await waitForNoMatchingDescriptor(identity);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["missing", "replaced"] as const)(
|
||||
"reads a captured zero-length member when the original container is %s",
|
||||
async (change) => {
|
||||
const root = await createTestTempDirectory("rea-asar-empty-member-");
|
||||
const source = join(root, "source");
|
||||
const archive = join(root, "fixture.asar");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "empty.js"), "");
|
||||
await createPackageWithOptions(source, archive, {});
|
||||
const reader = new AsarArtifactReader(archive);
|
||||
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
const entry = entries.find(({ path }) => path === "empty.js");
|
||||
if (entry === undefined) throw new Error("Expected empty ASAR member");
|
||||
if (change === "missing") await rm(archive);
|
||||
else await writeFile(archive, "replacement container");
|
||||
|
||||
expect(await buffer(await reader.open(entry))).toHaveLength(0);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves filesystem errors raised while consuming a member stream", async () => {
|
||||
const ioError = Object.assign(new Error("device read failed"), {
|
||||
code: "EIO",
|
||||
errno: -5,
|
||||
syscall: "read",
|
||||
});
|
||||
const source = Readable.from(
|
||||
(async function* () {
|
||||
yield Buffer.from("partial");
|
||||
throw ioError;
|
||||
})(),
|
||||
);
|
||||
const output = readValidatedAsarEntry(
|
||||
source,
|
||||
undefined,
|
||||
"nested/member.bin",
|
||||
"/tmp/source.asar",
|
||||
);
|
||||
|
||||
it("preserves filesystem errors raised while consuming a member stream", async () => {
|
||||
const ioError = Object.assign(new Error("device read failed"), {
|
||||
code: "EIO",
|
||||
errno: -5,
|
||||
syscall: "read",
|
||||
});
|
||||
const source = Readable.from(
|
||||
(async function* () {
|
||||
yield Buffer.from("partial");
|
||||
throw ioError;
|
||||
})(),
|
||||
);
|
||||
const output = readValidatedAsarEntry(
|
||||
source,
|
||||
undefined,
|
||||
"nested/member.bin",
|
||||
"/tmp/source.asar",
|
||||
);
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of output) {
|
||||
// Consume through the source failure.
|
||||
}
|
||||
}).rejects.toMatchObject({
|
||||
name: "ArtifactReaderFailure",
|
||||
reason: "io",
|
||||
message: expect.stringContaining(
|
||||
"Could not read nested/member.bin ASAR at /tmp/source.asar",
|
||||
),
|
||||
cause: { code: "EIO", errno: -5, syscall: "read" },
|
||||
});
|
||||
await expect(async () => {
|
||||
for await (const _chunk of output) {
|
||||
// Consume through the source failure.
|
||||
}
|
||||
}).rejects.toMatchObject({
|
||||
name: "ArtifactReaderFailure",
|
||||
reason: "io",
|
||||
message: expect.stringContaining(
|
||||
"Could not read nested/member.bin ASAR at /tmp/source.asar",
|
||||
),
|
||||
cause: { code: "EIO", errno: -5, syscall: "read" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { parse as parseJsonc } from "jsonc-parser";
|
||||
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
|
||||
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
|
||||
import { systemSetupHost } from "../../../src/application/SetupHost.js";
|
||||
import { MANAGED_SKILL_FILES } from "../../../src/application/SetupSkill.js";
|
||||
import { supportedClients } from "../../../src/application/SupportedClients.js";
|
||||
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
|
||||
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
|
||||
@@ -151,6 +152,7 @@ describe("Qwen Code profile setup", () => {
|
||||
});
|
||||
expect(await setup.installSkill([client.name])).toBe("installed");
|
||||
const skill = join(home, ".agents", "skills", PRODUCT_IDENTITY.skillName);
|
||||
await writeFile(join(skill, "user-notes.txt"), "user-authored");
|
||||
expect(await readFile(join(skill, "SKILL.md"), "utf8")).toContain(
|
||||
"reverse-engineer-anything",
|
||||
);
|
||||
@@ -173,7 +175,13 @@ describe("Qwen Code profile setup", () => {
|
||||
expect(registered).toEqual(client);
|
||||
expect((await uninstall.removeClient(client)).status).toBe("removed");
|
||||
expect((await uninstall.removeSkill()).status).toBe("removed");
|
||||
await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
for (const relativePath of MANAGED_SKILL_FILES)
|
||||
await expect(access(join(skill, relativePath))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
expect(await readFile(join(skill, "user-notes.txt"), "utf8")).toBe(
|
||||
"user-authored",
|
||||
);
|
||||
if (customHome)
|
||||
await expect(access(join(home, ".qwen"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
|
||||
@@ -2,10 +2,14 @@ import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
readlink,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
@@ -17,7 +21,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
|
||||
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
|
||||
|
||||
describe("safe artifact output tree", () => {
|
||||
describe("safe artifact output cleanup", () => {
|
||||
it.skipIf(process.platform !== "linux")(
|
||||
"closes the parent descriptor when the output disappears before commit",
|
||||
async () => {
|
||||
@@ -32,12 +36,169 @@ describe("safe artifact output tree", () => {
|
||||
),
|
||||
);
|
||||
expect(targets.filter((target) => target === parent)).toEqual([]);
|
||||
const failure = await tree.rollback().catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(failure).toMatchObject({ cleanup: { resources: [output] } });
|
||||
expect(tree.cleanup).toEqual({
|
||||
status: "incomplete",
|
||||
residualPaths: ["published"],
|
||||
});
|
||||
await rm(parent, { recursive: true, force: true });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("safe artifact output tree identity", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses a replaced root and retries cleanup when the owned root is restored",
|
||||
async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-root-");
|
||||
const output = join(parent, "published");
|
||||
const displaced = join(parent, "owned-original");
|
||||
const outside = join(parent, "outside");
|
||||
await mkdir(outside);
|
||||
const tree = await SafeOutputTree.create(output);
|
||||
await rename(output, displaced);
|
||||
await symlink(outside, output, "dir");
|
||||
|
||||
const writeFailure = await tree
|
||||
.write("escaped.txt", Readable.from(Buffer.from("outside")), {
|
||||
sha256: createHash("sha256").update("outside").digest("hex"),
|
||||
bytes: Buffer.byteLength("outside"),
|
||||
})
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(writeFailure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
await expect(access(join(outside, "escaped.txt"))).rejects.toThrow();
|
||||
await expect(tree.commit()).rejects.toBeInstanceOf(ArtifactReaderFailure);
|
||||
const cleanupFailure = await tree
|
||||
.rollback()
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(cleanupFailure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(cleanupFailure).toMatchObject({
|
||||
cleanup: { resources: [output] },
|
||||
});
|
||||
expect(await readlink(output)).toBe(outside);
|
||||
|
||||
await rm(output);
|
||||
await rename(displaced, output);
|
||||
expect(await tree.rollback()).toEqual({
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
expect(await readdir(parent)).toEqual(["outside"]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses a replaced nested parent without touching it and retries after restoration",
|
||||
async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-parent-");
|
||||
const output = join(parent, "published");
|
||||
const nested = join(output, "nested");
|
||||
const displaced = join(parent, "owned-nested");
|
||||
const outside = join(parent, "outside");
|
||||
await mkdir(outside);
|
||||
await writeFile(join(outside, "sentinel.txt"), "keep");
|
||||
const tree = await SafeOutputTree.create(output);
|
||||
const bytes = Buffer.from("owned");
|
||||
await tree.write("nested/owned.txt", Readable.from(bytes), {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.byteLength,
|
||||
});
|
||||
await rename(nested, displaced);
|
||||
await symlink(outside, nested, "dir");
|
||||
|
||||
const writeFailure = await tree
|
||||
.write("nested/escaped.txt", Readable.from(Buffer.from("outside")), {
|
||||
sha256: createHash("sha256").update("outside").digest("hex"),
|
||||
bytes: Buffer.byteLength("outside"),
|
||||
})
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(writeFailure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
await expect(access(join(outside, "escaped.txt"))).rejects.toThrow();
|
||||
expect(await readFile(join(outside, "sentinel.txt"), "utf8")).toBe(
|
||||
"keep",
|
||||
);
|
||||
const cleanupFailure = await tree
|
||||
.rollback()
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(cleanupFailure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(await readlink(nested)).toBe(outside);
|
||||
expect(await readFile(join(outside, "sentinel.txt"), "utf8")).toBe(
|
||||
"keep",
|
||||
);
|
||||
|
||||
await rm(nested);
|
||||
await rename(displaced, nested);
|
||||
expect(await tree.rollback()).toEqual({
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
expect(await readdir(parent)).toEqual(["outside"]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"preserves an outside file when a tracked output file is replaced by a symlink",
|
||||
async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-file-");
|
||||
const output = join(parent, "published");
|
||||
const tracked = join(output, "nested", "file.txt");
|
||||
const displaced = join(parent, "owned-file.txt");
|
||||
const outside = join(parent, "outside.txt");
|
||||
await writeFile(outside, "outside bytes");
|
||||
const tree = await SafeOutputTree.create(output);
|
||||
const bytes = Buffer.from("owned bytes");
|
||||
await tree.write("nested/file.txt", Readable.from(bytes), {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.byteLength,
|
||||
});
|
||||
await rename(tracked, displaced);
|
||||
await symlink(outside, tracked, "file");
|
||||
|
||||
const failure = await tree.rollback().catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(tree.cleanup).toMatchObject({ status: "incomplete" });
|
||||
expect(await readlink(tracked)).toBe(outside);
|
||||
expect(await readFile(outside, "utf8")).toBe("outside bytes");
|
||||
|
||||
await rm(tracked);
|
||||
await rename(displaced, tracked);
|
||||
expect(await tree.rollback()).toEqual({
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
expect(await readFile(outside, "utf8")).toBe("outside bytes");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("retains an untracked regular entry and retries after it is removed", async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-untracked-");
|
||||
const output = join(parent, "published");
|
||||
const untracked = join(output, "untracked.txt");
|
||||
const tree = await SafeOutputTree.create(output);
|
||||
const bytes = Buffer.from("owned bytes");
|
||||
await tree.write("owned.txt", Readable.from(bytes), {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.byteLength,
|
||||
});
|
||||
await writeFile(untracked, "leave untouched");
|
||||
|
||||
const failure = await tree.rollback().catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(tree.cleanup).toMatchObject({ status: "incomplete" });
|
||||
expect(await readdir(output)).toEqual(["untracked.txt"]);
|
||||
expect(await readFile(untracked, "utf8")).toBe("leave untouched");
|
||||
|
||||
await rm(untracked);
|
||||
expect(await tree.rollback()).toEqual({
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
expect(await readdir(parent)).toEqual([]);
|
||||
});
|
||||
describe("safe artifact output tree", () => {
|
||||
it("removes only its owned tree after digest failure and proves absence", async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-");
|
||||
const output = join(parent, "published");
|
||||
@@ -72,6 +233,7 @@ describe("safe artifact output tree", () => {
|
||||
const failure = await tree.rollback().catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(failure).toMatchObject({
|
||||
cause: { code: "EACCES" },
|
||||
cleanup: { resources: [output] },
|
||||
});
|
||||
} finally {
|
||||
@@ -81,6 +243,54 @@ describe("safe artifact output tree", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"retains cleanup progress when one owned sibling is not writable",
|
||||
async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-siblings-");
|
||||
const output = join(parent, "published");
|
||||
const completed = join(output, "completed");
|
||||
const blocked = join(output, "blocked");
|
||||
const tree = await SafeOutputTree.create(output);
|
||||
for (const directory of ["completed", "blocked"]) {
|
||||
const bytes = Buffer.from(join(output, directory));
|
||||
await tree.write(`${directory}/file.txt`, Readable.from(bytes), {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await chmod(blocked, 0o500);
|
||||
const failure = await tree.rollback().catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(failure).toMatchObject({
|
||||
cause: { code: "EACCES" },
|
||||
cleanup: { resources: [output] },
|
||||
});
|
||||
expect(await readdir(output)).toEqual(["blocked"]);
|
||||
await expect(access(completed)).rejects.toThrow();
|
||||
expect(await readFile(join(blocked, "file.txt"), "utf8")).toBe(blocked);
|
||||
await expect(
|
||||
tree.write("new/file.txt", Readable.from(Buffer.from("late")), {
|
||||
sha256: createHash("sha256").update("late").digest("hex"),
|
||||
bytes: Buffer.byteLength("late"),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ArtifactReaderFailure);
|
||||
await expect(tree.commit()).rejects.toBeInstanceOf(
|
||||
ArtifactReaderFailure,
|
||||
);
|
||||
} finally {
|
||||
await chmod(blocked, 0o700);
|
||||
}
|
||||
|
||||
expect(await tree.rollback()).toEqual({
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
expect(await readdir(parent)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("publishes files while building and preserves them after sealing", async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-");
|
||||
const output = join(parent, "published");
|
||||
@@ -115,6 +325,13 @@ describe("safe artifact output tree", () => {
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
await expect(
|
||||
tree.write("late.txt", Readable.from(Buffer.from("late")), {
|
||||
sha256: createHash("sha256").update("late").digest("hex"),
|
||||
bytes: Buffer.byteLength("late"),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ArtifactReaderFailure);
|
||||
await expect(tree.commit()).rejects.toBeInstanceOf(ArtifactReaderFailure);
|
||||
});
|
||||
|
||||
it("publishes without POSIX-only directory chmod or fsync on Windows", async () => {
|
||||
|
||||
@@ -187,12 +187,11 @@ describe("canonical skill transaction", () => {
|
||||
expect(installedSkill).toContain(
|
||||
`version: "${PRODUCT_IDENTITY.skillVersion}"`,
|
||||
);
|
||||
expect(installedSkill).toContain("call available analysis tools");
|
||||
expect(installedSkill).toContain("obtain approval before setup writes");
|
||||
expect(await readFile(`${nativeGuide}.rea.backup`, "utf8")).toBe(
|
||||
"stale filesystem-write permission grant\n",
|
||||
);
|
||||
for (const reference of [
|
||||
"connection-and-recovery.md",
|
||||
"native-and-artifacts.md",
|
||||
"javascript-applications.md",
|
||||
"android-applications.md",
|
||||
@@ -243,6 +242,7 @@ describe("canonical skill transaction", () => {
|
||||
const paths = [
|
||||
"SKILL.md",
|
||||
"references/android-applications.md",
|
||||
"references/connection-and-recovery.md",
|
||||
"references/evidence-workflows.md",
|
||||
"references/javascript-applications.md",
|
||||
"references/native-and-artifacts.md",
|
||||
|
||||
@@ -324,7 +324,7 @@ it("cancels a waiting request promptly while keeping later launches behind the a
|
||||
await assertFirmwareCleanup(fixture.launches);
|
||||
});
|
||||
|
||||
it("retains an uncertain workspace and lets the next operation create a new root", async () => {
|
||||
it("retains an uncertain worker's workspace and blocks later launches", async () => {
|
||||
const fixture = await firmwareFixture("cleanup-failure");
|
||||
const first = await fixture.service.execute("inspect_firmware_regions", {
|
||||
path: fixture.path,
|
||||
@@ -335,18 +335,22 @@ it("retains an uncertain workspace and lets the next operation create a new root
|
||||
});
|
||||
const workspace = fixture.launches[0]?.cwd;
|
||||
expect(workspace).toBeDefined();
|
||||
if (workspace === undefined) return;
|
||||
if (workspace === undefined) throw new Error("Expected retained workspace");
|
||||
await access(workspace);
|
||||
const originalLaunches = [...fixture.launches];
|
||||
const next = await fixture.service.execute("inspect_firmware_regions", {
|
||||
path: fixture.path,
|
||||
});
|
||||
expect(next).toMatchObject({ ok: false, error: { cleanupIncomplete: true } });
|
||||
expect(fixture.launches).toHaveLength(2);
|
||||
const nextWorkspace = fixture.launches[1]?.cwd;
|
||||
expect(nextWorkspace).toBeDefined();
|
||||
expect(nextWorkspace).not.toBe(workspace);
|
||||
expect(fixture.launches).toEqual(originalLaunches);
|
||||
await access(workspace);
|
||||
if (nextWorkspace !== undefined) await access(nextWorkspace);
|
||||
fixture.restoreCleanup();
|
||||
const resumed = await fixture.service.execute("inspect_firmware_regions", {
|
||||
path: fixture.path,
|
||||
});
|
||||
if (!resumed.ok) throw resumed.error;
|
||||
expect(resumed.value.subject?.local_path).toBe(fixture.path);
|
||||
await assertFirmwareCleanup(fixture.launches);
|
||||
});
|
||||
|
||||
it("accepts another build on the verified release line and reports it", async () => {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { access, chmod, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { expect, it } from "vitest";
|
||||
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { firmwareResultSchemas } from "../../../src/domain/firmware/firmwareAnalysis.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { ToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import {
|
||||
assertFirmwareCleanup,
|
||||
firmwareFixture,
|
||||
} from "../../fixtures/firmware/provider.js";
|
||||
|
||||
it.skipIf(process.platform !== "linux" || process.getuid?.() === 0)(
|
||||
"retains completed extractions and retries all workspaces without blocking stopped-worker operations",
|
||||
async () => {
|
||||
const fixture = await firmwareFixture("workspace-cleanup-failure");
|
||||
const sourceBytes = await readFile(fixture.path);
|
||||
const sourceSha256 = createHash("sha256").update(sourceBytes).digest("hex");
|
||||
const workspaces = new Set<string>();
|
||||
try {
|
||||
for (const output of [
|
||||
fixture.output,
|
||||
join(fixture.root, "second-output"),
|
||||
]) {
|
||||
const extracted = await fixture.service.execute("extract_firmware", {
|
||||
path: fixture.path,
|
||||
output_directory: output,
|
||||
range: { offset: 2, length: 4 },
|
||||
});
|
||||
const workspace = fixture.launches.at(-1)?.cwd;
|
||||
if (workspace === undefined)
|
||||
throw new Error("Expected owned workspace");
|
||||
expect(workspaces.has(workspace)).toBe(false);
|
||||
workspaces.add(workspace);
|
||||
if (extracted.ok) throw new Error("Expected workspace removal failure");
|
||||
expect(extracted.error.cleanupIncomplete).toBe(true);
|
||||
const partial = extracted.error.partialObservation;
|
||||
if (
|
||||
partial === undefined ||
|
||||
!("kind" in partial) ||
|
||||
partial.kind !== "firmware"
|
||||
)
|
||||
throw new Error("Expected completed firmware observation");
|
||||
expect(partial.provenance).toMatchObject({
|
||||
subject: {
|
||||
format: "file",
|
||||
path: fixture.path,
|
||||
sha256: sourceSha256,
|
||||
},
|
||||
provider: { id: "unblob", version: "26.6.4" },
|
||||
raw_result: {
|
||||
report: expect.any(Array),
|
||||
execution: { exit_code: 0 },
|
||||
},
|
||||
locations: [{ kind: "file-offset-range", start: 2, end: 6 }],
|
||||
});
|
||||
const result = firmwareResultSchemas.extract_firmware.parse(
|
||||
partial.result,
|
||||
);
|
||||
expect(result.selection).toEqual({
|
||||
offset: 2,
|
||||
length: 4,
|
||||
sha256: createHash("sha256")
|
||||
.update(sourceBytes.subarray(2, 6))
|
||||
.digest("hex"),
|
||||
});
|
||||
const file = result.files[0];
|
||||
if (file === undefined)
|
||||
throw new Error("Expected published extracted file");
|
||||
expect(await readFile(file.path, "utf8")).toBe("firmware=true\n");
|
||||
expect(extracted.error.cleanupResources).toContain(workspace);
|
||||
await access(workspace);
|
||||
const delivered = new ToolResultDelivery(
|
||||
STDIO_DEFAULT_MAX_BUFFER_SIZE,
|
||||
).toCallToolResult(extracted, toolContract("extract_firmware"));
|
||||
expect(delivered.isError).toBe(true);
|
||||
expect(delivered.structuredContent).toMatchObject({
|
||||
error: {
|
||||
details: {
|
||||
partial_observation: {
|
||||
kind: "firmware",
|
||||
result,
|
||||
provenance: partial.provenance,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const inspected = await fixture.service.execute(
|
||||
"inspect_firmware_regions",
|
||||
{
|
||||
path: fixture.path,
|
||||
},
|
||||
);
|
||||
if (!inspected.ok) throw inspected.error;
|
||||
expect(inspected.value.subject?.local_path).toBe(fixture.path);
|
||||
expect(
|
||||
firmwareResultSchemas.inspect_firmware_regions.parse(
|
||||
inspected.value.normalized_result,
|
||||
).regions[0]?.signature,
|
||||
).toBe("gzip");
|
||||
}
|
||||
await expect(fixture.close()).rejects.toMatchObject({
|
||||
cleanupResources: expect.arrayContaining([...workspaces]),
|
||||
});
|
||||
for (const workspace of workspaces) await access(workspace);
|
||||
} finally {
|
||||
for (const workspace of workspaces)
|
||||
await chmod(join(workspace, "cleanup-blocked"), 0o700);
|
||||
await fixture.close();
|
||||
}
|
||||
await assertFirmwareCleanup(fixture.launches);
|
||||
expect(await readFile(fixture.path)).toEqual(sourceBytes);
|
||||
for (const output of [fixture.output, join(fixture.root, "second-output")])
|
||||
expect(await readFile(join(output, "rootfs/config"), "utf8")).toBe(
|
||||
"firmware=true\n",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform !== "linux" || process.getuid?.() === 0)(
|
||||
"preserves the report failure alongside a workspace removal failure",
|
||||
async () => {
|
||||
const fixture = await firmwareFixture(
|
||||
"workspace-cleanup-failure-malformed",
|
||||
);
|
||||
let workspace: string | undefined;
|
||||
try {
|
||||
const failed = await fixture.service.execute("extract_firmware", {
|
||||
path: fixture.path,
|
||||
output_directory: fixture.output,
|
||||
});
|
||||
workspace = fixture.launches.at(-1)?.cwd;
|
||||
if (failed.ok || workspace === undefined)
|
||||
throw new Error("Expected both report and cleanup failures");
|
||||
expect(projectAnalysisError(failed.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
diagnostics: {
|
||||
primary_error: { code: "unreadable_output" },
|
||||
cleanup_error: {
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
diagnostics: { reason: expect.stringContaining("EACCES") },
|
||||
},
|
||||
},
|
||||
},
|
||||
resources: expect.arrayContaining([workspace]),
|
||||
},
|
||||
});
|
||||
await expect(access(fixture.output)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
} finally {
|
||||
if (workspace !== undefined)
|
||||
await chmod(join(workspace, "cleanup-blocked"), 0o700);
|
||||
await fixture.close();
|
||||
}
|
||||
await assertFirmwareCleanup(fixture.launches);
|
||||
},
|
||||
);
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
analysisViewJavaScriptEvidence,
|
||||
analysisViewJavaScriptAnalysisWithSource,
|
||||
analysisViewBindJavaScriptGraphs,
|
||||
analysisViewLayoutEvidence,
|
||||
analysisViewLayoutFixture,
|
||||
} from "../../fixtures/analysisView.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
@@ -111,6 +112,35 @@ it("advertises exact schemas and projects one layout section from retained Evide
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a tampered inline Evidence ID at its full MCP source path", async () => {
|
||||
const { client } = await connect();
|
||||
const parent = analysisViewLayoutEvidence();
|
||||
const response = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source: {
|
||||
kind: "inline",
|
||||
evidence: { ...parent, evidence_id: `ev_${"0".repeat(64)}` },
|
||||
},
|
||||
view: { kind: "summary" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(parseMcpToolError(response).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
category: "invalid_input",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["source", "evidence", "evidence_id"],
|
||||
reason: "invalid_value",
|
||||
message: "Evidence semantic identifier does not match its record",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers a bounded native dossier view over MCP without a Ghidra provider or lost connection", async () => {
|
||||
const { client, session } = await connect();
|
||||
const original = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
|
||||
@@ -36,8 +36,8 @@ const writeBlockingCompiler = async (directory: string) => {
|
||||
const statePath = join(directory, "compile-state.json");
|
||||
const releasePath = join(directory, "release-compiler");
|
||||
const source = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const fs = process.getBuiltinModule("node:fs");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const statePath = ${JSON.stringify(statePath)};
|
||||
const releasePath = ${JSON.stringify(releasePath)};
|
||||
const state = fs.existsSync(statePath)
|
||||
@@ -69,14 +69,14 @@ const writeBlockingIdentityCompiler = async (directory: string) => {
|
||||
const executable = join(directory, "fake-identity-xcrun");
|
||||
const statePath = join(directory, "identity-helper-state.json");
|
||||
const helperSource = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const fs = process.getBuiltinModule("node:fs");
|
||||
fs.writeFileSync(${JSON.stringify(statePath)}, JSON.stringify({
|
||||
root: __dirname,
|
||||
root: process.getBuiltinModule("node:path").dirname(fs.realpathSync(process.argv[1])),
|
||||
}));
|
||||
setInterval(() => {}, 1000);
|
||||
`;
|
||||
const compilerSource = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const fs = process.getBuiltinModule("node:fs");
|
||||
const outputIndex = process.argv.indexOf("-o");
|
||||
const output = process.argv[outputIndex + 1];
|
||||
fs.writeFileSync(output, ${JSON.stringify(helperSource)});
|
||||
@@ -309,14 +309,14 @@ const writeTemporaryObjectCompiler = async (directory: string) => {
|
||||
const executable = join(directory, "fake-temporary-xcrun");
|
||||
const statePath = join(directory, "temporary-state.json");
|
||||
const source = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const fs = process.getBuiltinModule("node:fs");
|
||||
const os = process.getBuiltinModule("node:os");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const { spawn } = process.getBuiltinModule("node:child_process");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "TemporaryDirectory."));
|
||||
const object = path.join(temporary, "ProcessRunTokenReader-1.o");
|
||||
const job = spawn(process.execPath, ["-e",
|
||||
"setTimeout(() => { const fs = require('node:fs'); fs.mkdirSync(" + JSON.stringify(temporary) +
|
||||
"setTimeout(() => { const fs = process.getBuiltinModule('node:fs'); fs.mkdirSync(" + JSON.stringify(temporary) +
|
||||
", { recursive: true }); fs.writeFileSync(" + JSON.stringify(object) + ", 'object'); }, 300)"],
|
||||
{ detached: true, stdio: "ignore" });
|
||||
job.unref();
|
||||
@@ -567,9 +567,9 @@ it.skipIf(process.platform === "win32")(
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"preserves cancellation through system-host baseline identity inspection",
|
||||
async () => {
|
||||
it.skipIf(process.platform === "win32").each(["baseline", "tokens"] as const)(
|
||||
"preserves cancellation through system-host %s inspection",
|
||||
async (kind) => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), "rea-process-baseline-abort-test-"),
|
||||
);
|
||||
@@ -579,8 +579,23 @@ it.skipIf(process.platform === "win32")(
|
||||
});
|
||||
const controller = new AbortController();
|
||||
let helperRoot: string | undefined;
|
||||
const inspection =
|
||||
kind === "baseline"
|
||||
? host.captureBaseline?.(controller.signal)
|
||||
: host.runTokens?.(
|
||||
[
|
||||
{
|
||||
pid: process.pid,
|
||||
parentPid: process.ppid,
|
||||
processGroupId: process.pid,
|
||||
state: "S",
|
||||
command: process.execPath,
|
||||
},
|
||||
],
|
||||
controller.signal,
|
||||
);
|
||||
const baseline =
|
||||
host.captureBaseline?.(controller.signal).then(
|
||||
inspection?.then(
|
||||
() => ({ state: "fulfilled" as const }),
|
||||
(cause: unknown) => ({ state: "rejected" as const, cause }),
|
||||
) ?? Promise.resolve({ state: "missing" as const });
|
||||
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user