Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ff226e98e | ||
|
|
e5bc4e85ec | ||
|
|
38221cb12e | ||
|
|
9f30b01387 | ||
|
|
707797d80f | ||
|
|
3749ccec47 | ||
|
|
a67dbf6a48 | ||
|
|
bd3a9bbbaf | ||
|
|
74050978be | ||
|
|
b212e92f9b | ||
|
|
990e1ad8c1 | ||
|
|
76d8f68cb9 | ||
|
|
fe574c0c9f | ||
|
|
3a7a3074e3 | ||
|
|
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 | ||
|
|
6d8f95e38d | ||
|
|
c1b5ccf0a4 | ||
|
|
5f30ea6ff2 | ||
|
|
d438675677 | ||
|
|
a84dea5500 | ||
|
|
2a66ec9094 | ||
|
|
d1235fadc9 | ||
|
|
a01148553e | ||
|
|
a6f76f5da9 | ||
|
|
2ca3ad2386 | ||
|
|
5d0a883605 | ||
|
|
0f0d6e4d43 | ||
|
|
6d4cefc4f7 | ||
|
|
2a0b58e440 |
@@ -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
|
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.
|
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:
|
metadata:
|
||||||
version: "36"
|
version: "37"
|
||||||
---
|
---
|
||||||
|
|
||||||
# REA
|
# 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
|
an older npm release. Keep complete inline Evidence when that server does not
|
||||||
advertise retained references.
|
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
|
Prefer an existing `rea` CLI. When none is installed and REA package acquisition
|
||||||
`npx -y rea-agents@latest doctor --client codex --json`. Substitute the current
|
is within the authorized scope, use `npx -y rea-agents@latest` in its place.
|
||||||
supported client: `claude_code`, `claude_desktop`, `codex`, `cursor`,
|
Configuration repair starts with a read-only setup plan; show paths, backups and
|
||||||
`gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`,
|
skill replacement before applying authorized changes. Preserve existing approval
|
||||||
`commandcode`, `qwen_code`, `vscode`, `grok_build`, `omp`, `pi`, `hermes`, or `grok_bot`. `grok_bot` has no local
|
and target-execution boundaries. REA setup never installs or upgrades unrelated
|
||||||
file registration: doctor reports the Grok Bot chat step, and setup does not
|
software, including Node.js, npm, Homebrew, Java, Ghidra, IDA, JADX, Binwalk or Unblob.
|
||||||
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.
|
|
||||||
|
|
||||||
## Route the target first
|
## 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
|
when composing broader workflows. Modern direct callers and unsupported dossier
|
||||||
facets remain unknown; live IDA results are not replayed from snapshots.
|
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
|
If the user says only "my app" without a name, path, or target already selected
|
||||||
name to one clear installed artifact when possible; ask only when matches are
|
for this task, ask which app before searching for artifacts. Paths in unrelated
|
||||||
ambiguous. Never choose an example app on the user's behalf.
|
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
|
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
|
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
|
call `analyze_javascript_application` or `inspect_binary_layout` with
|
||||||
`"detail": "summary"`, then inspect the retained `parent_evidence_id` with
|
`"detail": "summary"`, then inspect the retained `parent_evidence_id` with
|
||||||
`inspect_analysis_view`.
|
`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.
|
returned result leaves a specific question unanswered.
|
||||||
|
|
||||||
If MCP reports `resource_constraint` with `details.resource: "transport"`, call
|
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
|
repeating it merely to request the same oversized response. Complete CLI JSON
|
||||||
output also streams without a single MCP frame.
|
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
|
Every conclusion must distinguish observations, inferences, and unknowns. Cite
|
||||||
Evidence IDs, preserve limitations and incomplete coverage, and never imply
|
Evidence IDs, preserve limitations and incomplete coverage, and never imply
|
||||||
that static analysis observed execution. Runtime requests execute the declared
|
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
|
Within the user's requested investigation, call available analysis tools
|
||||||
directly. REA does not require permission grants or per-call approval flags.
|
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
|
## 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
|
Process captures are opt-in behavioral evidence, not a security sandbox. V3
|
||||||
captures cannot be upgraded to V4; rerun the original scenario. Distinguish root
|
captures cannot be upgraded to V4; rerun the original scenario. Distinguish root
|
||||||
exit from descendant settlement and require freshness when the task needs it.
|
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
|
# JavaScript and Electron application artifacts
|
||||||
|
|
||||||
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
|
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
|
||||||
extracted tree. The complete result, graph, and Evidence context are returned
|
extracted tree. When size is unknown or large and the connected schema advertises
|
||||||
inline when they fit the MCP frame. Source-map contents are part of the static analysis. Start from its
|
`detail`, request `detail: "summary"`. This returns an `inspect_analysis_view`
|
||||||
findings, coverage, unknowns, and graph context, then make a focused follow-up
|
Evidence record with a distinct ID and a `normalized_result.parent_evidence_id`
|
||||||
only when a specific question remains unanswered.
|
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
|
An oversized response reports `resource_constraint` and
|
||||||
`details.resource: "transport"`. Reuse
|
`details.resource: "transport"`. Reuse
|
||||||
`details.reported_limits.evidence_reference` with `inspect_analysis_view` for a
|
`details.reported_limits.evidence_reference` with `inspect_analysis_view` for a
|
||||||
summary, one module, or a stable page of module identities. Use
|
summary, one module, or a stable page of module identities.
|
||||||
`trace_application_feature` once a module, route, or string seed is known.
|
|
||||||
`export_evidence_bundle` writes the complete canonical session to a
|
`export_evidence_bundle` writes the complete canonical session to a
|
||||||
caller-selected file. These workflows keep the original Evidence and coverage;
|
caller-selected file. These workflows keep the original Evidence and coverage;
|
||||||
a broad follow-up can also exceed framing.
|
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
|
BrowserWindow preferences, preload and contextBridge surfaces, IPC
|
||||||
registrations, utility processes, and native binding requests are static syntax
|
registrations, utility processes, and native binding requests are static syntax
|
||||||
observations. Only a unique exact literal IPC channel match supports an inferred
|
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,
|
member is not a verified native export. Never claim runtime reachability,
|
||||||
registration, defaults, or policy enforcement from static analysis.
|
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
|
For version comparison, analyze each version once, then call
|
||||||
`compare_application_versions`. Accept only its digest, source-map, structural
|
`compare_application_versions`. Accept only its digest, source-map, structural
|
||||||
fingerprint, or non-module semantic matches. Module ordinals and minified names
|
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
|
### 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
|
## 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]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, "release/*"]
|
branches: [main, "release/*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||||
@@ -36,37 +40,39 @@ jobs:
|
|||||||
name: Classify changes
|
name: Classify changes
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
code: ${{ steps.package.outputs.required }}
|
code: ${{ steps.plan.outputs.code }}
|
||||||
package: ${{ steps.package.outputs.required }}
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Select package and Windows lanes
|
- uses: actions/setup-node@v4
|
||||||
id: package
|
with:
|
||||||
shell: bash
|
node-version-file: .nvmrc
|
||||||
|
- name: Select verification lanes
|
||||||
|
id: plan
|
||||||
env:
|
env:
|
||||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
run: |
|
CI_FULL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }}
|
||||||
if [[ "${GITHUB_EVENT_NAME}" != "pull_request" ]]; then
|
run: node scripts/ci/plan.mjs
|
||||||
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
|
|
||||||
|
|
||||||
static:
|
static:
|
||||||
name: Static checks
|
name: Static checks
|
||||||
@@ -84,17 +90,17 @@ jobs:
|
|||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
- if: needs.changes.outputs.code == 'true'
|
||||||
run: npm run check:ci
|
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
|
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
|
run: npm run format:check
|
||||||
|
|
||||||
test-shard:
|
test-shard:
|
||||||
name: Test shard (${{ matrix.shard }})
|
name: Test shard (${{ matrix.shard }})
|
||||||
needs: [changes, build]
|
needs: [changes, build]
|
||||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -131,7 +137,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build package
|
name: Build package
|
||||||
needs: changes
|
needs: changes
|
||||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -224,7 +230,7 @@ jobs:
|
|||||||
conformance-fixtures:
|
conformance-fixtures:
|
||||||
name: Source-built conformance fixtures
|
name: Source-built conformance fixtures
|
||||||
needs: changes
|
needs: changes
|
||||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.fixtures == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -235,7 +241,7 @@ jobs:
|
|||||||
windows-native-build:
|
windows-native-build:
|
||||||
name: Build bundled Windows x64 native controls
|
name: Build bundled Windows x64 native controls
|
||||||
needs: changes
|
needs: changes
|
||||||
if: github.event_name == 'push' || needs.changes.outputs.package == 'true'
|
if: needs.changes.outputs.runtime == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -265,24 +271,11 @@ jobs:
|
|||||||
package-e2e:
|
package-e2e:
|
||||||
name: Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }})
|
name: Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }})
|
||||||
needs: [changes, windows-native-build]
|
needs: [changes, windows-native-build]
|
||||||
if: github.event_name == 'push' || needs.changes.outputs.package == 'true'
|
if: needs.changes.outputs.runtime == 'true'
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 2
|
max-parallel: 2
|
||||||
matrix:
|
matrix: ${{ fromJSON(needs.changes.outputs.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
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
env:
|
env:
|
||||||
@@ -317,7 +310,7 @@ jobs:
|
|||||||
apple-artifacts:
|
apple-artifacts:
|
||||||
name: Real Apple artifact CLI/MCP E2E
|
name: Real Apple artifact CLI/MCP E2E
|
||||||
needs: changes
|
needs: changes
|
||||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.apple == 'true'
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
@@ -350,7 +343,7 @@ jobs:
|
|||||||
reconstruction-readiness:
|
reconstruction-readiness:
|
||||||
name: Reconstruction readiness
|
name: Reconstruction readiness
|
||||||
needs: changes
|
needs: changes
|
||||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.readiness == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
@@ -379,7 +372,7 @@ jobs:
|
|||||||
windows-curated:
|
windows-curated:
|
||||||
name: Windows capability and package lane
|
name: Windows capability and package lane
|
||||||
needs: [changes, windows-native-build]
|
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
|
runs-on: windows-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
@@ -476,7 +469,8 @@ jobs:
|
|||||||
|
|
||||||
docs:
|
docs:
|
||||||
name: Docs Generation
|
name: Docs Generation
|
||||||
if: github.event_name == 'pull_request'
|
needs: changes
|
||||||
|
if: needs.changes.outputs.docs == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -496,3 +490,104 @@ jobs:
|
|||||||
docs/verification/managed-conformance-*.json
|
docs/verification/managed-conformance-*.json
|
||||||
skills/reverse-engineer-anything
|
skills/reverse-engineer-anything
|
||||||
if-no-files-found: error
|
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:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
pull_request:
|
workflow_call:
|
||||||
paths:
|
|
||||||
- "bridge/ghidra/extensions/nativeaot/**"
|
|
||||||
- "scripts/generate-nativeaot-fixtures.mjs"
|
|
||||||
- "tests/fixtures/nativeaot/**"
|
|
||||||
- ".github/workflows/nativeaot-fixtures.yml"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -7,36 +7,7 @@ on:
|
|||||||
description: "Also verify the complete 65,281-section result over MCP (slow)"
|
description: "Also verify the complete 65,281-section result over MCP (slow)"
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
pull_request:
|
workflow_call:
|
||||||
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"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -1,40 +1,7 @@
|
|||||||
name: Real browser verification
|
name: Real browser verification
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
workflow_call:
|
||||||
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_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|||||||
@@ -2,33 +2,7 @@ name: Real offline EVM interface
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
pull_request:
|
workflow_call:
|
||||||
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"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -1,32 +1,7 @@
|
|||||||
name: Real JavaScript recovery
|
name: Real JavaScript recovery
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
workflow_call:
|
||||||
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_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -2,34 +2,7 @@ name: Real recorded crash evidence
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
pull_request:
|
workflow_call:
|
||||||
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"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
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:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
pull_request:
|
workflow_call:
|
||||||
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"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -1,34 +1,6 @@
|
|||||||
name: Real website runtime attribution
|
name: Real website runtime attribution
|
||||||
on:
|
on:
|
||||||
pull_request:
|
workflow_call:
|
||||||
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_dispatch:
|
workflow_dispatch:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -1,27 +1,6 @@
|
|||||||
name: Real website source-map verification
|
name: Real website source-map verification
|
||||||
on:
|
on:
|
||||||
pull_request:
|
workflow_call:
|
||||||
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_dispatch:
|
workflow_dispatch:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -35,7 +35,21 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
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
|
- uses: googleapis/release-please-action@v4
|
||||||
|
if: steps.editorial.outputs.frozen != 'true'
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
target-branch: main
|
target-branch: main
|
||||||
@@ -80,6 +94,16 @@ jobs:
|
|||||||
node scripts/verify-release-checkpoint.mjs \
|
node scripts/verify-release-checkpoint.mjs \
|
||||||
--phase publish --release-branch main --source-sha "$SOURCE_SHA"
|
--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
|
- uses: googleapis/release-please-action@v4
|
||||||
id: release
|
id: release
|
||||||
with:
|
with:
|
||||||
@@ -192,6 +216,16 @@ jobs:
|
|||||||
--phase "$RELEASE_PHASE" --release-branch "$RELEASE_BRANCH"
|
--phase "$RELEASE_PHASE" --release-branch "$RELEASE_BRANCH"
|
||||||
--source-sha "$SOURCE_SHA"
|
--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
|
- uses: googleapis/release-please-action@v4
|
||||||
id: release
|
id: release
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
name: Website checks
|
name: Website checks
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
workflow_call:
|
||||||
branches: [main, "release/*"]
|
|
||||||
paths:
|
|
||||||
- "website/**"
|
|
||||||
- "scripts/*website*.py"
|
|
||||||
- ".github/workflows/**"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
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.",
|
"_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.5,
|
"threshold": 2.6,
|
||||||
"reporters": ["consoleFull"],
|
"reporters": ["consoleFull"],
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
|
|||||||
@@ -148,10 +148,15 @@ Generated fixtures and manifests remain ignored.
|
|||||||
Use the [release guide](docs/releasing.md). Main pushes automatically refresh a
|
Use the [release guide](docs/releasing.md). Main pushes automatically refresh a
|
||||||
Release Please PR with the next version and changelog. Review its migration
|
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.
|
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
|
The Release workflow validates and tags that merge; both publishers build its
|
||||||
exact SHA. Ordinary main pushes cannot publish packages. An optional manual
|
exact SHA. Ordinary main pushes cannot publish packages. An optional manual
|
||||||
`release/VERSION` checkpoint retains explicit preparation and publication.
|
`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
|
Keep new implementation commits on main for the next release. The workflow
|
||||||
owns packaged-artifact verification, npm publication, the published CLI/MCP
|
owns packaged-artifact verification, npm publication, the published CLI/MCP
|
||||||
canary, and MCP Registry publication. Sync manual checkpoint metadata back to
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+591
-202
File diff suppressed because it is too large
Load Diff
@@ -3,20 +3,25 @@ import ghidra.app.script.GhidraScript;
|
|||||||
import ghidra.program.model.address.Address;
|
import ghidra.program.model.address.Address;
|
||||||
import ghidra.program.model.listing.*;
|
import ghidra.program.model.listing.*;
|
||||||
import ghidra.program.model.symbol.FlowType;
|
import ghidra.program.model.symbol.FlowType;
|
||||||
|
import ghidra.program.model.symbol.Reference;
|
||||||
import ghidra.program.model.pcode.PcodeOp;
|
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.HashSet;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.TreeSet;
|
import java.util.TreeSet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Undo wrong no-return flags from auto-analysis. A function flagged no-return that contains a
|
* 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
|
* real RET is an ordinary function (stream readers, cxa_guard_acquire, ...). Known returning
|
||||||
* call as a terminator and the decompiler truncated them. Clear the flag, then re-disassemble only
|
* 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.
|
* the call sites of the functions that were cleared and refit their owning functions in place.
|
||||||
*/
|
*/
|
||||||
public final class ReaGhidraNoReturnFix extends GhidraScript {
|
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
|
@Override
|
||||||
public void run() throws Exception {
|
public void run() throws Exception {
|
||||||
// Windows P0 does not admit analysis-database mutation.
|
// Windows P0 does not admit analysis-database mutation.
|
||||||
@@ -24,66 +29,95 @@ public final class ReaGhidraNoReturnFix extends GhidraScript {
|
|||||||
FunctionManager fm = currentProgram.getFunctionManager();
|
FunctionManager fm = currentProgram.getFunctionManager();
|
||||||
Listing li = currentProgram.getListing();
|
Listing li = currentProgram.getListing();
|
||||||
Set<Address> cleared = new HashSet<>();
|
Set<Address> cleared = new HashSet<>();
|
||||||
for (Function f : fm.getFunctions(true)) {
|
Deque<Address> pending = new ArrayDeque<>();
|
||||||
|
for (Function f : fm.getExternalFunctions()) {
|
||||||
monitor.checkCancelled();
|
monitor.checkCancelled();
|
||||||
if (!f.hasNoReturn() || f.isThunk()) continue;
|
// Do not apply import-name assumptions to local functions or to arbitrary externals.
|
||||||
for (Instruction ins : li.getInstructions(f.getBody(), true)) {
|
if (!f.hasNoReturn() || !RETURNING_IMPORTS.contains(externalAbiName(f))) continue;
|
||||||
FlowType ft = ins.getFlowType();
|
f.setNoReturn(false);
|
||||||
boolean decodedReturn = false;
|
enqueueReturning(f, cleared, pending);
|
||||||
for (PcodeOp operation : ins.getPcode()) {
|
|
||||||
if (operation.getOpcode() == PcodeOp.RETURN) decodedReturn = true;
|
|
||||||
}
|
|
||||||
if (decodedReturn && !ft.isJump() && !ft.isCall()) {
|
|
||||||
f.setNoReturn(false);
|
|
||||||
cleared.add(f.getEntryPoint());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// A thunk inherits the flag of the function it forwards to.
|
|
||||||
for (Function f : fm.getFunctions(true)) {
|
for (Function f : fm.getFunctions(true)) {
|
||||||
monitor.checkCancelled();
|
monitor.checkCancelled();
|
||||||
if (!f.isThunk()) continue;
|
if (clearDecodedReturn(f, li)) {
|
||||||
Function target = f.getThunkedFunction(true);
|
enqueueReturning(f, cleared, pending);
|
||||||
if (target != null && cleared.contains(target.getEntryPoint())) {
|
|
||||||
cleared.add(f.getEntryPoint());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
int redone = 0;
|
int redone = 0;
|
||||||
TreeSet<Address> owners = new TreeSet<>();
|
TreeSet<Address> owners = new TreeSet<>();
|
||||||
if (!cleared.isEmpty()) {
|
while (!pending.isEmpty()) {
|
||||||
List<Address> sites = new ArrayList<>();
|
Address target = pending.removeFirst();
|
||||||
for (Instruction ins : li.getInstructions(true)) {
|
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();
|
monitor.checkCancelled();
|
||||||
FlowType ft = ins.getFlowType();
|
sites.add(reference.getFromAddress());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
for (Address s : sites) {
|
for (Address s : sites) {
|
||||||
monitor.checkCancelled();
|
monitor.checkCancelled();
|
||||||
Instruction ins = li.getInstructionAt(s);
|
Instruction ins = li.getInstructionAt(s);
|
||||||
if (ins == null) continue;
|
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);
|
Function owner = fm.getFunctionContaining(s);
|
||||||
if (owner != null && !owner.isThunk()) owners.add(owner.getEntryPoint());
|
if (owner != null && !owner.isThunk()) owners.add(owner.getEntryPoint());
|
||||||
clearListing(ins.getMinAddress(), ins.getMaxAddress());
|
clearListing(ins.getMinAddress(), ins.getMaxAddress());
|
||||||
disassemble(s);
|
disassemble(s);
|
||||||
redone++;
|
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.
|
// 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.
|
|
||||||
}
|
}
|
||||||
|
// No analyzeChanges here: on libpl2.so it adds about 70 s, which pushes startup past the 330 s deadline.
|
||||||
println(
|
println(
|
||||||
"REA no-return fix: cleared=" + cleared.size() + " sites=" + redone + " refit=" + owners.size());
|
"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
|
- Pull and capture outputs are additive: the destination must not already
|
||||||
exist. Re-running a completed pull refuses the existing directory instead
|
exist. Re-running a completed pull refuses the existing directory instead
|
||||||
of overwriting evidence.
|
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
|
- The provider holds no long-lived processes; every invocation is one
|
||||||
bounded adb run. The adb server belongs to the caller.
|
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.
|
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
|
## 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
|
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
|
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
|
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
|
tags, unknown fields, duplicate step IDs, and undeclared secret references fail
|
||||||
validation.
|
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
|
The result is Evidence with an initial state followed by one record per
|
||||||
declared action. Each step reports action status, elapsed time, sanitized URLs,
|
declared action. Each step reports action status, elapsed time, sanitized URLs,
|
||||||
event bounds, and independently typed capture states for screenshot, DOM,
|
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
|
The import records hashes and metadata for the supplied files, separately
|
||||||
from observations of the current app. File names do not automatically exclude
|
from observations of the current app. It honors the source tree's `.gitignore`
|
||||||
files. Set `REA_REFERENCE_SECRET_PATTERNS_JSON` to a JSON array of ignore
|
and applies default exclusions for common generated and dependency paths such
|
||||||
patterns when you want to exclude selected paths.
|
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
|
JavaScript and TypeScript import parsing requires valid UTF-8. Malformed source
|
||||||
bytes retain their original hashes and sizes with a decoding diagnostic; REA
|
bytes retain their original hashes and sizes with a decoding diagnostic; REA
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ extraction have independent prerequisites; one does not require the other.
|
|||||||
Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its
|
Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its
|
||||||
absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed.
|
absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed.
|
||||||
|
|
||||||
|
REA retains at most 8 MiB of combined stdout and stderr per command. Exceeding
|
||||||
|
that diagnostic budget stops analysis with an explicit truncated-output error;
|
||||||
|
child process limits do not bound parent-side pipe retention. Completed commands
|
||||||
|
drain their output streams before recording diagnostics. Failed worker cleanup
|
||||||
|
retains the worker and workspace for retry before another request or at shutdown.
|
||||||
|
|
||||||
Unblob needs the external extractor for the selected format on its inherited
|
Unblob needs the external extractor for the selected format on its inherited
|
||||||
`PATH`: for example, `debugfs` for ext2/ext3/ext4. Its default Landlock sandbox
|
`PATH`: for example, `debugfs` for ext2/ext3/ext4. Its default Landlock sandbox
|
||||||
and skip rules stay enabled. Sandbox startup failures remain actionable errors;
|
and skip rules stay enabled. Sandbox startup failures remain actionable errors;
|
||||||
|
|||||||
@@ -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
|
Program and section declarations must agree; duplicate, conflicting, truncated
|
||||||
or out-of-file records are rejected. Stripped section tables and section-only
|
or out-of-file records are rejected. Stripped section tables and section-only
|
||||||
records are supported. Extended table numbering is explicitly unsupported.
|
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
|
The field layout follows
|
||||||
[`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h).
|
[`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
|
CLI/MCP discovery and decompilation, direct calls, target/profile/Evidence
|
||||||
identity, snapshot round-trip and final process cleanup remain covered.
|
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.
|
Pseudocode presence is a liveness check, not proof of semantic equivalence.
|
||||||
Instruction decoding and reader agreement likewise do not establish runtime,
|
Instruction decoding and reader agreement likewise do not establish runtime,
|
||||||
delay-slot execution or complete floating-point semantics. Assertion regressions
|
delay-slot execution or complete floating-point semantics. Assertion regressions
|
||||||
|
|||||||
+64
-35
@@ -1,16 +1,25 @@
|
|||||||
# Optional NativeAOT metadata recovery
|
# Optional NativeAOT metadata recovery
|
||||||
|
|
||||||
REA integrates [Washi1337/ghidra-nativeaot](https://github.com/Washi1337/ghidra-nativeaot)
|
REA has a built-in read-only snapshot parser for x64 PE targets with .NET 8 RTR
|
||||||
through an optional headless adapter. The original source is an opt-in submodule,
|
9.1 metadata. It parses the exact admitted Ghidra session snapshot and uses an
|
||||||
pinned to `effeb734fc570c32650f88b159608979dc7b423e`; its MIT license and existing
|
in-memory overlay for rehydrated metadata; it does not define Ghidra DataTypes,
|
||||||
source notices are retained. No upstream source or algorithms are rewritten.
|
labels, functions, or memory bytes. The hydration semantics are adapted from
|
||||||
|
[Washi1337/ghidra-nativeaot](https://github.com/Washi1337/ghidra-nativeaot) at
|
||||||
|
`effeb734fc570c32650f88b159608979dc7b423e` (MIT), cross-checked against
|
||||||
|
.NET's `MethodTable.h` at runtime commit
|
||||||
|
`a3b2d40328be0be3f8dd43f0e5cc925b8827b044` (MIT). The required notice ships in
|
||||||
|
[`licenses/nativeaot-parser-MIT.txt`](../licenses/nativeaot-parser-MIT.txt).
|
||||||
|
|
||||||
The initial tested layout is .NET runtime **8.0.22**, NativeAOT RTR **9.1**,
|
The optional headless Ghidra adapter remains available for database annotations
|
||||||
x86-64 ELF and native Windows PE **targets analyzed on Linux x64**, using
|
and broader recovery. It uses the same pinned upstream commit as an opt-in
|
||||||
Ghidra **12.1.4** and JDK **21**. The build accepts any Ghidra 12.1.x release and
|
submodule and is a separate, more capable workflow.
|
||||||
javac 21 or newer, and it compiles with `--release 21`. Real verification of
|
|
||||||
this layout remains the Ghidra 12.1.4 and JDK 21 pair. Other NativeAOT layouts,
|
The parser currently handles x64 PE32+ and RTR 9.1, including bounded
|
||||||
architectures, and hosts are unsupported.
|
DEHYDRATED_DATA processing, MethodTable relationships, virtual slots, and
|
||||||
|
frozen strings. Other RTR versions and PE architectures are reported as
|
||||||
|
unsupported or partial. The matching .NET 8.0.22 fixture is source-built and
|
||||||
|
the parser's Windows-host Ghidra workflow has not yet had a real Windows-host
|
||||||
|
run. This does not establish Windows Ghidra import or decompiler behavior.
|
||||||
Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun
|
Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun
|
||||||
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
|
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
|
||||||
|
|
||||||
@@ -43,42 +52,62 @@ arbitrary caller-supplied bytes. The bounded loader accepts regular JARs up to
|
|||||||
|
|
||||||
## Use existing tools
|
## Use existing tools
|
||||||
|
|
||||||
Open the executable with provider `ghidra`. `inspect_native_load_image` returns
|
Open the executable with provider `ghidra`. For an x64 PE, `inspect_native_load_image`
|
||||||
optional `observations.metadata_recovery` inline, including the detected header,
|
returns `observations.metadata_recovery` inline from the captured source snapshot,
|
||||||
layout, recovery status, type category paths/addresses, diagnostics, annotation
|
including the RTR header, MethodTable addresses, frozen strings, derived overlay
|
||||||
coverage and derived-memory SHA. The enclosing `unsupported` load-image status
|
identity, and examined/recovered counts. Its enclosing `unsupported` load-image
|
||||||
for PE/ELF concerns independent DOS loader verification; metadata recovery has
|
status means independent PE load-image verification was not performed; it does
|
||||||
its own status. It is not a failed import.
|
not mean the metadata parser failed or the executable failed to load.
|
||||||
|
|
||||||
Pass one returned type path or its typed address to `inspect_native_data_type`.
|
Pass a returned MethodTable address to `inspect_native_data_type`. A direct PE
|
||||||
Its optional `metadata_recovery` includes related/base type, implemented
|
query also derives the snapshot metadata from the authenticated Ghidra session
|
||||||
interfaces and virtual slots with targets and known procedure names. Follow
|
handshake, so it does not require a preceding load-image call. In a persistent
|
||||||
these with `analyze_function`, string searches and ordinary cross-references.
|
MCP session, load-image followed by type inspection reuses that same derivation.
|
||||||
For CLI use:
|
The database result remains `unavailable` when Ghidra has no DataType at that
|
||||||
|
address; its `metadata_recovery.source` is `read-only-derived-overlay` and
|
||||||
|
carries related type, interface, slot, and target observations. Generated
|
||||||
|
address identities do not claim original managed names. The parser does not
|
||||||
|
merge frozen literals into Ghidra's database string inventory. Follow slot
|
||||||
|
targets with `analyze_function` and ordinary cross-references.
|
||||||
|
|
||||||
|
The built-in CLI path uses a MethodTable address returned by the parser or
|
||||||
|
another observation:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
rea inspect-native-load-image ./NativeAotFixture --provider ghidra --json
|
rea inspect-native-load-image ./NativeAotFixture --provider ghidra --json
|
||||||
rea inspect-native-data-type ./NativeAotFixture --type /NativeAOT/MethodTables/Class_ADDRESS_MT --provider ghidra --json
|
rea inspect-native-data-type ./NativeAotFixture --address 0xMETHODTABLE_ADDRESS --provider ghidra --json
|
||||||
rea function ./NativeAotFixture 0xADDRESS --provider ghidra --json
|
rea function ./NativeAotFixture 0xADDRESS --provider ghidra --json
|
||||||
```
|
```
|
||||||
|
|
||||||
No new MCP workflow or approval flag is required. Configuration opts into the
|
The generated `/NativeAOT/...` category path belongs to the optional headless
|
||||||
adapter; without it ordinary Ghidra profiles/results remain unchanged.
|
adapter. Use `--type /NativeAOT/MethodTables/Class_ADDRESS_MT` only when that
|
||||||
|
adapter is configured and has created the corresponding Ghidra DataType.
|
||||||
|
Built-in PE analysis includes its parser contract and resource policy in the
|
||||||
|
Ghidra analysis profile; non-PE profiles do not include that contract.
|
||||||
|
|
||||||
## Evidence boundaries
|
## Evidence boundaries
|
||||||
|
|
||||||
Discovery prefers an imported RTR symbol, otherwise the upstream initialized,
|
The built-in parser uses an initialized non-executable PE signature scan and
|
||||||
non-executable-memory signature heuristic. REA rejects ambiguous candidates,
|
requires a unique RTR 9.1 header and exactly one DEHYDRATED_DATA row. Rehydrated
|
||||||
unknown layout versions, malformed directory rows, unsupported flags and
|
bytes live only in a parser-owned overlay, identified by virtual address, size,
|
||||||
unmapped ranges before recovery. It requires the verified dehydrated layout.
|
and SHA-256; the caller's source Buffer remains unchanged. Limits derive from
|
||||||
|
captured section extents and format field bounds. Separate resource policies
|
||||||
|
admit up to 128 MiB of captured PE bytes, 64 MiB-equivalent parser work, 64 MiB
|
||||||
|
of exact parser-owned overlay/index Buffers, and 4 MiB of serialized NativeAOT
|
||||||
|
report JSON. These are local parser limits, independent of MCP delivery; the
|
||||||
|
buffer counter does not claim to cap V8 heap or total process memory. Rows that
|
||||||
|
do not fit the report budget are omitted with explicit partial status and
|
||||||
|
coverage. Coverage counts describe the pointer scan, recovered MethodTables,
|
||||||
|
and frozen-string candidates examined, not every possible runtime type or
|
||||||
|
object. Cancellation is honored during snapshot I/O and checked immediately
|
||||||
|
before and after parsing; the synchronous parser call itself is bounded but
|
||||||
|
cannot be interrupted mid-call. Parser output never claims that a Ghidra
|
||||||
|
database type was defined.
|
||||||
|
|
||||||
The adapter performs rehydration, method-table recovery and frozen-object
|
The optional adapter performs its annotations in an ephemeral Program
|
||||||
annotation in one ephemeral Program transaction. Fatal failure/cancellation
|
transaction. That workflow may change analysis-memory bytes, never the original
|
||||||
rolls it back. Recovery may change analysis-memory bytes, never the original
|
executable file. It is unavailable on Windows P0, which has no database
|
||||||
executable file. Derived ranges have a digest and **no original file offset**;
|
mutation authority.
|
||||||
they are not captured runtime state. Candidate/committed instance annotation
|
|
||||||
counts distinguish partial frozen-object coverage. Coverage concerns the
|
|
||||||
recovered candidate set, not every possible object or every runtime type.
|
|
||||||
|
|
||||||
REA preserves pre-recovery calling conventions and uses the loaded compiler
|
REA preserves pre-recovery calling conventions and uses the loaded compiler
|
||||||
specification default for newly created methods. The upstream universal
|
specification default for newly created methods. The upstream universal
|
||||||
|
|||||||
+34
-4
@@ -8,9 +8,12 @@ Executable bytes remain unchanged.
|
|||||||
Use [installation](installation.md#ghidra) for supported hosts, prerequisites,
|
Use [installation](installation.md#ghidra) for supported hosts, prerequisites,
|
||||||
configuration, startup deadlines, resource controls, and cleanup recovery.
|
configuration, startup deadlines, resource controls, and cleanup recovery.
|
||||||
[Windows P0](windows-ghidra-p0.md) has its own admission and lifecycle boundary;
|
[Windows P0](windows-ghidra-p0.md) has its own admission and lifecycle boundary;
|
||||||
[NativeAOT recovery](ghidra-nativeaot.md) is an optional, separately verified
|
[NativeAOT observations](ghidra-nativeaot.md) include a built-in read-only PE
|
||||||
extension. Check the [release boundary](installation.md#released-package-and-main)
|
snapshot parser and a separate optional annotation extension. Check the
|
||||||
when using npm.
|
[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
|
Provider selection and profile identity follow
|
||||||
[MCP contracts](mcp-contracts.md#identity-and-discovery). One deep
|
[MCP contracts](mcp-contracts.md#identity-and-discovery). One deep
|
||||||
@@ -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`. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
## Admitted function-analysis semantics
|
||||||
|
|
||||||
| Concern | Ghidra contract |
|
| 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. |
|
| 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. |
|
| 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
|
`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
|
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
|
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
|
version that performed setup. Installing newer instructions does not update a
|
||||||
running server or its registration.
|
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
|
from the fixed checkpoint
|
||||||
[`b33236ec`](https://github.com/morluto/rea/releases/tag/rea-agents-5.0.0).
|
[`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
|
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` |
|
| Pi | `pi` |
|
||||||
| Hermes | `hermes` |
|
| 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),
|
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
|
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`.
|
`~/.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
|
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`
|
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.
|
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.
|
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
|
Setup and doctor follow Qwen Code's tilde and working-directory-relative home
|
||||||
overrides. Qwen Code also discovers personal skills in `~/.agents/skills`,
|
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
|
`enabledServers` lists `rea`, as OMP does, unless `disabledServers` also lists
|
||||||
it. Run setup under each profile that should load REA.
|
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
|
comments and unrelated settings. It resolves that file from `HERMES_HOME`, else
|
||||||
from the platform default Hermes itself uses — `%LOCALAPPDATA%\hermes` on
|
from the platform default Hermes itself uses — `%LOCALAPPDATA%\hermes` on
|
||||||
Windows, `~/.hermes` elsewhere — including any `HERMES_DATA_DIR_SUFFIX`. Hermes
|
Windows, `~/.hermes` elsewhere — including any `HERMES_DATA_DIR_SUFFIX`. Hermes
|
||||||
@@ -255,7 +293,10 @@ for its native configuration contract.
|
|||||||
|
|
||||||
### Other client settings
|
### 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
|
both load. If the configuration already uses OpenCode V2's native
|
||||||
`mcp.servers` table, setup registers REA there instead and replaces any earlier
|
`mcp.servers` table, setup registers REA there instead and replaces any earlier
|
||||||
`mcp.rea` entry from REA. Setup honors `OPENCODE_CONFIG_DIR` and
|
`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,
|
changes. Agent configuration writes preserve unrelated entries and comments,
|
||||||
create backups, use atomic replacement, and verify their result. Setup and
|
create backups, use atomic replacement, and verify their result. Setup and
|
||||||
uninstall retain an existing `.rea.backup` rather than replacing the first snapshot.
|
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
|
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
|
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.
|
installation and preserves Hopper during uninstall.
|
||||||
|
|
||||||
The supported native host baseline is macOS 12+, Ubuntu 24.04+, Fedora 41+,
|
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).
|
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
|
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
|
memory bytes, and observed file offsets. Independent load-image attestation
|
||||||
supports DOS MZ and explicitly selected COM; PE returns its measurements with that limitation.
|
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
|
On Linux and macOS, `annotate_native_function` also edits a function name and/or
|
||||||
entry comments atomically and returns refreshed analysis. These session metadata
|
entry comments atomically and returns refreshed analysis, and `set_address_name`
|
||||||
edits leave executable bytes unchanged and are discarded on close. GUI controls
|
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.
|
require Hopper; Windows P0 remains read-only.
|
||||||
|
|
||||||
Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes.
|
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
|
remain unknown, reference-kind provenance is preserved, and provider-specific
|
||||||
pseudocode is never treated as original source or Hopper-equivalent text.
|
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
|
### Ghidra heap and CPU controls
|
||||||
|
|
||||||
Set resource controls in the environment that launches the CLI or MCP server,
|
Set resource controls in the environment that launches the CLI or MCP server,
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ manifest and graph commitments, JavaScript Application Graph, static
|
|||||||
Electron summary, reconstruction statistics, and explicit limitations. It does
|
Electron summary, reconstruction statistics, and explicit limitations. It does
|
||||||
not require a live Hopper, Ghidra, browser, or Electron process.
|
not require a live Hopper, Ghidra, browser, or Electron process.
|
||||||
|
|
||||||
|
The application graph selects the shallowest `package.json` outside
|
||||||
|
`node_modules` as its package root, breaking equal-depth ties by Unicode
|
||||||
|
code-point order. Dependency manifests remain package observations but do not
|
||||||
|
declare Electron main or renderer entries. If only dependency manifests are
|
||||||
|
present, the inventory artifact remains the graph root.
|
||||||
|
|
||||||
## ASAR integrity
|
## ASAR integrity
|
||||||
|
|
||||||
ASAR inventory checks Electron integrity metadata for embedded archive entries
|
ASAR inventory checks Electron integrity metadata for embedded archive entries
|
||||||
@@ -89,9 +95,9 @@ queries use per-file range indexes, while call sites use exact-range lookups.
|
|||||||
Existing node budgets and coverage reporting remain in effect.
|
Existing node budgets and coverage reporting remain in effect.
|
||||||
|
|
||||||
Progress identifies the source file being parsed and projected. Cancellation is
|
Progress identifies the source file being parsed and projected. Cancellation is
|
||||||
checked between files, with event-loop yields that allow notifications and
|
checked between files and throughout result transfer. The owned analyzer can
|
||||||
completed file-local allocations to be released. A single file's synchronous
|
be stopped while its synchronous parser or semantic extractor is active;
|
||||||
parser and semantic extraction still need to finish before that boundary.
|
the CLI/MCP process remains available for control requests.
|
||||||
|
|
||||||
Graph and Evidence identifiers hash canonical JSON incrementally, without
|
Graph and Evidence identifiers hash canonical JSON incrementally, without
|
||||||
assembling a single string for the whole graph. The canonical bytes and existing
|
assembling a single string for the whole graph. The canonical bytes and existing
|
||||||
@@ -113,6 +119,46 @@ smaller view, for example `--format json --filter-output
|
|||||||
evidence_id,normalized_result.statistics`. Streaming output does not bound the
|
evidence_id,normalized_result.statistics`. Streaming output does not bound the
|
||||||
memory needed to construct the analysis graph itself.
|
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
|
CLI workflows parse larger JSON input files incrementally from one verified
|
||||||
regular-file handle, without constructing a document-sized string. Smaller
|
regular-file handle, without constructing a document-sized string. Smaller
|
||||||
files retain native JSON parsing. Both paths require strict JSON and valid
|
files retain native JSON parsing. Both paths require strict JSON and valid
|
||||||
@@ -207,6 +253,46 @@ while locally shadowed receivers can still contribute network endpoint
|
|||||||
candidates. A template recovered with a parser error and no cooked value stays
|
candidates. A template recovered with a parser error and no cooked value stays
|
||||||
dynamic; its raw spelling is not treated as a valid JavaScript string.
|
dynamic; its raw spelling is not treated as a valid JavaScript string.
|
||||||
|
|
||||||
|
RPC client syntax also contributes network endpoint candidates, in both
|
||||||
|
`analyze_javascript_application` and `analyze_web_bundle`, distinguished by
|
||||||
|
`mechanism`:
|
||||||
|
|
||||||
|
- `trpc:procedure:<method>` records the static member path between the root
|
||||||
|
expression and the method, such as `invoice.list` in
|
||||||
|
`root.invoice.list.useQuery(...)`. tRPC proxies keep these property names
|
||||||
|
through identifier minification. React hooks and TanStack option factories
|
||||||
|
(`useQuery`, `useMutation`, `useInfiniteQuery`, `useSubscription`, suspense
|
||||||
|
and prefetch variants, `queryOptions`, `infiniteQueryOptions`,
|
||||||
|
`mutationOptions`, `subscriptionOptions`) are recognized directly. The
|
||||||
|
vanilla `query` and `mutate` methods are recognized on a root bound to
|
||||||
|
`createTRPCClient` or `createTRPCProxyClient` in the same lexical binding, or on a
|
||||||
|
path of at least two segments in a source containing `TRPCClientError`,
|
||||||
|
`trpc-accept`, or `/trpc`. Factory bindings must have one unconditional
|
||||||
|
initializer and no binding assignments; shadowed locals do not inherit client
|
||||||
|
identity. `subscribe` additionally requires a tRPC observer
|
||||||
|
argument (`onData`, `onError`, `onStarted`, `onStopped`, `onComplete`, or
|
||||||
|
`onConnectionStateChange`). `this` roots, browser globals, and any path
|
||||||
|
containing an RTK Query `endpoints` segment are never procedures; cache-key
|
||||||
|
helpers such as `queryKey` issue no request and are excluded.
|
||||||
|
- `trpc:link:<link>` records the literal `url` option of `httpLink`,
|
||||||
|
`httpBatchLink`, `httpBatchStreamLink`, and `httpSubscriptionLink` when the
|
||||||
|
link's callee name survives bundling, including `(0, module.httpBatchLink)`
|
||||||
|
import-call wrappers.
|
||||||
|
- `graphql:query`, `graphql:mutation`, and `graphql:subscription` record a
|
||||||
|
named operation found at the start of a string or cooked template literal —
|
||||||
|
the name must be followed by variable definitions (`($`), a directive, or a
|
||||||
|
selection set starting with a field name — or in a precompiled
|
||||||
|
`OperationDefinition` document object. A graphql-tag `loc.source.body` copy is
|
||||||
|
not reported twice. Anonymous operations are not reported.
|
||||||
|
|
||||||
|
A procedure path is the server-side procedure name, not a resolved URL; the
|
||||||
|
transport URL depends on the link configuration and batching. When the proxy is
|
||||||
|
reached through a member expression, such as a webpack module binding
|
||||||
|
(`l.S.invoice.list`) or a property holding the proxy (`o.api.post.byId`), the
|
||||||
|
recorded path keeps those leading carrier segments; the procedure path is its
|
||||||
|
suffix. Utility calls such as `useUtils().invoice.list.fetch()`, `useQueries`,
|
||||||
|
and links whose callee was renamed by a minifier are not recognized.
|
||||||
|
|
||||||
Each recovered bundle module retains the exact factory-source digest. A complete
|
Each recovered bundle module retains the exact factory-source digest. A complete
|
||||||
bounded AST also receives a `babel-ast-v1` structural fingerprint that ignores
|
bounded AST also receives a `babel-ast-v1` structural fingerprint that ignores
|
||||||
ordinary identifier names while retaining syntax, literals, operators, object
|
ordinary identifier names while retaining syntax, literals, operators, object
|
||||||
@@ -230,6 +316,14 @@ substitute version-dependent Electron defaults for omitted `webPreferences`.
|
|||||||
Dynamic option objects, bridge keys, API objects, and IPC channel expressions
|
Dynamic option objects, bridge keys, API objects, and IPC channel expressions
|
||||||
remain unknown and make coverage partial.
|
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
|
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
|
with one unique `ipcMain.handle`/`handleOnce` candidate on the same exact literal
|
||||||
channel; a renderer send pairs only with one unique `ipcMain.on`/`once`
|
channel; a renderer send pairs only with one unique `ipcMain.on`/`once`
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ Inspect separately obtained components explicitly and preserve their identities.
|
|||||||
A valid implementation marker establishes a candidate native boundary, not
|
A valid implementation marker establishes a candidate native boundary, not
|
||||||
recovered native semantics or runtime behavior.
|
recovered native semantics or runtime behavior.
|
||||||
|
|
||||||
|
### Resource admission
|
||||||
|
|
||||||
|
The static provider admits regular input files up to 128 MiB before retaining
|
||||||
|
their bytes. Reads remain bounded if a file grows after admission. Metadata,
|
||||||
|
CIL decoding, and projected inspection records have a separate conservative
|
||||||
|
64 MiB representation budget; shared heap strings are cached, but repeated
|
||||||
|
output occurrences still consume that budget. These are inspection resource
|
||||||
|
policies, not PE format limits or a hard bound on total V8 heap use.
|
||||||
|
|
||||||
|
Input overflow returns a resource constraint. Decode or projection overflow
|
||||||
|
reports `resource-limit` with unavailable or partial coverage; omitted facts do
|
||||||
|
not become empty observations. Ambiguous PE section mappings are rejected
|
||||||
|
rather than interpreted according to section order.
|
||||||
|
|
||||||
## Analysis objective
|
## Analysis objective
|
||||||
|
|
||||||
REA's managed-code track is intended to answer five different questions without
|
REA's managed-code track is intended to answer five different questions without
|
||||||
|
|||||||
@@ -167,6 +167,9 @@ Native call tracing and process capture retain available observations in
|
|||||||
`details.partial_observation` on failure, including when cleanup succeeds.
|
`details.partial_observation` on failure, including when cleanup succeeds.
|
||||||
The observation reports its partial coverage; cleanup details describe host
|
The observation reports its partial coverage; cleanup details describe host
|
||||||
state separately from the execution failure.
|
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
|
Derived comparisons and reconstruction verification yield before computation
|
||||||
and before publication, so cancellation cannot race with successful Evidence.
|
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 leaves every annotation unchanged. CRLF, supplementary Unicode characters,
|
||||||
and combining characters are preserved. Windows P0 remains read-only. Ghidra has no
|
and combining characters are preserved. Windows P0 remains read-only. Ghidra has no
|
||||||
GUI authority, and REA never falls back automatically to Hopper.
|
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
|
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
|
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
|
`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
|
Environment overrides are recorded in the scenario commitment; inherited
|
||||||
values are not copied into Evidence.
|
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,
|
The capture contains raw PTY output chunks and rendered terminal states,
|
||||||
interaction dispatch outcomes, exit reason, sampled process-tree observations,
|
interaction dispatch outcomes, exit reason, sampled process-tree observations,
|
||||||
and settlement status. When filesystem paths are selected, REA records
|
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;
|
sampled process group quiesced or whether cleanup was needed or unverifiable;
|
||||||
sampling cannot prove that every short-lived or detached descendant was seen.
|
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:
|
Every capture requires `truncation_details`, with separate accounting for:
|
||||||
|
|
||||||
- `raw_terminal`: original UTF-8 PTY chunk bytes and observed/retained chunk
|
- `raw_terminal`: original UTF-8 PTY chunk bytes and observed/retained chunk
|
||||||
@@ -187,6 +287,10 @@ Every capture requires `truncation_details`, with separate accounting for:
|
|||||||
input, so raw omissions also limit rendered coverage.
|
input, so raw omissions also limit rendered coverage.
|
||||||
- `filesystem_before` and `filesystem_after`: file-count/depth limits,
|
- `filesystem_before` and `filesystem_after`: file-count/depth limits,
|
||||||
enumeration failures, whole-file hash budget and bytes successfully hashed.
|
enumeration failures, whole-file hash budget and bytes successfully hashed.
|
||||||
|
Directory names are read incrementally within the remaining entry capacity.
|
||||||
|
When a directory exceeds that capacity, the retained subset follows filesystem
|
||||||
|
enumeration order and is then sorted; it is explicitly incomplete and cannot
|
||||||
|
establish that an omitted path is absent.
|
||||||
Each retained regular file without a digest has an aliased path, size,
|
Each retained regular file without a digest has an aliased path, size,
|
||||||
remaining budget and reason: `file_bytes_budget`,
|
remaining budget and reason: `file_bytes_budget`,
|
||||||
`file_changed_or_short_read`, or `file_unavailable`. Its `system_code` is
|
`file_changed_or_short_read`, or `file_unavailable`. Its `system_code` is
|
||||||
|
|||||||
@@ -15,6 +15,48 @@ the original run after a partial publication.
|
|||||||
For a release that needs an independently frozen application checkpoint, use
|
For a release that needs an independently frozen application checkpoint, use
|
||||||
the optional manual path below. It retains explicit prepare and publish phases.
|
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
|
## 1. Select a source for a manual checkpoint
|
||||||
|
|
||||||
Use the next minor version proposed by Release Please, including releases with
|
Use the next minor version proposed by Release Please, including releases with
|
||||||
|
|||||||
+195
-24
@@ -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.
|
Use `REA_VERIFY_RUNTIME_ROOT` to select a production-only installed REA package.
|
||||||
Receipts retain client versions, result digests, host coverage and owned-process
|
Receipts retain client versions, result digests, host coverage and owned-process
|
||||||
cleanup. These POSIX lanes use a deterministic loopback model, so they do not
|
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
|
prove live model-provider or native Windows compatibility. Qwen, Pi and Hermes
|
||||||
caller-configured skill directories to isolate the fixture account from the OS
|
bind both `HOME` and `USERPROFILE` to the disposable account. Qwen and Pi discover
|
||||||
home; this does not prove their default home-directory discovery.
|
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
|
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.
|
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.
|
chat or `REA_VERIFY_RUNTIME_ROOT` for a production-only installed package.
|
||||||
The native offline BYOK adapter uses a loopback OpenAI completions/SSE fixture
|
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.
|
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
|
Set `REA_VERIFY_COPILOT_MODEL` to examine another client model ID and
|
||||||
verified client, `gpt-4.1` blocks the complete catalog before HTTP with
|
`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
|
`compaction_static_context_blocked`, even when requesting a larger BYOK prompt
|
||||||
capacity. The effective capacity is unknown; this lane does not establish a
|
capacity. The effective capacity is unknown. The default verifier preserves
|
||||||
fix for that client/model limit. It preserves the complete catalog and schemas.
|
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
|
Live model APIs, native Windows and default OS-home skill discovery remain
|
||||||
unverified.
|
unverified.
|
||||||
|
|
||||||
@@ -187,6 +216,30 @@ The loopback OpenAI completions/SSE custom model declares a one-million-token
|
|||||||
context and synthetic usage. Live xAI APIs, native Windows and default OS-home
|
context and synthetic usage. Live xAI APIs, native Windows and default OS-home
|
||||||
skill discovery remain unverified.
|
skill discovery remain unverified.
|
||||||
|
|
||||||
|
`verify:commandcode-client` requires installed Command Code (verified with npm
|
||||||
|
1.79.2); select it with `REA_VERIFY_COMMANDCODE_COMMAND`. Auth, MCP tokens and
|
||||||
|
sessions use the actual OS home, so provision a disposable POSIX account rather
|
||||||
|
than overriding `HOME`. Set `REA_VERIFY_COMMANDCODE_ACCOUNT_HOME` to that account's
|
||||||
|
actual home and create `.rea-client-verification` there with exactly
|
||||||
|
`Disposable REA client verification account` followed by a newline. The lane
|
||||||
|
owns that account's `.commandcode` configuration and shared REA skill. Build the
|
||||||
|
runtime as the checkout owner, then run `node scripts/verify-commandcode-client.mjs`
|
||||||
|
as the disposable account; the npm shortcut also needs a writable checkout.
|
||||||
|
The verifier does not create accounts or install clients. It guards setup
|
||||||
|
targets, preserves an unrelated registration and backups, checks idempotence,
|
||||||
|
and verifies native default shared-skill discovery and complete activation.
|
||||||
|
Default deferred-schema delivery stays enabled: the native prompt advertises
|
||||||
|
every REA tool, exact-name `search_tools` returns every input schema, and a native
|
||||||
|
shell query recovers the saved catalog's count and digest when the client spills
|
||||||
|
it. Actual analysis of a Unicode-path fixture delivers full named-schema Evidence
|
||||||
|
to the next model request. Use `-- chat` for ordinary chat or
|
||||||
|
`REA_VERIFY_RUNTIME_ROOT` for a production-only installed package. The loopback
|
||||||
|
OpenAI completions/SSE model is a keyless BYOK endpoint declaring a million-token
|
||||||
|
context with synthetic usage. A synthetic account-key value satisfies the
|
||||||
|
client's print-mode gate; native local-only mode refuses hosted API calls.
|
||||||
|
Updates, telemetry and cron are disabled. Hosted authentication, live models
|
||||||
|
and Windows remain unverified.
|
||||||
|
|
||||||
`verify:omp-client` requires installed OMP (verified with the official Linux x64
|
`verify:omp-client` requires installed OMP (verified with the official Linux x64
|
||||||
18.8.7 binary); select it with `REA_VERIFY_OMP_COMMAND`. This optional POSIX lane
|
18.8.7 binary); select it with `REA_VERIFY_OMP_COMMAND`. This optional POSIX lane
|
||||||
isolates the default agent, global config and XDG roots, guards setup targets,
|
isolates the default agent, global config and XDG roots, guards setup targets,
|
||||||
@@ -208,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
|
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
|
`REA_VERIFY_DEEPSEEK_COMMAND` to select its executable. This optional POSIX lane
|
||||||
installs the skill through `rea setup --skill`, writes the native Cordis MCP
|
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
|
patch into an isolated `DSH_HOME`, and binds `HOME` and `USERPROFILE` to the
|
||||||
skill discovery. A separate Git root prevents inherited project skills from
|
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,
|
masking a missing installation. The default `call` mode checks skill loading,
|
||||||
complete catalog discovery, the actual forwarded input schemas and regexes,
|
complete catalog discovery, the actual forwarded input schemas and regexes,
|
||||||
and full JavaScript Evidence for a Unicode path. Use `-- chat` for ordinary
|
and full JavaScript Evidence for a Unicode path. Use `-- chat` for ordinary
|
||||||
@@ -337,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.
|
`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
|
`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
|
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;
|
decoding. Its SDK client explicitly permits the larger inline JSON response;
|
||||||
@@ -390,14 +450,15 @@ installation (`application.java.min` through `application.java.max`). Current
|
|||||||
12.1 releases require JDK 21 or newer and set no maximum. The lanes below still
|
12.1 releases require JDK 21 or newer and set no maximum. The lanes below still
|
||||||
prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
|
prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
|
||||||
|
|
||||||
| Ghidra lane | Supported runner/target | Additional local tools |
|
| 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` | 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:swift` | macOS x64/arm64 Mach-O | Host Swift 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: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:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, 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 |
|
| `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:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
|
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
|
||||||
| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE; `-- --x86` selects native x86 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact |
|
| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
|
||||||
|
| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE; `-- --x86` selects native x86 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact |
|
||||||
|
|
||||||
The optional Swift lane compiles a source-owned fixture and checks modern and
|
The optional Swift lane compiles a source-owned fixture and checks modern and
|
||||||
provider-demangled procedure names through real stdio MCP, with and without the
|
provider-demangled procedure names through real stdio MCP, with and without the
|
||||||
@@ -611,6 +672,13 @@ boundaries and budgets.
|
|||||||
|
|
||||||
## Optional NativeAOT Ghidra analysis
|
## Optional NativeAOT Ghidra analysis
|
||||||
|
|
||||||
|
The source parser lane is independent of the optional annotation JAR. Its
|
||||||
|
synthetic RTR 9.1 PE golden checks captured-byte digest admission, rehydration
|
||||||
|
into an immutable parser overlay, MethodTable/slot relationships, and frozen
|
||||||
|
literal extraction; provider tests exercise `inspect_native_load_image` and
|
||||||
|
address-based `inspect_native_data_type` with the database result still
|
||||||
|
unavailable. These tests do not establish Windows Ghidra host behavior.
|
||||||
|
|
||||||
This lane is separate from the default native lane. `build:fixtures:nativeaot`
|
This lane is separate from the default native lane. `build:fixtures:nativeaot`
|
||||||
requires an existing .NET SDK 8.0.416, the platform NativeAOT compiler/linker and
|
requires an existing .NET SDK 8.0.416, the platform NativeAOT compiler/linker and
|
||||||
runtime pack 8.0.22. It builds benign sources into ignored `_reference/` and
|
runtime pack 8.0.22. It builds benign sources into ignored `_reference/` and
|
||||||
@@ -711,12 +779,37 @@ Pull requests that change only root `README*.md` files, `docs/`, `AGENTS.md`,
|
|||||||
or `CONTRIBUTING.md` run formatting and generated-document validation without
|
or `CONTRIBUTING.md` run formatting and generated-document validation without
|
||||||
the source-test shards or native package lanes. Classification compares the PR
|
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.
|
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
|
Static and coverage aggregate jobs remain present and fail if classification
|
||||||
classification fails. Mixed changes, unknown paths and CI configuration changes
|
fails. Every code change retains the complete four-shard Linux suite; individual
|
||||||
retain full checks. Main pushes retain the full baseline, including after
|
test files are not selected by imports or filenames.
|
||||||
documentation merges; newer commits cancel superseded CI for the same PR or
|
|
||||||
main branch. Release publication has its own concurrency group and retains its
|
`scripts/ci/plan.mjs` selects lanes using the ownership table in
|
||||||
package verification and public-registry canary.
|
`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:
|
CI exercises the pinned Node.js runtime on native hosted runners:
|
||||||
|
|
||||||
@@ -741,6 +834,35 @@ separate Apple-artifact and real-provider lanes. Actual Hopper, Ghidra, IDA,
|
|||||||
browser and managed-tool claims require their corresponding verification lanes.
|
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).
|
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
|
## Developer commands
|
||||||
|
|
||||||
Use source feedback while editing, explicit boundary checks for the changed
|
Use source feedback while editing, explicit boundary checks for the changed
|
||||||
@@ -1029,6 +1151,39 @@ real local Codex CLI with:
|
|||||||
npm run verify:agent
|
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
|
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
|
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
|
exactly the requested claim IDs, values, Evidence IDs, and Evidence authority and
|
||||||
@@ -1053,9 +1208,21 @@ navigation-context, and address-context scenarios have no factual rubric and
|
|||||||
remain `not_assessed`; the managed workflow requires both artifact and member
|
remain `not_assessed`; the managed workflow requires both artifact and member
|
||||||
inspection to answer its type and entry-point question.
|
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,
|
All scenarios retain routing, workflow, validation, repetition, process-exit,
|
||||||
and token-use gates. Configured factual scenarios additionally require a factual
|
and token-use gates. Configured factual scenarios additionally require a factual
|
||||||
pass. Their answer-text heuristics are diagnostic and do not affect the gate.
|
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.
|
Scenarios without a rubric retain the legacy text-heuristic gate.
|
||||||
`answerTermCoverageMet` checks case-insensitive substrings,
|
`answerTermCoverageMet` checks case-insensitive substrings,
|
||||||
`epistemicCuePresent` checks keywords, and `finalCitesEvidence` checks only an ID's
|
`epistemicCuePresent` checks keywords, and `finalCitesEvidence` checks only an ID's
|
||||||
@@ -1063,7 +1230,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
|
as factual assessments. Set `REA_AGENT_EVAL_TRANSCRIPT_DIR` to retain complete
|
||||||
tool results and final answers for review.
|
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/
|
string to an assessment summary with status, scope, and assessed/passed/failed/
|
||||||
not-assessed scenario counts. Scenario records include the factual assessment and
|
not-assessed scenario counts. Scenario records include the factual assessment and
|
||||||
configured claim IDs. The evaluation scope is
|
configured claim IDs. The evaluation scope is
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ additional permission flag or degraded mode is required.
|
|||||||
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
|
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
|
||||||
|
|
||||||
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
|
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
|
||||||
PE load-image inspection returns measurements with an explicit unsupported
|
PE load-image inspection returns an explicit unsupported attestation status;
|
||||||
attestation status; independent format-specific verification currently supports
|
independent load-image verification currently supports DOS MZ only. For x64 PE
|
||||||
DOS MZ only.
|
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
|
||||||
|
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
|
||||||
|
database mutation. A real Windows-host NativeAOT Ghidra run is still required
|
||||||
|
to establish the complete host workflow.
|
||||||
|
|
||||||
This boundary does not establish general Windows feature parity. Ghidra GUI
|
This boundary does not establish general Windows feature parity. Ghidra GUI
|
||||||
state, annotations, other target architectures and formats, and Hopper-only
|
state, annotations, other target architectures and formats, and Hopper-only
|
||||||
|
|||||||
+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."
|
fail "npm cannot install an exact version with build metadata. Pass a version without build metadata, then retry."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
prefix_args=()
|
install_args=(install --global)
|
||||||
if [[ "$platform" == "Linux" ]]; then
|
if [[ "$platform" == "Linux" ]]; then
|
||||||
prefix_args=(--prefix "$HOME/.local")
|
install_args+=(--prefix "$HOME/.local")
|
||||||
install_bin="$HOME/.local/bin/rea"
|
install_bin="$HOME/.local/bin/rea"
|
||||||
else
|
else
|
||||||
npm_prefix="$(npm prefix --global)" || fail "the npm global prefix could not be read. Repair the npm configuration, then retry."
|
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"
|
install_bin="$npm_prefix/bin/rea"
|
||||||
fi
|
fi
|
||||||
|
install_args+=("$PACKAGE@$version")
|
||||||
|
|
||||||
printf 'REA install plan\n'
|
printf 'REA install plan\n'
|
||||||
printf ' Version: %s\n' "$version"
|
printf ' Version: %s\n' "$version"
|
||||||
printf ' Runtime: Node.js %s\n' "$node_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 ' Binary: %s\n' "$install_bin"
|
||||||
printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')"
|
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
|
fi
|
||||||
|
|
||||||
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
|
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."
|
[[ -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="$("$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."
|
[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry."
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
"entry": [
|
"entry": [
|
||||||
"src/main.ts",
|
"src/main.ts",
|
||||||
"src/cli.ts",
|
"src/cli.ts",
|
||||||
|
"src/composition/javascriptAnalysisWorkerProcess.ts",
|
||||||
"src/contracts/errorSchemas.ts",
|
"src/contracts/errorSchemas.ts",
|
||||||
"src/evaluation/AgentEvaluationFixtures.ts",
|
"src/evaluation/AgentEvaluationFixtures.ts",
|
||||||
"src/application/RealHopperAssertions.ts",
|
"src/application/RealHopperAssertions.ts",
|
||||||
|
"scripts/verify/termux/browser.mjs",
|
||||||
|
"scripts/verify-real-ghidra-noreturn.mjs",
|
||||||
"tests/processOwnershipRunner.ts"
|
"tests/processOwnershipRunner.ts"
|
||||||
],
|
],
|
||||||
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
|
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
=====================
|
||||||
|
|
||||||
|
Copyright © 2023-2025 Washi
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the “Software”), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
The read-only NativeAOT parser adapts format and hydration semantics from
|
||||||
|
Washi1337/ghidra-nativeaot commit
|
||||||
|
effeb734fc570c32650f88b159608979dc7b423e (MIT). MethodTable field layout was
|
||||||
|
cross-checked against .NET runtime src/coreclr/nativeaot/Runtime/inc/MethodTable.h
|
||||||
|
at dotnet/runtime commit a3b2d40328be0be3f8dd43f0e5cc925b8827b044 (MIT).
|
||||||
Generated
+17
@@ -17,6 +17,7 @@
|
|||||||
"@lydell/node-pty": "1.1.0",
|
"@lydell/node-pty": "1.1.0",
|
||||||
"@modelcontextprotocol/client": "2.3.1",
|
"@modelcontextprotocol/client": "2.3.1",
|
||||||
"@modelcontextprotocol/server": "2.3.1",
|
"@modelcontextprotocol/server": "2.3.1",
|
||||||
|
"@xmldom/xmldom": "0.9.12",
|
||||||
"@xterm/addon-serialize": "0.14.0",
|
"@xterm/addon-serialize": "0.14.0",
|
||||||
"@xterm/headless": "6.0.0",
|
"@xterm/headless": "6.0.0",
|
||||||
"@zip.js/zip.js": "2.15.0",
|
"@zip.js/zip.js": "2.15.0",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"oxlint": "1.87.0",
|
"oxlint": "1.87.0",
|
||||||
"turbo": "2.11.7",
|
"turbo": "2.11.7",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
|
"typescript-termux": "npm:typescript@5.9.3",
|
||||||
"vitepress": "1.6.4",
|
"vitepress": "1.6.4",
|
||||||
"vitest": "5.0.3"
|
"vitest": "5.0.3"
|
||||||
},
|
},
|
||||||
@@ -7367,6 +7369,21 @@
|
|||||||
"@typescript/typescript-win32-x64": "7.0.2"
|
"@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": {
|
"node_modules/unbash": {
|
||||||
"version": "4.0.12",
|
"version": "4.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.12.tgz",
|
||||||
|
|||||||
+14
-1
@@ -30,6 +30,9 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"imports": {
|
||||||
|
"#javascript-analysis-worker": "./dist/composition/javascriptAnalysisWorkerProcess.js"
|
||||||
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rea": "scripts/rea.mjs",
|
"rea": "scripts/rea.mjs",
|
||||||
"rea-agents": "scripts/rea.mjs"
|
"rea-agents": "scripts/rea.mjs"
|
||||||
@@ -50,6 +53,7 @@
|
|||||||
"bridge/ghidra/ReaGhidraBridge.java",
|
"bridge/ghidra/ReaGhidraBridge.java",
|
||||||
"bridge/ghidra/ReaGhidraExtensions.java",
|
"bridge/ghidra/ReaGhidraExtensions.java",
|
||||||
"bridge/ghidra/ReaGhidraPrepareCom.java",
|
"bridge/ghidra/ReaGhidraPrepareCom.java",
|
||||||
|
"bridge/ghidra/ReaGhidraApplySeeds.java",
|
||||||
"bridge/ghidra/ReaGhidraNoReturnFix.java",
|
"bridge/ghidra/ReaGhidraNoReturnFix.java",
|
||||||
"bridge/android/ReaJadxBridge.java",
|
"bridge/android/ReaJadxBridge.java",
|
||||||
"bridge/mitmproxy/capture.py",
|
"bridge/mitmproxy/capture.py",
|
||||||
@@ -77,9 +81,11 @@
|
|||||||
"bridge/pwndbg/launch_impl.py",
|
"bridge/pwndbg/launch_impl.py",
|
||||||
"bridge/pwndbg/core_context.py",
|
"bridge/pwndbg/core_context.py",
|
||||||
"third_party/pwndbg/README.md",
|
"third_party/pwndbg/README.md",
|
||||||
"third_party/pwndbg/LICENSE.md"
|
"third_party/pwndbg/LICENSE.md",
|
||||||
|
"licenses/nativeaot-parser-MIT.txt"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"release:notes": "node scripts/release-notes.mjs",
|
||||||
"deps:check": "node scripts/check-dependency-install.mjs",
|
"deps:check": "node scripts/check-dependency-install.mjs",
|
||||||
"metadata:generate": "node scripts/run-exclusive.mjs artifacts node scripts/generate-package-metadata.mjs",
|
"metadata:generate": "node scripts/run-exclusive.mjs artifacts node scripts/generate-package-metadata.mjs",
|
||||||
"metadata:check": "node scripts/generate-package-metadata.mjs --check",
|
"metadata:check": "node scripts/generate-package-metadata.mjs --check",
|
||||||
@@ -101,6 +107,8 @@
|
|||||||
"precheck:ci": "npm run deps:check",
|
"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",
|
"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: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": "oxfmt .",
|
||||||
"format:check": "oxfmt --check .",
|
"format:check": "oxfmt --check .",
|
||||||
"lint": "oxlint && npm run verify:module-boundaries",
|
"lint": "oxlint && npm run verify:module-boundaries",
|
||||||
@@ -150,6 +158,7 @@
|
|||||||
"verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs",
|
"verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs",
|
||||||
"verify:adb": "npm run build:cached && node scripts/verify-adb.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": "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: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: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",
|
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
|
||||||
@@ -188,6 +197,7 @@
|
|||||||
"verify:gemini-client": "npm run build:cached && node scripts/verify-gemini-client.mjs",
|
"verify:gemini-client": "npm run build:cached && node scripts/verify-gemini-client.mjs",
|
||||||
"verify:opencode-client": "npm run build:cached && node scripts/verify-opencode-client.mjs",
|
"verify:opencode-client": "npm run build:cached && node scripts/verify-opencode-client.mjs",
|
||||||
"verify:grok-client": "npm run build:cached && node scripts/verify-grok-client.mjs",
|
"verify:grok-client": "npm run build:cached && node scripts/verify-grok-client.mjs",
|
||||||
|
"verify:commandcode-client": "npm run build:cached && node scripts/verify-commandcode-client.mjs",
|
||||||
"verify:omp-client": "npm run build:cached && node scripts/verify-omp-client.mjs",
|
"verify:omp-client": "npm run build:cached && node scripts/verify-omp-client.mjs",
|
||||||
"verify:copilot-client": "npm run build:cached && node scripts/verify-copilot-client.mjs",
|
"verify:copilot-client": "npm run build:cached && node scripts/verify-copilot-client.mjs",
|
||||||
"verify:claude-client": "npm run build:cached && node scripts/verify-claude-client.mjs",
|
"verify:claude-client": "npm run build:cached && node scripts/verify-claude-client.mjs",
|
||||||
@@ -235,6 +245,7 @@
|
|||||||
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
|
"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",
|
"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": "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:mcp-catalog": "node scripts/generate-mcp-tool-catalog.mjs",
|
||||||
"artifacts:skills": "node scripts/generate-skill-metadata.mjs",
|
"artifacts:skills": "node scripts/generate-skill-metadata.mjs",
|
||||||
"artifacts:product-catalog": "node scripts/generate-product-catalog.mjs",
|
"artifacts:product-catalog": "node scripts/generate-product-catalog.mjs",
|
||||||
@@ -260,6 +271,7 @@
|
|||||||
"@lydell/node-pty": "1.1.0",
|
"@lydell/node-pty": "1.1.0",
|
||||||
"@modelcontextprotocol/client": "2.3.1",
|
"@modelcontextprotocol/client": "2.3.1",
|
||||||
"@modelcontextprotocol/server": "2.3.1",
|
"@modelcontextprotocol/server": "2.3.1",
|
||||||
|
"@xmldom/xmldom": "0.9.12",
|
||||||
"@xterm/addon-serialize": "0.14.0",
|
"@xterm/addon-serialize": "0.14.0",
|
||||||
"@xterm/headless": "6.0.0",
|
"@xterm/headless": "6.0.0",
|
||||||
"@zip.js/zip.js": "2.15.0",
|
"@zip.js/zip.js": "2.15.0",
|
||||||
@@ -308,6 +320,7 @@
|
|||||||
"oxlint": "1.87.0",
|
"oxlint": "1.87.0",
|
||||||
"turbo": "2.11.7",
|
"turbo": "2.11.7",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
|
"typescript-termux": "npm:typescript@5.9.3",
|
||||||
"vitepress": "1.6.4",
|
"vitepress": "1.6.4",
|
||||||
"vitest": "5.0.3"
|
"vitest": "5.0.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const classes = await mkdtemp(join(output, "classes-"));
|
|||||||
try {
|
try {
|
||||||
const sources = [
|
const sources = [
|
||||||
...(await files(join(source, "src/main/java"), ".java")),
|
...(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 jars = await files(join(install, "Ghidra"), ".jar");
|
||||||
const args = [
|
const args = [
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
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),
|
||||||
|
];
|
||||||
|
// Actionlint validates workflow syntax only. The semantic suite asserts these
|
||||||
|
// workflows' release, publication and self-hosted runner safeguards.
|
||||||
|
const GUARDED_WORKFLOWS = new Set([
|
||||||
|
".github/workflows/release.yml",
|
||||||
|
".github/workflows/real-ghidra-windows.yml",
|
||||||
|
]);
|
||||||
|
// Sources compiled by build-conformance-fixtures.mjs. The readiness journey
|
||||||
|
// analyzes the built C fixture as well as its own applications.
|
||||||
|
const FIXTURE_SOURCES = [
|
||||||
|
"tests/conformance/c/**",
|
||||||
|
"tests/conformance/napi/**",
|
||||||
|
"tests/conformance/objc/**",
|
||||||
|
"tests/conformance/swift/**",
|
||||||
|
"tests/conformance/versions/**",
|
||||||
|
];
|
||||||
|
const READINESS_SOURCES = ["tests/conformance/readiness/**"];
|
||||||
|
const matchesAny = (file, patterns) =>
|
||||||
|
patterns.some((pattern) => matchesGlob(file, pattern));
|
||||||
|
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 (GUARDED_WORKFLOWS.has(file)) selected.code = 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 (matchesAny(file, FIXTURE_SOURCES)) {
|
||||||
|
selected.fixtures = true;
|
||||||
|
selected.readiness = true;
|
||||||
|
}
|
||||||
|
if (matchesAny(file, READINESS_SOURCES)) selected.readiness = 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,
|
command: executable,
|
||||||
arguments: [
|
arguments: [
|
||||||
"--headless=new",
|
"--headless=new",
|
||||||
|
"--password-store=basic",
|
||||||
...(process.env.REA_BROWSER_NO_SANDBOX === "true"
|
...(process.env.REA_BROWSER_NO_SANDBOX === "true"
|
||||||
? ["--no-sandbox"]
|
? ["--no-sandbox"]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -0,0 +1,683 @@
|
|||||||
|
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 raceWithTimeout = (operation, timeoutMs, onTimeout = () => undefined) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
return Promise.race([
|
||||||
|
operation,
|
||||||
|
delay(timeoutMs, undefined, { signal: controller.signal }).then(onTimeout),
|
||||||
|
]).finally(() => controller.abort());
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
// Chrome can leave speculative TCP connections without an HTTP request;
|
||||||
|
// server.close() alone waits indefinitely for those owned sockets.
|
||||||
|
server.closeAllConnections();
|
||||||
|
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 raceWithTimeout(restoreTarget(observer, target.url), 1_000).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
await raceWithTimeout(observer.close(), 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 raceWithTimeout(paused, 10_000, () => {
|
||||||
|
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 raceWithTimeout(
|
||||||
|
pending.catch((pendingCause) => ({ ok: false, error: pendingCause })),
|
||||||
|
5_000,
|
||||||
|
);
|
||||||
|
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 raceWithTimeout(caller.send("Debugger.disable"), 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(
|
throw new Error(
|
||||||
"Real Chrome attach-window console observation was missing",
|
"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");
|
throw new Error("Real Chrome WebSocket metadata was missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +81,8 @@ function assertObservationPrivacy(result) {
|
|||||||
result.network.requests.some(
|
result.network.requests.some(
|
||||||
(request) => request.body_shapes.status !== "not_approved",
|
(request) => request.body_shapes.status !== "not_approved",
|
||||||
) ||
|
) ||
|
||||||
result.network.websocket_events.some(
|
result.network.websocket_connections.some((connection) =>
|
||||||
(event) => event.payload_shape !== null,
|
connection.events.some((event) => event.payload_shape !== null),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -246,15 +250,17 @@ export function assertSensitiveShapes(result) {
|
|||||||
)
|
)
|
||||||
throw new Error("Real Chrome selected console text was not preserved");
|
throw new Error("Real Chrome selected console text was not preserved");
|
||||||
if (
|
if (
|
||||||
!result.network.websocket_events.some(
|
!result.network.websocket_connections.some((connection) =>
|
||||||
(event) =>
|
connection.events.some(
|
||||||
event.payload_shape?.format === "json" &&
|
(event) =>
|
||||||
event.payload_shape.json_shape?.properties.some(
|
event.payload_shape?.format === "json" &&
|
||||||
({ path }) =>
|
event.payload_shape.json_shape?.properties.some(
|
||||||
path.length === 1 &&
|
({ path }) =>
|
||||||
path[0].kind === "property" &&
|
path.length === 1 &&
|
||||||
path[0].name === "token",
|
path[0].kind === "property" &&
|
||||||
),
|
path[0].name === "token",
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error("Real Chrome WebSocket JSON shape was missing");
|
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: padded, length: 1 }),
|
||||||
await query("read_bytes", { address: entry, 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 [
|
for (const [command, flags, operation] of [
|
||||||
["read-bytes", ["--length", "1"], "read_bytes"],
|
["read-bytes", ["--length", "1"], "read_bytes"],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ export async function verifyGhidraBoundaries(
|
|||||||
`${name}: ${JSON.stringify(validate.errors)}`,
|
`${name}: ${JSON.stringify(validate.errors)}`,
|
||||||
);
|
);
|
||||||
assert.deepEqual(reply.structuredContent, JSON.parse(mcpTextValue(reply)));
|
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++;
|
successfulCalls++;
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@@ -378,6 +387,12 @@ export async function verifyGhidraBoundaries(
|
|||||||
).some((item) => item.address === marker.address),
|
).some((item) => item.address === marker.address),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await call("search_strings", {
|
||||||
|
pattern: `REA_GHIDRA_ABSENT_${randomUUID()}`,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(await call("inspect_native_instruction", { address: strings[0].address }))
|
(await call("inspect_native_instruction", { address: strings[0].address }))
|
||||||
.status,
|
.status,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export async function verifyGhidraEntryAliases({
|
|||||||
new URL("../../tests/conformance/ghidra/entry-aliases.c", import.meta.url),
|
new URL("../../tests/conformance/ghidra/entry-aliases.c", import.meta.url),
|
||||||
);
|
);
|
||||||
const path = join(workspace, "entry-aliases");
|
const path = join(workspace, "entry-aliases");
|
||||||
const prefix = process.platform === "darwin" ? "_" : "";
|
|
||||||
try {
|
try {
|
||||||
await exec(compiler, ["-O0", "-g", "-fno-inline", source, "-o", path], {
|
await exec(compiler, ["-O0", "-g", "-fno-inline", source, "-o", path], {
|
||||||
env,
|
env,
|
||||||
@@ -29,7 +28,8 @@ export async function verifyGhidraEntryAliases({
|
|||||||
});
|
});
|
||||||
const bytes = await readFile(path);
|
const bytes = await readFile(path);
|
||||||
await call("close_binary");
|
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 names = await call("list_names");
|
||||||
const alias = names.find(
|
const alias = names.find(
|
||||||
({ value }) => value === `${prefix}rea_entry_alias`,
|
({ 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");
|
assert.ok(canonical, "Fixture alias is not at a function entry");
|
||||||
const xrefsSelectors = [];
|
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 [
|
for (const value of [
|
||||||
canonical.value,
|
`${prefix}rea_alias_target`,
|
||||||
alias.value,
|
alias.value,
|
||||||
`${prefix}rea_xrefs_data`,
|
`${prefix}rea_xrefs_data`,
|
||||||
`${prefix}rea_xrefs_unreferenced`,
|
`${prefix}rea_xrefs_unreferenced`,
|
||||||
@@ -89,6 +91,18 @@ export async function verifyGhidraEntryAliases({
|
|||||||
);
|
);
|
||||||
assert.ok(interior);
|
assert.ok(interior);
|
||||||
assert.notEqual(interior.address, alias.address);
|
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", {
|
const failure = await reject("procedure_address", {
|
||||||
procedure: interior.value,
|
procedure: interior.value,
|
||||||
});
|
});
|
||||||
@@ -153,7 +167,11 @@ export async function verifyGhidraEntryAliases({
|
|||||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||||
);
|
);
|
||||||
const evidence = JSON.parse(stdout);
|
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));
|
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.equal(dossier.procedure.name, "rea_frame_probe");
|
||||||
assert.ok(dossier.comments.some(({ text }) => text === comment));
|
assert.ok(dossier.comments.some(({ text }) => text === comment));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
dossierRecord.raw_result.comments.some(({ text }) => text === comment),
|
dossierRecord.raw_result.value.comments.some(
|
||||||
|
({ text }) => text === comment,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const { stdout } = await exec(
|
const { stdout } = await exec(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ export async function verifyGhidraNamespaceAnnotations({
|
|||||||
);
|
);
|
||||||
return { namespace, address: procedure.address };
|
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" });
|
const ambiguous = await reject("procedure_address", { procedure: "same" });
|
||||||
assert.equal(ambiguous.code, "invalid_request");
|
assert.equal(ambiguous.code, "invalid_request");
|
||||||
for (const { address } of entries)
|
for (const { address } of entries)
|
||||||
@@ -125,6 +134,17 @@ export async function verifyGhidraNamespaceAnnotations({
|
|||||||
});
|
});
|
||||||
assert.deepEqual(unchanged.annotations, qualified.annotations);
|
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(
|
const { stdout } = await exec(
|
||||||
process.execPath,
|
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) {
|
async function requireNamespaceDemangler(env) {
|
||||||
const inspection = inspectGhidraInstallation({
|
const inspection = inspectGhidraInstallation({
|
||||||
environment: process.env,
|
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
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { access } from "node:fs/promises";
|
import { access } from "node:fs/promises";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
// Route production MCP before importing Incur. Incur owns registration helpers
|
// 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");
|
args.length === 1 && (args[0] === "--mcp" || args[0] === "mcp");
|
||||||
const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor";
|
const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor";
|
||||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
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
|
const runtimeFiles = isMcpMode
|
||||||
? ["dist/main.js"]
|
? ["dist/main.js"]
|
||||||
: isMcpDoctorMode
|
: 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 { spawn } from "node:child_process";
|
||||||
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import {
|
||||||
import { tmpdir } from "node:os";
|
chmod,
|
||||||
import { dirname, join, resolve } from "node:path";
|
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 { createInterface } from "node:readline";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { evaluateCodexEvents } from "../dist/evaluation/CodexAgentEval.js";
|
import { evaluateCodexEvents } from "../dist/evaluation/CodexAgentEval.js";
|
||||||
import {
|
import {
|
||||||
@@ -15,32 +24,95 @@ import {
|
|||||||
agentEvaluationPassed,
|
agentEvaluationPassed,
|
||||||
summarizeFactualCorrectness,
|
summarizeFactualCorrectness,
|
||||||
} from "../dist/evaluation/AgentEvaluationReport.js";
|
} 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";
|
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||||
|
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const verifierRun = createVerifierRun();
|
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 timeoutMs = Number(process.env.REA_AGENT_EVAL_TIMEOUT_MS ?? 480_000);
|
||||||
const codex = process.env.REA_CODEX_CLI ?? "codex";
|
const codex = process.env.REA_CODEX_CLI ?? "codex";
|
||||||
const optionalModel = process.env.REA_AGENT_EVAL_MODEL;
|
const optionalModel = process.env.REA_AGENT_EVAL_MODEL;
|
||||||
|
|
||||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 10_000)
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 10_000)
|
||||||
throw new Error("REA_AGENT_EVAL_TIMEOUT_MS must be an integer >= 10000");
|
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 {
|
try {
|
||||||
await mkdir(fixtureRoot, { recursive: true });
|
const codexVersion = await readCommandVersion(codex);
|
||||||
await cp(
|
await Promise.all([
|
||||||
join(repositoryRoot, "skills/reverse-engineer-anything"),
|
mkdir(fixtureRoot, { recursive: true }),
|
||||||
skillDestination,
|
mkdir(account),
|
||||||
{ recursive: true },
|
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(
|
const requestedScenarioIds = new Set(
|
||||||
(process.env.REA_AGENT_EVAL_SCENARIOS ?? "")
|
(process.env.REA_AGENT_EVAL_SCENARIOS ?? "")
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -57,7 +129,11 @@ try {
|
|||||||
expectedFirstTool: "open_binary",
|
expectedFirstTool: "open_binary",
|
||||||
requiresEvidence: false,
|
requiresEvidence: false,
|
||||||
requiredAnswerTermGroups: [["unavailable", "could not", "provider"]],
|
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",
|
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.`,
|
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",
|
id: "javascript-export-shape",
|
||||||
expectedFirstTool: "analyze_javascript_application",
|
expectedFirstTool: "analyze_javascript_application",
|
||||||
@@ -145,12 +253,14 @@ try {
|
|||||||
const results = [];
|
const results = [];
|
||||||
for (const scenario of scenarios) {
|
for (const scenario of scenarios) {
|
||||||
process.stderr.write(`Running Codex agent evaluation: ${scenario.id}\n`);
|
process.stderr.write(`Running Codex agent evaluation: ${scenario.id}\n`);
|
||||||
const fixtureClaims = agentFixtureClaims(scenario.id, targets);
|
const fixtureId = scenario.fixtureScenarioId ?? scenario.id;
|
||||||
const answerInstructions = agentFixtureAnswerInstructions(scenario.id);
|
const fixtureClaims = agentFixtureClaims(fixtureId, targets);
|
||||||
|
const answerInstructions = agentFixtureAnswerInstructions(fixtureId);
|
||||||
const execution = await runCodex(
|
const execution = await runCodex(
|
||||||
answerInstructions.length === 0
|
answerInstructions.length === 0
|
||||||
? scenario.prompt
|
? 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;
|
const transcriptDirectory = process.env.REA_AGENT_EVAL_TRANSCRIPT_DIR;
|
||||||
if (transcriptDirectory !== undefined) {
|
if (transcriptDirectory !== undefined) {
|
||||||
@@ -175,6 +285,12 @@ try {
|
|||||||
...(fixtureClaims === undefined ? {} : { fixtureClaims }),
|
...(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 = {
|
const result = {
|
||||||
id: scenario.id,
|
id: scenario.id,
|
||||||
expectedFirstTool: scenario.expectedFirstTool,
|
expectedFirstTool: scenario.expectedFirstTool,
|
||||||
@@ -185,6 +301,9 @@ try {
|
|||||||
exitCode: execution.exitCode,
|
exitCode: execution.exitCode,
|
||||||
stderr: execution.stderr,
|
stderr: execution.stderr,
|
||||||
...metrics,
|
...metrics,
|
||||||
|
...(targetClarificationPassed === undefined
|
||||||
|
? {}
|
||||||
|
: { targetClarificationPassed }),
|
||||||
};
|
};
|
||||||
results.push(result);
|
results.push(result);
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
@@ -194,13 +313,20 @@ try {
|
|||||||
|
|
||||||
const factualCorrectness = summarizeFactualCorrectness(results);
|
const factualCorrectness = summarizeFactualCorrectness(results);
|
||||||
const summary = {
|
const summary = {
|
||||||
schemaVersion: 3,
|
schemaVersion: 4,
|
||||||
evaluationScope: "routing_workflow_and_configured_fixture_claims",
|
evaluationScope: "routing_workflow_and_configured_fixture_claims",
|
||||||
factualCorrectness,
|
factualCorrectness,
|
||||||
verifier_run: await completeVerifierRun(verifierRun),
|
verifier_run: await completeVerifierRun(verifierRun),
|
||||||
codex,
|
codex,
|
||||||
codexVersion,
|
codexVersion,
|
||||||
model: optionalModel ?? null,
|
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,
|
scenarios: results,
|
||||||
totals: {
|
totals: {
|
||||||
scenarios: results.length,
|
scenarios: results.length,
|
||||||
@@ -253,12 +379,32 @@ try {
|
|||||||
`Agent routing/workflow/answer checks failed: ${failed.map(({ id }) => id).join(", ")}`,
|
`Agent routing/workflow/answer checks failed: ${failed.map(({ id }) => id).join(", ")}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} 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")
|
if (process.env.REA_AGENT_EVAL_KEEP_FIXTURES !== "true")
|
||||||
await rm(evaluationRoot, { recursive: true, force: true });
|
await rm(evaluationRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTargets(root, includeManaged) {
|
async function createTargets(root, includeManaged) {
|
||||||
const fixtures = await createAgentEvaluationFixtures(root, repositoryRoot);
|
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");
|
const managedOutput = join(root, "managed-output");
|
||||||
if (includeManaged) {
|
if (includeManaged) {
|
||||||
@@ -292,15 +438,29 @@ async function createTargets(root, includeManaged) {
|
|||||||
return {
|
return {
|
||||||
native: "/bin/true",
|
native: "/bin/true",
|
||||||
...fixtures,
|
...fixtures,
|
||||||
|
largeJavascript,
|
||||||
managed: join(managedOutput, "AgentEval.dll"),
|
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_ = [
|
const arguments_ = [
|
||||||
|
"--no-daemon",
|
||||||
"exec",
|
"exec",
|
||||||
"--ignore-user-config",
|
|
||||||
"--ignore-rules",
|
|
||||||
"--ephemeral",
|
"--ephemeral",
|
||||||
"--skip-git-repo-check",
|
"--skip-git-repo-check",
|
||||||
"--sandbox",
|
"--sandbox",
|
||||||
@@ -312,18 +472,16 @@ async function runCodex(prompt) {
|
|||||||
evaluationRoot,
|
evaluationRoot,
|
||||||
"-c",
|
"-c",
|
||||||
'approval_policy="never"',
|
'approval_policy="never"',
|
||||||
"-c",
|
...approvedTools.flatMap((name) => [
|
||||||
`mcp_servers.rea.command=${JSON.stringify(process.execPath)}`,
|
"-c",
|
||||||
"-c",
|
`mcp_servers.rea.tools.${name}.approval_mode="approve"`,
|
||||||
`mcp_servers.rea.args=${JSON.stringify([join(repositoryRoot, "scripts/rea.mjs"), "mcp"])}`,
|
]),
|
||||||
"-c",
|
|
||||||
`mcp_servers.rea.startup_timeout_sec=${String(MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)}`,
|
|
||||||
...(optionalModel === undefined ? [] : ["--model", optionalModel]),
|
...(optionalModel === undefined ? [] : ["--model", optionalModel]),
|
||||||
prompt,
|
prompt,
|
||||||
];
|
];
|
||||||
const child = spawn(codex, arguments_, {
|
const child = spawn(codex, arguments_, {
|
||||||
cwd: evaluationRoot,
|
cwd: evaluationRoot,
|
||||||
env: process.env,
|
env: evaluationEnvironment,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
const events = [];
|
const events = [];
|
||||||
@@ -362,9 +520,16 @@ async function runCodex(prompt) {
|
|||||||
return { events, exitCode, stderr: stderr.join("").slice(0, 32_768) };
|
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_, {
|
const child = spawn(command, arguments_, {
|
||||||
cwd,
|
cwd,
|
||||||
|
env,
|
||||||
stdio: ["ignore", "ignore", "pipe"],
|
stdio: ["ignore", "ignore", "pipe"],
|
||||||
});
|
});
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
|||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { isAbsolute, join, relative } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import Ajv2020 from "ajv/dist/2020.js";
|
||||||
|
import {
|
||||||
|
TOOL_CONTRACTS,
|
||||||
|
toolContract,
|
||||||
|
} from "../dist/contracts/toolContracts.js";
|
||||||
|
import { exec } from "./lib/verify-package-core.mjs";
|
||||||
|
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||||
|
import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs";
|
||||||
|
|
||||||
|
assert(
|
||||||
|
process.platform !== "win32",
|
||||||
|
"This verifier requires a disposable POSIX account; Windows is unverified",
|
||||||
|
);
|
||||||
|
const account = process.env.REA_VERIFY_COMMANDCODE_ACCOUNT_HOME;
|
||||||
|
assert(
|
||||||
|
account && isAbsolute(account) && account === homedir(),
|
||||||
|
"Run as a disposable account whose actual OS home matches REA_VERIFY_COMMANDCODE_ACCOUNT_HOME; do not override HOME",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await readFile(join(account, ".rea-client-verification"), "utf8"),
|
||||||
|
"Disposable REA client verification account\n",
|
||||||
|
);
|
||||||
|
const repo = fileURLToPath(new URL("..", import.meta.url));
|
||||||
|
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||||
|
const command = process.env.REA_VERIFY_COMMANDCODE_COMMAND ?? "cmd";
|
||||||
|
const mode = process.argv[2] ?? "call";
|
||||||
|
assert(
|
||||||
|
["chat", "call"].includes(mode),
|
||||||
|
"Usage: verify-commandcode-client.mjs [chat|call]",
|
||||||
|
);
|
||||||
|
const verifier = createVerifierRun();
|
||||||
|
const lab = await mkdtemp(join(account, "rea-commandcode-client-"));
|
||||||
|
const workspace = join(lab, "workspace");
|
||||||
|
const target = join(workspace, "fixture α测试");
|
||||||
|
const profile = join(account, ".commandcode");
|
||||||
|
const configPath = join(profile, "mcp.json");
|
||||||
|
const skillDirectory = join(
|
||||||
|
account,
|
||||||
|
".agents",
|
||||||
|
"skills",
|
||||||
|
"reverse-engineer-anything",
|
||||||
|
);
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(target, { recursive: true }),
|
||||||
|
mkdir(profile, { recursive: true }),
|
||||||
|
mkdir(join(lab, "tmp")),
|
||||||
|
]);
|
||||||
|
await exec("git", ["init", "--quiet", workspace], { timeout: 30_000 });
|
||||||
|
await writeFile(
|
||||||
|
join(target, "app.js"),
|
||||||
|
'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n',
|
||||||
|
);
|
||||||
|
const environment = {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
NODE_OPTIONS: process.env.NODE_OPTIONS,
|
||||||
|
USERPROFILE: account,
|
||||||
|
TMPDIR: join(lab, "tmp"),
|
||||||
|
CMD_LOCAL_ONLY: "1",
|
||||||
|
// Native print mode checks for an account key even with a keyless BYOK model.
|
||||||
|
// This synthetic value is never a real credential; local-only refuses hosted API calls.
|
||||||
|
COMMAND_CODE_API_KEY: "local-fixture-only",
|
||||||
|
COMMANDCODE_SKIP_UPDATES: "1",
|
||||||
|
COMMANDCODE_DISABLE_CRON: "1",
|
||||||
|
OTEL_SDK_DISABLED: "true",
|
||||||
|
REA_PROCESS_RUN_ID: verifier.run_id,
|
||||||
|
};
|
||||||
|
const expectedNames = TOOL_CONTRACTS.map(
|
||||||
|
(tool) => "mcp__rea__" + tool.name,
|
||||||
|
).sort();
|
||||||
|
const schemaValidator = new Ajv2020({ strict: false, validateFormats: false });
|
||||||
|
const textValues = (value) =>
|
||||||
|
typeof value === "string"
|
||||||
|
? [value]
|
||||||
|
: value !== null && typeof value === "object"
|
||||||
|
? Object.values(value).flatMap(textValues)
|
||||||
|
: [];
|
||||||
|
let clientVersion;
|
||||||
|
let expectedSkill;
|
||||||
|
let inventoryChecked = false;
|
||||||
|
let skillLoaded = false;
|
||||||
|
let schemasChecked = 0;
|
||||||
|
let catalogArtifact;
|
||||||
|
let catalogDigest;
|
||||||
|
let catalogProjection;
|
||||||
|
let artifactProjectionSeen = false;
|
||||||
|
let evidenceSeen = false;
|
||||||
|
let evidenceBytes;
|
||||||
|
let finalSeen = false;
|
||||||
|
let stage = 0;
|
||||||
|
let failure;
|
||||||
|
const call = (name, args) => ({
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: `call_commandcode_${++stage}`,
|
||||||
|
type: "function",
|
||||||
|
function: { name, arguments: JSON.stringify(args) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const validateCatalog = async (text) => {
|
||||||
|
const entries = [
|
||||||
|
...text.matchAll(
|
||||||
|
/^### (mcp__rea__[A-Za-z0-9_]+)\n[\s\S]*?\nParameters:\n```json\n([\s\S]*?)\n```/gm,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
entries.map((entry) => entry[1]).sort(),
|
||||||
|
expectedNames,
|
||||||
|
"Native exact-name search must return every REA input schema",
|
||||||
|
);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const schema = JSON.parse(entry[2]);
|
||||||
|
assert(
|
||||||
|
schemaValidator.validateSchema(schema),
|
||||||
|
`${entry[1]}: invalid native schema: ${JSON.stringify(schemaValidator.errors)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
schemasChecked = entries.length;
|
||||||
|
catalogDigest = createHash("sha256").update(text).digest("hex");
|
||||||
|
await writeFile(join(lab, "native-catalog.md"), text);
|
||||||
|
};
|
||||||
|
const fixture = await createOpenAiModelFixture({
|
||||||
|
directory: lab,
|
||||||
|
prefix: "commandcode",
|
||||||
|
onRequest: async ({ body, tools }) => {
|
||||||
|
assert(tools.some((tool) => tool.name === "search_tools"));
|
||||||
|
const strings = textValues(body.messages);
|
||||||
|
if (!inventoryChecked) {
|
||||||
|
const names = [
|
||||||
|
...new Set(
|
||||||
|
strings.flatMap(
|
||||||
|
(text) => text.match(/mcp__rea__[A-Za-z0-9_]+/g) ?? [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].sort();
|
||||||
|
assert.deepEqual(
|
||||||
|
names,
|
||||||
|
expectedNames,
|
||||||
|
"Default native deferred-tool prompt must advertise the complete REA inventory",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
tools.filter((tool) => tool.name.startsWith("mcp__rea__")).length,
|
||||||
|
0,
|
||||||
|
"Keep native lazy schema delivery enabled",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
tools
|
||||||
|
.find((tool) => tool.name === "activate_skill")
|
||||||
|
?.parameters.properties.name.enum.includes(
|
||||||
|
"reverse-engineer-anything",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
inventoryChecked = true;
|
||||||
|
}
|
||||||
|
if (mode === "chat") {
|
||||||
|
assert.equal(
|
||||||
|
body.messages.filter((message) => message.role === "tool").length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
finalSeen = true;
|
||||||
|
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
|
||||||
|
}
|
||||||
|
if (stage === 0)
|
||||||
|
return call("activate_skill", { name: "reverse-engineer-anything" });
|
||||||
|
assert(
|
||||||
|
strings.some((text) => text.includes(expectedSkill)),
|
||||||
|
"The model must receive the complete activated skill body",
|
||||||
|
);
|
||||||
|
skillLoaded = true;
|
||||||
|
const last = body.messages
|
||||||
|
.filter((message) => message.role === "tool")
|
||||||
|
.at(-1)?.content;
|
||||||
|
assert.equal(typeof last, "string");
|
||||||
|
if (stage === 1)
|
||||||
|
return call("search_tools", {
|
||||||
|
query: "select:" + expectedNames.join(","),
|
||||||
|
});
|
||||||
|
if (stage === 2) {
|
||||||
|
const spill = last.match(
|
||||||
|
/\[full output saved to: (.+?) — read it with read_file/,
|
||||||
|
);
|
||||||
|
if (spill) {
|
||||||
|
catalogArtifact = spill[1];
|
||||||
|
const child = relative(environment.TMPDIR, catalogArtifact);
|
||||||
|
assert(
|
||||||
|
isAbsolute(catalogArtifact) &&
|
||||||
|
child &&
|
||||||
|
!child.startsWith("..") &&
|
||||||
|
!isAbsolute(child),
|
||||||
|
"Native spill must remain inside this run's temporary directory",
|
||||||
|
);
|
||||||
|
const text = await readFile(catalogArtifact, "utf8");
|
||||||
|
await validateCatalog(text);
|
||||||
|
catalogProjection = { schemas: schemasChecked, sha256: catalogDigest };
|
||||||
|
const query =
|
||||||
|
"const fs=require('node:fs'),crypto=require('node:crypto');const text=fs.readFileSync(process.argv[1],'utf8');const schemas=[...text.matchAll(/^### (mcp__rea__[A-Za-z0-9_]+)\\n/gm)].length;console.log(JSON.stringify({rea_catalog:{schemas,sha256:crypto.createHash('sha256').update(text).digest('hex')}}));";
|
||||||
|
return call("shell_command", {
|
||||||
|
command: process.execPath,
|
||||||
|
args: ["-e", query, catalogArtifact],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await validateCatalog(last);
|
||||||
|
return call("mcp__rea__analyze_javascript_application", {
|
||||||
|
input_path: target,
|
||||||
|
format: "directory",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (catalogArtifact && !artifactProjectionSeen) {
|
||||||
|
const line = last
|
||||||
|
.split("\n")
|
||||||
|
.find((item) => item.startsWith('{"rea_catalog":'));
|
||||||
|
assert(line, "Native shell must return the selected saved-catalog facts");
|
||||||
|
assert.deepEqual(JSON.parse(line).rea_catalog, catalogProjection);
|
||||||
|
artifactProjectionSeen = true;
|
||||||
|
return call("mcp__rea__analyze_javascript_application", {
|
||||||
|
input_path: target,
|
||||||
|
format: "directory",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const evidence = toolContract(
|
||||||
|
"analyze_javascript_application",
|
||||||
|
).outputSchema.parse(JSON.parse(last));
|
||||||
|
assert.equal(evidence.subject.local_path, target);
|
||||||
|
const shapes = evidence.normalized_result.graph.nodes
|
||||||
|
.flatMap((node) => node.observations)
|
||||||
|
.map((observation) => observation.properties)
|
||||||
|
.filter(
|
||||||
|
(properties) =>
|
||||||
|
properties.semantic_role === "export-return-shapes" &&
|
||||||
|
properties.exported_name === "result",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
shapes.length > 0 &&
|
||||||
|
shapes.every(
|
||||||
|
(shape) => shape.return_shape_coverage?.projection_complete,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const fields = shapes.flatMap((shape) =>
|
||||||
|
shape.static_return_shapes.flatMap((item) => item.fields),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
fields.some(
|
||||||
|
(field) =>
|
||||||
|
field.path === "/clientCompatibility" &&
|
||||||
|
field.state === "literal" &&
|
||||||
|
field.value === "REA_CLIENT_FIXTURE",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
fields.some(
|
||||||
|
(field) =>
|
||||||
|
field.path === "/count" &&
|
||||||
|
field.state === "literal" &&
|
||||||
|
field.value === 7,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
evidenceBytes = Buffer.byteLength(last);
|
||||||
|
await writeFile(join(lab, "native-evidence.json"), last);
|
||||||
|
evidenceSeen = true;
|
||||||
|
finalSeen = true;
|
||||||
|
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
join(profile, "providers.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
provider: {
|
||||||
|
"rea-local": {
|
||||||
|
baseURL: fixture.baseUrl,
|
||||||
|
apiKey: false,
|
||||||
|
models: {
|
||||||
|
"rea-client-fixture": { contextWindow: 1_000_000, maxOutput: 4096 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const original =
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: { other: { command: "unrelated-server", enabled: false } },
|
||||||
|
preserved: "keep unrelated settings",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + "\n";
|
||||||
|
await writeFile(configPath, original);
|
||||||
|
const rea = (args) =>
|
||||||
|
exec(
|
||||||
|
process.execPath,
|
||||||
|
[join(runtimeRoot, "scripts/rea.mjs"), ...args, "--json"],
|
||||||
|
{ env: environment, cwd: workspace, timeout: 60_000 },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
clientVersion = (
|
||||||
|
await exec(command, ["--version"], {
|
||||||
|
env: environment,
|
||||||
|
cwd: workspace,
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
).stdout.trim();
|
||||||
|
for (const args of [
|
||||||
|
["setup", "--client", "commandcode", "--dry-run"],
|
||||||
|
["setup", "--client", "commandcode", "--yes"],
|
||||||
|
["doctor", "--client", "commandcode", "--skill"],
|
||||||
|
]) {
|
||||||
|
const response = await rea(args);
|
||||||
|
await writeFile(
|
||||||
|
join(lab, `${args[0]}${args.includes("--dry-run") ? "-plan" : ""}.json`),
|
||||||
|
response.stdout,
|
||||||
|
);
|
||||||
|
const result = JSON.parse(response.stdout);
|
||||||
|
if (args[0] === "doctor") assert.equal(result.healthy, true);
|
||||||
|
else
|
||||||
|
assert.equal(
|
||||||
|
result.status,
|
||||||
|
args.includes("--dry-run") ? "planned" : "ready",
|
||||||
|
);
|
||||||
|
if (args.includes("--dry-run")) {
|
||||||
|
assert(
|
||||||
|
result.plannedActions.some((action) => action.target === configPath),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
result.plannedActions.every(
|
||||||
|
(action) =>
|
||||||
|
action.target === configPath ||
|
||||||
|
action.target === skillDirectory ||
|
||||||
|
action.target.startsWith(skillDirectory + "/"),
|
||||||
|
),
|
||||||
|
"Refuse setup writes outside the disposable account's configuration and skill",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
|
||||||
|
const configured = await readFile(configPath, "utf8");
|
||||||
|
assert.equal(JSON.parse(configured).preserved, "keep unrelated settings");
|
||||||
|
assert.equal(
|
||||||
|
JSON.parse(configured).mcpServers.other.command,
|
||||||
|
"unrelated-server",
|
||||||
|
);
|
||||||
|
const repeated = JSON.parse(
|
||||||
|
(await rea(["setup", "--client", "commandcode", "--yes"])).stdout,
|
||||||
|
);
|
||||||
|
assert.deepEqual(repeated.appliedActions, []);
|
||||||
|
assert.equal(await readFile(configPath, "utf8"), configured);
|
||||||
|
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
|
||||||
|
expectedSkill = (await readFile(join(skillDirectory, "SKILL.md"), "utf8"))
|
||||||
|
.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "")
|
||||||
|
.trim();
|
||||||
|
const execution = exec(
|
||||||
|
command,
|
||||||
|
[
|
||||||
|
"-p",
|
||||||
|
mode === "chat"
|
||||||
|
? "Say hello briefly."
|
||||||
|
: `Use the REA skill to analyze ${target}.`,
|
||||||
|
"-m",
|
||||||
|
"rea-local/rea-client-fixture",
|
||||||
|
"--local-only",
|
||||||
|
"--skip-onboarding",
|
||||||
|
"--trust",
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
|
"--max-turns",
|
||||||
|
"8",
|
||||||
|
"--yolo",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: environment,
|
||||||
|
cwd: workspace,
|
||||||
|
timeout: 120_000,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
execution.child.stdin.end();
|
||||||
|
const response = await execution;
|
||||||
|
await writeFile(join(lab, `${mode}.stdout`), response.stdout);
|
||||||
|
await writeFile(join(lab, `${mode}.stderr`), response.stderr);
|
||||||
|
assert.equal(fixture.failure, undefined);
|
||||||
|
const events = response.stdout
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => JSON.parse(line));
|
||||||
|
assert(
|
||||||
|
events.some(
|
||||||
|
(event) =>
|
||||||
|
event.type === "result" &&
|
||||||
|
event.subtype === "success" &&
|
||||||
|
event.finalText === "REA_CLIENT_COMPATIBILITY_OK",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert(inventoryChecked && finalSeen);
|
||||||
|
if (mode === "call")
|
||||||
|
assert(
|
||||||
|
skillLoaded &&
|
||||||
|
schemasChecked === expectedNames.length &&
|
||||||
|
evidenceSeen &&
|
||||||
|
(!catalogArtifact || artifactProjectionSeen),
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
failure = cause;
|
||||||
|
if (typeof cause?.stdout === "string")
|
||||||
|
await writeFile(join(lab, `${mode}.stdout`), cause.stdout);
|
||||||
|
if (typeof cause?.stderr === "string")
|
||||||
|
await writeFile(join(lab, `${mode}.stderr`), cause.stderr);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
const receipt = {
|
||||||
|
client: "Command Code",
|
||||||
|
clientVersion,
|
||||||
|
mode,
|
||||||
|
passed: failure === undefined,
|
||||||
|
artifacts: lab,
|
||||||
|
runtimeRoot,
|
||||||
|
host: {
|
||||||
|
platform: process.platform,
|
||||||
|
architecture: process.arch,
|
||||||
|
node: process.versions.node,
|
||||||
|
},
|
||||||
|
modelFixture:
|
||||||
|
"loopback OpenAI completions/SSE, keyless BYOK, synthetic account-key gate and usage; no hosted authentication or live-model claim",
|
||||||
|
discovery:
|
||||||
|
"actual disposable OS account home; native user MCP configuration and default shared skill discovery; Windows unverified",
|
||||||
|
interface:
|
||||||
|
"default deferred schemas, complete exact-name search, native saved-catalog query when needed, actual JavaScript MCP call",
|
||||||
|
inventoryChecked,
|
||||||
|
catalogSize: inventoryChecked ? expectedNames.length : 0,
|
||||||
|
schemasChecked,
|
||||||
|
skillLoaded,
|
||||||
|
catalogArtifact,
|
||||||
|
catalogDigest,
|
||||||
|
artifactProjectionSeen,
|
||||||
|
evidenceSeen,
|
||||||
|
evidenceBytes,
|
||||||
|
finalSeen,
|
||||||
|
requests: fixture.requests,
|
||||||
|
probes: fixture.probes,
|
||||||
|
fixtureFailure: fixture.failure,
|
||||||
|
verifier: await completeVerifierRun(verifier),
|
||||||
|
};
|
||||||
|
await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2));
|
||||||
|
console.log(JSON.stringify(receipt));
|
||||||
|
}
|
||||||
|
if (failure !== undefined) throw failure;
|
||||||
@@ -22,6 +22,13 @@ const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
|||||||
const command = process.env.REA_VERIFY_COPILOT_COMMAND ?? "copilot";
|
const command = process.env.REA_VERIFY_COPILOT_COMMAND ?? "copilot";
|
||||||
const mode = process.argv[2] ?? "call";
|
const mode = process.argv[2] ?? "call";
|
||||||
const model = process.env.REA_VERIFY_COPILOT_MODEL ?? "gpt-5.4";
|
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(
|
assert(
|
||||||
["chat", "call"].includes(mode),
|
["chat", "call"].includes(mode),
|
||||||
"Usage: verify-copilot-client.mjs [chat|call]",
|
"Usage: verify-copilot-client.mjs [chat|call]",
|
||||||
@@ -44,6 +51,7 @@ await exec("git", ["init", "--quiet", workspace], { timeout: 30_000 });
|
|||||||
const environment = {
|
const environment = {
|
||||||
PATH: process.env.PATH,
|
PATH: process.env.PATH,
|
||||||
NODE_OPTIONS: process.env.NODE_OPTIONS,
|
NODE_OPTIONS: process.env.NODE_OPTIONS,
|
||||||
|
HOME: account,
|
||||||
USERPROFILE: account,
|
USERPROFILE: account,
|
||||||
COPILOT_HOME: profile,
|
COPILOT_HOME: profile,
|
||||||
COPILOT_OFFLINE: "true",
|
COPILOT_OFFLINE: "true",
|
||||||
@@ -54,7 +62,7 @@ const environment = {
|
|||||||
COPILOT_PROVIDER_BEARER_TOKEN: "",
|
COPILOT_PROVIDER_BEARER_TOKEN: "",
|
||||||
COPILOT_PROVIDER_HEADERS: "",
|
COPILOT_PROVIDER_HEADERS: "",
|
||||||
COPILOT_PROVIDER_MODEL_ID: model,
|
COPILOT_PROVIDER_MODEL_ID: model,
|
||||||
COPILOT_PROVIDER_WIRE_MODEL: model,
|
COPILOT_PROVIDER_WIRE_MODEL: wireModel,
|
||||||
COPILOT_PROVIDER_MAX_PROMPT_TOKENS: "1000000",
|
COPILOT_PROVIDER_MAX_PROMPT_TOKENS: "1000000",
|
||||||
COPILOT_PROVIDER_MAX_OUTPUT_TOKENS: "4096",
|
COPILOT_PROVIDER_MAX_OUTPUT_TOKENS: "4096",
|
||||||
REA_PROCESS_RUN_ID: verifier.run_id,
|
REA_PROCESS_RUN_ID: verifier.run_id,
|
||||||
@@ -112,10 +120,17 @@ const extractEvidence = (value) => {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
const wireModels = [];
|
||||||
const fixture = await createOpenAiModelFixture({
|
const fixture = await createOpenAiModelFixture({
|
||||||
directory: lab,
|
directory: lab,
|
||||||
prefix: "copilot",
|
prefix: "copilot",
|
||||||
onRequest: async ({ body, tools, requestIndex }) => {
|
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.
|
// Auxiliary requests without tools cannot execute the analyst's workflow.
|
||||||
if (tools.length === 0)
|
if (tools.length === 0)
|
||||||
return { role: "assistant", content: "REA fixture title" };
|
return { role: "assistant", content: "REA fixture title" };
|
||||||
@@ -311,6 +326,16 @@ try {
|
|||||||
assert.deepEqual(JSON.parse(repeated.stdout).appliedActions, []);
|
assert.deepEqual(JSON.parse(repeated.stdout).appliedActions, []);
|
||||||
assert.equal(await readFile(configPath, "utf8"), configured);
|
assert.equal(await readFile(configPath, "utf8"), configured);
|
||||||
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
|
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(
|
const installedSkill = await readFile(
|
||||||
join(skillDirectory, "SKILL.md"),
|
join(skillDirectory, "SKILL.md"),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -408,6 +433,8 @@ try {
|
|||||||
clientVersion,
|
clientVersion,
|
||||||
mode,
|
mode,
|
||||||
model,
|
model,
|
||||||
|
wireModel,
|
||||||
|
inputSchemaProfile,
|
||||||
passed: failure === undefined,
|
passed: failure === undefined,
|
||||||
artifacts: lab,
|
artifacts: lab,
|
||||||
runtimeRoot,
|
runtimeRoot,
|
||||||
@@ -421,7 +448,9 @@ try {
|
|||||||
skillDiscovery:
|
skillDiscovery:
|
||||||
"native COPILOT_HOME MCP configuration and caller-added isolated skill directory; default OS-home discovery unverified",
|
"native COPILOT_HOME MCP configuration and caller-added isolated skill directory; default OS-home discovery unverified",
|
||||||
capacityBoundary:
|
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,
|
catalogSize,
|
||||||
schemasChecked,
|
schemasChecked,
|
||||||
spillReadRequested,
|
spillReadRequested,
|
||||||
@@ -429,6 +458,7 @@ try {
|
|||||||
skillLoaded,
|
skillLoaded,
|
||||||
artifactDigest,
|
artifactDigest,
|
||||||
finalSeen,
|
finalSeen,
|
||||||
|
wireModels,
|
||||||
requests: fixture.requests,
|
requests: fixture.requests,
|
||||||
probes: fixture.probes,
|
probes: fixture.probes,
|
||||||
fixtureFailure: fixture.failure,
|
fixtureFailure: fixture.failure,
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ await Promise.all([
|
|||||||
]);
|
]);
|
||||||
const environment = {
|
const environment = {
|
||||||
PATH: process.env.PATH ?? "",
|
PATH: process.env.PATH ?? "",
|
||||||
|
HOME: account,
|
||||||
USERPROFILE: account,
|
USERPROFILE: account,
|
||||||
DSH_HOME: profile,
|
DSH_HOME: profile,
|
||||||
DSH_AGENTS_HOME: join(account, ".agents"),
|
|
||||||
OPENAI_API_KEY: "local-fixture-key",
|
OPENAI_API_KEY: "local-fixture-key",
|
||||||
REA_PROCESS_RUN_ID: verifier.run_id,
|
REA_PROCESS_RUN_ID: verifier.run_id,
|
||||||
};
|
};
|
||||||
@@ -328,7 +328,7 @@ try {
|
|||||||
"loopback custom OpenAI-compatible adapter; no live DeepSeek provider claim",
|
"loopback custom OpenAI-compatible adapter; no live DeepSeek provider claim",
|
||||||
modelContextWindow: 1000000,
|
modelContextWindow: 1000000,
|
||||||
skillDiscovery:
|
skillDiscovery:
|
||||||
"native DSH_AGENTS_HOME personal directory; isolated project root",
|
"native HOME shared personal directory; isolated project root",
|
||||||
catalogSize: catalog?.length,
|
catalogSize: catalog?.length,
|
||||||
patternCount,
|
patternCount,
|
||||||
uniquePatterns: patterns.size,
|
uniquePatterns: patterns.size,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ await writeFile(
|
|||||||
);
|
);
|
||||||
const environment = {
|
const environment = {
|
||||||
PATH: process.env.PATH ?? "",
|
PATH: process.env.PATH ?? "",
|
||||||
|
HOME: account,
|
||||||
USERPROFILE: account,
|
USERPROFILE: account,
|
||||||
[client === "pi" ? "PI_CODING_AGENT_DIR" : "HERMES_HOME"]: profile,
|
[client === "pi" ? "PI_CODING_AGENT_DIR" : "HERMES_HOME"]: profile,
|
||||||
PI_OFFLINE: "1",
|
PI_OFFLINE: "1",
|
||||||
@@ -71,13 +72,6 @@ const original =
|
|||||||
})
|
})
|
||||||
: "# Keep caller settings.\nmcp_servers:\n other:\n command: unrelated-server\n enabled: false\n";
|
: "# Keep caller settings.\nmcp_servers:\n other:\n command: unrelated-server\n enabled: false\n";
|
||||||
await writeFile(configPath, original);
|
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 = (
|
const clientVersion = (
|
||||||
await exec(command, ["--version"], { env: environment, timeout: 30_000 })
|
await exec(command, ["--version"], { env: environment, timeout: 30_000 })
|
||||||
).stdout.trim();
|
).stdout.trim();
|
||||||
@@ -448,7 +442,7 @@ try {
|
|||||||
"loopback deterministic OpenAI-compatible endpoint; no live provider claim",
|
"loopback deterministic OpenAI-compatible endpoint; no live provider claim",
|
||||||
skillDiscovery:
|
skillDiscovery:
|
||||||
client === "pi"
|
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",
|
: "native HERMES_HOME personal directory",
|
||||||
stickyHermes,
|
stickyHermes,
|
||||||
catalogSize: catalog?.length,
|
catalogSize: catalog?.length,
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ const callerDirectory = join(workspace, "caller cwd with spaces");
|
|||||||
const scriptCollisions = [
|
const scriptCollisions = [
|
||||||
"ReaGhidraBridge.java",
|
"ReaGhidraBridge.java",
|
||||||
"ReaGhidraPrepareCom.java",
|
"ReaGhidraPrepareCom.java",
|
||||||
|
"ReaGhidraApplySeeds.java",
|
||||||
"ReaGhidraNoReturnFix.java",
|
"ReaGhidraNoReturnFix.java",
|
||||||
];
|
];
|
||||||
await mkdir(callerDirectory);
|
await mkdir(callerDirectory);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ await Promise.all([
|
|||||||
]);
|
]);
|
||||||
const baseEnvironment = {
|
const baseEnvironment = {
|
||||||
PATH: process.env.PATH ?? "",
|
PATH: process.env.PATH ?? "",
|
||||||
|
HOME: account,
|
||||||
USERPROFILE: account,
|
USERPROFILE: account,
|
||||||
QWEN_HOME: profile,
|
QWEN_HOME: profile,
|
||||||
QWEN_RUNTIME_DIR: runtime,
|
QWEN_RUNTIME_DIR: runtime,
|
||||||
@@ -71,9 +72,6 @@ const original = JSON.stringify(
|
|||||||
},
|
},
|
||||||
telemetry: { enabled: false },
|
telemetry: { enabled: false },
|
||||||
model: { name: "rea-client-fixture" },
|
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 } },
|
mcpServers: { other: { command: "unrelated-server", disabled: true } },
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@@ -112,7 +110,7 @@ const configured = JSON.parse(
|
|||||||
await readFile(join(profile, "settings.json"), "utf8"),
|
await readFile(join(profile, "settings.json"), "utf8"),
|
||||||
);
|
);
|
||||||
assert.deepEqual(configured.model, { name: "rea-client-fixture" });
|
assert.deepEqual(configured.model, { name: "rea-client-fixture" });
|
||||||
assert.deepEqual(configured.skills, { directories: [sharedSkills] });
|
assert.equal(configured.skills, undefined);
|
||||||
assert.deepEqual(configured.mcpServers.other, {
|
assert.deepEqual(configured.mcpServers.other, {
|
||||||
command: "unrelated-server",
|
command: "unrelated-server",
|
||||||
disabled: true,
|
disabled: true,
|
||||||
@@ -384,7 +382,7 @@ try {
|
|||||||
modelFixture:
|
modelFixture:
|
||||||
"loopback deterministic OpenAI-compatible endpoint; no live model-provider claim",
|
"loopback deterministic OpenAI-compatible endpoint; no live model-provider claim",
|
||||||
skillDiscovery:
|
skillDiscovery:
|
||||||
"caller-configured isolated shared directory; default OS home discovery unverified",
|
"native HOME shared personal directory; no explicit skill setting",
|
||||||
catalogSize: TOOL_CONTRACTS.length,
|
catalogSize: TOOL_CONTRACTS.length,
|
||||||
requests: fixture.requests,
|
requests: fixture.requests,
|
||||||
probes: fixture.probes,
|
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 { verifyBrowserCaptureMetadataBudget } from "./lib/browser-capture-metadata-budget-e2e.mjs";
|
||||||
import { artifactCliEvidence, artifactMcpResult } from "./lib/artifact-e2e.mjs";
|
import { artifactCliEvidence, artifactMcpResult } from "./lib/artifact-e2e.mjs";
|
||||||
import { verifyScenarioEnvironment } from "./lib/browser-scenario-environment-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 REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000;
|
||||||
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
|
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
|
||||||
@@ -64,6 +65,7 @@ try {
|
|||||||
executable,
|
executable,
|
||||||
[
|
[
|
||||||
"--headless=new",
|
"--headless=new",
|
||||||
|
"--password-store=basic",
|
||||||
...(process.env.REA_BROWSER_NO_SANDBOX === "true"
|
...(process.env.REA_BROWSER_NO_SANDBOX === "true"
|
||||||
? ["--no-sandbox"]
|
? ["--no-sandbox"]
|
||||||
: []),
|
: []),
|
||||||
@@ -275,11 +277,18 @@ try {
|
|||||||
throw new Error("Scenario attachment terminated its external browser");
|
throw new Error("Scenario attachment terminated its external browser");
|
||||||
|
|
||||||
const profilesBefore = await scenarioProfiles();
|
const profilesBefore = await scenarioProfiles();
|
||||||
|
process.stderr.write("Browser verifier: scenario environment\n");
|
||||||
const scenarioEnvironment = await verifyScenarioEnvironment(
|
const scenarioEnvironment = await verifyScenarioEnvironment(
|
||||||
endpoint,
|
endpoint,
|
||||||
target,
|
target,
|
||||||
site.origin,
|
site.origin,
|
||||||
);
|
);
|
||||||
|
process.stderr.write("Browser verifier: scenario storage\n");
|
||||||
|
const scenarioStorage = await verifyScenarioStorage({
|
||||||
|
executable,
|
||||||
|
endpoint,
|
||||||
|
targetId: target,
|
||||||
|
});
|
||||||
const launchedScenario = await createBrowserScenarioProvider(
|
const launchedScenario = await createBrowserScenarioProvider(
|
||||||
process.env,
|
process.env,
|
||||||
).captureScenario(
|
).captureScenario(
|
||||||
@@ -332,7 +341,9 @@ try {
|
|||||||
`Browser scenario included unselected structured value: ${unselectedValue}`,
|
`Browser scenario included unselected structured value: ${unselectedValue}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
process.stderr.write("Browser verifier: large screenshot\n");
|
||||||
const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin);
|
const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin);
|
||||||
|
process.stderr.write("Browser verifier: popup coverage\n");
|
||||||
const popupEvents = await verifyPopupEventCoverage(executable);
|
const popupEvents = await verifyPopupEventCoverage(executable);
|
||||||
const networkEvidence = await verifyBrowserNetworkEvidence(
|
const networkEvidence = await verifyBrowserNetworkEvidence(
|
||||||
executable,
|
executable,
|
||||||
@@ -354,7 +365,11 @@ try {
|
|||||||
scripts: observed.value.scripts.items.length,
|
scripts: observed.value.scripts.items.length,
|
||||||
networkRequests: observed.value.network.requests.length,
|
networkRequests: observed.value.network.requests.length,
|
||||||
consoleEvents: observed.value.console.events.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,
|
bundleScripts: bundle.capture.scripts_analyzed,
|
||||||
sourceMaps: bundle.observations.source_maps.processed,
|
sourceMaps: bundle.observations.source_maps.processed,
|
||||||
bundle_cli_and_stdio_mcp: true,
|
bundle_cli_and_stdio_mcp: true,
|
||||||
@@ -373,6 +388,7 @@ try {
|
|||||||
browserScenarioLaunchCleanup: "terminated-owned-process",
|
browserScenarioLaunchCleanup: "terminated-owned-process",
|
||||||
scenarioFailure,
|
scenarioFailure,
|
||||||
scenarioEnvironment,
|
scenarioEnvironment,
|
||||||
|
scenarioStorage,
|
||||||
verified: true,
|
verified: true,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
} from "./verify-real-ghidra-assertions.mjs";
|
} from "./verify-real-ghidra-assertions.mjs";
|
||||||
|
|
||||||
async function functionCall(client, operation, parameters) {
|
async function functionCall(client, operation, parameters) {
|
||||||
|
return (await functionObservationCall(client, operation, parameters)).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function functionObservationCall(client, operation, parameters) {
|
||||||
const input = parseGhidraFunctionInput(operation, parameters);
|
const input = parseGhidraFunctionInput(operation, parameters);
|
||||||
if (!input.ok) throw input.error;
|
if (!input.ok) throw input.error;
|
||||||
const called = await client.callTool(operation, input.value);
|
const called = await client.callTool(operation, input.value);
|
||||||
@@ -153,6 +157,57 @@ export async function verifyDebugFunctionOperations(
|
|||||||
const denseSwitchDossier = await functionCall(client, "analyze_function", {
|
const denseSwitchDossier = await functionCall(client, "analyze_function", {
|
||||||
procedure: denseSwitchProcedure.address,
|
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
|
// 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
|
// switches to compare chains at -O0, so this dossier asserts only its
|
||||||
// complete shape; the optimized jump-table variant proves dense jump-table
|
// complete shape; the optimized jump-table variant proves dense jump-table
|
||||||
@@ -179,6 +234,8 @@ export async function verifyDebugFunctionOperations(
|
|||||||
instruction_window: {
|
instruction_window: {
|
||||||
returned: instructionWindow.instructions.length,
|
returned: instructionWindow.instructions.length,
|
||||||
},
|
},
|
||||||
|
terminal_call_limitations: terminalLimitations,
|
||||||
|
returning_call_limitations: returningObservation.limitations,
|
||||||
cancellation,
|
cancellation,
|
||||||
timeout,
|
timeout,
|
||||||
concurrency: concurrent,
|
concurrency: concurrent,
|
||||||
@@ -617,6 +674,113 @@ export async function verifyRelativeSwitch(client, procedures, entrySize) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oracle for relative-switch-signed.S: signed halfword byte offsets from a base
|
||||||
|
* inside the case bodies, a CBZ route for the zero entry, and a B.CS bound.
|
||||||
|
*/
|
||||||
|
const SIGNED_RELATIVE_SWITCH_CASES = [
|
||||||
|
{ value: 0, immediate: 200 },
|
||||||
|
{ value: 1, immediate: 201 },
|
||||||
|
{ value: 2, immediate: 202 },
|
||||||
|
{ value: 3, immediate: 250 },
|
||||||
|
{ value: 4, immediate: 204 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function verifyRelativeSignedSwitch(client, selectorBias = 0) {
|
||||||
|
const names = await inventoryCall(client, "list_names", {
|
||||||
|
document: null,
|
||||||
|
address: null,
|
||||||
|
});
|
||||||
|
const procedure = requireProcedure(names, "rea_relative_switch_signed");
|
||||||
|
const dossier = await functionCall(client, "analyze_function", {
|
||||||
|
procedure: procedure.address,
|
||||||
|
});
|
||||||
|
const table = dossier.native_api?.jump_tables.find((item) =>
|
||||||
|
item.data_sources.some(
|
||||||
|
(source) =>
|
||||||
|
source.entry_size_bytes === 2 &&
|
||||||
|
source.entry_count === SIGNED_RELATIVE_SWITCH_CASES.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (table === undefined)
|
||||||
|
throw new Error(
|
||||||
|
`Exact signed halfword relative table unavailable: ${JSON.stringify(dossier.native_api)}`,
|
||||||
|
);
|
||||||
|
const expectedValues = SIGNED_RELATIVE_SWITCH_CASES.map(
|
||||||
|
({ value }) => value + selectorBias,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
table.mappings.some(
|
||||||
|
(mapping) =>
|
||||||
|
mapping.case_value !== null &&
|
||||||
|
!expectedValues.includes(mapping.case_value),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Signed relative switch emitted an unsupported case label: ${JSON.stringify(table)}`,
|
||||||
|
);
|
||||||
|
if (table.mappings.length !== SIGNED_RELATIVE_SWITCH_CASES.length)
|
||||||
|
throw new Error(
|
||||||
|
`Signed relative switch has unexpected mappings: ${JSON.stringify(table)}`,
|
||||||
|
);
|
||||||
|
if (selectorBias !== 0) {
|
||||||
|
if (
|
||||||
|
!table.limitations.some((limitation) =>
|
||||||
|
limitation.includes("table indices are not proven"),
|
||||||
|
) ||
|
||||||
|
!table.mappings.some((mapping) => mapping.case_value === null) ||
|
||||||
|
table.mappings.some((mapping) => mapping.case_value === selectorBias + 3)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Normalized selector must retain unknown case evidence: ${JSON.stringify(table)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const { value: tableIndex, immediate } of SIGNED_RELATIVE_SWITCH_CASES) {
|
||||||
|
const value = tableIndex + selectorBias;
|
||||||
|
// Without a proven relationship between the machine index and the typed
|
||||||
|
// switch selector, the CBZ-only case must stay unknown rather than being
|
||||||
|
// assigned the normalized index or Ghidra's unreachable base destination.
|
||||||
|
if (selectorBias !== 0 && tableIndex === 3) continue;
|
||||||
|
const mapping = table.mappings.find((item) => item.case_value === value);
|
||||||
|
if (
|
||||||
|
mapping === undefined ||
|
||||||
|
mapping.confidence !== "high" ||
|
||||||
|
(selectorBias !== 0 &&
|
||||||
|
!mapping.evidence.some(
|
||||||
|
(evidence) => evidence.source === "ghidra-clang-case-token",
|
||||||
|
))
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Signed relative switch case ${value} was not verified: ${JSON.stringify(table)}`,
|
||||||
|
);
|
||||||
|
const instruction = await functionCall(
|
||||||
|
client,
|
||||||
|
"inspect_native_instruction",
|
||||||
|
{ address: mapping.target_address },
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
instruction.status !== "decoded" ||
|
||||||
|
!instruction.operands.some((operand) =>
|
||||||
|
operand.components.some(
|
||||||
|
(component) =>
|
||||||
|
component.kind === "immediate" &&
|
||||||
|
component.value === `0x${immediate.toString(16)}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Signed relative switch target does not implement source case ${value}: ${JSON.stringify(instruction)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
entry_size: 2,
|
||||||
|
signed: true,
|
||||||
|
selector_bias: selectorBias,
|
||||||
|
dispatch_address: table.dispatch_address,
|
||||||
|
mappings: table.mappings.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyNativeValueTrace(client, procedures, target) {
|
export async function verifyNativeValueTrace(client, procedures, target) {
|
||||||
const entry = requireProcedure(procedures, "rea_ghidra_inventory_entry");
|
const entry = requireProcedure(procedures, "rea_ghidra_inventory_entry");
|
||||||
const names = await inventoryCall(client, "list_names", {
|
const names = await inventoryCall(client, "list_names", {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -60,6 +60,9 @@ const env = {
|
|||||||
JAVA_HOME: installation.javaHome,
|
JAVA_HOME: installation.javaHome,
|
||||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
|
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 = [];
|
const reports = [];
|
||||||
let primaryFailure;
|
let primaryFailure;
|
||||||
try {
|
try {
|
||||||
@@ -227,6 +230,103 @@ try {
|
|||||||
fixture.sha256,
|
fixture.sha256,
|
||||||
"Analysis modified the source executable",
|
"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({
|
reports.push({
|
||||||
...fixture,
|
...fixture,
|
||||||
independent_reader: independent,
|
independent_reader: independent,
|
||||||
@@ -241,6 +341,14 @@ try {
|
|||||||
"CLI/MCP and snapshot identity",
|
"CLI/MCP and snapshot identity",
|
||||||
],
|
],
|
||||||
procedures: procedures.length,
|
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",
|
status: "passed",
|
||||||
});
|
});
|
||||||
} catch (cause) {
|
} 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);
|
const result = parseGhidraFunctionResult(operation, called.value);
|
||||||
if (!result.ok) throw result.error;
|
if (!result.ok) throw result.error;
|
||||||
observed.add(operation);
|
observed.add(operation);
|
||||||
return result.value;
|
return result.value.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertSession(session, targetSha256, profileDigest) {
|
function assertSession(session, targetSha256, profileDigest) {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
verifyJumpTableDenseSwitch,
|
verifyJumpTableDenseSwitch,
|
||||||
verifyNativeTypeLayout,
|
verifyNativeTypeLayout,
|
||||||
verifyRelativeSwitch,
|
verifyRelativeSwitch,
|
||||||
|
verifyRelativeSignedSwitch,
|
||||||
verifyNativeValueTrace,
|
verifyNativeValueTrace,
|
||||||
} from "./verify-real-ghidra-function.mjs";
|
} from "./verify-real-ghidra-function.mjs";
|
||||||
const exec = promisify(execFile);
|
const exec = promisify(execFile);
|
||||||
@@ -173,6 +174,36 @@ try {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const signedRelativeSource = fileURLToPath(
|
||||||
|
new URL(
|
||||||
|
"../tests/conformance/ghidra/relative-switch-signed.S",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const selectorBias of [0, 2, 10]) {
|
||||||
|
for (const format of [
|
||||||
|
"elf",
|
||||||
|
...(expectedNativeTarget.architecture === "arm64" ? ["mach-o"] : []),
|
||||||
|
]) {
|
||||||
|
const targetPath = join(
|
||||||
|
fixtureRoot,
|
||||||
|
`relative-signed-${selectorBias}-${format}.o`,
|
||||||
|
);
|
||||||
|
await exec(clang, [
|
||||||
|
...(format === "elf" ? ["--target=aarch64-linux-gnu"] : []),
|
||||||
|
`-DREA_SELECTOR_BIAS=${selectorBias}`,
|
||||||
|
"-c",
|
||||||
|
signedRelativeSource,
|
||||||
|
"-o",
|
||||||
|
targetPath,
|
||||||
|
]);
|
||||||
|
crossTargets.push([
|
||||||
|
targetPath,
|
||||||
|
`relative-signed-${selectorBias}`,
|
||||||
|
{ format, architecture: "arm64" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await exec(clang, [
|
await exec(clang, [
|
||||||
"--target=aarch64-linux-gnu",
|
"--target=aarch64-linux-gnu",
|
||||||
@@ -522,25 +553,30 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
|
|||||||
client,
|
client,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const probes = variant.startsWith("relative-")
|
const probes = variant.startsWith("relative-signed-")
|
||||||
? await verifyRelativeSwitch(
|
? await verifyRelativeSignedSwitch(
|
||||||
client,
|
client,
|
||||||
procedures,
|
Number(variant.split("-").at(-1)),
|
||||||
Number(variant.slice(-1)),
|
|
||||||
)
|
)
|
||||||
: variant === "type-layout"
|
: variant.startsWith("relative-")
|
||||||
? await verifyNativeTypeLayout(client, names)
|
? await verifyRelativeSwitch(
|
||||||
: variant === "jump-table"
|
client,
|
||||||
? await verifyJumpTableDenseSwitch(client, procedures)
|
procedures,
|
||||||
: variant === "custom" || variant === "aarch64-jump-table"
|
Number(variant.slice(-1)),
|
||||||
? null
|
)
|
||||||
: await verifyInventoryOperations({
|
: variant === "type-layout"
|
||||||
client,
|
? await verifyNativeTypeLayout(client, names)
|
||||||
variant,
|
: variant === "jump-table"
|
||||||
procedures,
|
? await verifyJumpTableDenseSwitch(client, procedures)
|
||||||
names,
|
: variant === "custom" || variant === "aarch64-jump-table"
|
||||||
strings,
|
? null
|
||||||
});
|
: await verifyInventoryOperations({
|
||||||
|
client,
|
||||||
|
variant,
|
||||||
|
procedures,
|
||||||
|
names,
|
||||||
|
strings,
|
||||||
|
});
|
||||||
const nativeValues =
|
const nativeValues =
|
||||||
variant === "debug"
|
variant === "debug"
|
||||||
? await verifyNativeValueTrace(client, procedures, parsedTarget.value)
|
? await verifyNativeValueTrace(client, procedures, parsedTarget.value)
|
||||||
|
|||||||
@@ -26,22 +26,49 @@ export async function verifyElectronToolContracts({
|
|||||||
await mkdtemp(join(tmpdir(), "rea-electron-e2e-")),
|
await mkdtemp(join(tmpdir(), "rea-electron-e2e-")),
|
||||||
);
|
);
|
||||||
const main = join(root, "main.cjs");
|
const main = join(root, "main.cjs");
|
||||||
|
const preload = join(root, "preload.cjs");
|
||||||
const ready = join(root, "ready");
|
const ready = join(root, "ready");
|
||||||
const entrypoint = join(repositoryRoot, "scripts", "rea.mjs");
|
const entrypoint = join(repositoryRoot, "scripts", "rea.mjs");
|
||||||
const runId = randomUUID();
|
const runId = randomUUID();
|
||||||
await writeFile(
|
await writeFile(
|
||||||
main,
|
main,
|
||||||
`const {app,BrowserWindow}=require("electron");
|
`const electron = require("electron");
|
||||||
app.whenReady().then(()=>{const window=new BrowserWindow();
|
const path = require("node:path");
|
||||||
window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringify(ready)},"ready");});`,
|
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(
|
await writeFile(
|
||||||
join(root, "renderer.html"),
|
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(
|
await writeFile(
|
||||||
join(root, "renderer.js"),
|
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({
|
const transport = new StdioClientTransport({
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
@@ -135,6 +162,25 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
|
|||||||
const staticAnalysis = await call("analyze_javascript_application", {
|
const staticAnalysis = await call("analyze_javascript_application", {
|
||||||
input_path: root,
|
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", {
|
const reconciled = await call("reconcile_javascript_runtime", {
|
||||||
static_layers: [
|
static_layers: [
|
||||||
{
|
{
|
||||||
@@ -169,7 +215,7 @@ window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringi
|
|||||||
executable_path: executable,
|
executable_path: executable,
|
||||||
application_path: main,
|
application_path: main,
|
||||||
application_root: root,
|
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(
|
const active = await client.callTool(
|
||||||
{ name: "capture_electron_scenario", arguments: input },
|
{ 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.application.cleanup, "terminated-owned-process");
|
||||||
}
|
}
|
||||||
assert.equal(capture.actions[0].status, "completed");
|
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(
|
assert.equal(
|
||||||
capture.coverage.status,
|
capture.coverage.status,
|
||||||
"partial_attach",
|
"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 { realpath } from "node:fs/promises";
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
|
|
||||||
import { analyzeJavaScriptApplication } from "../../../dist/application/javascript/JavaScriptApplicationService.js";
|
import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs";
|
||||||
import {
|
import {
|
||||||
compareApplicationVersionsEvidence,
|
compareApplicationVersionsEvidence,
|
||||||
compareJavaScriptExportShapesEvidence,
|
compareJavaScriptExportShapesEvidence,
|
||||||
@@ -199,9 +199,5 @@ function selectorKey(selector) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function analyze(path) {
|
async function analyze(path) {
|
||||||
const result = await analyzeJavaScriptApplication({
|
return analyzeJavaScriptApplicationWithOwnedScope({ input_path: path });
|
||||||
input_path: path,
|
|
||||||
});
|
|
||||||
if (!result.ok) throw result.error;
|
|
||||||
return result.value;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { readFile, realpath } from "node:fs/promises";
|
import { readFile, realpath } from "node:fs/promises";
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
|
|
||||||
import { analyzeJavaScriptApplication } from "../../../dist/application/javascript/JavaScriptApplicationService.js";
|
|
||||||
import { reconcileJavaScriptRuntimeEvidence } from "../../../dist/application/javascript/JavaScriptRuntimeReconciliationService.js";
|
import { reconcileJavaScriptRuntimeEvidence } from "../../../dist/application/javascript/JavaScriptRuntimeReconciliationService.js";
|
||||||
|
import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs";
|
||||||
import {
|
import {
|
||||||
completeVerifierRun,
|
completeVerifierRun,
|
||||||
createVerifierRun,
|
createVerifierRun,
|
||||||
@@ -44,14 +44,13 @@ const canonicalLayers = await Promise.all(
|
|||||||
);
|
);
|
||||||
const staticLayers = [];
|
const staticLayers = [];
|
||||||
for (const layer of canonicalLayers) {
|
for (const layer of canonicalLayers) {
|
||||||
const analyzed = await analyzeJavaScriptApplication({
|
const analyzed = await analyzeJavaScriptApplicationWithOwnedScope({
|
||||||
input_path: layer.path,
|
input_path: layer.path,
|
||||||
format: "auto",
|
format: "auto",
|
||||||
});
|
});
|
||||||
if (!analyzed.ok) throw analyzed.error;
|
|
||||||
staticLayers.push({
|
staticLayers.push({
|
||||||
role: layer.role,
|
role: layer.role,
|
||||||
analysis: analyzed.value,
|
analysis: analyzed,
|
||||||
runtime_mappings: [],
|
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
|
||||||
+348
-46
@@ -1,6 +1,6 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
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 { access, lstat, mkdir, rm, stat, writeFile } from "node:fs/promises";
|
||||||
import { basename, join, resolve } from "node:path";
|
import { basename, join, resolve } from "node:path";
|
||||||
|
|
||||||
@@ -24,8 +24,11 @@ import {
|
|||||||
AnalysisTimeoutError,
|
AnalysisTimeoutError,
|
||||||
AnalysisUnsupportedTargetError,
|
AnalysisUnsupportedTargetError,
|
||||||
} from "../domain/analysisErrorCore.js";
|
} 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 { err, ok, type Result } from "../domain/result.js";
|
||||||
|
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||||
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||||
import {
|
import {
|
||||||
execFileOutput,
|
execFileOutput,
|
||||||
execFileOutputFailure,
|
execFileOutputFailure,
|
||||||
@@ -97,11 +100,18 @@ const STDERR_EXCERPT_BYTES = 512;
|
|||||||
const UNPARSED_LINES_REPORTED = 10;
|
const UNPARSED_LINES_REPORTED = 10;
|
||||||
const STOP_GRACE_MS = 5_000;
|
const STOP_GRACE_MS = 5_000;
|
||||||
|
|
||||||
/** Stream a local file digest without retaining large files in memory. */
|
const hashAdbFile = async (
|
||||||
const sha256File = async (path: string): Promise<string> => {
|
path: string,
|
||||||
const hash = createHash("sha256");
|
operation: string,
|
||||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
signal: AbortSignal | undefined,
|
||||||
return hash.digest("hex");
|
) => {
|
||||||
|
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. */
|
/** 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>>;
|
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.
|
* ADB device inspection and acquisition over a caller-selected adb binary.
|
||||||
*
|
*
|
||||||
@@ -346,6 +398,12 @@ export interface AdbProviderOptions {
|
|||||||
*/
|
*/
|
||||||
export class AdbProvider {
|
export class AdbProvider {
|
||||||
private readonly environment: Readonly<Record<string, string | undefined>>;
|
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 = {}) {
|
constructor(options: AdbProviderOptions = {}) {
|
||||||
this.environment = options.environment ?? process.env;
|
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. */
|
/** Retry removal of package directories retained after failed rollback. */
|
||||||
async close(): Promise<void> {}
|
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(
|
async execute(
|
||||||
request: AdbRequest,
|
request: AdbRequest,
|
||||||
options?: ExecutionOptions,
|
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>> {
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||||
const signal = options?.signal;
|
const signal = options?.signal;
|
||||||
if (signal?.aborted === true)
|
if (signal?.aborted === true)
|
||||||
@@ -1010,9 +1177,11 @@ export class AdbProvider {
|
|||||||
);
|
);
|
||||||
if (!directory.ok) return directory;
|
if (!directory.ok) return directory;
|
||||||
const packageDirectory = join(directory.value, packageName);
|
const packageDirectory = join(directory.value, packageName);
|
||||||
|
let packageDirectoryCreated = false;
|
||||||
|
let packageDirectoryIdentity: PackageDirectoryIdentity | undefined;
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
await stat(packageDirectory).then(
|
await lstat(packageDirectory).then(
|
||||||
() => true,
|
() => true,
|
||||||
() => false,
|
() => false,
|
||||||
)
|
)
|
||||||
@@ -1021,14 +1190,44 @@ export class AdbProvider {
|
|||||||
`The package output directory already exists: ${packageDirectory}`,
|
`The package output directory already exists: ${packageDirectory}`,
|
||||||
);
|
);
|
||||||
await mkdir(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) {
|
} catch (cause) {
|
||||||
return err(
|
const primary = new AnalysisOutputError(
|
||||||
new AnalysisOutputError(
|
operation,
|
||||||
operation,
|
cause instanceof Error ? cause.message : String(cause),
|
||||||
cause instanceof Error ? cause.message : String(cause),
|
{ 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: {
|
const artifacts: {
|
||||||
device_path: string;
|
device_path: string;
|
||||||
@@ -1044,6 +1243,37 @@ export class AdbProvider {
|
|||||||
stage: "resolve_paths" | "pull" | "digest";
|
stage: "resolve_paths" | "pull" | "digest";
|
||||||
message: string;
|
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>();
|
const seenFileNames = new Set<string>();
|
||||||
for (const entry of parsed.paths) {
|
for (const entry of parsed.paths) {
|
||||||
if (seenFileNames.has(entry.file_name)) {
|
if (seenFileNames.has(entry.file_name)) {
|
||||||
@@ -1065,7 +1295,13 @@ export class AdbProvider {
|
|||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
if (!pulled.ok) {
|
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({
|
failures.push({
|
||||||
device_path: entry.device_path,
|
device_path: entry.device_path,
|
||||||
stage: "pull",
|
stage: "pull",
|
||||||
@@ -1074,18 +1310,24 @@ export class AdbProvider {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const info = await stat(localPath);
|
const digest = await hashAdbFile(localPath, operation, signal);
|
||||||
const sha256 = await sha256File(localPath);
|
|
||||||
artifacts.push({
|
artifacts.push({
|
||||||
device_path: entry.device_path,
|
device_path: entry.device_path,
|
||||||
file_name: entry.file_name,
|
file_name: entry.file_name,
|
||||||
local_path: localPath,
|
local_path: localPath,
|
||||||
bytes: info.size,
|
bytes: digest.bytes,
|
||||||
sha256,
|
sha256: digest.sha256,
|
||||||
role: entry.role,
|
role: entry.role,
|
||||||
role_basis: "file_name",
|
role_basis: "file_name",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (cause: unknown) {
|
||||||
|
if (cause instanceof AnalysisCancelledError)
|
||||||
|
return cancelledPackagePull(
|
||||||
|
cause,
|
||||||
|
localPath,
|
||||||
|
entry.device_path,
|
||||||
|
"digest",
|
||||||
|
);
|
||||||
failures.push({
|
failures.push({
|
||||||
device_path: entry.device_path,
|
device_path: entry.device_path,
|
||||||
stage: "digest",
|
stage: "digest",
|
||||||
@@ -1094,14 +1336,36 @@ export class AdbProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (artifacts.length === 0 && failures.length > 0) {
|
if (artifacts.length === 0 && failures.length > 0) {
|
||||||
await rm(packageDirectory, { recursive: true, force: true }).catch(
|
const primary = new AnalysisProtocolError(
|
||||||
() => undefined,
|
`Every APK pull failed for ${packageName} on ${serial}: ${failures[0]!.message}`,
|
||||||
);
|
|
||||||
return err(
|
|
||||||
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(
|
||||||
|
analysisErrorWithCleanupFailure(primary, cleanup, operation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return err(primary);
|
||||||
}
|
}
|
||||||
return ok(
|
return ok(
|
||||||
createAnalysisExecution(
|
createAnalysisExecution(
|
||||||
@@ -1156,8 +1420,7 @@ export class AdbProvider {
|
|||||||
);
|
);
|
||||||
if (!pulled.ok) return pulled;
|
if (!pulled.ok) return pulled;
|
||||||
try {
|
try {
|
||||||
const info = await stat(localPath);
|
const digest = await hashAdbFile(localPath, operation, signal);
|
||||||
const sha256 = await sha256File(localPath);
|
|
||||||
return ok(
|
return ok(
|
||||||
createAnalysisExecution(
|
createAnalysisExecution(
|
||||||
{
|
{
|
||||||
@@ -1165,8 +1428,8 @@ export class AdbProvider {
|
|||||||
serial: input.serial,
|
serial: input.serial,
|
||||||
device_path: input.device_path,
|
device_path: input.device_path,
|
||||||
local_path: localPath,
|
local_path: localPath,
|
||||||
bytes: info.size,
|
bytes: digest.bytes,
|
||||||
sha256,
|
sha256: digest.sha256,
|
||||||
},
|
},
|
||||||
provider,
|
provider,
|
||||||
{
|
{
|
||||||
@@ -1177,6 +1440,20 @@ export class AdbProvider {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (cause) {
|
} 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(
|
return err(
|
||||||
new AnalysisOutputError(
|
new AnalysisOutputError(
|
||||||
operation,
|
operation,
|
||||||
@@ -1211,7 +1488,13 @@ export class AdbProvider {
|
|||||||
cause: new Error(`The local file does not exist: ${localPath}`),
|
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(
|
const existsProbe = await runAdb(
|
||||||
operation,
|
operation,
|
||||||
input.device_path,
|
input.device_path,
|
||||||
@@ -1257,7 +1540,7 @@ export class AdbProvider {
|
|||||||
);
|
);
|
||||||
if (deviceDigest.ok) {
|
if (deviceDigest.ok) {
|
||||||
const digestMatch = /^([a-f0-9]{64})\s/u.exec(deviceDigest.value.stdout);
|
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(
|
return err(
|
||||||
new AnalysisArtifactChangedError(
|
new AnalysisArtifactChangedError(
|
||||||
operation,
|
operation,
|
||||||
@@ -1272,8 +1555,8 @@ export class AdbProvider {
|
|||||||
serial: input.serial,
|
serial: input.serial,
|
||||||
local_path: localPath,
|
local_path: localPath,
|
||||||
device_path: input.device_path,
|
device_path: input.device_path,
|
||||||
bytes: info.size,
|
bytes: digest.bytes,
|
||||||
sha256,
|
sha256: digest.sha256,
|
||||||
device_sha256: digestMatch?.[1] ?? null,
|
device_sha256: digestMatch?.[1] ?? null,
|
||||||
overwritten: deviceExists,
|
overwritten: deviceExists,
|
||||||
device_digest_source: "device_sha256sum",
|
device_digest_source: "device_sha256sum",
|
||||||
@@ -1294,8 +1577,8 @@ export class AdbProvider {
|
|||||||
serial: input.serial,
|
serial: input.serial,
|
||||||
local_path: localPath,
|
local_path: localPath,
|
||||||
device_path: input.device_path,
|
device_path: input.device_path,
|
||||||
bytes: info.size,
|
bytes: digest.bytes,
|
||||||
sha256,
|
sha256: digest.sha256,
|
||||||
device_sha256: null,
|
device_sha256: null,
|
||||||
overwritten: deviceExists,
|
overwritten: deviceExists,
|
||||||
device_digest_source: "unavailable",
|
device_digest_source: "unavailable",
|
||||||
@@ -1334,7 +1617,13 @@ export class AdbProvider {
|
|||||||
cause: new Error(`The local APK does not exist: ${apkPath}`),
|
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(
|
const installed = await runAdb(
|
||||||
operation,
|
operation,
|
||||||
input.serial,
|
input.serial,
|
||||||
@@ -1363,8 +1652,8 @@ export class AdbProvider {
|
|||||||
client,
|
client,
|
||||||
serial: input.serial,
|
serial: input.serial,
|
||||||
apk_path: apkPath,
|
apk_path: apkPath,
|
||||||
bytes: info.size,
|
bytes: digest.bytes,
|
||||||
sha256,
|
sha256: digest.sha256,
|
||||||
replaced: input.replace,
|
replaced: input.replace,
|
||||||
},
|
},
|
||||||
provider,
|
provider,
|
||||||
@@ -1734,16 +2023,15 @@ export class AdbProvider {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const info = await stat(reportedPath);
|
const digest = await hashAdbFile(reportedPath, operation, signal);
|
||||||
const sha256 = await sha256File(reportedPath);
|
|
||||||
return ok(
|
return ok(
|
||||||
createAnalysisExecution(
|
createAnalysisExecution(
|
||||||
{
|
{
|
||||||
client,
|
client,
|
||||||
serial: input.serial,
|
serial: input.serial,
|
||||||
local_path: reportedPath,
|
local_path: reportedPath,
|
||||||
bytes: info.size,
|
bytes: digest.bytes,
|
||||||
sha256,
|
sha256: digest.sha256,
|
||||||
adb_reported_path: reportedPath,
|
adb_reported_path: reportedPath,
|
||||||
},
|
},
|
||||||
provider,
|
provider,
|
||||||
@@ -1755,6 +2043,20 @@ export class AdbProvider {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (cause) {
|
} 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(
|
return err(
|
||||||
new AnalysisOutputError(
|
new AnalysisOutputError(
|
||||||
operation,
|
operation,
|
||||||
|
|||||||
@@ -1,40 +1,43 @@
|
|||||||
import { constants, createReadStream } from "node:fs";
|
|
||||||
import { chmod, copyFile } from "node:fs/promises";
|
|
||||||
import { createHash } from "node:crypto";
|
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 { join } from "node:path";
|
||||||
|
import { Transform, type TransformCallback } from "node:stream";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
import {
|
import {
|
||||||
AnalysisInputError,
|
AnalysisInputError,
|
||||||
AnalysisCapabilityUnavailableError,
|
AnalysisCapabilityUnavailableError,
|
||||||
} from "../domain/analysisErrorCore.js";
|
} from "../domain/analysisErrorCore.js";
|
||||||
|
import {
|
||||||
|
openRegularFile,
|
||||||
|
sameRegularFileState,
|
||||||
|
} from "../filesystem/RegularFile.js";
|
||||||
import type { AndroidOperation } from "../domain/android/androidAnalysis.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. */
|
/** Bind decompiler input to the admitted artifact, even if the original changes. */
|
||||||
export const snapshotAndroidTarget = async (
|
export const snapshotAndroidTarget = async (
|
||||||
path: string,
|
path: string,
|
||||||
sha256: string,
|
sha256: string,
|
||||||
root: string,
|
root: string,
|
||||||
operation: AndroidOperation,
|
operation: AndroidOperation,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const snapshot = join(root, "target.apk");
|
const snapshot = join(root, "target.apk");
|
||||||
await copyFile(path, snapshot, constants.COPYFILE_EXCL);
|
try {
|
||||||
await chmod(snapshot, 0o400);
|
await copyAndroidSnapshot(path, snapshot, sha256, signal);
|
||||||
if ((await hashAndroidFile(snapshot)) !== sha256)
|
signal?.throwIfAborted();
|
||||||
throw new AnalysisInputError(operation, undefined, [
|
await chmod(snapshot, 0o400);
|
||||||
|
return snapshot;
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (!(cause instanceof AndroidSnapshotChangedError)) throw cause;
|
||||||
|
throw new AnalysisInputError(operation, { cause }, [
|
||||||
{
|
{
|
||||||
path: ["path"],
|
path: ["path"],
|
||||||
reason: "invalid_value",
|
reason: "invalid_value",
|
||||||
message:
|
message: `APK bytes changed after admission at ${path}; retry against a stable file.`,
|
||||||
"APK bytes changed after admission; retry against a stable file.",
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
return snapshot;
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fingerprint the actual immutable engine bytes executed for this observation. */
|
/** Fingerprint the actual immutable engine bytes executed for this observation. */
|
||||||
@@ -43,15 +46,120 @@ export const snapshotAndroidEngine = async (
|
|||||||
sha256: string,
|
sha256: string,
|
||||||
root: string,
|
root: string,
|
||||||
operation: AndroidOperation,
|
operation: AndroidOperation,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<{ path: string; sha256: string }> => {
|
): Promise<{ path: string; sha256: string }> => {
|
||||||
const path = join(root, "engine.jar");
|
const path = join(root, "engine.jar");
|
||||||
await copyFile(source, path, constants.COPYFILE_EXCL);
|
try {
|
||||||
await chmod(path, 0o400);
|
const observed = await copyAndroidSnapshot(source, path, sha256, signal);
|
||||||
if ((await hashAndroidFile(path)) !== sha256)
|
signal?.throwIfAborted();
|
||||||
|
await chmod(path, 0o400);
|
||||||
|
return { path, sha256: observed };
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (!(cause instanceof AndroidSnapshotChangedError)) throw cause;
|
||||||
throw new AnalysisCapabilityUnavailableError(
|
throw new AnalysisCapabilityUnavailableError(
|
||||||
"jadx",
|
"jadx",
|
||||||
operation,
|
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,
|
signal?: AbortSignal,
|
||||||
): Promise<ProviderAvailability> => {
|
): Promise<ProviderAvailability> => {
|
||||||
try {
|
try {
|
||||||
const configuration = await readJadxConfiguration(environment, signal);
|
const configuration = await readJadxConfigurationInputs(
|
||||||
|
environment,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
await validateJadxConfigurationContents(configuration, environment, signal);
|
||||||
return {
|
return {
|
||||||
status: "available",
|
status: "available",
|
||||||
code: null,
|
code: null,
|
||||||
@@ -77,52 +81,83 @@ export const inspectJadxAvailability = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Resolve caller-selected tools and preserve the same prerequisite diagnostics. */
|
/** Resolve caller-selected paths and JVM options without probing their contents. */
|
||||||
export const resolveJadxConfiguration = async (
|
export const resolveJadxConfigurationInputs = async (
|
||||||
environment: Readonly<Record<string, string | undefined>>,
|
environment: Readonly<Record<string, string | undefined>>,
|
||||||
operation: AndroidOperation,
|
operation: AndroidOperation,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<JadxConfiguration> => {
|
): Promise<JadxConfiguration> => {
|
||||||
try {
|
try {
|
||||||
return await readJadxConfiguration(environment, signal);
|
return await readJadxConfigurationInputs(environment, signal);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (signal?.aborted === true) throw cause;
|
if (signal?.aborted === true) throw cause;
|
||||||
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
|
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
|
||||||
const output = cause.diagnostics;
|
throw projectConfigurationFailure(operation, cause);
|
||||||
throw new AnalysisCapabilityUnavailableError(
|
|
||||||
"jadx",
|
|
||||||
operation,
|
|
||||||
cause.message,
|
|
||||||
{
|
|
||||||
cause,
|
|
||||||
userMessage: cause.message,
|
|
||||||
...(typeof output.stdout === "string" &&
|
|
||||||
typeof output.stderr === "string"
|
|
||||||
? {
|
|
||||||
capturedOutput: {
|
|
||||||
stdout: output.stdout,
|
|
||||||
stderr: output.stderr,
|
|
||||||
truncated: output.output_truncated === true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const readJadxConfiguration = async (
|
/** Probe Java and archive prerequisites before starting a new engine session. */
|
||||||
|
export const validateJadxConfiguration = async (
|
||||||
|
configuration: JadxConfiguration,
|
||||||
|
environment: Readonly<Record<string, string | undefined>>,
|
||||||
|
operation: AndroidOperation,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await validateJadxConfigurationContents(configuration, environment, signal);
|
||||||
|
} catch (cause) {
|
||||||
|
if (signal?.aborted === true) throw cause;
|
||||||
|
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
|
||||||
|
throw projectConfigurationFailure(operation, cause);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readJadxConfigurationInputs = async (
|
||||||
environment: Readonly<Record<string, string | undefined>>,
|
environment: Readonly<Record<string, string | undefined>>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<JadxConfiguration> => {
|
): Promise<JadxConfiguration> => {
|
||||||
|
signal?.throwIfAborted();
|
||||||
requireSupportedHost();
|
requireSupportedHost();
|
||||||
const jar = await resolveJar(environment);
|
const jar = await resolveJar(environment);
|
||||||
const java = await resolveJava(environment, signal);
|
const java = await resolveJava(environment, signal);
|
||||||
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
|
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
|
||||||
const jvmArguments = resolveJvmArguments(environment);
|
signal?.throwIfAborted();
|
||||||
await inspectJavaRuntime(java, environment, signal);
|
return { jar, java, jvmArguments: resolveJvmArguments(environment) };
|
||||||
await requireEngineArchive(jar, signal);
|
};
|
||||||
return { jar, java, jvmArguments };
|
|
||||||
|
const validateJadxConfigurationContents = async (
|
||||||
|
configuration: JadxConfiguration,
|
||||||
|
environment: Readonly<Record<string, string | undefined>>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> => {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
await inspectJavaRuntime(configuration.java, environment, signal);
|
||||||
|
await requireEngineArchive(configuration.jar, signal);
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectConfigurationFailure = (
|
||||||
|
operation: AndroidOperation,
|
||||||
|
cause: JadxConfigurationFailure,
|
||||||
|
): AnalysisCapabilityUnavailableError => {
|
||||||
|
const output = cause.diagnostics;
|
||||||
|
return new AnalysisCapabilityUnavailableError(
|
||||||
|
"jadx",
|
||||||
|
operation,
|
||||||
|
cause.message,
|
||||||
|
{
|
||||||
|
cause,
|
||||||
|
userMessage: cause.message,
|
||||||
|
...(typeof output.stdout === "string" && typeof output.stderr === "string"
|
||||||
|
? {
|
||||||
|
capturedOutput: {
|
||||||
|
stdout: output.stdout,
|
||||||
|
stderr: output.stderr,
|
||||||
|
truncated: output.output_truncated === true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const requireEngineArchive = async (
|
const requireEngineArchive = async (
|
||||||
|
|||||||
+98
-28
@@ -21,12 +21,14 @@ import { err, ok, type Result } from "../domain/result.js";
|
|||||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||||
import {
|
import {
|
||||||
snapshotAndroidEngine,
|
snapshotAndroidEngine,
|
||||||
hashAndroidFile,
|
|
||||||
snapshotAndroidTarget,
|
snapshotAndroidTarget,
|
||||||
} from "./AndroidTargetSnapshot.js";
|
} from "./AndroidTargetSnapshot.js";
|
||||||
|
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||||
|
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
|
||||||
import {
|
import {
|
||||||
inspectJadxAvailability,
|
inspectJadxAvailability,
|
||||||
resolveJadxConfiguration,
|
resolveJadxConfigurationInputs,
|
||||||
|
validateJadxConfiguration,
|
||||||
} from "./JadxConfiguration.js";
|
} from "./JadxConfiguration.js";
|
||||||
import type { JadxLauncher } from "./JadxMcpTransport.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 {
|
interface PendingCleanup {
|
||||||
session: JadxSession | undefined;
|
session: JadxSession | undefined;
|
||||||
root: PrivateRuntimeRoot | undefined;
|
root: PrivateRuntimeRoot | undefined;
|
||||||
@@ -147,17 +193,18 @@ const cleanup = async (
|
|||||||
await root?.close();
|
await root?.close();
|
||||||
resources.root = undefined;
|
resources.root = undefined;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
return err(
|
const error = new ProviderCleanupError(
|
||||||
new ProviderCleanupError(
|
"jadx",
|
||||||
"jadx",
|
[root?.path ?? "unknown"],
|
||||||
[root?.path ?? "unknown"],
|
{
|
||||||
{
|
...diagnostics,
|
||||||
...diagnostics,
|
reason: cause instanceof Error ? cause.message : String(cause),
|
||||||
reason: cause instanceof Error ? cause.message : String(cause),
|
},
|
||||||
},
|
{ cause: previousError ?? cause },
|
||||||
{ cause: previousError ?? cause },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
if (previousError?.partialObservation !== undefined)
|
||||||
|
error.retainPartialObservation(previousError.partialObservation);
|
||||||
|
return err(error);
|
||||||
}
|
}
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
};
|
};
|
||||||
@@ -287,23 +334,33 @@ export class JadxProvider implements AndroidAnalysisPort {
|
|||||||
request.operation,
|
request.operation,
|
||||||
`Target ${target.path} is ${target.format}; provide one standalone APK.`,
|
`Target ${target.path} is ${target.format}; provide one standalone APK.`,
|
||||||
);
|
);
|
||||||
const configuration = await resolveJadxConfiguration(
|
const configuration = await resolveJadxConfigurationInputs(
|
||||||
this.environment,
|
this.environment,
|
||||||
request.operation,
|
request.operation,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
const jarHash = await hashAndroidFile(configuration.jar);
|
const jarHash = await hashAndroidSource(
|
||||||
const bridgeHash = await hashAndroidFile(BRIDGE_SOURCE);
|
configuration.jar,
|
||||||
if ((await hashAndroidFile(target.path)) !== target.sha256)
|
request.operation,
|
||||||
throw new AnalysisInputError(request.operation, undefined, [
|
signal,
|
||||||
{
|
);
|
||||||
path: ["path"],
|
const bridgeHash = await hashAndroidSource(
|
||||||
reason: "invalid_value",
|
BRIDGE_SOURCE,
|
||||||
message:
|
request.operation,
|
||||||
"APK bytes changed after admission; retry against a stable file.",
|
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({
|
const key = JSON.stringify({
|
||||||
path: target.path,
|
path: target.path,
|
||||||
sha256: target.sha256,
|
sha256: target.sha256,
|
||||||
@@ -326,27 +383,40 @@ export class JadxProvider implements AndroidAnalysisPort {
|
|||||||
}
|
}
|
||||||
let retained = this.#retained;
|
let retained = this.#retained;
|
||||||
if (retained === undefined) {
|
if (retained === undefined) {
|
||||||
|
await validateJadxConfiguration(
|
||||||
|
configuration,
|
||||||
|
this.environment,
|
||||||
|
request.operation,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" });
|
root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" });
|
||||||
const engine = await snapshotAndroidEngine(
|
const engine = await snapshotAndroidEngine(
|
||||||
configuration.jar,
|
configuration.jar,
|
||||||
jarHash,
|
jarHash,
|
||||||
root.path,
|
root.path,
|
||||||
request.operation,
|
request.operation,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
const snapshot = await snapshotAndroidTarget(
|
const snapshot = await snapshotAndroidTarget(
|
||||||
target.path,
|
target.path,
|
||||||
target.sha256,
|
target.sha256,
|
||||||
root.path,
|
root.path,
|
||||||
request.operation,
|
request.operation,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
const bridge = join(root.path, "ReaJadxBridge.java");
|
const bridge = join(root.path, "ReaJadxBridge.java");
|
||||||
await copyFile(BRIDGE_SOURCE, bridge);
|
await copyFile(BRIDGE_SOURCE, bridge);
|
||||||
await chmod(bridge, 0o400);
|
await chmod(bridge, 0o400);
|
||||||
if ((await hashAndroidFile(bridge)) !== bridgeHash)
|
const copiedBridgeHash = await hashAndroidSource(
|
||||||
throw new AnalysisCapabilityUnavailableError(
|
bridge,
|
||||||
"jadx",
|
request.operation,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (copiedBridgeHash !== bridgeHash)
|
||||||
|
throw androidSourceChanged(
|
||||||
request.operation,
|
request.operation,
|
||||||
"REA Android bridge bytes changed during admission; retry with a stable installation.",
|
BRIDGE_SOURCE,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
session = new JadxSession(
|
session = new JadxSession(
|
||||||
|
|||||||
@@ -78,12 +78,14 @@ export const parseApktoolVersionOutput = (
|
|||||||
export const inspectApktoolClient = async (
|
export const inspectApktoolClient = async (
|
||||||
selection: ApktoolCommandSelection,
|
selection: ApktoolCommandSelection,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||||
): Promise<ApktoolClientIdentity> => {
|
): Promise<ApktoolClientIdentity> => {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileOutput(selection.command, ["--version"], {
|
const { stdout } = await execFileOutput(selection.command, ["--version"], {
|
||||||
timeout: VERSION_TIMEOUT_MS,
|
timeout: VERSION_TIMEOUT_MS,
|
||||||
stopSignal: "SIGTERM",
|
stopSignal: "SIGTERM",
|
||||||
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
|
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
|
||||||
|
env: { ...process.env, ...environment },
|
||||||
...(signal === undefined ? {} : { signal }),
|
...(signal === undefined ? {} : { signal }),
|
||||||
});
|
});
|
||||||
return parseApktoolVersionOutput(stdout);
|
return parseApktoolVersionOutput(stdout);
|
||||||
|
|||||||
+327
-133
@@ -1,18 +1,20 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { createReadStream, constants as fsConstants } from "node:fs";
|
import { createWriteStream, constants as fsConstants } from "node:fs";
|
||||||
import {
|
import {
|
||||||
access,
|
access,
|
||||||
chmod,
|
chmod,
|
||||||
copyFile,
|
|
||||||
mkdir,
|
mkdir,
|
||||||
mkdtemp,
|
mkdtemp,
|
||||||
|
opendir,
|
||||||
open,
|
open,
|
||||||
readdir,
|
|
||||||
rm,
|
rm,
|
||||||
stat,
|
stat,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { Transform, type TransformCallback } from "node:stream";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import type { FileHandle } from "node:fs/promises";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExecutionOptions,
|
ExecutionOptions,
|
||||||
@@ -33,11 +35,19 @@ import {
|
|||||||
AnalysisUnsupportedTargetError,
|
AnalysisUnsupportedTargetError,
|
||||||
} from "../domain/analysisErrorCore.js";
|
} from "../domain/analysisErrorCore.js";
|
||||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
|
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||||
import { err, ok, type Result } from "../domain/result.js";
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||||
import {
|
import {
|
||||||
execFileOutput,
|
openRegularFile,
|
||||||
execFileOutputFailure,
|
sameRegularFileState,
|
||||||
} from "../process/ExecFileOutput.js";
|
} from "../filesystem/RegularFile.js";
|
||||||
|
import {
|
||||||
|
OwnedCommandFailure,
|
||||||
|
runOwnedCommand,
|
||||||
|
} from "../process/OwnedCommand.js";
|
||||||
|
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
||||||
|
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||||
import {
|
import {
|
||||||
ApktoolConfigurationFailure,
|
ApktoolConfigurationFailure,
|
||||||
apktoolConfigurationAvailability,
|
apktoolConfigurationAvailability,
|
||||||
@@ -64,20 +74,22 @@ const DECODE_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|||||||
const MANIFEST_MAX_BYTES = 512 * 1024;
|
const MANIFEST_MAX_BYTES = 512 * 1024;
|
||||||
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
|
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
|
||||||
const MAX_STRINGS = 2_000;
|
const MAX_STRINGS = 2_000;
|
||||||
const MAX_DECODED_FILES_COUNTED = 100_000;
|
const MAX_DECODED_ENTRIES_VISITED = 100_000;
|
||||||
const STDERR_EXCERPT_BYTES = 512;
|
const STDERR_EXCERPT_BYTES = 512;
|
||||||
|
|
||||||
/** Stream a local file digest without retaining large APKs in memory. */
|
const throwIfCancelled = (
|
||||||
const sha256File = async (path: string): Promise<string> => {
|
operation: string,
|
||||||
const hash = createHash("sha256");
|
signal: AbortSignal | undefined,
|
||||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
): void => {
|
||||||
return hash.digest("hex");
|
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Read at most the byte budget plus one byte, before decoding UTF-8. */
|
/** Read at most the byte budget plus one byte, before decoding UTF-8. */
|
||||||
const readBoundedText = async (
|
const readBoundedText = async (
|
||||||
path: string,
|
path: string,
|
||||||
budget: number,
|
budget: number,
|
||||||
|
operation: string,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
): Promise<{ text: string | null; truncated: boolean }> => {
|
): Promise<{ text: string | null; truncated: boolean }> => {
|
||||||
const file = await open(path, "r").catch((cause: unknown) => {
|
const file = await open(path, "r").catch((cause: unknown) => {
|
||||||
if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
|
if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||||
@@ -85,9 +97,11 @@ const readBoundedText = async (
|
|||||||
});
|
});
|
||||||
if (file === null) return { text: null, truncated: false };
|
if (file === null) return { text: null, truncated: false };
|
||||||
try {
|
try {
|
||||||
|
throwIfCancelled(operation, signal);
|
||||||
const bytes = Buffer.alloc(budget + 1);
|
const bytes = Buffer.alloc(budget + 1);
|
||||||
let length = 0;
|
let length = 0;
|
||||||
while (length < bytes.length) {
|
while (length < bytes.length) {
|
||||||
|
throwIfCancelled(operation, signal);
|
||||||
const result = await file.read(
|
const result = await file.read(
|
||||||
bytes,
|
bytes,
|
||||||
length,
|
length,
|
||||||
@@ -97,6 +111,7 @@ const readBoundedText = async (
|
|||||||
if (result.bytesRead === 0) break;
|
if (result.bytesRead === 0) break;
|
||||||
length += result.bytesRead;
|
length += result.bytesRead;
|
||||||
}
|
}
|
||||||
|
throwIfCancelled(operation, signal);
|
||||||
const truncated = length > budget;
|
const truncated = length > budget;
|
||||||
// A prefix may end within a UTF-8 character; omit those trailing bytes.
|
// A prefix may end within a UTF-8 character; omit those trailing bytes.
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
@@ -125,97 +140,141 @@ const runApktool = async (
|
|||||||
arguments_: readonly string[],
|
arguments_: readonly string[],
|
||||||
limits: DecodeLimits,
|
limits: DecodeLimits,
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
|
environment: Readonly<Record<string, string | undefined>>,
|
||||||
|
onCleanupOwner: (owner: ProviderProcessSupervisor) => void,
|
||||||
): Promise<Result<{ stdout: string; stderr: string }, AnalysisError>> => {
|
): Promise<Result<{ stdout: string; stderr: string }, AnalysisError>> => {
|
||||||
if (signal?.aborted === true)
|
if (signal?.aborted === true)
|
||||||
return err(new AnalysisCancelledError(operation));
|
return err(new AnalysisCancelledError(operation));
|
||||||
try {
|
try {
|
||||||
return ok(
|
const output = await runOwnedCommand(
|
||||||
await execFileOutput(command, arguments_, {
|
{
|
||||||
timeout: limits.timeoutMs,
|
command,
|
||||||
maxBuffer: limits.maxOutputBytes,
|
arguments: arguments_,
|
||||||
stopSignal: "SIGTERM",
|
runId: randomUUID(),
|
||||||
...(signal === undefined ? {} : { signal }),
|
hostEnvironment: { ...process.env, ...environment },
|
||||||
}),
|
},
|
||||||
|
{
|
||||||
|
timeoutMs: limits.timeoutMs,
|
||||||
|
diagnosticBytes: limits.maxOutputBytes,
|
||||||
|
},
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
);
|
);
|
||||||
|
return ok({ stdout: output.stdout.text, stderr: output.stderr.text });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (signal?.aborted || execFileOutputFailure(cause)?.code === "ABORT_ERR")
|
if (!(cause instanceof OwnedCommandFailure))
|
||||||
return err(new AnalysisCancelledError(operation, { cause }));
|
|
||||||
const failure = execFileOutputFailure(cause);
|
|
||||||
if (failure === undefined)
|
|
||||||
return err(
|
return err(
|
||||||
new AnalysisProtocolError(`apktool failed for ${operation}`, { cause }),
|
new AnalysisProtocolError(`apktool failed for ${operation}`, { cause }),
|
||||||
);
|
);
|
||||||
if (failure.outputTruncated)
|
if (cause.cleanupOwner !== undefined) onCleanupOwner(cause.cleanupOwner);
|
||||||
|
const output = cause.snapshot;
|
||||||
|
const capturedOutput =
|
||||||
|
output === null
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
stdout: output.stdout.text,
|
||||||
|
stderr: output.stderr.text,
|
||||||
|
truncated: output.diagnosticTruncated === true,
|
||||||
|
stdout_bytes: output.stdout.bytes,
|
||||||
|
stderr_bytes: output.stderr.bytes,
|
||||||
|
exit_code: output.exitCode ?? null,
|
||||||
|
signal: output.signal ?? null,
|
||||||
|
};
|
||||||
|
const cleanup =
|
||||||
|
cause.cleanupFailure === null
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
reason: cause.cleanupFailure,
|
||||||
|
resources: cause.resources,
|
||||||
|
};
|
||||||
|
if (cause.reason === "cancelled")
|
||||||
|
return err(
|
||||||
|
new AnalysisCancelledError(operation, {
|
||||||
|
cause,
|
||||||
|
...(cleanup === undefined ? {} : { cleanup }),
|
||||||
|
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (cause.reason === "timeout")
|
||||||
|
return err(
|
||||||
|
new AnalysisTimeoutError(operation, limits.timeoutMs, {
|
||||||
|
cause,
|
||||||
|
...(cleanup === undefined ? {} : { cleanup }),
|
||||||
|
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (cause.reason === "output-limit")
|
||||||
return err(
|
return err(
|
||||||
new AnalysisResourceConstraintError(
|
new AnalysisResourceConstraintError(
|
||||||
operation,
|
operation,
|
||||||
"memory",
|
"memory",
|
||||||
`apktool output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
|
`apktool output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
|
||||||
{ max_output_bytes: limits.maxOutputBytes },
|
{ max_output_bytes: limits.maxOutputBytes },
|
||||||
{ cause },
|
{
|
||||||
|
cause,
|
||||||
|
...(cleanup === undefined ? {} : { cleanup }),
|
||||||
|
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const excerpt = failure.stderr.trim().slice(0, STDERR_EXCERPT_BYTES);
|
|
||||||
if (failure.killed)
|
|
||||||
return err(
|
|
||||||
new AnalysisTimeoutError(operation, limits.timeoutMs, { cause }),
|
|
||||||
);
|
|
||||||
return err(
|
return err(
|
||||||
new AnalysisUnsupportedTargetError(
|
new AnalysisUnsupportedTargetError(
|
||||||
operation,
|
operation,
|
||||||
command,
|
command,
|
||||||
excerpt === ""
|
output?.stderr.text.trim().slice(0, STDERR_EXCERPT_BYTES) ||
|
||||||
? `apktool exited with code ${String(failure.code)} for ${operation}`
|
`apktool exited with code ${String(output?.exitCode ?? "unknown")} for ${operation}`,
|
||||||
: excerpt,
|
{
|
||||||
{ cause },
|
cause,
|
||||||
|
...(cleanup === undefined ? {} : { cleanup }),
|
||||||
|
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Count files in the decoded workspace with a hard ceiling. */
|
/** Count files while bounding streamed directory-entry traversal. */
|
||||||
const countDecodedFiles = async (
|
const countDecodedFiles = async (
|
||||||
root: string,
|
root: string,
|
||||||
): Promise<{ count: number; capped: boolean }> => {
|
signal: AbortSignal | undefined,
|
||||||
|
operation: string,
|
||||||
|
): Promise<{
|
||||||
|
count: number;
|
||||||
|
capped: boolean;
|
||||||
|
locales: readonly string[];
|
||||||
|
}> => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let capped = false;
|
let capped = false;
|
||||||
const walk = async (directory: string): Promise<void> => {
|
let visited = 0;
|
||||||
const entries = await readdir(directory, { withFileTypes: true });
|
const directories = [root];
|
||||||
for (const entry of entries) {
|
|
||||||
if (count >= MAX_DECODED_FILES_COUNTED) {
|
|
||||||
capped = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const path = join(directory, entry.name);
|
|
||||||
if (entry.isDirectory()) await walk(path);
|
|
||||||
else if (entry.isFile()) count += 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await walk(root);
|
|
||||||
return { count, capped };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Locale codes present as res/values-<locale>/strings.xml. */
|
|
||||||
const listStringLocales = async (
|
|
||||||
workspace: string,
|
|
||||||
): Promise<readonly string[]> => {
|
|
||||||
const res = join(workspace, "res");
|
|
||||||
const entries = await readdir(res, { withFileTypes: true }).catch(() => []);
|
|
||||||
const locales: string[] = [];
|
const locales: string[] = [];
|
||||||
for (const entry of entries) {
|
while (directories.length > 0 && !capped) {
|
||||||
if (!entry.isDirectory()) continue;
|
throwIfCancelled(operation, signal);
|
||||||
const match = /^values-(.+)$/u.exec(entry.name);
|
const directory = directories.pop()!;
|
||||||
if (match === null) continue;
|
for await (const entry of await opendir(directory)) {
|
||||||
const strings = join(res, entry.name, "strings.xml");
|
throwIfCancelled(operation, signal);
|
||||||
if (
|
if (visited >= MAX_DECODED_ENTRIES_VISITED) {
|
||||||
await access(strings).then(
|
capped = true;
|
||||||
() => true,
|
break;
|
||||||
() => false,
|
}
|
||||||
)
|
visited += 1;
|
||||||
)
|
const path = join(directory, entry.name);
|
||||||
locales.push(match[1]!);
|
if (entry.isDirectory()) {
|
||||||
|
directories.push(path);
|
||||||
|
if (directory === join(root, "res")) {
|
||||||
|
const match = /^values-(.+)$/u.exec(entry.name);
|
||||||
|
if (
|
||||||
|
match !== null &&
|
||||||
|
(await access(join(path, "strings.xml")).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
locales.push(match[1]!);
|
||||||
|
}
|
||||||
|
} else if (entry.isFile()) count += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return locales.sort();
|
return { count, capped, locales: locales.sort() };
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ApktoolProviderOptions {
|
export interface ApktoolProviderOptions {
|
||||||
@@ -235,9 +294,14 @@ export class ApktoolProvider {
|
|||||||
|
|
||||||
private readonly workspaces = new Set<string>();
|
private readonly workspaces = new Set<string>();
|
||||||
private readonly removeWorkspace: (path: string) => Promise<void>;
|
private readonly removeWorkspace: (path: string) => Promise<void>;
|
||||||
|
readonly #pendingCommandOwners = new Map<ProviderProcessSupervisor, string>();
|
||||||
|
readonly #shutdown = new AbortController();
|
||||||
|
readonly #active = new Set<Promise<void>>();
|
||||||
|
#closing = false;
|
||||||
|
#closePromise: Promise<void> | undefined;
|
||||||
|
|
||||||
constructor(options: ApktoolProviderOptions = {}) {
|
constructor(options: ApktoolProviderOptions = {}) {
|
||||||
this.environment = options.environment ?? process.env;
|
this.environment = snapshotEnvironment(options.environment ?? process.env);
|
||||||
this.removeWorkspace =
|
this.removeWorkspace =
|
||||||
options.removeWorkspace ??
|
options.removeWorkspace ??
|
||||||
((path) => rm(path, { recursive: true, force: true }));
|
((path) => rm(path, { recursive: true, force: true }));
|
||||||
@@ -248,12 +312,25 @@ export class ApktoolProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Probe the selected apktool launcher; decodes nothing. */
|
/** Probe the selected apktool launcher; decodes nothing. */
|
||||||
async inspectAvailability(
|
inspectAvailability(signal?: AbortSignal): Promise<ProviderAvailability> {
|
||||||
signal?: AbortSignal,
|
if (this.#closing)
|
||||||
|
return Promise.reject(
|
||||||
|
new AnalysisCancelledError("inspect_apktool_client"),
|
||||||
|
);
|
||||||
|
const operationSignal =
|
||||||
|
signal === undefined
|
||||||
|
? this.#shutdown.signal
|
||||||
|
: AbortSignal.any([signal, this.#shutdown.signal]);
|
||||||
|
return this.#track(this.#inspectAvailability(operationSignal));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #inspectAvailability(
|
||||||
|
signal: AbortSignal,
|
||||||
): Promise<ProviderAvailability> {
|
): Promise<ProviderAvailability> {
|
||||||
const selection = this.selection();
|
const selection = this.selection();
|
||||||
try {
|
try {
|
||||||
await inspectApktoolClient(selection, signal);
|
await inspectApktoolClient(selection, signal, this.environment);
|
||||||
|
throwIfCancelled("inspect_apktool_client", signal);
|
||||||
return {
|
return {
|
||||||
status: "available",
|
status: "available",
|
||||||
code: null,
|
code: null,
|
||||||
@@ -272,30 +349,44 @@ export class ApktoolProvider {
|
|||||||
|
|
||||||
/** Retain failed cleanup ownership and retry it on close. */
|
/** Retain failed cleanup ownership and retry it on close. */
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
const failures: unknown[] = [];
|
this.#closing = true;
|
||||||
for (const path of this.workspaces) {
|
this.#shutdown.abort();
|
||||||
try {
|
this.#closePromise ??= this.#drainAndClean().catch((cause: unknown) => {
|
||||||
await this.removeWorkspace(path);
|
this.#closePromise = undefined;
|
||||||
this.workspaces.delete(path);
|
throw cause;
|
||||||
} catch (cause) {
|
});
|
||||||
failures.push(cause);
|
return this.#closePromise;
|
||||||
}
|
|
||||||
}
|
|
||||||
if (failures.length > 0)
|
|
||||||
throw new AggregateError(failures, "Apktool workspace cleanup failed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(
|
execute(
|
||||||
request: ApktoolRequest,
|
request: ApktoolRequest,
|
||||||
options?: ExecutionOptions,
|
options?: ExecutionOptions,
|
||||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||||
const signal = options?.signal;
|
if (this.#closing)
|
||||||
|
return Promise.resolve(
|
||||||
|
err(new AnalysisCancelledError(request.operation)),
|
||||||
|
);
|
||||||
|
const signal =
|
||||||
|
options?.signal === undefined
|
||||||
|
? this.#shutdown.signal
|
||||||
|
: AbortSignal.any([options.signal, this.#shutdown.signal]);
|
||||||
|
return this.#track(this.#execute(request, signal));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #execute(
|
||||||
|
request: ApktoolRequest,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||||
if (signal?.aborted === true)
|
if (signal?.aborted === true)
|
||||||
return err(new AnalysisCancelledError(request.operation));
|
return err(new AnalysisCancelledError(request.operation));
|
||||||
const selection = this.selection();
|
const selection = this.selection();
|
||||||
let identity: ApktoolClientIdentity;
|
let identity: ApktoolClientIdentity;
|
||||||
try {
|
try {
|
||||||
identity = await inspectApktoolClient(selection, signal);
|
identity = await inspectApktoolClient(
|
||||||
|
selection,
|
||||||
|
signal,
|
||||||
|
this.environment,
|
||||||
|
);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||||
if (cause instanceof ApktoolConfigurationFailure)
|
if (cause instanceof ApktoolConfigurationFailure)
|
||||||
@@ -316,6 +407,8 @@ export class ApktoolProvider {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (signal.aborted)
|
||||||
|
return err(new AnalysisCancelledError(request.operation));
|
||||||
const client = {
|
const client = {
|
||||||
command: selection.command,
|
command: selection.command,
|
||||||
command_source: selection.commandSource,
|
command_source: selection.commandSource,
|
||||||
@@ -337,6 +430,47 @@ export class ApktoolProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#track<T>(operation: Promise<T>): Promise<T> {
|
||||||
|
const settled = operation.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
this.#active.add(settled);
|
||||||
|
void settled.then(() => this.#active.delete(settled));
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #drainAndClean(): Promise<void> {
|
||||||
|
await Promise.all(this.#active);
|
||||||
|
const failures: unknown[] = [];
|
||||||
|
for (const [owner, root] of this.#pendingCommandOwners) {
|
||||||
|
try {
|
||||||
|
const stopped = await owner.stop();
|
||||||
|
if (stopped.status === "incomplete") throw new Error(stopped.reason);
|
||||||
|
this.#pendingCommandOwners.delete(owner);
|
||||||
|
} catch (cause) {
|
||||||
|
failures.push(
|
||||||
|
new Error(
|
||||||
|
`apktool process cleanup remains incomplete for ${root}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||||
|
{ cause },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.length > 0)
|
||||||
|
throw new AggregateError(failures, "Apktool process cleanup failed");
|
||||||
|
for (const path of this.workspaces) {
|
||||||
|
try {
|
||||||
|
await this.removeWorkspace(path);
|
||||||
|
this.workspaces.delete(path);
|
||||||
|
} catch (cause) {
|
||||||
|
failures.push(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.length > 0)
|
||||||
|
throw new AggregateError(failures, "Apktool workspace cleanup failed");
|
||||||
|
}
|
||||||
|
|
||||||
private async decodeResources(
|
private async decodeResources(
|
||||||
operation: "decode_android_resources",
|
operation: "decode_android_resources",
|
||||||
selection: ApktoolCommandSelection,
|
selection: ApktoolCommandSelection,
|
||||||
@@ -353,28 +487,6 @@ export class ApktoolProvider {
|
|||||||
},
|
},
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||||
const info = await stat(input.path).then(
|
|
||||||
(value) => value,
|
|
||||||
() => null,
|
|
||||||
);
|
|
||||||
if (info === null || !info.isFile())
|
|
||||||
return err(
|
|
||||||
new AnalysisInputError(operation, {
|
|
||||||
cause: new Error(
|
|
||||||
`The selected APK is not a readable file: ${input.path}`,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const readable = await access(input.path, fsConstants.R_OK).then(
|
|
||||||
() => true,
|
|
||||||
() => false,
|
|
||||||
);
|
|
||||||
if (!readable)
|
|
||||||
return err(
|
|
||||||
new AnalysisInputError(operation, {
|
|
||||||
cause: new Error(`The selected APK is not readable: ${input.path}`),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "rea-apktool-"));
|
const root = await mkdtemp(join(tmpdir(), "rea-apktool-"));
|
||||||
this.workspaces.add(root);
|
this.workspaces.add(root);
|
||||||
const workspace = join(root, "decoded");
|
const workspace = join(root, "decoded");
|
||||||
@@ -384,6 +496,10 @@ export class ApktoolProvider {
|
|||||||
// The workspace is removed before the result is returned, so an
|
// The workspace is removed before the result is returned, so an
|
||||||
// unremovable workspace is reported instead of silently abandoned.
|
// unremovable workspace is reported instead of silently abandoned.
|
||||||
const removeWorkspace = async (): Promise<void> => {
|
const removeWorkspace = async (): Promise<void> => {
|
||||||
|
if ([...this.#pendingCommandOwners.values()].includes(root)) {
|
||||||
|
cleanupFailure = `The decode workspace is retained until its apktool process stops: ${root}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.removeWorkspace(root);
|
await this.removeWorkspace(root);
|
||||||
this.workspaces.delete(root);
|
this.workspaces.delete(root);
|
||||||
@@ -394,20 +510,78 @@ export class ApktoolProvider {
|
|||||||
const failureWithCleanup = (failure: AnalysisError): AnalysisError =>
|
const failureWithCleanup = (failure: AnalysisError): AnalysisError =>
|
||||||
cleanupFailure === null
|
cleanupFailure === null
|
||||||
? failure
|
? failure
|
||||||
: failure instanceof AnalysisCancelledError
|
: analysisErrorWithCleanupFailure(
|
||||||
? new AnalysisCancelledError(operation, {
|
failure,
|
||||||
cause: failure,
|
new ProviderCleanupError(
|
||||||
cleanup: { reason: cleanupFailure, resources: [root] },
|
APKTOOL_PROVIDER_IDENTITY.id,
|
||||||
})
|
[root],
|
||||||
: new AnalysisProtocolError(failure.message, {
|
{ reason: cleanupFailure },
|
||||||
cause: failure,
|
{ operation },
|
||||||
cleanup: { reason: cleanupFailure, resources: [root] },
|
),
|
||||||
});
|
operation,
|
||||||
|
);
|
||||||
|
let source: FileHandle | undefined;
|
||||||
|
let sourceAdmissionFailed = false;
|
||||||
try {
|
try {
|
||||||
await copyFile(input.path, snapshot, fsConstants.COPYFILE_EXCL);
|
throwIfCancelled(operation, signal);
|
||||||
|
sourceAdmissionFailed = true;
|
||||||
|
source = await openRegularFile(input.path, {
|
||||||
|
symlinks: "follow",
|
||||||
|
...(signal === undefined ? {} : { signal }),
|
||||||
|
});
|
||||||
|
const sourceInfo = await source.stat();
|
||||||
|
sourceAdmissionFailed = false;
|
||||||
|
const sourceSize = sourceInfo.size;
|
||||||
|
let copiedBytes = 0;
|
||||||
|
const sourceHash = createHash("sha256");
|
||||||
|
const boundedSource = new Transform({
|
||||||
|
transform(
|
||||||
|
chunk: Buffer,
|
||||||
|
_encoding: BufferEncoding,
|
||||||
|
callback: TransformCallback,
|
||||||
|
) {
|
||||||
|
copiedBytes += chunk.length;
|
||||||
|
if (copiedBytes > sourceSize) {
|
||||||
|
callback(
|
||||||
|
new AnalysisInputError(operation, {
|
||||||
|
cause: new Error(
|
||||||
|
`The selected APK changed while being copied: ${input.path}`,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceHash.update(chunk);
|
||||||
|
callback(null, chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await pipeline(
|
||||||
|
source.createReadStream({
|
||||||
|
start: 0,
|
||||||
|
end: sourceSize,
|
||||||
|
autoClose: false,
|
||||||
|
...(signal === undefined ? {} : { signal }),
|
||||||
|
}),
|
||||||
|
boundedSource,
|
||||||
|
createWriteStream(snapshot, { flags: "wx", mode: 0o600 }),
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
|
);
|
||||||
|
const finalSourceInfo = await source.stat();
|
||||||
|
if (
|
||||||
|
copiedBytes !== sourceSize ||
|
||||||
|
!sameRegularFileState(sourceInfo, finalSourceInfo)
|
||||||
|
)
|
||||||
|
throw new AnalysisInputError(operation, {
|
||||||
|
cause: new Error(
|
||||||
|
`The selected APK changed while being copied: ${input.path}`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const sha256 = sourceHash.digest("hex");
|
||||||
|
await source.close();
|
||||||
|
source = undefined;
|
||||||
await chmod(snapshot, 0o400);
|
await chmod(snapshot, 0o400);
|
||||||
|
throwIfCancelled(operation, signal);
|
||||||
const snapshotInfo = await stat(snapshot);
|
const snapshotInfo = await stat(snapshot);
|
||||||
const sha256 = await sha256File(snapshot);
|
|
||||||
await mkdir(workspace);
|
await mkdir(workspace);
|
||||||
await mkdir(framework);
|
await mkdir(framework);
|
||||||
const decoded = await runApktool(
|
const decoded = await runApktool(
|
||||||
@@ -428,14 +602,19 @@ export class ApktoolProvider {
|
|||||||
maxOutputBytes: DECODE_MAX_OUTPUT_BYTES,
|
maxOutputBytes: DECODE_MAX_OUTPUT_BYTES,
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
|
this.environment,
|
||||||
|
(owner) => this.#pendingCommandOwners.set(owner, root),
|
||||||
);
|
);
|
||||||
if (!decoded.ok) {
|
if (!decoded.ok) {
|
||||||
await removeWorkspace();
|
await removeWorkspace();
|
||||||
return err(failureWithCleanup(decoded.error));
|
return err(failureWithCleanup(decoded.error));
|
||||||
}
|
}
|
||||||
|
throwIfCancelled(operation, signal);
|
||||||
const yml = await readBoundedText(
|
const yml = await readBoundedText(
|
||||||
join(workspace, "apktool.yml"),
|
join(workspace, "apktool.yml"),
|
||||||
MANIFEST_MAX_BYTES,
|
MANIFEST_MAX_BYTES,
|
||||||
|
operation,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
if (yml.text === null || yml.truncated) {
|
if (yml.text === null || yml.truncated) {
|
||||||
throw new AnalysisProtocolError(
|
throw new AnalysisProtocolError(
|
||||||
@@ -446,11 +625,12 @@ export class ApktoolProvider {
|
|||||||
const manifestRead = await readBoundedText(
|
const manifestRead = await readBoundedText(
|
||||||
join(workspace, "AndroidManifest.xml"),
|
join(workspace, "AndroidManifest.xml"),
|
||||||
MANIFEST_MAX_BYTES,
|
MANIFEST_MAX_BYTES,
|
||||||
|
operation,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
const manifest = manifestRead.text;
|
const manifest = manifestRead.text;
|
||||||
const packageName =
|
const packageName =
|
||||||
manifest === null ? null : parseManifestPackage(manifest);
|
manifest === null ? null : parseManifestPackage(manifest);
|
||||||
const locales = await listStringLocales(workspace);
|
|
||||||
const stringsDirectory =
|
const stringsDirectory =
|
||||||
input.locale === undefined
|
input.locale === undefined
|
||||||
? "values"
|
? "values"
|
||||||
@@ -459,6 +639,8 @@ export class ApktoolProvider {
|
|||||||
? await readBoundedText(
|
? await readBoundedText(
|
||||||
join(workspace, "res", stringsDirectory, "strings.xml"),
|
join(workspace, "res", stringsDirectory, "strings.xml"),
|
||||||
STRINGS_XML_MAX_BYTES,
|
STRINGS_XML_MAX_BYTES,
|
||||||
|
operation,
|
||||||
|
signal,
|
||||||
)
|
)
|
||||||
: { text: null, truncated: false };
|
: { text: null, truncated: false };
|
||||||
// Do not parse an incomplete XML document as though its projection were complete.
|
// Do not parse an incomplete XML document as though its projection were complete.
|
||||||
@@ -468,7 +650,8 @@ export class ApktoolProvider {
|
|||||||
? { entries: [], unparsedCount: 0 }
|
? { entries: [], unparsedCount: 0 }
|
||||||
: parseDecodedStrings(stringsXml);
|
: parseDecodedStrings(stringsXml);
|
||||||
const bounded = parsedStrings.entries.slice(0, MAX_STRINGS);
|
const bounded = parsedStrings.entries.slice(0, MAX_STRINGS);
|
||||||
const files = await countDecodedFiles(workspace);
|
const files = await countDecodedFiles(workspace, signal, operation);
|
||||||
|
throwIfCancelled(operation, signal);
|
||||||
const coverage =
|
const coverage =
|
||||||
(stringsXml !== null && parsedStrings.entries.length > MAX_STRINGS) ||
|
(stringsXml !== null && parsedStrings.entries.length > MAX_STRINGS) ||
|
||||||
manifestRead.truncated ||
|
manifestRead.truncated ||
|
||||||
@@ -493,7 +676,7 @@ export class ApktoolProvider {
|
|||||||
manifest,
|
manifest,
|
||||||
strings: bounded,
|
strings: bounded,
|
||||||
locale: input.locale ?? null,
|
locale: input.locale ?? null,
|
||||||
locales,
|
locales: files.locales,
|
||||||
decoded_file_count: files.count,
|
decoded_file_count: files.count,
|
||||||
coverage,
|
coverage,
|
||||||
},
|
},
|
||||||
@@ -535,7 +718,7 @@ export class ApktoolProvider {
|
|||||||
: []),
|
: []),
|
||||||
...(files.capped
|
...(files.capped
|
||||||
? [
|
? [
|
||||||
`decoded_file_count is capped at ${String(MAX_DECODED_FILES_COUNTED)}`,
|
`Decoded workspace enumeration stopped after ${String(MAX_DECODED_ENTRIES_VISITED)} entries; decoded_file_count and locales may be partial`,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(cleanupFailure === null ? [] : [cleanupFailure]),
|
...(cleanupFailure === null ? [] : [cleanupFailure]),
|
||||||
@@ -547,14 +730,25 @@ export class ApktoolProvider {
|
|||||||
await removeWorkspace();
|
await removeWorkspace();
|
||||||
return err(
|
return err(
|
||||||
failureWithCleanup(
|
failureWithCleanup(
|
||||||
cause instanceof AnalysisCancelledError ||
|
signal?.aborted === true
|
||||||
cause instanceof AnalysisProtocolError
|
? new AnalysisCancelledError(operation, { cause })
|
||||||
? cause
|
: sourceAdmissionFailed
|
||||||
: new AnalysisProtocolError("Apktool resource projection failed", {
|
? new AnalysisInputError(operation, { cause })
|
||||||
cause,
|
: cause instanceof AnalysisInputError
|
||||||
}),
|
? cause
|
||||||
|
: cause instanceof AnalysisCancelledError ||
|
||||||
|
cause instanceof AnalysisProtocolError
|
||||||
|
? cause
|
||||||
|
: new AnalysisProtocolError(
|
||||||
|
"Apktool resource projection failed",
|
||||||
|
{
|
||||||
|
cause,
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
await source?.close().catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
|
import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
@@ -156,13 +156,14 @@ const wrappedBundle = async (bundle: string): Promise<string | undefined> => {
|
|||||||
if (isAbsence(cause)) return undefined;
|
if (isAbsence(cause)) return undefined;
|
||||||
throw cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
const apps = (await readdir(wrapper, { withFileTypes: true })).filter(
|
let app: string | undefined;
|
||||||
(entry) => entry.isDirectory() && entry.name.toLowerCase().endsWith(".app"),
|
for await (const entry of await opendir(wrapper)) {
|
||||||
);
|
if (!entry.isDirectory() || !entry.name.toLowerCase().endsWith(".app"))
|
||||||
const [app] = apps;
|
continue;
|
||||||
return apps.length === 1 && app !== undefined
|
if (app !== undefined) return undefined;
|
||||||
? join(wrapper, app.name)
|
app = join(wrapper, entry.name);
|
||||||
: undefined;
|
}
|
||||||
|
return app;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveLayoutExecutable = async (
|
const resolveLayoutExecutable = async (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { constants } from "node:fs";
|
import { constants, type Stats } from "node:fs";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
access,
|
access,
|
||||||
@@ -248,7 +248,7 @@ const isArchiveFormat = (
|
|||||||
const sha256Handle = async (
|
const sha256Handle = async (
|
||||||
handle: FileHandle,
|
handle: FileHandle,
|
||||||
path: string,
|
path: string,
|
||||||
initial: Awaited<ReturnType<FileHandle["stat"]>>,
|
initial: Stats,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const hash = createHash("sha256");
|
const hash = createHash("sha256");
|
||||||
@@ -256,11 +256,18 @@ const sha256Handle = async (
|
|||||||
let position = 0;
|
let position = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
throwIfTargetResolutionCancelled(signal);
|
throwIfTargetResolutionCancelled(signal);
|
||||||
const observed = await handle.read(chunk, 0, chunk.length, position);
|
const observed = await handle.read(
|
||||||
|
chunk,
|
||||||
|
0,
|
||||||
|
Math.min(chunk.length, initial.size - position + 1),
|
||||||
|
position,
|
||||||
|
);
|
||||||
throwIfTargetResolutionCancelled(signal);
|
throwIfTargetResolutionCancelled(signal);
|
||||||
if (observed.bytesRead === 0) break;
|
if (observed.bytesRead === 0) break;
|
||||||
hash.update(chunk.subarray(0, observed.bytesRead));
|
hash.update(chunk.subarray(0, observed.bytesRead));
|
||||||
position += observed.bytesRead;
|
position += observed.bytesRead;
|
||||||
|
if (position > initial.size)
|
||||||
|
throw new BinaryTargetError(path, "target grew while being read");
|
||||||
}
|
}
|
||||||
throwIfTargetResolutionCancelled(signal);
|
throwIfTargetResolutionCancelled(signal);
|
||||||
const [opened, currentPath] = await Promise.all([handle.stat(), lstat(path)]);
|
const [opened, currentPath] = await Promise.all([handle.stat(), lstat(path)]);
|
||||||
@@ -366,7 +373,13 @@ const readExecutableMetadata = async (
|
|||||||
if (!abiFlags.ok) return abiFlags;
|
if (!abiFlags.ok) return abiFlags;
|
||||||
return ok({
|
return ok({
|
||||||
...parsed.value,
|
...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 }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Result } from "../domain/result.js";
|
|||||||
|
|
||||||
/** Provider-neutral application boundary for controlled browser scenarios. */
|
/** Provider-neutral application boundary for controlled browser scenarios. */
|
||||||
export interface BrowserScenarioCapturePort {
|
export interface BrowserScenarioCapturePort {
|
||||||
|
close?(): Promise<void>;
|
||||||
identity(): ProviderIdentity;
|
identity(): ProviderIdentity;
|
||||||
captureScenario(
|
captureScenario(
|
||||||
scenario: BrowserScenario,
|
scenario: BrowserScenario,
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { err, ok, type Result } from "../domain/result.js";
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
import { readFile } from "node:fs/promises";
|
import {
|
||||||
|
RegularFileCleanupFailure,
|
||||||
|
readRegularFileText,
|
||||||
|
} from "./RegularFileRead.js";
|
||||||
import {
|
import {
|
||||||
effectiveClientServer,
|
effectiveClientServer,
|
||||||
parseClientConfiguration,
|
parseClientConfiguration,
|
||||||
type ClientConfigurationDocument,
|
type ClientConfigurationDocument,
|
||||||
} from "./ClientConfigurationDocument.js";
|
} from "./ClientConfigurationDocument.js";
|
||||||
import type { SetupClient } from "./SupportedClients.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. */
|
/** A named source failure that setup can explain without losing the bad path. */
|
||||||
export interface ClientConfigurationFileError {
|
export interface ClientConfigurationFileError {
|
||||||
readonly kind: "unreadable" | "malformed";
|
readonly kind: "unreadable" | "malformed";
|
||||||
readonly path: string;
|
readonly path: string;
|
||||||
readonly detail: string;
|
readonly detail: string;
|
||||||
|
readonly cleanupFailure?: RegularFileCleanupFailure;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preserve named read failures and distinguish a missing source from an empty file. */
|
/** Preserve named read failures and distinguish a missing source from an empty file. */
|
||||||
@@ -19,7 +24,7 @@ export const readConfigurationText = async (
|
|||||||
path: string,
|
path: string,
|
||||||
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
|
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
|
||||||
try {
|
try {
|
||||||
return ok(await readFile(path, "utf8"));
|
return ok(await readRegularFileText(path));
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
@@ -27,6 +32,9 @@ export const readConfigurationText = async (
|
|||||||
kind: "unreadable",
|
kind: "unreadable",
|
||||||
path,
|
path,
|
||||||
detail: `Configuration ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,
|
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]) {
|
for (const path of client.configPaths ?? [client.configPath]) {
|
||||||
const document = await readConfigurationFile(path, client.format);
|
const document = await readConfigurationFile(path, client.format);
|
||||||
if (!document.ok) return document;
|
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);
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user