Compare commits
141
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ac6832aa6 | ||
|
|
83e52559b8 | ||
|
|
824ecf109d | ||
|
|
8b4c3de3b3 | ||
|
|
f998fcb1cc | ||
|
|
f88c99021b | ||
|
|
49e6f00ed0 | ||
|
|
af88184eee | ||
|
|
c1efa6467d | ||
|
|
1e51c732bb | ||
|
|
7956934965 | ||
|
|
db4e360846 | ||
|
|
7bb4ac219c | ||
|
|
3df67c640a | ||
|
|
044aa34ce6 | ||
|
|
31bfff6fdf | ||
|
|
026126eafe | ||
|
|
29cc1b8746 | ||
|
|
ad41afa2b5 | ||
|
|
69a8b3a954 | ||
|
|
4733fc810a | ||
|
|
d21132fc97 | ||
|
|
66c344e289 | ||
|
|
28bd0d990a | ||
|
|
2612e474b6 | ||
|
|
a11fbc0df9 | ||
|
|
639b73097b | ||
|
|
bb074d84cb | ||
|
|
204e2c42a1 | ||
|
|
87edf14940 | ||
|
|
890dfa49f6 | ||
|
|
bc7846db91 | ||
|
|
5485483f7d | ||
|
|
c916850852 | ||
|
|
26038912f6 | ||
|
|
aa7c5b7b5e | ||
|
|
095fad9406 | ||
|
|
8b2f8514c5 | ||
|
|
c57e759ae6 | ||
|
|
7b25a73ac4 | ||
|
|
aa257ea4b1 | ||
|
|
4999e4be2c | ||
|
|
92cc0edd8e | ||
|
|
573598c632 | ||
|
|
8d3cfef753 | ||
|
|
975dff2e25 | ||
|
|
1b527f4898 | ||
|
|
b09dd333ab | ||
|
|
60fbb2f06e | ||
|
|
8b4c96e6c6 | ||
|
|
95a77b6e25 | ||
|
|
67966c69a5 | ||
|
|
279c942fe1 | ||
|
|
54e2890b30 | ||
|
|
0f605bca09 | ||
|
|
423b11c8ef | ||
|
|
52331ab637 | ||
|
|
3e931a7e35 | ||
|
|
6edd5cf045 | ||
|
|
1a6ccec636 | ||
|
|
afc9d819aa | ||
|
|
7b3011b41e | ||
|
|
6849ecf70a | ||
|
|
926c2d6ba7 | ||
|
|
c7345512ea | ||
|
|
53e7fadbd0 | ||
|
|
215001a1ce | ||
|
|
9515d2fcd3 | ||
|
|
ac9677bd6c | ||
|
|
69fe5b6010 | ||
|
|
684199ba0b | ||
|
|
a17e257f43 | ||
|
|
5502ca9c97 | ||
|
|
be75ed5e4b | ||
|
|
3f8c51e9b5 | ||
|
|
e0e49e9dd5 | ||
|
|
be6272f261 | ||
|
|
32d13c431e | ||
|
|
0fa0b04628 | ||
|
|
ffe0574e17 | ||
|
|
87d5ec93d5 | ||
|
|
f1c58cc53f | ||
|
|
6636045c9c | ||
|
|
b74d9a1bb2 | ||
|
|
6e0d5d64d3 | ||
|
|
d6f698d8af | ||
|
|
bbd01923e9 | ||
|
|
5a77a962ec | ||
|
|
6585293c56 | ||
|
|
be44b5098e | ||
|
|
6db97d5162 | ||
|
|
39da0b150b | ||
|
|
3967cfa43e | ||
|
|
2014ee7d68 | ||
|
|
5100a1a1a5 | ||
|
|
de0d341524 | ||
|
|
d1f04512af | ||
|
|
514df0918c | ||
|
|
8fb79c67ee | ||
|
|
6a41a29963 | ||
|
|
c12a532b9c | ||
|
|
b28c0337fc | ||
|
|
f4e437e544 | ||
|
|
8ba0a887ff | ||
|
|
f9ea9a9268 | ||
|
|
78778d91a4 | ||
|
|
d510f3e928 | ||
|
|
043fd2579d | ||
|
|
a761981897 | ||
|
|
2653b194b6 | ||
|
|
16d9c5adef | ||
|
|
b582b6ecb4 | ||
|
|
ba056e08a4 | ||
|
|
cb13a27bfa | ||
|
|
3d79903fb1 | ||
|
|
dce81ca400 | ||
|
|
bb764d26bd | ||
|
|
acd8d86898 | ||
|
|
e6d0b3993d | ||
|
|
92a901e0db | ||
|
|
cef6d6d3c4 | ||
|
|
bd972bf80b | ||
|
|
ac9709a80a | ||
|
|
d1e6fe2eca | ||
|
|
a147ecd4b4 | ||
|
|
22fdcb8ab7 | ||
|
|
b22d817073 | ||
|
|
3d876c5c1a | ||
|
|
cf346368c4 | ||
|
|
e52c0a72c5 | ||
|
|
999e3e2098 | ||
|
|
c1217e711b | ||
|
|
0d89978333 | ||
|
|
11e3fae697 | ||
|
|
603d04ee3a | ||
|
|
472f313232 | ||
|
|
1f7e279aa2 | ||
|
|
db8ccaa251 | ||
|
|
5daff4d67e | ||
|
|
a9fd1aecae | ||
|
|
abb8e81d3a |
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: rea-changelog-update
|
||||
description: Prepare or rewrite REA release changelogs and GitHub release notes from pinned Git history, with verified contributor thanks and Release Please synchronization.
|
||||
---
|
||||
|
||||
# REA changelog update
|
||||
|
||||
Use this for release-note preparation and contributor attribution. Read
|
||||
[docs/releasing.md](../../../docs/releasing.md) from the repository root for
|
||||
publication authority and the existing checkpoint checks. This skill prepares
|
||||
notes; it does not authorize merging, tagging, or publishing.
|
||||
|
||||
## Inventory before prose
|
||||
|
||||
Fetch complete history and tags. Select the published baseline tag from
|
||||
`.release-please-manifest.json` on the release PR's **base branch**, and pin that
|
||||
branch's exact SHA as the editorial target. REA tags are `rea-agents-VERSION`.
|
||||
Use the proposed version, including its prerelease suffix; do not change the
|
||||
`always-bump-minor` strategy to imitate OpenClaw's calendar versions.
|
||||
|
||||
```bash
|
||||
node scripts/release-notes.mjs inventory \
|
||||
--repository morluto/rea --base rea-agents-PREVIOUS \
|
||||
--target FULL_BASE_BRANCH_SHA --version VERSION \
|
||||
--manifest /tmp/rea-VERSION.contributions.json
|
||||
node scripts/release-notes.mjs record \
|
||||
--manifest /tmp/rea-VERSION.contributions.json \
|
||||
--output /tmp/rea-VERSION.record.md
|
||||
```
|
||||
|
||||
Read the inventory, not just the existing bot draft. It includes side-branch
|
||||
commits, GitHub-associated PRs whose merge commits are in the range, commit
|
||||
authors/co-authors, and confirmed closing issues with their reporters. GitHub
|
||||
connections are paginated. Network errors stop generation; rerun into a new
|
||||
output file after recovery. Existing output files are never overwritten.
|
||||
|
||||
Unresolved commit identities are retained as names/emails with `user: null`;
|
||||
never guess a handle or thank the merger as a substitute. The report preserves
|
||||
unknowns. This is a reviewed metadata snapshot, not independent proof that a
|
||||
human authored a patch. For unattributed identities, unusual merge strategies,
|
||||
backports, reversions, or references that GitHub did not associate, inspect
|
||||
the source PRs and diffs and report the limitation. The current collector uses
|
||||
confirmed PR-closing relationships for issue-reporter credit; unrelated issue
|
||||
mentions are not automatically credited. Do not claim that merely inventoried
|
||||
work still ships after a revert.
|
||||
|
||||
## Write for users and retain credit
|
||||
|
||||
Rewrite only the newest `## [VERSION]` section in `CHANGELOG.md`, preserving its
|
||||
version/date/link heading. Use single-line Markdown list items under:
|
||||
|
||||
- `### Highlights`: a few release-defining user outcomes; no quota for small releases.
|
||||
- `### Changes`: capabilities and meaningful behavior changes.
|
||||
- `### Fixes`: reliability, correctness, compatibility, and safety fixes.
|
||||
- `### ⚠ BREAKING CHANGES`: retain when applicable, with migrations and original
|
||||
PR/commit references required by the existing checkpoint validator.
|
||||
|
||||
Group related work by analyst workflow or user impact. Explain the resulting
|
||||
behavior; internal cleanup, tests, CI, and routine documentation generally
|
||||
belong only in the contribution record. Read ambiguous PRs/diffs before making
|
||||
claims. Distinguish simulated-provider checks from real-provider verification.
|
||||
|
||||
Each bullet covering a PR carries its link and `Thanks @...` for its verified
|
||||
human author, known co-authors, and confirmed closing-issue reporters. Grouping
|
||||
must preserve **all** those credits in the same bullet. Include the issue
|
||||
links. Direct-commit bullets carry the commit link and known human credit.
|
||||
Exclude GitHub bots and known agent accounts. REA's observed Anthropic and
|
||||
Cursor co-author trailers resolve to `@claude` and `@cursoragent` with GitHub
|
||||
type `User`; the credit policy excludes them while retaining their metadata.
|
||||
Do not copy OpenClaw's maintainer-specific exclusions.
|
||||
Use explicit GitHub links for cross-repository references to avoid ambiguous
|
||||
`#NNN` numbers. Unknown handles stay unknown; they are not fabricated credit.
|
||||
|
||||
Append the generated `### Complete contribution record` verbatim as the last
|
||||
section of this release. It accounts for every discovered in-range PR and
|
||||
direct contribution, including internal work omitted from the prose. Do not
|
||||
hand-edit generated credits to make validation pass. Refresh the inventory
|
||||
when product history changes; a prose-only correction can reuse the snapshot.
|
||||
|
||||
## Finalize with Release Please
|
||||
|
||||
Before changing the live release PR, have authorization for that PR update.
|
||||
Local preparation and validation can proceed independently.
|
||||
|
||||
1. Add `rea:release-notes-finalized` to the selected open release PR **before**
|
||||
editing its branch/body. Main proposal pushes respect this label. This is
|
||||
our workflow guard, not Release Please's `autorelease: snooze` mechanism.
|
||||
A proposal already running must finish before editing. Do not dispatch
|
||||
manual preparation over finalized notes.
|
||||
2. On that release branch, save the reviewed inventory as
|
||||
`docs/releases/VERSION.contributions.json`. This is durable public release
|
||||
provenance, not a temporary planning file. Do not add credentials or local
|
||||
environment dumps. Keep the existing version artifacts aligned.
|
||||
3. Run the existing checkpoint validator and the new checker:
|
||||
|
||||
```bash
|
||||
node scripts/release-notes.mjs check \
|
||||
--manifest docs/releases/VERSION.contributions.json
|
||||
node scripts/release-notes.mjs render \
|
||||
--manifest docs/releases/VERSION.contributions.json \
|
||||
--output /tmp/rea-VERSION.notes.md
|
||||
```
|
||||
|
||||
The checker supports inline PR bodies, including their surrounding metadata,
|
||||
up to GitHub's 65,536-character limit. It fails on larger notes rather than
|
||||
truncating credit; Release Please's overflow-link bodies are not integrated.
|
||||
|
||||
4. Update the release PR body with the rendered section while preserving its
|
||||
Release Please header/footer and component/version structure. Use a body
|
||||
file for `gh pr edit --body-file`; changing only `CHANGELOG.md` does not
|
||||
change Release Please's release body. Push only authorized changes.
|
||||
5. Freshly fetch the PR body to a file and run `check --pr-body FILE`. Review
|
||||
the complete diff and wait for current CI. Publication checks the body
|
||||
again before release creation. Avoid further body edits while publishing.
|
||||
|
||||
The finalization label freezes bot refreshes, not main. New product changes
|
||||
after the inventory target require refreshing the notes before publication.
|
||||
The workflow checks target ancestry and allows only release metadata paths
|
||||
after that target. It also requires the inventory target to equal the reviewed
|
||||
merge's first parent, so intervening main commits require a fresh inventory.
|
||||
Review diffs within the allowed release metadata paths too: the path guard does
|
||||
not prove that package dependency/script edits are version-only changes.
|
||||
To resume automatic proposals, remove the finalization label and let the bot
|
||||
regenerate; expect editorial changes to be replaced.
|
||||
|
||||
Existing releases without a contribution inventory retain their current
|
||||
publication path. Never retag or republish a package to correct release prose.
|
||||
@@ -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.
|
||||
@@ -38,3 +38,11 @@ or absence:
|
||||
Process captures are opt-in behavioral evidence, not a security sandbox. V3
|
||||
captures cannot be upgraded to V4; rerun the original scenario. Distinguish root
|
||||
exit from descendant settlement and require freshness when the task needs it.
|
||||
A timed-out target that must write a final report needs `finalization_ms`: REA
|
||||
sends SIGTERM to the captured root, keeps observing, and sends SIGKILL once
|
||||
only if the target has not exited by the end of the interval.
|
||||
`exit.finalization.signals` lists each attempt with its delivery result; the
|
||||
observed exit signal stays in `exit.signal`, `exit.code` is null for every
|
||||
deadline exit, and the code of a finalizer that exited on its own is in
|
||||
`exit.finalization.exit_code` (null on a signal exit or when no exit was
|
||||
observed).
|
||||
|
||||
@@ -1,20 +1,106 @@
|
||||
# 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.
|
||||
|
||||
When the connected schema advertises `max_heap_mb` and `analysis_timeout_ms`,
|
||||
these select the isolated source analyzer's old-space heap and per-source
|
||||
deadline. Defaults are 1024 MiB and 300000 ms. They do not change the MCP
|
||||
process heap. Resource-limited analyses preserve completed static facts and
|
||||
report partial semantic coverage with specific unknown scopes; this does not
|
||||
mean the source is malformed. Inspect those limits before retrying a source.
|
||||
When expanded relationships exceed the parent's available memory, inspect the
|
||||
file's modules item view for retained `static_analysis` and
|
||||
`semantic_module_analysis`. Their facts remain available even when graph links
|
||||
are unknown. Cumulative result exhaustion can stop analysis before every file
|
||||
is examined; the inventory and remaining unknown scopes distinguish that case.
|
||||
Timeout or cancellation after source analysis begins retains partial Evidence
|
||||
in the same session. A returned `error.details.partial_observation` reference
|
||||
can be supplied directly to `inspect_analysis_view`. If the SDK cancels locally, retrieve its recorded ID from
|
||||
the Evidence bundle before inspecting the partial analysis.
|
||||
|
||||
## 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 +108,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
|
||||
|
||||
@@ -152,7 +152,7 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu
|
||||
|
||||
### Function annotations in Ghidra
|
||||
|
||||
Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic and session-scoped; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits until close. CLI `annotate-native-function` returns the updated analysis before discarding the session. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations.
|
||||
Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic and session-scoped; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits until close. CLI `annotate-native-function` returns the updated analysis before discarding the session. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations. For naming only, `set_address_name` / `set_addresses_names` also work with Ghidra: they name function entries or data addresses (primary labels) and return compact per-address success booleans instead of a dossier.
|
||||
|
||||
## Offline PE resource inventory
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- hopper
|
||||
- ghidra-12-1-2
|
||||
# Hosted label introduced after the pinned actionlint release.
|
||||
- macos-15-intel
|
||||
+145
-50
@@ -5,6 +5,10 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main, "release/*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -36,37 +40,39 @@ jobs:
|
||||
name: Classify changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
code: ${{ steps.package.outputs.required }}
|
||||
package: ${{ steps.package.outputs.required }}
|
||||
code: ${{ steps.plan.outputs.code }}
|
||||
runtime: ${{ steps.plan.outputs.runtime }}
|
||||
package: ${{ steps.plan.outputs.package }}
|
||||
apple: ${{ steps.plan.outputs.apple }}
|
||||
fixtures: ${{ steps.plan.outputs.fixtures }}
|
||||
readiness: ${{ steps.plan.outputs.readiness }}
|
||||
docs: ${{ steps.plan.outputs.docs }}
|
||||
workflows: ${{ steps.plan.outputs.workflows }}
|
||||
browser: ${{ steps.plan.outputs.browser }}
|
||||
binary_layout: ${{ steps.plan.outputs.binary_layout }}
|
||||
evm: ${{ steps.plan.outputs.evm }}
|
||||
javascript_recovery: ${{ steps.plan.outputs.javascript_recovery }}
|
||||
recorded_crash: ${{ steps.plan.outputs.recorded_crash }}
|
||||
web_network: ${{ steps.plan.outputs.web_network }}
|
||||
web_runtime: ${{ steps.plan.outputs.web_runtime }}
|
||||
web_source_map: ${{ steps.plan.outputs.web_source_map }}
|
||||
nativeaot: ${{ steps.plan.outputs.nativeaot }}
|
||||
website: ${{ steps.plan.outputs.website }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Select package and Windows lanes
|
||||
id: package
|
||||
shell: bash
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Select verification lanes
|
||||
id: plan
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
if [[ "${GITHUB_EVENT_NAME}" != "pull_request" ]]; then
|
||||
echo "required=true" >> "${GITHUB_OUTPUT}"
|
||||
# Compare the PR's changes, excluding unrelated updates on its base.
|
||||
elif git diff --quiet "${BASE_SHA}...${HEAD_SHA}" -- \
|
||||
. \
|
||||
':(exclude)docs/**' \
|
||||
':(top,exclude,glob)README*.md' \
|
||||
':(exclude)AGENTS.md' \
|
||||
':(exclude)CONTRIBUTING.md'; then
|
||||
echo "required=false" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
diff_status=$?
|
||||
# A failed comparison must fail classification, not report a scope.
|
||||
if [[ "${diff_status}" -ne 1 ]]; then
|
||||
exit "${diff_status}"
|
||||
fi
|
||||
echo "required=true" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
CI_FULL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }}
|
||||
run: node scripts/ci/plan.mjs
|
||||
|
||||
static:
|
||||
name: Static checks
|
||||
@@ -84,17 +90,17 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
- if: needs.changes.outputs.code == 'true'
|
||||
run: npm run check:ci
|
||||
- if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
- if: needs.changes.outputs.code == 'true'
|
||||
run: npm run verify:test-discovery
|
||||
- if: github.event_name == 'pull_request' && needs.changes.outputs.code != 'true'
|
||||
- if: needs.changes.outputs.code != 'true'
|
||||
run: npm run format:check
|
||||
|
||||
test-shard:
|
||||
name: Test shard (${{ matrix.shard }})
|
||||
needs: [changes, build]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -131,7 +137,7 @@ jobs:
|
||||
build:
|
||||
name: Build package
|
||||
needs: changes
|
||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -224,7 +230,7 @@ jobs:
|
||||
conformance-fixtures:
|
||||
name: Source-built conformance fixtures
|
||||
needs: changes
|
||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
if: needs.changes.outputs.fixtures == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -235,7 +241,7 @@ jobs:
|
||||
windows-native-build:
|
||||
name: Build bundled Windows x64 native controls
|
||||
needs: changes
|
||||
if: github.event_name == 'push' || needs.changes.outputs.package == 'true'
|
||||
if: needs.changes.outputs.runtime == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -265,24 +271,11 @@ jobs:
|
||||
package-e2e:
|
||||
name: Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }})
|
||||
needs: [changes, windows-native-build]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.package == 'true'
|
||||
if: needs.changes.outputs.runtime == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
architecture: x64
|
||||
- os: ubuntu-24.04-arm
|
||||
platform: linux
|
||||
architecture: arm64
|
||||
- os: macos-15
|
||||
platform: darwin
|
||||
architecture: arm64
|
||||
- os: macos-15-intel
|
||||
platform: darwin
|
||||
architecture: x64
|
||||
matrix: ${{ fromJSON(needs.changes.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
@@ -317,7 +310,7 @@ jobs:
|
||||
apple-artifacts:
|
||||
name: Real Apple artifact CLI/MCP E2E
|
||||
needs: changes
|
||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
if: needs.changes.outputs.apple == 'true'
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -350,7 +343,7 @@ jobs:
|
||||
reconstruction-readiness:
|
||||
name: Reconstruction readiness
|
||||
needs: changes
|
||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
if: needs.changes.outputs.readiness == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -379,7 +372,7 @@ jobs:
|
||||
windows-curated:
|
||||
name: Windows capability and package lane
|
||||
needs: [changes, windows-native-build]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.package == 'true'
|
||||
if: needs.changes.outputs.package == 'true'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
@@ -476,7 +469,8 @@ jobs:
|
||||
|
||||
docs:
|
||||
name: Docs Generation
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -496,3 +490,104 @@ jobs:
|
||||
docs/verification/managed-conformance-*.json
|
||||
skills/reverse-engineer-anything
|
||||
if-no-files-found: error
|
||||
|
||||
workflows:
|
||||
name: Validate workflows
|
||||
needs: changes
|
||||
if: needs.changes.outputs.workflows == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate workflow syntax and expressions
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
|
||||
|
||||
browser:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.browser == 'true'
|
||||
uses: ./.github/workflows/real-browser.yml
|
||||
|
||||
binary_layout:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.binary_layout == 'true'
|
||||
uses: ./.github/workflows/real-binary-layout.yml
|
||||
|
||||
evm:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.evm == 'true'
|
||||
uses: ./.github/workflows/real-evm-interface.yml
|
||||
|
||||
javascript_recovery:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.javascript_recovery == 'true'
|
||||
uses: ./.github/workflows/real-javascript-recovery.yml
|
||||
|
||||
recorded_crash:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.recorded_crash == 'true'
|
||||
uses: ./.github/workflows/real-recorded-crash.yml
|
||||
|
||||
web_network:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.web_network == 'true'
|
||||
uses: ./.github/workflows/real-web-network-captures.yml
|
||||
|
||||
web_runtime:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.web_runtime == 'true'
|
||||
uses: ./.github/workflows/real-web-runtime.yml
|
||||
|
||||
web_source_map:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.web_source_map == 'true'
|
||||
uses: ./.github/workflows/real-web-source-map.yml
|
||||
|
||||
nativeaot:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.nativeaot == 'true'
|
||||
uses: ./.github/workflows/nativeaot-fixtures.yml
|
||||
|
||||
website:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.website == 'true'
|
||||
uses: ./.github/workflows/website-check.yml
|
||||
|
||||
required:
|
||||
name: CI required
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
[
|
||||
changes,
|
||||
pr-title,
|
||||
static,
|
||||
build,
|
||||
test-shard,
|
||||
test,
|
||||
conformance-fixtures,
|
||||
windows-native-build,
|
||||
package-e2e,
|
||||
apple-artifacts,
|
||||
reconstruction-readiness,
|
||||
windows-curated,
|
||||
docs,
|
||||
workflows,
|
||||
browser,
|
||||
binary_layout,
|
||||
evm,
|
||||
javascript_recovery,
|
||||
recorded_crash,
|
||||
web_network,
|
||||
web_runtime,
|
||||
web_source_map,
|
||||
nativeaot,
|
||||
website,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Require every selected lane to pass
|
||||
env:
|
||||
CI_JOBS: ${{ toJSON(needs) }}
|
||||
run: node scripts/ci/required.mjs
|
||||
|
||||
@@ -2,12 +2,7 @@ name: Source-built NativeAOT fixtures
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "bridge/ghidra/extensions/nativeaot/**"
|
||||
- "scripts/generate-nativeaot-fixtures.mjs"
|
||||
- "tests/fixtures/nativeaot/**"
|
||||
- ".github/workflows/nativeaot-fixtures.yml"
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -7,36 +7,7 @@ on:
|
||||
description: "Also verify the complete 65,281-section result over MCP (slow)"
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-binary-layout.yml"
|
||||
- "bridge/pwntools/**"
|
||||
- "src/native/pwntools/**"
|
||||
- "src/domain/native/binaryLayout*"
|
||||
- "src/application/binaryDiagnostics/**"
|
||||
- "src/contracts/native/binaryDiagnosticsToolContracts.ts"
|
||||
- "src/composition/binaryDiagnostics.ts"
|
||||
- "src/server/registerBinaryDiagnosticsTools.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/cli/binaryDiagnosticsCommands.ts"
|
||||
- "src/cli.ts"
|
||||
- "src/cliCommandNames.ts"
|
||||
- "src/contracts/toolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/process/OwnedCommand.ts"
|
||||
- "src/process/ProviderProcess.ts"
|
||||
- "src/process/ProviderDeadline.ts"
|
||||
- "src/process/ProcessOwnership*.ts"
|
||||
- "src/process/PrivateRuntimeRoot.ts"
|
||||
- "src/artifacts/readStableArtifact*.ts"
|
||||
- "src/artifacts/ArtifactReader.ts"
|
||||
- "src/domain/analysisError*.ts"
|
||||
- "scripts/verify-binary-layout.mjs"
|
||||
- "scripts/lib/elf-section-name-fixtures.mjs"
|
||||
- "scripts/lib/large-result-mcp.mjs"
|
||||
- "scripts/fixtures/binary-layout*"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,40 +1,7 @@
|
||||
name: Real browser verification
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-browser.yml"
|
||||
- "scripts/verify-real-browser.mjs"
|
||||
- "scripts/verify-browser-network.mjs"
|
||||
- "scripts/verify-browser-scripts.mjs"
|
||||
- "scripts/verify-browser-modules.mjs"
|
||||
- "scripts/verify-browser-dom-destinations.mjs"
|
||||
- "scripts/lib/browser-*.mjs"
|
||||
- "scripts/fixtures/browser-network-site.mjs"
|
||||
- "scripts/fixtures/browser-script-site.mjs"
|
||||
- "scripts/fixtures/browser-module-site.mjs"
|
||||
- "scripts/fixtures/browser-verifier-site.mjs"
|
||||
- "scripts/fixtures/page-cdp-proxy.mjs"
|
||||
- "src/browser/**"
|
||||
- "src/domain/browser*.ts"
|
||||
- "src/domain/webScript*.ts"
|
||||
- "src/domain/webModule*.ts"
|
||||
- "src/application/WebModule*.ts"
|
||||
- "src/composition/webModules.ts"
|
||||
- "src/artifacts/readStableArtifact.ts"
|
||||
- "src/artifacts/ArtifactPaths.ts"
|
||||
- "src/application/WebScriptExportService.ts"
|
||||
- "src/contracts/webScriptToolContracts.ts"
|
||||
- "src/server/registerWebScriptTool.ts"
|
||||
- "src/server/registerWebModuleTool.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/cli/webModuleCommands.ts"
|
||||
- "src/cli.ts"
|
||||
- "src/cliCommandNames.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/cli/webScriptCommands.ts"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -2,33 +2,7 @@ name: Real offline EVM interface
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-evm-interface.yml"
|
||||
- "src/evm/**"
|
||||
- "src/domain/evm/**"
|
||||
- "src/application/evm/**"
|
||||
- "src/contracts/evm/**"
|
||||
- "src/composition/evm.ts"
|
||||
- "src/cli/evmCommands.ts"
|
||||
- "src/server/registerEvmTools.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/cli.ts"
|
||||
- "src/cliCommandNames.ts"
|
||||
- "src/contracts/toolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/process/OwnedCommand.ts"
|
||||
- "src/process/ProviderProcess.ts"
|
||||
- "src/process/ProcessOwnership*.ts"
|
||||
- "src/process/ProviderDeadline.ts"
|
||||
- "src/process/PrivateRuntimeRoot.ts"
|
||||
- "src/artifacts/readStableArtifact*.ts"
|
||||
- "src/artifacts/ArtifactReader.ts"
|
||||
- "scripts/verify-evm-interface.mjs"
|
||||
- "scripts/fixtures/evm-interface.sol"
|
||||
- "third_party/evmole/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
name: Real JavaScript recovery
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-javascript-recovery.yml"
|
||||
- "src/javascript/recovery/**"
|
||||
- "src/domain/javascript/**"
|
||||
- "src/application/javascript/JavaScriptRecovery*.ts"
|
||||
- "src/composition/javascriptRecovery.ts"
|
||||
- "src/contracts/javascript/javascriptRecoveryToolContracts.ts"
|
||||
- "src/cli/javascriptRecoveryCommands.ts"
|
||||
- "src/cli/javascriptApplicationAnalysis.ts"
|
||||
- "src/server/registerJavaScriptRecoveryTool.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/server/sessionAvailabilityPolicy.ts"
|
||||
- "src/application/CapabilityInventory.ts"
|
||||
- "src/cli.ts"
|
||||
- "src/cliCommandNames.ts"
|
||||
- "src/contracts/toolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "scripts/rea.mjs"
|
||||
- "src/process/**"
|
||||
- "src/artifacts/SafeOutputTree.ts"
|
||||
- "scripts/verify/javascript/recovery.mjs"
|
||||
- "scripts/fixtures/javascript-recovery/**"
|
||||
- "third_party/wakaru"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -2,34 +2,7 @@ name: Real recorded crash evidence
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-recorded-crash.yml"
|
||||
- "bridge/pwntools/**"
|
||||
- "bridge/pwndbg/**"
|
||||
- "src/native/pwntools/**"
|
||||
- "src/native/pwndbg/**"
|
||||
- "src/domain/native/recordedCrash*"
|
||||
- "src/application/binaryDiagnostics/**"
|
||||
- "src/contracts/native/binaryDiagnosticsToolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/contracts/toolOutputSchemaGroups.ts"
|
||||
- "src/composition/binaryDiagnostics.ts"
|
||||
- "src/server/registerRecordedCrashTools.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/server/sessionAvailabilityPolicy.ts"
|
||||
- "src/cli/binaryDiagnosticsCommands.ts"
|
||||
- "src/cliCommandNames.ts"
|
||||
- "src/process/**"
|
||||
- "src/artifacts/readStableArtifact*.ts"
|
||||
- "src/domain/analysisError*.ts"
|
||||
- "scripts/verify-recorded-crash.mjs"
|
||||
- "scripts/verify/recorded-crash/**"
|
||||
- "scripts/lib/recorded-core-fixtures.py"
|
||||
- "scripts/fixtures/recorded-crash.c"
|
||||
- "third_party/pwndbg/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Real Termux browser verification
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: real-termux-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
|
||||
jobs:
|
||||
real-termux:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download the pinned Termux app and verify its release checksum
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/rea-termux"
|
||||
cd "$RUNNER_TEMP/rea-termux"
|
||||
release=https://github.com/termux/termux-app/releases/download/v0.118.3
|
||||
apk=termux-app_v0.118.3+github-debug_x86_64.apk
|
||||
sums=termux-app_v0.118.3+github-debug_sha256sums
|
||||
curl --fail --location --retry 3 --output "$apk" "$release/$apk"
|
||||
curl --fail --location --retry 3 --output "$sums" "$release/$sums"
|
||||
awk -v apk="$apk" '$2 == apk || $2 == "*" apk' "$sums" > selected.sha256
|
||||
test "$(wc -l < selected.sha256)" -eq 1
|
||||
sha256sum --check selected.sha256
|
||||
- name: Enable emulator acceleration
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
- name: Build and capture a page inside Termux
|
||||
uses: reactivecircus/android-emulator-runner@4c44018e59b437e86cdfc41da381398f93ed8808 # v2
|
||||
with:
|
||||
api-level: 30
|
||||
arch: x86_64
|
||||
target: google_apis
|
||||
ram-size: 4096M
|
||||
heap-size: 512M
|
||||
disk-size: 8192M
|
||||
disable-animations: true
|
||||
script: bash scripts/verify/termux/emulator.sh
|
||||
- name: Upload Termux and Android diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termux-browser-diagnostics
|
||||
path: ${{ runner.temp }}/rea-termux/diagnostics
|
||||
if-no-files-found: warn
|
||||
@@ -2,38 +2,7 @@ name: Real historical web network captures
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-web-network-captures.yml"
|
||||
- "bridge/mitmproxy/**"
|
||||
- "src/browser/history/**"
|
||||
- "src/domain/webNetworkCapture*.ts"
|
||||
- "src/domain/analysisError*.ts"
|
||||
- "src/contracts/errorSchemas.ts"
|
||||
- "src/domain/explicitSensitive*.ts"
|
||||
- "src/application/WebNetworkCapture*"
|
||||
- "src/composition/webNetworkCaptures.ts"
|
||||
- "src/cli/webNetworkCaptureCommands.ts"
|
||||
- "src/server/registerWebNetworkCaptureTool.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/server/toolRegistrationOptions.ts"
|
||||
- "src/cli.ts"
|
||||
- "src/cliCommandNames.ts"
|
||||
- "src/contracts/toolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/process/OwnedCommand.ts"
|
||||
- "src/process/ProviderProcess.ts"
|
||||
- "src/process/ProviderDeadline.ts"
|
||||
- "src/process/ProcessOwnership*.ts"
|
||||
- "src/process/PrivateRuntimeRoot.ts"
|
||||
- "src/process/capture/ProcessCaptureLifecycle.ts"
|
||||
- "src/artifacts/readStableArtifact*.ts"
|
||||
- "src/artifacts/ArtifactReader.ts"
|
||||
- "src/contracts/webNetworkCaptureToolContracts.ts"
|
||||
- "scripts/verify-web-network-captures.mjs"
|
||||
- "scripts/fixtures/generate-mitmproxy-capture.py"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,34 +1,6 @@
|
||||
name: Real website runtime attribution
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-web-runtime.yml"
|
||||
- "scripts/verify-browser-runtime.mjs"
|
||||
- "scripts/lib/browser-runtime-*.mjs"
|
||||
- "src/process/ProviderProcess.ts"
|
||||
- "src/process/ProcessOwnership*.ts"
|
||||
- "scripts/fixtures/browser-runtime-site.mjs"
|
||||
- "src/browser/execution/**"
|
||||
- "src/browser/CdpConnection.ts"
|
||||
- "src/browser/CdpEndpoint.ts"
|
||||
- "src/browser/CdpTarget*.ts"
|
||||
- "src/browser/CdpCaptureValues.ts"
|
||||
- "src/browser/CdpAuthorized*.ts"
|
||||
- "src/domain/webRuntime.ts"
|
||||
- "src/domain/webExecution.ts"
|
||||
- "src/domain/webEventListeners.ts"
|
||||
- "src/domain/browserObservationError*.ts"
|
||||
- "src/application/WebRuntime*.ts"
|
||||
- "src/composition/webRuntime.ts"
|
||||
- "src/contracts/webRuntimeToolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/server/registerWebRuntimeTools.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/cli/webRuntimeCommands.ts"
|
||||
- "src/cli/commandCancellation.ts"
|
||||
- "src/cli.ts"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
name: Real website source-map verification
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-web-source-map.yml"
|
||||
- "scripts/verify-browser-source-maps.mjs"
|
||||
- "scripts/lib/browser-source-map-e2e.mjs"
|
||||
- "scripts/fixtures/browser-source-map-site.mjs"
|
||||
- "src/javascript/sourceMaps/**"
|
||||
- "src/browser/assets/**"
|
||||
- "src/domain/webSource*.ts"
|
||||
- "src/domain/webScript*.ts"
|
||||
- "src/application/WebSourceLocation*.ts"
|
||||
- "src/application/ValidateSelectedWebScript.ts"
|
||||
- "src/composition/webSourceLocations.ts"
|
||||
- "src/server/registerWebSourceLocationTool.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/contracts/webScriptToolContracts.ts"
|
||||
- "src/process/**"
|
||||
- "src/cli/webSourceLocationCommands.ts"
|
||||
- "src/cli.ts"
|
||||
- "src/artifacts/readStableArtifact.ts"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -35,7 +35,21 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Respect finalized release notes
|
||||
id: editorial
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
finalized=$(gh api --paginate "repos/${GH_REPO}/pulls?state=open&base=main&per_page=100" \
|
||||
--jq '.[] | select(.head.repo.full_name == .base.repo.full_name) | select(.head.ref | startswith("release-please--branches--main--")) | select(any(.labels[]; .name == "rea:release-notes-finalized")) | .number')
|
||||
if [[ -n "${finalized}" ]]; then
|
||||
echo "Finalized release notes on PR ${finalized}; automatic proposal refresh is paused." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "frozen=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
if: steps.editorial.outputs.frozen != 'true'
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
target-branch: main
|
||||
@@ -80,6 +94,16 @@ jobs:
|
||||
node scripts/verify-release-checkpoint.mjs \
|
||||
--phase publish --release-branch main --source-sha "$SOURCE_SHA"
|
||||
|
||||
- name: Validate finalized notes before release creation
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RELEASE_PR: ${{ github.event.pull_request.number }}
|
||||
SOURCE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
run: |
|
||||
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
|
||||
--source "$SOURCE_SHA" --pr "$RELEASE_PR"
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
@@ -192,6 +216,16 @@ jobs:
|
||||
--phase "$RELEASE_PHASE" --release-branch "$RELEASE_BRANCH"
|
||||
--source-sha "$SOURCE_SHA"
|
||||
|
||||
- name: Validate manual finalized notes before release creation
|
||||
if: inputs.phase == 'publish'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
SOURCE_SHA: ${{ steps.branch.outputs.sha }}
|
||||
run: |
|
||||
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
|
||||
--source "$SOURCE_SHA"
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
name: Website checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, "release/*"]
|
||||
paths:
|
||||
- "website/**"
|
||||
- "scripts/*website*.py"
|
||||
- ".github/workflows/**"
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Test files are intentionally NOT excluded: the gate used to report zero clones because it skipped **/*.test.ts, hiding all test-tree duplication. Current actual is ~2.4% lines across src+ and tests/, down from 3.4% when tests were included. Lower this as duplication is removed.",
|
||||
"threshold": 2.5,
|
||||
"_comment": "Test files are intentionally NOT excluded: the gate used to report zero clones because it skipped **/*.test.ts, hiding all test-tree duplication. Current actual is ~2.47% lines across src+ and tests/, down from 3.4% when tests were included. The 2.6% limit leaves modest headroom for ordinary changes; lower it as duplication is removed.",
|
||||
"threshold": 2.6,
|
||||
"reporters": ["consoleFull"],
|
||||
"ignore": [
|
||||
"node_modules/**",
|
||||
|
||||
@@ -148,10 +148,15 @@ Generated fixtures and manifests remain ignored.
|
||||
Use the [release guide](docs/releasing.md). Main pushes automatically refresh a
|
||||
Release Please PR with the next version and changelog. Review its migration
|
||||
notes, wait for the current CI checks to pass, and merge it into main to publish.
|
||||
|
||||
The Release workflow validates and tags that merge; both publishers build its
|
||||
exact SHA. Ordinary main pushes cannot publish packages. An optional manual
|
||||
`release/VERSION` checkpoint retains explicit preparation and publication.
|
||||
|
||||
For grouped release notes with verified contributor thanks, use the repository's
|
||||
`rea-changelog-update` skill in `.agents/skills/rea-changelog-update/`. Its inventory,
|
||||
credit checks, and finalization process are described in the release guide.
|
||||
|
||||
Keep new implementation commits on main for the next release. The workflow
|
||||
owns packaged-artifact verification, npm publication, the published CLI/MCP
|
||||
canary, and MCP Registry publication. Sync manual checkpoint metadata back to
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.address.AddressSpace;
|
||||
import ghidra.program.model.listing.Function;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.program.model.mem.MemoryBlock;
|
||||
import ghidra.program.model.symbol.SourceType;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Apply caller-declared analysis seeds before default auto-analysis.
|
||||
*
|
||||
* The single argument is REA's canonical seed snapshot: one
|
||||
* {@code 0x<hex>\t(function|code|label)[\tname]} line per seed. Code seeds are
|
||||
* applied after functions (highest address first), then labels. The outcome is stored as a
|
||||
* program property that the bridge reports during its handshake.
|
||||
*/
|
||||
public final class ReaGhidraApplySeeds extends GhidraScript {
|
||||
static final String REPORT_OPTION = "REA analysis seeds";
|
||||
|
||||
private record Seed(Address address, String kind, String name) {}
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
String[] args = getScriptArgs();
|
||||
if (args.length != 1) {
|
||||
throw new IllegalArgumentException("REA seed script requires exactly one seed file");
|
||||
}
|
||||
Path path = Path.of(args[0]);
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalArgumentException("REA seed file is not a regular file");
|
||||
}
|
||||
byte[] bytes = Files.readAllBytes(path);
|
||||
String sha256 = HexFormat.of().formatHex(
|
||||
MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
);
|
||||
AddressSpace space = currentProgram.getAddressFactory().getDefaultAddressSpace();
|
||||
List<Seed> seeds = new ArrayList<>();
|
||||
for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n")) {
|
||||
if (line.isEmpty()) continue;
|
||||
String[] fields = line.split("\t", -1);
|
||||
if (fields.length < 2 || fields.length > 3 || !fields[0].startsWith("0x")) {
|
||||
throw new IllegalArgumentException("REA seed snapshot is malformed");
|
||||
}
|
||||
Address address;
|
||||
try {
|
||||
address = space.getAddress(Long.parseUnsignedLong(fields[0].substring(2), 16));
|
||||
} catch (ghidra.program.model.address.AddressOutOfBoundsException e) {
|
||||
address = null;
|
||||
}
|
||||
seeds.add(new Seed(address, fields[1], fields.length == 3 ? fields[2] : null));
|
||||
}
|
||||
|
||||
// Highest address first: a function body computed before analysis stops at
|
||||
// an existing function entry, so later creations cannot swallow earlier ones.
|
||||
seeds.sort((left, right) -> {
|
||||
if (left.address() == null) return right.address() == null ? 0 : 1;
|
||||
if (right.address() == null) return -1;
|
||||
return right.address().compareTo(left.address());
|
||||
});
|
||||
int unmapped = 0;
|
||||
int logged = 0;
|
||||
int codeDecoded = 0, codeFailed = 0;
|
||||
int functionCreated = 0, functionExisting = 0, functionFailed = 0;
|
||||
int labelApplied = 0, labelFailed = 0;
|
||||
for (String pass : new String[] { "function", "code", "label" }) {
|
||||
for (Seed seed : seeds) {
|
||||
if (!seed.kind().equals(pass)) continue;
|
||||
monitor.checkCancelled();
|
||||
MemoryBlock block = seed.address() == null
|
||||
? null
|
||||
: currentProgram.getMemory().getBlock(seed.address());
|
||||
if (block == null || (!pass.equals("label") && !block.isInitialized())) {
|
||||
unmapped++;
|
||||
continue;
|
||||
}
|
||||
switch (pass) {
|
||||
case "code" -> {
|
||||
if (decoded(seed.address())) codeDecoded++;
|
||||
else {
|
||||
codeFailed++;
|
||||
if (logged++ < 20) println("REA seed failed: code " + seed.address() + " " + why(seed.address()));
|
||||
}
|
||||
}
|
||||
case "function" -> {
|
||||
Function existing = getFunctionAt(seed.address());
|
||||
if (existing != null) {
|
||||
functionExisting++;
|
||||
if (seed.name() != null) {
|
||||
existing.setName(seed.name(), SourceType.USER_DEFINED);
|
||||
}
|
||||
} else if (decoded(seed.address()) &&
|
||||
createFunction(seed.address(), seed.name()) != null) {
|
||||
functionCreated++;
|
||||
} else {
|
||||
functionFailed++;
|
||||
if (logged++ < 20) println("REA seed failed: function " + seed.address() + " " + why(seed.address()));
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
try {
|
||||
createLabel(seed.address(), seed.name(), true, SourceType.USER_DEFINED);
|
||||
labelApplied++;
|
||||
} catch (Exception e) {
|
||||
labelFailed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
String report = "{\"format\":\"rea-ghidra-seeds-v1\",\"sha256\":\"" + sha256 + "\"" +
|
||||
",\"entries\":" + seeds.size() +
|
||||
",\"function_created\":" + functionCreated +
|
||||
",\"function_existing\":" + functionExisting +
|
||||
",\"function_failed\":" + functionFailed +
|
||||
",\"code_decoded\":" + codeDecoded +
|
||||
",\"code_failed\":" + codeFailed +
|
||||
",\"label_applied\":" + labelApplied +
|
||||
",\"label_failed\":" + labelFailed +
|
||||
",\"unmapped\":" + unmapped + "}";
|
||||
currentProgram.getOptions(Program.PROGRAM_INFO).setString(REPORT_OPTION, report);
|
||||
println("REA seeds applied: " + report);
|
||||
}
|
||||
|
||||
private String why(Address address) {
|
||||
if (getDataAt(address) != null) return "defined data " + getDataAt(address).getDataType().getName();
|
||||
var containing = getInstructionContaining(address);
|
||||
if (containing != null && !containing.getAddress().equals(address))
|
||||
return "inside instruction at " + containing.getAddress();
|
||||
Function owner = getFunctionContaining(address);
|
||||
if (owner != null) return "inside function " + owner.getEntryPoint();
|
||||
return getInstructionAt(address) == null ? "undecodable" : "function creation refused";
|
||||
}
|
||||
|
||||
/** Decode one instruction flow at an address, keeping an existing decode. */
|
||||
private boolean decoded(Address address) {
|
||||
if (getInstructionAt(address) != null) return true;
|
||||
if (getDataAt(address) != null || getInstructionContaining(address) != null) {
|
||||
return false;
|
||||
}
|
||||
disassemble(address);
|
||||
return getInstructionAt(address) != null;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@@ -80,12 +81,14 @@ import ghidra.program.database.mem.FileBytes;
|
||||
import ghidra.program.model.address.SegmentedAddress;
|
||||
import ghidra.program.model.reloc.Relocation;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import ghidra.program.model.symbol.Namespace;
|
||||
import ghidra.program.model.symbol.RefType;
|
||||
import ghidra.program.model.symbol.Reference;
|
||||
import ghidra.program.model.symbol.ReferenceIterator;
|
||||
import ghidra.program.model.symbol.SourceType;
|
||||
import ghidra.program.model.symbol.Symbol;
|
||||
import ghidra.program.model.symbol.SymbolIterator;
|
||||
import ghidra.program.model.symbol.SymbolTable;
|
||||
import ghidra.program.model.pcode.FunctionPrototype;
|
||||
import ghidra.program.model.pcode.HighFunction;
|
||||
import ghidra.program.model.pcode.HighParam;
|
||||
@@ -120,6 +123,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
);
|
||||
private static final String[] CAPABILITIES = {
|
||||
"annotate_native_function",
|
||||
"set_address_name",
|
||||
"set_addresses_names",
|
||||
"inspect_native_load_image",
|
||||
"read_bytes",
|
||||
"address_to_file_offset",
|
||||
@@ -373,7 +378,16 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
case "inspect_native_instruction" -> inspectNativeInstruction(request.params);
|
||||
case "resolve_native_call_targets" -> resolveNativeCallTargets(request.params);
|
||||
case "xrefs" -> xrefs(request.params);
|
||||
case "analyze_function" -> analyzeFunction(request.params);
|
||||
case "analyze_function" -> {
|
||||
FunctionAnalysisObservation observation = analyzeFunctionObservation(request.params);
|
||||
yield functionObservation(observation.dossier(), observation.limitations());
|
||||
}
|
||||
case "set_address_name", "set_addresses_names" -> {
|
||||
if (!descriptor.transport.equals("unix-socket"))
|
||||
throw new RequestFailure("method_unavailable", "Windows P0 does not admit database mutation");
|
||||
yield request.method.equals("set_address_name")
|
||||
? setAddressName(request.params) : setAddressesNames(request.params);
|
||||
}
|
||||
case "annotate_native_function" -> {
|
||||
if (!descriptor.transport.equals("unix-socket"))
|
||||
throw new RequestFailure("method_unavailable", "Windows P0 does not admit database mutation");
|
||||
@@ -417,12 +431,18 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.addProperty("analysis_timed_out", timedOut);
|
||||
result.add("capabilities", GSON.toJsonTree(
|
||||
java.util.Arrays.stream(CAPABILITIES)
|
||||
.filter(value -> !readOnly || !value.equals("annotate_native_function"))
|
||||
.filter(value -> !readOnly || !(value.equals("annotate_native_function")
|
||||
|| value.equals("set_address_name") || value.equals("set_addresses_names")))
|
||||
.toArray(String[]::new)
|
||||
));
|
||||
result.add("target", target);
|
||||
JsonArray extensions = analysisExtensions.reports();
|
||||
if (extensions.size() > 0) result.add("analysis_extensions", extensions);
|
||||
// Written by ReaGhidraApplySeeds before auto-analysis; REA checks it
|
||||
// against the committed profile.
|
||||
String seeds = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO)
|
||||
.getString("REA analysis seeds", null);
|
||||
if (seeds != null) result.add("analysis_seeds", JsonParser.parseString(seeds));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -641,10 +661,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private JsonElement procedurePseudocode(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of("document", "procedure"));
|
||||
requireDocument(params);
|
||||
String value = decompiledText(
|
||||
decompile(resolveProcedure(requireString(params, "procedure")))
|
||||
Function function = resolveProcedure(requireString(params, "procedure"));
|
||||
String value = decompiledText(decompile(function));
|
||||
return functionObservation(
|
||||
value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value),
|
||||
noReturnCallLimitations(
|
||||
currentProgram.getListing().getInstructions(function.getBody(), true)
|
||||
)
|
||||
);
|
||||
return value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value);
|
||||
}
|
||||
|
||||
private JsonElement procedureAssembly(JsonObject params) throws Exception {
|
||||
@@ -652,7 +676,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
requireDocument(params);
|
||||
Function function = resolveProcedure(requireString(params, "procedure"));
|
||||
InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE);
|
||||
return GSON.toJsonTree(renderAssembly(scan.instructions));
|
||||
return functionObservation(
|
||||
GSON.toJsonTree(renderAssembly(scan.instructions)),
|
||||
noReturnCallLimitations(scan.instructions)
|
||||
);
|
||||
}
|
||||
|
||||
private JsonObject readFunctionInstructions(JsonObject params) throws Exception {
|
||||
@@ -670,11 +697,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
limitations.add(
|
||||
"The fast path does not invoke the decompiler or scan whole-program names and strings."
|
||||
);
|
||||
List<String> boundaryLimitations = noReturnCallLimitations(scan.instructions);
|
||||
for (String limitation : boundaryLimitations) limitations.add(limitation);
|
||||
JsonObject result = new JsonObject();
|
||||
result.add("procedure", procedureIdentity(function));
|
||||
result.add("instructions", items);
|
||||
result.add("limitations", limitations);
|
||||
return result;
|
||||
return functionObservation(result, boundaryLimitations);
|
||||
}
|
||||
|
||||
private JsonArray procedureCalls(JsonObject params, boolean callers) throws Exception {
|
||||
@@ -707,7 +736,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.addProperty("signature", function.getPrototypeString(false, true));
|
||||
result.add("locals", functionLocals(function));
|
||||
result.add("classification", functionClassification(function));
|
||||
return result;
|
||||
return functionObservation(
|
||||
result,
|
||||
noReturnCallLimitations(
|
||||
currentProgram.getListing().getInstructions(function.getBody(), true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private JsonObject procedureReferences(JsonObject params) throws Exception {
|
||||
@@ -1027,11 +1061,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
for (String field : List.of("name", "comment", "inline_comment")) {
|
||||
if (params.has(field)) validateAnnotationText(requireText(params, field), field);
|
||||
}
|
||||
String leafName = params.has("name")
|
||||
? annotationLeafName(function, requireString(params, "name")) : null;
|
||||
int transaction = currentProgram.startTransaction("REA function annotations");
|
||||
boolean commit = false;
|
||||
try {
|
||||
String leafName = params.has("name")
|
||||
? symbolLeafName(function.getSymbol(), requireString(params, "name")) : null;
|
||||
// Validate edits through Ghidra's own setters, including name rules.
|
||||
// A later invalid name must roll back earlier comment writes.
|
||||
for (String field : List.of("comment", "inline_comment")) {
|
||||
@@ -1080,18 +1114,18 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
}
|
||||
}
|
||||
|
||||
private String annotationLeafName(Function function, String requested) {
|
||||
private String symbolLeafName(Symbol symbol, String requested) throws ghidra.util.exception.InvalidInputException {
|
||||
// Fully qualified readback must be usable as an idempotent rename input.
|
||||
if (requested.equals(procedureName(function))) return function.getName();
|
||||
String namespace = function.getParentNamespace().isGlobal()
|
||||
? "" : function.getParentNamespace().getName(true);
|
||||
if (requested.equals(symbol.getName(true))) return symbol.getName();
|
||||
String namespace = symbol.getParentNamespace().isGlobal()
|
||||
? "" : symbol.getParentNamespace().getName(true);
|
||||
String prefix = namespace + "::";
|
||||
if (!namespace.isEmpty() && requested.startsWith(prefix)) {
|
||||
String leaf = requested.substring(prefix.length());
|
||||
if (!leaf.isEmpty()) return leaf;
|
||||
throw new RequestFailure("invalid_function_name",
|
||||
"Function name has an empty leaf within namespace " + namespace + " at " +
|
||||
canonicalAddress(function.getEntryPoint()) + ": " + requested);
|
||||
throw new ghidra.util.exception.InvalidInputException(
|
||||
"Symbol name has an empty leaf within namespace " + namespace + " at " +
|
||||
canonicalAddress(symbol.getAddress()) + ": " + requested);
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
@@ -1119,6 +1153,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
}
|
||||
|
||||
private JsonObject analyzeFunction(JsonObject params) throws Exception {
|
||||
return analyzeFunctionObservation(params).dossier();
|
||||
}
|
||||
|
||||
private FunctionAnalysisObservation analyzeFunctionObservation(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of("procedure"));
|
||||
Function function = resolveProcedure(requireString(params, "procedure"));
|
||||
InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE);
|
||||
@@ -1176,12 +1214,16 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
limitations.add(
|
||||
"Synthetic Ghidra entry-point references without actionable memory sources are omitted."
|
||||
);
|
||||
List<String> boundaryLimitations = noReturnCallLimitations(scan.instructions);
|
||||
for (String limitation : boundaryLimitations) {
|
||||
limitations.add(limitation);
|
||||
}
|
||||
if (decompilation != null && decompilation.getErrorMessage() != null &&
|
||||
!decompilation.getErrorMessage().isBlank()) {
|
||||
limitations.add("Ghidra decompiler diagnostic: " + decompilation.getErrorMessage());
|
||||
}
|
||||
result.add("limitations", limitations);
|
||||
return result;
|
||||
return new FunctionAnalysisObservation(result, boundaryLimitations);
|
||||
}
|
||||
|
||||
private JsonObject nativeValueFlow(DecompileResults decompilation) throws Exception {
|
||||
@@ -1416,6 +1458,89 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return symbol == null ? JsonNull.INSTANCE : GSON.toJsonTree(symbol.getName(true));
|
||||
}
|
||||
|
||||
// Analyst naming: a function entry renames the function, other addresses get a primary label.
|
||||
private boolean applyAddressName(Address address, String name) throws Exception {
|
||||
if (!currentProgram.getMemory().contains(address))
|
||||
throw new RequestFailure("invalid_request", "Address is not mapped: " + canonicalAddress(address));
|
||||
validateAnnotationText(name, "name");
|
||||
int transaction = currentProgram.startTransaction("REA address name");
|
||||
boolean commit = false;
|
||||
try {
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
SymbolTable table = currentProgram.getSymbolTable();
|
||||
Symbol primary = function == null ? table.getPrimarySymbol(address) : function.getSymbol();
|
||||
Namespace namespace = primary == null
|
||||
? currentProgram.getGlobalNamespace()
|
||||
: primary.getParentNamespace();
|
||||
String expected = primary == null ? name : symbolLeafName(primary, name);
|
||||
for (Symbol symbol : table.getLabelOrFunctionSymbols(expected, namespace)) {
|
||||
if (!address.equals(symbol.getAddress())) return false;
|
||||
}
|
||||
if (function != null) {
|
||||
function.setName(expected, SourceType.USER_DEFINED);
|
||||
} else {
|
||||
if (primary != null && !primary.isDynamic()) {
|
||||
primary.setName(expected, SourceType.USER_DEFINED);
|
||||
} else {
|
||||
Symbol created = table.createLabel(address, expected, SourceType.USER_DEFINED);
|
||||
created.setPrimary();
|
||||
}
|
||||
}
|
||||
invalidateAnalysisCaches();
|
||||
Symbol readback = currentProgram.getSymbolTable().getPrimarySymbol(address);
|
||||
commit = readback != null && expected.equals(readback.getName());
|
||||
return commit;
|
||||
}
|
||||
catch (ghidra.util.exception.InvalidInputException | ghidra.util.exception.DuplicateNameException exception) {
|
||||
return false;
|
||||
}
|
||||
finally {
|
||||
currentProgram.endTransaction(transaction, commit);
|
||||
}
|
||||
}
|
||||
|
||||
private JsonElement setAddressName(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of("document", "address", "name"));
|
||||
requireDocument(params);
|
||||
return GSON.toJsonTree(applyAddressName(requireAddress(params, "address"), requireString(params, "name")));
|
||||
}
|
||||
|
||||
private JsonElement setAddressesNames(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of("document", "names"));
|
||||
requireDocument(params);
|
||||
if (!params.has("names") || !params.get("names").isJsonObject())
|
||||
throw new RequestFailure("invalid_request", "names must be an object of address -> name");
|
||||
Map<Address, List<AddressNameRequest>> requestsByAddress = new LinkedHashMap<>();
|
||||
JsonObject result = new JsonObject();
|
||||
for (Map.Entry<String, JsonElement> entry : params.getAsJsonObject("names").entrySet()) {
|
||||
try {
|
||||
Address address = parseReaAddress(entry.getKey());
|
||||
requestsByAddress.computeIfAbsent(address, ignored -> new ArrayList<>())
|
||||
.add(new AddressNameRequest(entry.getKey(), address, entry.getValue().getAsString()));
|
||||
}
|
||||
catch (RequestFailure failure) {
|
||||
result.addProperty(entry.getKey(), false);
|
||||
}
|
||||
}
|
||||
for (List<AddressNameRequest> requests : requestsByAddress.values()) {
|
||||
String name = requests.get(0).name;
|
||||
boolean conflictingAliasNames = requests.stream()
|
||||
.anyMatch(request -> !request.name.equals(name));
|
||||
boolean success = false;
|
||||
if (!conflictingAliasNames) {
|
||||
try {
|
||||
success = applyAddressName(requests.get(0).address, name);
|
||||
}
|
||||
catch (RequestFailure failure) {
|
||||
// Keep per-address failures in the existing batch result shape.
|
||||
}
|
||||
}
|
||||
for (AddressNameRequest request : requests)
|
||||
result.addProperty(request.inputAddress, success);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonArray listDocuments(JsonObject params) {
|
||||
requireKeys(params, Set.of());
|
||||
JsonArray result = new JsonArray();
|
||||
@@ -2577,6 +2702,63 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return new InstructionScan(List.copyOf(instructions), iterator.hasNext());
|
||||
}
|
||||
|
||||
private JsonObject functionObservation(
|
||||
JsonElement value,
|
||||
List<String> limitations) {
|
||||
JsonObject result = new JsonObject();
|
||||
result.add("value", value);
|
||||
result.add("limitations", GSON.toJsonTree(limitations));
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<String> noReturnCallLimitations(Iterable<Instruction> instructions)
|
||||
throws Exception {
|
||||
List<String> limitations = new ArrayList<>();
|
||||
for (Instruction instruction : instructions) {
|
||||
monitor.checkCancelled();
|
||||
if (!instruction.getFlowType().isCall() || !instruction.getFlowType().isTerminal()) {
|
||||
continue;
|
||||
}
|
||||
Address[] destinations = instruction.getFlows();
|
||||
StringBuilder limitation = new StringBuilder();
|
||||
limitation.append("Ghidra Listing reports a terminal call at ")
|
||||
.append(canonicalAddress(instruction.getAddress()));
|
||||
if (destinations.length == 0) {
|
||||
limitation.append(" with no resolved callee; its no-return basis is unknown.");
|
||||
}
|
||||
else {
|
||||
limitation.append(" to ");
|
||||
for (int index = 0; index < destinations.length; index++) {
|
||||
if (index > 0) limitation.append(", ");
|
||||
Address destination = destinations[index];
|
||||
limitation.append(canonicalAddress(destination));
|
||||
Function target = currentProgram.getFunctionManager().getFunctionAt(destination);
|
||||
if (target == null) {
|
||||
limitation.append(" (callee identity unknown)");
|
||||
}
|
||||
else {
|
||||
limitation.append(" ").append(procedureName(target));
|
||||
limitation.append(" (external=").append(target.isExternal());
|
||||
limitation.append(", thunk=").append(target.isThunk());
|
||||
limitation.append(", hasNoReturn=").append(target.hasNoReturn()).append(")");
|
||||
}
|
||||
}
|
||||
limitation.append("; fallthrough is excluded by Ghidra's flow model.");
|
||||
Function target = destinations.length == 1
|
||||
? currentProgram.getFunctionManager().getFunctionAt(destinations[0])
|
||||
: null;
|
||||
if (target != null && target.hasNoReturn()) {
|
||||
limitation.append(" This records Ghidra's FunctionManager flag, not an independent verification that the callee cannot return.");
|
||||
}
|
||||
else {
|
||||
limitation.append(" The reason for the terminal flow is not established by this observation.");
|
||||
}
|
||||
}
|
||||
limitations.add(limitation.toString());
|
||||
}
|
||||
return limitations;
|
||||
}
|
||||
|
||||
private static String renderAssembly(List<Instruction> instructions) {
|
||||
return String.join("\n", renderAssemblyLines(instructions));
|
||||
}
|
||||
@@ -3405,7 +3587,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
.thenComparing(Reference::isPrimary);
|
||||
|
||||
private record InventoryItem(Address address, String value, JsonObject facts) {}
|
||||
private record AddressNameRequest(String inputAddress, Address address, String name) {}
|
||||
private record FunctionEntry(Function function, InventoryItem item) {}
|
||||
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations) {}
|
||||
private record InstructionScan(List<Instruction> instructions, boolean truncated) {}
|
||||
private record SessionDescriptor(
|
||||
String transport,
|
||||
|
||||
@@ -3,20 +3,25 @@ import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.*;
|
||||
import ghidra.program.model.symbol.FlowType;
|
||||
import ghidra.program.model.symbol.Reference;
|
||||
import ghidra.program.model.pcode.PcodeOp;
|
||||
import java.util.ArrayList;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* Undo wrong no-return flags from auto-analysis. A function flagged no-return that contains a
|
||||
* real RET is an ordinary function (stream readers, cxa_guard_acquire, ...). Callers treated the
|
||||
* call as a terminator and the decompiler truncated them. Clear the flag, then re-disassemble only
|
||||
* real RET is an ordinary function (stream readers, cxa_guard_acquire, ...). Known returning
|
||||
* libc/TLS imports have no body to inspect, so clear their flags by exact external ABI name.
|
||||
* Callers treated the call as a terminator and the decompiler truncated them. Clear the flag, then re-disassemble only
|
||||
* the call sites of the functions that were cleared and refit their owning functions in place.
|
||||
*/
|
||||
public final class ReaGhidraNoReturnFix extends GhidraScript {
|
||||
private static final Set<String> RETURNING_IMPORTS = Set.of(
|
||||
"malloc", "__tls_get_addr", "memcpy", "memset", "strlen", "__errno_location");
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
// Windows P0 does not admit analysis-database mutation.
|
||||
@@ -24,66 +29,95 @@ public final class ReaGhidraNoReturnFix extends GhidraScript {
|
||||
FunctionManager fm = currentProgram.getFunctionManager();
|
||||
Listing li = currentProgram.getListing();
|
||||
Set<Address> cleared = new HashSet<>();
|
||||
for (Function f : fm.getFunctions(true)) {
|
||||
Deque<Address> pending = new ArrayDeque<>();
|
||||
for (Function f : fm.getExternalFunctions()) {
|
||||
monitor.checkCancelled();
|
||||
if (!f.hasNoReturn() || f.isThunk()) continue;
|
||||
for (Instruction ins : li.getInstructions(f.getBody(), true)) {
|
||||
FlowType ft = ins.getFlowType();
|
||||
boolean decodedReturn = false;
|
||||
for (PcodeOp operation : ins.getPcode()) {
|
||||
if (operation.getOpcode() == PcodeOp.RETURN) decodedReturn = true;
|
||||
}
|
||||
if (decodedReturn && !ft.isJump() && !ft.isCall()) {
|
||||
// Do not apply import-name assumptions to local functions or to arbitrary externals.
|
||||
if (!f.hasNoReturn() || !RETURNING_IMPORTS.contains(externalAbiName(f))) continue;
|
||||
f.setNoReturn(false);
|
||||
cleared.add(f.getEntryPoint());
|
||||
break;
|
||||
enqueueReturning(f, cleared, pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A thunk inherits the flag of the function it forwards to.
|
||||
for (Function f : fm.getFunctions(true)) {
|
||||
monitor.checkCancelled();
|
||||
if (!f.isThunk()) continue;
|
||||
Function target = f.getThunkedFunction(true);
|
||||
if (target != null && cleared.contains(target.getEntryPoint())) {
|
||||
cleared.add(f.getEntryPoint());
|
||||
if (clearDecodedReturn(f, li)) {
|
||||
enqueueReturning(f, cleared, pending);
|
||||
}
|
||||
}
|
||||
int redone = 0;
|
||||
TreeSet<Address> owners = new TreeSet<>();
|
||||
if (!cleared.isEmpty()) {
|
||||
List<Address> sites = new ArrayList<>();
|
||||
for (Instruction ins : li.getInstructions(true)) {
|
||||
while (!pending.isEmpty()) {
|
||||
Address target = pending.removeFirst();
|
||||
TreeSet<Address> sites = new TreeSet<>();
|
||||
// References include computed import calls after Ghidra resolves their destination.
|
||||
// Collect before redisassembly mutates the reference table.
|
||||
for (Reference reference : currentProgram.getReferenceManager().getReferencesTo(target)) {
|
||||
monitor.checkCancelled();
|
||||
FlowType ft = ins.getFlowType();
|
||||
if (!(ft.isCall() && ft.isTerminal())) continue;
|
||||
Address[] flows = ins.getFlows();
|
||||
if (flows.length == 0 || !cleared.contains(flows[0])) continue;
|
||||
// Do not redisassemble into an independently identified function.
|
||||
Address next = ins.getMaxAddress().add(1);
|
||||
if (fm.getFunctionAt(next) != null || !currentProgram.getMemory().contains(next)) continue;
|
||||
sites.add(ins.getAddress());
|
||||
sites.add(reference.getFromAddress());
|
||||
}
|
||||
for (Address s : sites) {
|
||||
monitor.checkCancelled();
|
||||
Instruction ins = li.getInstructionAt(s);
|
||||
if (ins == null) continue;
|
||||
FlowType ft = ins.getFlowType();
|
||||
if (!(ft.isCall() && ft.isTerminal())) continue;
|
||||
Address[] flows = ins.getFlows();
|
||||
boolean callsTarget = false;
|
||||
for (Address flow : flows) {
|
||||
if (flow.equals(target)) callsTarget = true;
|
||||
}
|
||||
if (!callsTarget) continue;
|
||||
// Do not redisassemble into an independently identified function.
|
||||
Address next = ins.getMaxAddress().add(1);
|
||||
if (fm.getFunctionAt(next) != null || !currentProgram.getMemory().contains(next)) continue;
|
||||
Function owner = fm.getFunctionContaining(s);
|
||||
if (owner != null && !owner.isThunk()) owners.add(owner.getEntryPoint());
|
||||
clearListing(ins.getMinAddress(), ins.getMaxAddress());
|
||||
disassemble(s);
|
||||
redone++;
|
||||
}
|
||||
for (Address e : owners) {
|
||||
monitor.checkCancelled();
|
||||
Function f = fm.getFunctionAt(e);
|
||||
// Refit the body in place so the name, signature and calling convention survive.
|
||||
if (f != null) CreateFunctionCmd.fixupFunctionBody(currentProgram, f, monitor);
|
||||
if (owner != null && !owner.isThunk()) {
|
||||
CreateFunctionCmd.fixupFunctionBody(currentProgram, owner, monitor);
|
||||
// Auto-analysis may have propagated the false flag to this truncated caller.
|
||||
// Its RET becomes visible only now; repair its callers in the same pass.
|
||||
if (clearDecodedReturn(owner, li)) enqueueReturning(owner, cleared, pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No analyzeChanges here: on libpl2.so it adds about 70 s, which pushes startup past the 330 s deadline.
|
||||
}
|
||||
println(
|
||||
"REA no-return fix: cleared=" + cleared.size() + " sites=" + redone + " refit=" + owners.size());
|
||||
}
|
||||
|
||||
private void enqueueReturning(Function function, Set<Address> cleared, Deque<Address> pending) {
|
||||
if (cleared.add(function.getEntryPoint())) pending.addLast(function.getEntryPoint());
|
||||
// A thunk inherits the flag of the function it forwards to, including through other thunks.
|
||||
Address[] thunks = function.getFunctionThunkAddresses(true);
|
||||
if (thunks == null) return;
|
||||
for (Address thunk : thunks) {
|
||||
if (cleared.add(thunk)) pending.addLast(thunk);
|
||||
}
|
||||
}
|
||||
|
||||
private String externalAbiName(Function function) {
|
||||
String name = function.getName();
|
||||
// Mach-O stores C-linkage symbols with one object-format underscore prefix.
|
||||
if (currentProgram.getExecutableFormat().contains("Mach-O") && name.startsWith("_"))
|
||||
return name.substring(1);
|
||||
return name;
|
||||
}
|
||||
|
||||
private boolean clearDecodedReturn(Function function, Listing listing) throws Exception {
|
||||
if (!function.hasNoReturn() || function.isThunk()) return false;
|
||||
for (Instruction instruction : listing.getInstructions(function.getBody(), true)) {
|
||||
monitor.checkCancelled();
|
||||
FlowType flow = instruction.getFlowType();
|
||||
if (flow.isJump() || flow.isCall()) continue;
|
||||
for (PcodeOp operation : instruction.getPcode()) {
|
||||
if (operation.getOpcode() != PcodeOp.RETURN) continue;
|
||||
function.setNoReturn(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,12 @@ existing analysis families directly, for example
|
||||
- Pull and capture outputs are additive: the destination must not already
|
||||
exist. Re-running a completed pull refuses the existing directory instead
|
||||
of overwriting evidence.
|
||||
- Cancellation during a package pull preserves caller-selected output. The
|
||||
error includes partial observations identifying completed artifacts and the
|
||||
interrupted local path; remaining APKs were not attempted.
|
||||
- Failed package acquisition rollback retains its directory identity and retries
|
||||
cleanup before later work and during shutdown. Cleanup refuses a replacement
|
||||
directory. Terminal shutdown stops admission and drains active operations.
|
||||
- The provider holds no long-lived processes; every invocation is one
|
||||
bounded adb run. The adb server belongs to the caller.
|
||||
|
||||
|
||||
@@ -53,8 +53,19 @@ google-chrome \
|
||||
|
||||
REA does not launch, own, or terminate this browser. Use a dedicated profile and stop it yourself when the investigation is complete. Do not expose the debugging port on a non-loopback interface.
|
||||
|
||||
On Linux, a reachable but locked desktop keyring can stall Chrome before its
|
||||
first navigation. For a dedicated investigation profile, add
|
||||
`--password-store=basic` to bypass that keyring. Empty or placeholder frame URLs
|
||||
then describe an unloaded page; REA continues to require an allowed, committed
|
||||
URL. See [the startup investigation](https://github.com/morluto/rea/issues/1384#issuecomment-6093781686).
|
||||
|
||||
## Request boundary
|
||||
|
||||
CDP connections enforce the shared 64 MiB limit per WebSocket message. A larger
|
||||
reply, including a valid reply from Chrome, fails with `payload_limit`. The limit
|
||||
counts message bytes before JSON parsing; it does not bound the memory used by
|
||||
decoded strings or JSON objects.
|
||||
|
||||
Discovery supplies a literal loopback CDP endpoint and returns all eligible
|
||||
page targets unless an exact HTTP(S) origin filter narrows the result. Follow-up
|
||||
inspection requests select one target; their default scope is its current origin. `localhost`, private-LAN
|
||||
|
||||
@@ -156,6 +156,18 @@ Storage cookies are scoped to the page URL at each capture. Unsupported action
|
||||
tags, unknown fields, duplicate step IDs, and undeclared secret references fail
|
||||
validation.
|
||||
|
||||
Cookies are installed before the initial navigation. Local storage entries are
|
||||
seeded once per browser storage partition; session storage entries are seeded
|
||||
once per origin in each top-level browsing context. Seeding occurs before the
|
||||
first document scripts for that area. An origin first reached by a later action
|
||||
receives its seed on that first visit. Application updates, deletions, and
|
||||
empty values then persist across later navigations in the same area; a new
|
||||
scenario starts with its own seeds. Partitioned frames can receive separate
|
||||
local storage seeds even when they share an origin. The pre-script mechanism
|
||||
uses CDP debugger instrumentation. The observer resumes only pauses whose top
|
||||
frame belongs to its private seed script; page breakpoints and `debugger`
|
||||
statements remain under the attached debugger client's control.
|
||||
|
||||
The result is Evidence with an initial state followed by one record per
|
||||
declared action. Each step reports action status, elapsed time, sanitized URLs,
|
||||
event bounds, and independently typed capture states for screenshot, DOM,
|
||||
|
||||
+10
-3
@@ -166,9 +166,16 @@ rea import-reference-source /absolute/path/to/source
|
||||
```
|
||||
|
||||
The import records hashes and metadata for the supplied files, separately
|
||||
from observations of the current app. File names do not automatically exclude
|
||||
files. Set `REA_REFERENCE_SECRET_PATTERNS_JSON` to a JSON array of ignore
|
||||
patterns when you want to exclude selected paths.
|
||||
from observations of the current app. It honors the source tree's `.gitignore`
|
||||
and applies default exclusions for common generated and dependency paths such
|
||||
as `node_modules/`, `dist/`, and `*.log`. Pattern-based exclusions record the
|
||||
matched pattern and whether it came from project, default, caller, or sensitive
|
||||
path policy. Set `REA_REFERENCE_SECRET_PATTERNS_JSON` to a JSON array of ignore
|
||||
patterns when you want to mark selected paths as sensitive; those patterns are
|
||||
reported separately as configured-secret exclusions.
|
||||
|
||||
Re-import saved graphs whose pattern-based exclusions lack `pattern` before
|
||||
using them in source-to-bundle comparisons.
|
||||
|
||||
JavaScript and TypeScript import parsing requires valid UTF-8. Malformed source
|
||||
bytes retain their original hashes and sizes with a decoding diagnostic; REA
|
||||
|
||||
@@ -29,6 +29,13 @@ section names or the record being inside the initial 4 KiB header probe.
|
||||
Program and section declarations must agree; duplicate, conflicting, truncated
|
||||
or out-of-file records are rejected. Stripped section tables and section-only
|
||||
records are supported. Extended table numbering is explicitly unsupported.
|
||||
For carved ELFs whose section table extends beyond the captured file, REA
|
||||
examines every complete captured section header and records the unavailable
|
||||
headers as a limitation. A valid program-header ABI record can still establish
|
||||
the supported declaration. Available section declarations must agree with it;
|
||||
their duplicate, conflicting or unreadable records remain errors. A missing
|
||||
program-header record does not make ABI declarations in uncaptured sections
|
||||
absent or supply a default ABI.
|
||||
The field layout follows
|
||||
[`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h).
|
||||
|
||||
@@ -92,6 +99,11 @@ observations and does not relabel the upstream result as verified.
|
||||
|
||||
CLI/MCP discovery and decompilation, direct calls, target/profile/Evidence
|
||||
identity, snapshot round-trip and final process cleanup remain covered.
|
||||
For both byte orders, the lane also carves away the section table while retaining
|
||||
all program-header payloads. It checks the actual Ghidra language, entry-address
|
||||
decompilation, distinct artifact digest and missing-section limitations through
|
||||
CLI Evidence and an MCP-produced snapshot. Caller-selected Ghidra heap limits
|
||||
are passed to these child workflows.
|
||||
Pseudocode presence is a liveness check, not proof of semantic equivalence.
|
||||
Instruction decoding and reader agreement likewise do not establish runtime,
|
||||
delay-slot execution or complete floating-point semantics. Assertion regressions
|
||||
|
||||
+31
-1
@@ -12,6 +12,9 @@ configuration, startup deadlines, resource controls, and cleanup recovery.
|
||||
extension. Check the [release boundary](installation.md#released-package-and-main)
|
||||
when using npm.
|
||||
|
||||
See [import language and analysis seeds](installation.md#ghidra-import-language-and-analysis-seeds)
|
||||
to override the header-detected language or seed analysis.
|
||||
|
||||
Provider selection and profile identity follow
|
||||
[MCP contracts](mcp-contracts.md#identity-and-discovery). One deep
|
||||
provider stays bound to the target; runtime failure never selects another
|
||||
@@ -29,9 +32,15 @@ not equivalence with another engine's pseudocode or analysis database.
|
||||
| Strings | Only Ghidra-defined string `Data` is observed. Items report charset, byte length, and whether a required null terminator is missing. The API cannot distinguish a present terminator from fixed/Pascal layouts when no terminator is missing, so that state is named `present_or_not_required`. |
|
||||
| Memory | Memory-block end addresses are exclusive. Read/write/execute, initialization, overlay, address space, and image base are direct Ghidra observations. |
|
||||
| Inventory | Procedure, symbol, and string listings plus searches return the complete matching collection in one response; callers do not provide offsets or result-count limits. |
|
||||
| Search | Literal and Java-regex searches scan the complete immutable inventory and return all matching entries inline. Search waits for a reply or caller cancellation; there are no caller-supplied offsets or result-count limits. |
|
||||
| Search | Literal and Java-regex searches return every match in the immutable provider-defined inventory; string search does not cover every text sequence in artifact bytes. Search waits for a reply or caller cancellation; there are no caller-supplied offsets or result-count limits. |
|
||||
| Analysis state | The socket is exposed only after default auto-analysis completes. Established operations wait for their reply or caller cancellation; there is no fixed response-size ceiling. |
|
||||
|
||||
String-search completeness applies only to Ghidra-defined string `Data`. Packed
|
||||
or undefined literals, including Rust strings, may be absent; an empty result
|
||||
does not prove text is absent. Both list and search Evidence carry this
|
||||
limitation. When an address is known, use `read_bytes` and `xrefs` to inspect
|
||||
bytes and references.
|
||||
|
||||
## Admitted function-analysis semantics
|
||||
|
||||
| Concern | Ghidra contract |
|
||||
@@ -46,6 +55,27 @@ not equivalence with another engine's pseudocode or analysis database.
|
||||
| P-code value flow | Function dossiers include up to 3,000 high-p-code operations, at most 64 inputs per operation, and 12,000 def-use edges. Results identify memory reads/writes, branches, and calls by p-code opcode, and mark decompiler-dead operations. Large results report truncation, an omitted-operation lower bound, and known omitted-input/edge counts. The dossier is an intra-function graph; `trace_native_values` composes derived parameter/argument and return/output dependencies across resolved calls. It does not resolve memory aliasing, call side effects, runtime behavior, persistent state, or business meaning. |
|
||||
| Result extent | Function instruction scans and native API boundary observations remain complete. P-code flow is the explicit bounded exception; its count fields distinguish exact retained counts from lower-bound or known-omitted counts. Caller cancellation and provider failures remain distinct. |
|
||||
|
||||
Function extents and instruction completeness refer to Ghidra's analyzed body.
|
||||
An inferred no-return call can terminate that body before subsequent bytes.
|
||||
Pseudocode, assembly, procedure info, instruction windows, and dossiers report
|
||||
observed terminal call sites in Evidence limitations, including resolved callee
|
||||
names, addresses, and FunctionManager flags when available. Instruction windows
|
||||
and dossiers also retain these warnings in their result limitations. An
|
||||
unresolved callee or unestablished terminal-flow basis remains explicit. These
|
||||
observations qualify the analysis; they do not prove that a no-return flag is
|
||||
incorrect or recover excluded code. Analysis profiles version this observation
|
||||
contract so older snapshots cannot replay unqualified results.
|
||||
|
||||
On non-Windows hosts, the ephemeral import repair clears no-return flags on the
|
||||
known returning external ABI names `malloc`, `__tls_get_addr`, `memcpy`, `memset`,
|
||||
`strlen`, and `__errno_location`. This relies on their expected ABI behavior;
|
||||
external implementations are not inspected. The repair re-disassembles affected
|
||||
terminal calls and refits their owning functions, preserving independent function
|
||||
entries. If a recovered caller reveals a decoded return, its propagated no-return
|
||||
flag and affected callers are repaired in the same pass. Unknown imports and
|
||||
genuine no-return imports keep their flags. Analysis profiles version this repair
|
||||
so snapshots produced before it are not reused. Windows P0 does not run it.
|
||||
|
||||
`npm run verify:ghidra` compiles the versioned C oracle into debug and stripped
|
||||
host-native targets (x86-64/AArch64 ELF on Linux or Mach-O on macOS), plus a native
|
||||
DWARF 4 type-layout object. It proves the admitted operations, external functions, resolved thunks, exports, stripped-name
|
||||
|
||||
+77
-7
@@ -68,7 +68,17 @@ selects the npm release, and persistent MCP registrations are pinned to the
|
||||
version that performed setup. Installing newer instructions does not update a
|
||||
running server or its registration.
|
||||
|
||||
The release checked on **2026-10-07** was **5.0.0** (133 MCP tools), published
|
||||
The npm release checked on **2026-10-11** was **6.3.0**, published from
|
||||
[`74cf9e14`](https://github.com/morluto/rea/commit/74cf9e1401fcd89482fe39c27cc898f5858e9b72),
|
||||
with bundled skill version 34. It does not include automatic Qwen Code, Pi, or
|
||||
Hermes registration: `setup --client qwen_code`, `setup --client pi`, and
|
||||
`setup --client hermes` reject those IDs before writing configuration. It also
|
||||
does not honor `OPENCODE_CONFIG_DIR` when selecting OpenCode's configuration
|
||||
directory. These workflows below require a source build or a later release
|
||||
containing the relevant changes; `@latest` alone cannot select unpublished
|
||||
changes.
|
||||
|
||||
The earlier release checked on **2026-10-07** was **5.0.0** (133 MCP tools), published
|
||||
from the fixed checkpoint
|
||||
[`b33236ec`](https://github.com/morluto/rea/releases/tag/rea-agents-5.0.0).
|
||||
The public CLI, MCP catalog and target-free session, and isolated update from
|
||||
@@ -142,7 +152,29 @@ 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)).
|
||||
For the native offline OpenAI-compatible BYOK adapter, full-profile chat and
|
||||
analysis also passed with a distinct `COPILOT_PROVIDER_MODEL_ID` such as
|
||||
`rea-gpt-4.1`, `COPILOT_PROVIDER_WIRE_MODEL=gpt-4.1`, and that distinct ID selected
|
||||
through `copilot --model rea-gpt-4.1`. The local fixture requested
|
||||
`COPILOT_PROVIDER_MAX_PROMPT_TOKENS=1000000`; configure the limit supported by
|
||||
your backend. This keeps every full schema and the actual wire model intact.
|
||||
It does not establish the effective client budget or fix the built-in-ID path.
|
||||
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),
|
||||
automatic setup requires a build containing the integration; npm 6.3.0 does not
|
||||
include it. See [released package and main](#released-package-and-main).
|
||||
In a supported build,
|
||||
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`.
|
||||
Use `rea setup --client pi --dry-run --json` to inspect the plan, then
|
||||
@@ -169,7 +201,10 @@ but unavailable in a project. An extension that registers `/mcp` can replace
|
||||
Pi's built-in MCP support and use its own configuration instead. Run `/reload`
|
||||
in an existing Pi session after changing servers outside the session.
|
||||
|
||||
For Qwen Code, setup registers `rea` in the `mcpServers` table of the
|
||||
For Qwen Code, automatic setup requires a build containing the integration;
|
||||
npm 6.3.0 does not include it. See
|
||||
[released package and main](#released-package-and-main). In a supported build,
|
||||
setup registers `rea` in the `mcpServers` table of the
|
||||
user-level `~/.qwen/settings.json`, or `$QWEN_HOME/settings.json` when configured.
|
||||
Setup and doctor follow Qwen Code's tilde and working-directory-relative home
|
||||
overrides. Qwen Code also discovers personal skills in `~/.agents/skills`,
|
||||
@@ -201,7 +236,10 @@ registration. Doctor treats an `enabled: false` entry as active when
|
||||
`enabledServers` lists `rea`, as OMP does, unless `disabledServers` also lists
|
||||
it. Run setup under each profile that should load REA.
|
||||
|
||||
For Hermes, setup writes a `mcp_servers.rea` entry to `config.yaml`, preserving
|
||||
For Hermes, automatic setup also requires a build containing the integration;
|
||||
npm 6.3.0 does not include it. See
|
||||
[released package and main](#released-package-and-main).
|
||||
In a supported build, setup writes a `mcp_servers.rea` entry to `config.yaml`, preserving
|
||||
comments and unrelated settings. It resolves that file from `HERMES_HOME`, else
|
||||
from the platform default Hermes itself uses — `%LOCALAPPDATA%\hermes` on
|
||||
Windows, `~/.hermes` elsewhere — including any `HERMES_DATA_DIR_SUFFIX`. Hermes
|
||||
@@ -255,7 +293,10 @@ for its native configuration contract.
|
||||
|
||||
### Other client settings
|
||||
|
||||
For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2
|
||||
For OpenCode, npm 6.3.0 does not honor `OPENCODE_CONFIG_DIR`; use a source build
|
||||
or a later release containing the override support when selecting a custom
|
||||
configuration directory. See [released package and main](#released-package-and-main).
|
||||
In a supported build, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2
|
||||
both load. If the configuration already uses OpenCode V2's native
|
||||
`mcp.servers` table, setup registers REA there instead and replaces any earlier
|
||||
`mcp.rea` entry from REA. Setup honors `OPENCODE_CONFIG_DIR` and
|
||||
@@ -334,6 +375,12 @@ Hopper installation or any file write. Declining or pressing Ctrl-C makes no
|
||||
changes. Agent configuration writes preserve unrelated entries and comments,
|
||||
create backups, use atomic replacement, and verify their result. Setup and
|
||||
uninstall retain an existing `.rea.backup` rather than replacing the first snapshot.
|
||||
Setup also retains valid environment overrides in REA's existing server entry,
|
||||
such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`.
|
||||
Detected provider settings replace only matching environment keys.
|
||||
Setup and doctor report a malformed REA environment map or invalid runtime
|
||||
setting before setup writes files. Repair the named setting in the client
|
||||
configuration; setup does not silently discard the rest of the map.
|
||||
|
||||
After setup, REA reports which agents, analysis tools, and workflow files passed
|
||||
its final checks. Restart any agent named in the completion message, then begin
|
||||
@@ -416,7 +463,7 @@ vendor-defined limits, and a paid license is optional. REA reuses any detected
|
||||
installation and preserves Hopper during uninstall.
|
||||
|
||||
The supported native host baseline is macOS 12+, Ubuntu 24.04+, Fedora 41+,
|
||||
Nobara 44+, 64-bit Arch Linux, or CachyOS. Ghidra and IDA have their own provider-specific
|
||||
Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. Ghidra and IDA have their own provider-specific
|
||||
host requirements; Windows Ghidra uses the [experimental P0 boundary](windows-ghidra-p0.md).
|
||||
|
||||
On macOS, approved setup downloads the official DMG, checks its published size
|
||||
@@ -598,8 +645,10 @@ dossiers, instructions, recovered data types, measured load mappings, loaded
|
||||
memory bytes, and observed file offsets. Independent load-image attestation
|
||||
supports DOS MZ and explicitly selected COM; PE returns its measurements with that limitation.
|
||||
On Linux and macOS, `annotate_native_function` also edits a function name and/or
|
||||
entry comments atomically and returns refreshed analysis. These session metadata
|
||||
edits leave executable bytes unchanged and are discarded on close. GUI controls
|
||||
entry comments atomically and returns refreshed analysis, and `set_address_name`
|
||||
and `set_addresses_names` name a function entry or any other mapped address
|
||||
(creating or renaming its primary label) with per-address success results. These
|
||||
session metadata edits leave executable bytes unchanged and are discarded on close. GUI controls
|
||||
require Hopper; Windows P0 remains read-only.
|
||||
|
||||
Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes.
|
||||
@@ -684,6 +733,27 @@ is no fixed per-operation or response-size ceiling. Unresolved computed calls
|
||||
remain unknown, reference-kind provenance is preserved, and provider-specific
|
||||
pseudocode is never treated as original source or Hopper-equivalent text.
|
||||
|
||||
### Ghidra import language and analysis seeds
|
||||
|
||||
Ghidra detects the language from the executable header. To set it (for example, Borland C++ PE images):
|
||||
|
||||
```bash
|
||||
export REA_GHIDRA_LANGUAGE_ID="x86:LE:32:default"
|
||||
export REA_GHIDRA_COMPILER_SPEC_ID="borlandcpp" # optional; requires a language
|
||||
```
|
||||
|
||||
REA passes `-processor`/`-cspec`, checks both against the installation's `.ldefs`, and records them in the analysis profile. DOS imports reject an override.
|
||||
|
||||
To seed code that auto-analysis cannot reach (computed jumps, pointers in data):
|
||||
|
||||
```bash
|
||||
export REA_GHIDRA_SEED_FILE=/absolute/path/to/seeds.tsv
|
||||
```
|
||||
|
||||
Each line is `address<TAB>kind[<TAB>name]`, where kind is `function`, `code` or `label` (a label needs a name). Addresses are `0x` hex in the default space; `#` lines are comments. A pre-script applies seeds before auto-analysis: functions from the highest address down, then code, then labels. The seed digest is part of the analysis profile, and the session fails if the applied seeds differ. Result limitations report the outcome counts; a seed fails when it overlaps an instruction Ghidra already decoded.
|
||||
|
||||
Label seeds can name mapped uninitialized storage such as `.bss`; function and code seeds require initialized bytes.
|
||||
|
||||
### Ghidra heap and CPU controls
|
||||
|
||||
Set resource controls in the environment that launches the CLI or MCP server,
|
||||
|
||||
@@ -89,9 +89,9 @@ queries use per-file range indexes, while call sites use exact-range lookups.
|
||||
Existing node budgets and coverage reporting remain in effect.
|
||||
|
||||
Progress identifies the source file being parsed and projected. Cancellation is
|
||||
checked between files, with event-loop yields that allow notifications and
|
||||
completed file-local allocations to be released. A single file's synchronous
|
||||
parser and semantic extraction still need to finish before that boundary.
|
||||
checked between files and throughout result transfer. The owned analyzer can
|
||||
be stopped while its synchronous parser or semantic extractor is active;
|
||||
the CLI/MCP process remains available for control requests.
|
||||
|
||||
Graph and Evidence identifiers hash canonical JSON incrementally, without
|
||||
assembling a single string for the whole graph. The canonical bytes and existing
|
||||
@@ -113,6 +113,46 @@ smaller view, for example `--format json --filter-output
|
||||
evidence_id,normalized_result.statistics`. Streaming output does not bound the
|
||||
memory needed to construct the analysis graph itself.
|
||||
|
||||
JavaScript source parsing, static analysis and semantic projection run in one
|
||||
owned analysis process, reused sequentially across files. Target code is never
|
||||
executed. The analyzer defaults to 1024 MiB of V8 old-space and a 300000 ms
|
||||
deadline per source, including startup and fact transfer. Select
|
||||
`max_heap_mb` and `analysis_timeout_ms` through MCP, or `--max-heap-mb` and
|
||||
`--analysis-timeout-ms` on the dedicated CLI command. These choices control the
|
||||
analyzer; the caller's `NODE_OPTIONS` still controls the CLI/MCP process heap.
|
||||
V8 old-space limits do not impose a process RSS limit.
|
||||
|
||||
A source that exhausts the analyzer heap does not terminate the CLI/MCP
|
||||
process. Completed static facts are retained, other files can still be analyzed,
|
||||
and unexamined semantic relationships remain explicit unknowns. The parent
|
||||
also checks transfer expansion and accumulated results against its remaining
|
||||
heap before accepting semantic facts. A resource-limited result has partial
|
||||
coverage, the observed limits and an `analyze-javascript-source` unknown scope
|
||||
with the specific failure, including captured diagnostics when available.
|
||||
Resource failures do not increment syntax parse failures. There is no fixed
|
||||
source-length cutoff.
|
||||
|
||||
Static observations also need memory when expanded into nodes, relationships
|
||||
and Evidence. If that expansion does not fit, the file node retains
|
||||
`static_analysis` and any admitted `semantic_module_analysis` observations,
|
||||
including their exact values and source locations. Use the public modules item
|
||||
view to read them. The graph marks those unexpanded relationships as unknown.
|
||||
If cumulative result capacity is exhausted, analysis stops; inventoried but
|
||||
unexamined files remain present with explicit unknown scopes.
|
||||
|
||||
A timeout stops the remaining analysis and returns typed failure details with
|
||||
completed partial Evidence. MCP failure details return
|
||||
`partial_observation: { kind: "retained-evidence", evidence_id: "..." }`
|
||||
after recording succeeds, so `inspect_analysis_view` can open that analysis
|
||||
directly without serializing the entire partial graph into the error response.
|
||||
CLI failure output retains the complete partial Evidence.
|
||||
Cancellation after source analysis begins likewise
|
||||
retains completed facts; an SDK client may reject its cancelled request before
|
||||
receiving that reply. On the same MCP connection, inspect the Evidence bundle
|
||||
and use the retained analysis ID with `inspect_analysis_view`. Owned analyzer
|
||||
cleanup is verified before its private files are released. Cleanup uncertainty
|
||||
is reported separately and retains ownership for retry.
|
||||
|
||||
CLI workflows parse larger JSON input files incrementally from one verified
|
||||
regular-file handle, without constructing a document-sized string. Smaller
|
||||
files retain native JSON parsing. Both paths require strict JSON and valid
|
||||
@@ -230,6 +270,14 @@ substitute version-dependent Electron defaults for omitted `webPreferences`.
|
||||
Dynamic option objects, bridge keys, API objects, and IPC channel expressions
|
||||
remain unknown and make coverage partial.
|
||||
|
||||
Electron API identity follows proven lexical module aliases. Reassigned bindings,
|
||||
dynamic selections, and visibly overwritten namespace members are unresolved.
|
||||
Direct and destructuring assignment, loop, update, and delete targets are checked
|
||||
through namespace aliases; unrelated member writes do not discard observed API
|
||||
identity. This static check does
|
||||
not evaluate arbitrary call side effects or runtime registration. Missing findings
|
||||
do not establish that an application has no Electron boundary usage.
|
||||
|
||||
IPC pairing is an inference, not an observation. A renderer `invoke` pairs only
|
||||
with one unique `ipcMain.handle`/`handleOnce` candidate on the same exact literal
|
||||
channel; a renderer send pairs only with one unique `ipcMain.on`/`once`
|
||||
|
||||
@@ -167,6 +167,9 @@ Native call tracing and process capture retain available observations in
|
||||
`details.partial_observation` on failure, including when cleanup succeeds.
|
||||
The observation reports its partial coverage; cleanup details describe host
|
||||
state separately from the execution failure.
|
||||
A process capture that fails during a finalization interval keeps every signal
|
||||
attempt and its delivery result in `details.partial_observation`, with
|
||||
`elapsed_ms` and `exit_code` null when no exit was observed.
|
||||
Derived comparisons and reconstruction verification yield before computation
|
||||
and before publication, so cancellation cannot race with successful Evidence.
|
||||
|
||||
|
||||
@@ -397,6 +397,12 @@ unpaired Unicode surrogate; a rejection identifies the field and UTF-16 index
|
||||
and leaves every annotation unchanged. CRLF, supplementary Unicode characters,
|
||||
and combining characters are preserved. Windows P0 remains read-only. Ghidra has no
|
||||
GUI authority, and REA never falls back automatically to Hopper.
|
||||
`set_address_name` and `set_addresses_names` give Ghidra sessions the same
|
||||
compact naming results as Hopper: a local function entry renames that function,
|
||||
and any other mapped address, such as a global, receives a new or renamed primary
|
||||
label. A batch applies each address separately and reports one success boolean
|
||||
per address. Use them to name many globals and helpers cheaply; use
|
||||
`annotate_native_function` when you also want comments and a refreshed dossier.
|
||||
Ghidra name edits preserve the existing namespace. Supply either a leaf name
|
||||
such as `renamed` or a fully qualified name in that namespace, such as
|
||||
`alpha::renamed`. The returned qualified name can be reused as an idempotent
|
||||
|
||||
@@ -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
|
||||
@@ -175,6 +181,100 @@ run the same owned-process cleanup path. Settlement reports whether the
|
||||
sampled process group quiesced or whether cleanup was needed or unverifiable;
|
||||
sampling cannot prove that every short-lived or detached descendant was seen.
|
||||
|
||||
### Finalization interval
|
||||
|
||||
By default a deadline sends `SIGKILL` at once. Set `finalization_ms` to give the
|
||||
target time to finish: when `timeout_ms` or `idle_timeout_ms` fires, REA sends
|
||||
`SIGTERM`, keeps capturing output and selected files, and sends `SIGKILL` once
|
||||
if the interval ends first. Cancellation is not delayed: it sends `SIGKILL`
|
||||
immediately, also during finalization, and ends the run as cancelled.
|
||||
|
||||
```json
|
||||
{
|
||||
"executable": "node",
|
||||
"arguments": ["./job.mjs"],
|
||||
"filesystem_observation_paths": ["./reports"],
|
||||
"timeout_ms": 30000,
|
||||
"finalization_ms": 1500
|
||||
}
|
||||
```
|
||||
|
||||
REA binds the captured root's first start identity to its ownership token,
|
||||
checking the identity before and after reading that token. A missing or
|
||||
mismatched token, or an identity change during validation, leaves the identity
|
||||
unverified rather than adopting a replacement process. Both finalization
|
||||
signals recheck that retained identity immediately before the call; the check
|
||||
and signal remain separate operating-system operations. REA does not fall back
|
||||
to a bare PID kill for either finalization signal; failed identity or ownership
|
||||
validation is recorded as `unverified` with its reason. Cancellation before any
|
||||
deadline keeps the existing terminal kill. Descendants are not signalled during
|
||||
finalization; owned-process cleanup handles them afterwards.
|
||||
|
||||
`exit.reason` keeps the initiating deadline (`timeout` or `idle_timeout`).
|
||||
`exit.code` is `null` for every deadline exit; the code of an exit observed
|
||||
during finalization is `exit.finalization.exit_code`, and the observed signal
|
||||
stays in `exit.signal`. `exit.finalization` records what was observed and is
|
||||
absent when no finalization was attempted:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": null,
|
||||
"signal": 0,
|
||||
"reason": "timeout",
|
||||
"finalization": {
|
||||
"requested_ms": 1500,
|
||||
"elapsed_ms": 12,
|
||||
"exit_code": 0,
|
||||
"signals": [
|
||||
{ "signal": "SIGTERM", "sent_at_ms": 0, "delivery": "signaled" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `signals` lists each attempt in order: `SIGTERM` first, at most one `SIGKILL`.
|
||||
`sent_at_ms` is the attempt time on a monotonic clock, relative to the start of
|
||||
finalization, also for an attempt that could not be delivered.
|
||||
- `delivery` is `signaled` (the identity matched and the signal call returned),
|
||||
`gone` (no live process matched), `identity-changed` (the PID now belongs to a
|
||||
different process, which was not signalled) or `unverified` (the identity could
|
||||
not be established or the call failed). None of them proves the process
|
||||
exited.
|
||||
An `unverified` attempt may carry a `reason` string naming why: an unavailable
|
||||
identity and its reason, an inspection or signal error, or delivery that did
|
||||
not settle within the bound (1000 ms). An attempt recorded `unverified` after
|
||||
that bound can still complete later; it stays identity-checked, and its late
|
||||
result is not added to the record.
|
||||
- `elapsed_ms` is when REA observed the exit, relative to the start of
|
||||
finalization, or `null` when no exit was observed. The PTY layer can deliver the
|
||||
exit slightly after the operating system ended the process.
|
||||
- `exit_code` is the code the PTY reported when the exit was observed during
|
||||
finalization and was not caused by a signal (`exit.signal` is `0`); a
|
||||
finalizer that exits with `3` records `3`, one that exits cleanly records `0`.
|
||||
It is `null` when no exit was observed or the target ended on a signal, and it
|
||||
is never inferred. A target that exited on its own near the deadline is
|
||||
recorded the same way.
|
||||
- The observed signal stays in `exit.signal`; `exit.code` is `null` for every
|
||||
deadline exit. The record does not attribute an exit to a particular signal,
|
||||
and a `SIGKILL` from a scenario event next to the escalation cannot be told
|
||||
apart from it.
|
||||
- If the `SIGKILL` cannot be delivered, REA stops waiting for an exit, keeps every
|
||||
observation collected so far with the delivery results, and releases the run
|
||||
through owned-process cleanup.
|
||||
- The `SIGKILL` is not sequenced after the `SIGTERM` delivery result: the two
|
||||
deliveries are independent, because cancellation must not wait for a slow
|
||||
identity check.
|
||||
|
||||
`timeout_ms + finalization_ms + settle_ms` must stay at or below
|
||||
`Number.MAX_SAFE_INTEGER` (9007199254740991), the largest total exactly
|
||||
representable as a JavaScript number; the scenario is rejected otherwise. This is
|
||||
a representability limit, not a timer limit. The nominal lifecycle budget is that
|
||||
sum; polling, snapshotting and cleanup lie outside it. Scenario `events` keep
|
||||
being dispatched during finalization and are recorded as usual. `finalization_ms`
|
||||
of `0`, the default, keeps today's behaviour and the committed identity of every
|
||||
existing scenario. `elapsed_ms` and `sent_at_ms` are timing data and take part in
|
||||
exit comparison like `settlement.elapsed_ms`.
|
||||
|
||||
Every capture requires `truncation_details`, with separate accounting for:
|
||||
|
||||
- `raw_terminal`: original UTF-8 PTY chunk bytes and observed/retained chunk
|
||||
|
||||
@@ -15,6 +15,48 @@ the original run after a partial publication.
|
||||
For a release that needs an independently frozen application checkpoint, use
|
||||
the optional manual path below. It retains explicit prepare and publish phases.
|
||||
|
||||
## Contributor-aware release notes
|
||||
|
||||
Use [the rea-changelog-update skill](https://github.com/morluto/rea/blob/main/.agents/skills/rea-changelog-update/SKILL.md)
|
||||
to turn the bot draft into grouped Highlights, Changes, and Fixes with inline
|
||||
`Thanks @...` credit. `npm run release:notes -- inventory` collects the full
|
||||
selected Git range and paginated GitHub PR/author/closing-issue metadata;
|
||||
`record` produces the complete contribution record, `check` validates credits
|
||||
and optional PR-body equality, and `render` writes the verified release section.
|
||||
Run a subcommand without its required options to see its usage. GitHub reads
|
||||
require an authenticated `gh`; record/check/render work offline from the saved
|
||||
inventory and complete local Git history.
|
||||
|
||||
Save a finalized inventory on the release branch at
|
||||
`docs/releases/VERSION.contributions.json`. Review unknown identities and
|
||||
reverted/backported work explicitly; GitHub metadata does not establish the
|
||||
quality of a user-facing claim. Preserve the existing breaking-change section
|
||||
and checkpoint checks. The contribution inventory is a reviewed snapshot, not
|
||||
a signed attestation; the offline checker verifies local range coverage and
|
||||
credit consistency, not GitHub's authorship independently.
|
||||
|
||||
Before editing the live PR, add `rea:release-notes-finalized` to prevent ordinary
|
||||
main-push proposals from overwriting the editorial notes. Wait for any already
|
||||
running proposal to finish. The guard applies to main-push automation; do not
|
||||
run manual checkpoint preparation over finalized notes. Remove the label to
|
||||
resume bot regeneration. The label does not freeze the base branch: new base
|
||||
commits require re-inventorying and re-reviewing the notes.
|
||||
|
||||
Update both the release branch's `CHANGELOG.md` and the PR body, preserving
|
||||
Release Please's surrounding metadata. Before creating a release, the workflow
|
||||
checks an inventory when present, requires its target to equal the reviewed
|
||||
merge's first parent, and compares the freshly fetched PR body to the changelog.
|
||||
It rejects changes outside the release metadata paths and stale credit records
|
||||
before any tag or publication. Review the diffs within allowed metadata paths
|
||||
as well, especially package dependencies and scripts. Existing candidates without inventories retain their current
|
||||
checks. Merging still starts publication; a notes edit does not authorize a
|
||||
merge or release by itself.
|
||||
|
||||
The checker supports inline Release Please PR bodies up to GitHub's 65,536
|
||||
character limit, including the surrounding header/footer. Overflow-link PR
|
||||
bodies need a separate integration and fail explicitly; credits are never
|
||||
silently truncated.
|
||||
|
||||
## 1. Select a source for a manual checkpoint
|
||||
|
||||
Use the next minor version proposed by Release Please, including releases with
|
||||
|
||||
+156
-16
@@ -99,9 +99,10 @@ result that its client offloads, and Pi exercises default codemode execution.
|
||||
Use `REA_VERIFY_RUNTIME_ROOT` to select a production-only installed REA package.
|
||||
Receipts retain client versions, result digests, host coverage and owned-process
|
||||
cleanup. These POSIX lanes use a deterministic loopback model, so they do not
|
||||
prove live model-provider or native Windows compatibility. Qwen and Pi use
|
||||
caller-configured skill directories to isolate the fixture account from the OS
|
||||
home; this does not prove their default home-directory discovery.
|
||||
prove live model-provider or native Windows compatibility. Qwen, Pi and Hermes
|
||||
bind both `HOME` and `USERPROFILE` to the disposable account. Qwen and Pi discover
|
||||
the shared personal skill through their default search paths without an explicit
|
||||
skill setting; Hermes discovers it through the selected `HERMES_HOME`.
|
||||
Set `REA_VERIFY_HERMES_STICKY_PROFILE=1` to exercise native Hermes selection of
|
||||
a named sticky profile with `HERMES_HOME` still pointing to its root.
|
||||
|
||||
@@ -159,11 +160,39 @@ Use `-- chat` for ordinary
|
||||
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
|
||||
Set `REA_VERIFY_COPILOT_MODEL` to examine another client model ID and
|
||||
`REA_VERIFY_COPILOT_WIRE_MODEL` for its backend model name (defaults to the client
|
||||
ID). Every actual HTTP request's model is checked and recorded separately.
|
||||
Both `HOME` and `USERPROFILE` point to the disposable account; native `skill add`
|
||||
still selects the installed skill directory explicitly.
|
||||
|
||||
The same native client passed full-profile chat and analysis with the actual
|
||||
wire model `gpt-4.1` under a distinct BYOK model ID:
|
||||
|
||||
```bash
|
||||
REA_VERIFY_COPILOT_MODEL=rea-gpt-4.1 REA_VERIFY_COPILOT_WIRE_MODEL=gpt-4.1 npm run verify:copilot-client -- chat
|
||||
REA_VERIFY_COPILOT_MODEL=rea-gpt-4.1 REA_VERIFY_COPILOT_WIRE_MODEL=gpt-4.1 npm run verify:copilot-client -- call
|
||||
```
|
||||
|
||||
These runs retain full canonical input/output schemas, all tools, installed-skill
|
||||
loading and complete Evidence readback. They use the fixture's requested
|
||||
one-million-token BYOK prompt setting; effective capacity and live API acceptance
|
||||
remain unknown. With the built-in `gpt-4.1` model ID, the verified client still
|
||||
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.
|
||||
|
||||
@@ -232,8 +261,9 @@ unverified; skill discovery uses an explicit isolated custom directory.
|
||||
verified with `@deepseek-ai/dsh@0.2.0-rc.2`) and Git. Set
|
||||
`REA_VERIFY_DEEPSEEK_COMMAND` to select its executable. This optional POSIX lane
|
||||
installs the skill through `rea setup --skill`, writes the native Cordis MCP
|
||||
patch into an isolated `DSH_HOME`, and uses `DSH_AGENTS_HOME` for native personal
|
||||
skill discovery. A separate Git root prevents inherited project skills from
|
||||
patch into an isolated `DSH_HOME`, and binds `HOME` and `USERPROFILE` to the
|
||||
disposable account. The native default `~/.agents/skills` search path discovers
|
||||
the installed skill without a `DSH_AGENTS_HOME` override. A separate Git root prevents inherited project skills from
|
||||
masking a missing installation. The default `call` mode checks skill loading,
|
||||
complete catalog discovery, the actual forwarded input schemas and regexes,
|
||||
and full JavaScript Evidence for a Unicode path. Use `-- chat` for ordinary
|
||||
@@ -361,6 +391,12 @@ regressions until a real fixture establishes equivalent coverage.
|
||||
|
||||
`verify:browser` exercises source-map failure isolation and expanded-output limits through the compiled CLI and stdio MCP with real Chrome. It also submits five 2 MiB source-map annotations, checks retained script identities and explicit map omissions, and closes its owned fixture target. Replacement and document-reset behavior has separate producer-boundary coverage.
|
||||
|
||||
The raw Chrome fixture launchers use `--password-store=basic` so Linux startup
|
||||
does not wait for an unlock prompt from a reachable but locked desktop keyring.
|
||||
Such a stall leaves the initial page unloaded with empty or placeholder CDP URLs;
|
||||
it does not establish permission to capture that page. See
|
||||
[the keyring diagnosis](https://github.com/morluto/rea/issues/1384#issuecomment-6093781686).
|
||||
|
||||
`verify:browser` also captures a source-owned noise canvas as a real PNG above
|
||||
8 MiB through the CLI and stdio MCP, with complete byte/digest parity and real PNG
|
||||
decoding. Its SDK client explicitly permits the larger inline JSON response;
|
||||
@@ -417,6 +453,7 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
|
||||
| Ghidra lane | Supported runner/target | Additional local tools |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:seeds` | POSIX host; source-owned 32-bit x86 raw fixture; function order and BSS labels | Ghidra 12.1.4 and full JDK 21 |
|
||||
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
|
||||
@@ -735,12 +772,37 @@ Pull requests that change only root `README*.md` files, `docs/`, `AGENTS.md`,
|
||||
or `CONTRIBUTING.md` run formatting and generated-document validation without
|
||||
the source-test shards or native package lanes. Classification compares the PR
|
||||
head with its merge base, so later base-branch changes do not expand that scope.
|
||||
The required static and coverage aggregate jobs remain present and fail if
|
||||
classification fails. Mixed changes, unknown paths and CI configuration changes
|
||||
retain full checks. Main pushes retain the full baseline, including after
|
||||
documentation merges; newer commits cancel superseded CI for the same PR or
|
||||
main branch. Release publication has its own concurrency group and retains its
|
||||
package verification and public-registry canary.
|
||||
Static and coverage aggregate jobs remain present and fail if classification
|
||||
fails. Every code change retains the complete four-shard Linux suite; individual
|
||||
test files are not selected by imports or filenames.
|
||||
|
||||
`scripts/ci/plan.mjs` selects lanes using the ownership table in
|
||||
`scripts/ci/scopes.mjs`. Provider implementation changes run their real-provider
|
||||
verifiers and the Linux x64 installed-package/Inspector baseline. Test-only
|
||||
changes run the Linux suite without native package verification. Website changes
|
||||
run website checks. Provider workflow changes validate the workflow and run that provider's verifier.
|
||||
Manual-provider/Android workflow changes run workflow validation only.
|
||||
Shared process/contract/CLI boundaries, dependencies, packaging inputs, unknown
|
||||
paths, and changes to the planner select the full baseline. Package script-only
|
||||
edits select their owning verifier when its entrypoint is identifiable; unknown
|
||||
or shared script changes select everything. Android build-script edits retain
|
||||
the Linux suite and workflow validation; real Android verification stays manual.
|
||||
Renames account for both paths.
|
||||
|
||||
Main pushes and manual dispatch retain the full baseline, including all portable
|
||||
provider lanes and the native matrix below. A `ci:full` PR label selects the full
|
||||
baseline on the next PR run; manual dispatch can request it immediately. Each
|
||||
classification job records its selection and broad-fallback reasons in the run
|
||||
summary. Representative Git-diff cases live in
|
||||
`tests/boundary/filesystem/ciChangeScope.test.ts`.
|
||||
|
||||
`CI required` checks classification and every selected lane, rejecting failed,
|
||||
cancelled, or unexpectedly skipped work. Existing required check names are
|
||||
preserved; changing branch protection to require the new aggregate is a separate
|
||||
repository-settings operation. Newer commits cancel superseded CI for the same
|
||||
PR or main branch. Release publication has its own concurrency group and retains
|
||||
its package verification and public-registry canary. Licensed/self-hosted
|
||||
provider workflows and the Android smoke test retain their explicit triggers.
|
||||
|
||||
CI exercises the pinned Node.js runtime on native hosted runners:
|
||||
|
||||
@@ -765,6 +827,35 @@ separate Apple-artifact and real-provider lanes. Actual Hopper, Ghidra, IDA,
|
||||
browser and managed-tool claims require their corresponding verification lanes.
|
||||
Runner labels follow the [GitHub hosted-runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
|
||||
|
||||
### Termux Android browser smoke test
|
||||
|
||||
The **Real Termux browser verification** workflow is manual-only while its
|
||||
emulator setup is being validated. After the workflow reaches the default
|
||||
branch, select a revision under **Actions → Real Termux browser verification →
|
||||
Run workflow**. It boots an Android 11 (API 30) x86_64 emulator, installs the
|
||||
checksum-verified Termux 0.118.3 APK, and lets the app initialize its bootstrap.
|
||||
The host stages the selected revision using `git archive HEAD`; no workstation
|
||||
build outputs or dependencies are copied into Android.
|
||||
|
||||
`scripts/verify/termux/emulator.sh` sends a `RUN_COMMAND` intent to Termux's app
|
||||
service. Root access is limited to staging files, sending the intent, and reading
|
||||
diagnostics; installation, compilation, Node, and Chromium run as Termux's app
|
||||
UID. The in-app script installs Termux's Android-linked Node distribution,
|
||||
checks its version against `.nvmrc`, pins npm from `packageManager`, runs
|
||||
`npm ci`, and builds with `build:termux`. Termux repository packages, including
|
||||
Chromium, are resolved at run time; their installed versions are logged.
|
||||
|
||||
The verifier leaves Playwright's cache environment overrides unset, requires
|
||||
a healthy public MCP doctor result, and captures URL plus DOM from a loopback
|
||||
HTTP fixture through `capture-browser-scenario`. It checks completed steps,
|
||||
the exact URL, a DOM marker, and reported browser cleanup. This lane covers one
|
||||
Android emulator and Termux combination, not physical ARM devices, Electron,
|
||||
or the complete desktop browser suite. A workflow definition alone does not
|
||||
establish passing Android coverage; inspect its execution receipt.
|
||||
|
||||
The `termux-browser-diagnostics` artifact retains the Termux command log, exit
|
||||
status, and Android logcat on success or failure. The emulator is disposable.
|
||||
|
||||
## Developer commands
|
||||
|
||||
Use source feedback while editing, explicit boundary checks for the changed
|
||||
@@ -1053,6 +1144,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 +1201,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 +1223,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
|
||||
|
||||
+7
-4
@@ -85,19 +85,22 @@ if [[ "$version" =~ \+ ]]; then
|
||||
fail "npm cannot install an exact version with build metadata. Pass a version without build metadata, then retry."
|
||||
fi
|
||||
|
||||
prefix_args=()
|
||||
install_args=(install --global)
|
||||
if [[ "$platform" == "Linux" ]]; then
|
||||
prefix_args=(--prefix "$HOME/.local")
|
||||
install_args+=(--prefix "$HOME/.local")
|
||||
install_bin="$HOME/.local/bin/rea"
|
||||
else
|
||||
npm_prefix="$(npm prefix --global)" || fail "the npm global prefix could not be read. Repair the npm configuration, then retry."
|
||||
install_bin="$npm_prefix/bin/rea"
|
||||
fi
|
||||
install_args+=("$PACKAGE@$version")
|
||||
|
||||
printf 'REA install plan\n'
|
||||
printf ' Version: %s\n' "$version"
|
||||
printf ' Runtime: Node.js %s\n' "$node_version"
|
||||
printf ' Command: npm install --global %s@%s\n' "$PACKAGE" "$version"
|
||||
printf ' Command:'
|
||||
printf ' %q' npm "${install_args[@]}"
|
||||
printf '\n'
|
||||
printf ' Binary: %s\n' "$install_bin"
|
||||
printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')"
|
||||
|
||||
@@ -107,7 +110,7 @@ if [[ "$dry_run" == true ]]; then
|
||||
fi
|
||||
|
||||
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
|
||||
npm install --global ${prefix_args[@]+"${prefix_args[@]}"} "$PACKAGE@$version" || fail "npm could not install REA. Check registry access and npm permissions, then retry."
|
||||
npm "${install_args[@]}" || fail "npm could not install REA. Check registry access and npm permissions, then retry."
|
||||
[[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry."
|
||||
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry."
|
||||
[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry."
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
"entry": [
|
||||
"src/main.ts",
|
||||
"src/cli.ts",
|
||||
"src/composition/javascriptAnalysisWorkerProcess.ts",
|
||||
"src/contracts/errorSchemas.ts",
|
||||
"src/evaluation/AgentEvaluationFixtures.ts",
|
||||
"src/application/RealHopperAssertions.ts",
|
||||
"scripts/verify/termux/browser.mjs",
|
||||
"scripts/verify-real-ghidra-noreturn.mjs",
|
||||
"tests/processOwnershipRunner.ts"
|
||||
],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
|
||||
|
||||
Generated
+17
@@ -17,6 +17,7 @@
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"@modelcontextprotocol/client": "2.3.1",
|
||||
"@modelcontextprotocol/server": "2.3.1",
|
||||
"@xmldom/xmldom": "0.9.12",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "6.0.0",
|
||||
"@zip.js/zip.js": "2.15.0",
|
||||
@@ -64,6 +65,7 @@
|
||||
"oxlint": "1.87.0",
|
||||
"turbo": "2.11.7",
|
||||
"typescript": "7.0.2",
|
||||
"typescript-termux": "npm:typescript@5.9.3",
|
||||
"vitepress": "1.6.4",
|
||||
"vitest": "5.0.3"
|
||||
},
|
||||
@@ -7367,6 +7369,21 @@
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-termux": {
|
||||
"name": "typescript",
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/unbash": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.12.tgz",
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#javascript-analysis-worker": "./dist/composition/javascriptAnalysisWorkerProcess.js"
|
||||
},
|
||||
"bin": {
|
||||
"rea": "scripts/rea.mjs",
|
||||
"rea-agents": "scripts/rea.mjs"
|
||||
@@ -50,6 +53,7 @@
|
||||
"bridge/ghidra/ReaGhidraBridge.java",
|
||||
"bridge/ghidra/ReaGhidraExtensions.java",
|
||||
"bridge/ghidra/ReaGhidraPrepareCom.java",
|
||||
"bridge/ghidra/ReaGhidraApplySeeds.java",
|
||||
"bridge/ghidra/ReaGhidraNoReturnFix.java",
|
||||
"bridge/android/ReaJadxBridge.java",
|
||||
"bridge/mitmproxy/capture.py",
|
||||
@@ -80,6 +84,7 @@
|
||||
"third_party/pwndbg/LICENSE.md"
|
||||
],
|
||||
"scripts": {
|
||||
"release:notes": "node scripts/release-notes.mjs",
|
||||
"deps:check": "node scripts/check-dependency-install.mjs",
|
||||
"metadata:generate": "node scripts/run-exclusive.mjs artifacts node scripts/generate-package-metadata.mjs",
|
||||
"metadata:check": "node scripts/generate-package-metadata.mjs --check",
|
||||
@@ -101,6 +106,8 @@
|
||||
"precheck:ci": "npm run deps:check",
|
||||
"check:ci": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint format:check knip metadata:check jscpd scan:todos && npm run verify:architecture-guards",
|
||||
"build:cached": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills",
|
||||
"build:termux": "node scripts/run-exclusive.mjs artifacts npm run build:termux:run",
|
||||
"build:termux:run": "npm run compile:termux && npm run artifacts:skills",
|
||||
"format": "oxfmt .",
|
||||
"format:check": "oxfmt --check .",
|
||||
"lint": "oxlint && npm run verify:module-boundaries",
|
||||
@@ -150,6 +157,7 @@
|
||||
"verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs",
|
||||
"verify:adb": "npm run build:cached && node scripts/verify-adb.mjs",
|
||||
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
|
||||
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
|
||||
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
|
||||
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
|
||||
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
|
||||
@@ -236,6 +244,7 @@
|
||||
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
|
||||
"mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check",
|
||||
"compile": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json",
|
||||
"compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json",
|
||||
"artifacts:mcp-catalog": "node scripts/generate-mcp-tool-catalog.mjs",
|
||||
"artifacts:skills": "node scripts/generate-skill-metadata.mjs",
|
||||
"artifacts:product-catalog": "node scripts/generate-product-catalog.mjs",
|
||||
@@ -261,6 +270,7 @@
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"@modelcontextprotocol/client": "2.3.1",
|
||||
"@modelcontextprotocol/server": "2.3.1",
|
||||
"@xmldom/xmldom": "0.9.12",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "6.0.0",
|
||||
"@zip.js/zip.js": "2.15.0",
|
||||
@@ -309,6 +319,7 @@
|
||||
"oxlint": "1.87.0",
|
||||
"turbo": "2.11.7",
|
||||
"typescript": "7.0.2",
|
||||
"typescript-termux": "npm:typescript@5.9.3",
|
||||
"vitepress": "1.6.4",
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ const classes = await mkdtemp(join(output, "classes-"));
|
||||
try {
|
||||
const sources = [
|
||||
...(await files(join(source, "src/main/java"), ".java")),
|
||||
...(await files(join(root, "bridge/ghidra/extensions/nativeaot"), ".java")),
|
||||
...(await files(join(root, "bridge/nativeaot"), ".java")),
|
||||
];
|
||||
const jars = await files(join(install, "Ghidra"), ".jar");
|
||||
const args = [
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { matchesGlob } from "node:path";
|
||||
import { SCOPES } from "./scopes.mjs";
|
||||
|
||||
const flags = [
|
||||
"code",
|
||||
"runtime",
|
||||
"package",
|
||||
"apple",
|
||||
"fixtures",
|
||||
"readiness",
|
||||
"docs",
|
||||
"workflows",
|
||||
...Object.keys(SCOPES),
|
||||
];
|
||||
const selected = Object.fromEntries(flags.map((flag) => [flag, false]));
|
||||
const reasons = [];
|
||||
const full = (reason) => {
|
||||
for (const flag of flags) selected[flag] = true;
|
||||
reasons.push(reason);
|
||||
};
|
||||
const git = (...args) =>
|
||||
execFileSync("git", args, { encoding: "utf8" }).trimEnd();
|
||||
function classifyPackage(base, head) {
|
||||
const before = JSON.parse(git("show", `${base}:package.json`));
|
||||
const after = JSON.parse(git("show", `${head}:package.json`));
|
||||
const { scripts: beforeScripts, ...beforeRest } = before;
|
||||
const { scripts: afterScripts, ...afterRest } = after;
|
||||
if (JSON.stringify(beforeRest) !== JSON.stringify(afterRest)) {
|
||||
full("Package metadata or dependencies changed");
|
||||
return;
|
||||
}
|
||||
selected.code = true;
|
||||
const changed = [
|
||||
...new Set([
|
||||
...Object.keys(beforeScripts ?? {}),
|
||||
...Object.keys(afterScripts ?? {}),
|
||||
]),
|
||||
].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]);
|
||||
for (const script of changed) {
|
||||
if (
|
||||
/^(test(?::|$)|check(?::|$)|lint(?::|$)|format(?::|$)|knip$|jscpd$)/.test(
|
||||
script,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
["compile:termux", "build:termux", "build:termux:run"].includes(script)
|
||||
) {
|
||||
selected.workflows = true;
|
||||
reasons.push(
|
||||
`Android build script changed; real Android verification remains manual: ${script}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Verify scripts own provider setup as well as execution. Reuse their
|
||||
// existing source-file ownership rather than maintaining a second map.
|
||||
const entrypoint = (command) =>
|
||||
/^(?:npm run build:cached && )?node (scripts\/[\w/-]+\.mjs)(?: [\w./=-]+)*$/.exec(
|
||||
command ?? "",
|
||||
)?.[1];
|
||||
const beforeEntry = entrypoint(beforeScripts?.[script]);
|
||||
const afterEntry = entrypoint(afterScripts?.[script]);
|
||||
const entries = [beforeEntry, afterEntry].filter(Boolean);
|
||||
const simple =
|
||||
(!beforeScripts?.[script] || beforeEntry) &&
|
||||
(!afterScripts?.[script] || afterEntry);
|
||||
const scriptOwners =
|
||||
simple &&
|
||||
entries.every((entry) =>
|
||||
Object.values(SCOPES).some((patterns) => patterns.includes(entry)),
|
||||
)
|
||||
? Object.entries(SCOPES)
|
||||
.filter(([, patterns]) =>
|
||||
entries.some((entry) => patterns.includes(entry)),
|
||||
)
|
||||
.map(([owner]) => owner)
|
||||
: [];
|
||||
if (scriptOwners.length)
|
||||
for (const owner of scriptOwners) selected[owner] = true;
|
||||
else full(`Shared or unknown package script: ${script}`);
|
||||
}
|
||||
}
|
||||
|
||||
const event = process.env.GITHUB_EVENT_NAME;
|
||||
let files = [];
|
||||
if (
|
||||
event === "push" ||
|
||||
event === "schedule" ||
|
||||
event === "workflow_dispatch" ||
|
||||
process.env.CI_FULL === "true"
|
||||
) {
|
||||
full("Full baseline requested by event or ci:full label");
|
||||
} else if (event === "pull_request") {
|
||||
const base = git("merge-base", process.env.BASE_SHA, process.env.HEAD_SHA);
|
||||
const head = process.env.HEAD_SHA;
|
||||
// Disable rename detection so both the removed and added paths participate.
|
||||
files = git("diff", "--name-only", "--no-renames", "-z", base, head)
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
for (const file of files) {
|
||||
if (
|
||||
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md)$/.test(file) ||
|
||||
file.startsWith("docs/")
|
||||
) {
|
||||
selected.docs = true;
|
||||
continue;
|
||||
}
|
||||
if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) {
|
||||
selected.code = true;
|
||||
continue;
|
||||
}
|
||||
const owners = Object.entries(SCOPES)
|
||||
.filter(([, patterns]) =>
|
||||
patterns.some((pattern) => matchesGlob(file, pattern)),
|
||||
)
|
||||
.map(([owner]) => owner);
|
||||
for (const owner of owners) selected[owner] = true;
|
||||
if (
|
||||
file.startsWith(".github/workflows/") ||
|
||||
file === ".github/actionlint.yaml"
|
||||
) {
|
||||
selected.workflows = true;
|
||||
if (file !== ".github/workflows/ci.yml") continue;
|
||||
}
|
||||
if (owners.length === 1 && owners[0] === "website") continue;
|
||||
if (/^src\/(contracts|domain)\//.test(file)) selected.docs = true;
|
||||
if (file === "package.json") {
|
||||
classifyPackage(base, head);
|
||||
continue;
|
||||
}
|
||||
selected.code = true;
|
||||
if (file.startsWith("tests/")) continue;
|
||||
if (
|
||||
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
|
||||
file,
|
||||
)
|
||||
) {
|
||||
full(`Shared boundary: ${file}`);
|
||||
} else if (owners.length) {
|
||||
if (file.startsWith("src/") || file.startsWith("bridge/"))
|
||||
selected.runtime = true;
|
||||
} else {
|
||||
full(`Unclassified input: ${file}`);
|
||||
}
|
||||
}
|
||||
} else throw new Error(`Unsupported CI event: ${event}`);
|
||||
selected.runtime ||= selected.package;
|
||||
const linux = { os: "ubuntu-latest", platform: "linux", architecture: "x64" };
|
||||
const matrix = {
|
||||
include: selected.package
|
||||
? [
|
||||
linux,
|
||||
{ os: "ubuntu-24.04-arm", platform: "linux", architecture: "arm64" },
|
||||
{ os: "macos-15", platform: "darwin", architecture: "arm64" },
|
||||
{ os: "macos-15-intel", platform: "darwin", architecture: "x64" },
|
||||
]
|
||||
: [linux],
|
||||
};
|
||||
const plan = { ...selected, matrix, files, reasons };
|
||||
if (process.env.GITHUB_OUTPUT)
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
Object.entries({ ...selected, matrix: JSON.stringify(matrix) })
|
||||
.map(([key, value]) => `${key}=${value}\n`)
|
||||
.join(""),
|
||||
);
|
||||
if (process.env.GITHUB_STEP_SUMMARY)
|
||||
appendFileSync(
|
||||
process.env.GITHUB_STEP_SUMMARY,
|
||||
`### Selected CI lanes\n\n${flags.filter((flag) => selected[flag]).join(", ") || "Formatting only"}\n\n${reasons.join("\n\n")}\n`,
|
||||
);
|
||||
console.log(JSON.stringify(plan));
|
||||
@@ -0,0 +1,25 @@
|
||||
const jobs = JSON.parse(process.env.CI_JOBS);
|
||||
const outputs = jobs.changes.outputs;
|
||||
const required = ["changes", "static", "test"];
|
||||
const lanes = {
|
||||
code: ["build", "test-shard"],
|
||||
runtime: ["windows-native-build", "package-e2e"],
|
||||
package: ["windows-curated"],
|
||||
apple: ["apple-artifacts"],
|
||||
fixtures: ["conformance-fixtures"],
|
||||
readiness: ["reconstruction-readiness"],
|
||||
docs: ["docs"],
|
||||
};
|
||||
for (const [scope, value] of Object.entries(outputs)) {
|
||||
if (value === "true") required.push(...(lanes[scope] ?? [scope]));
|
||||
}
|
||||
const failed = [...new Set(required)].filter(
|
||||
(name) => jobs[name]?.result !== "success",
|
||||
);
|
||||
for (const [name, job] of Object.entries(jobs)) {
|
||||
if (["failure", "cancelled"].includes(job.result) && !failed.includes(name))
|
||||
failed.push(name);
|
||||
}
|
||||
if (failed.length)
|
||||
throw new Error(`CI lanes did not pass: ${failed.join(", ")}`);
|
||||
console.log("Every selected CI lane passed");
|
||||
@@ -0,0 +1,227 @@
|
||||
// Ownership of the real-provider and website verification lanes. Shared build
|
||||
// inputs are classified once in plan.mjs rather than repeated in every workflow.
|
||||
export const SCOPES = {
|
||||
browser: [
|
||||
".github/workflows/real-browser.yml",
|
||||
"scripts/verify-real-browser.mjs",
|
||||
"scripts/verify-browser-network.mjs",
|
||||
"scripts/verify-browser-scripts.mjs",
|
||||
"scripts/verify-browser-modules.mjs",
|
||||
"scripts/verify-browser-dom-destinations.mjs",
|
||||
"scripts/lib/browser-*.mjs",
|
||||
"scripts/fixtures/browser-network-site.mjs",
|
||||
"scripts/fixtures/browser-script-site.mjs",
|
||||
"scripts/fixtures/browser-module-site.mjs",
|
||||
"scripts/fixtures/browser-verifier-site.mjs",
|
||||
"scripts/fixtures/page-cdp-proxy.mjs",
|
||||
"src/browser/**",
|
||||
"src/domain/browser*.ts",
|
||||
"src/domain/webScript*.ts",
|
||||
"src/domain/webModule*.ts",
|
||||
"src/application/WebModule*.ts",
|
||||
"src/composition/webModules.ts",
|
||||
"src/artifacts/readStableArtifact.ts",
|
||||
"src/artifacts/ArtifactPaths.ts",
|
||||
"src/application/WebScriptExportService.ts",
|
||||
"src/contracts/webScriptToolContracts.ts",
|
||||
"src/server/registerWebScriptTool.ts",
|
||||
"src/server/registerWebModuleTool.ts",
|
||||
"src/server/createServer.ts",
|
||||
"src/cli/webModuleCommands.ts",
|
||||
"src/cli.ts",
|
||||
"src/cliCommandNames.ts",
|
||||
"src/contracts/toolEffects.ts",
|
||||
"src/cli/webScriptCommands.ts",
|
||||
],
|
||||
binary_layout: [
|
||||
".github/workflows/real-binary-layout.yml",
|
||||
"bridge/pwntools/**",
|
||||
"src/native/pwntools/**",
|
||||
"src/domain/native/binaryLayout*",
|
||||
"src/application/binaryDiagnostics/**",
|
||||
"src/contracts/native/binaryDiagnosticsToolContracts.ts",
|
||||
"src/composition/binaryDiagnostics.ts",
|
||||
"src/server/registerBinaryDiagnosticsTools.ts",
|
||||
"src/server/createServer.ts",
|
||||
"src/cli/binaryDiagnosticsCommands.ts",
|
||||
"src/cli.ts",
|
||||
"src/cliCommandNames.ts",
|
||||
"src/contracts/toolContracts.ts",
|
||||
"src/contracts/toolEffects.ts",
|
||||
"src/process/OwnedCommand.ts",
|
||||
"src/process/ProviderProcess.ts",
|
||||
"src/process/ProviderDeadline.ts",
|
||||
"src/process/ProcessOwnership*.ts",
|
||||
"src/process/PrivateRuntimeRoot.ts",
|
||||
"src/artifacts/readStableArtifact*.ts",
|
||||
"src/artifacts/ArtifactReader.ts",
|
||||
"src/domain/analysisError*.ts",
|
||||
"scripts/verify-binary-layout.mjs",
|
||||
"scripts/lib/elf-section-name-fixtures.mjs",
|
||||
"scripts/lib/large-result-mcp.mjs",
|
||||
"scripts/fixtures/binary-layout*",
|
||||
],
|
||||
evm: [
|
||||
".github/workflows/real-evm-interface.yml",
|
||||
"src/evm/**",
|
||||
"src/domain/evm/**",
|
||||
"src/application/evm/**",
|
||||
"src/contracts/evm/**",
|
||||
"src/composition/evm.ts",
|
||||
"src/cli/evmCommands.ts",
|
||||
"src/server/registerEvmTools.ts",
|
||||
"src/server/createServer.ts",
|
||||
"src/cli.ts",
|
||||
"src/cliCommandNames.ts",
|
||||
"src/contracts/toolContracts.ts",
|
||||
"src/contracts/toolEffects.ts",
|
||||
"src/process/OwnedCommand.ts",
|
||||
"src/process/ProviderProcess.ts",
|
||||
"src/process/ProcessOwnership*.ts",
|
||||
"src/process/ProviderDeadline.ts",
|
||||
"src/process/PrivateRuntimeRoot.ts",
|
||||
"src/artifacts/readStableArtifact*.ts",
|
||||
"src/artifacts/ArtifactReader.ts",
|
||||
"scripts/verify-evm-interface.mjs",
|
||||
"scripts/fixtures/evm-interface.sol",
|
||||
"third_party/evmole/**",
|
||||
],
|
||||
javascript_recovery: [
|
||||
".github/workflows/real-javascript-recovery.yml",
|
||||
"src/javascript/recovery/**",
|
||||
"src/domain/javascript/**",
|
||||
"src/application/javascript/JavaScriptRecovery*.ts",
|
||||
"src/composition/javascriptRecovery.ts",
|
||||
"src/contracts/javascript/javascriptRecoveryToolContracts.ts",
|
||||
"src/cli/javascriptRecoveryCommands.ts",
|
||||
"src/cli/javascriptApplicationAnalysis.ts",
|
||||
"src/server/registerJavaScriptRecoveryTool.ts",
|
||||
"src/server/createServer.ts",
|
||||
"src/server/sessionAvailabilityPolicy.ts",
|
||||
"src/application/CapabilityInventory.ts",
|
||||
"src/cli.ts",
|
||||
"src/cliCommandNames.ts",
|
||||
"src/contracts/toolContracts.ts",
|
||||
"src/contracts/toolEffects.ts",
|
||||
"scripts/rea.mjs",
|
||||
"src/process/**",
|
||||
"src/artifacts/SafeOutputTree.ts",
|
||||
"scripts/verify/javascript/recovery.mjs",
|
||||
"scripts/fixtures/javascript-recovery/**",
|
||||
"third_party/wakaru",
|
||||
],
|
||||
recorded_crash: [
|
||||
".github/workflows/real-recorded-crash.yml",
|
||||
"bridge/pwntools/**",
|
||||
"bridge/pwndbg/**",
|
||||
"src/native/pwntools/**",
|
||||
"src/native/pwndbg/**",
|
||||
"src/domain/native/recordedCrash*",
|
||||
"src/application/binaryDiagnostics/**",
|
||||
"src/contracts/native/binaryDiagnosticsToolContracts.ts",
|
||||
"src/contracts/toolEffects.ts",
|
||||
"src/contracts/toolOutputSchemaGroups.ts",
|
||||
"src/composition/binaryDiagnostics.ts",
|
||||
"src/server/registerRecordedCrashTools.ts",
|
||||
"src/server/createServer.ts",
|
||||
"src/server/sessionAvailabilityPolicy.ts",
|
||||
"src/cli/binaryDiagnosticsCommands.ts",
|
||||
"src/cliCommandNames.ts",
|
||||
"src/process/**",
|
||||
"src/artifacts/readStableArtifact*.ts",
|
||||
"src/domain/analysisError*.ts",
|
||||
"scripts/verify-recorded-crash.mjs",
|
||||
"scripts/verify/recorded-crash/**",
|
||||
"scripts/lib/recorded-core-fixtures.py",
|
||||
"scripts/fixtures/recorded-crash.c",
|
||||
"third_party/pwndbg/**",
|
||||
],
|
||||
web_network: [
|
||||
".github/workflows/real-web-network-captures.yml",
|
||||
"bridge/mitmproxy/**",
|
||||
"src/browser/history/**",
|
||||
"src/domain/webNetworkCapture*.ts",
|
||||
"src/domain/analysisError*.ts",
|
||||
"src/contracts/errorSchemas.ts",
|
||||
"src/domain/explicitSensitive*.ts",
|
||||
"src/application/WebNetworkCapture*",
|
||||
"src/composition/webNetworkCaptures.ts",
|
||||
"src/cli/webNetworkCaptureCommands.ts",
|
||||
"src/server/registerWebNetworkCaptureTool.ts",
|
||||
"src/server/createServer.ts",
|
||||
"src/server/toolRegistrationOptions.ts",
|
||||
"src/cli.ts",
|
||||
"src/cliCommandNames.ts",
|
||||
"src/contracts/toolContracts.ts",
|
||||
"src/contracts/toolEffects.ts",
|
||||
"src/process/OwnedCommand.ts",
|
||||
"src/process/ProviderProcess.ts",
|
||||
"src/process/ProviderDeadline.ts",
|
||||
"src/process/ProcessOwnership*.ts",
|
||||
"src/process/PrivateRuntimeRoot.ts",
|
||||
"src/process/capture/ProcessCaptureLifecycle.ts",
|
||||
"src/artifacts/readStableArtifact*.ts",
|
||||
"src/artifacts/ArtifactReader.ts",
|
||||
"src/contracts/webNetworkCaptureToolContracts.ts",
|
||||
"scripts/verify-web-network-captures.mjs",
|
||||
"scripts/fixtures/generate-mitmproxy-capture.py",
|
||||
],
|
||||
web_runtime: [
|
||||
".github/workflows/real-web-runtime.yml",
|
||||
"scripts/verify-browser-runtime.mjs",
|
||||
"scripts/lib/browser-runtime-*.mjs",
|
||||
"src/process/ProviderProcess.ts",
|
||||
"src/process/ProcessOwnership*.ts",
|
||||
"scripts/fixtures/browser-runtime-site.mjs",
|
||||
"src/browser/execution/**",
|
||||
"src/browser/CdpConnection.ts",
|
||||
"src/browser/CdpEndpoint.ts",
|
||||
"src/browser/CdpTarget*.ts",
|
||||
"src/browser/CdpCaptureValues.ts",
|
||||
"src/browser/CdpAuthorized*.ts",
|
||||
"src/domain/webRuntime.ts",
|
||||
"src/domain/webExecution.ts",
|
||||
"src/domain/webEventListeners.ts",
|
||||
"src/domain/browserObservationError*.ts",
|
||||
"src/application/WebRuntime*.ts",
|
||||
"src/composition/webRuntime.ts",
|
||||
"src/contracts/webRuntimeToolContracts.ts",
|
||||
"src/contracts/toolEffects.ts",
|
||||
"src/server/registerWebRuntimeTools.ts",
|
||||
"src/server/createServer.ts",
|
||||
"src/cli/webRuntimeCommands.ts",
|
||||
"src/cli/commandCancellation.ts",
|
||||
"src/cli.ts",
|
||||
],
|
||||
web_source_map: [
|
||||
".github/workflows/real-web-source-map.yml",
|
||||
"scripts/verify-browser-source-maps.mjs",
|
||||
"scripts/lib/browser-source-map-e2e.mjs",
|
||||
"scripts/fixtures/browser-source-map-site.mjs",
|
||||
"src/javascript/sourceMaps/**",
|
||||
"src/browser/assets/**",
|
||||
"src/domain/webSource*.ts",
|
||||
"src/domain/webScript*.ts",
|
||||
"src/application/WebSourceLocation*.ts",
|
||||
"src/application/ValidateSelectedWebScript.ts",
|
||||
"src/composition/webSourceLocations.ts",
|
||||
"src/server/registerWebSourceLocationTool.ts",
|
||||
"src/server/createServer.ts",
|
||||
"src/contracts/webScriptToolContracts.ts",
|
||||
"src/process/**",
|
||||
"src/cli/webSourceLocationCommands.ts",
|
||||
"src/cli.ts",
|
||||
"src/artifacts/readStableArtifact.ts",
|
||||
],
|
||||
nativeaot: [
|
||||
"bridge/nativeaot/**",
|
||||
"scripts/generate-nativeaot-fixtures.mjs",
|
||||
"tests/fixtures/nativeaot/**",
|
||||
".github/workflows/nativeaot-fixtures.yml",
|
||||
],
|
||||
website: [
|
||||
"website/**",
|
||||
"scripts/*website*.py",
|
||||
".github/workflows/website-check.yml",
|
||||
],
|
||||
};
|
||||
@@ -18,6 +18,7 @@ export async function startRuntimeFixtureBrowser(executable, origin) {
|
||||
command: executable,
|
||||
arguments: [
|
||||
"--headless=new",
|
||||
"--password-store=basic",
|
||||
...(process.env.REA_BROWSER_NO_SANDBOX === "true"
|
||||
? ["--no-sandbox"]
|
||||
: []),
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
|
||||
import { createBrowserScenarioProvider } from "../../dist/composition/browserScenario.js";
|
||||
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
|
||||
import {
|
||||
runScenarioCli,
|
||||
scenarioProfiles,
|
||||
} from "./browser-scenario-verifier.mjs";
|
||||
|
||||
const scenarioFor = (browser, origin) =>
|
||||
browserScenarioSchema.parse({
|
||||
browser,
|
||||
start_url: { url: `${origin}/storage-main` },
|
||||
actions: [
|
||||
{
|
||||
step_id: "frame-ready",
|
||||
action: "wait_for",
|
||||
locator: { kind: "css", selector: "#frame-report[data-ready='true']" },
|
||||
state: "attached",
|
||||
timeout_ms: 10_000,
|
||||
},
|
||||
{
|
||||
step_id: "top-level-partition",
|
||||
action: "goto",
|
||||
destination: {
|
||||
url: `${origin.replace("127.0.0.1", "localhost")}/storage-top`,
|
||||
},
|
||||
wait_until: "load",
|
||||
},
|
||||
{
|
||||
step_id: "return-to-origin",
|
||||
action: "goto",
|
||||
destination: { url: `${origin}/storage-main` },
|
||||
wait_until: "load",
|
||||
},
|
||||
],
|
||||
storage: {
|
||||
local_storage: [
|
||||
{
|
||||
origin,
|
||||
entries: [
|
||||
{
|
||||
name: "rea_storage_probe",
|
||||
value: { source: "literal", value: "main-initial" },
|
||||
},
|
||||
{
|
||||
name: "rea_storage_remove",
|
||||
value: { source: "literal", value: "main-remove-seed" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
origin: origin.replace("127.0.0.1", "localhost"),
|
||||
entries: [
|
||||
{
|
||||
name: "rea_storage_probe",
|
||||
value: { source: "literal", value: "frame-initial" },
|
||||
},
|
||||
{
|
||||
name: "rea_storage_remove",
|
||||
value: { source: "literal", value: "frame-remove-seed" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
session_storage: [
|
||||
{
|
||||
origin,
|
||||
entries: [
|
||||
{
|
||||
name: "rea_storage_probe",
|
||||
value: { source: "literal", value: "main-session-initial" },
|
||||
},
|
||||
{
|
||||
name: "rea_storage_empty",
|
||||
value: { source: "literal", value: "main-empty-seed" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
origin: origin.replace("127.0.0.1", "localhost"),
|
||||
entries: [
|
||||
{
|
||||
name: "rea_storage_probe",
|
||||
value: { source: "literal", value: "frame-session-initial" },
|
||||
},
|
||||
{
|
||||
name: "rea_storage_empty",
|
||||
value: { source: "literal", value: "frame-empty-seed" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
capture: { after_each_step: ["dom"], at_end: ["dom"] },
|
||||
});
|
||||
|
||||
const storagePage = (kind, port) => {
|
||||
if (kind === "main")
|
||||
return `<!doctype html><html><body><pre id="main-report"></pre><pre id="frame-report"></pre><iframe src="http://localhost:${port}/storage-frame"></iframe><script>
|
||||
const state = { local: localStorage.getItem("rea_storage_probe"), session: sessionStorage.getItem("rea_storage_probe"), removed: localStorage.getItem("rea_storage_remove"), empty: sessionStorage.getItem("rea_storage_empty") };
|
||||
document.querySelector("#main-report").textContent = JSON.stringify(state);
|
||||
localStorage.setItem("rea_storage_probe", "main-updated");
|
||||
sessionStorage.setItem("rea_storage_probe", "main-session-updated");
|
||||
localStorage.removeItem("rea_storage_remove");
|
||||
sessionStorage.setItem("rea_storage_empty", "");
|
||||
addEventListener("message", (event) => {
|
||||
if (event.data?.kind !== "rea-storage-frame") return;
|
||||
const report = document.querySelector("#frame-report");
|
||||
report.textContent = JSON.stringify(event.data.state);
|
||||
report.dataset.ready = "true";
|
||||
});
|
||||
</script></body></html>`;
|
||||
if (kind === "frame")
|
||||
return `<!doctype html><html><body><pre id="frame-state"></pre><script>
|
||||
const state = { local: localStorage.getItem("rea_storage_probe"), session: sessionStorage.getItem("rea_storage_probe"), removed: localStorage.getItem("rea_storage_remove"), empty: sessionStorage.getItem("rea_storage_empty") };
|
||||
document.querySelector("#frame-state").textContent = JSON.stringify(state);
|
||||
localStorage.setItem("rea_storage_probe", "frame-updated");
|
||||
sessionStorage.setItem("rea_storage_probe", "frame-session-updated");
|
||||
localStorage.removeItem("rea_storage_remove");
|
||||
sessionStorage.setItem("rea_storage_empty", "");
|
||||
parent.postMessage({ kind: "rea-storage-frame", state }, "*");
|
||||
</script></body></html>`;
|
||||
if (kind === "debugger")
|
||||
return `<!doctype html><html><body><pre id="debugger-state"></pre><script>
|
||||
window.reaDebuggerBefore = true;
|
||||
debugger;
|
||||
window.reaDebuggerAfter = true;
|
||||
document.querySelector("#debugger-state").textContent = String(window.reaDebuggerAfter);
|
||||
</script></body></html>`;
|
||||
return `<!doctype html><html><body><pre id="top-report"></pre><script>
|
||||
const state = { local: localStorage.getItem("rea_storage_probe"), session: sessionStorage.getItem("rea_storage_probe"), removed: localStorage.getItem("rea_storage_remove"), empty: sessionStorage.getItem("rea_storage_empty") };
|
||||
document.querySelector("#top-report").textContent = JSON.stringify(state);
|
||||
localStorage.setItem("rea_storage_probe", "top-updated");
|
||||
sessionStorage.setItem("rea_storage_probe", "top-session-updated");
|
||||
</script></body></html>`;
|
||||
};
|
||||
|
||||
const startStorageSite = async () => {
|
||||
let port = 0;
|
||||
const server = createServer((request, response) => {
|
||||
const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
|
||||
const kind =
|
||||
path === "/storage-main"
|
||||
? "main"
|
||||
: path === "/storage-frame"
|
||||
? "frame"
|
||||
: path === "/storage-debugger"
|
||||
? "debugger"
|
||||
: path === "/storage-top"
|
||||
? "top"
|
||||
: undefined;
|
||||
if (kind === undefined) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
response.end(storagePage(kind, port));
|
||||
});
|
||||
server.listen(0, "::");
|
||||
await once(server, "listening");
|
||||
port = server.address().port;
|
||||
return {
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
close: async () => {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const domText = (step) => {
|
||||
assert.equal(step.artifacts.dom.state, "captured");
|
||||
return step.artifacts.dom.value.text;
|
||||
};
|
||||
|
||||
const assertContainsState = (text, id, expected) => {
|
||||
const match = new RegExp(`<pre id="${id}"[^>]*>(.*?)</pre>`, "su").exec(text);
|
||||
assert.ok(match, `Captured DOM omitted #${id}: ${text}`);
|
||||
assert.deepEqual(JSON.parse(match[1]), expected);
|
||||
};
|
||||
|
||||
/** Verify one-time local/session storage initialization on a real Chromium. */
|
||||
export async function verifyScenarioStorage({
|
||||
executable,
|
||||
endpoint,
|
||||
targetId,
|
||||
}) {
|
||||
const site = await startStorageSite();
|
||||
try {
|
||||
await verifyLaunchStorage(executable, site.origin);
|
||||
await verifyConnectedStorage(endpoint, targetId, site.origin);
|
||||
return {
|
||||
mocked: false,
|
||||
launch: true,
|
||||
connect: true,
|
||||
oopifInitialization: true,
|
||||
firstInlineScriptSeeded: true,
|
||||
updatesAndDeletesPersistAcrossNavigation: true,
|
||||
newScenarioGetsFreshSeed: true,
|
||||
externalTargetSurvives: true,
|
||||
cancellationCleanup: true,
|
||||
};
|
||||
} finally {
|
||||
await site.close();
|
||||
}
|
||||
}
|
||||
|
||||
const verifyLaunchStorage = async (executable, origin) => {
|
||||
const scenario = scenarioFor(
|
||||
{ mode: "launch", executable_path: executable },
|
||||
origin,
|
||||
);
|
||||
const profilesBefore = await scenarioProfiles();
|
||||
const result = await runScenarioCli(scenario);
|
||||
const capture = result.normalized_result;
|
||||
assert.ok(capture, "Launch-mode storage scenario returned no result");
|
||||
assertLaunchStorageStates(capture);
|
||||
assert.equal(capture.browser.cleanup, "terminated-owned-process");
|
||||
const profilesAfter = await scenarioProfiles();
|
||||
assert.ok([...profilesAfter].every((profile) => profilesBefore.has(profile)));
|
||||
|
||||
const freshScenario = browserScenarioSchema.parse({
|
||||
...scenario,
|
||||
actions: [scenario.actions[0]],
|
||||
});
|
||||
const fresh = await createBrowserScenarioProvider(
|
||||
process.env,
|
||||
).captureScenario(freshScenario);
|
||||
assert.equal(fresh.ok, true);
|
||||
if (!fresh.ok) throw fresh.error;
|
||||
assertContainsState(domText(fresh.value.steps[0]), "main-report", {
|
||||
local: "main-initial",
|
||||
session: "main-session-initial",
|
||||
removed: "main-remove-seed",
|
||||
empty: "main-empty-seed",
|
||||
});
|
||||
};
|
||||
|
||||
const assertLaunchStorageStates = (capture) => {
|
||||
assertContainsState(domText(capture.steps[0]), "main-report", {
|
||||
local: "main-initial",
|
||||
session: "main-session-initial",
|
||||
removed: "main-remove-seed",
|
||||
empty: "main-empty-seed",
|
||||
});
|
||||
assertContainsState(domText(capture.steps[1]), "frame-report", {
|
||||
local: "frame-initial",
|
||||
session: "frame-session-initial",
|
||||
removed: "frame-remove-seed",
|
||||
empty: "frame-empty-seed",
|
||||
});
|
||||
assertContainsState(domText(capture.steps[2]), "top-report", {
|
||||
local: "frame-updated",
|
||||
session: "frame-session-updated",
|
||||
removed: null,
|
||||
empty: "",
|
||||
});
|
||||
assertContainsState(domText(capture.steps[3]), "main-report", {
|
||||
local: "main-updated",
|
||||
session: "main-session-updated",
|
||||
removed: null,
|
||||
empty: "",
|
||||
});
|
||||
assertContainsState(domText(capture.steps[3]), "frame-report", {
|
||||
local: "top-updated",
|
||||
session: "top-session-updated",
|
||||
removed: null,
|
||||
empty: "",
|
||||
});
|
||||
};
|
||||
|
||||
const verifyConnectedStorage = async (endpoint, targetId, origin) => {
|
||||
const scenario = scenarioFor(
|
||||
{ mode: "connect", cdp_endpoint: endpoint, target_id: targetId },
|
||||
origin,
|
||||
);
|
||||
const targets = await (
|
||||
await fetch(`${endpoint}/json/list`, { signal: AbortSignal.timeout(2_000) })
|
||||
).json();
|
||||
const target = targets.find(({ id }) => id === targetId);
|
||||
assert.ok(target, "External browser target is missing");
|
||||
const observer = await CdpConnection.connect(
|
||||
target.webSocketDebuggerUrl,
|
||||
"capture_browser_scenario",
|
||||
);
|
||||
try {
|
||||
await assertStorageKeyPartition(observer, origin);
|
||||
const result = await runScenarioCli(scenario);
|
||||
const capture = result.normalized_result;
|
||||
assert.ok(capture, "Connect-mode storage scenario returned no result");
|
||||
assertContainsState(domText(capture.steps[0]), "main-report", {
|
||||
local: "main-initial",
|
||||
session: "main-session-initial",
|
||||
removed: "main-remove-seed",
|
||||
empty: "main-empty-seed",
|
||||
});
|
||||
assert.equal(capture.browser.cleanup, "disconnected-external");
|
||||
await verifyCallerDebuggerPause(observer, origin, scenario);
|
||||
await verifyCancelledStorage(observer, endpoint, targetId, scenario);
|
||||
} finally {
|
||||
await Promise.race([
|
||||
restoreTarget(observer, target.url),
|
||||
delay(1_000),
|
||||
]).catch(() => undefined);
|
||||
await Promise.race([observer.close(), delay(1_000)]).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const assertStorageKeyPartition = async (connection, origin) => {
|
||||
const attachedSessions = new Set();
|
||||
const unsubscribe = connection.onEvent((event) => {
|
||||
if (
|
||||
event.method === "Target.attachedToTarget" &&
|
||||
typeof event.params === "object" &&
|
||||
event.params !== null &&
|
||||
"sessionId" in event.params &&
|
||||
typeof event.params.sessionId === "string" &&
|
||||
"targetInfo" in event.params &&
|
||||
typeof event.params.targetInfo === "object" &&
|
||||
event.params.targetInfo !== null &&
|
||||
"type" in event.params.targetInfo &&
|
||||
event.params.targetInfo.type === "iframe"
|
||||
)
|
||||
attachedSessions.add(event.params.sessionId);
|
||||
});
|
||||
await connection.send("Page.enable");
|
||||
await connection.send("Target.setAutoAttach", {
|
||||
autoAttach: true,
|
||||
waitForDebuggerOnStart: false,
|
||||
flatten: true,
|
||||
});
|
||||
try {
|
||||
const start = await connection.send("Runtime.evaluate", {
|
||||
expression: "performance.timeOrigin",
|
||||
returnByValue: true,
|
||||
});
|
||||
await connection.send("Page.navigate", {
|
||||
url: `${origin}/storage-main`,
|
||||
});
|
||||
await waitForPagePath(connection, "/storage-main", start.result.value);
|
||||
|
||||
let childSession;
|
||||
let childFrameId;
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline && childFrameId === undefined) {
|
||||
for (const sessionId of attachedSessions) {
|
||||
try {
|
||||
const tree = await connection.send(
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
sessionId,
|
||||
);
|
||||
const frame = tree.frameTree?.frame;
|
||||
if (
|
||||
typeof frame?.id === "string" &&
|
||||
frame.url ===
|
||||
`${origin.replace("127.0.0.1", "localhost")}/storage-frame`
|
||||
) {
|
||||
childSession = sessionId;
|
||||
childFrameId = frame.id;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// The iframe may detach between target discovery and frame inspection.
|
||||
}
|
||||
}
|
||||
if (childFrameId === undefined) await delay(25);
|
||||
}
|
||||
assert.ok(
|
||||
childSession && childFrameId,
|
||||
"Storage fixture OOPIF was not attached",
|
||||
);
|
||||
const child = await connection.send(
|
||||
"Storage.getStorageKey",
|
||||
{ frameId: childFrameId },
|
||||
childSession,
|
||||
);
|
||||
const rootTree = await connection.send("Page.getFrameTree");
|
||||
const beforeRoot = await connection.send("Storage.getStorageKey", {
|
||||
frameId: rootTree.frameTree.frame.id,
|
||||
});
|
||||
await connection.send("Page.navigate", {
|
||||
url: `${origin.replace("127.0.0.1", "localhost")}/storage-top`,
|
||||
});
|
||||
await waitForPagePath(connection, "/storage-top", start.result.value);
|
||||
const afterTree = await connection.send("Page.getFrameTree");
|
||||
const topLevel = await connection.send("Storage.getStorageKey", {
|
||||
frameId: afterTree.frameTree.frame.id,
|
||||
});
|
||||
assert.notEqual(
|
||||
child.storageKey,
|
||||
topLevel.storageKey,
|
||||
`Chrome did not expose distinct partition keys: child=${JSON.stringify(child)} top=${JSON.stringify(topLevel)} rootBefore=${JSON.stringify(beforeRoot)}`,
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await connection.send("Target.setAutoAttach", {
|
||||
autoAttach: false,
|
||||
waitForDebuggerOnStart: false,
|
||||
flatten: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const waitForPagePath = async (connection, path, priorTimeOrigin) => {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await connection.send("Runtime.evaluate", {
|
||||
expression: `JSON.stringify({ path: location.pathname, time: performance.timeOrigin })`,
|
||||
returnByValue: true,
|
||||
});
|
||||
const current = JSON.parse(result.result.value);
|
||||
if (current.path === path && current.time !== priorTimeOrigin) return;
|
||||
await delay(25);
|
||||
}
|
||||
throw new Error(`External browser did not navigate to ${path}`);
|
||||
};
|
||||
|
||||
const verifyCallerDebuggerPause = async (caller, origin, scenario) => {
|
||||
await caller.send("Runtime.enable");
|
||||
await caller.send("Debugger.enable");
|
||||
const controller = new AbortController();
|
||||
const debuggerUrl = `${origin}/storage-debugger`;
|
||||
const scripts = new Map();
|
||||
const protocolTrace = [];
|
||||
let pauseObserved = false;
|
||||
let resolvePaused;
|
||||
const paused = new Promise((resolve) => {
|
||||
resolvePaused = (callFrameId) => {
|
||||
pauseObserved = true;
|
||||
resolve(callFrameId);
|
||||
};
|
||||
});
|
||||
const unsubscribe = caller.onEvent((event) => {
|
||||
if (event.method === "Debugger.scriptParsed") {
|
||||
const params = event.params;
|
||||
if (
|
||||
typeof params === "object" &&
|
||||
params !== null &&
|
||||
"scriptId" in params &&
|
||||
typeof params.scriptId === "string" &&
|
||||
"url" in params &&
|
||||
typeof params.url === "string"
|
||||
)
|
||||
scripts.set(params.scriptId, params.url);
|
||||
if (
|
||||
typeof params === "object" &&
|
||||
params !== null &&
|
||||
"url" in params &&
|
||||
typeof params.url === "string" &&
|
||||
params.url.includes("storage")
|
||||
)
|
||||
protocolTrace.push({ method: event.method, url: params.url });
|
||||
} else if (event.method === "Debugger.paused") {
|
||||
const params = event.params;
|
||||
const callFrames =
|
||||
typeof params === "object" && params !== null && "callFrames" in params
|
||||
? params.callFrames
|
||||
: undefined;
|
||||
const frame = Array.isArray(callFrames) ? callFrames[0] : undefined;
|
||||
const scriptId =
|
||||
typeof frame === "object" && frame !== null && "location" in frame
|
||||
? frame.location?.scriptId
|
||||
: undefined;
|
||||
protocolTrace.push({
|
||||
method: event.method,
|
||||
url: typeof scriptId === "string" ? scripts.get(scriptId) : undefined,
|
||||
reason:
|
||||
typeof params === "object" && params !== null && "reason" in params
|
||||
? params.reason
|
||||
: undefined,
|
||||
});
|
||||
if (
|
||||
typeof frame === "object" &&
|
||||
frame !== null &&
|
||||
typeof frame.callFrameId === "string" &&
|
||||
typeof scriptId === "string" &&
|
||||
scripts.get(scriptId) === debuggerUrl
|
||||
)
|
||||
resolvePaused(frame.callFrameId);
|
||||
}
|
||||
});
|
||||
const debuggerScenario = browserScenarioSchema.parse({
|
||||
...scenario,
|
||||
start_url: { url: debuggerUrl },
|
||||
actions: [
|
||||
{ step_id: "after-debugger", action: "wait_for_timeout", duration_ms: 1 },
|
||||
],
|
||||
});
|
||||
let settled = false;
|
||||
const pending = createBrowserScenarioProvider(process.env)
|
||||
.captureScenario(debuggerScenario, { signal: controller.signal })
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
unsubscribe();
|
||||
});
|
||||
try {
|
||||
const callFrameId = await Promise.race([
|
||||
paused,
|
||||
delay(10_000).then(() => {
|
||||
throw new Error(
|
||||
`Caller debugger pause was not observed at ${debuggerUrl}; settled=${String(settled)} events=${JSON.stringify(protocolTrace)}`,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
const pausedState = await caller.send("Debugger.evaluateOnCallFrame", {
|
||||
callFrameId,
|
||||
expression: "window.reaDebuggerAfter === true",
|
||||
returnByValue: true,
|
||||
});
|
||||
assert.equal(
|
||||
pausedState.result.value,
|
||||
false,
|
||||
"Caller-owned debugger frame resumed before the caller inspected it",
|
||||
);
|
||||
await delay(100);
|
||||
assert.equal(
|
||||
settled,
|
||||
false,
|
||||
"Storage observer resumed a caller-owned debugger pause",
|
||||
);
|
||||
await caller.send("Debugger.resume");
|
||||
pauseObserved = false;
|
||||
const result = await pending;
|
||||
if (!result.ok) throw result.error;
|
||||
const debuggerState = await caller.send("Runtime.evaluate", {
|
||||
expression: "window.reaDebuggerAfter === true",
|
||||
returnByValue: true,
|
||||
});
|
||||
assert.equal(debuggerState.result.value, true);
|
||||
} catch (cause) {
|
||||
if (!pauseObserved) controller.abort(cause);
|
||||
else await caller.send("Debugger.resume").catch(() => undefined);
|
||||
const completion = await Promise.race([
|
||||
pending.catch((pendingCause) => ({ ok: false, error: pendingCause })),
|
||||
delay(5_000).then(() => undefined),
|
||||
]);
|
||||
if (completion === undefined)
|
||||
throw new Error("Capture remained pending after cancellation", { cause });
|
||||
const completionError =
|
||||
typeof completion === "object" &&
|
||||
completion !== null &&
|
||||
"error" in completion
|
||||
? completion.error
|
||||
: typeof completion === "object" &&
|
||||
completion !== null &&
|
||||
"ok" in completion &&
|
||||
completion.ok === false
|
||||
? completion.error
|
||||
: undefined;
|
||||
const completionDetail =
|
||||
completionError === undefined
|
||||
? "capture did not report an error"
|
||||
: (() => {
|
||||
const causes = [];
|
||||
let current = completionError;
|
||||
for (
|
||||
let depth = 0;
|
||||
depth < 4 && current !== undefined;
|
||||
depth += 1
|
||||
) {
|
||||
causes.push({
|
||||
name: current.name,
|
||||
tag: current._tag,
|
||||
reason: current.reason,
|
||||
message: current.message,
|
||||
});
|
||||
current = current.cause;
|
||||
}
|
||||
return JSON.stringify(causes);
|
||||
})();
|
||||
throw new Error(`${String(cause)}; capture=${completionDetail}`, {
|
||||
cause,
|
||||
});
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await Promise.race([caller.send("Debugger.disable"), delay(1_000)]).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCancelledStorage = async (
|
||||
observer,
|
||||
endpoint,
|
||||
targetId,
|
||||
scenario,
|
||||
) => {
|
||||
const controller = new AbortController();
|
||||
const cancelledScenario = browserScenarioSchema.parse({
|
||||
...scenario,
|
||||
actions: [
|
||||
{
|
||||
step_id: "cancelled-wait",
|
||||
action: "wait_for",
|
||||
locator: { kind: "css", selector: "#never-storage-ready" },
|
||||
state: "visible",
|
||||
timeout_ms: 30_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
const priorDocument = await observer.send("Runtime.evaluate", {
|
||||
expression: "performance.timeOrigin",
|
||||
returnByValue: true,
|
||||
});
|
||||
const pending = createBrowserScenarioProvider(process.env).captureScenario(
|
||||
cancelledScenario,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await waitForNewDocument(
|
||||
observer,
|
||||
"/storage-main",
|
||||
priorDocument.result.value,
|
||||
);
|
||||
const state = await observer.send("Runtime.evaluate", {
|
||||
expression:
|
||||
"JSON.parse(document.querySelector('#main-report').textContent)",
|
||||
returnByValue: true,
|
||||
});
|
||||
assert.deepEqual(state.result.value, {
|
||||
local: "main-initial",
|
||||
session: "main-session-initial",
|
||||
removed: "main-remove-seed",
|
||||
empty: "main-empty-seed",
|
||||
});
|
||||
controller.abort(new Error("cancel storage seed scenario"));
|
||||
const result = await pending;
|
||||
assert.ok(
|
||||
!result.ok ||
|
||||
result.value.steps.some(({ status }) => status === "cancelled"),
|
||||
"Cancellation unexpectedly completed every scenario step",
|
||||
);
|
||||
const targets = await (await fetch(`${endpoint}/json/list`)).json();
|
||||
assert.ok(
|
||||
targets.some(({ id }) => id === targetId),
|
||||
"Cancelled connect-mode storage cleanup closed the external target",
|
||||
);
|
||||
};
|
||||
|
||||
const waitForNewDocument = async (connection, path, priorTimeOrigin) => {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await connection.send("Runtime.evaluate", {
|
||||
expression: `JSON.stringify({ path: location.pathname, time: performance.timeOrigin, state: document.querySelector('#main-report')?.textContent })`,
|
||||
returnByValue: true,
|
||||
});
|
||||
const current = JSON.parse(result.result.value);
|
||||
if (
|
||||
current.path === path &&
|
||||
current.time !== priorTimeOrigin &&
|
||||
current.state !== undefined
|
||||
)
|
||||
return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
throw new Error(`External browser did not navigate to ${path}`);
|
||||
};
|
||||
|
||||
const restoreTarget = async (connection, url) => {
|
||||
await connection.send("Page.navigate", { url });
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await connection.send("Runtime.evaluate", {
|
||||
expression: "location.href",
|
||||
returnByValue: true,
|
||||
});
|
||||
if (result.result.value === url) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
throw new Error("External browser did not return to its original URL");
|
||||
};
|
||||
@@ -65,7 +65,11 @@ function assertObservationEvents(result) {
|
||||
throw new Error(
|
||||
"Real Chrome attach-window console observation was missing",
|
||||
);
|
||||
if (result.network.websocket_events.length < 1)
|
||||
if (
|
||||
!result.network.websocket_connections.some(
|
||||
(connection) => connection.events.length > 0,
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome WebSocket metadata was missing");
|
||||
}
|
||||
|
||||
@@ -77,8 +81,8 @@ function assertObservationPrivacy(result) {
|
||||
result.network.requests.some(
|
||||
(request) => request.body_shapes.status !== "not_approved",
|
||||
) ||
|
||||
result.network.websocket_events.some(
|
||||
(event) => event.payload_shape !== null,
|
||||
result.network.websocket_connections.some((connection) =>
|
||||
connection.events.some((event) => event.payload_shape !== null),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
@@ -246,7 +250,8 @@ export function assertSensitiveShapes(result) {
|
||||
)
|
||||
throw new Error("Real Chrome selected console text was not preserved");
|
||||
if (
|
||||
!result.network.websocket_events.some(
|
||||
!result.network.websocket_connections.some((connection) =>
|
||||
connection.events.some(
|
||||
(event) =>
|
||||
event.payload_shape?.format === "json" &&
|
||||
event.payload_shape.json_shape?.properties.some(
|
||||
@@ -255,6 +260,7 @@ export function assertSensitiveShapes(result) {
|
||||
path[0].kind === "property" &&
|
||||
path[0].name === "token",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome WebSocket JSON shape was missing");
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { analyzeJavaScriptApplication } from "../../dist/application/javascript/JavaScriptApplicationService.js";
|
||||
import { ArtifactResourceScope } from "../../dist/artifacts/ArtifactResourceScope.js";
|
||||
|
||||
/** Analyze one JavaScript application and close its caller-owned resources. */
|
||||
export const analyzeJavaScriptApplicationWithOwnedScope = async (input) => {
|
||||
const scope = new ArtifactResourceScope();
|
||||
let outcome;
|
||||
try {
|
||||
const result = await analyzeJavaScriptApplication(input, scope);
|
||||
outcome = result.ok
|
||||
? { kind: "completed", value: result.value }
|
||||
: { kind: "failed", cause: result.error };
|
||||
} catch (cause) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
try {
|
||||
await scope.close();
|
||||
} catch (cleanupCause) {
|
||||
if (outcome.kind === "failed")
|
||||
throw new AggregateError(
|
||||
[outcome.cause, cleanupCause],
|
||||
"JavaScript analysis and artifact cleanup both failed",
|
||||
);
|
||||
throw cleanupCause;
|
||||
}
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
return outcome.value;
|
||||
};
|
||||
@@ -62,6 +62,36 @@ export async function verifyGhidraAddressBoundaries(client, entry, cli) {
|
||||
await query("read_bytes", { address: padded, length: 1 }),
|
||||
await query("read_bytes", { address: entry, length: 1 }),
|
||||
);
|
||||
const aliasKeys = [entry, padded];
|
||||
assert.deepEqual(
|
||||
await query("set_addresses_names", {
|
||||
names: {
|
||||
[entry]: "rea_same_address_alias",
|
||||
[padded]: "rea_same_address_alias",
|
||||
},
|
||||
}),
|
||||
Object.fromEntries(aliasKeys.map((address) => [address, true])),
|
||||
"Equivalent address spellings with one requested name share one edit",
|
||||
);
|
||||
const nameBeforeAliasedBatch = await query("address_name", {
|
||||
address: entry,
|
||||
});
|
||||
assert.equal(nameBeforeAliasedBatch, "rea_same_address_alias");
|
||||
assert.deepEqual(
|
||||
await query("set_addresses_names", {
|
||||
names: {
|
||||
[entry]: "rea_first_address_alias",
|
||||
[padded]: "rea_second_address_alias",
|
||||
},
|
||||
}),
|
||||
Object.fromEntries(aliasKeys.map((address) => [address, false])),
|
||||
"Conflicting spellings of one Ghidra address must be rejected together",
|
||||
);
|
||||
assert.equal(
|
||||
await query("address_name", { address: entry }),
|
||||
nameBeforeAliasedBatch,
|
||||
"A rejected alias collision must leave the preexisting target name unchanged",
|
||||
);
|
||||
for (const [command, flags, operation] of [
|
||||
["read-bytes", ["--length", "1"], "read_bytes"],
|
||||
[
|
||||
|
||||
@@ -58,6 +58,15 @@ export async function verifyGhidraBoundaries(
|
||||
`${name}: ${JSON.stringify(validate.errors)}`,
|
||||
);
|
||||
assert.deepEqual(reply.structuredContent, JSON.parse(mcpTextValue(reply)));
|
||||
if (name === "list_strings" || name === "search_strings")
|
||||
assert.ok(
|
||||
reply.structuredContent.limitations.some((limitation) =>
|
||||
/Only Ghidra-defined string Data.*empty search does not establish/u.test(
|
||||
limitation,
|
||||
),
|
||||
),
|
||||
`${name} omitted defined-string coverage`,
|
||||
);
|
||||
successfulCalls++;
|
||||
return result;
|
||||
};
|
||||
@@ -378,6 +387,12 @@ export async function verifyGhidraBoundaries(
|
||||
).some((item) => item.address === marker.address),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await call("search_strings", {
|
||||
pattern: `REA_GHIDRA_ABSENT_${randomUUID()}`,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
assert.equal(
|
||||
(await call("inspect_native_instruction", { address: strings[0].address }))
|
||||
.status,
|
||||
|
||||
@@ -21,7 +21,6 @@ export async function verifyGhidraEntryAliases({
|
||||
new URL("../../tests/conformance/ghidra/entry-aliases.c", import.meta.url),
|
||||
);
|
||||
const path = join(workspace, "entry-aliases");
|
||||
const prefix = process.platform === "darwin" ? "_" : "";
|
||||
try {
|
||||
await exec(compiler, ["-O0", "-g", "-fno-inline", source, "-o", path], {
|
||||
env,
|
||||
@@ -29,7 +28,8 @@ export async function verifyGhidraEntryAliases({
|
||||
});
|
||||
const bytes = await readFile(path);
|
||||
await call("close_binary");
|
||||
await call("open_binary", { path, provider_id: "ghidra" });
|
||||
const imported = await call("open_binary", { path, provider_id: "ghidra" });
|
||||
const prefix = imported.format === "mach-o" ? "_" : "";
|
||||
const names = await call("list_names");
|
||||
const alias = names.find(
|
||||
({ value }) => value === `${prefix}rea_entry_alias`,
|
||||
@@ -46,8 +46,10 @@ export async function verifyGhidraEntryAliases({
|
||||
);
|
||||
assert.ok(canonical, "Fixture alias is not at a function entry");
|
||||
const xrefsSelectors = [];
|
||||
// CLI xrefs keeps bare hexadecimal selectors as addresses. A Mach-O
|
||||
// primary label such as "dead" is tested below as a procedure name instead.
|
||||
for (const value of [
|
||||
canonical.value,
|
||||
`${prefix}rea_alias_target`,
|
||||
alias.value,
|
||||
`${prefix}rea_xrefs_data`,
|
||||
`${prefix}rea_xrefs_unreferenced`,
|
||||
@@ -89,6 +91,18 @@ export async function verifyGhidraEntryAliases({
|
||||
);
|
||||
assert.ok(interior);
|
||||
assert.notEqual(interior.address, alias.address);
|
||||
assert.equal(
|
||||
interior.symbol.type,
|
||||
"label",
|
||||
"Fixture interior control must remain an imported label",
|
||||
);
|
||||
assert.equal(
|
||||
(await call("list_procedures")).some(
|
||||
({ address }) => address === interior.address,
|
||||
),
|
||||
false,
|
||||
"Fixture interior control must not be a Ghidra procedure entry",
|
||||
);
|
||||
const failure = await reject("procedure_address", {
|
||||
procedure: interior.value,
|
||||
});
|
||||
@@ -153,7 +167,11 @@ export async function verifyGhidraEntryAliases({
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const evidence = JSON.parse(stdout);
|
||||
assert.deepEqual(evidence.normalized_result, expected);
|
||||
assert.deepEqual(
|
||||
evidence.normalized_result,
|
||||
expected,
|
||||
`CLI xrefs mismatch for ${JSON.stringify(selector)}`,
|
||||
);
|
||||
assert.equal(evidence.parameters.address, selector.at(-1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,9 @@ export async function verifyGhidraLargeResults({
|
||||
assert.equal(dossier.procedure.name, "rea_frame_probe");
|
||||
assert.ok(dossier.comments.some(({ text }) => text === comment));
|
||||
assert.ok(
|
||||
dossierRecord.raw_result.comments.some(({ text }) => text === comment),
|
||||
dossierRecord.raw_result.value.comments.some(
|
||||
({ text }) => text === comment,
|
||||
),
|
||||
);
|
||||
const { stdout } = await exec(
|
||||
process.execPath,
|
||||
|
||||
@@ -54,6 +54,15 @@ export async function verifyGhidraNamespaceAnnotations({
|
||||
);
|
||||
return { namespace, address: procedure.address };
|
||||
});
|
||||
const names = await call("list_names");
|
||||
const globals = ["alpha", "outer::inner"].map((namespace) => {
|
||||
const symbol = names.find(
|
||||
(item) => item.value === `${namespace}::value` && item.symbol.primary,
|
||||
);
|
||||
assert.ok(symbol, `Missing demangled fixture global ${namespace}::value`);
|
||||
return { namespace, address: symbol.address };
|
||||
});
|
||||
await verifyDuplicateAddressNames(call, procedures, names);
|
||||
const ambiguous = await reject("procedure_address", { procedure: "same" });
|
||||
assert.equal(ambiguous.code, "invalid_request");
|
||||
for (const { address } of entries)
|
||||
@@ -125,6 +134,17 @@ export async function verifyGhidraNamespaceAnnotations({
|
||||
});
|
||||
assert.deepEqual(unchanged.annotations, qualified.annotations);
|
||||
}
|
||||
await verifyAddressNames(call, [entries[0], entries[2]], "function");
|
||||
await verifyAddressNames(call, globals, "label");
|
||||
for (const { namespace, address } of entries) {
|
||||
assert.equal(
|
||||
await call("set_address_name", {
|
||||
address,
|
||||
name: `${namespace}::qualified`,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
const { stdout } = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
@@ -159,6 +179,179 @@ export async function verifyGhidraNamespaceAnnotations({
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyDuplicateAddressNames(call, procedures, names) {
|
||||
const functionEntries = [
|
||||
"rea_duplicate_function_a",
|
||||
"rea_duplicate_function_b",
|
||||
].map((name) => {
|
||||
const procedure = procedures.find(
|
||||
({ value }) => value === name || value === `_${name}`,
|
||||
);
|
||||
assert.ok(procedure, `Missing duplicate-name fixture function ${name}`);
|
||||
return { address: procedure.address, original: procedure.value };
|
||||
});
|
||||
const labelEntries = ["rea_duplicate_label_a", "rea_duplicate_label_b"].map(
|
||||
(name) => {
|
||||
const symbol = names.find(
|
||||
(item) =>
|
||||
(item.value === name || item.value === `_${name}`) &&
|
||||
item.symbol.primary,
|
||||
);
|
||||
assert.ok(symbol, `Missing duplicate-name fixture label ${name}`);
|
||||
return { address: symbol.address, original: symbol.value };
|
||||
},
|
||||
);
|
||||
|
||||
for (const [kind, entries] of [
|
||||
["function", functionEntries],
|
||||
["label", labelEntries],
|
||||
]) {
|
||||
const [owner, duplicate] = entries;
|
||||
const name = `rea_duplicate_${kind}`;
|
||||
assert.equal(
|
||||
await call("set_address_name", { address: owner.address, name }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await call("set_address_name", { address: owner.address, name }),
|
||||
true,
|
||||
"Renaming the current owner to its existing name remains idempotent",
|
||||
);
|
||||
assert.equal(
|
||||
await call("set_address_name", { address: duplicate.address, name }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await call("address_name", { address: duplicate.address }),
|
||||
duplicate.original,
|
||||
"A rejected duplicate leaves the other address unchanged",
|
||||
);
|
||||
|
||||
const batchName = `rea_batch_duplicate_${kind}`;
|
||||
assert.deepEqual(
|
||||
await call("set_addresses_names", {
|
||||
names: {
|
||||
[owner.address]: batchName,
|
||||
[duplicate.address]: batchName,
|
||||
},
|
||||
}),
|
||||
{ [owner.address]: true, [duplicate.address]: false },
|
||||
);
|
||||
assert.equal(
|
||||
await call("address_name", { address: owner.address }),
|
||||
batchName,
|
||||
);
|
||||
assert.equal(
|
||||
await call("address_name", { address: duplicate.address }),
|
||||
duplicate.original,
|
||||
"A failed batch member rolls back only its own address",
|
||||
);
|
||||
const owners = (await call("list_names")).filter(
|
||||
(item) => item.value === batchName && item.symbol.primary,
|
||||
);
|
||||
assert.deepEqual(
|
||||
owners.map(({ address }) => address),
|
||||
[owner.address],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAddressNames(call, symbols, kind) {
|
||||
for (const { namespace, address } of symbols) {
|
||||
assert.equal(
|
||||
await call("set_address_name", {
|
||||
address,
|
||||
name: `address_renamed_${kind}`,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const qualified = `${namespace}::address_qualified_${kind}`;
|
||||
assert.equal(
|
||||
await call("set_address_name", { address, name: qualified }),
|
||||
true,
|
||||
);
|
||||
assert.equal(await call("address_name", { address }), qualified);
|
||||
assert.equal(
|
||||
await call("set_address_name", {
|
||||
address,
|
||||
name: `other::literal_${kind}`,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const literal = await call("address_name", { address });
|
||||
assert.equal(literal, `${namespace}::other::literal_${kind}`);
|
||||
assert.equal(
|
||||
await call("set_address_name", { address, name: literal }),
|
||||
true,
|
||||
);
|
||||
assert.equal(await call("address_name", { address }), literal);
|
||||
assert.equal(
|
||||
await call("set_address_name", { address, name: qualified }),
|
||||
true,
|
||||
);
|
||||
for (let repeat = 0; repeat < 2; repeat++) {
|
||||
const readback = await call("address_name", { address });
|
||||
assert.deepEqual(
|
||||
await call("set_addresses_names", { names: { [address]: readback } }),
|
||||
{ [address]: true },
|
||||
);
|
||||
assert.equal(await call("address_name", { address }), qualified);
|
||||
}
|
||||
assert.equal(
|
||||
await call("set_address_name", { address, name: `${namespace}::` }),
|
||||
false,
|
||||
);
|
||||
const session = await call("binary_session");
|
||||
for (const operation of [
|
||||
"set_address_name",
|
||||
"set_addresses_names",
|
||||
"address_name",
|
||||
"list_names",
|
||||
])
|
||||
assert.equal(
|
||||
session.capabilities.find((item) => item.operation === operation)
|
||||
?.available,
|
||||
true,
|
||||
`An invalid name must not mark ${operation} unavailable`,
|
||||
);
|
||||
assert.equal(await call("address_name", { address }), qualified);
|
||||
}
|
||||
const [valid, invalid] = symbols;
|
||||
assert.deepEqual(
|
||||
await call("set_addresses_names", {
|
||||
names: {
|
||||
[valid.address]: `${valid.namespace}::batch_renamed_${kind}`,
|
||||
[invalid.address]: `${invalid.namespace}::`,
|
||||
},
|
||||
}),
|
||||
{ [valid.address]: true, [invalid.address]: false },
|
||||
);
|
||||
assert.equal(
|
||||
await call("address_name", { address: valid.address }),
|
||||
`${valid.namespace}::batch_renamed_${kind}`,
|
||||
);
|
||||
assert.equal(
|
||||
await call("address_name", { address: invalid.address }),
|
||||
`${invalid.namespace}::address_qualified_${kind}`,
|
||||
);
|
||||
const names = await call("list_names");
|
||||
for (const { namespace, address } of symbols) {
|
||||
const expected =
|
||||
address === valid.address
|
||||
? `${namespace}::batch_renamed_${kind}`
|
||||
: `${namespace}::address_qualified_${kind}`;
|
||||
assert.ok(
|
||||
names.some(
|
||||
(item) =>
|
||||
item.address === address &&
|
||||
item.value === expected &&
|
||||
item.symbol.primary,
|
||||
),
|
||||
`Missing refreshed primary symbol ${expected}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function requireNamespaceDemangler(env) {
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
releaseAuthorSchema as author,
|
||||
releaseIssueSchema as issue,
|
||||
releasePullRequestSchema,
|
||||
} from "./release-notes.mjs";
|
||||
|
||||
const pageInfo = z.object({
|
||||
hasNextPage: z.boolean(),
|
||||
endCursor: z.string().nullable(),
|
||||
});
|
||||
const connection = (node) => z.object({ nodes: z.array(node), pageInfo });
|
||||
const associated = z.object({
|
||||
number: z.number().int().positive(),
|
||||
mergeCommit: z.object({ oid: z.string() }).nullable(),
|
||||
});
|
||||
const pr = releasePullRequestSchema.omit({ issues: true }).extend({
|
||||
closingIssuesReferences: connection(issue),
|
||||
});
|
||||
const PAGE = "pageInfo { hasNextPage endCursor }";
|
||||
const ACTOR = "login __typename";
|
||||
const AUTHORS = `authors(first:100, after:$cursor) { nodes { name email user { ${ACTOR} } } ${PAGE} }`;
|
||||
const ASSOCIATED = `associatedPullRequests(first:100, after:$cursor) { nodes { number mergeCommit { oid } } ${PAGE} }`;
|
||||
const ISSUES = `closingIssuesReferences(first:100, after:$cursor) { nodes { number url author { ${ACTOR} } repository { nameWithOwner } } ${PAGE} }`;
|
||||
|
||||
async function query(run, repository, selection, cursor = null) {
|
||||
const [owner, name] = repository.split("/");
|
||||
const response = JSON.parse(
|
||||
await run(
|
||||
"gh",
|
||||
["api", "graphql", "--input", "-"],
|
||||
JSON.stringify({
|
||||
query: `query($owner:String!,$name:String!,$cursor:String) { repository(owner:$owner,name:$name) { ${selection} } }`,
|
||||
variables: { owner, name, cursor },
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (response.errors?.length)
|
||||
throw new Error(
|
||||
`GitHub inventory query failed: ${JSON.stringify(response.errors)}`,
|
||||
);
|
||||
if (!response.data?.repository)
|
||||
throw new Error(`GitHub repository unavailable: ${repository}`);
|
||||
return response.data.repository;
|
||||
}
|
||||
|
||||
async function remainingPages(run, repository, selection, field) {
|
||||
const initial = field.initial;
|
||||
const nodes = [...initial.nodes];
|
||||
let page = initial;
|
||||
const cursors = new Set();
|
||||
while (page.pageInfo.hasNextPage) {
|
||||
const cursor = page.pageInfo.endCursor;
|
||||
if (!cursor || cursors.has(cursor))
|
||||
throw new Error(`GitHub returned a non-advancing ${field.name} cursor`);
|
||||
cursors.add(cursor);
|
||||
const response = await query(run, repository, selection, cursor);
|
||||
page = field.schema.parse(response.item?.[field.name]);
|
||||
nodes.push(...page.nodes);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** GitHub's commit authors include co-authors; unresolved identities remain explicit. */
|
||||
export async function collectGitHubCredits(run, repository, commits) {
|
||||
const shipped = new Set(commits.map((commit) => commit.sha));
|
||||
const numbers = new Set();
|
||||
const result = [];
|
||||
// Small query batches limit transient GraphQL work, never the history examined.
|
||||
for (let offset = 0; offset < commits.length; offset += 20) {
|
||||
const batch = commits.slice(offset, offset + 20);
|
||||
const selection = batch
|
||||
.map(
|
||||
(commit, index) =>
|
||||
`c${index}: object(oid:${JSON.stringify(commit.sha)}) { ... on Commit { oid ${AUTHORS} ${ASSOCIATED} } }`,
|
||||
)
|
||||
.join("\n");
|
||||
const response = await query(run, repository, selection);
|
||||
for (const [index, commit] of batch.entries()) {
|
||||
const value = z
|
||||
.object({
|
||||
oid: z.literal(commit.sha),
|
||||
authors: connection(author),
|
||||
associatedPullRequests: connection(associated),
|
||||
})
|
||||
.parse(response[`c${index}`]);
|
||||
const select = (fields) =>
|
||||
`item: object(oid:${JSON.stringify(commit.sha)}) { ... on Commit { ${fields} } }`;
|
||||
const authors = await remainingPages(run, repository, select(AUTHORS), {
|
||||
name: "authors",
|
||||
schema: connection(author),
|
||||
initial: value.authors,
|
||||
});
|
||||
const associations = await remainingPages(
|
||||
run,
|
||||
repository,
|
||||
select(ASSOCIATED),
|
||||
{
|
||||
name: "associatedPullRequests",
|
||||
schema: connection(associated),
|
||||
initial: value.associatedPullRequests,
|
||||
},
|
||||
);
|
||||
const pullRequests = [
|
||||
...new Set(
|
||||
associations
|
||||
.filter((item) => shipped.has(item.mergeCommit?.oid))
|
||||
.map((item) => item.number),
|
||||
),
|
||||
];
|
||||
for (const number of pullRequests) numbers.add(number);
|
||||
result.push({ ...commit, authors, pullRequests });
|
||||
}
|
||||
}
|
||||
const pullRequests = [];
|
||||
const sorted = [...numbers].sort((a, b) => a - b);
|
||||
for (let offset = 0; offset < sorted.length; offset += 20) {
|
||||
const batch = sorted.slice(offset, offset + 20);
|
||||
const fields = `number title url author { ${ACTOR} } mergeCommit { oid } ${ISSUES}`;
|
||||
const response = await query(
|
||||
run,
|
||||
repository,
|
||||
batch
|
||||
.map(
|
||||
(number, index) =>
|
||||
`p${index}: pullRequest(number:${number}) { ${fields} }`,
|
||||
)
|
||||
.join("\n"),
|
||||
);
|
||||
for (const [index, number] of batch.entries()) {
|
||||
const value = pr.parse(response[`p${index}`]);
|
||||
if (value.number !== number || !shipped.has(value.mergeCommit.oid))
|
||||
throw new Error(`PR #${number} changed outside the selected history`);
|
||||
const issues = await remainingPages(
|
||||
run,
|
||||
repository,
|
||||
`item: pullRequest(number:${number}) { ${ISSUES} }`,
|
||||
{
|
||||
name: "closingIssuesReferences",
|
||||
schema: connection(issue),
|
||||
initial: value.closingIssuesReferences,
|
||||
},
|
||||
);
|
||||
const { closingIssuesReferences: _connection, ...metadata } = value;
|
||||
pullRequests.push({ ...metadata, issues });
|
||||
}
|
||||
}
|
||||
return { commits: result, pullRequests };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { valid } from "semver";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
releaseInventorySchema,
|
||||
verifyInventoryHistory,
|
||||
verifyReleaseNotes,
|
||||
} from "./release-notes.mjs";
|
||||
|
||||
const sha = z.string().regex(/^[a-f0-9]{40}$/u);
|
||||
const repository = z.string().regex(/^[\w.-]+\/[\w.-]+$/u);
|
||||
const pullRequest = z.object({
|
||||
number: z.number().int().positive(),
|
||||
body: z.string(),
|
||||
merged_at: z.string().nullable(),
|
||||
merge_commit_sha: sha.nullable(),
|
||||
head: z.object({
|
||||
ref: z.string(),
|
||||
repo: z.object({ full_name: z.string() }).nullable(),
|
||||
}),
|
||||
labels: z.array(z.object({ name: z.string() })),
|
||||
});
|
||||
|
||||
/** Read the reviewed Git snapshot and live PR before Release Please can mutate tags. */
|
||||
export async function verifyNotesPublication(run, options) {
|
||||
const repo = repository.parse(options.repository);
|
||||
const source = sha.parse(
|
||||
(
|
||||
await run("git", [
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
"--end-of-options",
|
||||
`${options.source}^{commit}`,
|
||||
])
|
||||
).trim(),
|
||||
);
|
||||
const read = (path) => run("git", ["show", `${source}:${path}`]);
|
||||
const { version } = z
|
||||
.object({ version: z.string() })
|
||||
.parse(JSON.parse(await read("package.json")));
|
||||
if (valid(version) !== version || version.includes("+"))
|
||||
throw new Error("Invalid reviewed package version");
|
||||
let candidates;
|
||||
if (options.pr !== undefined) {
|
||||
const number = z.coerce.number().int().positive().parse(options.pr);
|
||||
candidates = [
|
||||
pullRequest.parse(
|
||||
JSON.parse(await run("gh", ["api", `repos/${repo}/pulls/${number}`])),
|
||||
),
|
||||
];
|
||||
} else {
|
||||
candidates = z
|
||||
.array(z.array(pullRequest))
|
||||
.parse(
|
||||
JSON.parse(
|
||||
await run("gh", [
|
||||
"api",
|
||||
"--paginate",
|
||||
"--slurp",
|
||||
`repos/${repo}/commits/${source}/pulls?per_page=100`,
|
||||
]),
|
||||
),
|
||||
)
|
||||
.flat();
|
||||
}
|
||||
const matching = candidates.filter(
|
||||
(pr) =>
|
||||
pr.merged_at !== null &&
|
||||
pr.merge_commit_sha === source &&
|
||||
pr.head.repo?.full_name.toLowerCase() === repo.toLowerCase() &&
|
||||
pr.head.ref.startsWith("release-please--branches--"),
|
||||
);
|
||||
if (matching.length !== 1)
|
||||
throw new Error(
|
||||
"Expected exactly one merged Release Please PR for the reviewed source",
|
||||
);
|
||||
const pr = matching[0];
|
||||
const path = `docs/releases/${version}.contributions.json`;
|
||||
const present =
|
||||
(
|
||||
await run("git", ["ls-tree", "--name-only", source, "--", path])
|
||||
).trim() === path;
|
||||
if (!present) {
|
||||
if (pr.labels.some((label) => label.name === "rea:release-notes-finalized"))
|
||||
throw new Error(
|
||||
"Finalized release PR is missing its contribution inventory",
|
||||
);
|
||||
return { status: "legacy", version, pullRequest: pr.number };
|
||||
}
|
||||
const inventory = releaseInventorySchema.parse(JSON.parse(await read(path)));
|
||||
if (
|
||||
inventory.version !== version ||
|
||||
inventory.repository.toLowerCase() !== repo.toLowerCase()
|
||||
)
|
||||
throw new Error(
|
||||
"Contribution inventory differs from the reviewed release version or repository",
|
||||
);
|
||||
const baseline = z
|
||||
.object({ ".": z.string() })
|
||||
.parse(
|
||||
JSON.parse(
|
||||
await run("git", ["show", `${source}^1:.release-please-manifest.json`]),
|
||||
),
|
||||
)["."];
|
||||
if (valid(baseline) !== baseline || baseline.includes("+"))
|
||||
throw new Error("Invalid release baseline version");
|
||||
const baselineSha = (
|
||||
await run("git", ["rev-parse", `refs/tags/rea-agents-${baseline}^{commit}`])
|
||||
).trim();
|
||||
if (inventory.base !== baselineSha)
|
||||
throw new Error(
|
||||
"Contribution inventory does not start at the published baseline tag",
|
||||
);
|
||||
await verifyInventoryHistory(run, inventory, source);
|
||||
return {
|
||||
status: "verified",
|
||||
...verifyReleaseNotes(inventory, await read("CHANGELOG.md"), pr.body),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { valid } from "semver";
|
||||
import { z } from "zod";
|
||||
|
||||
const sha = z.string().regex(/^[a-f0-9]{40}$/u);
|
||||
const actor = z.object({
|
||||
login: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9-]*(?:\[bot\])?$/u),
|
||||
__typename: z.string(),
|
||||
});
|
||||
const author = z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
user: actor.nullable(),
|
||||
});
|
||||
const commit = z.object({
|
||||
sha,
|
||||
subject: z.string(),
|
||||
body: z.string(),
|
||||
parents: z.array(sha),
|
||||
authors: z.array(author),
|
||||
pullRequests: z.array(z.number().int().positive()),
|
||||
});
|
||||
const issue = z.object({
|
||||
number: z.number().int().positive(),
|
||||
url: z.url(),
|
||||
author: actor.nullable(),
|
||||
repository: z.object({ nameWithOwner: z.string() }),
|
||||
});
|
||||
const pullRequest = z.object({
|
||||
number: z.number().int().positive(),
|
||||
title: z.string(),
|
||||
url: z.url(),
|
||||
author: actor.nullable(),
|
||||
mergeCommit: z.object({ oid: sha }),
|
||||
issues: z.array(issue),
|
||||
});
|
||||
export {
|
||||
author as releaseAuthorSchema,
|
||||
issue as releaseIssueSchema,
|
||||
pullRequest as releasePullRequestSchema,
|
||||
};
|
||||
export const releaseInventorySchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
repository: z.string().regex(/^[\w.-]+\/[\w.-]+$/u),
|
||||
version: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => valid(value) === value && !value.includes("+"),
|
||||
"Expected exact release SemVer",
|
||||
),
|
||||
base: sha,
|
||||
target: sha,
|
||||
commits: z.array(commit),
|
||||
pullRequests: z.array(pullRequest),
|
||||
});
|
||||
|
||||
export async function readReleaseHistory(run, base, target) {
|
||||
if (
|
||||
(await run("git", ["rev-parse", "--is-shallow-repository"])).trim() !==
|
||||
"false"
|
||||
)
|
||||
throw new Error(
|
||||
"Release inventory requires complete Git history; fetch --unshallow first",
|
||||
);
|
||||
const resolve = async (ref) =>
|
||||
sha.parse(
|
||||
(
|
||||
await run("git", [
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
"--end-of-options",
|
||||
`${ref}^{commit}`,
|
||||
])
|
||||
).trim(),
|
||||
);
|
||||
const baseSha = await resolve(base);
|
||||
const targetSha = await resolve(target);
|
||||
await run("git", ["merge-base", "--is-ancestor", baseSha, targetSha]);
|
||||
const text = await run("git", [
|
||||
"log",
|
||||
"--reverse",
|
||||
"--topo-order",
|
||||
"-z",
|
||||
"--format=%H%x00%s%x00%B%x00%P",
|
||||
`${baseSha}..${targetSha}`,
|
||||
]);
|
||||
const fields = text.split("\0");
|
||||
if (fields.pop() !== "" || fields.length % 4)
|
||||
throw new Error("Malformed Git release history");
|
||||
const commits = [];
|
||||
for (let index = 0; index < fields.length; index += 4) {
|
||||
commits.push({
|
||||
sha: sha.parse(fields[index]),
|
||||
subject: fields[index + 1],
|
||||
body: fields[index + 2],
|
||||
parents: fields[index + 3].split(" ").filter(Boolean),
|
||||
});
|
||||
}
|
||||
return { base: baseSha, target: targetSha, commits };
|
||||
}
|
||||
|
||||
// REA history maps Anthropic and Cursor's agent trailers to ordinary User actors.
|
||||
// Keep those source identities in the inventory while excluding automation credit.
|
||||
const AGENT_ACCOUNTS = new Set(["claude", "cursoragent"]);
|
||||
const human = (value) =>
|
||||
value?.__typename === "User" &&
|
||||
!value.login.endsWith("[bot]") &&
|
||||
!AGENT_ACCOUNTS.has(value.login.toLowerCase())
|
||||
? value.login
|
||||
: undefined;
|
||||
const unique = (values) =>
|
||||
[...new Set(values.filter(Boolean))].sort((a, b) =>
|
||||
a.toLowerCase().localeCompare(b.toLowerCase()),
|
||||
);
|
||||
|
||||
function prCredits(inventory, pr) {
|
||||
return unique([
|
||||
human(pr.author),
|
||||
...pr.issues.map((item) => human(item.author)),
|
||||
...inventory.commits
|
||||
.filter(
|
||||
(item) =>
|
||||
item.parents.length === 1 && item.pullRequests.includes(pr.number),
|
||||
)
|
||||
.flatMap((item) => item.authors.map((identity) => human(identity.user))),
|
||||
]);
|
||||
}
|
||||
|
||||
function thanks(handles) {
|
||||
return handles.length
|
||||
? ` Thanks ${handles.map((handle) => `@${handle}`).join(", ")}.`
|
||||
: "";
|
||||
}
|
||||
|
||||
export function contributionRecord(raw) {
|
||||
const inventory = releaseInventorySchema.parse(raw);
|
||||
const lines = [
|
||||
"### Complete contribution record",
|
||||
"",
|
||||
`History: \`${inventory.base}..${inventory.target}\`; ${inventory.pullRequests.length} merged PRs, ${inventory.commits.length} commits examined.`,
|
||||
"",
|
||||
];
|
||||
for (const pr of inventory.pullRequests) {
|
||||
const issues = pr.issues
|
||||
.map(
|
||||
(item) =>
|
||||
`[${item.repository.nameWithOwner}#${item.number}](${item.url})`,
|
||||
)
|
||||
.join(", ");
|
||||
lines.push(
|
||||
`- **PR [#${pr.number}](${pr.url})**${issues ? ` Related ${issues}.` : ""}${thanks(prCredits(inventory, pr))}`,
|
||||
);
|
||||
}
|
||||
const direct = inventory.commits.filter(
|
||||
(item) => item.pullRequests.length === 0 && item.parents.length <= 1,
|
||||
);
|
||||
if (direct.length) {
|
||||
lines.push("", "#### Direct contributions", "");
|
||||
for (const item of direct)
|
||||
lines.push(
|
||||
`- [${item.sha.slice(0, 7)}](https://github.com/${inventory.repository}/commit/${item.sha})${thanks(unique(item.authors.map((identity) => human(identity.user))))}`,
|
||||
);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function selectedReleaseNotes(text, version) {
|
||||
const headings = [...text.matchAll(/^## \[([^\]]+)\].*$/gmu)];
|
||||
if (headings[0]?.[1] !== version)
|
||||
throw new Error(`Notes must start with release [${version}]`);
|
||||
return text
|
||||
.slice(headings[0].index, headings[1]?.index ?? text.length)
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Notes have one Markdown list item per line; fenced examples are never credit sources.
|
||||
function proseBullets(text) {
|
||||
let fence;
|
||||
return text.split("\n").filter((line) => {
|
||||
const marker = /^\s*(`{3,}|~{3,})/u.exec(line)?.[1];
|
||||
if (marker) {
|
||||
if (!fence) fence = marker;
|
||||
else if (marker[0] === fence[0] && marker.length >= fence.length)
|
||||
fence = undefined;
|
||||
return false;
|
||||
}
|
||||
return !fence && /^[-*] /u.test(line);
|
||||
});
|
||||
}
|
||||
|
||||
function bulletPrs(bullet, repository) {
|
||||
// Resolve explicit links first so cross-repository numbers cannot acquire local credit.
|
||||
const local = [];
|
||||
const withoutLinks = bullet.replace(
|
||||
/\[([^\]]*)\]\(https:\/\/github\.com\/([^/]+\/[^/]+)\/(?:pull|issues)\/(\d+)\)/gu,
|
||||
(_match, _label, repo, number) => {
|
||||
if (repo.toLowerCase() === repository.toLowerCase())
|
||||
local.push(Number(number));
|
||||
return "";
|
||||
},
|
||||
);
|
||||
for (const match of withoutLinks.matchAll(/(?<![\w/])#(\d+)\b/gu))
|
||||
local.push(Number(match[1]));
|
||||
return new Set(local);
|
||||
}
|
||||
|
||||
function requiredCredits(inventory, bullet, references) {
|
||||
const required = [];
|
||||
for (const pr of inventory.pullRequests.filter((item) =>
|
||||
references.has(item.number),
|
||||
)) {
|
||||
for (const handle of prCredits(inventory, pr))
|
||||
required.push({ handle, context: `PR #${pr.number}` });
|
||||
}
|
||||
for (const issue of inventory.pullRequests.flatMap((item) => item.issues)) {
|
||||
const mentioned =
|
||||
(issue.repository.nameWithOwner.toLowerCase() ===
|
||||
inventory.repository.toLowerCase() &&
|
||||
references.has(issue.number)) ||
|
||||
bullet.includes(`](${issue.url})`);
|
||||
const handle = human(issue.author);
|
||||
if (mentioned && handle)
|
||||
required.push({
|
||||
handle,
|
||||
context: `issue ${issue.repository.nameWithOwner}#${issue.number}`,
|
||||
});
|
||||
}
|
||||
for (const match of bullet.matchAll(
|
||||
/https:\/\/github\.com\/([^/]+\/[^/]+)\/commit\/([a-f0-9]{7,40})\b/gu,
|
||||
)) {
|
||||
if (match[1].toLowerCase() !== inventory.repository.toLowerCase()) continue;
|
||||
const matches = inventory.commits.filter((item) =>
|
||||
item.sha.startsWith(match[2]),
|
||||
);
|
||||
if (matches.length !== 1)
|
||||
throw new Error(`Unknown or ambiguous release commit ${match[2]}`);
|
||||
if (matches[0].parents.length > 1) continue;
|
||||
for (const identity of matches[0].authors) {
|
||||
const handle = human(identity.user);
|
||||
if (handle) required.push({ handle, context: `commit ${match[2]}` });
|
||||
}
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
function verifyEditorialCredits(inventory, prose) {
|
||||
const known = new Set(inventory.pullRequests.map((pr) => pr.number));
|
||||
const issues = new Set(
|
||||
inventory.pullRequests.flatMap((pr) =>
|
||||
pr.issues
|
||||
.filter(
|
||||
(item) =>
|
||||
item.repository.nameWithOwner.toLowerCase() ===
|
||||
inventory.repository.toLowerCase(),
|
||||
)
|
||||
.map((item) => item.number),
|
||||
),
|
||||
);
|
||||
const eligible = new Set(
|
||||
unique([
|
||||
...inventory.pullRequests.flatMap((pr) => prCredits(inventory, pr)),
|
||||
...inventory.commits.flatMap((item) =>
|
||||
item.authors.map((identity) => human(identity.user)),
|
||||
),
|
||||
]).map((handle) => handle.toLowerCase()),
|
||||
);
|
||||
const errors = [];
|
||||
for (const bullet of proseBullets(prose)) {
|
||||
const references = bulletPrs(bullet, inventory.repository);
|
||||
const start = bullet.indexOf("Thanks ");
|
||||
const thanked = new Set(
|
||||
[
|
||||
...bullet
|
||||
.slice(start < 0 ? bullet.length : start)
|
||||
.matchAll(/@([A-Za-z0-9-]+(?:\[bot\])?)/gu),
|
||||
].map((match) => match[1].toLowerCase()),
|
||||
);
|
||||
for (const number of references)
|
||||
if (!known.has(number) && !issues.has(number))
|
||||
errors.push(`Unknown release reference #${number}`);
|
||||
for (const { handle, context } of requiredCredits(
|
||||
inventory,
|
||||
bullet,
|
||||
references,
|
||||
)) {
|
||||
if (!thanked.has(handle.toLowerCase()))
|
||||
errors.push(`Missing Thanks @${handle} in bullet for ${context}`);
|
||||
}
|
||||
for (const handle of thanked)
|
||||
if (!eligible.has(handle))
|
||||
errors.push(`Unverified human credit @${handle}`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function verifyReleaseNotes(raw, text, body) {
|
||||
const inventory = releaseInventorySchema.parse(raw);
|
||||
const notes = selectedReleaseNotes(text, inventory.version);
|
||||
// Release Please's inline issue/PR body limit (pull-request-overflow-handler).
|
||||
if (notes.length > 65_536 || (body !== undefined && body.length > 65_536))
|
||||
throw new Error(
|
||||
"Release notes exceed GitHub's inline PR body limit; overflow-link bodies are not supported by this checker",
|
||||
);
|
||||
if (notes.length > 125_000 || Buffer.byteLength(notes) > 125_000)
|
||||
throw new Error(
|
||||
"Release notes exceed GitHub's body budget; shorten prose without dropping credit",
|
||||
);
|
||||
const record = contributionRecord(inventory).trim();
|
||||
const start = notes.indexOf("### Complete contribution record");
|
||||
if (start < 0 || notes.slice(start).trim() !== record)
|
||||
throw new Error(
|
||||
"Contribution record differs from inventory; regenerate it with release-notes record",
|
||||
);
|
||||
const prose = notes.slice(0, start);
|
||||
const errors = verifyEditorialCredits(inventory, prose);
|
||||
for (const heading of ["Highlights", "Changes", "Fixes"])
|
||||
if (!prose.includes(`### ${heading}\n`))
|
||||
errors.push(`Missing ${heading} section`);
|
||||
if (body !== undefined) {
|
||||
const bodyNotes = selectedReleaseNotes(body, inventory.version)
|
||||
.replace(
|
||||
/\n---\nThis PR was generated with \[Release Please\][\s\S]*$/u,
|
||||
"",
|
||||
)
|
||||
.trim();
|
||||
if (bodyNotes !== notes)
|
||||
errors.push("Release PR body differs from CHANGELOG.md");
|
||||
}
|
||||
if (errors.length) throw new Error(errors.join("\n"));
|
||||
return {
|
||||
version: inventory.version,
|
||||
pullRequests: inventory.pullRequests.length,
|
||||
commits: inventory.commits.length,
|
||||
unresolvedAuthors: inventory.commits.flatMap((item) =>
|
||||
item.authors
|
||||
.filter((identity) => identity.user === null)
|
||||
.map((identity) => ({ sha: item.sha, ...identity })),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reject stale inventories and product changes made after the editorial target. */
|
||||
export async function verifyInventoryHistory(run, raw, source) {
|
||||
const inventory = releaseInventorySchema.parse(raw);
|
||||
const history = await readReleaseHistory(
|
||||
run,
|
||||
inventory.base,
|
||||
inventory.target,
|
||||
);
|
||||
const original = inventory.commits.map(
|
||||
({ authors: _authors, pullRequests: _prs, ...item }) => item,
|
||||
);
|
||||
if (JSON.stringify(history.commits) !== JSON.stringify(original))
|
||||
throw new Error("Inventory does not match complete local Git history");
|
||||
const shas = new Set(history.commits.map((item) => item.sha));
|
||||
const numbers = new Set();
|
||||
for (const pr of inventory.pullRequests) {
|
||||
if (numbers.has(pr.number) || !shas.has(pr.mergeCommit.oid))
|
||||
throw new Error(`Duplicate or out-of-range PR #${pr.number}`);
|
||||
numbers.add(pr.number);
|
||||
}
|
||||
for (const item of inventory.commits)
|
||||
for (const number of item.pullRequests)
|
||||
if (!numbers.has(number))
|
||||
throw new Error(`Commit refers to missing PR #${number}`);
|
||||
if (source !== undefined) {
|
||||
const resolved = sha.parse(
|
||||
(
|
||||
await run("git", [
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
"--end-of-options",
|
||||
`${source}^{commit}`,
|
||||
])
|
||||
).trim(),
|
||||
);
|
||||
const parent = (await run("git", ["rev-parse", `${resolved}^1`])).trim();
|
||||
if (parent !== inventory.target)
|
||||
throw new Error(
|
||||
"Release base moved after notes target; regenerate inventory for the reviewed merge's first parent",
|
||||
);
|
||||
await run("git", [
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
inventory.target,
|
||||
resolved,
|
||||
]);
|
||||
const allowed = new Set([
|
||||
"CHANGELOG.md",
|
||||
".release-please-manifest.json",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"server.json",
|
||||
"src/generatedPackageMetadata.ts",
|
||||
"docs/installation.md",
|
||||
`docs/releases/${inventory.version}.contributions.json`,
|
||||
]);
|
||||
const paths = (
|
||||
await run("git", [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
inventory.target,
|
||||
resolved,
|
||||
])
|
||||
)
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const product = paths.filter((path) => !allowed.has(path));
|
||||
if (product.length)
|
||||
throw new Error(
|
||||
`Product changed after notes target; regenerate inventory: ${product.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { access } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Route production MCP before importing Incur. Incur owns registration helpers
|
||||
@@ -15,6 +15,21 @@ const isMcpMode =
|
||||
args.length === 1 && (args[0] === "--mcp" || args[0] === "mcp");
|
||||
const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor";
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
// Playwright's registry assumes one of its three desktop host platforms when
|
||||
// it chooses the browser cache directory. Termux reports `android`, although
|
||||
// its Node runtime and filesystem are POSIX-compatible. Keep the cache path
|
||||
// explicit so REA can start on Termux. Browser launch still requires a
|
||||
// caller-supplied Android-compatible Chromium executable.
|
||||
if (
|
||||
process.platform === "android" &&
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH === undefined
|
||||
) {
|
||||
const cacheRoot =
|
||||
process.env.XDG_CACHE_HOME ?? join(process.env.HOME ?? ".", ".cache");
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright");
|
||||
}
|
||||
|
||||
const runtimeFiles = isMcpMode
|
||||
? ["dist/main.js"]
|
||||
: isMcpDoctorMode
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { parseArgs, promisify } from "node:util";
|
||||
import { collectGitHubCredits } from "./lib/release-notes-github.mjs";
|
||||
import { verifyNotesPublication } from "./lib/release-notes-publication.mjs";
|
||||
import {
|
||||
contributionRecord,
|
||||
readReleaseHistory,
|
||||
releaseInventorySchema,
|
||||
selectedReleaseNotes,
|
||||
verifyInventoryHistory,
|
||||
verifyReleaseNotes,
|
||||
} from "./lib/release-notes.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
async function run(command, args, input) {
|
||||
const pending = execFileAsync(command, args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
pending.child.stdin.end(input);
|
||||
return (await pending).stdout;
|
||||
}
|
||||
|
||||
async function createInventory(values) {
|
||||
if (!values.repository || !values.base || !values.target || !values.version)
|
||||
throw new Error(
|
||||
"inventory requires --repository, --base, --target and --version",
|
||||
);
|
||||
// Parse caller-selected boundaries before making network requests.
|
||||
const history = await readReleaseHistory(run, values.base, values.target);
|
||||
releaseInventorySchema.omit({ commits: true, pullRequests: true }).parse({
|
||||
schemaVersion: 1,
|
||||
repository: values.repository,
|
||||
version: values.version,
|
||||
base: history.base,
|
||||
target: history.target,
|
||||
});
|
||||
const credits = await collectGitHubCredits(
|
||||
run,
|
||||
values.repository,
|
||||
history.commits,
|
||||
);
|
||||
const inventory = releaseInventorySchema.parse({
|
||||
schemaVersion: 1,
|
||||
repository: values.repository,
|
||||
version: values.version,
|
||||
base: history.base,
|
||||
target: history.target,
|
||||
...credits,
|
||||
});
|
||||
await verifyInventoryHistory(run, inventory);
|
||||
await writeFile(values.manifest, `${JSON.stringify(inventory, null, 2)}\n`, {
|
||||
flag: "wx",
|
||||
});
|
||||
process.stdout.write(
|
||||
`Inventoried ${inventory.commits.length} commits and ${inventory.pullRequests.length} merged PRs. Saved ${values.manifest}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { values, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
repository: { type: "string" },
|
||||
pr: { type: "string" },
|
||||
base: { type: "string" },
|
||||
target: { type: "string" },
|
||||
version: { type: "string" },
|
||||
manifest: { type: "string" },
|
||||
notes: { type: "string", default: "CHANGELOG.md" },
|
||||
"pr-body": { type: "string" },
|
||||
source: { type: "string" },
|
||||
output: { type: "string" },
|
||||
},
|
||||
});
|
||||
const [command] = positionals;
|
||||
if (command === "publication" && positionals.length === 1) {
|
||||
if (!values.repository || !values.source)
|
||||
throw new Error(
|
||||
"publication requires --repository and --source; --pr selects the merged release PR",
|
||||
);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(await verifyNotesPublication(run, values), null, 2)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
positionals.length !== 1 ||
|
||||
!["inventory", "record", "check", "render"].includes(command) ||
|
||||
!values.manifest
|
||||
)
|
||||
throw new Error(
|
||||
"Usage: release-notes inventory|record|check|render --manifest FILE [--repository OWNER/REPO --base TAG --target SHA --version VERSION] [--notes FILE --pr-body FILE --source SHA --output FILE]",
|
||||
);
|
||||
if (command === "inventory") {
|
||||
await createInventory(values);
|
||||
return;
|
||||
}
|
||||
const inventory = releaseInventorySchema.parse(
|
||||
JSON.parse(await readFile(values.manifest, "utf8")),
|
||||
);
|
||||
await verifyInventoryHistory(run, inventory, values.source);
|
||||
if (command === "record") {
|
||||
if (values.output)
|
||||
await writeFile(values.output, contributionRecord(inventory), {
|
||||
flag: "wx",
|
||||
});
|
||||
else process.stdout.write(contributionRecord(inventory));
|
||||
return;
|
||||
}
|
||||
const notes = await readFile(values.notes, "utf8");
|
||||
const body = values["pr-body"]
|
||||
? await readFile(values["pr-body"], "utf8")
|
||||
: undefined;
|
||||
const report = verifyReleaseNotes(inventory, notes, body);
|
||||
if (command === "check") {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
if (!values.output)
|
||||
throw new Error(
|
||||
"render requires --output (an existing file is never overwritten)",
|
||||
);
|
||||
const rendered = selectedReleaseNotes(notes, inventory.version);
|
||||
await writeFile(values.output, `${rendered}\n`, { flag: "wx" });
|
||||
}
|
||||
|
||||
await main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`Release notes failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -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"',
|
||||
...approvedTools.flatMap((name) => [
|
||||
"-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)}`,
|
||||
`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,13 @@ 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 wireModel = process.env.REA_VERIFY_COPILOT_WIRE_MODEL ?? model;
|
||||
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]",
|
||||
@@ -44,6 +51,7 @@ await exec("git", ["init", "--quiet", workspace], { timeout: 30_000 });
|
||||
const environment = {
|
||||
PATH: process.env.PATH,
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS,
|
||||
HOME: account,
|
||||
USERPROFILE: account,
|
||||
COPILOT_HOME: profile,
|
||||
COPILOT_OFFLINE: "true",
|
||||
@@ -54,7 +62,7 @@ const environment = {
|
||||
COPILOT_PROVIDER_BEARER_TOKEN: "",
|
||||
COPILOT_PROVIDER_HEADERS: "",
|
||||
COPILOT_PROVIDER_MODEL_ID: model,
|
||||
COPILOT_PROVIDER_WIRE_MODEL: model,
|
||||
COPILOT_PROVIDER_WIRE_MODEL: wireModel,
|
||||
COPILOT_PROVIDER_MAX_PROMPT_TOKENS: "1000000",
|
||||
COPILOT_PROVIDER_MAX_OUTPUT_TOKENS: "4096",
|
||||
REA_PROCESS_RUN_ID: verifier.run_id,
|
||||
@@ -112,10 +120,17 @@ const extractEvidence = (value) => {
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const wireModels = [];
|
||||
const fixture = await createOpenAiModelFixture({
|
||||
directory: lab,
|
||||
prefix: "copilot",
|
||||
onRequest: async ({ body, tools, requestIndex }) => {
|
||||
assert.equal(
|
||||
body.model,
|
||||
wireModel,
|
||||
"Copilot must send the selected wire model",
|
||||
);
|
||||
wireModels.push(body.model);
|
||||
// Auxiliary requests without tools cannot execute the analyst's workflow.
|
||||
if (tools.length === 0)
|
||||
return { role: "assistant", content: "REA fixture title" };
|
||||
@@ -311,6 +326,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 +433,8 @@ try {
|
||||
clientVersion,
|
||||
mode,
|
||||
model,
|
||||
wireModel,
|
||||
inputSchemaProfile,
|
||||
passed: failure === undefined,
|
||||
artifacts: lab,
|
||||
runtimeRoot,
|
||||
@@ -421,7 +448,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."
|
||||
: "Complete canonical input and output schemas. Client model ID and wire model are recorded separately; effective token capacity and live provider compatibility remain unknown.",
|
||||
catalogSize,
|
||||
schemasChecked,
|
||||
spillReadRequested,
|
||||
@@ -429,6 +458,7 @@ try {
|
||||
skillLoaded,
|
||||
artifactDigest,
|
||||
finalSeen,
|
||||
wireModels,
|
||||
requests: fixture.requests,
|
||||
probes: fixture.probes,
|
||||
fixtureFailure: fixture.failure,
|
||||
|
||||
@@ -40,9 +40,9 @@ await Promise.all([
|
||||
]);
|
||||
const environment = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: account,
|
||||
USERPROFILE: account,
|
||||
DSH_HOME: profile,
|
||||
DSH_AGENTS_HOME: join(account, ".agents"),
|
||||
OPENAI_API_KEY: "local-fixture-key",
|
||||
REA_PROCESS_RUN_ID: verifier.run_id,
|
||||
};
|
||||
@@ -328,7 +328,7 @@ try {
|
||||
"loopback custom OpenAI-compatible adapter; no live DeepSeek provider claim",
|
||||
modelContextWindow: 1000000,
|
||||
skillDiscovery:
|
||||
"native DSH_AGENTS_HOME personal directory; isolated project root",
|
||||
"native HOME shared personal directory; isolated project root",
|
||||
catalogSize: catalog?.length,
|
||||
patternCount,
|
||||
uniquePatterns: patterns.size,
|
||||
|
||||
@@ -54,6 +54,7 @@ await writeFile(
|
||||
);
|
||||
const environment = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: account,
|
||||
USERPROFILE: account,
|
||||
[client === "pi" ? "PI_CODING_AGENT_DIR" : "HERMES_HOME"]: profile,
|
||||
PI_OFFLINE: "1",
|
||||
@@ -71,13 +72,6 @@ const original =
|
||||
})
|
||||
: "# Keep caller settings.\nmcp_servers:\n other:\n command: unrelated-server\n enabled: false\n";
|
||||
await writeFile(configPath, original);
|
||||
if (client === "pi") {
|
||||
// The CLI fixture account and native os.homedir() differ; use Pi's caller-owned skill setting.
|
||||
await writeFile(
|
||||
join(profile, "settings.json"),
|
||||
JSON.stringify({ skills: [join(account, ".agents", "skills")] }),
|
||||
);
|
||||
}
|
||||
const clientVersion = (
|
||||
await exec(command, ["--version"], { env: environment, timeout: 30_000 })
|
||||
).stdout.trim();
|
||||
@@ -448,7 +442,7 @@ try {
|
||||
"loopback deterministic OpenAI-compatible endpoint; no live provider claim",
|
||||
skillDiscovery:
|
||||
client === "pi"
|
||||
? "caller-configured isolated shared directory; default OS home discovery unverified"
|
||||
? "native HOME shared personal directory; no explicit skill setting"
|
||||
: "native HERMES_HOME personal directory",
|
||||
stickyHermes,
|
||||
catalogSize: catalog?.length,
|
||||
|
||||
@@ -175,6 +175,7 @@ const callerDirectory = join(workspace, "caller cwd with spaces");
|
||||
const scriptCollisions = [
|
||||
"ReaGhidraBridge.java",
|
||||
"ReaGhidraPrepareCom.java",
|
||||
"ReaGhidraApplySeeds.java",
|
||||
"ReaGhidraNoReturnFix.java",
|
||||
];
|
||||
await mkdir(callerDirectory);
|
||||
|
||||
@@ -45,6 +45,7 @@ await Promise.all([
|
||||
]);
|
||||
const baseEnvironment = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: account,
|
||||
USERPROFILE: account,
|
||||
QWEN_HOME: profile,
|
||||
QWEN_RUNTIME_DIR: runtime,
|
||||
@@ -71,9 +72,6 @@ const original = JSON.stringify(
|
||||
},
|
||||
telemetry: { enabled: false },
|
||||
model: { name: "rea-client-fixture" },
|
||||
// Qwen uses os.homedir(), while REA's isolated CLI account uses USERPROFILE.
|
||||
// This caller-owned setting lets the real client load the REA-installed bundle.
|
||||
skills: { directories: [sharedSkills] },
|
||||
mcpServers: { other: { command: "unrelated-server", disabled: true } },
|
||||
},
|
||||
null,
|
||||
@@ -112,7 +110,7 @@ const configured = JSON.parse(
|
||||
await readFile(join(profile, "settings.json"), "utf8"),
|
||||
);
|
||||
assert.deepEqual(configured.model, { name: "rea-client-fixture" });
|
||||
assert.deepEqual(configured.skills, { directories: [sharedSkills] });
|
||||
assert.equal(configured.skills, undefined);
|
||||
assert.deepEqual(configured.mcpServers.other, {
|
||||
command: "unrelated-server",
|
||||
disabled: true,
|
||||
@@ -384,7 +382,7 @@ try {
|
||||
modelFixture:
|
||||
"loopback deterministic OpenAI-compatible endpoint; no live model-provider claim",
|
||||
skillDiscovery:
|
||||
"caller-configured isolated shared directory; default OS home discovery unverified",
|
||||
"native HOME shared personal directory; no explicit skill setting",
|
||||
catalogSize: TOOL_CONTRACTS.length,
|
||||
requests: fixture.requests,
|
||||
probes: fixture.probes,
|
||||
|
||||
@@ -45,6 +45,7 @@ import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e
|
||||
import { verifyBrowserCaptureMetadataBudget } from "./lib/browser-capture-metadata-budget-e2e.mjs";
|
||||
import { artifactCliEvidence, artifactMcpResult } from "./lib/artifact-e2e.mjs";
|
||||
import { verifyScenarioEnvironment } from "./lib/browser-scenario-environment-e2e.mjs";
|
||||
import { verifyScenarioStorage } from "./lib/browser-scenario-storage-e2e.mjs";
|
||||
|
||||
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000;
|
||||
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
|
||||
@@ -64,6 +65,7 @@ try {
|
||||
executable,
|
||||
[
|
||||
"--headless=new",
|
||||
"--password-store=basic",
|
||||
...(process.env.REA_BROWSER_NO_SANDBOX === "true"
|
||||
? ["--no-sandbox"]
|
||||
: []),
|
||||
@@ -280,6 +282,11 @@ try {
|
||||
target,
|
||||
site.origin,
|
||||
);
|
||||
const scenarioStorage = await verifyScenarioStorage({
|
||||
executable,
|
||||
endpoint,
|
||||
targetId: target,
|
||||
});
|
||||
const launchedScenario = await createBrowserScenarioProvider(
|
||||
process.env,
|
||||
).captureScenario(
|
||||
@@ -354,7 +361,11 @@ try {
|
||||
scripts: observed.value.scripts.items.length,
|
||||
networkRequests: observed.value.network.requests.length,
|
||||
consoleEvents: observed.value.console.events.length,
|
||||
websocketEvents: observed.value.network.websocket_events.length,
|
||||
websocketConnections: observed.value.network.websocket_connections.length,
|
||||
websocketFrames: observed.value.network.websocket_connections.reduce(
|
||||
(total, connection) => total + connection.events.length,
|
||||
0,
|
||||
),
|
||||
bundleScripts: bundle.capture.scripts_analyzed,
|
||||
sourceMaps: bundle.observations.source_maps.processed,
|
||||
bundle_cli_and_stdio_mcp: true,
|
||||
@@ -373,6 +384,7 @@ try {
|
||||
browserScenarioLaunchCleanup: "terminated-owned-process",
|
||||
scenarioFailure,
|
||||
scenarioEnvironment,
|
||||
scenarioStorage,
|
||||
verified: true,
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
} from "./verify-real-ghidra-assertions.mjs";
|
||||
|
||||
async function functionCall(client, operation, parameters) {
|
||||
return (await functionObservationCall(client, operation, parameters)).value;
|
||||
}
|
||||
|
||||
async function functionObservationCall(client, operation, parameters) {
|
||||
const input = parseGhidraFunctionInput(operation, parameters);
|
||||
if (!input.ok) throw input.error;
|
||||
const called = await client.callTool(operation, input.value);
|
||||
@@ -153,6 +157,57 @@ export async function verifyDebugFunctionOperations(
|
||||
const denseSwitchDossier = await functionCall(client, "analyze_function", {
|
||||
procedure: denseSwitchProcedure.address,
|
||||
});
|
||||
const terminalProcedure = requireProcedure(
|
||||
procedures,
|
||||
"rea_ghidra_inventory_terminal_call",
|
||||
);
|
||||
const terminalObservations = await Promise.all(
|
||||
[
|
||||
"procedure_info",
|
||||
"procedure_assembly",
|
||||
"read_function_instructions",
|
||||
"procedure_pseudo_code",
|
||||
"analyze_function",
|
||||
].map((operation) =>
|
||||
functionObservationCall(client, operation, {
|
||||
...(operation === "analyze_function" ? {} : { document: null }),
|
||||
procedure: terminalProcedure.address,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const terminalLimitations = terminalObservations.map((result) =>
|
||||
result.limitations.find(
|
||||
(limitation) =>
|
||||
/terminal call at 0x[0-9a-f]+/i.test(limitation) &&
|
||||
/ to 0x[0-9a-f]+/i.test(limitation) &&
|
||||
limitation.includes("abort") &&
|
||||
limitation.includes("hasNoReturn=true"),
|
||||
),
|
||||
);
|
||||
if (
|
||||
terminalLimitations.some((limitation) => limitation === undefined) ||
|
||||
terminalLimitations.some(
|
||||
(limitation) =>
|
||||
!limitation.includes("external=") ||
|
||||
!limitation.includes("thunk=") ||
|
||||
!limitation.includes("fallthrough is excluded") ||
|
||||
!limitation.includes("not an independent verification"),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
`Ghidra no-return boundary evidence was missing callee, address, or flag provenance: ${JSON.stringify(terminalObservations.map(({ limitations }) => limitations))}`,
|
||||
);
|
||||
const returningObservation = await functionObservationCall(
|
||||
client,
|
||||
"procedure_info",
|
||||
{ document: null, procedure: leaf.address },
|
||||
);
|
||||
if (
|
||||
returningObservation.limitations.some((limitation) =>
|
||||
limitation.includes("puts"),
|
||||
)
|
||||
)
|
||||
throw new Error("Ghidra marked an ordinary returning call as terminal");
|
||||
// The debug fixture is compiled -O0, and GCC 16 and later lower dense
|
||||
// switches to compare chains at -O0, so this dossier asserts only its
|
||||
// complete shape; the optimized jump-table variant proves dense jump-table
|
||||
@@ -179,6 +234,8 @@ export async function verifyDebugFunctionOperations(
|
||||
instruction_window: {
|
||||
returned: instructionWindow.instructions.length,
|
||||
},
|
||||
terminal_call_limitations: terminalLimitations,
|
||||
returning_call_limitations: returningObservation.limitations,
|
||||
cancellation,
|
||||
timeout,
|
||||
concurrency: concurrent,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -60,6 +60,9 @@ const env = {
|
||||
JAVA_HOME: installation.javaHome,
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
|
||||
};
|
||||
for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
|
||||
if (process.env[setting] !== undefined) env[setting] = process.env[setting];
|
||||
}
|
||||
const reports = [];
|
||||
let primaryFailure;
|
||||
try {
|
||||
@@ -227,6 +230,103 @@ try {
|
||||
fixture.sha256,
|
||||
"Analysis modified the source executable",
|
||||
);
|
||||
// Carved firmware keeps all program headers/load bytes but loses sections.
|
||||
const read16 = (offset) =>
|
||||
byteOrder === "little"
|
||||
? sourceBytes.readUInt16LE(offset)
|
||||
: sourceBytes.readUInt16BE(offset);
|
||||
const read32 = (offset) =>
|
||||
byteOrder === "little"
|
||||
? sourceBytes.readUInt32LE(offset)
|
||||
: sourceBytes.readUInt32BE(offset);
|
||||
const sectionOffset = read32(32);
|
||||
const cut = sectionOffset - 1;
|
||||
const programOffset = read32(28);
|
||||
const programCount = read16(44);
|
||||
assert.equal(read16(42), 32);
|
||||
assert.ok(cut >= programOffset + programCount * 32);
|
||||
for (let i = 0; i < programCount; i += 1) {
|
||||
const offset = programOffset + i * 32;
|
||||
assert.ok(read32(offset + 4) + read32(offset + 16) <= cut);
|
||||
}
|
||||
const carvedBytes = sourceBytes.subarray(0, cut);
|
||||
const carvedPath = join(workspace, `${byteOrder}-carved.elf`);
|
||||
const carvedSha256 = createHash("sha256")
|
||||
.update(carvedBytes)
|
||||
.digest("hex");
|
||||
await writeFile(carvedPath, carvedBytes);
|
||||
const carvedResolved = await parseBinaryTarget(carvedPath);
|
||||
if (!carvedResolved.ok) throw carvedResolved.error;
|
||||
assert.deepEqual(
|
||||
carvedResolved.value.mips.abiFlags,
|
||||
independent.mips.abiFlags,
|
||||
);
|
||||
assert.match(
|
||||
carvedResolved.value.mips.limitations.join(" "),
|
||||
/section header table/u,
|
||||
);
|
||||
const carvedTarget = await call("open_binary", {
|
||||
path: carvedPath,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(carvedTarget.sha256, carvedSha256);
|
||||
const carvedImage = await call("inspect_native_load_image");
|
||||
assertMipsLoadedImage(carvedImage.observations, byteOrder, carvedSha256);
|
||||
const carvedProcedures = await call("list_procedures");
|
||||
assert.ok(
|
||||
carvedProcedures.some(
|
||||
(p) =>
|
||||
BigInt(p.address) === BigInt(independent.symbols.rea_mips_entry),
|
||||
),
|
||||
);
|
||||
const carvedDossier = functionDossierSchema.parse(
|
||||
await call("analyze_function", {
|
||||
procedure: independent.symbols.rea_mips_entry,
|
||||
}),
|
||||
);
|
||||
assert.ok(carvedDossier.pseudocode.trim().length > 0);
|
||||
const carvedSnapshotPath = join(
|
||||
workspace,
|
||||
`${byteOrder}-carved.snapshot.json`,
|
||||
);
|
||||
await call("close_binary", { snapshot_path: carvedSnapshotPath });
|
||||
const carvedSnapshot = parseAnalysisSnapshot(
|
||||
JSON.parse(await readFile(carvedSnapshotPath, "utf8")),
|
||||
);
|
||||
assert.equal(carvedSnapshot.target.sha256, carvedSha256);
|
||||
assert.ok(
|
||||
carvedSnapshot.evidence_bundle.records.some((record) =>
|
||||
parseEvidence(record).limitations.some((item) =>
|
||||
item.includes("section header table"),
|
||||
),
|
||||
),
|
||||
);
|
||||
const carvedCli = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"function",
|
||||
carvedPath,
|
||||
independent.symbols.rea_mips_entry,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const carvedEvidence = parseEvidence(JSON.parse(carvedCli.stdout));
|
||||
assert.equal(carvedEvidence.subject?.digest.sha256, carvedSha256);
|
||||
assert.ok(
|
||||
carvedEvidence.limitations.some((item) =>
|
||||
item.includes("section header table"),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
functionDossierSchema.parse(carvedEvidence.normalized_result).procedure
|
||||
.address,
|
||||
carvedDossier.procedure.address,
|
||||
);
|
||||
assert.deepEqual(await readFile(carvedPath), carvedBytes);
|
||||
reports.push({
|
||||
...fixture,
|
||||
independent_reader: independent,
|
||||
@@ -241,6 +341,14 @@ try {
|
||||
"CLI/MCP and snapshot identity",
|
||||
],
|
||||
procedures: procedures.length,
|
||||
carved: {
|
||||
sha256: carvedSha256,
|
||||
source_bytes: carvedBytes.length,
|
||||
section_header_offset: sectionOffset,
|
||||
effective_language: carvedImage.observations.language_id,
|
||||
limitations: carvedResolved.value.mips.limitations,
|
||||
procedures: carvedProcedures.length,
|
||||
},
|
||||
status: "passed",
|
||||
});
|
||||
} catch (cause) {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import { parseConfig } from "../dist/config/parseConfig.js";
|
||||
import { GhidraClient } from "../dist/ghidra/GhidraClient.js";
|
||||
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
|
||||
import { SUPPORTED_GHIDRA_VERSION } from "../dist/ghidra/GhidraInstallationPolicy.js";
|
||||
import { GhidraProvider } from "../dist/ghidra/GhidraProvider.js";
|
||||
import { silentLogger } from "../dist/logger.js";
|
||||
import { cleanupOwnedProcessGroup } from "../dist/process/ProcessOwnership.js";
|
||||
import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
} from "../dist/process/ProviderProcess.js";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const run = createVerifierRun();
|
||||
const procedureName = (name) =>
|
||||
process.platform === "darwin" ? `_${name}` : name;
|
||||
assert.ok(
|
||||
process.platform === "linux" || process.platform === "darwin",
|
||||
"This regression lane requires Linux or macOS",
|
||||
);
|
||||
assert.ok(
|
||||
process.arch === "x64" || process.arch === "arm64",
|
||||
"This regression lane requires x86-64 or arm64",
|
||||
);
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) throw config.error;
|
||||
const installation = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir: config.value.ghidraInstallDir,
|
||||
javaHome: config.value.ghidraJavaHome,
|
||||
});
|
||||
assert.equal(installation.status, "available", JSON.stringify(installation));
|
||||
assert.equal(installation.providerVersion, SUPPORTED_GHIDRA_VERSION);
|
||||
const fixtures = fileURLToPath(
|
||||
new URL("../tests/conformance/ghidra", import.meta.url),
|
||||
);
|
||||
const bridge = fileURLToPath(new URL("../bridge/ghidra", import.meta.url));
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-ghidra-noreturn-"));
|
||||
let cleaned = true;
|
||||
let report;
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
try {
|
||||
const targetPath = join(
|
||||
workspace,
|
||||
process.platform === "darwin" ? "noreturn.dylib" : "noreturn.so",
|
||||
);
|
||||
const targetOptions =
|
||||
process.platform === "darwin"
|
||||
? [
|
||||
"-dynamiclib",
|
||||
"-undefined",
|
||||
"dynamic_lookup",
|
||||
"-U_FORTIFY_SOURCE",
|
||||
"-D_FORTIFY_SOURCE=0",
|
||||
]
|
||||
: ["-shared"];
|
||||
await exec(process.env.REA_CC ?? "cc", [
|
||||
...targetOptions,
|
||||
"-fPIC",
|
||||
"-O0",
|
||||
"-fno-builtin",
|
||||
join(fixtures, "no-return.c"),
|
||||
"-o",
|
||||
targetPath,
|
||||
]);
|
||||
const targetDigest = digest(await readFile(targetPath));
|
||||
const sourceDigests = Object.fromEntries(
|
||||
await Promise.all(
|
||||
["ReaGhidraBridge.java", "ReaGhidraNoReturnFix.java"].map(
|
||||
async (name) => [name, digest(await readFile(join(bridge, name)))],
|
||||
),
|
||||
),
|
||||
);
|
||||
const project = join(workspace, "project");
|
||||
await mkdir(project);
|
||||
const launch = await spawnOwnedProviderProcess({
|
||||
command: installation.analyzeHeadlessPath,
|
||||
arguments: [
|
||||
project,
|
||||
"NoReturn",
|
||||
"-import",
|
||||
targetPath,
|
||||
"-readOnly",
|
||||
"-deleteProject",
|
||||
"-scriptPath",
|
||||
`${fixtures};${bridge}`,
|
||||
"-postScript",
|
||||
join(fixtures, "ReaNoReturnProbe.java"),
|
||||
bridge,
|
||||
],
|
||||
runId: run.run_id,
|
||||
expectedCommand: null,
|
||||
});
|
||||
const supervisor = new ProviderProcessSupervisor({
|
||||
...launch,
|
||||
ownsProcessLifetime: true,
|
||||
cleanup: () => cleanupOwnedProcessGroup(launch.ownership),
|
||||
});
|
||||
let probe;
|
||||
try {
|
||||
assert.ok(
|
||||
await supervisor.waitForExit(120000),
|
||||
"No-return probe timed out",
|
||||
);
|
||||
assert.ok(
|
||||
await supervisor.waitForOutputClose(1000),
|
||||
"No-return probe output did not close",
|
||||
);
|
||||
const snapshot = supervisor.snapshot();
|
||||
const output = `${snapshot.stdout.text}\n${snapshot.stderr.text}`;
|
||||
assert.equal(snapshot.exitCode, 0, output);
|
||||
const reports = [
|
||||
...output.matchAll(/REA_NO_RETURN_PROBE_JSON (\{[^\r\n]*\})/gu),
|
||||
];
|
||||
assert.equal(reports.length, 1, output);
|
||||
probe = JSON.parse(reports[0][1]);
|
||||
assert.equal(probe.status, "passed");
|
||||
assert.equal(probe.ghidra_version, SUPPORTED_GHIDRA_VERSION);
|
||||
assert.equal(probe.target_sha256, targetDigest);
|
||||
for (const [name, expected] of Object.entries(sourceDigests))
|
||||
assert.equal(probe[name], expected);
|
||||
assert.ok(probe.checks.includes("independent_entry_preserved"));
|
||||
assert.ok(probe.checks.includes("propagated_flag_repaired_rea_tls_outer"));
|
||||
assert.ok(probe.checks.includes("recovered_pseudocode"));
|
||||
} finally {
|
||||
const stopped = await supervisor.stop();
|
||||
cleaned = stopped.status === "verified-cleanup";
|
||||
assert.ok(
|
||||
cleaned,
|
||||
`Owned cleanup incomplete; retained ${workspace}: ${JSON.stringify(stopped)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Exercise the actual Java server, authenticated transport, request queue and Evidence
|
||||
// projection. This read-only TCP lane also runs where AF_UNIX is unavailable; it does
|
||||
// not claim Windows authority or validation of the default Linux Unix-socket transport.
|
||||
const provider = new GhidraProvider(
|
||||
config.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
undefined,
|
||||
(options) =>
|
||||
new GhidraClient({ ...options, transport: "authenticated-loopback-tcp" }),
|
||||
);
|
||||
const target = await parseBinaryTarget(targetPath);
|
||||
if (!target.ok) throw target.error;
|
||||
const profile = await provider.resolveAnalysisProfile(target.value);
|
||||
if (!profile.ok) throw profile.error;
|
||||
assert.ok(profile.value.profile);
|
||||
assert.equal(
|
||||
profile.value.profile.parameters.no_return_repair,
|
||||
"returning-imports-decoded-return-v2",
|
||||
);
|
||||
const client = provider.createClient(target.value, profile.value.profile);
|
||||
cleaned = false;
|
||||
const observations = [];
|
||||
const failures = [];
|
||||
try {
|
||||
for (const operation of [
|
||||
"procedure_pseudo_code",
|
||||
"procedure_assembly",
|
||||
"procedure_info",
|
||||
"read_function_instructions",
|
||||
"analyze_function",
|
||||
]) {
|
||||
const result = await client.execute(operation, {
|
||||
procedure: procedureName("rea_abort"),
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
const warnings = result.value.limitations.filter((value) =>
|
||||
/terminal call/u.test(value),
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(value) => /abort/u.test(value) && /fallthrough/u.test(value),
|
||||
),
|
||||
operation,
|
||||
);
|
||||
observations.push({ operation, warnings });
|
||||
}
|
||||
const healthy = await client.execute("procedure_pseudo_code", {
|
||||
procedure: procedureName("rea_tls"),
|
||||
});
|
||||
if (!healthy.ok) throw healthy.error;
|
||||
assert.ok(
|
||||
!healthy.value.limitations.some((value) => /terminal call/u.test(value)),
|
||||
);
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
}
|
||||
const closed = await client.close();
|
||||
cleaned = closed.ok;
|
||||
if (!closed.ok) failures.push(closed.error);
|
||||
if (failures.length > 0)
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
"No-return Evidence verification failed",
|
||||
);
|
||||
assert.equal(digest(await readFile(targetPath)), targetDigest);
|
||||
report = {
|
||||
status: "passed",
|
||||
probe,
|
||||
observations,
|
||||
scope:
|
||||
"Real Ghidra ELF or Mach-O analysis with injected flags; real read-only TCP Evidence projection. No target execution; exact Amethyst artifact and Windows are unverified.",
|
||||
};
|
||||
} finally {
|
||||
if (cleaned) await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
...report,
|
||||
cleanup: "complete",
|
||||
verifier_run: await completeVerifierRun(run),
|
||||
}),
|
||||
);
|
||||
@@ -322,7 +322,7 @@ async function functionOperation(operation, parameters) {
|
||||
const result = parseGhidraFunctionResult(operation, called.value);
|
||||
if (!result.ok) throw result.error;
|
||||
observed.add(operation);
|
||||
return result.value;
|
||||
return result.value.value;
|
||||
}
|
||||
|
||||
function assertSession(session, targetSha256, profileDigest) {
|
||||
|
||||
@@ -26,22 +26,49 @@ export async function verifyElectronToolContracts({
|
||||
await mkdtemp(join(tmpdir(), "rea-electron-e2e-")),
|
||||
);
|
||||
const main = join(root, "main.cjs");
|
||||
const preload = join(root, "preload.cjs");
|
||||
const ready = join(root, "ready");
|
||||
const entrypoint = join(repositoryRoot, "scripts", "rea.mjs");
|
||||
const runId = randomUUID();
|
||||
await writeFile(
|
||||
main,
|
||||
`const {app,BrowserWindow}=require("electron");
|
||||
app.whenReady().then(()=>{const window=new BrowserWindow();
|
||||
window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringify(ready)},"ready");});`,
|
||||
`const electron = require("electron");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow, ipcMain } = electron;
|
||||
const Window = BrowserWindow;
|
||||
const mainIpc = ipcMain;
|
||||
app.whenReady().then(() => {
|
||||
const window = new Window({
|
||||
webPreferences: { preload: path.join(__dirname, "preload.cjs") },
|
||||
});
|
||||
window.loadFile("renderer.html");
|
||||
require("node:fs").writeFileSync(${JSON.stringify(ready)}, "ready");
|
||||
});
|
||||
mainIpc.handle("fixture:echo", (_event, value) => ({ value }));`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "renderer.html"),
|
||||
'<!doctype html><button id="run">Run</button><script src="renderer.js"></script>',
|
||||
`<!doctype html>
|
||||
<button id="run">Run</button>
|
||||
<output id="result"></output>
|
||||
<script src="renderer.js"></script>`,
|
||||
);
|
||||
await writeFile(
|
||||
preload,
|
||||
`const electron = require("electron");
|
||||
const { contextBridge, ipcRenderer } = electron;
|
||||
const bridge = contextBridge;
|
||||
const rendererIpc = ipcRenderer;
|
||||
bridge.exposeInMainWorld("fixture", {
|
||||
echo: (value) => rendererIpc.invoke("fixture:echo", value),
|
||||
});`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "renderer.js"),
|
||||
'document.querySelector("#run").addEventListener("click",()=>console.log("fixture-click"));',
|
||||
`document.querySelector("#run").addEventListener("click", async () => {
|
||||
const result = await window.fixture.echo("renderer");
|
||||
document.querySelector("#result").textContent = result.value;
|
||||
});`,
|
||||
);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
@@ -135,6 +162,25 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
|
||||
const staticAnalysis = await call("analyze_javascript_application", {
|
||||
input_path: root,
|
||||
});
|
||||
assert.equal(staticAnalysis.normalized_result.summary.browser_windows, 1);
|
||||
assert.equal(
|
||||
staticAnalysis.normalized_result.summary.preload_entrypoints,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
staticAnalysis.normalized_result.summary.context_bridge_apis,
|
||||
1,
|
||||
);
|
||||
assert.equal(staticAnalysis.normalized_result.summary.ipc.main_handlers, 1);
|
||||
assert.equal(
|
||||
staticAnalysis.normalized_result.summary.ipc.renderer_transmissions,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
staticAnalysis.normalized_result.summary.ipc
|
||||
.paired_renderer_transmissions,
|
||||
1,
|
||||
);
|
||||
const reconciled = await call("reconcile_javascript_runtime", {
|
||||
static_layers: [
|
||||
{
|
||||
@@ -169,7 +215,7 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
|
||||
executable_path: executable,
|
||||
application_path: main,
|
||||
application_root: root,
|
||||
actions: [{ step_id: "settle", kind: "wait", duration_ms: 50 }],
|
||||
actions: [{ step_id: "ipc", kind: "click", selector: "#run" }],
|
||||
};
|
||||
const active = await client.callTool(
|
||||
{ name: "capture_electron_scenario", arguments: input },
|
||||
@@ -192,6 +238,13 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
|
||||
assert.equal(capture.application.cleanup, "terminated-owned-process");
|
||||
}
|
||||
assert.equal(capture.actions[0].status, "completed");
|
||||
assert.ok(
|
||||
capture.ipc.events.some(
|
||||
({ kind, channel }) =>
|
||||
kind === "main-handler-invocation" && channel === "fixture:echo",
|
||||
),
|
||||
JSON.stringify(capture.ipc),
|
||||
);
|
||||
assert.equal(
|
||||
capture.coverage.status,
|
||||
"partial_attach",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
spawnOwnedProviderProcess,
|
||||
ProviderProcessSupervisor,
|
||||
} from "../../../dist/process/ProviderProcess.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../dist/process/ProcessOwnership.js";
|
||||
import { createVerifierRun } from "../../lib/verifier-run.mjs";
|
||||
|
||||
const installation = process.env.GHIDRA_INSTALL_DIR;
|
||||
assert.ok(
|
||||
installation && isAbsolute(installation),
|
||||
"Set GHIDRA_INSTALL_DIR to a local Ghidra installation",
|
||||
);
|
||||
assert.notEqual(
|
||||
process.platform,
|
||||
"win32",
|
||||
"This direct headless regression requires a POSIX host; Windows uses its controlled provider lane",
|
||||
);
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-ghidra-seed-regression-"));
|
||||
const run = createVerifierRun();
|
||||
let supervisor;
|
||||
try {
|
||||
const target = join(workspace, "seed-functions.bin");
|
||||
const seeds = join(workspace, "seeds.tsv");
|
||||
// Falling through from the lower entry reaches the higher entry. Creating
|
||||
// the higher function first is what keeps the two function bodies separate.
|
||||
await writeFile(target, Buffer.from([0x90, 0x90, 0xc3]));
|
||||
await writeFile(
|
||||
seeds,
|
||||
[
|
||||
"0x0\tfunction\tseed_low",
|
||||
"0x100000000\tfunction\tout_of_range",
|
||||
"0x1\tfunction\tseed_high",
|
||||
"0x1000\tlabel\tseed_global",
|
||||
"0x1000\tcode",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const launch = await spawnOwnedProviderProcess({
|
||||
command: join(installation, "support", "analyzeHeadless"),
|
||||
arguments: [
|
||||
workspace,
|
||||
"seed-regression",
|
||||
"-import",
|
||||
target,
|
||||
"-loader",
|
||||
"BinaryLoader",
|
||||
"-processor",
|
||||
"x86:LE:32:default",
|
||||
"-noanalysis",
|
||||
"-readOnly",
|
||||
"-deleteProject",
|
||||
"-scriptPath",
|
||||
[
|
||||
fileURLToPath(new URL("../../../bridge/ghidra", import.meta.url)),
|
||||
fileURLToPath(
|
||||
new URL("../../../tests/conformance/ghidra", import.meta.url),
|
||||
),
|
||||
].join(";"),
|
||||
"-postScript",
|
||||
"ReaSeedRegressionProbe.java",
|
||||
seeds,
|
||||
],
|
||||
runId: run.run_id,
|
||||
expectedCommand: null,
|
||||
});
|
||||
supervisor = new ProviderProcessSupervisor({
|
||||
...launch,
|
||||
ownsProcessLifetime: true,
|
||||
cleanup: () => cleanupOwnedProcessGroup(launch.ownership),
|
||||
});
|
||||
assert.ok(
|
||||
await supervisor.waitForExit(180_000),
|
||||
"Ghidra seed regression exceeded its startup deadline",
|
||||
);
|
||||
const snapshot = supervisor.snapshot();
|
||||
const output = `${snapshot.stdout.text}\n${snapshot.stderr.text}`;
|
||||
assert.equal(snapshot.exitCode, 0, output);
|
||||
// Headless can exit zero after a script fails, so require the probe's marker.
|
||||
assert.ok(output.includes("REA_SEED_REGRESSION_OK "), output);
|
||||
} finally {
|
||||
if (supervisor !== undefined) {
|
||||
const stopped = await supervisor.stop();
|
||||
assert.notEqual(stopped.status, "incomplete", JSON.stringify(stopped));
|
||||
supervisor.dispose();
|
||||
}
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
console.log(
|
||||
"Real Ghidra seed regression passed: descending function order, out-of-range seed, BSS label, BSS code rejection, and owned-process cleanup.",
|
||||
);
|
||||
@@ -3,7 +3,7 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../dist/application/javascript/JavaScriptApplicationService.js";
|
||||
import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs";
|
||||
import {
|
||||
compareApplicationVersionsEvidence,
|
||||
compareJavaScriptExportShapesEvidence,
|
||||
@@ -199,9 +199,5 @@ function selectorKey(selector) {
|
||||
}
|
||||
|
||||
async function analyze(path) {
|
||||
const result = await analyzeJavaScriptApplication({
|
||||
input_path: path,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
return analyzeJavaScriptApplicationWithOwnedScope({ input_path: path });
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { readFile, realpath } from "node:fs/promises";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../dist/application/javascript/JavaScriptApplicationService.js";
|
||||
import { reconcileJavaScriptRuntimeEvidence } from "../../../dist/application/javascript/JavaScriptRuntimeReconciliationService.js";
|
||||
import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs";
|
||||
import {
|
||||
completeVerifierRun,
|
||||
createVerifierRun,
|
||||
@@ -44,14 +44,13 @@ const canonicalLayers = await Promise.all(
|
||||
);
|
||||
const staticLayers = [];
|
||||
for (const layer of canonicalLayers) {
|
||||
const analyzed = await analyzeJavaScriptApplication({
|
||||
const analyzed = await analyzeJavaScriptApplicationWithOwnedScope({
|
||||
input_path: layer.path,
|
||||
format: "auto",
|
||||
});
|
||||
if (!analyzed.ok) throw analyzed.error;
|
||||
staticLayers.push({
|
||||
role: layer.role,
|
||||
analysis: analyzed.value,
|
||||
analysis: analyzed,
|
||||
runtime_mappings: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
assert.equal(process.platform, "android", "Run this verifier inside Termux");
|
||||
assert.equal(process.env.PLAYWRIGHT_BROWSERS_PATH, undefined);
|
||||
// Import no browser provider here: the public dispatcher must establish its
|
||||
// Android cache fallback before Playwright initializes in each subprocess.
|
||||
const { stdout: doctorOutput } = await execute(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", "mcp", "doctor", "--format", "json"],
|
||||
{ timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
|
||||
);
|
||||
assert.equal(JSON.parse(doctorOutput).healthy, true, doctorOutput);
|
||||
const executable = `${process.env.PREFIX}/bin/chromium`;
|
||||
const { stdout: chromiumVersion } = await execute(executable, ["--version"], {
|
||||
timeout: 30_000,
|
||||
});
|
||||
const marker = "rea-termux-browser-smoke";
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(`<!doctype html><html><body><h1>${marker}</h1></body></html>`);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
try {
|
||||
const url = `http://127.0.0.1:${server.address().port}/smoke`;
|
||||
const scenario = {
|
||||
browser: { mode: "launch", executable_path: executable },
|
||||
start_url: { url },
|
||||
actions: [
|
||||
{
|
||||
step_id: "ready",
|
||||
action: "wait_for",
|
||||
locator: { kind: "css", selector: "h1" },
|
||||
state: "visible",
|
||||
},
|
||||
],
|
||||
capture: { at_end: ["url", "dom"] },
|
||||
};
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/rea.mjs",
|
||||
"capture-browser-scenario",
|
||||
JSON.stringify(scenario),
|
||||
"--json",
|
||||
],
|
||||
{ timeout: 180_000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const evidence = JSON.parse(stdout);
|
||||
assert.equal(evidence.error, undefined, stdout);
|
||||
const capture = evidence.normalized_result;
|
||||
assert.ok(
|
||||
capture.steps.every((step) => step.status === "completed"),
|
||||
stdout,
|
||||
);
|
||||
const artifacts = capture.steps.at(-1).artifacts;
|
||||
assert.equal(artifacts.url.state, "captured", stdout);
|
||||
assert.equal(artifacts.url.value.url, url);
|
||||
assert.equal(artifacts.dom.state, "captured", stdout);
|
||||
assert.ok(artifacts.dom.value.text.includes(`<h1>${marker}</h1>`), stdout);
|
||||
assert.equal(capture.browser.cleanup, "terminated-owned-process");
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
node: process.version,
|
||||
chromium: chromiumVersion.trim(),
|
||||
mcp_doctor: "healthy",
|
||||
url_capture: "passed",
|
||||
dom_capture: "passed",
|
||||
cleanup: capture.browser.cleanup,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((cause) => (cause ? reject(cause) : resolve())),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Host-side orchestration. Root is used only to stage/read files and send the
|
||||
# intent; TermuxService launches all package/build/browser work as the app UID.
|
||||
set -euo pipefail
|
||||
|
||||
staging="${RUNNER_TEMP:?}/rea-termux"
|
||||
app_home=/data/data/com.termux/files/home
|
||||
prefix=/data/data/com.termux/files/usr
|
||||
mkdir -p "$staging/diagnostics"
|
||||
collect_diagnostics() {
|
||||
adb logcat -d > "$staging/diagnostics/logcat.txt" 2>&1 || true
|
||||
adb exec-out cat "$app_home/rea-ci/run.log" > "$staging/diagnostics/termux.log" 2>&1 || true
|
||||
adb exec-out cat "$app_home/rea-ci/status" > "$staging/diagnostics/status.txt" 2>&1 || true
|
||||
}
|
||||
trap collect_diagnostics EXIT
|
||||
|
||||
adb root
|
||||
adb wait-for-device
|
||||
adb install "$staging/termux-app_v0.118.3+github-debug_x86_64.apk"
|
||||
adb shell am start -n com.termux/.app.TermuxActivity
|
||||
# First launch unpacks the APK's bundled bootstrap. Do not replace it with a
|
||||
# root-installed userland: that would miss Termux's own initialization.
|
||||
deadline=$((SECONDS + 180))
|
||||
until adb shell test -x "$prefix/bin/bash"; do
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo 'Termux bootstrap did not finish within 180 seconds' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
git archive --format=tar HEAD > "$staging/source.tar"
|
||||
adb push "$staging/source.tar" /data/local/tmp/rea-source.tar
|
||||
adb push scripts/verify/termux/in-app.sh /data/local/tmp/rea-in-app.sh
|
||||
adb shell chmod 644 /data/local/tmp/rea-source.tar /data/local/tmp/rea-in-app.sh
|
||||
app_uid=$(adb shell stat -c %u /data/data/com.termux | tr -d '\r')
|
||||
[[ "$app_uid" =~ ^[0-9]+$ && "$app_uid" != 0 ]]
|
||||
adb shell mkdir -p "$app_home/rea-ci" "$app_home/.termux"
|
||||
adb shell "echo allow-external-apps=true > $app_home/.termux/termux.properties"
|
||||
adb shell cp /data/local/tmp/rea-in-app.sh "$app_home/rea-ci/in-app.sh"
|
||||
adb shell cp /data/local/tmp/rea-source.tar "$app_home/rea-ci/source.tar"
|
||||
adb shell chown -R "$app_uid:$app_uid" "$app_home/rea-ci" "$app_home/.termux"
|
||||
adb shell restorecon -RF "$app_home/rea-ci" "$app_home/.termux"
|
||||
# Reload the external-command policy in a fresh app process before sending it.
|
||||
adb shell am force-stop com.termux
|
||||
adb shell am start -n com.termux/.app.TermuxActivity
|
||||
# Root may send this permission-protected intent; the service executes as the
|
||||
# application, rather than running Node/Chromium through adb shell or su.
|
||||
adb shell am startservice \
|
||||
-n com.termux/.app.RunCommandService \
|
||||
-a com.termux.RUN_COMMAND \
|
||||
--es com.termux.RUN_COMMAND_PATH "$prefix/bin/bash" \
|
||||
--esa com.termux.RUN_COMMAND_ARGUMENTS "-l,$app_home/rea-ci/in-app.sh" \
|
||||
--es com.termux.RUN_COMMAND_WORKDIR "$app_home" \
|
||||
--ez com.termux.RUN_COMMAND_BACKGROUND true
|
||||
|
||||
deadline=$((SECONDS + 2100))
|
||||
until adb shell test -f "$app_home/rea-ci/status"; do
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo 'Termux verification exceeded 35 minutes' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
adb exec-out cat "$app_home/rea-ci/run.log"
|
||||
status=$(adb shell cat "$app_home/rea-ci/status" | tr -d '\r')
|
||||
[[ "$status" == 0 ]]
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
report="$HOME/rea-ci"
|
||||
exec > "$report/run.log" 2>&1
|
||||
# Publish the result only after all commands have settled. The host reads it as
|
||||
# root; no shared-storage permission or helper Android application is needed.
|
||||
trap 'result=$?; printf "%s\n" "$result" > "$report/status"' EXIT
|
||||
set -x
|
||||
[[ "$(id -u)" != 0 ]]
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export HUSKY=0
|
||||
pkg update -y
|
||||
pkg upgrade -y
|
||||
pkg install -y nodejs-lts npm git x11-repo termux-exec
|
||||
pkg install -y chromium
|
||||
mkdir -p "$report/source"
|
||||
tar -xf "$report/source.tar" -C "$report/source"
|
||||
cd "$report/source"
|
||||
node -e 'if (process.platform !== "android") throw new Error("Expected Android Node.js")'
|
||||
node -e 'const expected = require("node:fs").readFileSync(".nvmrc", "utf8").trim().replace(/^v/, ""); if (process.versions.node !== expected) throw new Error(`Termux Node ${process.versions.node} differs from .nvmrc ${expected}`)'
|
||||
# Android uses Termux's Bionic-linked Node distribution; the desktop .nvmrc
|
||||
# binary cannot run here. Keep npm at the repository's exact pinned version.
|
||||
npm install --prefix "$report/toolchain" --no-package-lock --ignore-scripts \
|
||||
"$(node -p 'JSON.parse(require("node:fs").readFileSync("package.json", "utf8")).packageManager')"
|
||||
export PATH="$report/toolchain/node_modules/.bin:$PATH"
|
||||
node --version
|
||||
npm --version
|
||||
dpkg-query -W nodejs-lts chromium termux-exec
|
||||
npm ci
|
||||
npm run build:termux
|
||||
unset PLAYWRIGHT_BROWSERS_PATH npm_config_playwright_browsers_path npm_package_config_playwright_browsers_path
|
||||
node scripts/verify/termux/browser.mjs
|
||||
+343
-41
@@ -1,6 +1,6 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream, constants as fsConstants } from "node:fs";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { access, lstat, mkdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
|
||||
@@ -24,8 +24,11 @@ import {
|
||||
AnalysisTimeoutError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
@@ -97,11 +100,18 @@ const STDERR_EXCERPT_BYTES = 512;
|
||||
const UNPARSED_LINES_REPORTED = 10;
|
||||
const STOP_GRACE_MS = 5_000;
|
||||
|
||||
/** Stream a local file digest without retaining large files in memory. */
|
||||
const sha256File = async (path: string): Promise<string> => {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
const hashAdbFile = async (
|
||||
path: string,
|
||||
operation: string,
|
||||
signal: AbortSignal | undefined,
|
||||
) => {
|
||||
try {
|
||||
return await hashStableFile(path, signal);
|
||||
} catch (cause: unknown) {
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError(operation, { cause });
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
/** adb joins shell operands into a remote POSIX command; quote at that boundary. */
|
||||
@@ -335,6 +345,48 @@ export interface AdbProviderOptions {
|
||||
readonly environment?: Readonly<Record<string, string | undefined>>;
|
||||
}
|
||||
|
||||
interface PendingPackageDirectoryCleanup {
|
||||
readonly path: string;
|
||||
readonly identity: PackageDirectoryIdentity | undefined;
|
||||
readonly primary: AnalysisError;
|
||||
readonly remove: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface PackageDirectoryIdentity {
|
||||
readonly dev: number;
|
||||
readonly ino: number;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
const removeOwnedPackageDirectory = async (
|
||||
path: string,
|
||||
identity: PackageDirectoryIdentity | undefined,
|
||||
): Promise<void> => {
|
||||
if (identity === undefined)
|
||||
throw new Error(
|
||||
`Cannot verify the created package directory identity; refusing to remove ${path}`,
|
||||
);
|
||||
let current: Awaited<ReturnType<typeof lstat>>;
|
||||
try {
|
||||
current = await lstat(path);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT")
|
||||
return;
|
||||
throw cause;
|
||||
}
|
||||
if (
|
||||
!current.isDirectory() ||
|
||||
current.isSymbolicLink() ||
|
||||
current.dev !== identity.dev ||
|
||||
current.ino !== identity.ino ||
|
||||
current.mode !== identity.mode
|
||||
)
|
||||
throw new Error(
|
||||
`The created package directory was replaced or changed; refusing to remove ${path}`,
|
||||
);
|
||||
await rm(path, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* ADB device inspection and acquisition over a caller-selected adb binary.
|
||||
*
|
||||
@@ -346,6 +398,12 @@ export interface AdbProviderOptions {
|
||||
*/
|
||||
export class AdbProvider {
|
||||
private readonly environment: Readonly<Record<string, string | undefined>>;
|
||||
readonly #pendingPackageDirectoryCleanup =
|
||||
new Set<PendingPackageDirectoryCleanup>();
|
||||
readonly #activeOperations = new Set<Promise<void>>();
|
||||
#closing = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
#packageCleanupPromise: Promise<void> | undefined;
|
||||
|
||||
constructor(options: AdbProviderOptions = {}) {
|
||||
this.environment = options.environment ?? process.env;
|
||||
@@ -378,12 +436,121 @@ export class AdbProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider holds no long-lived resources; the adb server is caller-owned. */
|
||||
async close(): Promise<void> {}
|
||||
/** Retry removal of package directories retained after failed rollback. */
|
||||
close(): Promise<void> {
|
||||
this.#closing = true;
|
||||
if (this.#closePromise !== undefined) return this.#closePromise;
|
||||
const closing = (async () => {
|
||||
await Promise.all(this.#activeOperations);
|
||||
await this.#retryPackageDirectoryCleanup();
|
||||
})().finally(() => {
|
||||
if (this.#closePromise === closing) this.#closePromise = undefined;
|
||||
});
|
||||
this.#closePromise = closing;
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
#retryPackageDirectoryCleanup(): Promise<void> {
|
||||
if (this.#packageCleanupPromise !== undefined)
|
||||
return this.#packageCleanupPromise;
|
||||
const cleanup = this.#performPackageDirectoryCleanup().finally(() => {
|
||||
if (this.#packageCleanupPromise === cleanup)
|
||||
this.#packageCleanupPromise = undefined;
|
||||
});
|
||||
this.#packageCleanupPromise = cleanup;
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
async #performPackageDirectoryCleanup(): Promise<void> {
|
||||
const failures: AnalysisError[] = [];
|
||||
for (const owner of this.#pendingPackageDirectoryCleanup) {
|
||||
try {
|
||||
await owner.remove();
|
||||
this.#pendingPackageDirectoryCleanup.delete(owner);
|
||||
} catch (cause: unknown) {
|
||||
const cleanup = new ProviderCleanupError(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
[owner.path],
|
||||
{
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
{ operation: "pull_adb_package", cause },
|
||||
);
|
||||
failures.push(
|
||||
analysisErrorWithCleanupFailure(
|
||||
owner.primary,
|
||||
cleanup,
|
||||
"pull_adb_package",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failures.length === 1) throw failures[0];
|
||||
if (failures.length > 1)
|
||||
throw new ProviderCleanupError(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
[...this.#pendingPackageDirectoryCleanup].map(({ path }) => path),
|
||||
{
|
||||
reason:
|
||||
"Failed package output directories remain after cleanup retry",
|
||||
failures: failures.map(({ message, cleanupResources }) => ({
|
||||
message,
|
||||
resources: [...cleanupResources],
|
||||
})),
|
||||
},
|
||||
{
|
||||
operation: "pull_adb_package",
|
||||
cause: new AggregateError(
|
||||
failures,
|
||||
"Multiple ADB package rollback cleanups failed",
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async execute(
|
||||
request: AdbRequest,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
if (this.#closing)
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
let release!: () => void;
|
||||
const active = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
this.#activeOperations.add(active);
|
||||
try {
|
||||
if (this.#pendingPackageDirectoryCleanup.size > 0) {
|
||||
try {
|
||||
await this.#retryPackageDirectoryCleanup();
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderCleanupError(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
[...this.#pendingPackageDirectoryCleanup].map(
|
||||
({ path }) => path,
|
||||
),
|
||||
{
|
||||
reason:
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
{ operation: request.operation, cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return await this.#executeOperation(request, options);
|
||||
} finally {
|
||||
this.#activeOperations.delete(active);
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
async #executeOperation(
|
||||
request: AdbRequest,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
const signal = options?.signal;
|
||||
if (signal?.aborted === true)
|
||||
@@ -1010,9 +1177,11 @@ export class AdbProvider {
|
||||
);
|
||||
if (!directory.ok) return directory;
|
||||
const packageDirectory = join(directory.value, packageName);
|
||||
let packageDirectoryCreated = false;
|
||||
let packageDirectoryIdentity: PackageDirectoryIdentity | undefined;
|
||||
try {
|
||||
if (
|
||||
await stat(packageDirectory).then(
|
||||
await lstat(packageDirectory).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
@@ -1021,14 +1190,44 @@ export class AdbProvider {
|
||||
`The package output directory already exists: ${packageDirectory}`,
|
||||
);
|
||||
await mkdir(packageDirectory);
|
||||
packageDirectoryCreated = true;
|
||||
const created = await lstat(packageDirectory);
|
||||
if (!created.isDirectory() || created.isSymbolicLink())
|
||||
throw new Error(
|
||||
`The created package output is not a regular directory: ${packageDirectory}`,
|
||||
);
|
||||
packageDirectoryIdentity = {
|
||||
dev: created.dev,
|
||||
ino: created.ino,
|
||||
mode: created.mode,
|
||||
};
|
||||
} catch (cause) {
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
const primary = new AnalysisOutputError(
|
||||
operation,
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
if (!packageDirectoryCreated) return err(primary);
|
||||
const owner: PendingPackageDirectoryCleanup = {
|
||||
path: packageDirectory,
|
||||
identity: packageDirectoryIdentity,
|
||||
primary,
|
||||
remove: () =>
|
||||
removeOwnedPackageDirectory(
|
||||
packageDirectory,
|
||||
packageDirectoryIdentity,
|
||||
),
|
||||
};
|
||||
this.#pendingPackageDirectoryCleanup.add(owner);
|
||||
const cleanup = new ProviderCleanupError(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
[packageDirectory],
|
||||
{
|
||||
reason: "Could not establish an owned package directory identity",
|
||||
},
|
||||
{ operation, cause },
|
||||
);
|
||||
return err(analysisErrorWithCleanupFailure(primary, cleanup, operation));
|
||||
}
|
||||
const artifacts: {
|
||||
device_path: string;
|
||||
@@ -1044,6 +1243,37 @@ export class AdbProvider {
|
||||
stage: "resolve_paths" | "pull" | "digest";
|
||||
message: string;
|
||||
}[] = [];
|
||||
const cancelledPackagePull = (
|
||||
error: AnalysisCancelledError,
|
||||
localPath: string,
|
||||
devicePath: string,
|
||||
stage: "pull" | "digest",
|
||||
) => {
|
||||
error.retainPartialObservation({
|
||||
provider_id: "adb",
|
||||
operation,
|
||||
result: {
|
||||
client,
|
||||
serial,
|
||||
package_name: packageName,
|
||||
output_directory: directory.value,
|
||||
artifacts,
|
||||
failures: [
|
||||
...failures,
|
||||
{
|
||||
device_path: devicePath,
|
||||
stage,
|
||||
message:
|
||||
stage === "pull"
|
||||
? `Pull cancelled; any incomplete file is retained at ${localPath}. Remaining APKs were not attempted.`
|
||||
: `Digesting the pulled file was cancelled; the file is retained at ${localPath}. Remaining APKs were not attempted.`,
|
||||
},
|
||||
],
|
||||
coverage: "partial",
|
||||
},
|
||||
});
|
||||
return err(error);
|
||||
};
|
||||
const seenFileNames = new Set<string>();
|
||||
for (const entry of parsed.paths) {
|
||||
if (seenFileNames.has(entry.file_name)) {
|
||||
@@ -1065,7 +1295,13 @@ export class AdbProvider {
|
||||
signal,
|
||||
);
|
||||
if (!pulled.ok) {
|
||||
if (pulled.error instanceof AnalysisCancelledError) return pulled;
|
||||
if (pulled.error instanceof AnalysisCancelledError)
|
||||
return cancelledPackagePull(
|
||||
pulled.error,
|
||||
localPath,
|
||||
entry.device_path,
|
||||
"pull",
|
||||
);
|
||||
failures.push({
|
||||
device_path: entry.device_path,
|
||||
stage: "pull",
|
||||
@@ -1074,18 +1310,24 @@ export class AdbProvider {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const info = await stat(localPath);
|
||||
const sha256 = await sha256File(localPath);
|
||||
const digest = await hashAdbFile(localPath, operation, signal);
|
||||
artifacts.push({
|
||||
device_path: entry.device_path,
|
||||
file_name: entry.file_name,
|
||||
local_path: localPath,
|
||||
bytes: info.size,
|
||||
sha256,
|
||||
bytes: digest.bytes,
|
||||
sha256: digest.sha256,
|
||||
role: entry.role,
|
||||
role_basis: "file_name",
|
||||
});
|
||||
} catch {
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisCancelledError)
|
||||
return cancelledPackagePull(
|
||||
cause,
|
||||
localPath,
|
||||
entry.device_path,
|
||||
"digest",
|
||||
);
|
||||
failures.push({
|
||||
device_path: entry.device_path,
|
||||
stage: "digest",
|
||||
@@ -1094,15 +1336,37 @@ export class AdbProvider {
|
||||
}
|
||||
}
|
||||
if (artifacts.length === 0 && failures.length > 0) {
|
||||
await rm(packageDirectory, { recursive: true, force: true }).catch(
|
||||
() => undefined,
|
||||
const primary = new AnalysisProtocolError(
|
||||
`Every APK pull failed for ${packageName} on ${serial}: ${failures[0]!.message}`,
|
||||
);
|
||||
const owner: PendingPackageDirectoryCleanup = {
|
||||
path: packageDirectory,
|
||||
identity: packageDirectoryIdentity,
|
||||
primary,
|
||||
remove: () =>
|
||||
removeOwnedPackageDirectory(
|
||||
packageDirectory,
|
||||
packageDirectoryIdentity,
|
||||
),
|
||||
};
|
||||
try {
|
||||
await owner.remove();
|
||||
} catch (cause: unknown) {
|
||||
this.#pendingPackageDirectoryCleanup.add(owner);
|
||||
const cleanup = new ProviderCleanupError(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
[packageDirectory],
|
||||
{
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
{ operation: operation, cause },
|
||||
);
|
||||
return err(
|
||||
new AnalysisProtocolError(
|
||||
`Every APK pull failed for ${packageName} on ${serial}: ${failures[0]!.message}`,
|
||||
),
|
||||
analysisErrorWithCleanupFailure(primary, cleanup, operation),
|
||||
);
|
||||
}
|
||||
return err(primary);
|
||||
}
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
@@ -1156,8 +1420,7 @@ export class AdbProvider {
|
||||
);
|
||||
if (!pulled.ok) return pulled;
|
||||
try {
|
||||
const info = await stat(localPath);
|
||||
const sha256 = await sha256File(localPath);
|
||||
const digest = await hashAdbFile(localPath, operation, signal);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
@@ -1165,8 +1428,8 @@ export class AdbProvider {
|
||||
serial: input.serial,
|
||||
device_path: input.device_path,
|
||||
local_path: localPath,
|
||||
bytes: info.size,
|
||||
sha256,
|
||||
bytes: digest.bytes,
|
||||
sha256: digest.sha256,
|
||||
},
|
||||
provider,
|
||||
{
|
||||
@@ -1177,6 +1440,20 @@ export class AdbProvider {
|
||||
),
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCancelledError) {
|
||||
cause.retainPartialObservation({
|
||||
provider_id: "adb",
|
||||
operation,
|
||||
result: {
|
||||
client,
|
||||
serial: input.serial,
|
||||
local_path: localPath,
|
||||
device_path: input.device_path,
|
||||
digest_status: "unknown",
|
||||
},
|
||||
});
|
||||
return err(cause);
|
||||
}
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
@@ -1211,7 +1488,13 @@ export class AdbProvider {
|
||||
cause: new Error(`The local file does not exist: ${localPath}`),
|
||||
}),
|
||||
);
|
||||
const sha256 = await sha256File(localPath);
|
||||
let digest: Awaited<ReturnType<typeof hashAdbFile>>;
|
||||
try {
|
||||
digest = await hashAdbFile(localPath, operation, signal);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||
throw cause;
|
||||
}
|
||||
const existsProbe = await runAdb(
|
||||
operation,
|
||||
input.device_path,
|
||||
@@ -1257,7 +1540,7 @@ export class AdbProvider {
|
||||
);
|
||||
if (deviceDigest.ok) {
|
||||
const digestMatch = /^([a-f0-9]{64})\s/u.exec(deviceDigest.value.stdout);
|
||||
if (digestMatch !== null && digestMatch[1] !== sha256)
|
||||
if (digestMatch !== null && digestMatch[1] !== digest.sha256)
|
||||
return err(
|
||||
new AnalysisArtifactChangedError(
|
||||
operation,
|
||||
@@ -1272,8 +1555,8 @@ export class AdbProvider {
|
||||
serial: input.serial,
|
||||
local_path: localPath,
|
||||
device_path: input.device_path,
|
||||
bytes: info.size,
|
||||
sha256,
|
||||
bytes: digest.bytes,
|
||||
sha256: digest.sha256,
|
||||
device_sha256: digestMatch?.[1] ?? null,
|
||||
overwritten: deviceExists,
|
||||
device_digest_source: "device_sha256sum",
|
||||
@@ -1294,8 +1577,8 @@ export class AdbProvider {
|
||||
serial: input.serial,
|
||||
local_path: localPath,
|
||||
device_path: input.device_path,
|
||||
bytes: info.size,
|
||||
sha256,
|
||||
bytes: digest.bytes,
|
||||
sha256: digest.sha256,
|
||||
device_sha256: null,
|
||||
overwritten: deviceExists,
|
||||
device_digest_source: "unavailable",
|
||||
@@ -1334,7 +1617,13 @@ export class AdbProvider {
|
||||
cause: new Error(`The local APK does not exist: ${apkPath}`),
|
||||
}),
|
||||
);
|
||||
const sha256 = await sha256File(apkPath);
|
||||
let digest: Awaited<ReturnType<typeof hashAdbFile>>;
|
||||
try {
|
||||
digest = await hashAdbFile(apkPath, operation, signal);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||
throw cause;
|
||||
}
|
||||
const installed = await runAdb(
|
||||
operation,
|
||||
input.serial,
|
||||
@@ -1363,8 +1652,8 @@ export class AdbProvider {
|
||||
client,
|
||||
serial: input.serial,
|
||||
apk_path: apkPath,
|
||||
bytes: info.size,
|
||||
sha256,
|
||||
bytes: digest.bytes,
|
||||
sha256: digest.sha256,
|
||||
replaced: input.replace,
|
||||
},
|
||||
provider,
|
||||
@@ -1734,16 +2023,15 @@ export class AdbProvider {
|
||||
),
|
||||
);
|
||||
try {
|
||||
const info = await stat(reportedPath);
|
||||
const sha256 = await sha256File(reportedPath);
|
||||
const digest = await hashAdbFile(reportedPath, operation, signal);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
client,
|
||||
serial: input.serial,
|
||||
local_path: reportedPath,
|
||||
bytes: info.size,
|
||||
sha256,
|
||||
bytes: digest.bytes,
|
||||
sha256: digest.sha256,
|
||||
adb_reported_path: reportedPath,
|
||||
},
|
||||
provider,
|
||||
@@ -1755,6 +2043,20 @@ export class AdbProvider {
|
||||
),
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCancelledError) {
|
||||
cause.retainPartialObservation({
|
||||
provider_id: "adb",
|
||||
operation,
|
||||
result: {
|
||||
client,
|
||||
serial: input.serial,
|
||||
local_path: reportedPath,
|
||||
adb_reported_path: reportedPath,
|
||||
digest_status: "unknown",
|
||||
},
|
||||
});
|
||||
return err(cause);
|
||||
}
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
|
||||
@@ -1,40 +1,43 @@
|
||||
import { constants, createReadStream } from "node:fs";
|
||||
import { chmod, copyFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { ReadStream, Stats, WriteStream } from "node:fs";
|
||||
import { chmod, open, rm, stat, type FileHandle } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Transform, type TransformCallback } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
openRegularFile,
|
||||
sameRegularFileState,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
|
||||
|
||||
/** Stream a local file digest without retaining the APK or JAR in memory. */
|
||||
export const hashAndroidFile = async (path: string): Promise<string> => {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
};
|
||||
|
||||
/** Bind decompiler input to the admitted artifact, even if the original changes. */
|
||||
export const snapshotAndroidTarget = async (
|
||||
path: string,
|
||||
sha256: string,
|
||||
root: string,
|
||||
operation: AndroidOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> => {
|
||||
const snapshot = join(root, "target.apk");
|
||||
await copyFile(path, snapshot, constants.COPYFILE_EXCL);
|
||||
try {
|
||||
await copyAndroidSnapshot(path, snapshot, sha256, signal);
|
||||
signal?.throwIfAborted();
|
||||
await chmod(snapshot, 0o400);
|
||||
if ((await hashAndroidFile(snapshot)) !== sha256)
|
||||
throw new AnalysisInputError(operation, undefined, [
|
||||
return snapshot;
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof AndroidSnapshotChangedError)) throw cause;
|
||||
throw new AnalysisInputError(operation, { cause }, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"APK bytes changed after admission; retry against a stable file.",
|
||||
message: `APK bytes changed after admission at ${path}; retry against a stable file.`,
|
||||
},
|
||||
]);
|
||||
return snapshot;
|
||||
}
|
||||
};
|
||||
|
||||
/** Fingerprint the actual immutable engine bytes executed for this observation. */
|
||||
@@ -43,15 +46,120 @@ export const snapshotAndroidEngine = async (
|
||||
sha256: string,
|
||||
root: string,
|
||||
operation: AndroidOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ path: string; sha256: string }> => {
|
||||
const path = join(root, "engine.jar");
|
||||
await copyFile(source, path, constants.COPYFILE_EXCL);
|
||||
try {
|
||||
const observed = await copyAndroidSnapshot(source, path, sha256, signal);
|
||||
signal?.throwIfAborted();
|
||||
await chmod(path, 0o400);
|
||||
if ((await hashAndroidFile(path)) !== sha256)
|
||||
return { path, sha256: observed };
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof AndroidSnapshotChangedError)) throw cause;
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
operation,
|
||||
"REA_JADX_MCP_JAR bytes changed during admission; retry with a stable engine file.",
|
||||
`REA_JADX_MCP_JAR bytes changed during admission at ${source}; retry with a stable engine file.`,
|
||||
{ cause },
|
||||
);
|
||||
return { path, sha256 };
|
||||
}
|
||||
};
|
||||
|
||||
const copyAndroidSnapshot = async (
|
||||
sourcePath: string,
|
||||
snapshotPath: string,
|
||||
expectedSha256: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> => {
|
||||
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,
|
||||
_encoding: BufferEncoding,
|
||||
callback: TransformCallback,
|
||||
) {
|
||||
copiedBytes += chunk.length;
|
||||
if (copiedBytes > initial.size) {
|
||||
callback(
|
||||
new AndroidSnapshotChangedError(
|
||||
`Android input grew while being copied: ${sourcePath}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
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, { signal });
|
||||
signal?.throwIfAborted();
|
||||
await verifySourceState(source, sourcePath, initial, copiedBytes);
|
||||
const observedSha256 = hash.digest("hex");
|
||||
if (observedSha256 !== expectedSha256)
|
||||
throw new AndroidSnapshotChangedError(
|
||||
`Android snapshot digest mismatch for ${sourcePath}`,
|
||||
);
|
||||
return observedSha256;
|
||||
} catch (cause: unknown) {
|
||||
removeSnapshot = destination !== undefined;
|
||||
throw cause;
|
||||
}
|
||||
} finally {
|
||||
// Non-auto-closing streams retain a reference to their FileHandle even
|
||||
// after pipeline completion. Release those references before awaiting
|
||||
// descriptor closure, after the admitted source state has been verified.
|
||||
inputStream?.destroy();
|
||||
outputStream?.destroy();
|
||||
try {
|
||||
await Promise.all([source.close(), destination?.close()]);
|
||||
} finally {
|
||||
if (removeSnapshot)
|
||||
await rm(snapshotPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const verifySourceState = async (
|
||||
handle: Awaited<ReturnType<typeof openRegularFile>>,
|
||||
path: string,
|
||||
initial: Stats,
|
||||
observedBytes: number,
|
||||
): Promise<void> => {
|
||||
const [opened, currentPath] = await Promise.all([handle.stat(), stat(path)]);
|
||||
if (
|
||||
observedBytes !== initial.size ||
|
||||
!sameRegularFileState(initial, opened) ||
|
||||
!sameRegularFileState(initial, currentPath)
|
||||
)
|
||||
throw new AndroidSnapshotChangedError(
|
||||
`Android input changed while being read: ${path}`,
|
||||
);
|
||||
};
|
||||
|
||||
class AndroidSnapshotChangedError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,27 +81,73 @@ 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;
|
||||
throw projectConfigurationFailure(operation, cause);
|
||||
}
|
||||
};
|
||||
|
||||
/** 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);
|
||||
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;
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
return new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
operation,
|
||||
cause.message,
|
||||
{
|
||||
cause,
|
||||
userMessage: cause.message,
|
||||
...(typeof output.stdout === "string" &&
|
||||
typeof output.stderr === "string"
|
||||
...(typeof output.stdout === "string" && typeof output.stderr === "string"
|
||||
? {
|
||||
capturedOutput: {
|
||||
stdout: output.stdout,
|
||||
@@ -108,21 +158,6 @@ export const resolveJadxConfiguration = async (
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const readJadxConfiguration = async (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JadxConfiguration> => {
|
||||
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 };
|
||||
};
|
||||
|
||||
const requireEngineArchive = async (
|
||||
|
||||
+91
-21
@@ -21,12 +21,14 @@ import { err, ok, type Result } from "../domain/result.js";
|
||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||
import {
|
||||
snapshotAndroidEngine,
|
||||
hashAndroidFile,
|
||||
snapshotAndroidTarget,
|
||||
} from "./AndroidTargetSnapshot.js";
|
||||
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";
|
||||
|
||||
@@ -104,6 +106,50 @@ const executionError = (context: {
|
||||
});
|
||||
};
|
||||
|
||||
const androidTargetChanged = (
|
||||
operation: string,
|
||||
path: string,
|
||||
cause?: unknown,
|
||||
): AnalysisInputError =>
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
cause === undefined ? undefined : { cause },
|
||||
[
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message: `APK bytes changed after admission at ${path}; retry against a stable file.`,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const androidSourceChanged = (
|
||||
operation: string,
|
||||
source: string,
|
||||
cause?: unknown,
|
||||
): AnalysisCapabilityUnavailableError =>
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
operation,
|
||||
`Selected JADX input bytes changed during admission at ${source}; retry with a stable installation.`,
|
||||
cause === undefined ? undefined : { cause },
|
||||
);
|
||||
|
||||
const hashAndroidSource = async (
|
||||
path: string,
|
||||
operation: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
return (await hashStableFile(path, signal)).sha256;
|
||||
} catch (cause: unknown) {
|
||||
if (signal.aborted) throw cause;
|
||||
if (cause instanceof RegularFileChangedError)
|
||||
throw androidSourceChanged(operation, path, cause);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
interface PendingCleanup {
|
||||
session: JadxSession | undefined;
|
||||
root: PrivateRuntimeRoot | undefined;
|
||||
@@ -147,8 +193,7 @@ const cleanup = async (
|
||||
await root?.close();
|
||||
resources.root = undefined;
|
||||
} catch (cause) {
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
const error = new ProviderCleanupError(
|
||||
"jadx",
|
||||
[root?.path ?? "unknown"],
|
||||
{
|
||||
@@ -156,8 +201,10 @@ const cleanup = async (
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
{ cause: previousError ?? cause },
|
||||
),
|
||||
);
|
||||
if (previousError?.partialObservation !== undefined)
|
||||
error.retainPartialObservation(previousError.partialObservation);
|
||||
return err(error);
|
||||
}
|
||||
return ok(undefined);
|
||||
};
|
||||
@@ -287,23 +334,33 @@ 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,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
const jarHash = await hashAndroidFile(configuration.jar);
|
||||
const bridgeHash = await hashAndroidFile(BRIDGE_SOURCE);
|
||||
if ((await hashAndroidFile(target.path)) !== target.sha256)
|
||||
throw new AnalysisInputError(request.operation, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"APK bytes changed after admission; retry against a stable file.",
|
||||
},
|
||||
]);
|
||||
const jarHash = await hashAndroidSource(
|
||||
configuration.jar,
|
||||
request.operation,
|
||||
signal,
|
||||
);
|
||||
const bridgeHash = await hashAndroidSource(
|
||||
BRIDGE_SOURCE,
|
||||
request.operation,
|
||||
signal,
|
||||
);
|
||||
let targetHash: string;
|
||||
try {
|
||||
targetHash = (await hashStableFile(target.path, signal)).sha256;
|
||||
} catch (cause: unknown) {
|
||||
if (signal.aborted) throw cause;
|
||||
if (cause instanceof RegularFileChangedError)
|
||||
throw androidTargetChanged(request.operation, target.path, cause);
|
||||
throw cause;
|
||||
}
|
||||
if (targetHash !== target.sha256)
|
||||
throw androidTargetChanged(request.operation, target.path);
|
||||
const key = JSON.stringify({
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
@@ -326,27 +383,40 @@ 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);
|
||||
await chmod(bridge, 0o400);
|
||||
if ((await hashAndroidFile(bridge)) !== bridgeHash)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
const copiedBridgeHash = await hashAndroidSource(
|
||||
bridge,
|
||||
request.operation,
|
||||
"REA Android bridge bytes changed during admission; retry with a stable installation.",
|
||||
signal,
|
||||
);
|
||||
if (copiedBridgeHash !== bridgeHash)
|
||||
throw androidSourceChanged(
|
||||
request.operation,
|
||||
BRIDGE_SOURCE,
|
||||
undefined,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
session = new JadxSession(
|
||||
|
||||
@@ -157,7 +157,7 @@ const runApktool = async (
|
||||
timeoutMs: limits.timeoutMs,
|
||||
diagnosticBytes: limits.maxOutputBytes,
|
||||
},
|
||||
(signal === undefined ? {} : { signal }),
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return ok({ stdout: output.stdout.text, stderr: output.stderr.text });
|
||||
} catch (cause) {
|
||||
|
||||
@@ -373,7 +373,13 @@ const readExecutableMetadata = async (
|
||||
if (!abiFlags.ok) return abiFlags;
|
||||
return ok({
|
||||
...parsed.value,
|
||||
mips: { ...parsed.value.mips, abiFlags: abiFlags.value },
|
||||
mips: {
|
||||
...parsed.value.mips,
|
||||
abiFlags: abiFlags.value.abiFlags,
|
||||
...(abiFlags.value.limitations.length === 0
|
||||
? {}
|
||||
: { limitations: abiFlags.value.limitations }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
RegularFileCleanupFailure,
|
||||
readRegularFileText,
|
||||
} from "./RegularFileRead.js";
|
||||
import {
|
||||
effectiveClientServer,
|
||||
parseClientConfiguration,
|
||||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.js";
|
||||
|
||||
/** A named source failure that setup can explain without losing the bad path. */
|
||||
export interface ClientConfigurationFileError {
|
||||
readonly kind: "unreadable" | "malformed";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
readonly cleanupFailure?: RegularFileCleanupFailure;
|
||||
}
|
||||
|
||||
/** Preserve named read failures and distinguish a missing source from an empty file. */
|
||||
@@ -19,7 +24,7 @@ export const readConfigurationText = async (
|
||||
path: string,
|
||||
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
|
||||
try {
|
||||
return ok(await readFile(path, "utf8"));
|
||||
return ok(await readRegularFileText(path));
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||
return ok(undefined);
|
||||
@@ -27,6 +32,9 @@ export const readConfigurationText = async (
|
||||
kind: "unreadable",
|
||||
path,
|
||||
detail: `Configuration ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
...(cause instanceof RegularFileCleanupFailure
|
||||
? { cleanupFailure: cause }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -63,7 +71,16 @@ export const readClientConfigurationFiles = async (
|
||||
for (const path of client.configPaths ?? [client.configPath]) {
|
||||
const document = await readConfigurationFile(path, client.format);
|
||||
if (!document.ok) return document;
|
||||
if (document.value !== undefined) documents.push(document.value);
|
||||
if (document.value !== undefined) {
|
||||
const environment = clientRegistrationEnvironment(document.value);
|
||||
if (!environment.ok)
|
||||
return err({
|
||||
kind: "malformed",
|
||||
path,
|
||||
detail: `Configuration ${path} has an invalid REA server environment: ${environment.error.settings.map(({ setting, constraint }) => `${setting}: ${constraint}`).join("; ")}. Repair it before rerunning setup.`,
|
||||
});
|
||||
documents.push(document.value);
|
||||
}
|
||||
}
|
||||
return ok(documents);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { parseEnvironment } from "../config/environment.js";
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
effectiveClientServer,
|
||||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
|
||||
/** Read explicit REA overrides without dropping peers when one value is invalid. */
|
||||
export const clientRegistrationEnvironment = (
|
||||
parsed: ClientConfigurationDocument | undefined,
|
||||
overrides: Readonly<Record<string, string>> = {},
|
||||
): Result<Readonly<Record<string, string>>, ConfigurationError> => {
|
||||
const key =
|
||||
parsed?.dialect === "opencode" || parsed?.dialect === "opencode_v2"
|
||||
? "environment"
|
||||
: "env";
|
||||
const registration =
|
||||
parsed === undefined
|
||||
? undefined
|
||||
: effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey);
|
||||
const raw =
|
||||
typeof registration === "object" && registration !== null
|
||||
? Reflect.get(registration, key)
|
||||
: undefined;
|
||||
const existing = z
|
||||
.record(z.string(), z.string())
|
||||
.safeParse(raw === undefined ? {} : raw);
|
||||
if (!existing.success)
|
||||
return err(
|
||||
new ConfigurationError("Invalid REA server environment", {
|
||||
settings: existing.error.issues.map((issue) => ({
|
||||
setting: [key, ...issue.path].join("."),
|
||||
constraint:
|
||||
"Server environment must be an object containing only string values",
|
||||
})),
|
||||
}),
|
||||
);
|
||||
// Validate known settings with the runtime's parser, but retain the original
|
||||
// string overrides, including unknown client-specific keys and no defaults.
|
||||
const environment = { ...existing.data, ...overrides };
|
||||
const validated = parseEnvironment(environment);
|
||||
if (!validated.ok) return validated;
|
||||
return ok(
|
||||
Object.fromEntries(
|
||||
Object.entries(environment).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,7 @@ describe("doctor", () => {
|
||||
),
|
||||
).toEqual({
|
||||
node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
|
||||
host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
|
||||
target: "Supply a readable local app or program path.",
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@ const hostCheck = async (host: DoctorHost): Promise<DoctorCheck> => {
|
||||
: `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`);
|
||||
return check("host", supported, detail, {
|
||||
remediation:
|
||||
"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
classification: "unsupported_host",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { parseUtf8Json } from "./Utf8JsonInput.js";
|
||||
import { readRegularFile } from "./RegularFileRead.js";
|
||||
import { readJsonInputFile } from "./JsonInputFile.js";
|
||||
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
|
||||
|
||||
/** Request control and its owning operation for an interruptible atomic write. */
|
||||
@@ -37,12 +36,20 @@ export const readJsonFile = async (
|
||||
> => {
|
||||
const requestedPath = resolve(path);
|
||||
try {
|
||||
const encoded = await readRegularFile(requestedPath);
|
||||
const decoded = parseUtf8Json(encoded, "read_evidence_file", requestedPath);
|
||||
const decoded = await readJsonInputFile(
|
||||
requestedPath,
|
||||
"read_evidence_file",
|
||||
);
|
||||
if (!decoded.ok) {
|
||||
if (decoded.error instanceof AnalysisResourceConstraintError)
|
||||
return err(decoded.error);
|
||||
return err(
|
||||
new EvidenceFileError("read", "invalid-json", {
|
||||
cause: decoded.cause ?? new SyntaxError(decoded.error),
|
||||
cause:
|
||||
decoded.error.cause ??
|
||||
new SyntaxError(
|
||||
decoded.error.issues[0]?.message ?? decoded.error.message,
|
||||
),
|
||||
path: requestedPath,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -6,61 +6,76 @@ import { getHeapStatistics } from "node:v8";
|
||||
import parser from "stream-json/parser.js";
|
||||
import streamValues from "stream-json/streamers/stream-values.js";
|
||||
|
||||
import { withRegularFile } from "./application/RegularFileRead.js";
|
||||
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
|
||||
import { withRegularFile } from "./RegularFileRead.js";
|
||||
import {
|
||||
JSON_BYTE_ORDER_MARK_MESSAGE,
|
||||
JSON_INPUT_RESOURCE_REMEDIATION,
|
||||
parseUtf8Json,
|
||||
} from "./application/Utf8JsonInput.js";
|
||||
} from "./Utf8JsonInput.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "./domain/analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "./domain/result.js";
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
type JsonFileFailure = AnalysisInputError | AnalysisResourceConstraintError;
|
||||
type JsonInputFileFailure =
|
||||
| AnalysisInputError
|
||||
| AnalysisResourceConstraintError;
|
||||
const READ_CHUNK_BYTES = 64 * 1024;
|
||||
const PACKED_STRING_PART_CODE_UNITS = 64 * 1024;
|
||||
const PACKED_TOKEN_PART_CODE_UNITS = 64 * 1024;
|
||||
// Retain native-parser speed for modest inputs while bounding its extra copies.
|
||||
// This selects an implementation; larger files remain accepted through streaming.
|
||||
const NATIVE_PARSE_READ_BUDGET = 8 * 1024 * 1024;
|
||||
|
||||
/** Keep native parsing only when input copies and value expansion fit live headroom. */
|
||||
const nativeParseFits = (inputBytes: number): boolean =>
|
||||
inputBytes <= NATIVE_PARSE_READ_BUDGET &&
|
||||
// The assembler estimate reserves up to 128 bytes per two-byte container.
|
||||
// Allow that 64x expansion plus byte/text copies before native JSON.parse,
|
||||
// whose individual container allocations cannot be intercepted.
|
||||
inputBytes * 72 + READ_CHUNK_BYTES <=
|
||||
getHeapStatistics().total_available_size;
|
||||
|
||||
/** Parse strict UTF-8 JSON without requiring a whole-document string. */
|
||||
export const readCliJsonFile = (
|
||||
export const readJsonInputFile = (
|
||||
path: string,
|
||||
operation: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<unknown, JsonFileFailure>> =>
|
||||
): Promise<Result<unknown, JsonInputFileFailure>> =>
|
||||
withRegularFile(
|
||||
path,
|
||||
async (handle, stats) => {
|
||||
let found = false;
|
||||
let value: unknown;
|
||||
try {
|
||||
const prefix =
|
||||
stats.size <= NATIVE_PARSE_READ_BUDGET
|
||||
const prefix = nativeParseFits(stats.size)
|
||||
? await readPrefix(handle, stats.size + 1, signal)
|
||||
: undefined;
|
||||
if (prefix?.complete === true) {
|
||||
if (prefix !== undefined) {
|
||||
if (!prefix.complete || prefix.bytes.length !== stats.size)
|
||||
throw new RegularFileChangedError(path);
|
||||
const parsed = parseUtf8Json(
|
||||
prefix.bytes,
|
||||
operation,
|
||||
path,
|
||||
"cli-json-input",
|
||||
"json-file-input",
|
||||
);
|
||||
return parsed.ok
|
||||
? ok(parsed.value)
|
||||
: err(invalidJson(operation, parsed.error, parsed.cause));
|
||||
}
|
||||
await pipeline(
|
||||
decodedChunks(handle, signal, prefix?.bytes),
|
||||
decodedChunks(handle, stats.size, path, signal),
|
||||
parser.asStream({
|
||||
jsonStreaming: false,
|
||||
streamValues: false,
|
||||
packKeys: false,
|
||||
packStrings: false,
|
||||
packNumbers: false,
|
||||
}),
|
||||
packJsonStrings(operation, stats.size),
|
||||
packJsonScalars(operation, stats.size, path),
|
||||
admitJsonAssembly(operation, stats.size, path),
|
||||
streamValues.asStream(),
|
||||
async (rows: AsyncIterable<unknown>) => {
|
||||
for await (const row of rows) {
|
||||
@@ -107,8 +122,9 @@ export const readCliJsonFile = (
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
"An individual JSON string exceeds the runtime string limit",
|
||||
"An individual JSON string or number token exceeds the runtime string limit",
|
||||
{
|
||||
input_path: path,
|
||||
input_file_bytes: stats.size,
|
||||
max_string_code_units: constants.MAX_STRING_LENGTH,
|
||||
},
|
||||
@@ -121,12 +137,16 @@ export const readCliJsonFile = (
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
signal,
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const packJsonStrings = (operation: string, inputFileBytes: number) =>
|
||||
const packJsonScalars = (
|
||||
operation: string,
|
||||
inputFileBytes: number,
|
||||
path: string,
|
||||
) =>
|
||||
async function* (tokens: AsyncIterable<unknown>): AsyncGenerator<unknown> {
|
||||
let kind: "keyValue" | "stringValue" | undefined;
|
||||
let kind: "keyValue" | "stringValue" | "numberValue" | undefined;
|
||||
let length = 0;
|
||||
let value = "";
|
||||
let parts: string[] = [];
|
||||
@@ -139,23 +159,32 @@ const packJsonStrings = (operation: string, inputFileBytes: number) =>
|
||||
case "startString":
|
||||
kind = token.name === "startKey" ? "keyValue" : "stringValue";
|
||||
break;
|
||||
case "startNumber":
|
||||
kind = "numberValue";
|
||||
break;
|
||||
case "numberChunk":
|
||||
case "stringChunk":
|
||||
if (
|
||||
kind === undefined ||
|
||||
!("value" in token) ||
|
||||
typeof token.value !== "string"
|
||||
)
|
||||
throw new Error("Unexpected JSON string fragment");
|
||||
throw new Error("Unexpected JSON scalar fragment");
|
||||
length += token.value.length;
|
||||
if (length > constants.MAX_STRING_LENGTH)
|
||||
throw new RangeError("Invalid string length");
|
||||
parts.push(token.value);
|
||||
partLength += token.value.length;
|
||||
if (partLength >= PACKED_STRING_PART_CODE_UNITS) {
|
||||
if (partLength >= PACKED_TOKEN_PART_CODE_UNITS) {
|
||||
// The tokenizer emits short fragments. Coalesce them before retaining
|
||||
// a string so millions of substring/rope nodes cannot exhaust the heap
|
||||
// a scalar so millions of substring/rope nodes cannot exhaust the heap
|
||||
// before the native string-length constraint can be reported.
|
||||
requireStringAssemblyHeadroom(operation, inputFileBytes, length);
|
||||
requireStringAssemblyHeadroom(
|
||||
operation,
|
||||
inputFileBytes,
|
||||
path,
|
||||
length,
|
||||
);
|
||||
value += parts.join("");
|
||||
parts = [];
|
||||
partLength = 0;
|
||||
@@ -163,8 +192,14 @@ const packJsonStrings = (operation: string, inputFileBytes: number) =>
|
||||
break;
|
||||
case "endKey":
|
||||
case "endString":
|
||||
if (kind === undefined) throw new Error("Unexpected JSON string end");
|
||||
requireStringAssemblyHeadroom(operation, inputFileBytes, length);
|
||||
case "endNumber":
|
||||
if (kind === undefined) throw new Error("Unexpected JSON scalar end");
|
||||
requireStringAssemblyHeadroom(
|
||||
operation,
|
||||
inputFileBytes,
|
||||
path,
|
||||
length,
|
||||
);
|
||||
yield { name: kind, value: value + parts.join("") };
|
||||
kind = undefined;
|
||||
length = 0;
|
||||
@@ -178,22 +213,78 @@ const packJsonStrings = (operation: string, inputFileBytes: number) =>
|
||||
}
|
||||
};
|
||||
|
||||
/** Admit the retained value graph before the upstream assembler grows it. */
|
||||
const admitJsonAssembly = (
|
||||
operation: string,
|
||||
inputFileBytes: number,
|
||||
path: string,
|
||||
) =>
|
||||
async function* (tokens: AsyncIterable<unknown>): AsyncGenerator<unknown> {
|
||||
let projectedBytes = 0;
|
||||
let nextCheck = 0;
|
||||
for await (const token of tokens) {
|
||||
if (typeof token !== "object" || token === null || !("name" in token))
|
||||
throw new Error("Unexpected JSON assembly token");
|
||||
if (token.name === "startObject" || token.name === "startArray") {
|
||||
projectedBytes += 128;
|
||||
} else if (token.name === "keyValue" || token.name === "stringValue") {
|
||||
if (!("value" in token) || typeof token.value !== "string")
|
||||
throw new Error("Unexpected packed JSON string");
|
||||
projectedBytes += 64 + token.value.length * 2;
|
||||
} else if (
|
||||
token.name === "numberValue" ||
|
||||
token.name === "nullValue" ||
|
||||
token.name === "trueValue" ||
|
||||
token.name === "falseValue"
|
||||
) {
|
||||
projectedBytes += 64;
|
||||
}
|
||||
if (projectedBytes >= nextCheck) {
|
||||
const heap = getHeapStatistics();
|
||||
// Conservative capacity for containers, property/array slots and UTF-16
|
||||
// values. Keep another whole projection available for container growth,
|
||||
// plus the next chunk of estimates between samples. This is admission
|
||||
// against live runtime headroom, not a fixed document-size policy.
|
||||
const requiredBytes = projectedBytes + READ_CHUNK_BYTES;
|
||||
if (requiredBytes > heap.total_available_size)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
"Insufficient heap headroom to materialize the complete JSON value",
|
||||
{
|
||||
input_path: path,
|
||||
input_file_bytes: inputFileBytes,
|
||||
projected_value_bytes: projectedBytes,
|
||||
assembly_headroom_bytes: requiredBytes,
|
||||
available_heap_bytes: heap.total_available_size,
|
||||
heap_size_limit_bytes: heap.heap_size_limit,
|
||||
},
|
||||
{ remediationAction: JSON_INPUT_RESOURCE_REMEDIATION },
|
||||
);
|
||||
nextCheck = projectedBytes + READ_CHUNK_BYTES;
|
||||
}
|
||||
yield token;
|
||||
}
|
||||
};
|
||||
|
||||
const requireStringAssemblyHeadroom = (
|
||||
operation: string,
|
||||
inputFileBytes: number,
|
||||
path: string,
|
||||
stringCodeUnits: number,
|
||||
): void => {
|
||||
if (stringCodeUnits < PACKED_STRING_PART_CODE_UNITS) return;
|
||||
if (stringCodeUnits < PACKED_TOKEN_PART_CODE_UNITS) return;
|
||||
const heap = getHeapStatistics();
|
||||
// Concatenation or key interning may flatten the retained rope. Reserve its
|
||||
// UTF-16 representation and the next coalesced part before that allocation.
|
||||
const requiredBytes = 2 * (stringCodeUnits + PACKED_STRING_PART_CODE_UNITS);
|
||||
const requiredBytes = 2 * (stringCodeUnits + PACKED_TOKEN_PART_CODE_UNITS);
|
||||
if (requiredBytes > heap.total_available_size)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
"Insufficient heap headroom to assemble a complete JSON string",
|
||||
{
|
||||
input_path: path,
|
||||
input_file_bytes: inputFileBytes,
|
||||
string_code_units: stringCodeUnits,
|
||||
string_assembly_headroom_bytes: requiredBytes,
|
||||
@@ -208,8 +299,9 @@ const requireStringAssemblyHeadroom = (
|
||||
|
||||
async function* decodedChunks(
|
||||
handle: FileHandle,
|
||||
admittedBytes: number,
|
||||
path: string,
|
||||
signal: AbortSignal | undefined,
|
||||
prefix?: Uint8Array,
|
||||
): AsyncGenerator<string> {
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||
let leading = true;
|
||||
@@ -220,25 +312,25 @@ async function* decodedChunks(
|
||||
}
|
||||
return text;
|
||||
};
|
||||
if (prefix !== undefined)
|
||||
for (let offset = 0; offset < prefix.length; offset += READ_CHUNK_BYTES) {
|
||||
signal?.throwIfAborted();
|
||||
yield checked(
|
||||
decoder.decode(prefix.subarray(offset, offset + READ_CHUNK_BYTES), {
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const bytes = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
||||
let observedBytes = 0;
|
||||
while (true) {
|
||||
signal?.throwIfAborted();
|
||||
const read = await handle.read(bytes, 0, bytes.length, null);
|
||||
const read = await handle.read(
|
||||
bytes,
|
||||
0,
|
||||
Math.min(bytes.length, admittedBytes - observedBytes + 1),
|
||||
null,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (read.bytesRead === 0) break;
|
||||
observedBytes += read.bytesRead;
|
||||
if (observedBytes > admittedBytes) throw new RegularFileChangedError(path);
|
||||
yield checked(
|
||||
decoder.decode(bytes.subarray(0, read.bytesRead), { stream: true }),
|
||||
);
|
||||
}
|
||||
if (observedBytes !== admittedBytes) throw new RegularFileChangedError(path);
|
||||
yield checked(decoder.decode());
|
||||
}
|
||||
|
||||
@@ -264,8 +356,7 @@ const readPrefix = async (
|
||||
return { bytes: bytes.subarray(0, offset), complete: true };
|
||||
offset += read.bytesRead;
|
||||
}
|
||||
// The file grew after admission. Continue from this same handle, preserving
|
||||
// the prefix already read rather than reopening or dropping its bytes.
|
||||
// One byte beyond the admitted extent proves the selected file changed.
|
||||
return { bytes, complete: false };
|
||||
};
|
||||
|
||||
@@ -60,12 +60,16 @@ describe("Linux Hopper host classification", () => {
|
||||
['ID=nobara\nVERSION_ID="44"\nID_LIKE="rhel centos fedora"\n', "rpm"],
|
||||
["ID=arch\n", "arch"],
|
||||
["ID=cachyos\nID_LIKE=arch\n", "arch"],
|
||||
] as const)("accepts an official Hopper distribution", (document, family) => {
|
||||
['ID=omarchy\nID_LIKE=arch\nVERSION_ID="4.0.4"\n', "arch"],
|
||||
] as const)(
|
||||
"accepts an explicitly supported distribution",
|
||||
(document, family) => {
|
||||
expect(parseLinuxDistribution(document)).toMatchObject({
|
||||
packageFamily: family,
|
||||
supported: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("detects unresolved or loader-reported shared libraries", () => {
|
||||
expect(
|
||||
@@ -93,6 +97,8 @@ describe("Linux Hopper host classification", () => {
|
||||
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
|
||||
"ID=manjaro\nID_LIKE=arch\n",
|
||||
"ID=garuda\nID_LIKE=arch\n",
|
||||
"ID=omarchy\n",
|
||||
"ID=omarchy\nID_LIKE=debian\n",
|
||||
])("rejects unsupported vendor/version combinations", (document) => {
|
||||
expect(parseLinuxDistribution(document).supported).toBe(false);
|
||||
});
|
||||
|
||||
@@ -154,6 +154,7 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => {
|
||||
packageFamily !== undefined &&
|
||||
(id === "arch" ||
|
||||
id === "cachyos" ||
|
||||
(id === "omarchy" && packageFamily === "arch") ||
|
||||
(id === "ubuntu" && versionAtLeast(versionId, 24)) ||
|
||||
(id === "fedora" && versionAtLeast(versionId, 41)) ||
|
||||
(id === "nobara" && versionAtLeast(versionId, 44)));
|
||||
|
||||
@@ -5,6 +5,12 @@ import type {
|
||||
} from "../domain/binaryTargetTypes.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Observed ABI declarations and coverage of their source tables. */
|
||||
export interface MipsAbiFlagsInspection {
|
||||
readonly abiFlags: MipsAbiFlags | null;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read ELF32 MIPS ABI declarations from the caller's already-open file.
|
||||
* Inspect both typed tables (not section names), including records beyond the
|
||||
@@ -15,7 +21,7 @@ export const readMipsElfAbiFlags = async (
|
||||
handle: FileHandle,
|
||||
metadata: MipsElfMetadata,
|
||||
checkCancelled: () => void,
|
||||
): Promise<Result<MipsAbiFlags | null, string>> => {
|
||||
): Promise<Result<MipsAbiFlagsInspection, string>> => {
|
||||
checkCancelled();
|
||||
if (metadata.elfClass !== 32)
|
||||
return err("MIPS ABI record inspection currently supports ELF32 only");
|
||||
@@ -81,6 +87,7 @@ export const readMipsElfAbiFlags = async (
|
||||
dataOffset: 4,
|
||||
sizeOffset: 16,
|
||||
recordType: 0x70000003, // PT_MIPS_ABIFLAGS
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
label: "section header table",
|
||||
@@ -92,6 +99,7 @@ export const readMipsElfAbiFlags = async (
|
||||
dataOffset: 16,
|
||||
sizeOffset: 20,
|
||||
recordType: 0x7000002a, // SHT_MIPS_ABIFLAGS
|
||||
optional: true,
|
||||
},
|
||||
];
|
||||
// Extended numbering needs a separate section-zero reader. Refuse it rather
|
||||
@@ -99,6 +107,7 @@ export const readMipsElfAbiFlags = async (
|
||||
if (u16(bytes, 44) === 0xffff || u16(bytes, 48) >= 0xff00)
|
||||
return err("extended or reserved MIPS ELF table numbering is unsupported");
|
||||
let record: Buffer | undefined;
|
||||
const limitations: string[] = [];
|
||||
for (const table of tables) {
|
||||
if (table.count === 0) {
|
||||
if (table.offset !== 0)
|
||||
@@ -107,16 +116,27 @@ export const readMipsElfAbiFlags = async (
|
||||
}
|
||||
if (table.offset < 52 || table.stride !== table.entrySize)
|
||||
return err(`invalid MIPS ${table.label} offset or entry size`);
|
||||
const count = table.optional
|
||||
? Math.min(
|
||||
table.count,
|
||||
Math.max(0, Math.floor((fileSize - table.offset) / table.entrySize)),
|
||||
)
|
||||
: table.count;
|
||||
if (count < table.count)
|
||||
limitations.push(
|
||||
`MIPS ${table.label} at file offset ${table.offset} extends beyond the captured file: examined ${count} of ${table.count} complete headers. ABI declarations in unavailable headers remain unknown.`,
|
||||
);
|
||||
if (count === 0) continue;
|
||||
// Counts are bounded by the ELF16 fields and strides by their exact ELF32
|
||||
// structure sizes. Never allocate according to an untrusted section size.
|
||||
const contents = await read(
|
||||
table.offset,
|
||||
table.count * table.entrySize,
|
||||
count * table.entrySize,
|
||||
table.label,
|
||||
);
|
||||
if (!contents.ok) return contents;
|
||||
let found = false;
|
||||
for (let i = 0; i < table.count; i += 1) {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
checkCancelled();
|
||||
const position = i * table.entrySize;
|
||||
if (u32(contents.value, position + table.typeOffset) !== table.recordType)
|
||||
@@ -134,10 +154,10 @@ export const readMipsElfAbiFlags = async (
|
||||
record = candidate.value;
|
||||
}
|
||||
}
|
||||
if (record === undefined) return ok(null);
|
||||
if (record === undefined) return ok({ abiFlags: null, limitations });
|
||||
// Preserve declarations verbatim, including unknown versions/values. Provider
|
||||
// admission, not parsing, decides which interpretations have been verified.
|
||||
return ok({
|
||||
const abiFlags: MipsAbiFlags = {
|
||||
version: u16(record, 0),
|
||||
isaLevel: record.readUInt8(2),
|
||||
isaRevision: record.readUInt8(3),
|
||||
@@ -149,5 +169,6 @@ export const readMipsElfAbiFlags = async (
|
||||
ases: u32(record, 12),
|
||||
flags1: u32(record, 16),
|
||||
flags2: u32(record, 20),
|
||||
});
|
||||
};
|
||||
return ok({ abiFlags, limitations });
|
||||
};
|
||||
|
||||
@@ -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,13 +114,20 @@ 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,
|
||||
@@ -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.`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user