32 Commits
Author SHA1 Message Date
czcdr ed6b3c99b8 docs: redraw the market screenshot for 6.8.0 (229 tools, measured 2026-10-26) 2026-10-26 21:27:32 +08:00
czcdr c55768233c fix: the setup card must say what setup resets
Restarting dsh with the 2.0.12 copy still in the profile re-provisioned
the bundle to 2.0.12's pin: REA went back to 6.7.0, the Ghidra gate
override went with it, and the MCP row failed with "REA could not start".
That is the card doing its job on a machine whose plugin copy was stale —
but the card never said the aligned run also wipes the override.

- client.js: third note in both languages. It names the override case and
  the command that restores it, so the card is not advice that silently
  undoes a deliberate choice.
- README (both): the "you must run setup" block says the same thing.
- ghidra-gate-override.mjs: judge a recorded override by the files on disk.
  Re-provisioning replaces rea-agents while leaving the record behind, so
  --enable now replaces a stale record instead of refusing, and --disable
  counts already-lost files instead of throwing.
- CHANGELOG: 2.0.13 records both, plus the failure that prompted them.

Verified after re-aligning this machine: setup --reuse reached
"setup complete" with its Ghidra smoke importing java.exe in 28 s on 12.2,
the shim resolves REA 6.8.0, and the MCP path through the shim returns the
same segments / procedure inventory / entry pseudocode as the 12.1.4
baseline with provider.version = 12.2.

Gates: audit 24/24 hash-pinned clean, 88 tests (85 pass / 3 skipped),
verify-branch ALL CHECKS PASSED, host-compat 10 covered / 5 outside.
2026-10-26 21:16:33 +08:00
czcdr 10572a7958 feat: let an operator run a Ghidra release REA has not verified
The four CVEs in the chain are Ghidra 12.1.4's and are fixed in 12.2, and
12.2 is exactly the release REA 6.8.0 refuses: SUPPORTED_GHIDRA_VERSION is
still 12.1.4, and an out-of-line build withdraws the whole Ghidra family.

Measured today, so the decision rests on evidence rather than on the gate:
Ghidra 12.2 (ghidra_12.2_PUBLIC_20261016.zip, 569,712,708 B, sha256
2c2cd776... matching the release body) against 6.8.0's own bridge produced
the same 9 segments, the same 341-procedure inventory (74,483 chars) and a
byte-identical `entry` pseudocode as the 12.1.4 baseline, through the shim,
with provider.version = 12.2. The gate blocks a working combination; it does
not describe a broken one.

scripts/ghidra-gate-override.mjs therefore makes that choice explicit,
recorded and reversible: it rewrites only the constant in the vendored copy
(three files), writes what it changed to <bundle>/ghidra-gate-override.json,
and --disable restores the original values. Re-provisioning rea-agents
removes the patch, so the default after any setup run is the pinned,
verified combination again. 12.2 needs JDK 25+ (application.java.min=25),
which the adopted Corretto 25.0.5 satisfies here but the pinned Temurin 21
would not.

check-updates.ps1 now reads that record and reports "local override" instead
of advising a setup re-run, which is what would have silently undone it.

Gates: audit 24/24 hash-pinned clean, 88 tests (85 pass / 3 skipped),
verify-branch ALL CHECKS PASSED.

Also recorded: editing check-updates.ps1 stripped its UTF-8 BOM, which makes
PowerShell 5.1 read it as ANSI and fail to parse at all; the BOM is restored
and the note is in docs/project-notes.md.
2026-10-26 19:09:10 +08:00
czcdr 513b09408b feat: pin REA 6.8.0 and follow its Node engines floor (2.0.13)
REA 6.8.0 (npm latest, 2026-10-25) is a tool expansion, not a patch: 229
MCP tools instead of 207 (+22: APK signatures, binary diagnostics, model
artifacts, CUDA/HIP code objects), 175 CLI commands, 6 prompts, and a
shipped dist/mcp-catalog.json that records the counts and three digests.

It also raises engines.node to ^22.23.4 || ^24.22.1 || >=26.12.1, which
makes the pinned Node 24.22.1 exactly the floor of its line. The pin,
package.json engines and the node probe therefore move together: a probe
has no version comparator, so the range is one alternation verified
against 24 versions (22.23.3, 24.22.0 and 26.12.0 must be rejected).

Nothing else moved. 6.8.0 still declares SUPPORTED_GHIDRA_VERSION 12.1.4
(12.2 stays unsupported) and JADX_RELEASE 0.7.1 with the same sha256, and
Adoptium still publishes no Windows build of 21.0.13 — so Ghidra, jadx,
JDK, apktool, platform-tools and Node keep their pins.

Measured here, not inferred: setup --reuse reported cli=6.8.0 tools=229
and its Ghidra smoke imported java.exe in 26 s; an independent stdio MCP
handshake answered serverInfo 6.8.0 with 229 tools / 6 prompts on
protocol 2024-11-05; executing 6.8.0's release policy gave 12.1.4
verified / 12.1.5 compatible / 12.2 unsupported; apktool 3.0.3, adb
37.0.1 and the 0.7.1 jar hash all re-checked. Gates: audit 24/24
hash-pinned clean, 88 unit tests (85 pass, 3 skipped), verify-branch
ALL CHECKS PASSED, host-compat 10 covered / 5 outside.

Docs: both READMEs (versions, tool counts, engines sentence, tag example),
docs/bundle-design.md and the new docs/update-review-2026-10-26.md, which
also records the npm/proxy trap that made the first provisioning run fail
with ECONNRESET, and why WSL needs no separate update.
2026-10-26 18:25:51 +08:00
czcdr 3d46fbcf02 docs: drop the upgrade ceiling headline from both READMEs
The paragraph below it already states what is pinned, why 12.2 / 0.8.0
are refused, and what the trade-off is; the one-line lead was redundant
and read as a judgement rather than a fact. No other change.
2026-10-24 00:38:18 +08:00
czcdr 508f3c24d6 docs: record why the runtime pins stop at their current versions
Adds docs/update-review-2026-10-24.md and one paragraph to both READMEs,
directly under the "which version is this" block.

Measured findings:
- Ghidra 12.2 is published (2026-10-22), but GhidraReleasePolicy.js accepts
  the 12.1 line only: 12.2 resolves to "unsupported" and the whole Ghidra
  decompile/analysis group disappears rather than degrading.
- jadx-headless-mcp 0.8.0 passes class admission but is rejected by
  JadxSession.js:61, which requires the engine to report 0.7.1. Only
  BuildInfo and JadxSession differ from 0.7.1; the other bridge classes are
  byte-identical, so the break is the version assertion, not ABI.
- Temurin 21.0.13+9 ships 204 assets and none for Windows, so win32-x64 has
  nothing to point at; it also removes only 3 of the 4 eMudhra anchors.
- REA 6.7.0, Node 24.22.1, apktool 3.0.3 and the platform-tools bytes are
  already the newest upstream.

No versions pinned or moved; documentation only.
2026-10-24 00:31:29 +08:00
czcdr b8b0f593cc fix: the guide's command must not re-download a runtime that is already there (2.0.12)
The one-shot setup card appears mostly after an upgrade, when the runtime is
already on the machine. Its command carried no `--reuse`, so following it
re-fetched the ~570 MB JDK + Ghidra pair — the opposite of "align the runtime",
and enough to turn a thirty-second step into fifteen minutes. The command now
reads `node "…/scripts/setup.mjs" --reuse`; on a first install the flag costs
nothing, because every candidate is probed and a machine with none downloads
exactly as before.

The card also states the two things that look like failures and are not: setup
ends by importing a small file to prove the engine works (32–42 s of silence),
and a concurrent run holds `setup.lock`, which makes setup exit 75 with the
holder's pid and log path.

The README install lines move to #v2.0.12, which verify-branch pins against
package.json.
2026-10-23 23:41:40 +08:00
czcdr a7dd23f375 docs: release notes for 2.0.11 (2026-10-23) 2026-10-23 23:31:24 +08:00
czcdr 1f5a9925c7 fix: ten review findings — dead audit check, silent zero-arg exit, lock without mutual exclusion (2.0.11)
audit-manifest — item 13 tested a string with `Number.isFinite`, which never
coerces, so the branch was always false and "does the probe accept the next
patch/minor" could not fail. Verified end to end with a probe of `^6\.`: the
audit now reports "accepts 6.7.1 (the next patch)" and "accepts 6.8.0".

rea-shim — a zero-argument start printed usage and exited 0, while both launch
paths still carried an unreachable `argv.length > 0 ? argv : ["mcp"]` fallback.
With `failOnStartupError: false`, that exit 0 leaves REA silently absent from
every tool list. The two cases are now told apart by whether a terminal is
attached: usage + 0 for a person, usage + stderr + EX_USAGE(64) for a harness
that lost its arguments. Both dead fallbacks removed.

setup.mjs — three:
  * provisioning cleared the destination BEFORE extracting, so a missing
    extractor or a full disk destroyed a working install and replaced it with
    nothing; the tree is cleared only for the rename now.
  * a `.part` holding the whole body could not be rescued when the manifest
    declared no size — the Range request got 416 on every retry. A pinned
    sha256 is now enough evidence to finish the rename.
  * `setup.lock` was written only when a run ENDED, so it recorded an outcome
    and excluded nothing: the shim's detached run and a hand-run setup could
    write the same `.part` and the same staging directory. It is claimed with
    O_EXCL before the first byte moves; a live holder is reported with its pid
    and log path and the run exits 75, while a dead pid or a `failed` lock is
    taken over.

check-updates.ps1 — a missing `application.version` line was reported as a
version mismatch ("requires 12.1.x, currently "), sending the reader after a
newer Ghidra instead of a reinstall.

rea-bundle-bootstrap.ps1 — the engines check compared only major/minor, so
`^24.11.1` accepted 24.11.0; all three components compare now.

rea-shim PATH dedupe — Windows compared path spellings case-sensitively and
could prepend the vendored Node twice.

cordis.patch.yml — the home fallback skipped HOMEDRIVE+HOMEPATH, so a service
account resolved `/.dsh/...`. It now mirrors `os.homedir()`: USERPROFILE →
HOMEDRIVE+HOMEPATH → HOME. Both READMEs follow, because the audit compares the
documented expression with the shipped one.

setup.mjs header — the TOFU sentence promised "enforced afterwards" while an
`unverified` checksum is trusted on a cache hit. The comment now states what the
code does and names the gap in `bundle.json` instead of implying verification.

Tests 85 -> 88: extraction order, the audit's item-13 branch, and a live lock
blocking a second run; the bare-invocation case asserts EX_USAGE plus the
`--shim-help` path that must stay 0.
2026-10-23 23:03:39 +08:00
czcdr 50864f0a9d fix: record what was adopted, smoke the engine, pin Node 24.22.1 (2.0.11)
setup.mjs — three defects that all made `bundle.json` describe something other
than what the machine runs:

* A reused component carried no version. `provisionComponent` rebuilt
  `{path, reused}` from the adopted record, so `versions.node` fell back to the
  interpreter running setup and `versions.jdk`/`versions.ghidra` to the
  manifest pin. Both are wrong whenever the adopted build is not that one — the
  exact case `check-updates.ps1` exists to catch. Each installation is now asked
  what it is: `-v` for Node, `release` for a JDK, `Ghidra/application.properties`
  for Ghidra.
* The provisioning-time `rea doctor` got neither `GHIDRA_INSTALL_DIR` nor
  `JAVA_HOME`, so REA fell back to probing the machine and reported whichever
  Ghidra and JDK were installed globally. On this host that was a different
  Ghidra than the one being provisioned. Doctor and the smoke step now receive
  the same two variables the mirrored shim exports.
* New `smoke` step: after wiring, import a small PE through `rea analyze` and
  fail the run when it cannot. `doctor` validates paths, versions and which Java
  would be chosen but never imports a binary, so a broken bridge or a mismatched
  native addon surfaced only as an MCP tool timeout on the first real analysis —
  which reads exactly like "the runtime upgrade broke REA". Measured here: 36 s
  for a small PE, 64 s for a 6 MB DLL. `--skip-smoke` omits it.

manifest — Node 24.21.0 -> 24.22.1 on all six platforms (version, url, size,
sha256, verification date). 24.22.1 is Node's 2026-10-21 security release
(Brotli decoder out-of-bounds write RCE, two TLS hostname bypasses, eMudhra
intermediate removal); the reuse path was fine on a machine that already had it,
but a clean environment downloaded the vulnerable 24.21.0. Checksums come from
the official SHASUMS256.txt; the win32-x64 archive was also downloaded, hashed,
unpacked and run (`node -v`, `npm -v`) here.

scripts/check-updates.mjs — `npm run check-updates` goes through a portable
wrapper: PowerShell (5.1 first, then pwsh) on Windows, and on other platforms an
explicit explanation plus the two commands that do work, exiting non-zero rather
than pretending "not applicable" is "all green".

Tests 81 -> 85: smoke is skipped without an engine, --skip-smoke wins, the smoke
step really drives `analyze` and a failure is a FAIL, and the reuse record
spreads the adopted version instead of rebuilding it.
2026-10-23 20:47:36 +08:00
czcdr 1438672f9a feat: show a one-shot setup guide after install and every upgrade (2.0.11)
The setup step is mandatory but invisible: a market install or update replaces
the package and stops there, and a skipped setup fails silently in both
directions - no tools on a first install, the old REA after an upgrade. The
guide names the actual state instead of asking whether it was shown before.

- bin/rea-guide.mjs: computeGuideState compares the REA version recorded in
  plugins/rea-bundle/bundle.json with the manifest pin and reports
  missing / stale / unknown / unreadable / ok. Same pin rules as
  scripts/pin-match.ps1 (dotted pin exact, bare major as a family), and it
  reads UTF-16 records the way the shim and setup.mjs now do.
- index.mjs: serves GET /dsh-plugin-rea/guide-state, mounted only through
  ctx.inject(['webServer']), so headless and ACP hosts load the plugin exactly
  as before. Read-only: no provisioning, no spawning, no writes.
- client.js: shell.overlay card shown once per plugin version, with the exact
  command for this install; "Later" and "I ran it" record the version in
  localStorage, so the next upgrade re-arms it and the same version stays quiet.
- cordis.patch.yml: one extra row for the package entry only (no tools, no REA).
- 5 new tests (78 total); verify-branch section 9 checks all four ends of the
  chain, so a rename cannot make the guide silently never appear.
2026-10-20 19:09:16 +08:00
czcdr 212e20ae28 fix: five shim edge-path defects and UTF-16 record decoding (2.0.11)
systemReaCandidates followed this process's platform instead of the one it is resolving, so the win32 shape could never be exercised on POSIX; a failed background spawn left a lock naming a live pid (and an unhandled error event could kill the shim), so it now clears or marks the lock failed and logs why; probeRea accumulated stdout without a bound; readJson now decodes UTF-16 LE/BE in both the shim and setup, because PowerShell 5.1 writes that by default; foreignReaRegistration strips a YAML trailing comment.
2026-10-20 11:35:22 +08:00
czcdr 71252e26b8 docs: put the mandatory setup step above the fold, in both languages
A market install or update replaces the package and stops; nobody runs setup.mjs for the user. That sentence now sits at the top of both READMEs, and the standing per-release note in the changelog says the same so later release notes inherit it.
2026-10-19 22:35:49 +08:00
czcdr 2546cca893 fix: report an outdated bundle instead of calling it pin-consistent (2.0.10)
check-updates compared every component by major version, so 6.5.0 matched the 6.7.0 pin and the only signal that setup must be re-run disappeared. Dotted pins now compare exactly; a major-only pin (jdk 21) still matches its family; an adopted (reused) Node is checked against engines and is never counted as pin-consistent.

Also: the screenshot generator reads versions from the manifest instead of hardcoding 6.5.0, and both READMEs state the two standing upgrade rules (the mirrored launcher self-heals from 2.0.8; the mirrored setup copy is refreshed only by running setup from the plugin directory).
2026-10-19 22:04:15 +08:00
czcdr 7087d059be deps: pin REA 6.7.0 (2.0.10)
6.6.0 was tagged upstream but never published to npm; 6.7.0 is npm latest and keeps
the contract unchanged: same engines (^22.19.0 || ^24.11.0 || >=26.0.0), same bin
(scripts/rea.mjs), same optional frida 17.23.2, and the tarball still ships no
node_modules (1775 entries, 0 there), so provisioning stays an npm install.

Measured on this machine: rea --version 6.7.0, MCP initialize answers
serverInfo {name: rea, version: 6.7.0}, tools/list and doctor both report 207 tools
- unchanged from 6.5.0, so this is a feature/fix upgrade rather than tool growth.
doctor gains kuna-installation, another caller-supplied check provisioning leaves alone.

The rea stub in the tests now takes its version from the manifest pin instead of
hard-coding it: a bump otherwise fails --offline for a reason unrelated to offline.
2026-10-19 18:45:26 +08:00
czcdr 9a0f2708a1 docs: say which setup copy an upgrade leaves behind (2.0.9) 2026-10-18 15:37:47 +08:00
czcdr 95803a6833 docs: date the 2.0.9 entry for its release 2026-10-18 15:35:07 +08:00
czcdr 7b02aa95b6 setup: strip the BOM from the records setup itself reads (2.0.9)
2.0.9 taught the shim to drop a leading U+FEFF but left setup.mjs parsing raw.
Both programs read the same mirror.json and bundle.json, so a BOM made one file
mean different things: the shim found the checkout, setup did not — and a
mirrored setup re-mirrored the previous manifest and provisioned from it.
bundle.json was read as "nothing is provisioned" for the same reason.

Both sides now use the same readJson(). verify-branch.ps1 and CI fail on a bare
JSON.parse(readFileSync(...)) in either file, and two tests cover a BOM-ed
mirror.json and a BOM-ed bundle.json; both fail without the strip (checked).
2026-10-18 15:16:40 +08:00
czcdr ebb912ace2 ci: keep the README check's inline script quote-free
The step runs its JavaScript inside `node -e "..."`, so a double quote in a
comment ended the shell string early: bash then ran the tail as commands and node
saw a truncated script ("Unexpected end of input"). The step failed on both
runners while the same comparison passed locally in verify-branch.ps1, which is
exactly the split that hides a broken gate.

The comment is quote-free now, and the extracted script runs clean from the repo
root (`readme range ok`).
2026-10-18 15:03:37 +08:00
czcdr 3f413e1989 shim: settle the re-exec only when it fails, and tolerate BOM-ed JSON (2.0.9)
Review of 2.0.9 found that the fallback still could not fire in the case it was written for, and that a hand-edited mirror.json behaved as if absent.

- The re-exec promise resolved true at the `spawn` event, which fires before the child has read anything. A later "it actually failed" could no longer be seen, so a non-zero exit with the source gone fell through to `resolve(false)`, was ignored, and the process ended 0 - silently, with the message never printed. It now resolves ONLY on failure: while the child lives this process is parked and the child's exit is the exit. Measured before/after with a checkout launcher that deletes itself and exits 3: exit 0 (no output) -> exit 78 with "could not run ... running the mirrored copy instead".
- That also covers the realistic upgrade race, which is not a spawn failure at all: node starts, finds its file replaced, and exits 1 with MODULE_NOT_FOUND. A non-zero exit whose source no longer exists now falls back instead of being passed through.
- JSON reads strip a leading U+FEFF. JSON.parse rejects it, and every caller treats a parse failure as "no record", so a BOM (which Windows editors write) silently disabled the mirror check and made bundle.json look unprovisioned. Measured with PowerShell-written fixtures.

Docs/CI: the README pin example names the version this tree publishes (#v2.0.9); both verify-branch.ps1 and validate.yml now fail when the pin does not equal package.json's version, and the README range check moved into the same step. A mutation (pin reverted to v2.0.8) fails the check and was restored.

Tests: 69 (66 pass + 3 skipped); the new one deletes the checkout launcher mid-flight and asserts the fallback. verify-branch 41 checks green, audit clean, host-compat 10 covered / 5 outside. Version 2.0.9, NOT published: no tag, no release, no npm.
2026-10-18 15:01:52 +08:00
czcdr edf3968319 shim: make the mirror fallback real, repair the mojibake, and close the doc gaps (2.0.9)
Two problems blocked publishing 2.0.8's follow-up, both found by an external review:

- reexecFromCheckout announced a fallback it did not perform. On a spawn error it printed "continuing with the mirrored copy" and returned; main() had already returned, no exit event followed, the loop drained, and the row exited 0 with no tools and no message. It now resolves a boolean: only a started child takes over the exit code, and a failure falls through to the local launch path with an honest message.
- bin/rea-shim.mjs carried 16 mojibake dashes (U+9225 + "?") from a GBK edit in 2.0.7, two of them in user-visible stderr ("first run - provisioning started", "nothing is running right now - read the log"). All restored to an em dash and the space the mis-decode had eaten.

A checkout copy no longer hops into the plugin a bundle's mirror.json names: only a mirror re-execs, a checkout (it ships cordis.patch.yml) never does. That was a real footgun - a developer running the checkout would silently test the installed plugin. The re-exec's comment now matches the code, and the mirror tests run the mirrored copy instead of the checkout.

Docs and CI: the README pin example moves from v2.0.0 (the most bug-prone release) to v2.0.8; the READMEs state the Windows limitation that a forced kill of the shim leaves the delegated REA running; docs/bundle-design.md gains the 2.0.7 launch mode and the 2.0.8 self-healing mirror, including mirror.json's shimSha256/pluginVersion/dshHome; and CI now checks that both READMEs quote engines.dsh verbatim, which previously only verify-branch.ps1 did. systemReaCandidates dedupes on the platform argument rather than process.platform.

Tests: 68 (65 pass + 3 skipped), one new for the checkout guard. verify-branch 39 checks green, audit clean, host-compat 10 covered / 5 outside. Version is 2.0.9 and NOT published: no tag, no release, no npm.
2026-10-18 14:47:44 +08:00
czcdr e3b2a064af docs: keep the declared range in the READMEs, and fix the home fallback (2.0.8)
An independent audit caught three non-blocking inconsistencies in the 2.0.8 tree:

- Both READMEs still quoted engines.dsh as ">=0.1.7-rc.2 <0.3.0" after the declaration moved to "<0.3.0-0" — the stale form the range's own negative test proves is wrong (it admits 0.3.0 prereleases). verify-branch now compares the sentence with package.json, so the drift cannot come back silently; a mutation (reverting one README) fails the check and the file was restored.
- CHANGELOG 2.0.8 still claimed 62 tests (59+3); the tree has 67 (64+3), seven of them new in this release.
- check-updates.ps1 computed $homeEarly only inside the "no REA_BUNDLE_ROOT" branch, so with the override set it stayed $null and Join-Path threw, degrading the profile section to "no profile". It is resolved unconditionally now and the profile lookup uses it.

Verified: 67 tests (64 pass + 3 skipped), audit clean, verify-branch ALL CHECKS PASSED with the new README check, and the home/profile block re-run with REA_BUNDLE_ROOT set resolves DSH_HOME and the real profile directory.
2026-10-18 14:17:07 +08:00
czcdr 3b57a9d1ac shim: find the real DSH home when the harness has scrubbed the environment (2.0.8)
The duplicate-registration check reads <DSH_HOME>/cordis.patch.yml, but an MCP child has every DSH_* variable stripped, so a mirrored copy with a custom home read ~/.dsh instead: the wrong file, and a conflict it could never report. setup.mjs now records the home it ran under in mirror.json (dshHome), and the shim prefers the environment, then that record, then ~/.dsh. The mirrored copy's own bundle root was never affected - it comes from its location - so this only changes which patch file the warning reads.

check-updates.ps1 had a second home algorithm in the same script: its profile section hardcoded $env:USERPROFILE, so on a custom DSH_HOME it reported "no profile found" while the rest of the run had resolved the home correctly. It now reuses the same rule.

Test: a bundle whose mirror.json names a home containing a foreign serverName: rea, run with DSH_HOME="" (the scrubbed shape), must report duplicateRegistration with that file; 67 tests total (64 pass + 3 skipped).
2026-10-18 14:09:16 +08:00
czcdr 5c22588001 shim: refresh a stale mirrored launcher from the installed plugin (2.0.8)
The row launches <bundle>/bin/rea-shim.mjs, a copy made by setup. A market upgrade replaces the package and runs no setup, so that copy kept executing the code it was made from: every fix in a new plugin version reached a user only if they happened to re-run setup, and an old copy keeps working, which is why nobody notices.

From 2.0.8 the copy compares itself with <mirror.json.pluginRoot>/bin/rea-shim.mjs and re-execs that file when the two differ: one hop, guarded by REA_BUNDLE_SHIM_REEXEC, and only mirror -> checkout (the checkout finds itself and does nothing). --shim-status reports mirror.{pluginRoot,mirroredAt,stale,reason}; setup records shimSha256 and pluginVersion; check-updates.ps1 reports a lagging mirror and says to re-run setup.

A version older than this one cannot repair its own upgrades - the copy has no such code - so upgrading from 2.0.0..2.0.7 still needs one setup run. The README says so in both languages.

Tests: 66 (63 pass + 3 skipped). Four new ones cover the re-exec, the guard, the stale report and the identical case.
2026-10-18 13:12:21 +08:00
czcdr 0e3aa428bf shim: stop the launch probe from recursing or leaking its tree (2.0.8)
2.0.7's probe spawned a candidate with the plain environment, so a `rea` on PATH that wraps this shim made the probe recurse: probe -> wrapper -> a fresh shim -> probe again. Measured 83+ concurrent node processes, still respawning after the parent was killed. The guard only covered delegation, not probing; the probe now runs with REA_BUNDLE_DELEGATED=1 and REA_BUNDLE_AUTO_PROVISION=0, so the nested run takes the bundle path and returns (0.2 s, no extra processes).

The timeout path leaked a grandchild: spawnSync's timeout reaps the child it started, so the following taskkill /T could not find the parent, and a hanging candidate left a live REA per attempt. The probe is now an async spawn with its own timer, which kills the tree while the parent is still alive (measured: 8.3 s, rejected ["timeout"], no leftover process).

Also: the Windows signal handlers are removed rather than kept as dead code (child.kill is TerminateProcess there, and a console Ctrl+C reaches the child directly), with the platform limitation stated where it matters; comspec() is validated and falls back to %SystemRoot%\System32\cmd.exe so a broken ComSpec no longer reads as "no REA"; and a directory named like the binary is reported as "not a file" instead of "does not exist".

Tests: 62 (59 pass + 3 skipped). Two of the new ones reproduce the recursion and the ComSpec case.
2026-10-18 13:10:03 +08:00
czcdr 37d2adb17a shim: run the REA a machine already has, and tighten the host declaration (2.0.7)
Launch decision at startup: a REA on PATH that answers --version and is not older than the provisioned build wins, and the bundle is never provisioned (the 1.x contract, reachable from the 2.x row). REA_BUNDLE_LAUNCH=auto|system|bundle overrides it; REA_BUNDLE_SYSTEM_REA names a binary off PATH; --shim-status reports launch.{mode,reason,unusable,systemRea,rejected}. A delegated child carries REA_BUNDLE_DELEGATED=1 so a wrapper cannot make the shim delegate to itself.

Two independent reviews found real defects in the first cut, all fixed here: the cmd line quoted the arguments, so a .cmd's %* received "mcp"; a 3 s probe budget against a measured 2.4 s --version turned detection into a coin flip and leaked a live REA per timeout; SIGINT forwarding on Windows killed only the cmd.exe wrapper. Also: status reported 0 for a pinned-but-unusable system launch, existsSync accepted directories, PATH entries were not normalised, and fail() suggested a setup command without --root.

The engines.dsh ceiling is now <0.3.0-0: with includePrerelease, <0.3.0 admitted 0.3.0-alpha.1 and 0.3.0-rc.1, which nobody audited. host-compat's tests pin the release list literally and prove the leak check has teeth; the evaluator accepts +build metadata and rejects v-prefixes and zero-padded identifiers as semver does.

Environment independence: every shim call in the suite pins REA_BUNDLE_LAUNCH, setup's own shim probe pins it too, and both CI jobs set it at job level. Bootstrap now honours REA_BUNDLE_ROOT like the other three readers.
2026-10-18 12:57:59 +08:00
czcdr a6ee476bfd compat: declare the DSH host range the market reads (2.0.6)
engines.dsh = '>=0.1.7-rc.2 <0.3.0'. The market's deriveHostCompatibility reads only engines.dsh (top-level or dsh.engines) and peers on @deepseek-ai/dsh*; with neither it reports 'unknown', which is what the card has shown until now. Peer declarations are deliberately NOT used: @deepseek-ai/dsh-mcp-client is published on npm at 0.0.1-rc.1 while the host ships 0.2.x, so a peer range would resolve to a version the host does not have.

The range is checked, not guessed: scripts/host-compat.mjs carries the dsh release list (0.1.7-rc.2 .. 0.2.1-rc.2) and fails when the declaration stops covering one of them, or starts admitting 0.3.0. The row surface was compared at dsh-v0.1.7-rc.2 and dsh-v0.2.1-rc.2: packages/mcp/mcp-client exposes the same config keys, and dsh.bundle.patch exists at both.

Upstream rea-agents 6.6.0 is released on GitHub but npm latest is still 6.5.0, so the pin stays at 6.5.0; the bump is scheduled for 2026-10-19.
2026-10-18 12:31:27 +08:00
czcdr d0e2ffd7a6 package: declare the entry file the market's update check requires (2.0.5)
dshmarket's update path asks hasLoadableEntry whether the new build has an entry file that exists (main/exports, else index.js) and rolls back when it does not. This package declared neither, and the package it mounts (@deepseek-ai/dsh-mcp-client) ships inside the harness build rather than in node_modules, so every location the check probes was empty: 2.0.1 -> 2.0.4 failed three times with 'updated build has no loadable entry' on a healthy profile, and the rollback was reported unverifiable for the same reason.

index.mjs is a real but deliberately empty cordis plugin: the working row is still inserted by cordis.patch.yml, so loading it must do nothing (a second serverName: rea would be refused). Two tests, verify-branch and the CI manifest/payload checks now pin both the pointer and its presence in files[].
2026-10-18 11:53:24 +08:00
czcdr 27d3f1106c shim: guard the stale-lock removal and name the duplicate REA registration (2.0.4) 2026-10-17 13:03:15 +08:00
czcdr f6afdea83d shim/setup: fix the wedging stale lock, drop the 1.x examples, and close the second audit
Each item was reproduced before it was changed, and every fix has a test that
fails when it is reverted (18 reverse checks; two of them on WSL because the
code path only exists on POSIX).

- A lock older than the freshness window was never removed, so the exclusive
  create below it failed with EEXIST forever: after a killed download the shim
  never provisioned again — and said nothing, because main() printed no message
  for "in-flight". Reproduced with a 7-hour dead lock (no run started, lock
  untouched, no output) against a 5-minute control lock that behaved correctly.
  An expired lock, or one whose holder is gone, is now cleared; "in-flight",
  "spawn-failed" and "unwritable" each say what they are.
- examples/ still held the 1.x rows (`command: rea`, `cmd.exe /c rea`) and shipped
  inside the 2.x npm package, where copying one bypasses the bundle row entirely.
  Removed from main (v1 keeps them, where they are correct) together with the
  package.json files[] entry and the three validate.yml checks that named them;
  the payload is 19 files now.
- `--offline` was a promise the npm step ignored: provisionReaAgents ran
  `npm install` regardless. It now refuses that step when the vendored copy is
  unusable, and still accepts one that is.
- That npm step is the only one that cannot use this bundle's own route/proxy
  resolution; its failure now says so, and both READMEs name HTTPS_PROXY /
  NPM_CONFIG_REGISTRY.
- POSIX extraction had exactly one tool per format while Ghidra is a zip on every
  platform, so a bare image without unzip failed for certain. zip now falls back
  unzip -> python3 -m zipfile -> the provisioned JDK's jar, tarballs tar ->
  python3, and when none is present the log names the tools it tried.
- The platform-tools arm64 comments were wrong (Google ships x86_64 binaries for
  both linux and darwin): corrected, so an arm64 host gets an accurate
  optional-component report instead of an implied native adb.
- docs/bundle-design.md caught up with the implementation: the row resolves
  <DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs at load time, and the vendored
  CLI lives under vendor/node_modules/rea-agents.
- release.yml's publish job now requires the version's tag and its release asset,
  so a workflow_dispatch run cannot publish to npm while the market's fallback
  tarball link is missing.
- Numeric environment overrides are sanitised instead of becoming NaN ("gave up
  after NaN attempt(s)"), and the effective limits are logged; the PowerShell
  route's docstring now says it is the primary Windows route; five redundant
  dynamic `node:fs` imports are gone.

37 unit tests (Windows 35 pass + 2 platform skips; WSL 36 pass + 1), audit
clean, verify-branch ALL CHECKS PASSED, and the real machine re-provisioned with
the mirror byte-identical to this tree (REA 6.5.0, 207 tools, a live tool call
reporting path_source: environment).
2026-10-17 00:04:04 +08:00
czcdr 975ac5aac8 setup/shim: fix eleven verified defects, and audit the gaps that hid them
Every item below was confirmed in the code (or reproduced) before it was changed,
and each now has a regression test that fails when its fix is reverted.

- `--only` rewrote bundle.json from this run's results alone, erasing every other
  component's record: the shim stopped exporting JAVA_HOME/GHIDRA_INSTALL_DIR and
  the next full run re-extracted them. Untouched records are carried over, and
  only while their recorded path still exists.
- `--only` skipped the requires check entirely, so `--only apktool` resolved
  {jvm} to a vendor/jdk that only a full run creates and failed on a machine whose
  JDK was recorded next to it.
- A complete .part was not treated as finished work: it logged "already complete"
  and then asked for `Range: bytes=<size>-` (416, four attempts, reported failed).
  It is verified and renamed now.
- The Windows PowerShell route deleted the partial file before every attempt, and
  the Node fallback reused a stale `have`, appending a full 200 response onto the
  bytes a failed route had already written. The route resumes from what is on
  disk, and the fallback re-reads the file's real length.
- POSIX wrappers landed 0644 while REA spawns them without a shell (EACCES); a
  POSIX entry marked `executable` is now set 0755.
- apktool's wrapper was pinned to `master` with a hard checksum, so one upstream
  commit was enough to break every new install. Pinned to the immutable v3.0.3
  tag, with the hashes measured from it.
- `--reuse` adopted a JDK because `javac` existed and a Ghidra because
  `analyzeHeadless` existed. A candidate must now pass the component's own probe,
  and a `file` probe finally honours `expect.contains`.
- A malformed bundle.json failed permanently: setup could not get far enough to
  replace the file it could not parse, and nothing started setup. Setup tolerates
  it, and the shim provisions for `malformed` as well as `missing`.
- A failed run kept a fresh lock that made the shim claim "provisioning started in
  the background" for six hours while nothing ran. The lock records its state and
  pid, and a dead holder no longer blocks a retry.
- A successful `--json` run never released the lock, because the removal sat
  inside the non-`--json` branch.
- The shim's fallback REA path omitted `node_modules`, so an older bundle.json
  without paths.reaEntry reported a working install as gone.
- bootstrap.ps1 threw on a non-numeric `node -v` (an int cast under
  ErrorActionPreference=Stop) instead of falling through to the portable download,
  and could exit 0 when `&` failed; it reads REA's engines from the manifest pin
  instead of a second hardcoded copy, and -DryRun no longer creates directories
  while claiming nothing changed.
- check-updates.ps1 looked for REA in the npm global root and for Ghidra in a
  user-level variable, which is the 1.x story: on a 2.x machine the REA section
  pointed at the wrong object, the doctor self-check was skipped entirely, and the
  bundle drift check silently skipped unrecorded pins while printing "all pins
  match". It reads bundle.json first now.

audit-manifest.mjs gained four checks: manifest components vs INSTALL_ORDER,
pins.nodeEngines vs package.json engines, the REA pin probe against the next
patch/minor, and the POSIX wrapper exec flags. validate.yml gained a
windows-latest job, because the PowerShell download route, the zip extraction and
the bootstrap were Windows-only code that no CI ever ran.

33 unit tests (32 pass, 1 platform skip), audit clean, on Windows and WSL.
2026-10-16 23:26:08 +08:00
czcdr 98e3bd0863 2.0.2: document what the 1.x line is for, at the end of both READMEs
1.x was only mentioned in the opening version note and the upgrade section; the question
"when would I actually want it?" had no answer in the README. Both READMEs now end (just
before the license) with a short section: what 1.x is — the adapter without provisioning,
launching `rea mcp` from PATH — when it is the better choice (you already manage REA and
Ghidra, you do not want the plugin fetching anything, or the row must point at your own
`rea`), and how to install it (`[email protected]` or `github:czcdr/dsh-plugin-rea#v1.0.6`).

Docs-only patch release so the npm package page renders it.
2026-10-15 19:05:07 +08:00
29 changed files with 7139 additions and 386 deletions

No files matched your search

+19
View File
@@ -78,5 +78,24 @@ jobs:
npm install -g 'npm@^11'
npm --version
- name: The version being published is tagged and carries its release asset
# workflow_dispatch can start this job with no Release behind it, and then npm
# and the GitHub tarball drift apart: the market's fallback link points at the
# release asset, so publishing a version whose asset does not exist ships a 404
# to half the install paths.
env:
GH_TOKEN: ${{ github.token }}
run: |
ver="v$(node -p "require('./package.json').version")"
if ! git ls-remote --tags --exit-code origin "refs/tags/$ver" >/dev/null 2>&1; then
echo "FAIL: no tag $ver on origin — publish the GitHub Release first"
exit 1
fi
if ! gh release view "$ver" --json assets --jq '.assets[].name' | grep -qx 'dsh-plugin-rea.tgz'; then
echo "FAIL: release $ver has no dsh-plugin-rea.tgz asset — the market link would 404"
exit 1
fi
echo "$ver is tagged and carries the version-free tarball"
- name: Publish to npm via trusted publishing
run: npm publish --provenance --access public
+98 -10
View File
@@ -11,6 +11,11 @@ permissions:
jobs:
validate:
runs-on: ubuntu-latest
# The shim's launch decision reads this machine's REA. No runner has one, so
# the default `auto` would happen to take the bundle path today — pin it so
# that stays true if an image ever ships REA.
env:
REA_BUNDLE_LAUNCH: bundle
steps:
- uses: actions/checkout@v4
@@ -25,13 +30,74 @@ jobs:
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1) };
if (!p.dsh || !p.dsh.bundle || !p.dsh.bundle.patch) fail('package.json is missing dsh.bundle.patch');
if (p.private) fail('package.json must not be private to be publishable');
for (const f of ['cordis.patch.yml', 'README.md', 'LICENSE', 'check-updates.ps1']) {
for (const f of ['cordis.patch.yml', 'index.mjs', 'README.md', 'LICENSE', 'check-updates.ps1']) {
if (!p.files.includes(f)) fail('files[] must ship ' + f);
}
if (!require('fs').existsSync(p.dsh.bundle.patch)) fail('dsh.bundle.patch target does not exist');
// The market's update path rolls a build back when package.json points
// at no existing entry (dshmarket's hasLoadableEntry); this package
// shipped no main at all until 2.0.5, so every update of a healthy
// profile failed with "updated build has no loadable entry".
if (typeof p.main !== 'string') fail('package.json must declare main');
if (!require('fs').existsSync(p.main)) fail('main points at no shipped file: ' + p.main);
// engines.dsh is what the market reads to say "compatible" instead of
// "unknown"; without it the plugin is reported as undeclared.
if (!p.engines || typeof p.engines.dsh !== 'string') fail('package.json must declare engines.dsh');
console.log('manifest ok');
"
- name: Declared DSH range covers every released host
# A wrong range is silent on both sides: unparseable reads as "undeclared"
# and a drifted floor drops hosts the README still lists.
run: node scripts/host-compat.mjs
- name: The READMEs quote the declared DSH range verbatim
# The same comparison verify-branch.ps1 makes locally. Without it here, a
# stale quote survives every CI signal: the JSON, host-compat and the
# audit all agree while the sentence a human reads names another ceiling
# (that is how `<0.3.0` outlived the move to `<0.3.0-0`).
run: |
node -e "
const p = require('./package.json');
const { readFileSync } = require('fs');
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1) };
for (const doc of ['README.md', 'README.zh-CN.md']) {
const text = readFileSync(doc, 'utf8');
if (!text.includes(p.engines.dsh)) fail(doc + ' does not quote engines.dsh (' + p.engines.dsh + ')');
// The pin example is copy-pasteable: it must name the version this
// tree publishes, or the reader gets a ref that does not exist.
if (!text.includes('dsh-plugin-rea#v' + p.version)) fail(doc + ' does not pin dsh-plugin-rea#v' + p.version);
}
console.log('readme range ok');
"
- name: Both JSON readers tolerate a BOM
# verify-branch.ps1 runs the same check locally. bin/rea-shim.mjs and
# scripts/setup.mjs read the same mirror.json/bundle.json, so a strip on
# one side only makes the same file say "there is a checkout" to the shim
# and "there is none" to setup — which shipped once: setup re-mirrored the
# previous manifest. A bare JSON.parse(readFileSync(...)) is that bug.
run: |
node - <<'JS'
const { readFileSync } = require('node:fs');
const blind = [];
for (const file of ['bin/rea-shim.mjs', 'scripts/setup.mjs']) {
const text = readFileSync(file, 'utf8');
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
// readJson() itself is the strip; anything else must not parse a file raw.
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
blind.push(file + ':' + text.slice(0, m.index).split('\n').length);
}
}
if (!text.includes('replace(/^\\uFEFF/')) blind.push(file + ': no U+FEFF strip');
}
if (blind.length) {
console.error('FAIL: BOM-blind JSON read at ' + blind.join(', '));
process.exit(1);
}
console.log('bom-blind reads: none');
JS
- name: JSON files parse
run: |
node -e "
@@ -44,16 +110,15 @@ jobs:
- name: The npm payload ships every market-facing file
run: |
# Regression guard for files[]: the market reads screenshots.json and the
# images next to package.json, and a user copies examples/ by hand — none of
# that is worth anything if npm pack drops it.
# images next to package.json — none of that is worth anything if npm pack
# drops it.
node -e "
const { execSync } = require('child_process');
const meta = JSON.parse(execSync('npm pack --dry-run --json', { encoding: 'utf8' }))[0];
const files = meta.files.map((f) => f.path);
const need = [
'cordis.patch.yml', 'README.md', 'README.zh-CN.md', 'LICENSE', 'CHANGELOG.md', 'check-updates.ps1',
'cordis.patch.yml', 'index.mjs', 'README.md', 'README.zh-CN.md', 'LICENSE', 'CHANGELOG.md', 'check-updates.ps1',
'screenshots.json', 'assets/screenshot-1.png', 'assets/screenshot-2.png',
'examples/cordis.patch.posix.yml', 'examples/cordis.patch.with-env.yml',
];
const missing = need.filter((f) => !files.includes(f));
if (missing.length) {
@@ -77,7 +142,7 @@ jobs:
'tag:yaml.org,2002:js',
lambda loader, node: loader.construct_scalar(node),
)
files = ['cordis.patch.yml', 'docs/marketplace-entry.yml'] + sorted(glob.glob('examples/*.yml')) + sorted(glob.glob('.github/workflows/*.yml'))
files = ['cordis.patch.yml', 'docs/marketplace-entry.yml'] + sorted(glob.glob('.github/workflows/*.yml'))
for f in files:
with open(f, encoding='utf-8') as fh:
yaml.safe_load(fh)
@@ -87,10 +152,9 @@ jobs:
- name: The shipped patches stay machine-independent
run: |
set -e
# The row DSH loads, plus every example a reader is told to copy: none of
# them may carry a real machine path. `<placeholders in angle brackets>`
# are the intended form.
targets=$(printf '%s\n' cordis.patch.yml examples/*.yml)
# The row DSH loads may carry no real machine path; `<placeholders in angle
# brackets>` are the intended form.
targets=$(printf '%s\n' cordis.patch.yml)
if grep -nE '[A-Za-z]:\\' $targets; then
echo "FAIL: a shipped patch contains an absolute Windows path"
exit 1
@@ -163,3 +227,27 @@ jobs:
# Checksum coverage, unresolved tokens, duplicate JSON keys, and the
# patch-wiring agreement between the row, the shim and setup.mjs.
run: npm run bundle:audit
windows:
# The PowerShell download route, the tar/Expand-Archive extraction path and
# scripts/rea-bundle-bootstrap.ps1 are Windows-only code, and an ubuntu-only
# matrix never executed any of them — they were verified by hand, if at all.
# The tests are platform-sensitive on purpose: each platform exercises the
# download route it actually chooses.
runs-on: windows-latest
# Same pin as the ubuntu job: the launch decision must not depend on what the
# runner image happens to have installed.
env:
REA_BUNDLE_LAUNCH: bundle
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Unit tests for the pure logic (Windows)
run: npm run bundle:test
- name: Bundle manifest audit (Windows)
run: npm run bundle:audit
+611
View File
@@ -2,6 +2,617 @@
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
## 2.0.13 — 2026-10-26
**上游 REA 从 6.7.0 升到 6.8.0。这一次上游**同时抬高了 Node 的 engines 下限**,所以不是只改一个版本号 ——
钉子、`package.json` 的 engines、以及 node probe 的三处措辞必须一起动,否则「声明一套、执行另一套」。**
### 变更
- **`pins.reaAgents` → 6.8.0**(= `components.reaAgents.sources.npm.version`),probe 改为 `^6\.8\.0(\s|$)`。
6.8.0 是 npm `latest`(发布 2026-10-25T09:07Z);tarball 2398 条 / 0 条 `node_modules`,仍走
`npm install --ignore-scripts` 解析闭包;闭包只多两个包(`@bufbuild/protobuf 2.16.0`、`@msgpack/msgpack 3.1.3`)。
- **工具数 207 → 229(+22)**,另 175 条 CLI 命令、6 个 prompt。随包 `dist/mcp-catalog.json` 记录
`identity.counts` 与三个 digest(`tools_sha256 a98940ba…`),不再是「只能靠 doctor 现场数」。新增族:
APK 签名、binary diagnostics(PE 证书/导入/加载配置、ELF notes、内核模块、devicetree、Apple IPS、字节熵)、
模型文件(GGUF/safetensors)、CUDA/HIP 代码对象。**这是工具扩容,不是修 bug 版本。**
- **engines 下限抬升**:`^22.19.0 || ^24.11.0 || >=26.0.0` → `^22.23.4 || ^24.22.1 || >=26.12.1`。同步改了
`package.json engines.node`、`manifest.pins.nodeEngines`、以及 node probe 正则 —— probe 没有比较器,只能写成
一条交替(22.23.4–22.23.x / 22.24+ / 24.22.1–24.22.x / 24.23+ / 26.12.1–26.12.x / 26.13+ / 27+),
并用 24 组版本正反验证(22.23.3、24.22.0、26.12.0 必须拒;20.x 与 25.x 仍拒)。
**后果**:6.7.0 时代合法的 Node 22.19–22.23.3、24.11–24.22.0 现在会被 reuse 路径拒掉 —— 钉住的 **24.22.1
正好是 24 线的下限**,既是最新也是最低可接受。
- **其余钉子一个没动**:Ghidra 仍 12.1.4(6.8.0 的 `SUPPORTED_GHIDRA_VERSION` 未变,12.2 仍 `unsupported`)、
jadx 仍 0.7.1(6.8.0 的 `JADX_RELEASE.sha256` 与盘上 0.7.1 jar 逐字节相同)、JDK 21.0.12.1(Adoptium 21 线的
Windows 资产仍停在 21.0.12.1)、apktool 3.0.3、platform-tools 37.0.1、Node 24.22.1。
### 新增
- **`scripts/ghidra-gate-override.mjs`**:把「要不要跑 REA 未验证的 Ghidra」变成显式、有记录、可还原的选择。
2026-10-26 实测 Ghidra **12.2**(那四个 CVE 的修复版)与 6.8.0 的桥接可用 —— 段表 9 段、函数表 341 个
(74,483 字符)、`entry` 伪代码与 12.1.4 **逐字节相同**,`provider.version = 12.2` —— 挡住它的只是
`SUPPORTED_GHIDRA_VERSION = "12.1.4"` 这条等值判定。脚本只改 vendored 副本里声明该常量的三个文件
(`dist/ghidra/GhidraReleasePolicy.js`、`dist/cli-runtime/KunaCommand-*.js`、`dist/mcp/binary-*.js`),
把原值/新值/时间写进 `<bundle>/ghidra-gate-override.json`,`--disable` 精确还原;重新供给 rea-agents
会把它抹掉(默认回到钉住的已验证组合)。12.2 要求 **JDK 25+**(`application.java.min=25`)。
- **`check-updates.ps1` 认得这份记录**:盘上跑 12.2 时打印「本机覆盖 Ghidra 闸门」,而不是劝用户「重跑 setup 对齐」
—— 后者正好会把覆盖悄悄抹掉。
### 修复
- **「还要跑一次 setup」卡片的说明补上覆盖这一条**:这张卡片出现在每次升级之后,而它就是叫你去跑 setup 的。
setup 会重新供给内置 REA,**Ghidra 闸门覆盖随之消失** —— 卡片现在明写这一点并给出恢复命令。踩到的实例:
本机重启后,profile 里还是 2.0.12 的旧副本,它的 shim 按自己的 pin 把 bundle 重新供给回 6.7.0,
覆盖与 6.8.0 一起被抹掉,MCP 行随之 `REA could not start`。
- **`ghidra-gate-override.mjs` 处理「记录还在、补丁已被 re-provision 抹掉」的半状态**:`--enable` 现在按盘上
实际内容判断记录是否仍成立,陈旧记录直接替换而不是报错;`--disable` 对已失去补丁的文件只计数不抛错。
### 验证
- 单测 **88 条**(85 过 / 3 平台跳过 / 0 失败);`audit-manifest` **24/24 hash-pinned clean**;
`verify-branch.ps1` **ALL CHECKS PASSED**;`host-compat` 10 covered / 5 outside。
- 真机(Windows 11 x64):`setup.mjs --reuse` → `probe cli=6.8.0 tools=229`,末尾 Ghidra 冒烟 26 s 导入
`java.exe`,shim 解析 6.8.0;另起一次独立 stdio MCP 握手 → `serverInfo {name: rea, version: 6.8.0}`、
**229 tools / 6 prompts**、protocol `2024-11-05`。
- 把 6.8.0 自己的发布策略加载执行:`12.1.4 → verified`、`12.1.5 → compatible`、`12.2 → unsupported`;
`JADX_RELEASE` 仍是 0.7.1 / `6e5eacf5…`。apktool 3.0.3、`adb` 37.0.1 (1.0.41) 现场复核。
- 文档:两份 README 的版本表/工具数/engines 句子、`docs/bundle-design.md` 的实测行、
新报告 [docs/update-review-2026-10-26.md](docs/update-review-2026-10-26.md)。
### 升级 / 排障
- 已供给的机器**不会自动换到 6.8.0**(auto-provision 只在「未供给」时触发):重跑
`node scripts/setup.mjs --reuse`,再重启 dsh 让 MCP 行重连。
- **`npm install` 报 `ECONNRESET` 时先查代理,不要怀疑哈希。** setup 的 npm 子进程不继承本脚本自己的代理路由;
直连 registry 的 tarball 会失败并以 `-4077` 收场,reify 回滚后 probe 仍报旧版本(读起来像「升级没生效」)。
设 `HTTPS_PROXY` / `HTTP_PROXY` 后重跑即可 —— 本轮就是这么过的。
## 2.0.12 — 2026-10-23
**2.0.11 的引导卡片给了升级用户一条会让它重下约 570 MB 的命令。这一版只修那一处。**
### 修复
- **引导卡片的命令缺 `--reuse`**:这张卡片主要出现在**升级之后**,而那时运行时已经在机器上;不带这个参数,`setup.mjs` 会重新下载 JDK + Ghidra(约 570 MB)—— 既是「对齐运行时」的反面,也让一次本该几十秒的升级变成十几分钟。首次安装时该参数不产生任何代价:每个候选都会被探测,机器上没有就照常下载。
- **卡片没有说明 setup 末尾的引擎验证**:`setup.mjs` 现在会用 JDK 自带的 `java` 跑一次真实分析(实测 32–42 秒)。没有这句,用户会以为进程卡死。
- **卡片没有说明并发锁**:shim 的后台供给与手动 setup 相撞时,后者以 **75** 退出并报出持有者的 pid 与日志路径。卡片现在把这句话写出来,免得用户把它当成故障。
### 验证
- 单测 **88 条**(85 过 / 3 平台跳过),`verify-branch.ps1` ALL CHECKS PASSED,`audit-manifest` clean(24/24 hash-pinned)。
- 引导命令以新进程求值确认:`node "…/scripts/setup.mjs" --reuse`。
- 卡片的两条新说明在中英文里各存在一份,并各自渲染为列表项。
### 升级
市场更新到 2.0.12 后,卡片给出的命令就带上 `--reuse` 了;本机若已装 2.0.11,重启一次 dsh 才会看到新卡片(host 侧的模块在进程内缓存)。
## 2.0.11 — 2026-10-23
> **发布说明(致使用者)**
>
> 这一版比预期晚了一点,两件事叠在一起:一方面我还在上学,课业与考试挤在同一周;另一方面,这一版真正要处理的是
> **昨晚才公开的漏洞**——上游在 10/21–10/22 连续放出 Node 24.22.1:Brotli 解码器越界写(可远程触发 RCE)、
> 两处 TLS 主机名绕过、以及 eMudhra 中间 CA 的移除。漏洞窗口本身就是昨晚才打开的,晚的这部分是「等补丁、
> 等验证、跑通再发」,不是拖。
>
> **本次更新的主因(按重要性)**
>
> 1. **把自供给的 Node 升到 24.22.1。** 干净环境过去会下载 24.21.0 —— 那正是带漏洞的版本;本机因为 `--reuse`
> 拿了系统里的新 Node 所以看不出来,但别人第一次装就会踩上。现在六个平台的 manifest 源全部换成 24.22.1
> (校验和取自官方 `SHASUMS256.txt`;win32-x64 在本机下载、比对、解包并跑通 `node -v` / `npm -v` 后才写入)。
> 2. **供给侧不再「看起来成功」。** 复用的 JDK / Ghidra / Node 现在记录它们**自己的**版本(而不是跑 setup 的
> 解释器或 manifest 的钉子);供给期的 `doctor` 检查的是**本次供给的**引擎,而不是机器上恰好存在的那个;
> 供给末尾会真跑一次 Ghidra 导入——`doctor` 只验证路径与版本,从不导入二进制,桥接坏了过去只能在第一次
> MCP 调用超时时才发现。
> 3. **审查轮 10 处修复。** manifest 审计里一项永不触发的检查、零参数启动静默退出 0、解压失败销毁旧安装、
> `setup.lock` 不构成互斥等 —— 逐条见下方「修复 · 审查轮」。
**shim 层五个缺陷修复。不动供给流程、工具注册与 REA 钉子;改的是「找系统 REA、后台供给失败、读记录」这三条边缘路径。**
### 新增
- **首次安装与每次升级插件后,Web GUI 显示一次「还要跑一次 setup」提示**(`client.js` + `index.mjs`):
- 触发不看「弹过没有」,而看**事实**:`GET /dsh-plugin-rea/guide-state` 把
`<DSH_HOME>/plugins/rea-bundle/bundle.json` 里记录的 REA 版本与本包 manifest 的钉子比对,
返回 `missing` / `stale` / `unknown` / `unreadable` / `ok` 之一(`bin/rea-guide.mjs` 里的纯函数,
带点的钉子精确比对、只有主版本号的按家族比对,与 `scripts/pin-match.ps1` 同一套规则);
- 文案给出**这次安装**的完整命令(路径随市场解包位置变化),并写明「只对齐运行时、不更新插件包」;
- 点「稍后」或「我已运行」后按**插件版本**记入 localStorage:同一版本不再弹,升级换了版本后重新提示一次;
- 该 route 只在宿主有 `webServer` 时挂载(`ctx.inject(['webServer'])`),headless/ACP 主机行为不变;
- 它只是提示:不 spawn、不下载、不改 `bundle.json`,也不跑 setup。
- **`setup.mjs` 末尾新增 Ghidra 冒烟(`smoke` 步,`--skip-smoke` 可关)**:`rea doctor` 只校验路径、版本与
会用哪个 Java,**从不导入一个二进制**。首次真实导入要付一次性成本(本机实测:小 PE 约 36 s,6 MB DLL 约 64 s),
一旦桥接或 native addon 有问题,操作者只在**第一次 MCP 工具调用超时**时才撞见 —— 那读起来就是「升级把 REA 搞坏了」。
现在供给结束会用 JDK 自带的 `bin/java` 跑一次 `rea analyze --json`,把「能不能分析」变成有测量结果的结论;
失败即 `FAIL` 并计入退出码。`--skip-smoke` 跳过(CI 或低内存机器)。
### 修复
- **`systemReaCandidates` 的平台分隔符取错了对象**:`path.delimiter` 与 `path.join` 跟随的是「当前进程」的平台,
而函数入参是 `platform`。注释一直声称测试能在同一台机器上模拟两种形态 —— 实际做不到:POSIX 上模拟 `win32` 会把
`;` 分隔的 PATH 按 `:` 切开,候选全部指向不存在的碎片。现在分隔符与 `resolve` / `isAbsolute` / `join` 一律跟随
`platform`(生产环境两者相同,因此这只是测试缝的问题,但缝必须是真缝)。
- **后台供给的 spawn 失败会留下幽灵锁,异步 `error` 还会打死 shim**:`spawn` 同步抛错时锁停在
`state:"running"` + 本进程 pid;更常见的是异步 `error`(ENOENT/EACCES/ENOMEM)—— `ChildProcess` 没挂监听器,
EventEmitter 直接抛未捕获异常,shim 无声退出,锁仍指向一个「活着的」pid。PID 被系统复用后,下一次启动会被判成
`in-flight`,自动供给停摆 6 小时且没有任何日志。现在:同步失败删锁;异步失败把锁改写成 `state:"failed"` 并往
`logs/setup.log` 记一行原因。
- **`probeRea` 对 `--version` 的输出没有上限**:PATH 上一个损坏或恶意的候选可以一直输出到超时,等于把内存交给
陌生人。现在 64 KiB 封顶,超限立刻 `killTree` 并按「不可用」处理(回落到 bundle,行为与其它拒绝一致)。
- **`readJson` 只认 UTF-8 BOM,不认 UTF-16**:Windows PowerShell 5.1 的 `>` / `Out-File` 默认写 UTF-16 LE,读出来
是错位乱码 → 被当成「没有记录」,于是带记录的本机会被重新供给。`bin/rea-shim.mjs` 与 `scripts/setup.mjs` 现在
都按字节判 BOM,支持 UTF-16 LE/BE 与 UTF-8(含 BOM)。**两个读取器必须同时改** —— 只改一边,同一个文件在两个
程序眼里又是两个意思(2.0.9 的教训)。
- **`foreignReaRegistration` 不剥行尾注释**:`serverName: rea # upstream` 会被解析成 `rea # upstream` ≠ `rea`,
重复注册漏报 —— 而这正是人手编辑那个文件时最常见的形态。现在按 YAML 规则取值:引号内原样,引号外 ` #` 之后是
注释(`rea#1` 仍然是值)。
- **`npm run check-updates` 在非 Windows 上是坏的**:这个 script 直接写死了
`powershell -NoProfile -File ./check-updates.ps1`,而脚本本身读 `%APPDATA%`、优先 `rea.cmd`
(并刻意避开 `rea.ps1` 垫片),所以在 macOS/Linux 上即使装了 `pwsh` 也只会从一个不存在的布局里
给出一个自信的错答案。现在改为 `node scripts/check-updates.mjs`:Windows 上转发给 `powershell`
(缺失时退 `pwsh`),其它平台说明原因、列出 `node bin/rea-shim.mjs --shim-status` 与
`node scripts/setup.mjs --dry-run`,并以**非零码**退出 —— 不把「不适用」伪装成「全绿」。
- **供给期的 `rea doctor` 检查的不是本次供给的引擎**:那一步没传 `GHIDRA_INSTALL_DIR` / `JAVA_HOME`,
REA 于是回落到探测本机,于是报告里出现的是系统那份 Ghidra 与 JDK(本机实测:报的是 `D:\ghidra\...` + 系统
Java 25,而 vendor 里那份从未被检查)。现在 doctor 与冒烟共用与镜像 shim 完全相同的一组变量。
- **复用 Node 时不记录候选的真实版本**:`reusableNodeChecked` 只回 `{ok}`,于是 `bundle.json` 的
`versions.node` 落到 `process.versions.node` —— 那是**跑 setup 的解释器**,不是被复用的那个。两者不同时
(`REA_BUNDLE_NODE`,或 PATH 首个 `node` 是另一个安装)记录与实际就静默不一致。现在探针把 `-v` 的第一行
带回,复用路径照实记录。
- **manifest 把 Node 钉在 24.21.0(含已知漏洞)**:24.22.1 是 2026-10-21 的安全发布(Brotli 解码器越界写 RCE、
两处 TLS 主机名绕过、移除 eMudhra 中间 CA)。复用路径在装有新 Node 的机器上没问题,但**干净环境走下载路径
会装回带漏洞的 24.21.0**。六个平台的 `version` / `url` / `size` / `sha256` 全部升到 24.22.1
(校验和取自官方 `SHASUMS256.txt`;win32-x64 在本机下载、比对、解压并跑通 `node -v` / `npm -v` 后写入)。
### 修复 · 审查轮
- **`audit-manifest` 第 13 项是死代码**:`Number.isFinite(next.split(".")[2])` 测的是**字符串**,而
`Number.isFinite` 不做隐式转换 —— 该分支恒为假,「probe 是否误接受下一个 patch/minor」这项检查从未生效。
改为 `Number.isFinite(Number(...))`。实测把 probe 换成 `^6\.` 后,审计报出
「accepts 6.7.1 (the next patch)」「accepts 6.8.0 (the next minor)」,修复前一条都不报。
- **零参数启动静默退出 0**:`main` 对空 argv 打印 usage 并以 0 退出,而 `launchSystemRea` 与 bundle 分支仍留着
`argv.length > 0 ? argv : ["mcp"]` 的兜底(永远不可达)。行一旦丢掉 `mcp`(profile 合并、手改 patch),
子进程以 0 退出、`failOnStartupError: false` 再把它压住,症状只有「没有任何 `mcp__rea__*` 工具」。
现在按**是否有终端**区分:有人看 → usage + 0;无人看(被 spawn)→ usage + stderr 说明 + **64**。
两处死兜底一并删除。
- **解压前先删旧安装**:`provisionComponent` 在 `extract()` 之前无条件 `rmSync(target)`,与文件头
「Non-destructive」相悖 —— 提取器缺失(极简 Linux 无 unzip/python3/tar)或磁盘满时,一份可用的安装已经没了,
换不来任何东西。旧目录现在只在 rename 之前清除(`extract` 失败不再触碰它)。
- **下载:有钉住校验和但清单未声明 `size` 时,完整 `.part` 无法救援**:救援分支的条件要求 `expected.size`,
于是 resume 发 `Range: bytes=<size>-`、服务器回 416,每次重跑都同样失败,只能手工清缓存。现在这种组合用
钉住的 sha256 判定:匹配即完成 rename,不匹配则清掉重下。(当前清单 24 条全有 size + sha,这是为将来留的路径。)
- **`setup.lock` 不是互斥锁**:它只在运行**结束**时写或删,启动时从不获取 —— shim 的 detach 供给与手工
`node scripts/setup.mjs` 能同时写同一个 `.part` 与同一个 `<component>.staging`。现在启动即以 `O_EXCL`(`wx`)
抢锁;遇到**活**持有者则报出 pid 与日志路径、以 **75** 退出,死 pid 或 `failed` 锁被接管(崩溃后不需要手工 `rm`)。
- **`check-updates.ps1` 把「文件不完整」误报成「版本不符」**:`application.properties` 缺
`application.version=` 行时 `$v` 为空,于是报「当前要求 Ghidra 12.1.x,当前为」——读者会去找新版本,
而真正该做的是重装该组件。现在分成两句。
- **shim 的 PATH 去重不比对大小写**(Windows):`C:\Program Files\nodejs` 与 `c:\program files\nodejs`
被当成两个目录,vendored Node 会被重复前置。现在 Windows 上先 `toLowerCase()` 再比。
- **`cordis.patch.yml` 的主目录兜底缺 `HOMEDRIVE`+`HOMEPATH`**:服务账户没有 `USERPROFILE`,表达式于是产出
`/.dsh/...`(当前盘根),而 `setup.mjs` / shim 用的是 `os.homedir()`。现在三处同序:
`USERPROFILE` → `HOMEDRIVE`+`HOMEPATH` → `HOME`;两份 README 同步。
- **`rea-bundle-bootstrap.ps1` 的 engines 判断只看 major/minor**:`^24.11.1` 会接受 24.11.0。改为三段比较。
- **文件头关于 TOFU 的承诺与实现不符**:注释写「首次记录、之后强制」,但 `sha256: "unverified"` 的条目在缓存
命中时无条件放行(没有可比的钉住值)。注释改为实情:首次测得的摘要列进 `unverifiedChecksums`,之后缓存命中
不再复核,因为**没有**钉住值可比 —— 记录点名这个缺口,而不是假装那些字节经过验证。
### 升级
> **两条长期规则 —— 本版如此,以后每个 2.x 版本都如此。**
>
> `setup.mjs` **不会替你跑**:市场里的安装/更新只替换包,然后到此为止;装完一次、之后每次升级插件版本一次,
> 都要你自己从插件目录跑 `node scripts/setup.mjs`。跳过它不会报错 —— 首次安装是「工具消失且无提示」,升级后是
> 「继续跑旧 REA 且无提示」(`check-updates.ps1` 会报出漂移)。
>
> 1. 行执行的是 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,那是 setup 复制的**副本**:市场升级只换包、
> 不换它,副本可能一直跑旧代码而**没有症状**。从 **2.0.8** 起副本会与 `mirror.json` 记录的启动器比对,不同就
> 一跳转发执行插件里的那份(`REA_BUNDLE_SHIM_REEXEC` 守卫);**2.0.0–2.0.7 做不到**,从它们升级仍需跑一次
> `node scripts/setup.mjs`。`--shim-status` 报 `mirror.stale`,`check-updates.ps1` 提示重跑。
> 2. 镜像的 `scripts/setup.mjs` 也是副本、**不会自愈**:从 bundle 根目录跑的是建 bundle 那一版;要从**插件目录**
> 跑一次刷新它。从 **2.0.9** 起它会像 shim 一样剥掉 `mirror.json` / `bundle.json` 的 UTF-8 BOM。
### 验证
- 单测 **88 条**(本轮新增 3 条:「解压失败不销毁旧安装」「审计第 13 项不是死代码」「活锁挡住第二个 setup」;
上一轮加的是冒烟三条与「复用组件带上自己的版本」;此前 81 条为基础)。
- 真实执行证据(不是读代码):把 probe 改成 `^6\.` 后审计报出 `accepts 6.7.1` / `accepts 6.8.0`;
零参数在无终端下退出 **64**、`--shim-help` 仍退出 0 —— 由单测覆盖。
- `manifest` 审计:**24 条 fetch 全部 hash-pinned,0 TOFU**(Node 六平台换到 24.22.1 后重跑)。
- `verify-branch.ps1` 新增 §9:指南链路四端(`bin/rea-guide.mjs` 的 route、`index.mjs` 的挂载、`client.js` 的
fetch、`cordis.patch.yml` 的行)逐一比对,防止改名后指南静默不出现;§10:`npm run check-updates` 必须走
包装器,且包装器必须保留「Windows 才跑」与两条替代命令。
- 已发版:tag **v2.0.11** / GitHub Release 附 `dsh-plugin-rea.tgz` / npm **2.0.11**(Trusted Publishing,带 provenance)。
## 2.0.10 — 2026-10-19
**上游 REA 钉子从 6.5.0 升到 6.7.0。包、CLI 与 MCP 契约都没变,工具数仍是 207 —— 这是上游的功能与修复升级,不是工具扩容。**
### 修复
- **`check-updates.ps1` 的版本对账是假绿**(本版最重要的「用户能看见」的修复):此前对所有组件都用
「主版本前缀」比对,`"6.5.0".StartsWith("6.")` 为真 —— 插件已钉 6.7.0、bundle 里还是 6.5.0 时,这一行报
「与 pin 一致」。**这正好打掉 2.0.10 自己的升级场景**:用户跑完检查也看不到「该重跑 setup」,机器上再没有
第二个信号(shim 只比「系统 REA vs 已供给」,不比 pin)。apktool 同理(盘上 3.0.9 对 pin 3.0.3 也假绿)。
现在:带点的 pin 一律**精确比对**;只有 pin 本身只有主版本号(`jdk: 21`)才按 `21.` 前缀匹配。
`--reuse` 采纳的本机 Node 不再拿「下载版本」精确比对(那会误报),改为对 **engines** 对账:满足则单列一行、
不计入「全绿」,不满足则报动作。规则抽到 `scripts/pin-match.ps1`,`verify-branch.ps1` 直接 dot-source 同 7 组
用例做回归(含 6.5.0/6.7.0、3.0.9/3.0.3、`21.0.12.1+1`/`21`)。
- `assets/make-screenshots.ps1` 里硬编码的 `6.5.0`:版本号改为从 `manifest` 的 pins 读取(工具数 207 仍是人工
核对常量,注释里写明升 pin 时要一起核)。`screenshot-1.png` 用同一脚本重绘,只有这两行文字变化;
`screenshot-2.png` 重绘后逐字节不变,未改动。
### 变更
- `pins.reaAgents` 与 `components.reaAgents.sources.npm.version` → **6.7.0**(npm `latest`;6.6.0 只有 GitHub tag,
npm 从未发布)。探针正则改为 `^6\.7\.0(\s|$)`,audit 的「不能把下一版当成这一版放行」检查继续覆盖。
- 契约复核(实测,不是推断):`engines.node` 仍 `^22.19.0 || ^24.11.0 || >=26.0.0`;`bin` 仍 `scripts/rea.mjs`;
`optionalDependencies.frida` 仍 17.23.2;tarball 仍不带 `node_modules`(6.7.0:1775 条、0 条 `node_modules`),
所以供给路径仍是 `npm install --ignore-scripts`,不改回解包。
- 真机复核:`rea --version` = 6.7.0;MCP `initialize` 返回 `serverInfo {name: rea, version: 6.7.0}`
(protocol `2024-11-05`);`tools/list` = **207**,与 `rea doctor --json` 的 `catalog.counts.mcp_tools` 一致。
- 上游 6.6.0 + 6.7.0 的变化(与插件代码无关,记录以便对照):Kuna 作为调用方自备的静态分析 provider、
JavaScript facts 视图、APK 多 locale 表、Windows ARM PE(ARMNT/Thumb-2、ARM64)与显式 raw m68k、JADX 操作超时,
以及 6.7.0 的客户端配置「重复成员」语义修复。**`rea doctor` 因此多一项 `kuna-installation`**,与 Hopper / IDA
同属「本机自备」类检查,供给不碰,`healthy=false` 依旧不算失败。
- 文档:两份 README 的 REA 版本与工具数、`docs/bundle-design.md` 的实测环境同步到 6.7.0;README 的钉版本示例
跟到 `#v2.0.10`。
### 升级
> **两条长期规则 —— 本版如此,以后每个 2.x 版本都如此。**
>
> `setup.mjs` **不会替你跑**:市场里的安装/更新只替换包,然后到此为止;装完一次、之后每次升级插件版本一次,
> 都要你自己从插件目录跑 `node scripts/setup.mjs`。跳过它不会报错 —— 首次安装是「工具消失且无提示」,升级后是
> 「继续跑旧 REA 且无提示」(`check-updates.ps1` 会报出漂移)。
>
> 1. 行执行的是 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,那是 setup 复制的**副本**:市场升级只换包、
> 不换它,副本可能一直跑旧代码而**没有症状**。从 **2.0.8** 起副本会与 `mirror.json` 记录的启动器比对,不同就
> 一跳转发执行插件里的那份(`REA_BUNDLE_SHIM_REEXEC` 守卫);**2.0.0–2.0.7 做不到**,从它们升级仍需跑一次
> `node scripts/setup.mjs`。`--shim-status` 报 `mirror.stale`,`check-updates.ps1` 提示重跑。
> 2. 镜像的 `scripts/setup.mjs` 也是副本、**不会自愈**:从 bundle 根目录跑的是建 bundle 那一版;要从**插件目录**
> 跑一次刷新它。从 **2.0.9** 起它会像 shim 一样剥掉 `mirror.json` / `bundle.json` 的 UTF-8 BOM。
- `setup.mjs` **只对齐 bundle,不更新插件包**:它读的是「当前已安装插件」的 manifest,所以装着 2.0.9 时跑 setup
仍旧供给 6.5.0,哪怕 2.0.10 已发布。插件包只能从市场(或 `npm i -g dsh-plugin-rea@<版本>` / `file:` 重装)换。
- 已供给的机器**不会自动换到 6.7.0**:auto-provision 只在「未供给」时触发。重跑一次 `node scripts/setup.mjs`
(或只补 REA:`node scripts/setup.mjs --only reaAgents`),探针会拒收盘上的 6.5.0 并重装。
修好上面那条对账后,`npm run check-updates` 会直接报
`reaAgents 盘上为 6.5.0,manifest 已钉 6.7.0 —— 重跑 setup 对齐`。
### 验证
- 单测 **71 条**(68 过 + 3 平台跳过);`verify-branch.ps1` **53 项**全绿(新增 §5b 的 2 项结构检查 + 7 组
pin 用例);`audit-manifest` clean;`host-compat` 10 covered / 5 outside。
- 对账修复的实证(不是读代码推断):临时 bundle 记 6.5.0、manifest 钉 6.7.0 时实跑 `check-updates.ps1`,输出
`reaAgents 盘上为 6.5.0,manifest 已钉 6.7.0 —— 重跑 setup 对齐`,且**不再**打印「所有组件与 pin 一致」;
`--reuse` 采纳的 Node 24.11.1 单列为「满足 engines」(不并入全绿),20.0.0 则报动作。
- `npm pack` **22 文件**(新增 `scripts/pin-match.ps1`;`screenshot-1.png` 重绘后 68,067 B);发行资产名仍是
版本无关的 `dsh-plugin-rea.tgz`。
- 发布:tag `v2.0.10` → GitHub Release(资产 `dsh-plugin-rea.tgz`)→ npm `2.0.10`;插件市场按 npm 包名与
`releases/latest/download` 两条路径解析。
## 2.0.9 — 2026-10-18
**2.0.8 的两处发布前问题(假回退、乱码)与若干文档缺口;不改正常路径,只让失败路径说真话。**
### 修复
- **`reexecFromCheckout` 的「回退」是假话**:spawn 失败时只打印「继续用镜像副本」,函数却返回了,`main()` 已经
return —— 没有 `exit` 事件、事件循环排空、进程以 **exit 0 静默退出**,行看起来启动成功却没有任何工具。现在这个
Promise **只在没接管时才 resolve**:子进程活着时本进程等待、由子进程的退出码决定;失败则 `main()` 继续用本地副本
启动,并如实说明在回退。
- **回退覆盖了升级竞态的主形态**:`spawn` 的目标是 `process.execPath`,spawn 级失败几乎只剩资源耗尽;真正常见的是
「哈希比对通过 → node 启动 → 文件已被换掉」→ node 以 `MODULE_NOT_FOUND` 退 1。现在**退出码非 0 且源文件已不存在**
时同样回退到本地副本(窗口毫秒级,代价是一次 `stat`),不再把竞态变成一行死掉的 MCP。
- **乱码 `鈥?` 混进用户可见输出**:2.0.7 在 GBK 编辑器里碰过 `bin/rea-shim.mjs`,16 处破折号被写成 `鈥?`,其中两条是
stderr 消息(首次自动供给、上次供给失败)—— 每个用户都会看到。全部还原为 `— `(含被吃掉的空格)。
- **开发时可能测到「已安装插件」而不是自己的改动**:checkout 副本会顺着 bundle 的 `mirror.json` 转发到插件目录。
现在 checkout(带 `cordis.patch.yml`)永不转发,只有镜像副本会;注释也改成与行为一致。
- **带 BOM 的 `mirror.json` / `bundle.json` 被当成「没有记录」**:`JSON.parse` 拒绝前导 BOM,而每个调用方都把解析失败
读成「无记录」。Windows 编辑器保存的 JSON 就会带 BOM,表现是镜像自愈静默不生效、bundle 被当成未供给。`bin/rea-shim.mjs`
与 `scripts/setup.mjs` 现在都经各自的 `readJson()` 去掉 `U+FEFF` 再解析。**两个文件读的是同一份记录,所以缺一不可**:
此前只有 shim 剥了 BOM,带 BOM 的 `mirror.json` 在 setup 眼里仍是「没有 checkout」,镜像 layout 下的 setup 于是把上一版
manifest 又镜了一遍并从它供给;`bundle.json` 同理被读成未供给。`verify-branch.ps1` 与 CI 都加了守卫:这两个文件里
出现裸的 `JSON.parse(readFileSync(...))` 即失败。
### 文档 / CI
- 两份 README 的钉版本示例改为**当前版本** `#v2.0.9`(2.0.8 那一轮已从 `#v2.0.0` 提到 `#v2.0.8`,版本号一动就得跟上,
否则读者拿到的 ref 不存在);README 的「机器上已有 REA」一节补上 Windows 限制:**强制结束 shim 不会带走被委派的 REA**。
- `docs/bundle-design.md`(随包发布)补上 2.0.7 的 launch mode 与 2.0.8 的镜像自愈,含 `mirror.json` 的
`shimSha256` / `pluginVersion` / `dshHome` 三个字段与控制流图。
- CI 新增两条:README 必须逐字引用 `engines.dsh`;README 的钉版本示例必须等于 `package.json` 的 version(`verify-branch.ps1`
同步加了第二条,避免同一个漂移第三次发生)。
- `systemReaCandidates` 的去重键改用传入的 `platform` 参数(测试缝一致,生产无变化)。
### 验证
- 单测 **71 条**(68 过 + 3 平台跳过;新增「checkout 副本不转发」「文件中途消失 → 回退」「带 BOM 的 `mirror.json`」
「带 BOM 的 `bundle.json`」四条);镜像转发测试改为真的运行镜像副本。
- `verify-branch.ps1` 44 项全绿;`audit-manifest` clean;`host-compat` 10 covered / 5 outside。
## 2.0.8 — 2026-10-18
**2.0.7 的启动检测在两种边界上会失控:一个包着本 shim 的 `rea` 会让探针递归成进程链;超时探针会漏掉一个孙进程。两条都已修,并补了复现测试。**
### 修复
- **重复注册检测在自定义 `DSH_HOME` 下失效**:harness 会从 MCP 子进程里抹掉所有 `DSH_*`,镜像副本因此读
`~/.dsh` —— 那是错的文件,上游写进真实 home 的第二条 `serverName: rea` 永远报不出来。`setup.mjs` 现在把运行时
的 home 记进 `mirror.json` 的 `dshHome`,shim 优先读环境、其次读这条记录。`check-updates.ps1` 的 profile 段也
改用同一条 home 规则(此前硬编码 `$env:USERPROFILE`,自定义 home 时报「找不到 profile」)。
- **镜像启动器不会自己更新**:行执行的是 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 这份**副本**,而市场升级
只换包、不跑 setup —— 副本会一直跑它被复制时的代码,于是新版本里的修复对老用户静默失效(旧副本照常工作,最难发现)。
现在镜像启动时会与 `mirror.json` 记录的插件目录比对哈希,不同就转发执行插件里的那一份(一跳,带
`REA_BUNDLE_SHIM_REEXEC` 守卫,且只从镜像指向插件,不会反向)。`--shim-status` 新增 `mirror.stale`,
`check-updates.ps1` 也会报「镜像落后,重跑 setup」;`mirror.json` 增加 `shimSha256` 与 `pluginVersion` 便于追溯。
**2.0.8 之前的版本自身无法完成这一步**(副本里没有这段代码),从那些版本升级仍需手动跑一次 setup。
- **探针递归**:`probeRea` 派生候选时没带 `REA_BUNDLE_DELEGATED=1`,而防自委派的守卫只在**委派**前生效。若 PATH 上是「调用本 shim 的 wrapper」,
探针 → wrapper → 新 shim → 再探针……实测并发 node 峰值 83+、杀掉后仍自我再生。现在探针环境同时带 `REA_BUNDLE_DELEGATED=1` 与
`REA_BUNDLE_AUTO_PROVISION=0`:嵌套运行直接走 bundle 并返回,实测 0.2 秒、零额外进程。
- **超时探针泄漏孙进程**:`spawnSync` 的超时只杀它自己启动的 cmd.exe,而 `taskkill /T` 在 `spawnSync` 回收之后调用已找不到父进程 ——
每次超时留一个 REA/ping 在跑。探针改为异步 `spawn` + 自己的计时器:超时发生在父进程仍存活时,`taskkill /T /F` 能真正清掉整棵树。
实测 8.3 秒放弃、`rejected=["timeout"]`、无残留进程。
- **Windows 信号处理器是死代码**:`child.kill("SIGTERM")` 在 Windows 上是 TerminateProcess,不可捕获;控制台的 Ctrl+C 又直达子进程。
该分支已删除,并如实写明限制:**在 Windows 上强制结束 shim 不会带走被委派的 REA**(Node 没有 job object 可用)。
- **`comspec()` 不校验**:`ComSpec` 指向不存在的文件时,正常的 `.cmd` REA 被判 `spawn-failed`(`auto` 静默退回 bundle,`system` 直接 78)。
现在校验为常规文件,否则回落到 `%SystemRoot%\System32\cmd.exe`;实测 `ComSpec` 指向不存在路径仍能识别 6.9.0。
- **文案**:显式路径是目录时报「does not exist」,改为「is not a file」。
### 验证
- 单测 **67 条**(2.0.7 是 60 条,本轮 +7:wrapper 递归必须被判为 `exit 78` 且 20 秒内收敛;`ComSpec` 失效仍识别 `.cmd` REA;
镜像自愈的转发/守卫/`stale` 上报/同内容不转发;`DSH_HOME` 被抹时从 `mirror.json` 找到真实 home 并报出重复注册):
Windows 64 过 + 3 平台跳过。
- `verify-branch.ps1` 全绿(39 项),含「两份 README 必须写出 package.json 里的 `engines.dsh` 原文」;`audit-manifest` clean;
`host-compat` 10 covered / 5 correctly outside。
- 手工复现三例:wrapper 递归 0.2 秒收敛、0 残留进程;挂起候选 8.3 秒放弃且无 `PING` 残留;`ComSpec` 指向空路径仍识别系统 REA。
## 2.0.7 — 2026-10-18
**机器上已有可用的 REA 时,2.x 直接按 1.x 的方式跑它(环境检测 + 委派),不再供给;同时把 2.0.6 的宿主声明收紧到真的能证明的范围。**
### 新增:环境检测 → 跑 1.x 的那条路
- **启动时判断该跑谁**:`rea` 在 PATH 上且能应答 `--version`,就用它(`rea mcp`,用户自己的 Node 与 PATH);否则走供给的
bundle。这正是 1.x 的契约,现在从 2.x 的行里也能到。
- **版本优先级**:系统 REA 比供给的旧就不抢(6.4.0 是 170 个工具、6.5.0 是 207 个,静默回退工具数是真回归)。相等或更新
才切;预发布/无法比较时保守留 bundle。
- **覆盖**:`REA_BUNDLE_LAUNCH=auto|system|bundle`(默认 auto)、`REA_BUNDLE_SYSTEM_REA=<path>` 点名不在 PATH 上的二进制。
`system` 在找不到时**报错退出**,而不是替用户去下 800 MB。
- **`--shim-status` 报告决定**:`launch.{preference,mode,reason,unusable,systemRea,rejected}`;`--shim-print-env` 在 system
模式下打印 `{launch,rea,version}`。
- **防自委派**:被委派的子进程带 `REA_BUNDLE_DELEGATED=1`,一个调用本 shim 的 wrapper 不会让委派无限递归。
### 修复(两轮独立复核,逐条可复现)
- **委派参数被加引号**:`cmd /c` 下 `.cmd` 的 `%*` 收到的是 `"mcp"` 而不是 `mcp` —— 探针能过、委派是坏的。现在只给路径加
引号,参数原样传(行固定传 `mcp`;需要 shell 引号的参数本就不在这条路上)。
- **探测预算 3s 与真机同量级**:实测经 `.cmd` 的 `rea --version` 为 2.4s(并发 2.8–3.0s),3s 预算等于掷硬币。改为 8s;超时
用 `taskkill /T /F` 杀进程树,不再只杀 cmd.exe 而留下一个占着管道的 REA。
- **Windows 信号转发是错的**:`child.kill("SIGINT")` 只 TerminateProcess(cmd.exe),里面的 REA 继续跑;现在 Windows 走
`taskkill /T`,POSIX 不重复转发(stdio inherit 已由进程组投递)。
- **`--shim-status` 退出码**:`system` 被点名但不可用时曾返回 0(bundle 恰好已供给),实际启动却 78;现在按「这一行能不能
起来」返回,并新增 `launch.unusable` 字段。
- **候选判定**:`existsSync` 换成 `statSync().isFile()`(名为 `rea.cmd` 的目录曾被当成坏安装);PATH 项去引号、跳过相对项;
显式路径被拒时理由如实报告。
- **`fail()` 的补救命令补上 `--root`**:shim 用 `REA_BUNDLE_ROOT` 解析出的根与 setup 的默认根可以不同,照着旧提示执行会供给
到另一个根。
- **`engine.dsh` 上限收紧**:`<0.3.0` 在 includePrerelease 语义下会放行 `0.3.0-alpha.1`/`0.3.0-rc.1`;改为 `<0.3.0-0`,并用
市场自己的 `satisfiesRange` 逐版本核对。
- **`host-compat.mjs` 的测试加固**:发布清单逐项钉住;新增「低地板必须被判为泄漏」的反向对照(原实现把 `leaked` 改成 `[]`
仍然全绿);`+build` 元数据按 semver 接受、`v` 前缀与零填充预发布按 semver 拒绝。
- **环境独立性**:`setup.mjs` 自检的 shim 步骤钉 `bundle` 模式(否则机器上更新的 REA 会冒充本次供给的产物);CI 两个 job
各加 job 级 `REA_BUNDLE_LAUNCH=bundle`;测试里的每个 shim 调用点都钉住模式,测试不再随机器变。
- **`rea-bundle-bootstrap.ps1` 认 `REA_BUNDLE_ROOT`**:四份代码里只有它忽略这个覆盖,导致 bootstrap 把 800 MB 装到
`<DSH_HOME>/plugins/rea-bundle`,而 shim/setup/check-updates 去 `REA_BUNDLE_ROOT` 找,看到空 bundle 再起一次。
- 中文 README 的「更新到 2.0.4」措辞改正为「更新到 2.0.5」。
### 验证
- 单测 **60 条**(新增 12 条:6 条启动模式 + 1 条清单钉住 + 1 条泄漏反向对照 + 1 条 semver 容差 + 3 条 PATH/空格路径/委派退出码),
Windows 57 过 + 3 平台跳过。
- `verify-branch.ps1` 全绿;`audit-manifest.mjs` clean;`host-compat.mjs` 10 covered / 5 correctly outside。
- 真机:全局 `rea` 6.4.0 与供给的 6.5.0 并存时 `auto` 留 bundle(不降工具数),`REA_BUNDLE_LAUNCH=system` 才切过去。
## 2.0.6 — 2026-10-18
**声明宿主版本:`engines.dsh` 覆盖 `0.1.7-rc.2` … `0.2.1-rc.2` 全部发布版本,市场卡片因此显示「兼容」而不是「未声明」。**
### 兼容声明
- **`engines.dsh: ">=0.1.7-rc.2 <0.3.0"`**:市场侧的 `deriveHostCompatibility` 只认两类声明 —— `engines.dsh`
与指向 `@deepseek-ai/dsh*` 的 `peerDependencies`;两者都没有就是 `unknown`(卡片「兼容性未知」)。
这里只声明前者,刻意不声明 peer:`@deepseek-ai/dsh-mcp-client` 在 npm 上只有 `0.0.1-rc.1`,
而宿主随包内置的是 0.2.x,声明 peer 会让 pnpm 解析出一个与宿主不一致的版本,反而制造假冲突。
- **范围来自核对,不是估计**:`scripts/host-compat.mjs` 内置 dsh 发布列表(0.1.7-rc.2 至 0.2.1-rc.2 共 10 个),
逐个验证是否被声明覆盖,并反向确认 `0.3.0` 与 `0.1.7-rc.1`、`0.1.7-alpha.2` 落在范围之外。
- **跨版本比对过的东西**:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
配置面与发布的行一致(`serverName`/`transport`/`command`/`args`/`env`/`failOnStartupError` 均存在且同名),
且两个 tag 都有 `dsh.bundle.patch`。范围声明的是「核对过的宿主」,不是「应该能用」。
### 上游(只更新仓库,暂不带入)
- 上游已发布 **rea-agents 6.6.0**(GitHub release `rea-agents-6.6.0`,2026-10-18);npm `latest` 仍是 **6.5.0**,
所以本包的钉(`manifest/bundle.manifest.json` 的 `pins.reaAgents` 与 `components.reaAgents.sources.npm.version`)
**保持 6.5.0 不动**。已核 6.6.0 的 `engines.node`(`^22.19.0 || ^24.11.0 || >=26.0.0`)与 `bin`
(`scripts/rea.mjs`)均未变化,`optionalDependencies.frida` 为 17.23.2。
- 计划:**2026-10-19** 随下一版把钉升到 6.6.0 并复跑真机;届时工具数、探针正则与文档一并更新。
### 修复 / 工程
- 新增 `scripts/host-compat.mjs`(纯函数 + CLI):semver 预发布排序、`>=floor <ceiling` 判定、发布列表覆盖检查。
- `bundle:host-compat` npm script;`verify-branch.ps1` 新增 `engines.dsh` 存在性与覆盖率两项;
`validate.yml` 新增「Declared DSH range covers every released host」步骤,清单检查也要求 `engines.dsh`。
### 验证
- 单测 **46 条**(新增 4 条:声明可解析、10 个发布版本全覆盖且 3 个未核对版本被排除、反向对照能抓出
`>=0.2.0` 这种看似合理却漏掉整个 0.2.0-rc 线的写法、预发布排序符合 semver 而非字符串序):Windows 43 过 + 3 平台跳过。
- `node scripts/host-compat.mjs`:10 个发布版本 covered、3 个 correctly outside。
- `verify-branch.ps1` 全绿;`audit-manifest.mjs` clean(两份 README 结构仍平行)。
## 2.0.5 — 2026-10-18
**市场更新失败已定位并修复:清单里没有 `main`,市场因此在每次更新时判「新构建没有可加载入口」并回滚。**
### 修复
- **应用内市场更新到 2.0.4 失败**:`dshmarket` 的更新路径用 `hasLoadableEntry` 判断新构建能不能加载
(实现是 `entryArtifactExists`:读 `main`/`exports`,两者都没有就回落 `index.js`)。本包是纯 bundle
(只有 `dsh.bundle.patch`),从未声明 `main`;而它挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
构建、不在 `node_modules` 里,于是该检查要看的 profile / 包内 / workspace 三个位置全空。结果:2.0.1 → 2.0.4
的更新连续三次报「更新后缺少入口文件……且未能验证恢复原版本文件」,而回滚其实成功了 —— 旧版同样没有入口,
所以回滚后的复检依然为 false。现已声明 `main: ./index.mjs`,并让该文件进入 `files[]`。
- **`index.mjs` 是真空插件,不是占位**:导出 `name` 与空 `apply()`,不注册行、不起进程、不写状态;真正起作用的
行仍由 `cordis.patch.yml` 插入。若 harness 某天真把它当插件加载,行为仍是「什么都不做」,因此不会造出第二条
`serverName: rea`。
### 文档
- 两份 README 新增「在市场里更新 / Updating from the market」小节:说明该报错来自市场侧的入口判定、与 tarball
完整性无关、profile 未受损,以及 2.0.5 起的做法。
### 验证
- 用 `dshmarket` 自身的 `hasLoadableEntry` 实测:把本包按 profile 布局模拟安装,修复前 `false`、修复后 `true`;
对仓库目录 `entryArtifactExists` 修复后为 `true`。
- 单测 42 条(新增 2 条:清单声明的入口必须存在且被 `files[]` 收录;入口可 `import`、导出的 `name` 与包名一致、
`apply()` 返回 `undefined`):Windows 39 过 + 3 平台跳过。
- `verify-branch.ps1` 新增 `main` 存在性与 `files[]` 收录检查;`validate.yml` 的清单检查与 npm 载荷检查加了同一条守卫。
- `npm pack --dry-run`:20 个文件,含 `index.mjs`。
## 2.0.4 — 2026-10-17
**两处已核实的缺陷:只读 bundle 目录下 shim 启动即崩;与上游 `rea setup --client dsh` 的同名注册冲突现在会被明确提示。**
### 修复
- **只读 bundle 根目录 + 陈旧 lock → shim 未捕获 EACCES 崩溃**:2.0.3 把删锁移出了 `if (fresh)`,却没包
try/catch。目录只读(`chmod 555`、系统级安装目录)且锁已陈旧时,`rmSync(lock)` 抛 `EACCES`,shim 以
未捕获异常退出(exit 1 + 堆栈)—— 比原来的静默更吵,但同样起不来。沙箱已复现(WSL `chmod 555`)。
现在删锁失败返回 `unwritable`,走既有的「无法写入,请手动跑 setup」路径(exit 78,行不崩)。
- **与上游 `rea setup --client dsh` 的同名注册冲突**:该命令往 `<DSH_HOME>/cordis.patch.yml` 写入
`id: rea-mcp`、`serverName: rea`,而本包是 `id: mcp-rea`、同一个 `serverName`;DSH 对同名条目只保留
一个(后加载者报 duplicate server name)。已按上游 6.5.0 实测生成该文件确认。shim 现在启动时检测同
文件的 `serverName: rea`(id 不是 `mcp-rea`)并向 stderr 提示「二选一」,`--shim-status` 也会在
`duplicateRegistration` 下报告。
### 文档
- 两份 README 新增「只保留一条 REA 注册」小节:与 `rea setup --client dsh` 二选一,并说明两条路线的
取舍(零依赖供给 vs 上游官方纯配置注册)。
- v1 分支 README 顶部加横幅:指向 2.x(`main`,默认推荐)与上游官方注册。
### 验证
- 单测 40 条:Windows 37 过 + 3 平台跳过;WSL 39 过 + 1 跳过(本轮新增 3 条,其中只读目录一条仅 POSIX)。
- 反向验证:回退删锁的 try/catch → 只读目录测试在 WSL 必红(EACCES/exit 1);回退冲突检测 → 重复注册
测试必红。
- `verify-branch.ps1` 全绿。
## 2.0.3 — 2026-10-16
**修复 11 处已核实的缺陷(每条都先确认存在、并配了"回退即变红"的回归测试);上游 REA 钉子从 6.4.0 升到 6.5.0。**
### 上游
- `pins.reaAgents` 与 `components.reaAgents.sources.npm.version` → **6.5.0**:engines 与 bin 入口未变
(`^22.19.0 || ^24.11.0 || >=26.0.0`、`scripts/rea.mjs`),工具数 170 → **207**(运行时 `rea doctor --json` 实测;上游源码静态清点为 206,多出的 1 个注册在 contracts 之外。新增:Frida 9、
GDB 4、Rizin/Cutter 6、pcap/packet 4、flutter 2、sqlite/rust/go/wasm/electron/harmony/hopper 各 1 等)。
新增的 `optionalDependencies.frida` 在 `--ignore-scripts` 下不会执行安装脚本。REA 探针正则收紧为
`^6\.5\.0(\s|$)`,审计新增"下一个 patch/minor 不得通过"的断言。
- Ghidra 12.1.4 / apktool 3.0.3 / jadx-headless-mcp 0.7.1 **经核实仍是当前最新**;Node 24.22.0 虽已发布,
但晚于本插件的实测矩阵,故保持 24.21.0(engines 完全满足)。
### 修复
- **`--only` 抹掉其它组件的记录**:bundle.json 由本轮结果整份重写,未点名组件的 `paths.jdk/ghidra` 变
`null`,shim 不再导出 `JAVA_HOME`/`GHIDRA_INSTALL_DIR`——原生分析静默失效,下次完整跑还得重新解压
750 MB。未点名的组件现在按旧记录结转(且只在记录的路径仍然存在时),缺依赖时优先用旧记录/机器/运行
setup 的解释器来满足,而不是整段跳过检查。
- **完整的 `.part` 不被当作已完成**:只打一行日志,然后发 `Range: bytes=<全长>-`(416;四次重试后把
已经下好的文件报成失败)。现在校验通过即改名收下。
- **Windows 下载主路线没有续传**:PowerShell 路线每轮先删 `.part`;失败后 Node 兜底仍用进本轮前的
`have`,以 append 把 200 全量接在半成品后面(长度必然超标,只能重下)。两条路线现在都按盘上实际
长度发 Range 续传。
- **POSIX 的 apktool wrapper 没有执行位**(copyFileSync 落 0644,而 REA 不加 shell 直接 spawn → EACCES):
manifest 里标了 `executable` 的条目落盘时 chmod 0755。
- **apktool wrapper 钉在 `master` 分支 + 硬校验 sha256**:上游对 wrapper 的任何一次提交都会让全网新装
hash mismatch。改钉不可变的 `v3.0.3` tag,哈希按该 tag 重新实测。
- **`--reuse` 不校验版本**:JDK 只看 `javac` 存在、Ghidra 只看 `analyzeHeadless` 存在(JDK 8/11、Ghidra 11
照收)。现在候选必须通过该组件自己的 probe;同时修好"文件型 probe 从不检查 `expect`"(Ghidra 的
`application.version=` 断言此前是死配置)。
- **`bundle.json` 损坏即永久失败**:setup 读不了它、也没人启动 setup,用户只能手删文件。现在 setup 容忍
它(并按需重写),shim 对 `malformed` 与 `missing` 一样触发供给。
- **失败后 shim 谎报 6 小时**:lock 现在记录状态与 pid;失败态如实报告"上一次失败了",持有者已消失的
lock 不再阻塞重试。
- **成功的 `--json` 轮次从不删 lock**:删除语句原先在非 `--json` 分支里,--json 成功也留下 lock。
- **shim 兜底 REA 路径少了 `node_modules`**:旧 bundle.json 缺 `paths.reaEntry` 时会把可用安装判成"CLI 丢了"。
- **`scripts/rea-bundle-bootstrap.ps1`**:`node -v` 输出非数字时 `[int]` 强转在 `$ErrorActionPreference='Stop'`
下会炸掉整个 bootstrap(而它存在的意义就是这条路径);`&` 调用失败时 `$LASTEXITCODE` 可能为 `$null` →
`exit $null` = 假成功;engines 改从 manifest 的 `pins.nodeEngines` 读(不再抄第二份);`-DryRun` 不再创建
目录(它承诺"什么都没改")。
- **`check-updates.ps1` 的 1.x 残留**:REA 节默认找 npm 全局包(2.x 是 vendored)、Ghidra 节读用户级
`GHIDRA_INSTALL_DIR`、`rea doctor` 拿不到 vendored CLI(整段自检被静默跳过)、一体包对账对"未记录版本"
的组件静默 `continue` 却打印"全部一致"。现在一律先读 `bundle.json`,并如实报告无法对账的项。
### 审计与 CI
- `audit-manifest.mjs` 新增 4 项:manifest 组件 ⇄ `INSTALL_ORDER` 对账、`pins.nodeEngines` ⇄ `package.json
engines`、REA pin 探针不得放行下一个 patch/minor、POSIX wrapper 必须标 `executable`。
- `validate.yml` 新增 `windows-latest` job:PowerShell 下载路线、zip 解压与 bootstrap 此前是零 CI 覆盖的
Windows 专属代码。
- 单测 33 条(32 过 + 1 条按平台跳过);`--reuse adopts a compatible node` 在 engines 之外的宿主上改为显式
skip(此前在 Node 20 上必红,而 CI 钉 22 看不出来)。
### 第二轮修复(同一份**更新后**的清单,2026-10-16 晚)
- 🔴 **过期 lock 永久卡死自动供给(2.0.0–2.0.2 三版全带)**:`maybeAutoProvision()` 只在 lock 新鲜时
处理它,过期的 lock 既不清除也不报告 → 落到独占创建 → `EEXIST` → 返回 `in-flight`,而 `main()` 对
`in-flight` **一个字都不打印**。于是「下载被 kill / 断电」这一常态,在 6 小时后再启动就变成**永久不再
自动供给且完全静默**。已按清单复现(7 小时死 lock:不启动、lock 原样、无任何提示;5 分钟 lock 对照组
正常删锁重启),修法:**不新鲜或持有者已死就一律清锁**(这正是注释里"只在新鲜时尊重它"的本意),
并给 `in-flight` / `spawn-failed` / `unwritable` 补上如实提示。
- 🔴 **`examples/` 两个文件是 1.x 内容,却随 2.x 进了 npm 包**:`cordis.patch.posix.yml` 教人把整行换成
`command: rea`、`cordis.patch.with-env.yml` 给的是 `cmd.exe /c rea` 的 1.x 行——对 2.x 全错(2.x 的行是
跨平台的 `process.execPath` + bundle shim,照抄会整行绕过 bundle)。两份都已是孤儿(README 不再引用),
故从 `main` **删除**,并同步 `package.json files[]` 与 `validate.yml` 的载荷/YAML/机器路径三项检查
(v1 分支保留它们,在 1.x 语境里是对的)。npm 载荷由此 21 → 19 个文件。
- 🟡 **`--offline` 管不住 `reaAgents`**:`provisionReaAgents()` 里 0 处 offline 判断,`npm install` 永远
联网——与「`--offline` is a promise, not a hint」直接矛盾。现在 `--offline` 且内置副本不可用时**直接
FAIL** 并说明原因(而可用的内置副本仍照常跳过)。
- 🟡 **npm 那一步没有代理/镜像兜底**:压缩包有 direct→proxy→PowerShell 三路,npm 只读环境变量。失败时
现在会打印这句提示,两份 README 的能力/注意里也写明 `HTTPS_PROXY` / `NPM_CONFIG_REGISTRY`。
- 🟡 **POSIX 缺 `unzip`/`xz` 必现失败**:Ghidra 全平台只有 zip,而 POSIX 解压原先只有 `unzip` 一条路。
现在 zip 依次尝试 `unzip` → `python3 -m zipfile` → **已供给 JDK 自带的 `jar`**(JDK 在 Ghidra 之前供给,
等于保底),tar 依次 `tar` → `python3`;三者皆无时打印明确的「no zip extractor available」而不是干巴巴的
「extraction failed」。
- 🟡 **manifest 的 arm64 注释是错的**:Google 的 linux/darwin platform-tools 都是 x86_64 二进制,
「The same archive serves x86-64 and arm64」不成立。三个条目改成如实描述(macOS 需 Rosetta 2、
linux-arm64 上 probe 预期失败并被报为可选组件不可用)。
- 🟡 **`docs/bundle-design.md` 与实现漂移**:§3 的「row points at `<plugin>/bin/rea-shim.mjs`」改成
「按 `DSH_HOME` 算出 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`(镜像副本)」;§7 的
`vendor/rea-agents` 改成 `vendor/node_modules/rea-agents`。
- 🟢 **`release.yml` 的 `workflow_dispatch` 可绕过 Release 直接发 npm**:publish job 现在先断言
`v<package.json version>` 的 tag 在 origin 上存在、且该 Release 挂着版本无关的 `dsh-plugin-rea.tgz`
资产,否则拒绝发布(否则 npm 与市场兜底链接会岔开)。
- 🟢 **数值环境变量会变成 NaN**:`REA_BUNDLE_FETCH_RETRIES=abc` → `Math.max(1, NaN)` → 报
「gave up after NaN attempt(s)」;NaN 的 socket 超时等价于 0(立即超时)。现在非正有限值一律回落到默认,
且启动日志新增一行 `limits connect=… idle=… retries=…`,让覆盖生效可见。
- 🟢 注释与整洁:`downloadViaPowerShell` 的文档说自己是 fallback,实际在 Windows 上是主路线(已改口径);
去掉 setup.mjs 里 3 处对已静态导入成员的重复 `await import("node:fs")`(`readdirSync` 补进静态导入)。
## 2.0.2 — 2026-10-15
**仅文档,无行为变更。**
- 两份 README 的**末尾**(许可证之前)新增一节,说明**什么时候该用 1.x 线**以及它的安装方式
(`dsh-plugin-rea@1.0.6` 或 `github:czcdr/dsh-plugin-rea#v1.0.6`)—— 此前 1.x 只出现在开头的版本说明与升级章节里。
## 2.0.1 — 2026-10-15
**仅文档与版式,无行为变更。**
+244 -14
View File
@@ -10,6 +10,38 @@
> kept on the [`v1` branch](https://github.com/czcdr/dsh-plugin-rea/tree/v1) and on npm as `[email protected]`.
> Full comparison: [Two lines: 1.x and 2.x](#two-lines-1x-and-2x) · upgrading: [Upgrading from 1.x](#upgrading-from-1x).
> REA **6.8.0** — this package's pin — still accepts Ghidra **12.1.x** only (12.2 is `unsupported`, and the whole
> Ghidra decompile/analysis group disappears) and the jadx bridge **0.7.1** only (0.8.0 fails on its first call with
> `Expected engine 0.7.1`, before any APK is loaded). Both gates were re-checked against 6.8.0 on 2026-10-26 and
> neither one moved. JDK **21.0.13** is out upstream, but Adoptium publishes no Windows build of it, so there is
> nothing to point at here — and it drops only three of the four eMudhra anchors. The trade-off is therefore "all
> tools green, CVE still open": Ghidra 12.1.4's CVE-2026-100504 / 100505 / 100503 / 113604 stay in the chain — **do
> not analyse untrusted samples with this environment** — and the JDK still trusts the eMudhra anchors. Evidence,
> mitigations and revisit triggers: [docs/update-review-2026-10-24.md](docs/update-review-2026-10-24.md) and
> [docs/update-review-2026-10-26.md](docs/update-review-2026-10-26.md).
>
> **Ghidra 12.2 itself works** — measured 2026-10-26: same 9 segments, same 341 procedures, byte-identical `entry`
> pseudocode as 12.1.4, through REA's own bridge. What blocks it is REA's version-equality check, not the bridge.
> If you want the CVE-fixed engine anyway, `node scripts/ghidra-gate-override.mjs --enable --version 12.2` rewrites
> that one constant in the vendored copy, records what it changed in `<bundle>/ghidra-gate-override.json`, and
> `--disable` restores it; re-running setup reverts it too. It needs **JDK 25+** (`application.java.min=25`).
> **You must run `node scripts/setup.mjs` yourself — once after installing, and once after every plugin
> upgrade.** Nothing runs it for you: a market install or update replaces the package and stops there.
> Since **2.0.8** a market upgrade keeps the *row* working (the mirrored launcher re-execs the new one), but it
> does **not** move the bundle to the new pins — REA and the engines stay at the versions recorded by the last
> setup run. Skip it on a first install and the row exits `MODULE_NOT_FOUND` with no tools and no message;
> skip it after an upgrade and you keep running the old REA silently (`check-updates.ps1` reports the drift).
> If you had enabled the Ghidra gate override (see §9 of the 2026-10-26 review), that setup run re-provisions the
> bundled REA and the patch goes with it — re-enable it afterwards, or accept the pinned Ghidra again.
> The full rule set is under [Updating](#updating--three-independent-things).
> **A first install and every plugin upgrade also show a one-time guide in the Web GUI.** It says whether the
> runtime is missing or behind this package's pin, shows the exact `node "<install path>/scripts/setup.mjs"`
> command for that install, and remembers the dismissal per plugin version. It is a notice only: it never runs
> setup for you and never edits the bundle. Run it once, restart dsh, and the guide stays away until the next
> plugin version.
Brings the upstream **[REA — Reverse Engineer Anything](https://github.com/morluto/rea)** MCP server into
**DeepSeek Harness** (dsh), so its tools show up in a session as `mcp__rea__*`.
@@ -82,6 +114,10 @@ Both lines ship the **same patch shape** — one `insert` row of `@deepseek-ai/d
same `mcp__rea__*` surface; they differ in who prepares the runtime. Moving from 1.x to 2.x takes two
commands: see [Upgrading from 1.x](#upgrading-from-1x).
Upstream also registers itself with dsh now (`rea setup --client dsh`); that route is essentially the
1.x model maintained upstream — configuration only, your own Node/REA/Ghidra. This bundle is the
zero-dependency route. Pick one of the three, and never keep two `serverName: rea` rows at once.
## Install
**Nothing to install first.** On a bare Windows x64 machine:
@@ -96,7 +132,7 @@ releases, checksum-verified). Then install the plugin for your profile:
```
dsh plugin --profile <profile> add dsh-plugin-rea
# or pin this release instead of tracking npm latest:
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.13
```
> **On an Electron host (the DeepSeek Harness desktop app) the profile is owned by the application.**
@@ -110,6 +146,11 @@ If Node is already installed, you can skip the bootstrap and run provisioning di
node scripts/setup.mjs --reuse # or --dry-run to see the plan first
```
Node, when you provide it instead of letting setup fetch one, must satisfy REA's engines —
`^22.23.4 || ^24.22.1 || >=26.12.1` (check with `node -v`). REA raised those floors in 6.8.0: the pinned
24.22.1 is now the *oldest* acceptable 24.x, so a 24.11–24.21 runtime that 6.7.0 accepted is rejected here.
A lower Node does not fail at install time; the MCP row fails later, which reads as silence.
Installing the plugin **first** also works, but it takes two steps. The row resolves to
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`, and that file is created by setup — so until the first
provisioning run the row exits with `MODULE_NOT_FOUND`, and nothing is there to start a background
@@ -117,6 +158,46 @@ provisioning (the harness itself is unaffected: `failOnStartupError: false` is d
`node scripts/setup.mjs` once; from then on the shim is in place, and every later missing state is picked up
by the shim itself, which provisions in the background.
### Which DSH versions
`package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`, and that declaration is the answer the plugin market
reads: with it the card says **compatible**; without it the market can only say *unknown*. The range is not a guess —
it is the dsh release list this plugin is checked against, and `node scripts/host-compat.mjs` fails the build when the
two disagree:
| Host | Covered |
|---|---|
| `0.1.7-rc.2` … `0.2.1-rc.2` — every release in between, alpha and rc | yes |
| `0.3.0` and later | no — a new minor line is a host nobody updated this row for yet |
What was actually compared across that range is the row provider: `packages/mcp/mcp-client` at `dsh-v0.1.7-rc.2` and at
`dsh-v0.2.1-rc.2` exposes the same config surface the shipped row uses (`serverName`, `transport`, `command`, `args`,
`env`, `failOnStartupError`), and `dsh.bundle.patch` exists at both tags. A host outside the range can still work; it is
simply not claimed.
### A REA you already have is used as-is
2.x exists to supply what a machine is missing. If REA is already installed — `rea` on PATH, the 1.x setup — the row
runs **that**, exactly as the 1.x line did, and nothing is provisioned:
| What the machine has | What the row runs |
|---|---|
| `rea` on PATH, at least as new as the provisioned build | your REA (`rea mcp`), with your Node and your PATH |
| `rea` on PATH but older than the provisioned build | the provisioned bundle — a stale global CLI would take the tool count backwards (6.4.0 ships 170 tools, 6.7.0 ships 207, 6.8.0 ships 229) |
| no `rea` on PATH | the provisioned bundle, provisioning itself on first use |
- `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the decision (`auto` is the default); `REA_BUNDLE_SYSTEM_REA=<path>`
names a binary that is not on PATH.
- The probe is the CLI's own `--version`, bounded at 8 s: a `.cmd` shim on Windows takes ~2.4 s to answer, so the check
runs once per start and only when a `rea` actually exists.
- `--shim-status` prints the decision — `launch.mode`, `launch.reason`, `launch.systemRea` — and its `launch.rejected`
list says why a candidate was skipped (`"exit 7"`, `"inside the bundle"`, `"timeout"`).
- A delegated child carries `REA_BUNDLE_DELEGATED=1`, so a wrapper that calls this shim cannot make it delegate to
itself forever.
- **On Windows a forced kill of the shim does not reap the delegated REA.** There is no job object here: Task Manager
or a supervisor terminating the shim leaves your `rea` running with the row's pipes (a console Ctrl+C reaches the
whole process group and does stop it). On POSIX the child is in the same group and dies with the terminal.
## Configure — nothing
The shipped row carries **no machine path**:
@@ -130,7 +211,7 @@ The shipped row carries **no machine path**:
transport: stdio
command: !!js process.execPath
args:
- !!js "(process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : (process.env.USERPROFILE || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
- !!js "(process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : (process.env.USERPROFILE || ((process.env.HOMEDRIVE || '') + (process.env.HOMEPATH || '')) || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
- mcp
env:
ELECTRON_RUN_AS_NODE: '1'
@@ -142,6 +223,12 @@ Three details in that row are load-bearing, and all three were bugs first:
- **The `!!js` scalar is quoted.** A bare scalar containing `? ` and `: ` is not one scalar: YAML reads it as an explicit key/value pair, so the loader receives a *mapping* where an expression belongs. Measured against the harness's own dialect (`JSON_SCHEMA` plus the `!!js` type from `app.asar`), the unquoted form made `args[0]` into `{"[object Object]": "..."}` — an object the row cannot launch with. Quoting removes the ambiguity, and the JS string literals inside then use single quotes.
- **Forward slashes, never backslashes.** A single-quoted YAML scalar keeps `\` literal, and JavaScript then consumes it: `'\plugins'` is not a path but the BEL control character, `'\rea-bundle'` becomes a carriage return and `'\bin'` a backspace — the path is destroyed silently. Forward slashes work on Windows and on POSIX.
- **The `DSH_HOME` fallback is `<home>/.dsh`, not `<home>`.** The shim, `setup.mjs` and `bootstrap.ps1` all resolve an unset `DSH_HOME` to `<home>/.dsh/...`; a fallback that stopped one level higher made the row point at a file provisioning never writes — invisible on any machine where `DSH_HOME` happens to be set.
- **The home fallback mirrors `os.homedir()`: `USERPROFILE`, then `HOMEDRIVE`+`HOMEPATH`, then `HOME`.**
The middle pair is what a Windows service account gets instead of `USERPROFILE`; an expression that skipped
it produced `/.dsh/...` — the root of the current drive — for exactly the accounts that cannot read that
path. Only when all four are empty (a container account with no home at all) does the concatenation land on
`/.dsh/...`, and `failOnStartupError: false` keeps that quiet, so the only symptom is "no `mcp__rea__*`
tools". Set `DSH_HOME` explicitly on such an account.
- **No `path`/`require` in a `!!js` expression**: `ctx` is the loader scope, so the DSH_HOME fallback is written as plain concatenation.
`npm run bundle:audit` enforces the first three: it evaluates this exact expression with `DSH_HOME` unset, empty and set, and rejects a bare scalar that YAML would read as a mapping.
@@ -168,31 +255,71 @@ To pin a different launch command on this machine (for example your own globally
the row from the profile's own `cordis.patch.yml` — that file is machine-local and never published. Only one
row may use `serverName: rea` in a profile.
A patch replaces the row's `config` **wholesale** — it does not deep-merge — so a partial override such as
`config: {env: {...}}` also deletes `command`, `args` and `serverName`, and the row stops starting. Supply the
complete config, or use `REA_BUNDLE_LAUNCH=system` (above), which needs no patch at all.
Ghidra and the JDK are not read from your environment at launch: `setup.mjs` records their absolute paths in
`bundle.json` and the shim exports `GHIDRA_INSTALL_DIR` / `JAVA_HOME` from that record — which is why a
non-standard install location works without touching the row. To adopt an existing install, point
`GHIDRA_INSTALL_DIR` / `JAVA_HOME` at it and run `node scripts/setup.mjs --reuse`: the manifest's probe checks
the candidate and falls back to a download when it does not match, so a wrong path is reported during setup
instead of failing later inside a tool call.
### One REA registration only
Upstream REA 6.5.0 can register itself with DSH:
```
rea setup --client dsh
```
That writes a row with `id: rea-mcp` and `serverName: rea` into `<DSH_HOME>/cordis.patch.yml`. This
bundle's row is `id: mcp-rea` with the same `serverName`, and DSH rejects the later of two entries
that share a server name — so which REA row survives depends on load order. **Use one, not both.**
Which one? Upstream's is the configuration-only route: it expects Node on REA's engines,
`rea-agents` reachable, and a Ghidra/JDK you provisioned yourself. This bundle is the
zero-dependency route: it fetches Node, REA, JDK and Ghidra on first run. The patch shape and the
`mcp__rea__*` surface are the same either way.
The shim reports the conflict on startup — `setup.mjs` records the DSH home it ran under in
`mirror.json`, and the mirrored copy reads that when the harness has stripped `DSH_*` from its
environment; `node bin/rea-shim.mjs --shim-status` lists it under `duplicateRegistration`.
## Verify
Ask for a `mcp__rea__*` call — `binary_session` reports the engine checks
(`configuration` / `installation` / `headless` / `native_decompiler` / `java`), all of which should be
`ok: true`. Then `open_binary` a PE, `binary_overview`, and `procedure_pseudo_code`.
Provisioning itself ends with two probes: `rea doctor --json` (reported, with unmet checks named) and the
exact shim launch the MCP row will use.
Provisioning itself ends with three checks: `rea doctor --json` (reported, with unmet checks named), a
**Ghidra smoke import** that runs `rea analyze` on the JDK's own `bin/java` and fails the run when it
cannot import (`--skip-smoke` omits it), and the exact shim launch the MCP row will use. The smoke step
exists because `doctor` validates paths, versions and which Java would be chosen — it never imports a
binary, so a broken bridge or a mismatched native addon used to surface only as an MCP timeout.
**The first analysis of each new target is slow.** Measured here: 36 s for a small PE, 64 s for a 6 MB
DLL — every import starts a JVM and runs Ghidra's default auto-analysis. If the first
`open_binary` → `list_segments` on a large file times out, that is this cost and not a broken engine:
call it again and the open target answers immediately, because it keeps its ephemeral project.
## Verified against
Author's machine, 2026-10-13 / 14:
Author's machine, 2026-10-13 / 14 / 16 / 19 / 23 / 26:
| Component | Version |
|---|---|
| REA (`rea-agents`) | **6.4.0** (170 tools) |
| REA (`rea-agents`) | **6.8.0** (229 tools) |
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**, with its bundled dsh CLI 0.1.7-rc.2 |
| `@deepseek-ai/dsh-mcp-client` (row provider) | bundled by the harness |
| Ghidra | 12.1.4 |
| JDK | Temurin 21 (provisioned) / Microsoft OpenJDK 25.0.4 (adopted) |
| Node.js | 24.21.0 (REA engines: `^22.19.0 \|\| ^24.11.0 \|\| >=26.0.0`) |
| JDK | Temurin 21 (provisioned) / Amazon Corretto 25.0.5 (adopted by the host) |
| Node.js | **24.22.1** adopted (REA engines: `^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`; ABI 137) |
| Host | Windows 11 x64 (build 26200) |
Exercised live: a clean `setup.mjs --reuse` run reached `setup complete`; an MCP `initialize` handshake
**through the shim with no `rea` on PATH** answered `serverInfo {name: rea, version: 6.4.0}`; and the whole
**through the shim with no `rea` on PATH** answered `serverInfo {name: rea, version: 6.8.0}`; and the whole
chain ran under the real desktop host — its own Electron binary launched
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`, the shim started the **vendored** REA, and a live tool
call reported `path_source: environment` for a path only that machine's `bundle.json` knows.
@@ -201,24 +328,96 @@ Not exercised end to end here, and therefore not claimed: the ~775 MB JDK + Ghid
by `--dry-run`, but this machine adopted its existing installs with `--reuse`), and first runs on
macOS/Linux.
On 2026-10-23 the same chain was re-verified after the host Node moved to **24.22.1**: the vendored
`rea-windows-x64.node` and both `conpty` addons loaded under ABI 137; `binary_overview` on `conpty.node`
reported 10 segments / 1178 procedures / 618 strings; `procedure_pseudo_code` returned real decompiled
output; and `java -version` from the vendored JDK answered `21.0.12.1`. The one thing that did **not**
work on the first try was the first `list_segments` — it timed out at 64 s of cold Ghidra analysis, which
is what the smoke step and the note above are for.
On 2026-10-26 the pin moved to **REA 6.8.0** and the chain was re-verified on the same machine: `setup.mjs
--reuse` reported `probe cli=6.8.0 tools=229` and its Ghidra smoke imported `java.exe` in 26 s; an independent
MCP handshake over stdio answered `serverInfo {name: rea, version: 6.8.0}` with **229 tools / 6 prompts** on
protocol `2024-11-05`; apktool still answers `3.0.3`, `adb` still reports 37.0.1 (1.0.41), and the jadx jar on
disk still hashes to `6e5eacf5…` — the value REA 6.8.0 requires. Executing 6.8.0's own release policy gave
`12.1.4 → verified`, `12.1.5 → compatible`, `12.2 → unsupported`, so Ghidra stays where it was.
## Scope and caveats
- REA exposes 170 tools at 6.4.0, so every session's tool list grows.
- REA exposes 229 tools at 6.8.0, so every session's tool list grows.
- Provisioning is honest about checksums: where a vendor publishes one it is pinned and verified; where none
exists (jadx-headless-mcp, the apktool wrapper, Android platform-tools, the rolling "latest" URLs) the
hash is **measured once and recorded**, and later drift is reported rather than silently accepted.
`bundle.json` lists which components are in that state under `unverifiedChecksums`.
- Downloads are slow on some networks (measured 0.35–0.54 MiB/s from the GitHub release CDN here, so Ghidra
alone takes ~25–45 min). Fetching is resumable: the cache is kept and re-verified, and a killed run
continues instead of restarting.
alone takes ~25–45 min). Fetching is resumable on both routes: a partial `.part` is continued with a Range
request, and a `.part` that already holds the whole file is verified and kept instead of re-fetched.
- Some REA tools launch processes, write files and access the network — DSH's permission policy applies.
Analyse only artifacts you are authorized to inspect.
- The MCP child and the engines it launches are ordinary processes running as **you**. DSH's tool
approvals govern what the model asks for, not what that child may read, write or send on its own;
treat the plugin like any third-party code you install.
- On Linux, upstream's process-ownership checks need a **procps-compatible `ps`** on PATH; the
Ghidra/JADX and firmware paths use it. A minimal image without it fails those tools, not the install.
- `scripts/setup.mjs` runs `npm install` for the REA CLI with `--ignore-scripts`: the published tarball
carries no `node_modules`, so a dependency closure must be resolved, and package lifecycle scripts are
skipped on purpose during provisioning.
skipped on purpose during provisioning. That step is also the **only** one that cannot use this bundle's
own route/proxy resolution — npm reads `HTTPS_PROXY`/`HTTP_PROXY` and `NPM_CONFIG_REGISTRY` from the
environment, so on a network that needs a mirror, set one of those before provisioning
(`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`). With `--offline` the step is refused outright
instead of quietly going online.
- On POSIX, extraction falls back when the usual tools are missing: a zip is opened with `unzip`, then
`python3 -m zipfile`, then the provisioned JDK's `jar`; a tarball with `tar`, then `python3`. A minimal
image that has none of them fails with an explicit "no zip extractor available" line rather than a bare
"extraction failed". This matters because Ghidra is a zip on every platform and `unzip` is not installed
everywhere by default.
## Updating — three independent things
> **Two standing rules — for this release and for every later 2.x release.**
>
> `setup.mjs` reconciles the **bundle**, never the **plugin**: it reads the manifest of the plugin that is
> installed *right now*. Installed 2.0.9? setup provisions 6.5.0 even after a newer release exists. The package changes
> only through the market (or `npm i -g dsh-plugin-rea@<version>` / a `file:` reinstall); running setup more
> often never substitutes for that.
>
> **And nothing runs it for you.** A market install or update replaces the package and stops; the first
> `setup.mjs` — and one after every upgrade — is yours to run, from the plugin directory.
>
> 1. **The row executes a copy of this plugin's launcher.** `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` is a
> mirror made by setup, and a market upgrade replaces the package without replacing it — so the mirror can keep
> running old code with **no symptom at all**. Since **2.0.8** the mirror compares itself with the launcher
> recorded in `mirror.json` and re-execs the plugin's copy when the two differ (one hop, guarded by
> `REA_BUNDLE_SHIM_REEXEC`). A mirror **older than 2.0.8 cannot do this**, so upgrading from **2.0.0–2.0.7**
> still needs one `node scripts/setup.mjs`. `--shim-status` reports `mirror.stale`; `check-updates.ps1` says
> when to re-run.
> 2. **The mirrored `scripts/setup.mjs` is a copy too, and it has no self-heal of its own.** Running setup from the
> bundle root therefore executes the version from when the bundle was created, not the one you just installed.
> Run it once **from the plugin directory** to refresh that copy. Since **2.0.9** the copy strips the UTF-8 BOM
> from `mirror.json` / `bundle.json` exactly as the shim does, so a BOM-ed record no longer reads as "no record"
> on the setup side (which used to re-mirror the previous manifest).
### Updating from the market
Update it where you installed it. Updating **to 2.0.5 or later** is routine; updating an installed 2.0.1–2.0.4
*through the in-app market* failed with this, three times on a healthy profile:
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
That message is not about a broken tarball. On every update the market asks whether the new build's
`package.json` points at an entry file that exists (`hasLoadableEntry` in `dshmarket`), and rolls the build back
when the answer is false. A bundle may legitimately own no entry — but only if a package it *mounts* has one, and
the package this bundle mounts (`@deepseek-ai/dsh-mcp-client`) ships inside the harness build rather than in
`node_modules`, so every location that test probes comes back empty. The 1.x and 2.0.x manifests also declared
no `main` at all, so the answer was false even though the profile was working; the rollback was then reported as
unverifiable for the same reason. Nothing in the profile was actually damaged — the update simply did not apply.
2.0.5 declares `main: ./index.mjs`. That file is a real, deliberately empty Cordis plugin: it registers nothing,
spawns nothing, touches no state — the working row is still inserted by `cordis.patch.yml` — and it exists so the
question "can this package load?" has an honest answer, and so `import 'dsh-plugin-rea'` resolves. On an older
version, update to `>=2.0.5`; if the market refuses before that version exists, install `[email protected]`
(Desktop: through the plugin manager) instead of updating in place.
### Upgrading from 1.x
Install 2.x, then run provisioning **once** before restarting dsh:
@@ -236,6 +435,18 @@ back on the next start.
- **This plugin** never updates itself. Installed from a market? Update it there. Installed from a `file:`
checkout? Overwrite the directory and reinstall.
- **The row runs a COPY of this plugin's launcher.** `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` is a mirror
made by setup, so a market upgrade replaces the package but not the file the row executes — until setup runs
again. From **2.0.8** the mirror notices that the installed plugin ships a different launcher and re-execs it
(one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`), so upgrades carry. On an older version the mirror stays behind
silently; `--shim-status` now reports `mirror.stale`, and `check-updates.ps1` says to re-run setup. If you are
upgrading from 2.0.0–2.0.7, run `node scripts/setup.mjs` once after the upgrade.
- **The mirrored `scripts/setup.mjs` is a copy too.** A market upgrade replaces the package but not that copy,
so a remediation command that points at `<DSH_HOME>/plugins/rea-bundle/scripts/setup.mjs` runs the setup from
when the bundle was created — not the one you just installed. From **2.0.9** both the shim and setup strip a
UTF-8 BOM from `mirror.json` / `bundle.json` (a BOM-ed record used to read as "no record" on the setup side,
which re-mirrored the previous manifest). Running `node scripts/setup.mjs` once **from the plugin directory**
refreshes that copy too.
- **Upgrading from `2.0.0-bundle.1`**: that release mirrored the shim and `setup.mjs` but not the manifest,
and recorded nothing about the plugin checkout, so a bundle it provisioned cannot repair itself — its
background provisioning dies on the missing manifest, invisibly, and its own lock suppresses retries for
@@ -246,7 +457,26 @@ back on the next start.
- **Upstream REA / Ghidra**: update the pins in the manifest, then re-run setup. REA version-gates Ghidra
(12.1.x today), so update REA first.
`check-updates.ps1` (Windows PowerShell) reports the state of all of these at once.
`check-updates.ps1` (Windows PowerShell) reports the state of all of these at once. `npm run check-updates`
forwards to it on Windows (PowerShell 5.1, then `pwsh`); on macOS/Linux it prints why the script cannot
answer there and names the two portable commands instead — `node bin/rea-shim.mjs --shim-status` and
`node scripts/setup.mjs --dry-run` — exiting non-zero rather than reporting a green it did not earn.
## Still prefer the configuration-only 1.x line?
1.x is the adapter **without** provisioning: one row that launches `rea mcp` from PATH and expects Ghidra to be
installed by you. It is the right choice when you already manage REA and Ghidra yourself, when you do not want
the plugin fetching anything, or when you want the row to keep pointing at your own `rea` binary:
```
dsh plugin --profile <profile> add [email protected]
# or pin the branch instead of the version:
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v1.0.6
```
Both lines publish the same patch shape and the same `mcp__rea__*` surface. 1.x is maintained on the
[`v1` branch](https://github.com/czcdr/dsh-plugin-rea/tree/v1), where its own install notes live (Node on REA's
engines, `npm i -g rea-agents`, Ghidra 12.1.x for native analysis).
## License
+211 -13
View File
@@ -10,6 +10,32 @@
> [`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),npm 上仍是 `[email protected]`。
> 完整对比:[两条线:1.x 与 2.x](#两条线1x-与-2x) · 升级:[从 1.x 升级](#从-1x-升级)。
> REA **6.8.0**(本包钉子)只认 Ghidra **12.1.x**(12.2 判 `unsupported`,
> Ghidra 系的反编译/分析工具整组消失)和 jadx 桥接 **0.7.1**(0.8.0 会在第一次调用就报
> `Expected engine 0.7.1`,失败点在加载 APK 之前)。这两道闸门 2026-10-26 对着 6.8.0 重新核过,**一道都没动**。
> JDK **21.0.13** 上游已发,但 Adoptium 仍没给 Windows 构建,本机无处可指,而且它只移除 4 个 eMudhra 锚里的
> 3 个。所以现在的取舍是「工具全绿、CVE 未清」:Ghidra 12.1.4 的 CVE-2026-100504 / 100505 / 100503 / 113604
> 仍在链上 —— **别拿这套环境分析来源不可信的样本**;JDK 也仍信任 eMudhra 锚。证据、缓解与重开条件:
> [docs/update-review-2026-10-24.md](docs/update-review-2026-10-24.md) 与
> [docs/update-review-2026-10-26.md](docs/update-review-2026-10-26.md)。
>
> **Ghidra 12.2 本身是可用的** —— 2026-10-26 实测:经 REA 自己的桥接,段表(9 段)、函数表(341 个)与 `entry`
> 伪代码与 12.1.4 **逐字节相同**。挡住它的不是兼容性,是 REA 那条版本等值判定。如果你就是要用修掉 CVE 的那一版:
> `node scripts/ghidra-gate-override.mjs --enable --version 12.2` 只改 vendored 副本里那一个常量、把改动记进
> `<bundle>/ghidra-gate-override.json`,`--disable` 还原;重跑 setup 也会还原。它要求 **JDK 25+**
> (`application.java.min=25`)。
> **`node scripts/setup.mjs` 要你自己跑 —— 装完跑一次,之后每次升级插件版本再跑一次。** 没有东西会替你跑:
> 市场里的安装/更新只替换包,然后到此为止。从 **2.0.8** 起,市场升级能保证**那一行**继续可用(镜像启动器会
> 转发执行新的那份),但它**不会**把 bundle 换到新的钉子 —— REA 与引擎仍是上一次 setup 时记下的版本。
> 首次安装不跑:行以 `MODULE_NOT_FOUND` 退出,工具消失且没有任何提示;升级后不跑:仍旧跑旧 REA 且没有症状
>(`check-updates.ps1` 会报出这条漂移)。若你开过 Ghidra 闸门覆盖,这一次 setup 会重新供给内置 REA、覆盖随之
> 消失 —— 跑完再 `--enable` 恢复,或者接受回到钉住的那一版。完整规则见[更新](#更新--三件互相独立的事)。
> **首次安装与每次升级插件后,Web GUI 会显示一次操作指南。** 它会说明运行时是缺失、还是落后于本插件钉的版本,
> 给出这次安装对应的完整 `node "<安装路径>/scripts/setup.mjs"` 命令,并按插件版本记住“已看过”。
> 它只是提示:不会替你运行 setup,也不会改动 bundle。跑一次、重启 dsh,提示就会消失,直到下一个插件版本。
把上游 **[REA — Reverse Engineer Anything](https://github.com/morluto/rea)** 的 MCP 服务接入
**DeepSeek Harness**(dsh),让它的工具在会话里以 `mcp__rea__*` 出现。
@@ -77,6 +103,9 @@ patch 里不含任何机器路径**。
`mcp__rea__*` 工具面;区别只在"谁准备运行时"。从 1.x 到 2.x 只要两条命令,见
[从 1.x 升级](#从-1x-升级)。
上游现在也能自己注册进 dsh(`rea setup --client dsh`):那基本就是上游维护的 1.x 模式 —— 只写配置,
Node/REA/Ghidra 由你准备。本包是零依赖路线。三条路选一条,不要同时留两行 `serverName: rea`。
## 安装
**前置什么都不用装。** 在干净的 Windows x64 机器上:
@@ -91,7 +120,7 @@ profile 安装插件:
```
dsh plugin --profile <profile> add dsh-plugin-rea
# 或钉住这个版本,而不是跟着 npm latest 走:
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.13
```
> **Electron 宿主(DeepSeek Harness 桌面版)的 profile 由应用独占。** 那种机器上 `dsh plugin --profile desktop …`
@@ -104,11 +133,53 @@ dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
```
如果 Node 由你自备(而不是让 setup 去取),它必须满足 REA 的 engines:
`^22.23.4 || ^24.22.1 || >=26.12.1`(用 `node -v` 核对)。REA 在 6.8.0 抬了这条下限:钉住的 24.22.1
现在是 24 线**最低**可接受版本,6.7.0 能吃下的 24.11–24.21 在这里会被拒。版本偏低不会在安装时失败,
而是到 MCP 行启动时才失败 —— 表现就是"没有反应"。
反过来**先装插件**也可以,但要分两步:发布的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
而那个文件**只有 setup 会创建**——所以在第一次供给之前,该行会以 `MODULE_NOT_FOUND` 退出,也就没有
任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
`node scripts/setup.mjs`;此后 shim 就位,之后每一次「状态缺失」都由它自己发现并在后台补供给。
### 支持哪些 DSH 版本
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`,这就是插件市场读的那句话:有它,卡片显示
**兼容**;没有它,市场只能说*未声明*。这个范围不是猜的——它对应本插件实际核对过的 dsh 发布列表,
`node scripts/host-compat.mjs` 会在两者不一致时让构建失败:
| 宿主版本 | 是否覆盖 |
|---|---|
| `0.1.7-rc.2` … `0.2.1-rc.2`(中间的 alpha 与 rc 全部在内) | 是 |
| `0.3.0` 及以后 | 否 —— 新的小版本线是还没为它核对过这一行的宿主 |
真正被比对过的是「行提供方」:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
暴露的配置面与发布的行完全一致(`serverName`、`transport`、`command`、`args`、`env`、`failOnStartupError`),
并且两个 tag 都有 `dsh.bundle.patch`。范围之外的宿主可能照样能用,只是没有替它声明。
### 机器上已有 REA 就直接用它
2.x 存在的意义是补上机器缺的东西。如果 REA 已经装好——`rea` 在 PATH 上,也就是 1.x 的形态——这一行就**直接跑它**,
和 1.x 一模一样,不再做任何供给:
| 机器上的情况 | 这一行跑什么 |
|---|---|
| `rea` 在 PATH 上,且不比供给的版本旧 | 你自己那份(`rea mcp`),用你的 Node 和你的 PATH |
| `rea` 在 PATH 上,但比供给的版本旧 | 供给出来的 bundle —— 旧的全局 CLI 会把工具数往回退(6.4.0 是 170 个,6.7.0 是 207 个,6.8.0 是 229 个) |
| PATH 上没有 `rea` | 供给出来的 bundle,首次使用时自行供给 |
- `REA_BUNDLE_LAUNCH=auto|system|bundle` 覆盖这个判断(默认 `auto`);`REA_BUNDLE_SYSTEM_REA=<path>` 用来点名一个
不在 PATH 上的可执行文件。
- 探测用的是 CLI 自己的 `--version`,上限 8 秒:Windows 上的 `.cmd` 垫片要 ~2.4 秒才答得出来,所以这个检查每次启动
只做一次,而且只在真的存在 `rea` 时才做。
- `--shim-status` 会把判断结果打出来 —— `launch.mode`、`launch.reason`、`launch.systemRea`,`launch.rejected` 里
写的是每个候选被跳过的原因(`"exit 7"`、`"inside the bundle"`、`"timeout"`)。
- 被委派的子进程带着 `REA_BUNDLE_DELEGATED=1`,所以一个调用本 shim 的 wrapper 不会让它无限委派给自己。
- **Windows 上强制结束 shim 不会带走被委派的 REA**:这里没有 job object,任务管理器或 supervisor 终止 shim 时,
你自己的 `rea` 会继续占着这一行的管道(控制台 Ctrl+C 会发给整个进程组,那种情况下会一起停)。POSIX 上子进程
在同一进程组,随终端一起退出。
## 配置 —— 不需要
发布的那一行**不含任何机器路径**:
@@ -122,7 +193,7 @@ node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
transport: stdio
command: !!js process.execPath
args:
- !!js "(process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : (process.env.USERPROFILE || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
- !!js "(process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : (process.env.USERPROFILE || ((process.env.HOMEDRIVE || '') + (process.env.HOMEPATH || '')) || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
- mcp
env:
ELECTRON_RUN_AS_NODE: '1'
@@ -134,6 +205,10 @@ node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
- **`!!js` 标量必须加引号。** 裸标量里同时出现 `? ` 和 `: ` 时,它就不是一个标量了:YAML 会把它读成显式键值对,于是 loader 拿到的是一个 **mapping** 而不是表达式节点。用 harness 自己的方言实测(`JSON_SCHEMA` + `app.asar` 里的 `!!js` 类型),不加引号时 `args[0]` 变成 `{"[object Object]": "..."}` —— 一个行根本无法用来启动的对象。加引号消除歧义,内部的 JS 字符串改用单引号。
- **必须用正斜杠,不能用反斜杠。** YAML 单引号标量会保留 `\` 字面量,接着 JavaScript 会把它吃掉:`'\plugins'` 不是路径而是 BEL 控制字符,`'\rea-bundle'` 变成回车、`'\bin'` 变成退格 —— 路径被静默摧毁。正斜杠在 Windows 与 POSIX 上都成立。
- **`DSH_HOME` 的兜底是 `<home>/.dsh`,不是 `<home>`。** shim、`setup.mjs`、`bootstrap.ps1` 三者都把未设置的 `DSH_HOME` 解析到 `<home>/.dsh/...`;兜底少这一层,行就会指向供给永远不会写的位置 —— 而在 `DSH_HOME` 恰好被设置的机器上,这个错误完全不可见。
- **主目录兜底与 `os.homedir()` 同序:`USERPROFILE` → `HOMEDRIVE`+`HOMEPATH` → `HOME`。** 中间那一对是
Windows 服务账户拿到的形态(没有 `USERPROFILE`);跳过它,表达式会给这类账户算出 `/.dsh/...` —— 当前盘的根,
而它们恰好读不到那个路径。只有四者全空(连主目录都没有的容器账户)才会落到 `/.dsh/...`,而
`failOnStartupError: false` 会让它保持安静,症状只有「没有 `mcp__rea__*` 工具」。这类账户请显式设置 `DSH_HOME`。
- **`!!js` 表达式里没有 `path`/`require`**:`ctx` 是 loader 作用域,所以 DSH_HOME 的兜底写成纯字符串拼接。
`npm run bundle:audit` 会把前三条钉住:它用「`DSH_HOME` 未设 / 为空 / 已设」三种情形求值这个表达式,并拒绝会被 YAML 读成 mapping 的裸标量。
@@ -159,54 +234,149 @@ node bin/rea-shim.mjs --shim-print-env # MCP 子进程将看到的环境变量
`cordis.patch.yml` 按 id 覆盖即可 —— 那个文件是机器本地的,永不发布。一个 profile 里只允许一行使用
`serverName: rea`。
覆盖是**整块替换** `config`(不做深合并):只写 `config: {env: …}` 会把 `command`、`args`、`serverName`
一起删掉,这一行就起不来了。要么写完整的 config,要么改用 `REA_BUNDLE_LAUNCH=system`(见上),它不需要改 patch。
Ghidra 和 JDK **不是启动时从环境里读的**:`setup.mjs` 把它们的绝对路径记进 `bundle.json`,shim 再从那份
记录导出 `GHIDRA_INSTALL_DIR` / `JAVA_HOME` —— 所以装在非标准路径也能用,不必动这一行。要采纳现成的安装,
把 `GHIDRA_INSTALL_DIR` / `JAVA_HOME` 指向它并跑 `node scripts/setup.mjs --reuse`:manifest 里的探针会验证
候选,不符合就回落到下载,**路径写错会在供给时就报出来**,而不是等到某次工具调用里才失效。
### 只保留一条 REA 注册
上游 REA 6.5.0 起可以自己注册进 DSH:
```
rea setup --client dsh
```
它会把一行 `id: rea-mcp`、`serverName: rea` 写进 `<DSH_HOME>/cordis.patch.yml`。本包的行是
`id: mcp-rea`、同样 `serverName: rea`,而 DSH 对同名条目只保留一个:后加载的那行会以
duplicate server name 报错并加载失败,最终留下哪一行取决于加载顺序。**二选一,不要同时用。**
选哪个?上游那条是纯配置路线:需要 Node 满足 REA engines、`rea-agents` 可达,Ghidra/JDK 由你
自己准备。本包是零依赖路线:首次运行自动取 Node、REA、JDK、Ghidra。两者的 patch 形态与
`mcp__rea__*` 工具面相同。
shim 会在启动时提示冲突 —— `setup.mjs` 会把运行时的 DSH home 记进 `mirror.json`,镜像副本在 harness 抹掉
`DSH_*` 之后就用这条记录去找那份文件;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
## 验证
随便发一个 `mcp__rea__*` 调用 —— `binary_session` 会报引擎各项检查
(`configuration` / `installation` / `headless` / `native_decompiler` / `java`),应当全为 `ok: true`。
然后 `open_binary` 一个 PE、`binary_overview`、`procedure_pseudo_code`。
供给本身结束时也会跑两道探针:`rea doctor --json`(如实报告,并点名未满足项),以及**与 MCP 行完全相同的
shim 启动**。
供给本身结束时跑三道检查:`rea doctor --json`(如实报告,并点名未满足项)、一次 **Ghidra 冒烟导入**
(用 JDK 自带的 `bin/java` 跑 `rea analyze`,导不进就判本次供给失败;`--skip-smoke` 可关),以及
**与 MCP 行完全相同的 shim 启动**。之所以要冒烟:`doctor` 只校验路径、版本与会用哪个 Java,**从不导入
二进制**,桥接或 native addon 坏了,过去只能在第一次 MCP 调用超时时才撞见。
**每个新目标的第一次分析都很慢。** 本机实测:小 PE 36 s,6 MB 的 DLL 64 s —— 每次导入都要起 JVM 并跑
Ghidra 的默认自动分析。如果大文件的第一次 `open_binary` → `list_segments` 超时,那是这个成本、不是引擎坏了:
再调一次,已打开的目标会立刻返回(它保留着自己的临时项目)。
## 实测环境
作者机器,2026-10-13 / 14:
作者机器,2026-10-13 / 14 / 16 / 19 / 23 / 26:
| 组件 | 版本 |
|---|---|
| REA(`rea-agents`) | **6.4.0**(170 个工具) |
| REA(`rea-agents`) | **6.8.0**(229 个工具) |
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**,内含 dsh CLI 0.1.7-rc.2 |
| `@deepseek-ai/dsh-mcp-client`(行 provider) | 随 harness 打包 |
| Ghidra | 12.1.4 |
| JDK | Temurin 21(供给)/ Microsoft OpenJDK 25.0.4(复用) |
| Node.js | 24.21.0(REA engines:`^22.19.0 \|\| ^24.11.0 \|\| >=26.0.0`) |
| JDK | Temurin 21(供给)/ Amazon Corretto 25.0.5(宿主复用) |
| Node.js | **24.22.1**(复用;REA engines:`^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`,ABI 137) |
| 宿主 | Windows 11 x64(build 26200) |
实测跑过:一次干净的 `setup.mjs --reuse` 走到 `setup complete`;一次**不经 PATH 上的 `rea`、完全经由
shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.4.0}`;以及**真实桌面宿主下的完整链路** ——
shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.8.0}`;以及**真实桌面宿主下的完整链路** ——
宿主自己的 Electron 二进制启动 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`,shim 拉起 **bundle 内置**
的 REA,随后一次真实工具调用报出 `path_source: environment`,而那个路径只有该机的 `bundle.json` 知道。
本机**没有**端到端跑过、因此不做承诺的:约 775 MB 的 JDK + Ghidra 下载分支(`--dry-run` 有计划,本机用
`--reuse` 复用了已有安装),以及 macOS/Linux 的首次运行。
2026-10-23 宿主 Node 换到 **24.22.1** 后,同一条链路重验过:vendor 里的 `rea-windows-x64.node` 与两个
`conpty` addon 在 ABI 137 下加载成功;`conpty.node` 的 `binary_overview` 报出 10 个段 / 1178 个函数 /
618 个字符串;`procedure_pseudo_code` 返回了真实反编译结果;vendor 里的 JDK `java -version` 回 `21.0.12.1`。
唯一**第一次没成**的是 `list_segments`:它撞上 Ghidra 冷分析的 64 s 而超时 —— 这正是上面冒烟步与那条说明的由来。
2026-10-26 钉子换到 **REA 6.8.0** 后,同一条链路重验过:`setup.mjs --reuse` 报 `probe cli=6.8.0 tools=229`,
末尾的 Ghidra 冒烟用 26 s 导入 `java.exe`;另起一次独立的 stdio MCP 握手回 `serverInfo {name: rea, version:
6.8.0}`,协议 `2024-11-05`,**229 个工具 / 6 个 prompt**;apktool 仍答 `3.0.3`,`adb` 仍报 37.0.1 (1.0.41),
盘上的 jadx jar 仍是 `6e5eacf5…` —— 正是 6.8.0 要求的那个值。把 6.8.0 自己的发布策略加载执行,得到
`12.1.4 → verified`、`12.1.5 → compatible`、`12.2 → unsupported`,所以 Ghidra 原地不动。
## 边界与注意事项
- REA 在 6.4.0 有 170 个工具,每个会话的工具列表都会变长。
- REA 在 6.8.0 有 229 个工具,每个会话的工具列表都会变长。
- 校验和对得起良心:厂商发布了的就固定并验证;没发布的(jadx-headless-mcp、apktool 包装脚本、
Android platform-tools、滚动的 `latest` 地址)则**实测一次并记录**,之后若漂移是**报告**而不是默默接受。
`bundle.json` 的 `unverifiedChecksums` 会列出处于该状态的组件。
- 某些网络下很慢(这里实测 GitHub release CDN 只有 0.35–0.54 MiB/s,光 Ghidra 就要 25–45 分钟)。
下载是**可续传**的:缓存保留并重新校验,被中断的一轮会继续而不是从头再来。
下载是**可续传**的:两条路线都按盘上已有的字节发 Range 继续;若 `.part` 恰好已经完整,会校验后直接收下
而不是重下。
- **代理**:Node 的全局 `fetch` 不读系统代理,而 .NET/PowerShell 会读。在开着 WinINET 代理的机器上,
同一个地址 `Invoke-WebRequest` 通、Node 却 `ECONNRESET`/超时。setup 自己解析代理
(环境变量优先,Windows 上回退读注册表)并用 CONNECT 隧道,`NO_PROXY` 也照常生效。
- REA 的部分工具会启动进程、写文件、访问网络 —— DSH 的权限策略照常适用。只分析你有权检视的样本。
- MCP 子进程与它拉起的引擎都是以**你的身份**运行的普通进程。DSH 的工具审批管的是模型发起的调用,
管不到这个子进程自己去读、写或发送什么;请把插件当作你安装的第三方代码来对待。
- Linux 上,上游的进程归属检查依赖 PATH 上**兼容 procps 的 `ps`**;Ghidra/JADX 与固件工具会用到它。
极简镜像里缺它只会让这些工具失败,不影响整体安装。
- `scripts/setup.mjs` 为 REA CLI 跑的是带 `--ignore-scripts` 的 `npm install`:官方 tarball 不带
`node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。
`node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。这一步也是**唯一**用不上
本插件自己的路由/代理回退的环节 —— npm 只从环境读 `HTTPS_PROXY`/`HTTP_PROXY` 与
`NPM_CONFIG_REGISTRY`,所以网络需要镜像时请先设其中一个(例如
`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`)。带 `--offline` 时这一步会被**直接拒绝**,
而不是悄悄联网。
- POSIX 上解压有兜底:zip 依次尝试 `unzip` → `python3 -m zipfile` → 已供给 JDK 自带的 `jar`;tar 包依次
尝试 `tar` → `python3`。三者都没有的最小镜像会得到一句明确的「no zip extractor available」,而不是
干巴巴的「extraction failed」。Ghidra 在所有平台都只有 zip,而 `unzip` 并非每个发行版默认安装,
所以这条不是理论问题。
## 更新 —— 三件互相独立的事
> **两条长期规则 —— 本版如此,以后每个 2.x 版本都如此。**
>
> `setup.mjs` 只对齐 **bundle**,不更新**插件包**:它读的是「当前已安装插件」的 manifest。装着 2.0.9 时跑 setup
> 仍旧供给 6.5.0,哪怕已经有更新的一版发布。插件包只能从市场(或 `npm i -g dsh-plugin-rea@<版本>` / `file:` 重装)换,
> **多跑几次 setup 也替代不了那一步**。
>
> **而且没有人会替你跑它。** 市场里的安装/更新只替换包,然后到此为止;第一次的 `setup.mjs`,以及之后每次升级后的
> 那一次,都要你自己从插件目录跑。
>
> 1. **行执行的是本插件启动器的「镜像副本」**:`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 由 setup 复制,
> 市场升级只换包、不换它,所以副本可能一直跑旧代码而**完全没有症状**。从 **2.0.8** 起,副本会与 `mirror.json`
> 记录的启动器比对,不同就一跳转发执行插件里的那份(带 `REA_BUNDLE_SHIM_REEXEC` 守卫)。**2.0.0–2.0.7 的镜像
> 做不到**,所以从它们升级仍需跑一次 `node scripts/setup.mjs`。`--shim-status` 会报 `mirror.stale`,
> `check-updates.ps1` 会提示重跑。
> 2. **镜像的 `scripts/setup.mjs` 同样是副本,而且它自己不会自愈**:从 bundle 根目录跑它,跑的是建 bundle 那一版
> 的 setup,不是你刚装的这一版。要从**插件目录**跑一次来刷新这份副本。从 **2.0.9** 起,它会像 shim 一样剥掉
> `mirror.json` / `bundle.json` 的 UTF-8 BOM —— 带 BOM 的记录以前在 setup 眼里等于「没有记录」,会把上一版
> manifest 又镜一遍。
### 在市场里更新
在哪儿装的就在哪儿更新。更新到 **2.0.5 及以上**是常规操作;而把已装的 2.0.1–2.0.4 通过应用内市场**更新到
2.0.5**,在健康的 profile 上连续失败了三次:
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
这条消息与 tarball 是否完整无关。市场在每次更新时都会问一句:新构建的 `package.json` 是否指向一个真实存在的
入口文件(`dshmarket` 里的 `hasLoadableEntry`);答案是「否」,就把这次更新判为坏构建并回滚。bundle 本来允许
自己没有入口 —— 前提是它**挂载**的某个包有入口;而本包挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
构建,不在 `node_modules` 里,于是这项检查要看的每个位置都是空的。1.x 与 2.0.x 的清单又根本没有声明 `main`,
所以答案必然是「否」,哪怕 profile 一直是好的;紧接着的回滚也因此被报成「无法验证」。实际上 profile 没有被
破坏,只是这次更新没有生效。
2.0.5 声明了 `main: ./index.mjs`。那是一个真实存在、刻意留空的 Cordis 插件:不注册任何行、不启动任何进程、
不碰任何状态(真正起作用的行仍由 `cordis.patch.yml` 插入),它的作用只是让「这个包能不能加载」有一个诚实的
答案,并让 `import 'dsh-plugin-rea'` 能解析。还停在旧版本时,更新到 `>=2.0.5` 即可;若市场在 2.0.5 之前就拒绝,
就直接安装 `[email protected]`(桌面端:在插件管理器里装),不要原地更新。
### 从 1.x 升级
装上 2.x 后,**先跑一次供给**,再重启 dsh:
@@ -222,6 +392,16 @@ node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-boot
工具就回来了。
- **本插件**从不自更新。从市场装的就在市场更新;从 `file:` 检出的就覆盖目录重装。
- **行跑的是本插件启动器的「镜像副本」**:`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 是 setup 复制过去的,
所以市场升级只换包、不换行执行的那个文件 —— 直到再跑一次 setup。从 **2.0.8** 起,镜像会发现自己落后于已安装的
插件,并转发执行插件里的那一份(一跳,带 `REA_BUNDLE_SHIM_REEXEC` 守卫),升级因此能生效。更早的版本里镜像会
静默地停在旧代码;`--shim-status` 现在会报 `mirror.stale`,`check-updates.ps1` 也会提示重跑 setup。**从
2.0.0–2.0.7 升级的话,升级后跑一次 `node scripts/setup.mjs`。**
- **镜像的 `scripts/setup.mjs` 同样是副本。** 市场升级只换包、不换它,所以照着提示去跑
`<DSH_HOME>/plugins/rea-bundle/scripts/setup.mjs` 时,跑的是建 bundle 那一版的 setup,而不是你刚装的这一版。
从 **2.0.9** 起,shim 与 setup 都会剥掉 `mirror.json` / `bundle.json` 的 UTF-8 BOM(此前 setup 侧把带 BOM
的记录读成「没有记录」,会把上一版 manifest 再镜一遍)。**从插件目录**跑一次 `node scripts/setup.mjs`,
这份镜像副本也会一起刷新。
- **从 `2.0.0-bundle.1` 升级**:那一版镜像了 shim 与 `setup.mjs`,却漏了 manifest,也没有留下插件 checkout
的记录,所以它供给出来的 bundle **无法自愈** —— 后台供给会因缺 manifest 而崩溃且完全不可见,而它自己写下的
lock 又会让重试被抑制六小时。请**从插件目录**重跑一次 `node scripts/setup.mjs`:它会重新镜像三个文件,
@@ -231,7 +411,25 @@ node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-boot
- **上游 REA / Ghidra**:改清单里的钉子再重跑 setup。REA 对 Ghidra 有版本闸门(目前 12.1.x),
所以先更新 REA。
`check-updates.ps1`(Windows PowerShell)会一次性报告以上各项的状态。
`check-updates.ps1`(Windows PowerShell)会一次性报告以上各项的状态。`npm run check-updates` 在 Windows
上转发给它(优先 PowerShell 5.1,其次 `pwsh`);在 macOS/Linux 上它会说明这个脚本为何在那里给不出答案,
并列出两条可移植命令 —— `node bin/rea-shim.mjs --shim-status` 与 `node scripts/setup.mjs --dry-run` ——
且以非零码退出,不会报一个自己没查出来的「全绿」。
## 还想用纯配置的 1.x 线?
1.x 是**不带供给**的适配器:一行配置,从 PATH 上启动 `rea mcp`,并假设 Ghidra 由你自己装好。这些情况它是更合适的选择 ——
你已经自己管理 REA 与 Ghidra、不希望插件去下载任何东西、或者想让那一行继续指向你自己的 `rea`:
```
dsh plugin --profile <profile> add [email protected]
# 或钉住分支而不是版本号:
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v1.0.6
```
两条线发布的 patch 形态与 `mcp__rea__*` 工具面完全相同。1.x 维护在
[`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),它自己的安装说明也在那里(Node 满足 REA engines、
`npm i -g rea-agents`、原生分析需要 Ghidra 12.1.x)。
## 许可证
+37 -2
View File
@@ -19,6 +19,41 @@ param([string]$OutDir)
# actually regenerated a screenshot. Resolve the default in the body instead.
if (-not $OutDir -or $OutDir.Trim() -eq '') { $OutDir = $PSScriptRoot }
# 版本号从 manifest 读,不再手写成固定值。2.0.10 就踩过:pin 升到 6.7.0 之后,
# 这个脚本还写着 6.5.0 —— 现有 PNG 没问题(它们是 6.5.0 时生成的),但下一次
# 重绘会把旧版本号带进市场页,而且没有人会注意到,因为图片是二进制。
# 工具数(207)不是 manifest 字段,是人工核对过的常量:升 pin 时一起核一遍。
$reaPin = '6.7.0'
$reaTools = 229
$ghidraPin = '12.1.4'
$nodePin = '24.21.0'
# 截图是一次「实测」,这个日期必须是真的测量日;重绘时改成当天。
$measuredOn = '2026-10-26'
$manifestFile = Join-Path (Split-Path -Parent $PSScriptRoot) 'manifest\bundle.manifest.json'
if (Test-Path $manifestFile) {
try {
$pinDoc = Get-Content $manifestFile -Raw -Encoding UTF8 | ConvertFrom-Json
if ($pinDoc.pins.reaAgents) { $reaPin = [string]$pinDoc.pins.reaAgents }
if ($pinDoc.pins.ghidra) { $ghidraPin = [string]$pinDoc.pins.ghidra }
if ($pinDoc.pins.node) { $nodePin = [string]$pinDoc.pins.node }
} catch {
Write-Warning "manifest 里的钉子读不到($($_.Exception.Message));用脚本内的默认值。"
}
}
# 实测版本未必等于钉:本机若开过 Ghidra 闸门覆盖(scripts/ghidra-gate-override.mjs),
# 跑的是 REA 未验证的那一版。图上写实测的那个,并标出钉值 —— 否则图会谎报「跑过什么」。
$ghidraMeasured = $ghidraPin
$overrideRecord = $null
if ($env:DSH_HOME) {
$overrideFile = Join-Path $env:DSH_HOME 'plugins\rea-bundle\ghidra-gate-override.json'
if (Test-Path $overrideFile) {
try {
$overrideRecord = Get-Content $overrideFile -Raw -Encoding UTF8 | ConvertFrom-Json
if ($overrideRecord.requestedVersion) { $ghidraMeasured = "$($overrideRecord.requestedVersion)(闸门覆盖,钉 $ghidraPin)" }
} catch { $overrideRecord = $null }
}
}
Add-Type -AssemblyName System.Drawing
$W = 1280; $H = 720
@@ -84,7 +119,7 @@ Draw-Text $g '第三方适配 · 自身不做逆向分析 · 自供给 Node/REA/
$rowY = 180; $boxH = 158; $boxW = 250
Draw-Box $g 60 $rowY $boxW $boxH $([System.Drawing.Color]::White) $line
Draw-Text $g 'DeepSeek Harness 会话' $fBox $ink 80 ($rowY + 18)
Draw-Text $g "mcp__rea__* 工具`n(6.4.0 暴露 170+)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
Draw-Text $g "mcp__rea__* 工具`n($reaPin 暴露 $reaTools)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
Draw-Box $g 360 $rowY $boxW $boxH $([System.Drawing.Color]::FromArgb(255,239,246,255)) $accent
Draw-Text $g '官方 MCP 客户端行' $fBox $accent 380 ($rowY + 18)
@@ -112,7 +147,7 @@ Draw-Line $g 358 446 150 446 $muted
Draw-Arrow $g 150 446 150 342 $muted
Draw-Text $g '上游 REA(MIT):github.com/morluto/rea 本插件:github.com/czcdr/dsh-plugin-rea' $fSmall $muted 60 620
Draw-Text $g '实测 REA 6.4.0 + DSH 桌面运行时 0.2.0-rc.2 + Ghidra 12.1.4 + Node 24.21.0(Windows 11 x64,2026-10-14)' $fSmall $muted 60 646
Draw-Text $g "实测 REA $reaPin($reaTools 个工具) + DSH 桌面运行时 0.2.0-rc.2 + Ghidra $ghidraMeasured + Node ${nodePin}(Windows 11 x64,$measuredOn)" $fSmall $muted 60 646
Draw-Text $g 'Ghidra 为 Apache-2.0(NSA);REA 为 MIT。本仓库不分发上游代码。' $fSmall $muted 60 672
Save-Png $bmp $g (Join-Path $OutDir 'screenshot-1.png')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 68 KiB

+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env node
/**
* Facts behind the one-shot "run setup" guide.
*
* The Web GUI needs three things the browser cannot know by itself:
*
* - which version of THIS package is installed (the guide re-arms on upgrade),
* - which REA version the manifest pins,
* - which REA version the machine's bundle actually holds.
*
* The third one is the point. A plugin upgrade replaces the package and stops
* there; `scripts/setup.mjs` is what moves the runtime to the new pin. So the
* honest trigger is not "the page was never shown" but "the bundle on disk does
* not match the pin this package ships". `computeGuideState` answers exactly
* that, and stays a pure function of three values so it can be tested without a
* harness, a browser or a provisioned bundle.
*
* `index.mjs` serves the result as GET /dsh-plugin-rea/guide-state; `client.js`
* renders the guide once per plugin version. This file also runs standalone:
*
* node bin/rea-guide.mjs --json
*/
import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
/** Route served by index.mjs and read by client.js. One definition, two readers. */
export const GUIDE_ROUTE = '/dsh-plugin-rea/guide-state'
/** Package root (the parent of bin/), used to build a copy-pasteable command. */
export const PLUGIN_ROOT = fileURLToPath(new URL('..', import.meta.url))
/**
* Read a JSON file the way the shim and setup.mjs do.
*
* PowerShell 5.1's `>` and `Out-File` write UTF-16 LE by default, so a record a
* user hand-edited can be UTF-16 while a record setup wrote is UTF-8. Treating
* only UTF-8 as valid made a present bundle look absent, which is the same
* silent re-provision the shim's reader had to be fixed for in 2.0.11.
*/
export function readJsonFile(file) {
const bytes = readFileSync(file)
if (bytes.length >= 2) {
const [first, second] = bytes
if (first === 0xff && second === 0xfe) {
return JSON.parse(bytes.subarray(2).toString('utf16le'))
}
if (first === 0xfe && second === 0xff) {
const swapped = Buffer.from(bytes.subarray(2))
swapped.swap16()
return JSON.parse(swapped.toString('utf16le'))
}
}
let text = bytes.toString('utf8')
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
return JSON.parse(text)
}
/** `$DSH_HOME` when set, otherwise the same `<home>/.dsh` fallback the shim uses. */
export function dshHome(env = process.env) {
const explicit = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
return explicit !== '' ? explicit : join(homedir(), '.dsh')
}
/** Where setup.mjs records what it provisioned. */
export function defaultBundlePath(home = dshHome()) {
return join(home, 'plugins', 'rea-bundle', 'bundle.json')
}
/**
* The exact command a user should run, quoted so a path with spaces survives.
*
* `--reuse` is not cosmetic. The guide appears mostly after an upgrade, when the
* runtime is already on the machine: without the flag, setup re-fetches the
* ~570 MB JDK + Ghidra pair even though the machine can answer for both, which is
* both slow and the opposite of what "align the runtime" means. On a first
* install the flag costs nothing — every candidate is probed, and a machine with
* none downloads exactly as before.
*/
export function setupCommandFor(pluginRoot = PLUGIN_ROOT) {
return `node "${join(pluginRoot, 'scripts', 'setup.mjs')}" --reuse`
}
/**
* Pin comparison, mirroring scripts/pin-match.ps1.
*
* A pin that carries a dot is a version: `6.7.0` matches only `6.7.0`.
* A pin that is a bare major is a family: `21` also matches `21.0.12.1+1`.
* The distinction exists because pins like `jdk: 21` legitimately resolve to a
* patched build, while `reaAgents: 6.7.0` must not silently accept `6.7.0-rc.1`
* or `6.5.0`.
*/
export function reaPinMatches(installed, pinned) {
const have = String(installed ?? '').trim()
const want = String(pinned ?? '').trim()
if (have === '' || want === '') return false
if (have === want) return true
if (!want.includes('.')) return have.startsWith(`${want}.`)
return false
}
/**
* Compute the guide's state.
*
* Returned `state`:
* - `missing` no bundle.json — a first install that never ran setup
* - `unreadable` the file exists but cannot be parsed
* - `unknown` parsed, but records no reaAgents version
* - `stale` a REA version is recorded and it is not the pin
* - `ok` recorded version matches the pin
*
* `needsSetup` is true for everything except `ok`: a guide that stays silent on
* an unreadable record is a guide that hides the exact case it exists for.
*/
export function computeGuideState(options = {}) {
const {
pluginVersion = '',
pinnedRea = '',
bundlePath = defaultBundlePath(),
pluginRoot = PLUGIN_ROOT,
} = options
const base = {
pluginVersion: String(pluginVersion),
pinnedRea: String(pinnedRea),
installedRea: null,
provisionedAt: null,
state: 'missing',
needsSetup: true,
bundlePath,
setupCommand: setupCommandFor(pluginRoot),
detail: '',
}
if (!existsSync(bundlePath)) {
return { ...base, detail: 'no bundle.json on this machine' }
}
let record
try {
record = readJsonFile(bundlePath)
} catch (error) {
return {
...base,
state: 'unreadable',
detail: `bundle.json could not be read: ${error && error.message ? error.message : String(error)}`,
}
}
const installed = record && record.versions ? record.versions.reaAgents : record && record.reaAgents
const provisionedAt = record && typeof record.provisionedAt === 'string' ? record.provisionedAt : null
if (typeof installed !== 'string' || installed.trim() === '') {
return {
...base,
provisionedAt,
state: 'unknown',
detail: 'bundle.json records no reaAgents version',
}
}
const matched = reaPinMatches(installed, pinnedRea)
return {
...base,
installedRea: installed,
provisionedAt,
state: matched ? 'ok' : 'stale',
needsSetup: !matched,
detail: matched
? `bundle reaAgents ${installed} matches pin ${pinnedRea}`
: `bundle reaAgents ${installed} does not match pin ${pinnedRea}`,
}
}
/** Read this package's own version and REA pin, for the route and the CLI. */
export function localPackageFacts(pluginRoot = PLUGIN_ROOT) {
const pkg = readJsonFile(join(pluginRoot, 'package.json'))
let pinnedRea = ''
try {
const manifest = readJsonFile(join(pluginRoot, 'manifest', 'bundle.manifest.json'))
pinnedRea = manifest && manifest.pins && typeof manifest.pins.reaAgents === 'string'
? manifest.pins.reaAgents
: ''
} catch {
/* A missing manifest is the audit's problem, not the guide's. */
}
return { pluginVersion: pkg && typeof pkg.version === 'string' ? pkg.version : '', pinnedRea }
}
function main(argv) {
const asJson = argv.includes('--json')
const bundleFlag = argv.indexOf('--bundle')
const facts = localPackageFacts()
const state = computeGuideState({
pluginVersion: facts.pluginVersion,
pinnedRea: facts.pinnedRea,
...(bundleFlag !== -1 && argv[bundleFlag + 1] ? { bundlePath: argv[bundleFlag + 1] } : {}),
})
if (asJson) {
process.stdout.write(`${JSON.stringify(state, null, 2)}\n`)
return
}
process.stdout.write([
`plugin ${state.pluginVersion}`,
`pin ${state.pinnedRea}`,
`bundle ${state.bundlePath}`,
`state ${state.state}${state.needsSetup ? ' (setup needed)' : ''}`,
`reaAgents ${state.installedRea || '-'}`,
state.provisionedAt ? `provisionedAt ${state.provisionedAt}` : '',
'',
state.needsSetup ? `run: ${state.setupCommand}` : 'nothing to do',
].filter((line) => line !== '').join('\n') + '\n')
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
main(process.argv.slice(2))
}
+996 -32
View File
File diff suppressed because it is too large. Load diff
+192 -26
View File
@@ -37,6 +37,21 @@ function Get-NumericVersion([string]$v) {
try { return [version]$core } catch { return $null }
}
# 版本比对规则放在独立文件里:这一行是用户唯一能看到的「pin 变了,该重跑 setup」
# 信号,2.0.10 之前它假绿过(6.5.0 被判成与 pin 6.7.0 一致),所以需要一条不依赖
# 网络、不依赖 profile 的回归测试 —— verify-branch.ps1 直接 dot-source 这个文件。
$pinMatchFile = Join-Path $PSScriptRoot 'scripts\pin-match.ps1'
if (Test-Path $pinMatchFile) {
. $pinMatchFile
} else {
# 目录不完整(旧拷贝、手工搬走了一个文件):退化为精确比对。
# 宁可多报一次「重跑 setup」,也不能再假绿。
function Test-PinMatch {
param([string]$Installed, [string]$Pinned)
return [string]::Equals(([string]$Installed).Trim(), ([string]$Pinned).Trim(), [System.StringComparison]::Ordinal)
}
}
# 判断 Node 版本是否满足 engines 字符串(支持 ^X.Y.Z / ~X.Y.Z / >=X.Y.Z,用 || 分隔)
# 参数收 string 不收 [version]:类型约束在函数体之前生效,调用方一旦传入
# 'abc' / '' / 'v24.21.0' 这类值就会抛 ParameterBindingArgumentTransformationException。
@@ -69,18 +84,53 @@ function Test-NodeEngines([string]$node, [string]$engines) {
}
# ------------------------------------------------------- 定位 REA 与 profile
if (-not $ReaPackageDir) {
$npmRoot = $null
if (Get-Command npm -ErrorAction SilentlyContinue) {
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
}
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
# 2.x 的 REA 在 <bundleRoot>\vendor\node_modules\rea-agents,不在 npm 全局。先读
# bundle.json:把全局路径当默认是 1.x 的答案,在 2.x 机器上会让这一节指向错误的
# 对象("全局包丢了"),并给出错误的升级动作(npm i -g)。
$bundleRootEarly = $env:REA_BUNDLE_ROOT
# Resolve the home unconditionally: the profile section below needs it whether or
# not REA_BUNDLE_ROOT was set, and computing it only inside the fallback branch
# left it $null — `Join-Path $null 'profiles'` throws, so the section degraded to
# "no profile" on exactly the machines that use the override.
$homeEarly = $env:DSH_HOME
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
if (-not $bundleRootEarly) {
$bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
}
$bundleState = $null
$bundleJsonEarly = Join-Path $bundleRootEarly 'bundle.json'
if (Test-Path $bundleJsonEarly) {
try { $bundleState = Get-Content $bundleJsonEarly -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $bundleState = $null }
}
$bundleReaPackageDir = $null
$bundleReaEntry = $null
if ($bundleState -and $bundleState.paths.reaEntry) {
$bundleReaEntry = [string]$bundleState.paths.reaEntry
# <vendor>\node_modules\rea-agents\scripts\rea.mjs -> <vendor>\node_modules\rea-agents
$bundleReaPackageDir = Split-Path -Parent (Split-Path -Parent $bundleReaEntry)
}
if (-not $ReaPackageDir) {
if ($bundleReaPackageDir -and (Test-Path (Join-Path $bundleReaPackageDir 'package.json'))) {
$ReaPackageDir = $bundleReaPackageDir
} else {
$npmRoot = $null
if (Get-Command npm -ErrorAction SilentlyContinue) {
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
}
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
}
}
$reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaPackageDir)
if (-not $ProfileDir) {
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
else {
$profiles = @(Get-ChildItem (Join-Path $env:USERPROFILE '.dsh\profiles') -Directory -ErrorAction SilentlyContinue)
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $env:USERPROFILE '.dsh\profiles\desktop' }
# Same home rule the bundle uses everywhere else: DSH_HOME, then ~/.dsh.
# Hardcoding $env:USERPROFILE here was a second algorithm in the same script,
# so on a custom DSH_HOME the profile section reported "no profile found"
# while the rest of the run had already resolved that home correctly.
$profiles = @(Get-ChildItem (Join-Path $homeEarly 'profiles') -Directory -ErrorAction SilentlyContinue)
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $homeEarly 'profiles\desktop' }
}
}
@@ -94,11 +144,21 @@ if (Test-Path $pkgFile) {
$installed = $pkg.version; $engines = $pkg.engines.node
$binPath = ($pkg.bin.PSObject.Properties | Select-Object -First 1).Value
if ($binPath) { $reaEntry = Join-Path $ReaPackageDir ($binPath -replace '/', '\') }
Ok "本地已安装: $installed"
Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
if ($reaFromBundle) {
Ok "一体包内置(vendored): $installed"
Info "位置: $ReaPackageDir"
Info "入口(bin): $binPath"
} else {
Ok "本地已安装: $installed"
Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
}
if ($engines) { Info "Node 要求: $engines" }
} catch { Bad "package.json 解析失败: $($_.Exception.Message)" }
} else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
} else {
if ($reaFromBundle) { Bad "找不到 $pkgFile —— 内置副本不完整?重跑 setup.mjs" }
elseif ($bundleReaPackageDir) { Bad "找不到 $pkgFile —— 一体包内置副本也缺($bundleReaPackageDir);记录里没有 reaEntry" }
else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
}
$latest = $null
foreach ($r in @($Registry, 'https://registry.npmjs.org')) {
@@ -110,7 +170,11 @@ if ($installed -and $latest) {
$vi = Get-NumericVersion $installed; $vl = Get-NumericVersion $latest
if ($vi -and $vl -and $vl -gt $vi) {
Warn "有新版本:$installed → $latest"
$actions.Add("升级上游 REA: npm i -g rea-agents@$latest (只更新 REA;本插件不会自动更新——从市场装的在市场里更新,file: 装的覆盖目录后重装)")
if ($reaFromBundle) {
$actions.Add("升级一体包内置 REA:把 manifest 的 pins.reaAgents / components.reaAgents.sources.npm.version 改成 $latest,然后重跑 node scripts\setup.mjs(内置副本不会自己更新)")
} else {
$actions.Add("升级上游 REA: npm i -g rea-agents@$latest (只更新 REA;本插件不会自动更新——从市场装的在市场里更新,file: 装的覆盖目录后重装)")
}
} elseif ($vi -and $vl -and $vl -eq $vi -and $installed -ne $latest) {
Warn "同版本号但通道不同:$installed → $latest(预发布/正式)"
} else { Ok '已是最新版本' }
@@ -139,15 +203,30 @@ if (Get-Command node -ErrorAction SilentlyContinue) {
# ------------------------------------------------------------ Ghidra 引擎
Head 'Ghidra 引擎'
$gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User')
# bundle.json 是 2.x 的真源:shim 只在 MCP 子进程里设 GHIDRA_INSTALL_DIR,用户级
# 变量是 1.x 的写法,没手工设过的机器会被误报"未设置"。
$gd = $null
if ($bundleState -and $bundleState.paths.ghidra) {
$bundleGhidra = [string]$bundleState.paths.ghidra
if (Test-Path $bundleGhidra) { $gd = $bundleGhidra }
}
if (-not $gd) { $gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User') }
if (-not $gd) { $gd = $env:GHIDRA_INSTALL_DIR }
if ($gd -and (Test-Path $gd)) {
Ok "GHIDRA_INSTALL_DIR = $gd"
$props = Join-Path $gd 'Ghidra\application.properties'
if (Test-Path $props) {
$v = (Select-String -Path $props -Pattern '^application\.version=' | Select-Object -First 1).Line -replace '^application\.version=', ''
Ok "已安装版本: $v"
if ($v -notmatch '^12\.1\.') { Bad "REA 当前要求 Ghidra 12.1.x,当前为 $v —— 先确认新版 REA 是否放开版本门槛" }
$line = (Select-String -Path $props -Pattern '^application\.version=' | Select-Object -First 1).Line
if (-not $line) {
# "not 12.1.x" for an unreadable version sent the reader looking for a
# newer Ghidra when the file is simply incomplete — a different problem
# with a different fix.
Bad 'Ghidra\application.properties 缺少 application.version 行 —— 文件不完整(重装该组件)'
} else {
$v = $line -replace '^application\.version=', ''
Ok "已安装版本: $v"
if ($v -notmatch '^12\.1\.') { Bad "REA 当前要求 Ghidra 12.1.x,当前为 $v —— 先确认新版 REA 是否放开版本门槛" }
}
} else { Bad '缺少 Ghidra\application.properties —— 目录不完整' }
} else {
Warn "GHIDRA_INSTALL_DIR 未设置或目录不存在(当前值: $gd)"
@@ -162,11 +241,28 @@ try {
# --------------------------------------------------------- REA 引擎自检
Head 'REA 引擎自检(rea doctor)'
$env:GHIDRA_INSTALL_DIR = $gd
# 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
# 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
$reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
# 2.x:优先用 bundle 内置的 CLI 和它自己的 Node。PATH 上的 rea 可能是另一个,
# 甚至完全不在 PATH —— 那会让整段自检被静默跳过。这正是 1.x 口径的残留。
$reaCmd = $null
$reaArgs = @()
if ($bundleReaEntry -and (Test-Path $bundleReaEntry)) {
$bundleNode = $null
if ($bundleState -and $bundleState.paths.node) {
$candidateNode = [string]$bundleState.paths.node
if (Test-Path $candidateNode) { $bundleNode = $candidateNode }
}
if (-not $bundleNode) { $bundleNode = (Get-Command node -ErrorAction SilentlyContinue).Source }
if ($bundleNode) {
$reaCmd = $bundleNode; $reaArgs = @($bundleReaEntry)
Info '使用一体包内置 REA(node <bundle 入口>),不依赖 PATH 上的 rea'
}
}
if (-not $reaCmd) {
# 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
# 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
$reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
}
if (-not $reaCmd) {
if ($reaEntry -and (Test-Path $reaEntry) -and (Get-Command node -ErrorAction SilentlyContinue)) {
$reaCmd = 'node'; $reaArgs = @($reaEntry)
@@ -247,6 +343,18 @@ if (-not (Test-Path $manifestPath)) {
$state = Get-Content $bundleJson -Raw -Encoding UTF8 | ConvertFrom-Json
Ok "已供给:$($state.provisionedAt)"
$drift = 0
$compared = 0
$unrecorded = 0
# 采纳的本机运行时(reused)不按 pin 精确比对,但也不能算进「全绿」。
$adopted = 0
# 本机 Ghidra 闸门覆盖(scripts/ghidra-gate-override.mjs):盘上跑的是 REA 未
# 验证的 Ghidra 版本。这是一次**有记录的、可还原的**偏离,不是「忘了对齐」——
# 不这样标注的话,这行会劝人去重跑 setup,而重跑正好会把覆盖悄悄抹掉。
$ghidraOverride = $null
$overrideFile = Join-Path (Split-Path $bundleJson -Parent) 'ghidra-gate-override.json'
if (Test-Path $overrideFile) {
try { $ghidraOverride = Get-Content $overrideFile -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $ghidraOverride = $null }
}
foreach ($name in $manifest.pins.PSObject.Properties.Name) {
if ($name -eq 'nodeEngines') { continue }
$pinned = [string]$manifest.pins.$name
@@ -254,18 +362,76 @@ if (-not (Test-Path $manifestPath)) {
if ($state.versions -and ($state.versions.PSObject.Properties.Name -contains $name)) {
$installed = [string]$state.versions.$name
}
if (-not $installed) { continue }
# Node 的 pin 是引擎范围,不是版本号;其它组件比前缀即可
$same = $installed.Equals($pinned) -or $installed.StartsWith(($pinned -split '\.')[0] + '.')
if ($same) { Info " $name = $installed(pin $pinned)" }
if ($name -eq 'ghidra' -and $ghidraOverride -and [string]$ghidraOverride.requestedVersion -eq $installed) {
Info " ghidra = $installed(本机覆盖 Ghidra 闸门:REA 未验证这一版;要用 pin 就 --disable 再重跑 setup)"
$adopted++
continue
}
if (-not $installed) {
# 被 --reuse 采纳的组件没有 version 字段(bundle.json 里是 null)。静默
# continue 会让这一项从未被比对,却在下面打印"全部一致"——那是假绿。
$unrecorded++
Warn " $name 未记录版本(reuse 采纳的组件?)—— 无法与 pin $pinned 对账"
continue
}
$compared++
$component = $null
if ($state.components -and ($state.components.PSObject.Properties.Name -contains $name)) {
$component = $state.components.$name
}
# 采纳的本机 Node(setup --reuse)不是 bundle 安装的版本,拿它跟「下载版本」
# 精确比对必然误报;它要满足的是 engines。采纳时 setup 跑过同一份探针,这里
# 再用同一个 Test-NodeEngines 对一次账,而不是把它当成「与 pin 一致」。
if ($name -eq 'node' -and $component -and $component.reused -eq $true) {
$engines = [string]$manifest.pins.nodeEngines
$enginesOk = Test-NodeEngines $installed $engines
if ($enginesOk -eq $true) {
Info " node = $installed(采纳本机运行时;pin $pinned 指下载版本,满足 engines)"
$adopted++
} elseif ($enginesOk -eq $false) {
Warn "node 盘上为 $installed(采纳的本机运行时),不满足 REA engines $engines —— 换一个 Node 或重跑 setup"
$drift++
} else {
Warn "node 盘上为 $installed,但 engines 无法解析($engines)—— 未比对"
$unrecorded++
}
continue
}
# 2.0.10 修掉的假绿:此前对所有组件都比「主版本前缀」,
# "6.5.0".StartsWith("6.") 为真 → bundle 还在 6.5.0 时报「与 pin 6.7.0 一致」,
# 而这行正是 pin 升级后唯一面向用户的「该重跑 setup」信号。apktool 同理
# (3.0.9 对 pin 3.0.3 也假绿)。主版本前缀只对 pin 本身只有主版本号时成立(jdk 21)。
if (Test-PinMatch -Installed $installed -Pinned $pinned) { Info " $name = $installed(pin $pinned)" }
else { Warn "$name 盘上为 $installed,manifest 已钉 $pinned —— 重跑 setup 对齐"; $drift++ }
}
if ($drift -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
if ($drift -eq 0 -and $unrecorded -eq 0 -and $adopted -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
elseif ($drift -eq 0 -and $unrecorded -eq 0) { Warn "共比对 $compared 项:其中 $adopted 项为采纳的本机运行时(按 engines 放行,见上),其余与 pin 一致" }
elseif ($drift -eq 0) { Warn "已比对的 $compared 项与 pin 一致,但 $unrecorded 项没有记录版本(见上)—— 不是全绿" }
if ($state.unverifiedChecksums -and @($state.unverifiedChecksums).Count -gt 0) {
Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
}
$mirrored = Join-Path $bundleRoot 'bin\rea-shim.mjs'
if (Test-Path $mirrored) { Ok 'MCP 行指向的 shim 已就位' }
if (Test-Path $mirrored) {
Ok 'MCP 行指向的 shim 已就位'
# The row launches this COPY, so a plugin upgrade that does not re-run
# setup leaves the old launcher in place — visible only as "the new
# version behaves like the old one". Compare it with the checkout the
# mirror record names.
$mirrorRecord = Join-Path $bundleRoot 'mirror.json'
if (Test-Path $mirrorRecord) {
try {
$record = Get-Content $mirrorRecord -Raw -Encoding UTF8 | ConvertFrom-Json
$pluginShim = if ($record.pluginRoot) { Join-Path $record.pluginRoot 'bin\rea-shim.mjs' } else { $null }
if ($pluginShim -and (Test-Path $pluginShim)) {
$same = (Get-FileHash $mirrored -Algorithm SHA256).Hash -eq (Get-FileHash $pluginShim -Algorithm SHA256).Hash
if ($same) { Ok '镜像 shim 与插件一致(行跑的就是这个版本)' }
else { Warn "镜像 shim 落后于插件($($record.pluginRoot))—— 行仍在跑旧副本;重跑 node scripts/setup.mjs 让它换成新版本" }
} else {
Warn "mirror.json 指向的插件目录已不存在($($record.pluginRoot))—— 无法比对镜像 shim 是否最新"
}
} catch { Warn "mirror.json 解析失败: $($_.Exception.Message)" }
}
}
else { Bad "缺少 MCP 行指向的 shim: $mirrored —— 重跑 setup --reuse" }
# 镜像副本的 setup 用它自己所在的位置推 plugin root,所以 manifest 必须
# 一起镜像。旧版镜像缺这个文件:auto-provision 会以 ENOENT 崩掉且不可见,
+400
View File
@@ -0,0 +1,400 @@
/**
* dsh-plugin-rea — the one-shot "run setup" guide.
*
* Why this exists: a market install or update replaces the package and stops
* there. The runtime (Node, REA, a JDK, Ghidra) lives in the profile's
* `plugins/rea-bundle` and is only moved by `scripts/setup.mjs`, which nobody
* runs for the user. Skipping it fails twice, silently: on a first install the
* MCP row exits MODULE_NOT_FOUND with no tools, and after an upgrade the old
* REA keeps answering. The README says so; nobody reads the README.
*
* So the guide appears once per plugin version — first install, and again after
* every upgrade — states which side is out of date, and shows the exact command
* for THIS install (the path depends on where the market unpacked the package).
* "Later" and "I ran it" both record the version and stop the reminder until
* the next upgrade. It is a notice, not a runner: this file never spawns setup.
*
* Facts come from GET /dsh-plugin-rea/guide-state (index.mjs → bin/rea-guide.mjs),
* which compares the recorded bundle against this package's pin. When that route
* is absent (a host without a webServer, an older build) the guide stays silent
* rather than guessing.
*/
window.__ModuleLoader__.load({
id: "dsh-plugin-rea",
factory: (require) => {
const module = { exports: {} };
const exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const react = require("react");
const h = react.createElement;
const { useEffect, useState } = react;
const name = "dsh-plugin-rea";
const inject = ["slots"];
const ROUTE = "/dsh-plugin-rea/guide-state";
const ACK_KEY = "dsh-plugin-rea.guide.ack";
const TEXT = {
zh: {
badge: "需要一次性配置",
title: "还要跑一次 setup",
leadMissing: "插件装好了,但运行时不在这个包里。",
leadStale: "插件更新了,机器上的运行时还是旧的。",
leadUnknown: "运行时记录不完整,无法确认它和本插件是否一致。",
body: "setup.mjs 负责把 Node、REA、JDK、Ghidra 下载或对齐到本机。市场里的安装和更新只替换插件包,不会替你跑它。",
statusMissing: "未检测到运行时 —— 这是一次全新的安装。",
statusStale: "插件 {v}:运行时里是 REA {installed},本插件钉 {pin}。",
statusUnknown: "运行时记录里没有 REA 版本。",
statusUnreadable: "运行时记录读不出来(bundle.json 无法解析)。",
commandLabel: "在任意目录运行(路径已指向这次安装的插件)",
copy: "复制命令",
copied: "已复制",
copyFailed: "复制失败,请手动选中",
done: "我已运行",
later: "稍后",
hint: "跑完重启 dsh。这一步只对齐运行时,不会更新插件包本身;升级后同样再跑一次。",
noteSmoke: "setup 结束时会用 JDK 自带的 java 跑一次真实分析来验证引擎 —— 那里 30–60 秒没有任何输出是正常的。",
noteLock: "若它报 “another provisioning run holds …”,说明后台已有一个 setup 在跑,等它结束再试即可。",
noteOverride: "若你开过 Ghidra 闸门覆盖(让本机跑 REA 未验证的 Ghidra),setup 会重新供给内置 REA,那层覆盖随之消失 —— 跑完执行 node scripts/ghidra-gate-override.mjs --enable --version 12.2 恢复。",
},
en: {
badge: "ONE-TIME SETUP",
title: "One more step: run setup",
leadMissing: "The plugin is installed. Its runtime is not.",
leadStale: "The plugin was updated; the runtime on this machine is still the old one.",
leadUnknown: "The runtime record is incomplete, so it cannot be compared with this package.",
body: "setup.mjs downloads or aligns Node, REA, a JDK and Ghidra on this machine. A market install or update only replaces the package — nothing runs it for you.",
statusMissing: "No runtime found — this is a first install.",
statusStale: "Plugin {v}: the runtime holds REA {installed}, this package pins {pin}.",
statusUnknown: "The runtime record has no REA version.",
statusUnreadable: "The runtime record cannot be read (bundle.json did not parse).",
commandLabel: "Run it from any directory (the path points at this install)",
copy: "Copy command",
copied: "Copied",
copyFailed: "Copy failed — select it manually",
done: "I ran it",
later: "Later",
hint: "Restart dsh afterwards. This only aligns the runtime; it does not update the plugin package — run it again after the next upgrade.",
noteSmoke: "It finishes by importing a small file to prove the analysis engine works — 30–60 s with no output there is normal.",
noteLock: "If it reports “another provisioning run holds …”, a setup is already running in the background; wait for it.",
noteOverride: "If you had enabled the Ghidra gate override (running a Ghidra REA has not verified), setup re-provisions the bundled REA and the patch goes with it — run node scripts/ghidra-gate-override.mjs --enable --version 12.2 afterwards to restore it.",
},
};
function pickLang() {
try {
const declared = String((document.documentElement && document.documentElement.lang) || "");
if (/^zh/i.test(declared)) return "zh";
if (declared) return "en";
const nav = String((typeof navigator !== "undefined" && navigator.language) || "");
return /^zh/i.test(nav) ? "zh" : "en";
} catch {
return "en";
}
}
function readAck() {
try {
return window.localStorage.getItem(ACK_KEY) || "";
} catch {
return "";
}
}
function writeAck(version) {
try {
window.localStorage.setItem(ACK_KEY, String(version));
} catch {
/* private mode: the guide simply reappears next time */
}
}
async function copyText(text) {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
/* fall through to the selection path */
}
try {
const area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.top = "-9999px";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
const ok = document.execCommand("copy");
document.body.removeChild(area);
return ok;
} catch {
return false;
}
}
function statusLine(state, t) {
if (state.state === "missing") return t.statusMissing;
if (state.state === "stale") {
return t.statusStale
.replace("{v}", String(state.pluginVersion || "?"))
.replace("{installed}", String(state.installedRea || "?"))
.replace("{pin}", String(state.pinnedRea || "?"));
}
if (state.state === "unreadable") return t.statusUnreadable;
return t.statusUnknown;
}
function leadLine(state, t) {
if (state.state === "missing") return t.leadMissing;
if (state.state === "stale") return t.leadStale;
return t.leadUnknown;
}
const maskStyle = {
position: "fixed",
inset: 0,
zIndex: 2400,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
background: "rgba(6, 8, 12, 0.55)",
pointerEvents: "auto",
};
const cardStyle = {
width: "min(620px, 100%)",
maxHeight: "82vh",
overflow: "auto",
padding: "22px 24px 18px",
borderRadius: 14,
border: "1px solid var(--dsw-alias-border-secondary, rgba(255,255,255,0.14))",
background: "var(--dsw-alias-bg-elevated, #171a20)",
color: "var(--dsw-alias-label-primary, #e9e9e9)",
boxShadow: "0 24px 64px rgba(0,0,0,0.45)",
lineHeight: 1.6,
fontSize: 14,
};
const codeStyle = {
margin: "10px 0 0",
padding: "10px 12px",
borderRadius: 9,
background: "var(--dsw-alias-bg-secondary, rgba(255,255,255,0.06))",
border: "1px solid var(--dsw-alias-border-secondary, rgba(255,255,255,0.10))",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
fontSize: 12.5,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
userSelect: "all",
};
const buttonBase = {
appearance: "none",
border: "1px solid var(--dsw-alias-border-secondary, rgba(255,255,255,0.18))",
background: "transparent",
color: "inherit",
borderRadius: 8,
padding: "6px 12px",
fontSize: 13,
cursor: "pointer",
font: "inherit",
};
const primaryButton = Object.assign({}, buttonBase, {
background: "var(--dsw-alias-label-brand, #4f8cff)",
borderColor: "var(--dsw-alias-label-brand, #4f8cff)",
color: "#fff",
fontWeight: 600,
});
function Guide() {
const [state, setState] = useState(null);
const [closed, setClosed] = useState(false);
const [copyLabel, setCopyLabel] = useState("");
useEffect(() => {
let alive = true;
const ctrl = typeof AbortController === "function" ? new AbortController() : null;
const timer = setTimeout(() => {
try {
if (ctrl) ctrl.abort();
} catch {
/* ignore */
}
}, 5000);
fetch(ROUTE, {
signal: ctrl ? ctrl.signal : undefined,
credentials: "same-origin",
headers: { accept: "application/json" },
})
.then((response) => (response && response.ok ? response.json() : null))
.then((data) => {
if (alive && data && typeof data === "object") setState(data);
})
.catch(() => {
/* no route (headless host, older build): stay silent */
})
.finally(() => clearTimeout(timer));
return () => {
alive = false;
clearTimeout(timer);
try {
if (ctrl) ctrl.abort();
} catch {
/* ignore */
}
};
}, []);
const lang = pickLang();
const t = TEXT[lang];
const acked = state ? readAck() === String(state.pluginVersion) : true;
const visible = !!state && !closed && state.needsSetup === true && !acked;
useEffect(() => {
if (!visible) return undefined;
const onKey = (event) => {
if (event && (event.key === "Escape" || event.key === "Esc")) {
writeAck(state.pluginVersion);
setClosed(true);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [visible, state]);
if (!visible) return null;
const dismiss = () => {
writeAck(state.pluginVersion);
setClosed(true);
};
const onCopy = () => {
copyText(String(state.setupCommand || "")).then((ok) => {
setCopyLabel(ok ? t.copied : t.copyFailed);
setTimeout(() => setCopyLabel(""), 2500);
});
};
const node = h(
"div",
{ style: maskStyle, role: "dialog", "aria-modal": "true", "aria-label": t.title },
h(
"div",
{ style: cardStyle },
h(
"div",
{ style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 } },
h(
"span",
{
style: {
fontSize: 11,
letterSpacing: "0.08em",
padding: "2px 8px",
borderRadius: 999,
background: "var(--dsw-alias-bg-secondary, rgba(255,255,255,0.08))",
color: "var(--dsw-alias-label-secondary, #a9a9a9)",
},
},
t.badge,
),
h(
"span",
{ style: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #a9a9a9)" } },
`dsh-plugin-rea ${state.pluginVersion || ""}`,
),
),
h("h2", { style: { margin: "0 0 6px", fontSize: 19, fontWeight: 700 } }, t.title),
h(
"p",
{ style: { margin: "0 0 4px", color: "var(--dsw-alias-label-secondary, #b6b6b6)" } },
leadLine(state, t),
),
h("p", { style: { margin: "0 0 10px" } }, t.body),
h(
"p",
{
style: {
margin: "0 0 6px",
padding: "8px 10px",
borderRadius: 8,
background: "var(--dsw-alias-bg-secondary, rgba(255,255,255,0.05))",
color: "var(--dsw-alias-label-secondary, #c2c2c2)",
fontSize: 13,
},
},
statusLine(state, t),
),
h("div", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #9a9a9a)" } }, t.commandLabel),
h("pre", { style: codeStyle }, h("code", null, String(state.setupCommand || ""))),
h(
"p",
{ style: { margin: "10px 0 8px", fontSize: 12.5, color: "var(--dsw-alias-label-secondary, #9a9a9a)" } },
t.hint,
),
h(
"ul",
{
style: {
margin: "0 0 16px",
paddingLeft: 18,
fontSize: 12.5,
color: "var(--dsw-alias-label-secondary, #9a9a9a)",
},
},
h("li", null, t.noteSmoke),
h("li", null, t.noteLock),
h("li", null, t.noteOverride),
),
h(
"div",
{ style: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" } },
h("button", { type: "button", style: primaryButton, onClick: onCopy }, copyLabel || t.copy),
h("button", { type: "button", style: buttonBase, onClick: dismiss }, t.done),
h("span", { style: { flex: 1 } }),
h("button", { type: "button", style: buttonBase, onClick: dismiss }, t.later),
),
),
);
try {
const dom = require("react-dom");
if (dom && typeof dom.createPortal === "function" && typeof document !== "undefined" && document.body) {
return dom.createPortal(node, document.body);
}
} catch {
/* no react-dom: the overlay still renders in place */
}
return node;
}
function apply(ctx) {
if (!ctx || !ctx.slots || typeof ctx.slots.register !== "function") return;
const mount = () =>
ctx.slots.register(
{ name: "shell.overlay", id: "rea-setup-guide", order: 70 },
() => h(Guide),
);
if (typeof ctx.slots.inject === "function") ctx.slots.inject("shell.overlay", mount);
else mount();
}
exports.name = name;
exports.inject = inject;
exports.apply = apply;
return module.exports;
},
});
+14 -1
View File
@@ -22,6 +22,11 @@
# `<home>/.dsh/plugins/rea-bundle/bin/` while the row looked for it one level
# up, so the row could never start on a machine that merely happened to have
# DSH_HOME unset.
# * The home fallback mirrors Node's own `os.homedir()`: USERPROFILE, then
# HOMEDRIVE+HOMEPATH (the shape a service account gets), then HOME. An
# expression that checked only USERPROFILE and HOME produced `/.dsh/...` —
# the root of the current drive — for a service account, which is neither
# where setup mirrors the shim nor a path anything can read.
#
# One row may use serverName: rea in a profile. To pin a different launch
# command on this machine, override `mcp-rea` from the profile's own
@@ -41,7 +46,7 @@
# args[0] becomes an object the launcher cannot use. Quoting makes the
# scalar unambiguous; the JS string literals inside use single quotes so
# they cannot collide with the YAML ones.
- !!js "(process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : (process.env.USERPROFILE || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
- !!js "(process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : (process.env.USERPROFILE || ((process.env.HOMEDRIVE || '') + (process.env.HOMEPATH || '')) || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
- mcp
env:
# Only meaningful when the harness itself runs on Electron; harmless
@@ -49,3 +54,11 @@
# variable happening to survive the child-env scrub.
ELECTRON_RUN_AS_NODE: '1'
failOnStartupError: false
# The Web GUI's one-shot setup guide. This row loads only the package entry
# (index.mjs), which serves a read-only GET /dsh-plugin-rea/guide-state and
# mounts nothing when the host has no webServer. It does not launch REA, does
# not provision, and never runs setup for the user — the guide only reports
# that the recorded bundle does not match the pin this package ships.
- id: rea-guide
name: 'dsh-plugin-rea'
+42 -6
View File
@@ -31,11 +31,11 @@ Two upstream rules shape the runtime wiring:
## 2. Verified facts this design rests on
Measured on Windows 11 x64, REA 6.4.0, Ghidra 12.1.4, Temurin JDK 25.0.4:
Measured on Windows 11 x64, REA 6.8.0, Ghidra 12.1.4, Temurin JDK 25.0.4:
| Fact | Evidence |
| --- | --- |
| A row can launch REA with **no PATH `rea`** | `node <pkg>/scripts/rea.mjs mcp` answered the MCP `initialize` handshake (`serverInfo.name = rea`, version 6.4.0) and `rea doctor --json` reported 170 tools, `healthy: true` |
| A row can launch REA with **no PATH `rea`** | `node <pkg>/scripts/rea.mjs mcp` answered the MCP `initialize` handshake (`serverInfo.name = rea`, version 6.8.0) and `rea doctor --json` reported 229 tools (measured again after the 6.8.0 pin; 6.7.0 reported 207). `healthy` is `false` on a bundle-only install: 6.8.0 also checks the host's own setup (Hopper, IDA registration, Kuna, `rea setup` skill/registrations), which provisioning deliberately never touches — the row does not require `healthy`, only a CLI that runs and a catalog it can read |
| `!!js` expressions are supported in this profile layer | the existing profile patch already used one; the loader evaluates them |
| A row-level `env:` block reaches the MCP child | `binary_session` reported `registrationEnvironment` = `GHIDRA_INSTALL_DIR`, `JAVA_HOME` |
| No fixed home/layout is promised | the disk layout differs per install; nothing may assume one |
@@ -54,12 +54,25 @@ Three responsibilities, kept strictly apart:
checksums, unpacks, runs the readiness probe, and writes `bundle.json`.
Idempotent and re-runnable; `--reuse` adopts pre-existing installs.
3. **Patch (wiring, published).** `cordis.patch.yml` on `main`, carrying
**no machine path**: the row points at `<plugin>/bin/rea-shim.mjs` and the
shim is responsible for the rest.
**no machine path**: the row computes `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`
at load time from `DSH_HOME` alone, and that mirrored shim is responsible for the
rest. (The checkout's own copy is what a human runs; the row never points at it.)
The published plugin therefore stays portable, while the machine-specific truth
lives in one small JSON file under the DSH home.
A fourth, deliberately passive piece sits beside the three: the **guide**
(`client.js` + `index.mjs` → `bin/rea-guide.mjs`). It exists because the setup
step is mandatory but invisible — a market install or update replaces the
package and stops there, and a skipped setup fails silently in both directions
(no tools on a first install, the old REA after an upgrade). The patch mounts one
extra row for the package entry, which serves a read-only
`GET /dsh-plugin-rea/guide-state`; the client half renders that verdict once per
plugin version and remembers the dismissal in `localStorage`. It is a notice:
it never spawns setup, never downloads, and never writes `bundle.json`. The row
is mounted only when the host has a `webServer`, so headless and ACP hosts load
the plugin exactly as before.
```
<dsh home>/plugins/rea-bundle/
bundle.json # machine-local truth: resolved paths + versions + hashes
@@ -82,7 +95,10 @@ through `failOnStartupError: false` without breaking the harness.
```
dsh starts
└─ MCP row: node <bundle root>/bin/rea-shim.mjs (setup mirrors it there)
├─ bundle.json present & components verify → exec REA (normal path)
├─ mirrored copy differs from the installed plugin → re-exec that copy, once
├─ a working `rea` on PATH is at least as new as the bundle
│ → exec it (the 1.x contract)
├─ bundle.json present & components verify → exec the provisioned REA
├─ missing, auto-provision allowed → start setup detached, print
│ remediation, exit non-zero
└─ missing, auto-provision refused → print exact command, exit
@@ -128,6 +144,26 @@ Two notes on stages that exist because their absence was a bug:
harness's own dialect, `args[0]` became `{"[object Object]": "..."}` and the
row could not launch at all. Any future expression with a ternary needs the
quotes; `audit-manifest.mjs` rejects the bare form.
- **Launch mode (2.0.7).** 2.x exists to supply a runtime a machine lacks, so a
machine that already has one should not be provisioned. Before touching bundle
state the shim asks whether `rea` is on PATH and answers `--version`; if it
does, and its version is not older than `bundle.json`'s `versions.reaAgents`,
the row execs that install exactly as the 1.x row did. The probe is asynchronous
and bounded (8 s: a `.cmd` shim needs ~2.4 s), runs with
`REA_BUNDLE_DELEGATED=1` and `REA_BUNDLE_AUTO_PROVISION=0` so a candidate that
wraps this shim cannot recurse or start an 800 MB run, and kills its whole tree
on timeout. `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the choice; an
unorderable pair (a prerelease on either side) keeps the bundle.
- **Self-healing mirror (2.0.8).** The row launches a COPY, and a market upgrade
runs no setup, so without this the copy keeps executing the code it was made
from — invisibly, because it still works. The copy therefore compares its own
hash with `<mirror.json.pluginRoot>/bin/rea-shim.mjs` and re-execs that file
when they differ: one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`, and only from a
mirror (a checkout ships `cordis.patch.yml`, so it never hops into another
copy). `mirror.json` carries `shimSha256`, `pluginVersion` and `dshHome` —
the last one because the harness strips `DSH_*` from an MCP child, so the
duplicate-registration check would otherwise read `~/.dsh` on a machine with a
custom home. `--shim-status` reports `mirror.stale` instead of hopping.
- **3 (fetch).** Route order is deliberate and environment-dependent: direct
first, then the system proxy, and on Windows the .NET route ahead of both
because it follows the GitHub release-asset redirect that the Node routes
@@ -161,7 +197,7 @@ Windows and is what this bundle targets to complete.
| Component | Needed for | Provisioned as |
| --- | --- | --- |
| Node runtime | running REA at all | reuse a compatible existing node, else portable `node.exe` |
| `rea-agents` | the MCP server itself | vendored copy under `vendor/rea-agents` |
| `rea-agents` | the MCP server itself | vendored copy under `vendor/node_modules/rea-agents` |
| Temurin JDK 21/25 | Ghidra headless + JADX bridge | portable JDK zip (full JDK, not a JRE) |
| Ghidra 12.1.x | native analysis | official release zip, extracted |
| apktool | Android resource decode | `apktool.bat` + jar in one directory |
+200
View File
@@ -0,0 +1,200 @@
# 更新审查:为什么现在不动这些钉子(2026-10-24)
一次针对“能不能把运行时升到最新”的核查。结论写在最前面,证据按下表逐条可复现;
最后是重开这个决定的条件,以及本轮**没有**做的事。
## 0. 结论
| 组件 | 上游最新 | 本机/钉子 | 能否升 | 依据 |
|---|---|---|---|---|
| REA(`rea-agents`) | 6.7.0 | 6.7.0 | 已是最新 | npm `dist-tags.latest = 6.7.0` |
| Node | 24.22.1 | 24.22.1 | 已是最新 | `nodejs.org/dist/index.json`:24 线最高 24.22.1(2026-10-20) |
| apktool | 3.0.3 | 3.0.3 | 已是最新 | GitHub latest = v3.0.3(2026-07-20),jar 15,478,013 B / `dbf930b0…` 一致 |
| platform-tools | 滚动 | 钉了当前字节 | 无需动 | 下载今日 `platform-tools-latest-windows.zip`:8,044,989 B / `45f4d631…` 与钉子逐字节一致 |
| Ghidra | **12.2**(2026-10-22) | 12.1.4 | **不能** | REA 只接受 12.1.x,12.2 判 `unsupported` |
| jadx 桥接 | **0.8.0**(2026-10-19) | 0.7.1 | **不能** | REA 硬校验引擎自报版本必须等于 0.7.1 |
| JDK | 21.0.13+9 / 25.0.5+8 | 21.0.12.1(vendor)/ Corretto 25.0.5(运行) | **Windows 上不能** | 两个 release 都没有任何 Windows 资产 |
**净结论:本轮不移动任何 pin。** 能升的两项会当场打掉整组工具;JDK 在 win32-x64 上没有可指的构建;
其余三项已是上游最新。现有组合实测 207 个工具全绿(见 §3)。
## 1. Ghidra 12.2:被 REA 的发布线挡住
REA 运行时的策略常量:
```
dist/ghidra/GhidraReleasePolicy.js:2 export const SUPPORTED_GHIDRA_VERSION = "12.1.4";
dist/ghidra/GhidraReleasePolicy.js:13 if (raw === SUPPORTED_GHIDRA_VERSION) → verified
else version.major/minor 相同 → compatible
else → unsupported
dist/ghidra/GhidraReleasePolicy.js:54 ghidraReleaseAccepted = status === "verified" || "compatible"
```
把该模块直接加载执行的结果:
```
12.1.4 → verified
12.1.5 → compatible ← 若上游发 12.1.5,会自动放行
12.2 → unsupported ← 12.2.0 同样
12.0.4 → unsupported
```
`GhidraInstallation.js` 在 `:515` 用 `ghidraReleaseAccepted(raw)` 决定 `passed`,`:107/:135-145` 一旦
有 failed check 就返回 `unavailable`。也就是说升 12.2 之后:provider 直接不可用,
`analyze_function` / `procedure_pseudo_code` / `list_segments` / `search_bytes` 等整组消失——不是降级。
插件的 `check-updates.ps1:228` 也钉着 `^12\.1\.`,会同步报“REA 当前要求 Ghidra 12.1.x”。
**代价**:Ghidra 12.1.4 的四个 CVE 继续留在链上(见 §4)。
## 2. jadx 桥接 0.8.0:一条版本等值断言
REA 对 jar 的准入分三层,前两层 0.8.0 都能过,第三层过不去:
```
dist/android/JadxConfiguration.js:224-241 要求 jar 内含 5 个桥接类(0.8.0 全部存在)
dist/android/JadxSession.js:55-57 serverInfo 必须是 rea-jadx-bridge/4(由 REA 自己的桥提供,不在 jar 里)
dist/android/JadxSession.js:59-62 const runtime = await tools.json("rea_jvm_status", {});
if (runtime.engine_reported_version !== JADX_RELEASE.version) throw
`Expected engine 0.7.1; JAR reported …`
dist/android/JadxRelease.js:3 version: "0.7.1"
dist/android/JadxRelease.js:5 sha256: 6e5eacf5…ff75(仅用于 source_revision,不参与准入)
```
实测两个 jar 的 `com/atxx/jhmcp/BuildInfo.class`(`javap -v`,`VERSION` 是 `ConstantValue`,
只有 `-c` 看不到赋值):
```
0.7.1-all.jar → ConstantValue: String 0.7.1
0.8.0-all.jar → ConstantValue: String 0.8.0
```
失败点在 `load_apk`(`JadxSession.js:64`)之前,即 JVM 起 + MCP initialize + 一次工具往返之后,
抛出的是明确错误,不是静默不可用。
另一条有用的事实:两个 jar 只有 `BuildInfo` 与 `JadxSession` 两个类字节不同,
`SessionHolder`、`jadx/api/JadxDecompiler`、`gson/Gson`、MCP `Server` 逐字节相同——
所以坏的纯粹是那一条版本断言,不是 ABI 或编译不兼容。**只要 REA 放开这个常量,0.8.0 是安全的。**
## 3. JDK:上游有新版,Windows 没有构建
`adoptium/temurin21-binaries` 的 `jdk-21.0.13+9`(2026-10-22T08:58Z)共 204 个资产,
**没有任何 `windows` 资产**;`temurin25-binaries` 的 `jdk-25.0.5+8`(147 个资产)同样没有。
| manifest 钉的平台 | 21.0.13+9 是否存在 |
|---|---|
| win32-x64 | **缺失**(`OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip` 不存在) |
| darwin-x64 | `OpenJDK21U-jdk_x64_mac_hotspot_21.0.13_9.tar.gz` 193,957,782 B |
| darwin-arm64 | `OpenJDK21U-jdk_aarch64_mac_hotspot_21.0.13_9.tar.gz` 199,754,598 B |
| linux-x64 | `OpenJDK21U-jdk_x64_linux_hotspot_21.0.13_9.tar.gz` 207,858,352 B |
| linux-arm64 | `OpenJDK21U-jdk_aarch64_linux_hotspot_21.0.13_9.tar.gz` 205,995,218 B |
顺带:`jdk-25.0.5+8` 连 `x64_mac` 的 JDK 资产都没有,darwin-x64 也跟不了 25.x 线。
所以“把 manifest 的 JDK 钉到 21.0.13”这件事在 win32-x64 上**无法执行**;只改另外 4 个平台会制造
平台间版本分裂,而 Windows(本插件的主平台)仍是旧信任库。
REA 侧对 JDK 没有版本钉子:Ghidra 12.1.4 的 `application.properties` 是 `application.java.min=21`、
`max=`(空=无上限),接受逻辑是 `major >= min && (max 为空 || major <= max)`
(`dist/ghidra/GhidraInstallationPolicy.js:74-76`),所以 21.0.13 与今天的 25.0.5 都能过。
`SUPPORTED_GHIDRA_JAVA_MAJOR = 21` 只是缺属性时的兜底常量,不是“必须等于 21”。
## 4. 风险与缓解
**Ghidra 12.1.4 的四个 CVE(NVD 实测编号与分数)**
| CVE | 分数 | 影响 |
|---|---|---|
| CVE-2026-100504 | 7.3 | 反编译器 `leftshift128` 负移位导致栈越界写,可致代码执行 |
| CVE-2026-100505 | 4.8 | `StringManager::getCodepoint` 多字节解码堆越界读(9.2–12.1.4) |
| CVE-2026-100503 | 4.8 | 反编译器 `Funcdata::opInsertAfter` UAF 拒绝服务 |
| CVE-2026-113604 | 6.8 | ELF 加载器 `ElfHeader` 内存耗尽拒绝服务(10.2–12.1.4) |
缓解只能是流程上的:**不要用这套环境分析来源不可信的样本**;根治要等 REA 放开 12.1.x 这条线。
**CVE-2026-91403(9.1,JSSE,eMudhra 中间 CA)**
Oracle 的 Security Alert 把 `21.0.13` 和 `25.0.5` 都列为受影响版本。实测信任库里的 eMudhra 锚:
| 锚(SHA-256 指纹前缀) | Temurin 21.0.12.1 | Temurin 21.0.13 | Corretto 25.0.5(本机运行) |
|---|---|---|---|
| g1 `40:F6:AF:03…` | 有 | **有** | 有 |
| c1 `12:56:09:AA…` | 有 | 无 | 有 |
| c3 `BC:4D:80:9B…` | 有 | 无 | 有 |
| g3 `86:A1:EC:BA…` | 有 | 无 | 有 |
| g2 `1A:A0:C2:70…` | 无 | 无 | 有 |
即:21.0.12.1 → 21.0.13 会移除 c1/c3/g3,**但保留 g1**;本机运行的 Corretto 25.0.5 反而五张全在。
**没有任何一份已发布版本是“干净”的**,所以“升到 21.0.13 就修掉这个 9.1”不成立。
可用的缓解有两条,都要动信任库,需人工确认:
1. 给 REA 单独指定 truststore(`-Djavax.net.ssl.trustStore=<剔除了 emsign 锚的副本>`),不动系统 JDK——
`F:\game\xbox` 同时是别的程序(Minecraft)的运行时,直接删证书会波及它们。
2. 等 Adoptium 发布带 Windows 资产、且移除了全新 eMudhra 锚的下一版,再统一升 manifest。
注:插件自身的下载都是 sha256 固定的,伪造证书无法改写字节,只能误导版本探测;这条风险主要落在
“REA/Java 去访问任意 HTTPS 站点”上,而不是供给链路。
**信任库统计陷阱(记录一下,避免再犯)**:只 grep `emudhra` 会漏掉 Corretto 的 `emsignrootca-*` 别名,
把“5 张锚”读成“0 张”。正确做法是 `(?i)emsign|emudhra`,或直接按 SHA-256 指纹比对。
## 5. 本轮实测:工具面全绿
| 检查 | 结果 |
|---|---|
| Ghidra(`C:\Windows\System32\whoami.exe`) | 9 段 / 341 函数 / 308 字符串;`entry` 反编译出 `FUN_1400021b4(); FUN_140001af0();` |
| Ghidra 冷启动 | CLI `rea analyze` 52 s;同进程内后续 `list_segments` 立即返回 |
| apktool | `inspect_apktool_client` → 3.0.3;wrapper 直接跑 `--version` → 3.0.3(280 ms) |
| adb | `inspect_adb_client` → 37.0.1-15733141;`adb version` → 1.0.41 |
| jadx 桥接 | 0.7.1-all.jar 在 JDK 25.0.5 下正常启动(输出 usage) |
| managed(mscorlib.dll) | 20,899 行元数据,MVID 2acc5ce0-… |
| sqlite | 3.53.4,schema + 行读取正常 |
| 仓库门禁 | `audit-manifest` 24/24 hash-pinned clean;单测 85 过 / 3 平台跳过 / 0 失败;`verify-branch.ps1` ALL CHECKS PASSED |
不可用的工具族都是“需要外部工具”的那批,与版本无关:`inspect_with_objdump`(本机无 GNU objdump)、
`inspect_pcap_*`(无 Wireshark/tshark)、`extract_firmware`(无 binwalk/unblob)、`inspect_wasm_artifact`
(无 WABT)、`recover_javascript_sources`(无 Wakaru)、`inspect_binary_layout`(无 pwntools)、
`inspect_evm_interface`、JEB 家族、macOS 专属项。
## 6. 什么时候重开这个决定
1. **Ghidra 12.2**:`rea-agents` 新版本把 `SUPPORTED_GHIDRA_VERSION` 提到 12.2(或新增 12.2 兼容分支)
→ 同步放宽 `check-updates.ps1:228` 的 `^12\.1\.`,改 manifest 的 `ghidra` 源,重跑
`bundle:audit` + `bundle:test` + `setup --reuse`(含末尾 smoke)。
2. **jadx 0.8.0**:`JadxRelease.js` 的 `version` / `sha256` 变化 → 换 jar、改 manifest 的
`jadxHeadlessMcp` 与 `probe`、重跑一次真实 APK 分析。
3. **JDK**:Adoptium 发布带 Windows 资产的 21.0.13+(或更新的 LTS 补丁)→ 一次改 5 个平台的
url/size/sha256,跑 audit + test + `setup --reuse --skip-smoke`,再用 vendor JDK 跑一次 smoke。
4. 任何一项落地时,都保持“先验证再切换”:本机当前的运行组合随时可回退。
## 7. 本轮的已知弱点(未修,留待决定)
- `manifest/bundle.manifest.json:293` 的 jadx probe 只检查 jar 路径存在(`{"file": …}` 没有 `expect`),
`setup.mjs` 的 `runProbe` 对 file probe 只做 `existsSync`。换一个同名但版本不同的 jar,setup 会静默放行,
直到第一次 Android 调用才报 `Expected engine 0.7.1`。
- JDK probe 只要求 `java --list-modules` 含 `jdk.compiler`,一个 JDK 11 也能通过(`setup.mjs:1116-1118`
的注释正描述了这类事故)。Ghidra 的 `application.java.min` 会在运行期兜住,但那时已经装完了。
- `setup.mjs:1147-1155` 的“已供给”分支只跑组件 probe,不比对落盘文件哈希,所以装在机器上的
`vendor/apktool/apktool.bat` 一度是旧 master 副本(2,348 B / `9440ec9d…`)而 manifest 钉的是 tag 副本
(2,386 B / `d81a91bd…`)。本轮已把该文件对齐到钉子(备份 `apktool.bat.bak-2348`),两者行为实测一致。
## 8. 本轮没有做的事
- 没有移动任何 pin(`manifest`/`package.json` 未改)。
- 没有提交、没有推送;工作树保持干净(本文件与 README 的说明除外)。
- 没有重启 dsh / REA:运行中的 MCP 进程与 Ghidra 会话不受影响。
## 附:外部评审文档的核对结果
| 说法 | 判定 |
|---|---|
| Ghidra 12.2 被 REA 的 12.1.x 硬线挡住 | ✅ |
| 不存在 12.1.5 | ✅(Ghidra release 列表里 12.1.4 之后直接是 12.2) |
| jadx 0.8.0“不会被硬拒绝”“大概率能用”“失败是静默的” | ❌ 硬拒绝、明确报错,见 §2 |
| 升 JDK 21.0.13 能修掉 CVE-2026-91403 | ❌ 只移除 4 张锚里的 3 张,g1 仍在;Oracle 把 21.0.13 列为受影响 |
| 改 manifest 的六个平台条目即可升 JDK | ❌ 只有 5 个平台条目;win32-x64 无资产 |
| `SUPPORTED_GHIDRA_JAVA_MAJOR = 21` 意味着“只接受主版本 21” | ❌ 实际是 `>= application.java.min`(25.0.5 今天就被接受) |
| apktool 3.0.3 已是上游最新、哈希与官方一致 | ✅ |
| platform-tools 无钉子、始终取最新 | ⚠️ URL 是滚动的,但 manifest 钉了当前字节的 sha256(今日仍一致) |
| jadx 0.7.1 受 CVE-2022-39259 / CVE-2024-32653 影响 | ❌ 类目错误:0.7.1 是 jadx-headless-mcp 的版本号,不是 jadx-core 的 |
+301
View File
@@ -0,0 +1,301 @@
# 更新落地:REA 6.7.0 → 6.8.0(2026-10-26)
上游在 2026-10-25 放出 `[email protected]`(npm `latest`)。本轮按以往的做法核对全部钉子、只升级能升的那一个,
并在真机上把整条链路重跑一遍。**结论:只动 REA 一个 pin,但它连带改了 Node 的 engines 下限,所以三处措辞一起动;
Ghidra / jadx / JDK / apktool / platform-tools / Node 全部原地不动。**
---
## 0. 结论
| 组件 | 上游最新 | 本轮钉子 | 动作 | 依据 |
|---|---|---|---|---|
| REA(`rea-agents`) | **6.8.0**(2026-10-25) | **6.8.0** | **升** | npm `dist-tags.latest = 6.8.0`,`time.modified 2026-10-25T09:07:31Z` |
| Node | LTS 24.22.1(2026-10-20);current 26.12.1(非 LTS) | 24.22.1 | 不动 | 24.22.1 仍是 24 线最新 LTS,且**正好等于 6.8.0 的 24 线下限** |
| Ghidra | **12.2**(2026-10-22) | 12.1.4 | **不动**(本机可覆盖,见 §9) | 6.8.0 的 `SUPPORTED_GHIDRA_VERSION` 仍是 `12.1.4`;12.2 判 `unsupported`,Ghidra 系工具整组消失 |
| jadx 桥接 | **0.8.0**(2026-10-19) | 0.7.1 | **不动** | 6.8.0 的 `JADX_RELEASE` 仍是 0.7.1,`sha256` 与盘上 jar 逐字节相同 |
| JDK | 21.0.13+9(无 Windows 资产)/ 25.0.4.1+1(Windows 线) | 21.0.12.1 | 不动 | Adoptium `/v3/assets/latest/21/...os=windows` 仍返回 21.0.12.1+1(`updated 2026-08-21`) |
| apktool | 3.0.3(2026-07-20) | 3.0.3 | 不动 | GitHub latest 未变;jar 15,478,013 B / `dbf930b0…` 现场复核 |
| platform-tools | 滚动 | 37.0.1 | 不动 | 现场 `adb version` = 37.0.1 (1.0.41),`source.properties` `Pkg.Revision=37.0.1` |
本轮唯一变化的 pin:`reaAgents 6.7.0 → 6.8.0`。唯一连带变化:`nodeEngines` 与 node probe。
**例外:Ghidra 12.2 在本机是「有记录的本机覆盖」,不是 pin 变更**(§9)。四项 CVE 的修复版就是 12.2,实测桥接可用、
分析结果与 12.1.4 逐字节一致,所以给了 `scripts/ghidra-gate-override.mjs` 这条显式通道;`setup.mjs` 仍按 12.1.4 供给。
---
## 1. 6.8.0 是什么
不是修 bug 的补丁版,**是工具扩容**:
| 项 | 6.7.0 | 6.8.0 |
|---|---|---|
| tarball 条目 | 1775 | **2398** |
| `node_modules` 条目 | 0 | 0(仍由 `npm install --ignore-scripts` 解闭包) |
| 解包体积 | — | 49,251,336 B(压缩包 9,944,902 B) |
| MCP 工具 | 207 | **229**(+22) |
| CLI 命令 / prompt | — | 175 / 6 |
| catalog 记录 | 无 | `dist/mcp-catalog.json`(含 `identity.counts` 与三个 digest) |
| engines.node | `^22.19.0 \|\| ^24.11.0 \|\| >=26.0.0` | **`^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`** |
| 依赖闭包 | 42 条 | 44 条(+`@bufbuild/protobuf 2.16.0`、+`@msgpack/msgpack 3.1.3`) |
文件级差异(按路径比对):**+843 / −225**。新增族从目录名就能看出来:`dist/apkSignature/*`(APK 签名与证书)、
`dist/application/binaryDiagnostics/*`(PE 证书/导入/加载配置、ELF notes、内核模块、devicetree、Apple IPS、
字节熵)、`inspect_model_artifact`(GGUF v2/v3、safetensors 声明)、`inspect_gpu_code_objects`(CUDA fatbin /
CUBIN / PTX / HIP bundle 的静态解析)。后两者**不需要外部引擎**(描述里明写 "without loading models or
executing code" / "No GPU tools"),所以 `notProvisionable` 不用新增条目。
catalog 自带的三个 digest(可用来判定「装的是不是同一份工具表」):
```
tools_sha256 a98940ba69acd9c1781d180cee362d4ddab18f77eefdd3e3216f10e1bf98e5e9
prompts_sha256 9609379442110570cd52fdea61a777242350873bc28958f54827ca3590e680b7
combined_sha256 fe938a7c6eba3763b22ae2022de76dc4a7812d35d86f1d4efd36b9962b04da0b
```
---
## 2. 为什么只有 REA 动了:两道闸门实测
把 6.8.0 的策略模块直接加载执行(不是读代码推断):
```
dist/ghidra/GhidraReleasePolicy.js
12.1.4 → verified
12.1.5 → compatible (这个 release 并不存在,12.1.x 最后一版就是 12.1.4)
12.2 → unsupported
12.2.0 → unsupported
dist/android/JadxRelease.js
JADX_RELEASE = { version: "0.7.1",
revision: "5844800a486d2248fa949b74c9896285f5b54de2",
sha256: "6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75" }
↑ 与盘上 vendor/jadx/jadx-headless-mcp-0.7.1-all.jar 的实测哈希一致
```
也就是说:**6.8.0 没有放宽任何一条版本线**。Ghidra 12.2 会照旧让整个 Ghidra 族消失,jadx 0.8.0 会照旧在
`load_apk` 之前抛 `Expected engine 0.7.1`。这两项维持 10-24 那轮的决定。
---
## 3. engines 抬升:一处声明,三处执行
REA 抬了下限,本包有三处必须同时跟上,否则会出现「文档说一套、reuse 路径执行另一套」:
| 位置 | 值 | 谁在校验 |
|---|---|---|
| `manifest.pins.nodeEngines` | `^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1` | `audit-manifest.mjs` §12 与 `package.json engines.node` 逐字比对 |
| `package.json engines.node` | 同上 | npm / DSH 市场 |
| `components.node.probe.expect.regex` | 见下 | setup 的 reuse 路径(把一个 24.11 的 Node 判成可用就是静默降级) |
probe 只有一条正则、没有版本比较器,所以写成一条交替:
```
^v(22\.23\.(?:[4-9]|[1-9][0-9]+) # 22.23.4 … 22.23.x
|22\.(?:2[4-9]|[3-9][0-9])\.\d+ # 22.24+ … 22.9x
|24\.22\.(?:[1-9]|[1-9][0-9]+) # 24.22.1 … 24.22.x
|24\.(?:2[3-9]|[3-9][0-9])\.\d+ # 24.23+ … 24.9x
|26\.12\.(?:[1-9]|[1-9][0-9]+) # 26.12.1 … 26.12.x
|26\.(?:1[3-9]|[2-9][0-9])\.\d+ # 26.13+ … 26.9x
|2[7-9]\.\d+\.\d+|[3-9][0-9]\.\d+\.\d+)
```
用 24 组版本正反验证(正例 12 / 反例 12,0 处不符):
```
接受 v22.23.4 v22.23.10 v22.24.0 v22.30.1 v24.22.1 v24.22.9 v24.22.12
v24.23.0 v26.12.1 v26.12.15 v26.13.0 v26.20.0 v27.0.0 v30.1.2
拒绝 v22.19.0 v22.23.3 v24.11.0 v24.21.0 v24.22.0 v25.0.0 v26.0.0
v26.12.0 v20.19.0 v18.20.0
```
**用户可见的后果**:6.7.0 时代合法的 Node 22.19–22.23.3 与 24.11–24.22.0,现在会被 reuse 路径拒绝。
钉住的 24.22.1 现在是 24 线**既最新、又最低**可接受的那一个 —— 这种「钉值同时是下限」的情况本包第一次遇到。
---
## 4. 实测记录(Windows 11 x64)
`node scripts/setup.mjs --reuse`:
```
[skip] node — reusing existing node from PATH: F:\jiuguan\node.exe
[skip] jdk — reusing existing jdk from JAVA_HOME: F:\game\xbox\
[skip] ghidra — reusing existing ghidra from GHIDRA_INSTALL_DIR: D:\ghidra\ghidra_12.1.4_PUBLIC
[skip] apktool — already provisioned (3.0.3)
[skip] jadxHeadlessMcp — already provisioned (0.7.1)
[skip] androidPlatformTools — already provisioned (latest (37.0.1))
[ok ] reaAgents — …\vendor\node_modules\rea-agents
[ok ] probe — cli=6.8.0 healthy=false tools=229 unmet=10
[ok ] smoke — imported java.exe in 26s via ghidra
[ok ] shim — resolved to REA 6.8.0
setup complete.
```
`healthy=false` 与 `unmet=10` 是**预期**:Hopper / IDA / Kuna / 五个客户端的 registration / skill 都属「调用方自备」,
供给刻意不碰(与 6.7.0 时同样的 10 条)。
独立 stdio MCP 握手(不经 PATH 的 `rea`):
```
initialize → serverInfo {name: rea, version: 6.8.0}, protocol 2024-11-05
tools/list → 229, nextCursor null
prompts/list → 6
```
`rea doctor --json` 的 identity 段与 tarball 里的 catalog 一致:
```
cli_package_version 6.8.0 · expected_skill_version 49 · sdk 2.3.1
catalog.counts {cli_commands: 175, mcp_tools: 229, mcp_prompts: 6}
```
各引擎现场复核:`java --version` = openjdk 25.0.5(宿主 Corretto);`java -jar vendor/apktool/apktool.jar --version`
= 3.0.3;`vendor/platform-tools/adb.exe version` = 1.0.41 / 37.0.1;`vendor/jadx/jadx-headless-mcp-0.7.1-all.jar`
= 42,619,858 B / `6e5eacf5…`。
门禁:单测 **88(85 过 / 3 平台跳过 / 0 失败)**、`audit-manifest` **24/24 hash-pinned clean**、
`verify-branch.ps1` **ALL CHECKS PASSED**、`host-compat` 10 covered / 5 outside。
---
## 5. 供给过程里的一个坑(值得写进排障)
第一次 `setup --reuse` 在 reaAgents 这一步失败:
```
[FAIL] reaAgents — npm install failed (4294963219): npm error network … ECONNRESET
```
npm 日志显示 `rea-agents-6.8.0.tgz` 取包时 `read ECONNRESET`(`errno -4077`),reify 回滚后 probe 仍读 6.7.0 /
207 工具 —— 读起来像「升级根本没生效」。原因不是哈希、不是权限:**setup 的 npm 子进程不继承本脚本自己的代理
路由**,而直连 registry 的 tarball 在这条网络上是断的(同一个 URL 用 `Invoke-WebRequest` 却能下完,所以只看
浏览器/`curl` 会误判)。
解法:给 setup 的进程环境加代理后重跑即可。
```powershell
$env:HTTPS_PROXY="http://127.0.0.1:7890"; $env:HTTP_PROXY="http://127.0.0.1:7890"
node scripts/setup.mjs --reuse
```
---
## 6. 仍不能升的四项(与 10-24 结论一致)
1. **Ghidra 12.2**:`SUPPORTED_GHIDRA_VERSION = "12.1.4"` 未变;跨 minor 即 `unsupported`,Ghidra 族工具整组不可用。
—— 但这条闸门**不等于 12.2 坏了**:实际测下来它可用,见 §9。
2. **jadx 0.8.0**:`JADX_RELEASE.version` 仍是 0.7.1,`JadxSession` 在其自报版本不等时立刻抛错。
3. **JDK 21.0.13**:Adoptium 的 Windows 资产仍停在 21.0.12.1+1;且 21.0.13 只移除 4 个 eMudhra 锚里的 3 个。
4. **Node 26**:`26.12.1` 是本轮唯一的 26 线新版本,但它是 current 而非 LTS;本包钉 LTS,且 24.22.1 已满足
6.8.0 的 `^24.22.1`。
---
## 7. 重开条件
| 触发 | 动作 |
|---|---|
| REA 把 `SUPPORTED_GHIDRA_VERSION` 提到 `12.2.x` | 换 Ghidra 源(一个 zip 通吃三平台),重跑冒烟(首次导入 26–64 s) |
| REA 把 `JADX_RELEASE` 提到 0.8.0 | 换 jar 的 url/size/sha256(vendor 侧 sha256 可自算),并确认桥接类仍能加载 |
| Adoptium 为 21 线发布 Windows 资产 | 换 5 条 jdk 源(win32-x64 / darwin-x64 / darwin-arm64 / linux-x64 / linux-arm64) |
| Node 26 转为 LTS | 重新评估是否把 `pins.node` 抬到 26(engines 已允许 `>=26.12.1`) |
---
## 8. 本轮没做的事
- **没有 npm publish、没有 GitHub Release**:`package.json` 到 2.0.13,仓库与 tag 先行,npm `latest` 仍是 2.0.12。
- **没有动 Ghidra / jadx / JDK / apktool / platform-tools 的任何一个字节**。
- **没有清理 `C:\Users\lenovo\.dsh`** 下的旧 bundle(本机 `DSH_HOME=D:\dsh-home`,那份是历史遗留,与当前宿主无关)。
- **没有跑 macOS / Linux 的首次供给分支**(本机只有 Windows)。
---
## 9. 能不能升到 Ghidra 12.2(这四个 CVE 的修复版):能,但要覆盖上游闸门
用户问的是「这些不是漏洞吗,能不能升到最新版、同时保证 REA 能用」。答案分两半:**「升」可以,「默认升」不行**,
因为挡住 12.2 的不是兼容性,是 REA 自己的一条版本等值判定。
### 9.1 试了什么
```
下载 ghidra_12.2_PUBLIC_20261016.zip 569,712,708 B
sha256 2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67 ← 与 release body 一致
解包 application.version=12.2 · application.java.min=25 · analyzeHeadless.bat / win_x86_64 decompile.exe 均在
```
只改了 **vendored 副本**里那三处常量(`dist/ghidra/GhidraReleasePolicy.js`、`dist/cli-runtime/KunaCommand-*.js`、
`dist/mcp/binary-*.js`,都是 `SUPPORTED_GHIDRA_VERSION = "12.1.4"`),把值换成 `12.2`。
### 9.2 结果
| 检查 | 12.1.4(pin) | 12.2(覆盖后) |
|---|---|---|
| `rea doctor` ghidra-version | `12.1.4` ok | `12.2; compatible 12.2.x release` ok |
| `rea doctor` ghidra-java | `25.0.5 … accepted a 64-bit full JDK 21 or newer` | `25.0.5 … accepted a 64-bit full JDK 25 or newer` |
| `rea analyze whoami.exe` | 52 s · 9 段 / 341 函数 / 308 字符串 | 52 s · 9 段 / 341 函数 / 308 字符串 |
| `list_segments`(MCP,经 shim) | 基准 | **逐字节相同** |
| `list_procedures`(MCP) | 基准 | **逐字节相同**(74,483 字符) |
| `procedure_pseudo_code("entry")` | `FUN_1400021b4(); FUN_140001af0();` | **逐字节相同**(101 字符) |
| `provider.version` | `12.1.4` | `12.2` |
即:**段表、函数表、入口伪代码三样在两个版本上完全一致**,桥接也没有出现 API 漂移的编译/运行错误。
### 9.3 所以怎么用
```powershell
node scripts/ghidra-gate-override.mjs --status # 现在是什么状态
node scripts/ghidra-gate-override.mjs --enable --version 12.2 # 打补丁 + 记录
node scripts/ghidra-gate-override.mjs --disable # 还原成上游闸门
```
- 状态写进 `<bundle>/ghidra-gate-override.json`(原值、目标值、改了哪几个文件、何时启用)。
- **重跑 `setup.mjs` 会重新供给 rea-agents,补丁随之消失** —— 这是刻意的:选错方向的默认值应该是「回到钉住的
已验证组合」,而不是「悄悄继续跑未验证的版本」。
- `check-updates.ps1` 认得这份记录:盘上是 12.2 时它打印「本机覆盖 Ghidra 闸门」,不再劝你「重跑 setup 对齐」。
- 12.2 要求 **JDK ≥ 25**(`application.java.min=25`)。本机 `JAVA_HOME=F:\game\xbox` 是 Corretto **25.0.5**,
正好满足;而本包钉的 vendor JDK 是 **21**,所以「干净机器 + 12.2」还得把 JDK 钉到 25
(Adoptium 25 线 Windows 资产是 25.0.4.1+1)。
### 9.4 没被验证的部分(说清楚)
只测了 PE 的 `open_binary` / `list_segments` / `list_procedures` / `procedure_pseudo_code` / `analyze`。
**没测**:ELF / macOS / COM / DOL / raw 这些走各自 `ReaGhidraPrepare*` 的路径、PSP 扩展、
值流(`trace_native_values`)、decompiler hook(`ReaGhidraNoReturnFix`、`ReaGhidraMipsSoftFloat`)的边角。
上游没有验证 12.2,这些边角现在记在使用者名下 —— 这正是上游闸门存在的理由,覆盖它就得承担它。
---
## 10. CVE-2026-91403(JSSE / eMudhra)为什么不靠「升级」解决
- **没有已发布的 JDK 移除全部 5 张 eMudhra 锚**:Temurin 21.0.13 只掉了 c1/c3/g3(保留 g1),Corretto 25.0.5 五张全在。
所以「升到某个版本就干净」在任何厂商都不成立。
- **这条链上没有 Java 侧出站 TLS**:REA 本体是 Node(用 Node 自带的 CA 集,不用 Java truststore);Ghidra 是本地
headless,jadx 桥接也是本地进程。Java truststore 里那几张锚在这条链上**没有被使用的调用点**。
- 真要清理,只能给 REA 单独做一份剔除了 emsign 锚的 truststore 并通过 JVM 参数指过去;那属于硬化动作,
不改变本链路的实际暴露面。本轮没有做。
---
## 11. jadx 0.8.0 为什么不在这次
它没有对应的 CVE 修复诉求(用户的问题是关于漏洞的),而 REA 对它的要求是**精确等于 0.7.1 + 该 jar 的 sha256**;
0.8.0 会在 `load_apk` 之前抛 `Expected engine 0.7.1`。要升它同样得覆盖 `JADX_RELEASE`,风险与本轮无关,暂不动。
---
## 附:WSL 侧核对
用户的会话标题带「WSL 环境更新」,所以顺带查了一遍:
| 项 | 实测 | 判断 |
|---|---|---|
| WSL 发行版 | Ubuntu 22.04.5 LTS(WSL2,内核 6.18.54.1) | 与本次更新无关 |
| WSL 原生 Node | `/usr/bin/node` = **v22.23.4** | 正好等于 6.8.0 的 22 线下限(旧 engines 的 `^22.19.0` 也吃它)→ 无可升级项 |
| WSL 侧 `~/.dsh` | 不存在 | 没有第二份 bundle 要供 |
| WSL 里的 `dsh` | `/mnt/d/dev/npm-global/dsh`(Windows 侧实体) | 用的仍是 `D:\dsh-home` 那一份 → 更新一处即覆盖两边 |
结论:**WSL 侧不需要单独更新**,REA 6.8.0 通过共享的 `D:\dsh-home\plugins\rea-bundle` 同时生效。
-18
View File
@@ -1,18 +0,0 @@
# macOS / Linux variant of the row.
#
# `npm i -g rea-agents` installs a `rea` executable on PATH, so no shell wrapper
# is needed here (on Windows the shim is a .cmd/.ps1 file, which is why the
# default cordis.patch.yml goes through cmd.exe).
#
# Copy the block below into cordis.patch.yml, replacing the Windows row.
- insert:
- id: mcp-rea
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: rea
transport: stdio
command: 'rea'
args:
- 'mcp'
failOnStartupError: false
-42
View File
@@ -1,42 +0,0 @@
# Windows variant that also pins the analysis-engine environment for the MCP child.
#
# Use this when GHIDRA_INSTALL_DIR is NOT set as an OS-level environment variable
# the harness can inherit — for example after setting it up just now, without
# restarting the harness.
#
# The value below is a placeholder in angle brackets, not a real directory:
# replace it with your own, or use the `!!js` form to read the variable the
# harness already has. REA reads only GHIDRA_INSTALL_DIR; `java` comes from PATH
# or JAVA_HOME.
#
# Copy the block into cordis.patch.yml, or — better — keep it out of the plugin
# entirely and override the row from your profile's own cordis.patch.yml:
#
# - id: mcp-rea
# config:
# env:
# GHIDRA_INSTALL_DIR: '<your Ghidra directory>'
#
# A profile-level override is machine-local and is never published.
- insert:
- id: mcp-rea
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: rea
transport: stdio
command: 'cmd.exe'
args:
- '/c'
- 'rea'
- 'mcp'
env:
# Replace with your own Ghidra 12.1.x extraction directory.
# Leaving the placeholder in place is harmless: REA then reports the
# native engine as unavailable, exactly as when the variable is unset.
GHIDRA_INSTALL_DIR: '<your Ghidra 12.1.x extraction directory>'
# Or read it from the environment the harness already has — the
# entry-list dialect evaluates `!!js` expressions at activation:
# GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR
# JAVA_HOME: '<your JDK 21 or newer home>' # only when java is not on PATH
failOnStartupError: false
+109
View File
@@ -0,0 +1,109 @@
/**
* Package entry — a loadable package, plus the read-only route behind the
* one-shot setup guide.
*
* Two jobs, deliberately kept apart:
*
* 1. Make this package loadable. The row that does the real work lives in
* `cordis.patch.yml`: it inserts one row of `@deepseek-ai/dsh-mcp-client`
* that launches `bin/rea-shim.mjs`. A package that declares no
* `main`/`exports` is judged by its consumers as "has no entry file",
* whether or not the loader needs one: dshmarket's `hasLoadableEntry`
* asks that on every update and rolls a working build back when the answer
* is false (measured 2026-10-18, 2.0.1 → 2.0.4), and npm metadata resolves
* `main` too. So this is a real module with a real Cordis shape.
*
* 2. Answer GET /dsh-plugin-rea/guide-state for `client.js`. The guide has to
* tell a first install apart from an upgrade — not by guessing, but by
* comparing the REA version recorded in `plugins/rea-bundle/bundle.json`
* with the version this package pins. `bin/rea-guide.mjs` computes it as a
* pure function; this file only serves the result. Nothing here provisions,
* spawns or mutates: the route is read-only, and the guide never runs
* setup for the user.
*
* The route is mounted only when the host has a webServer (the Web GUI), through
* `ctx.inject`, so a headless/ACP host loads this plugin exactly like before.
*/
import { GUIDE_ROUTE, computeGuideState, localPackageFacts } from './bin/rea-guide.mjs'
/** Cordis plugin name. Also the package name, which is what the loader expects. */
export const name = 'dsh-plugin-rea'
/** Cached package facts; the files change only when the package is replaced. */
let cachedFacts = null
function packageFacts() {
if (!cachedFacts) cachedFacts = localPackageFacts()
return cachedFacts
}
function sendJson(res, status, payload, headOnly = false) {
const body = JSON.stringify(payload)
res.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
'content-length': Buffer.byteLength(body),
})
res.end(headOnly ? undefined : body)
}
/**
* Register the read-only guide route on a host that has a webServer.
*
* `webServer.register` takes `{ kind: 'exact', path, handler }` and returns a
* disposer; `ctx.effect` ties that disposer to this plugin's lifetime, so a
* hot disable removes the route instead of leaving a handler over unloaded code.
*/
function mountGuideRoute(host) {
const server = host && host.webServer
if (!server || typeof server.register !== 'function') return
if (typeof host.effect !== 'function') return
host.effect(() =>
server.register({
kind: 'exact',
path: GUIDE_ROUTE,
handler: (req, res) => {
try {
if (req.method !== 'GET' && req.method !== 'HEAD') {
sendJson(res, 405, { error: 'GET only' })
return
}
const facts = packageFacts()
const state = computeGuideState({
pluginVersion: facts.pluginVersion,
pinnedRea: facts.pinnedRea,
})
sendJson(res, 200, state, req.method === 'HEAD')
} catch (error) {
sendJson(res, 500, {
error: error && error.message ? error.message : String(error),
})
}
},
}),
)
}
/**
* Mount the guide route when the host can serve it.
*
* `ctx.inject` is the normal path: it runs the callback once `webServer` exists,
* and never on a host that has none. The direct fallback keeps older Cordis
* shapes working. Neither path touches the MCP row — that row is the bundle
* patch's, and registering a second `serverName: rea` here would be the exact
* duplicate the shim warns about.
*/
export function apply(ctx) {
if (!ctx) return
if (typeof ctx.inject === 'function') {
try {
ctx.inject(['webServer'], (host) => mountGuideRoute(host))
return
} catch {
/* fall through to the direct check */
}
}
if (ctx.webServer) mountGuideRoute(ctx)
}
+104 -97
View File
@@ -12,18 +12,20 @@
" binaries - workspace-relative entries inside the unpacked directory that are",
" directly spawnable; the first that exists becomes the component's",
" recorded path so another component can depend on it.",
" executable - a placed file that must carry the POSIX exec bit (a wrapper REA",
" spawns without a shell); setup chmods exactly these to 0755.",
"Nothing here is redistributed inside the npm package; setup downloads it."
],
"manifestVersion": 1,
"generatedFor": "dsh-plugin-rea one-plugin bundle",
"pins": {
"reaAgents": "6.4.0",
"reaAgents": "6.8.0",
"ghidra": "12.1.4",
"jdk": "21",
"apktool": "3.0.3",
"jadxHeadlessMcp": "0.7.1",
"node": "24.21.0",
"nodeEngines": "^22.19.0 || ^24.11.0 || >=26.0.0"
"node": "24.22.1",
"nodeEngines": "^22.23.4 || ^24.22.1 || >=26.12.1"
},
"components": {
"node": {
@@ -35,65 +37,65 @@
"binaries": ["bin/node", "node{exe}"],
"probe": {
"argv": ["{node}", "-v"],
"expect": { "regex": "^v(22\\.(1[9]|[2-9][0-9])|24\\.(1[1-9]|[2-9][0-9])|2[6-9]\\.|[3-9][0-9]\\.)" },
"why": "REA's engines are ^22.19.0 || ^24.11.0 || >=26.0.0. A loose '^v\\d+' accepted Node 20, which then fails at REA runtime; the reuse path must reject it here."
"expect": { "regex": "^v(22\\.23\\.(?:[4-9]|[1-9][0-9]+)|22\\.(?:2[4-9]|[3-9][0-9])\\.\\d+|24\\.22\\.(?:[1-9]|[1-9][0-9]+)|24\\.(?:2[3-9]|[3-9][0-9])\\.\\d+|26\\.12\\.(?:[1-9]|[1-9][0-9]+)|26\\.(?:1[3-9]|[2-9][0-9])\\.\\d+|2[7-9]\\.\\d+\\.\\d+|[3-9][0-9]\\.\\d+\\.\\d+)" },
"why": "REA 6.8.0 raised its engines to ^22.23.4 || ^24.22.1 || >=26.12.1, so the pinned Node 24.22.1 is now exactly the floor of the 24 line. A loose '^v\\d+' accepted Node 20, which then fails at REA runtime; the reuse path must reject it here. Enforced as one alternation because a probe has no version comparator: 22.23.4-22.23.x, 22.24+, 24.22.1-24.22.x, 24.23+, 26.12.1-26.12.x, 26.13+, 27+. 22.23.3, 24.22.0 and 26.12.0 are below the declared floor and must not pass."
},
"sources": {
"win32-x64": {
"version": "24.21.0",
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip",
"version": "24.22.1",
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip",
"format": "zip",
"size": 37618919,
"sha256": "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541",
"sha256Source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt (line: node-v24.21.0-win-x64.zip)",
"sha256VerifiedOn": "2026-10-13",
"note": "Checksum and unpack verified on this machine: the extracted node.exe runs standalone (v24.21.0), no installer and no registry writes."
"size": 37748064,
"sha256": "d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5",
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-win-x64.zip)",
"sha256VerifiedOn": "2026-10-23",
"note": "Checksum and unpack verified on this machine: the extracted node.exe runs standalone (v24.22.1), no installer and no registry writes. 24.22.1 is Node's 2026-10-21 security release (Brotli decoder out-of-bounds write RCE, two TLS hostname bypasses, eMudhra intermediate removal); 24.21.0 must not be redistributed. REA 6.8.0 declares ^24.22.1 as the floor of the 24 line, so this version is now both the newest 24.x and the oldest acceptable one — a 24.21.x runtime must be rejected by the reuse path even though it was fine under 6.7.0."
},
"win32-arm64": {
"version": "24.21.0",
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-arm64.zip",
"version": "24.22.1",
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-arm64.zip",
"format": "zip",
"size": 33679608,
"sha256": "8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921",
"sha256Source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt (line: node-v24.21.0-win-arm64.zip), size confirmed by HEAD",
"sha256VerifiedOn": "2026-10-13"
"size": 33786263,
"sha256": "a0c807319fede24c4a0d8f84564c82a619bd313b16b10c007c383c2609e99171",
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-win-arm64.zip), size confirmed by HEAD",
"sha256VerifiedOn": "2026-10-23"
},
"darwin-x64": {
"version": "24.21.0",
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz",
"version": "24.22.1",
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-x64.tar.gz",
"format": "tar.gz",
"size": 54203979,
"sha256": "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097",
"sha256Source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt (line: node-v24.21.0-darwin-x64.tar.gz)",
"sha256VerifiedOn": "2026-10-13"
"size": 54382566,
"sha256": "d132557e7ee2b85af08b0c8c34338e65c6430f2238d01f727728ef42dc83b035",
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-darwin-x64.tar.gz)",
"sha256VerifiedOn": "2026-10-23"
},
"darwin-arm64": {
"version": "24.21.0",
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz",
"version": "24.22.1",
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-arm64.tar.gz",
"format": "tar.gz",
"size": 52909993,
"sha256": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057",
"sha256Source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt (line: node-v24.21.0-darwin-arm64.tar.gz)",
"sha256VerifiedOn": "2026-10-13"
"size": 53062778,
"sha256": "89c682593d76cd44233fa53b1563264e7de9cbb189cad169135c5b4fadfbc0a7",
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-darwin-arm64.tar.gz)",
"sha256VerifiedOn": "2026-10-23"
},
"linux-x64": {
"version": "24.21.0",
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.xz",
"version": "24.22.1",
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.xz",
"format": "tar.xz",
"size": 31890184,
"sha256": "fd8e59d5a511510f6a298afb548f18c7d2b1be404d8b4a27d94fbe49f56cb2d6",
"sha256Source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt (line: node-v24.21.0-linux-x64.tar.xz)",
"sha256VerifiedOn": "2026-10-13",
"size": 32026916,
"sha256": "6c172a9ab6718f7b801fb5a7bd486af2ee5e32758086a04b84fcce2aa77095e6",
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-linux-x64.tar.xz)",
"sha256VerifiedOn": "2026-10-23",
"note": ".tar.xz is not unpackable by Windows Expand-Archive; bsdtar handles it. A .tar.gz twin exists for every platform if xz support is ever in doubt."
},
"linux-arm64": {
"version": "24.21.0",
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.xz",
"version": "24.22.1",
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-arm64.tar.xz",
"format": "tar.xz",
"size": 30859684,
"sha256": "6ad1325edbdb5649c379b75a237147a666c95d4f9ae8d340fef2d1575d289ad2",
"sha256Source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt (line: node-v24.21.0-linux-arm64.tar.xz)",
"sha256VerifiedOn": "2026-10-13"
"size": 30978868,
"sha256": "dbabf7a1cbf947e6059ffad2c27b80041db97dea1e553b62afdee7f13fca336e",
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-linux-arm64.tar.xz)",
"sha256VerifiedOn": "2026-10-23"
}
}
},
@@ -112,7 +114,7 @@
"size": 205073461,
"sha256": "f9d6e191ab098c0d416e7d588a24420a8621cd2f4720dab2459b8b7b2d2d8b4e",
"sha256Source": "Adoptium API /v3/assets/latest/21/hotspot (binary.package.checksum), cross-checked against the per-asset .zip.sha256.txt sidecar",
"sha256VerifiedOn": "2026-10-13",
"sha256VerifiedOn": "2026-10-23",
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=x64&image_type=jdk&os=windows&vendor=eclipse",
"note": "Unpacked and exercised on this machine: bin/javac.exe and jmods are present, java --list-modules reports jdk.compiler, and REA's Ghidra provider accepted it as a 64-bit full JDK 21+."
},
@@ -123,7 +125,7 @@
"size": 194316575,
"sha256": "44db0f08196daf19a47f90d13388b0c943b67663cb537f998fe29e836fa842ce",
"sha256Source": "Adoptium API (binary.package.checksum)",
"sha256VerifiedOn": "2026-10-13",
"sha256VerifiedOn": "2026-10-23",
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=x64&image_type=jdk&os=mac&vendor=eclipse"
},
"darwin-arm64": {
@@ -133,7 +135,7 @@
"size": 200073404,
"sha256": "3623232f33a9c3baadf304480b2535f9a3cba8a58d42ecbb438ba267315d9998",
"sha256Source": "Adoptium API (binary.package.checksum)",
"sha256VerifiedOn": "2026-10-13",
"sha256VerifiedOn": "2026-10-23",
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=aarch64&image_type=jdk&os=mac&vendor=eclipse"
},
"linux-x64": {
@@ -143,7 +145,7 @@
"size": 207473347,
"sha256": "ce79869e1307ed8ee1e2baa86a412b1eb5b75d10a01006d788a6f968bcfaee94",
"sha256Source": "Adoptium API (binary.package.checksum)",
"sha256VerifiedOn": "2026-10-13",
"sha256VerifiedOn": "2026-10-23",
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=x64&image_type=jdk&os=linux&vendor=eclipse"
},
"linux-arm64": {
@@ -153,7 +155,7 @@
"size": 205641175,
"sha256": "23e37e026f12f3e706f18938ff611db3032d075b09d0879a25d06718c773e223",
"sha256Source": "Adoptium API (binary.package.checksum)",
"sha256VerifiedOn": "2026-10-13",
"sha256VerifiedOn": "2026-10-23",
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=aarch64&image_type=jdk&os=linux&vendor=eclipse"
}
}
@@ -174,7 +176,7 @@
"sha256": "ddac49f903da9d5bac833e5cc79395098b9c33cfd3279be5f31bd00387d2d4db",
"sha256Source": "GitHub release body of the pinned tag: api.github.com/repos/NationalSecurityAgency/ghidra/releases/tags/Ghidra_12.1.4_build -> .body, line '* SHA-256: `ddac49f9...`'. No checksum asset exists (six candidate names 404). Always read the pinned tag, never releases/latest, because the body changes.",
"releaseApi": "https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/tags/Ghidra_12.1.4_build",
"sha256VerifiedOn": "2026-10-13",
"sha256VerifiedOn": "2026-10-23",
"note": "No platform split for the public release: one zip serves Windows/macOS/Linux. Unpacked and sniffed here (support/analyzeHeadless.bat and the win_x86_64 native decompiler are present, and REA reported provider version 12.1.4). Windows traps: a path containing '!' or '^' breaks Ghidra, and macOS native components are not shipped (they must be built with support/gradle/gradlew buildNatives). Ghidra requires JDK 21+; application.java.max is empty at this tag, so a newer JDK is permitted."
}
}
@@ -194,7 +196,7 @@
"size": 15478013,
"sha256": "dbf930b076c6b9be08d57c449cacefc3bdd6b71ebd59b3066fc0e1f5b14f9423",
"sha256Source": "Vendor-published in the 3.0.3 release post (apktool.org/blog/apktool-3.0.3/), md5 50d98c920b005d2fcdc18d3d52e3f23b. Independently reproduced here: this is the hash of the 15,478,013-byte apktool_3.0.3.jar.",
"sha256VerifiedOn": "2026-10-13",
"sha256VerifiedOn": "2026-10-23",
"moveTo": "apktool",
"saveAs": "apktool.jar"
}
@@ -203,16 +205,16 @@
"win32-x64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/windows/apktool.bat",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/windows/apktool.bat",
"format": "file",
"size": 2348,
"sha256": "9440ec9d98efdef14136f4cefd79ac200d72baa016b5991d128234a3a3ef3b43",
"sha256Source": "measured on 2026-10-13 (raw.githubusercontent.com publishes no checksum for the wrapper); a single 2,348-byte file, so the archive-free case needs no TOFU",
"sha256VerifiedOn": "2026-10-13",
"size": 2386,
"sha256": "d81a91bd694459a61a88e4a1a8184efc21130390ff7cb5c172f631551ad9842a",
"sha256Source": "measured on 2026-10-16 from the immutable v3.0.3 tag (raw.githubusercontent.com publishes no checksum for the wrapper). The tag, not master: pinning master meant any upstream commit to the wrapper broke every new install with a hash mismatch, and four retries could not fix a download whose content had legitimately changed.",
"sha256VerifiedOn": "2026-10-16",
"notes": [
"The wrapper pushd's to its own directory and resolves apktool.jar first, then globs apktool_*.jar, so the renamed jar next to it is found.",
"Its final line is `for %%i in (%cmdcmdline%) do if /i \"%%~i\"==\"/c\" pause & exit /b`: measured here it returns exit 0 in 0.2 s, but it appends 'Press any key to continue . . .' to stdout. That trailing text is upstream's, not a provisioning defect; tools that parse apktool's stdout should tolerate it.",
"The previous entry measured master's copy (2,348 bytes) and recorded that it returns exit 0 in 0.2 s while appending 'Press any key to continue . . .' to stdout. This is the tag's copy (2,386 bytes) and that runtime behaviour has NOT been re-measured; treat the trailing-text claim as unverified for this file.",
"REA is pointed at this .bat (REA_APKTOOL_COMMAND), not at the jar, exactly as the wrapper expects."
],
"moveTo": "apktool",
@@ -222,13 +224,14 @@
"darwin-x64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/osx/apktool",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/osx/apktool",
"format": "file",
"size": 2672,
"sha256": "d0c86f0475ed73bfffa6ac39faa69de7aa2dd40ff5e7e6540f82002be3a38f0b",
"sha256Source": "measured on 2026-10-13 from the osx wrapper (the correct one for macOS; the linux script is a different file)",
"sha256VerifiedOn": "2026-10-13",
"size": 2709,
"sha256": "3b9a95c41680c4a758b0b8d6e91e122b2ea3f1a189685a915613ca1ccba00fd9",
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag (the correct file for macOS; the linux script is a different file). The tag is immutable, so this pin cannot drift under a new install the way master's did.",
"sha256VerifiedOn": "2026-10-16",
"executable": true,
"moveTo": "apktool",
"saveAs": "apktool"
}
@@ -236,13 +239,14 @@
"darwin-arm64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/osx/apktool",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/osx/apktool",
"format": "file",
"size": 2672,
"sha256": "d0c86f0475ed73bfffa6ac39faa69de7aa2dd40ff5e7e6540f82002be3a38f0b",
"sha256Source": "measured on 2026-10-13 from the osx wrapper",
"sha256VerifiedOn": "2026-10-13",
"size": 2709,
"sha256": "3b9a95c41680c4a758b0b8d6e91e122b2ea3f1a189685a915613ca1ccba00fd9",
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag; the same osx wrapper serves arm64.",
"sha256VerifiedOn": "2026-10-16",
"executable": true,
"moveTo": "apktool",
"saveAs": "apktool"
}
@@ -250,13 +254,14 @@
"linux-x64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/linux/apktool",
"format": "file",
"size": 2648,
"sha256": "a851371023c43ca9b9a30218f87f9fd536304283de7b85447183ae811dfd033c",
"sha256Source": "measured on 2026-10-13 from the linux wrapper",
"sha256VerifiedOn": "2026-10-13",
"size": 2685,
"sha256": "493a9c98df968a25c373ca9df871ca77730c82954be4959b74e90adaf91a0e77",
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag.",
"sha256VerifiedOn": "2026-10-16",
"executable": true,
"moveTo": "apktool",
"saveAs": "apktool"
}
@@ -264,13 +269,14 @@
"linux-arm64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/linux/apktool",
"format": "file",
"size": 2648,
"sha256": "a851371023c43ca9b9a30218f87f9fd536304283de7b85447183ae811dfd033c",
"sha256Source": "measured on 2026-10-13 from the linux wrapper",
"sha256VerifiedOn": "2026-10-13",
"size": 2685,
"sha256": "493a9c98df968a25c373ca9df871ca77730c82954be4959b74e90adaf91a0e77",
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag; the same linux wrapper serves arm64. It is only correct where the platform-tools archive is too — see the arch caveat on androidPlatformTools.",
"sha256VerifiedOn": "2026-10-16",
"executable": true,
"moveTo": "apktool",
"saveAs": "apktool"
}
@@ -295,7 +301,7 @@
"size": 42619858,
"sha256": "6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75",
"sha256Source": "Vendor-published sidecar asset releases/download/v0.7.1/jadx-headless-mcp.sha256 (98 bytes, one line); the value was independently reproduced here by hashing the downloaded 42,619,858-byte jar. 0.7.1 is the newest release and REA requires exactly this bridge build: the project's own build.gradle.kts documents that the fat jar must be shaded with duplicatesStrategy INCLUDE, otherwise only KotlinMetadataPlugin registers and every APK resolves to a single R class. Versions 0.6.0 and earlier predate that fix.",
"sha256VerifiedOn": "2026-10-13",
"sha256VerifiedOn": "2026-10-23",
"moveTo": "jadx",
"saveAs": "jadx-headless-mcp-0.7.1-all.jar"
}
@@ -317,8 +323,8 @@
"format": "zip",
"size": 8044989,
"sha256": "45f4d63113e895ebde0c90f194099a4676b6ac653bd28d54314a9e022bbc1a99",
"sha256Source": "Google publishes no checksum for this rolling asset; measured on 2026-10-13 and the archive verified readable (15 entries incl. adb.exe). The archive's own platform-tools/source.properties reports Pkg.Revision=37.0.1, so the real pin is 'platform-tools 37.0.1'. Google's repository2-3.xml carries size+checksum for versioned archives if a vendor hash is ever required.",
"sha256VerifiedOn": "2026-10-13",
"sha256Source": "Google publishes no checksum for this rolling asset; measured on 2026-10-23 and the archive verified readable (15 entries incl. adb.exe). The archive's own platform-tools/source.properties reports Pkg.Revision=37.0.1, so the real pin is 'platform-tools 37.0.1'. Google's repository2-3.xml carries size+checksum for versioned archives if a vendor hash is ever required.",
"sha256VerifiedOn": "2026-10-23",
"note": "Rolling 'latest' URL pinned by observation; setup re-verifies against this value and reports drift. Android SDK components are not OSI-licensed and Google restricts redistribution, so this is fetched from Google at install time and never re-hosted. Also contains fastboot, sqlite3, mke2fs and the USB API DLLs."
},
"darwin-x64": {
@@ -327,8 +333,8 @@
"format": "zip",
"size": 16110554,
"sha256": "ee39ad5967e95c2a07f04dbcbde96b1a0c916ba376096db5d2f498b7727a5d1d",
"sha256Source": "Google publishes no checksum for this rolling asset; measured on 2026-10-13 by downloading the archive and hashing it locally (16,110,554 bytes)",
"sha256VerifiedOn": "2026-10-13",
"sha256Source": "Google publishes no checksum for this rolling asset; measured on 2026-10-23 by downloading the archive and hashing it locally (16,110,554 bytes)",
"sha256VerifiedOn": "2026-10-23",
"note": "Rolling 'latest' URL pinned by observation; setup re-verifies against this value and reports drift."
},
"darwin-arm64": {
@@ -337,9 +343,9 @@
"format": "zip",
"size": 16110554,
"sha256": "ee39ad5967e95c2a07f04dbcbde96b1a0c916ba376096db5d2f498b7727a5d1d",
"sha256Source": "Same darwin archive as x64 (measured 2026-10-13); it serves Intel and Apple silicon.",
"sha256VerifiedOn": "2026-10-13",
"note": "The same darwin archive serves Intel and Apple silicon."
"sha256Source": "Same darwin archive as x64 (measured 2026-10-23); Google publishes one darwin zip for both architectures.",
"sha256VerifiedOn": "2026-10-23",
"note": "The darwin archive contains x86_64 binaries. On Apple silicon they run only through Rosetta 2; without it adb cannot start and this optional component is reported unavailable."
},
"linux-x64": {
"version": "latest",
@@ -347,9 +353,9 @@
"format": "zip",
"size": 9054187,
"sha256": "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1",
"sha256Source": "Google publishes no checksum for this rolling asset; measured on 2026-10-13 by downloading the archive and hashing it locally (9,054,187 bytes)",
"sha256VerifiedOn": "2026-10-13",
"note": "The same linux archive serves x86-64 and arm64."
"sha256Source": "Google publishes no checksum for this rolling asset; measured on 2026-10-23 by downloading the archive and hashing it locally (9,054,187 bytes)",
"sha256VerifiedOn": "2026-10-23",
"note": "The linux archive contains x86_64 binaries. Google publishes no arm64 platform-tools and this bundle does not emulate one, so on linux-arm64 the entry below is offered but its probe is expected to fail — reported as an optional component being unavailable, never as a broken install."
},
"linux-arm64": {
"version": "latest",
@@ -357,8 +363,9 @@
"format": "zip",
"size": 9054187,
"sha256": "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1",
"sha256Source": "Same linux archive as x64 (measured 2026-10-13).",
"sha256VerifiedOn": "2026-10-13"
"sha256Source": "Google publishes no arm64 platform-tools; this is the same x86_64 linux archive as x64 (measured 2026-10-23), recorded so the mismatch is explicit instead of implied.",
"sha256VerifiedOn": "2026-10-23",
"note": "Kept so an arm64 host reports a precise failure instead of \"no source\". adb here is x86_64: it runs under qemu/binfmt where the host provides it, and otherwise the probe fails and the report names it."
}
}
},
@@ -370,14 +377,14 @@
"unpack": { "moveTo": "rea-agents" },
"probe": {
"argv": ["{node}", "{vendor}/node_modules/rea-agents/scripts/rea.mjs", "--version"],
"expect": { "regex": "^6\\.4\\.0" }
"expect": { "regex": "^6\\.8\\.0(\\s|$)" }
},
"sources": {
"npm": {
"version": "6.4.0",
"version": "6.8.0",
"registry": "https://registry.npmjs.org/rea-agents",
"integrity": "npm's own dist.integrity is the authoritative check for this tarball: the registry tarball ships NO node_modules (verified: 1161 entries, 0 under node_modules), so setup resolves the dependency closure with `npm install --ignore-scripts` instead of unpacking a tarball.",
"note": "`just unpacking the published tarball cannot work` is a verified fact, not a caution: the CLI would fail with ERR_MODULE_NOT_FOUND on zod."
"integrity": "npm's own dist.integrity is the authoritative check for this tarball: the registry tarball ships NO node_modules (verified: 2398 entries in 6.8.0, 0 under node_modules), so setup resolves the dependency closure with `npm install --ignore-scripts` instead of unpacking a tarball.",
"note": "`just unpacking the published tarball cannot work` is a verified fact, not a caution: the CLI would fail with ERR_MODULE_NOT_FOUND on zod. 6.8.0 (published 2026-10-25, npm latest; 6.6.0 was never on npm) does raise the runtime floor: engines.node went from ^22.19.0 || ^24.11.0 || >=26.0.0 to ^22.23.4 || ^24.22.1 || >=26.12.1, which makes the pinned Node 24.22.1 exactly the floor of its line — hence pins.nodeEngines and the node probe had to move with it. bin is still scripts/rea.mjs, and the dependency closure gained exactly two packages (@bufbuild/protobuf 2.16.0, @msgpack/msgpack 3.1.3), so the same npm install path resolves it. This one IS a tool expansion: the shipped dist/mcp-catalog.json records identity.counts = 229 MCP tools / 175 CLI commands / 6 prompts (6.7.0 shipped no catalog and exposed 207), with new families for APK signatures, binary diagnostics, model artifacts (GGUF/safetensors) and CUDA/HIP code objects. The two version gates this bundle cares about did NOT move: SUPPORTED_GHIDRA_VERSION is still 12.1.4 (so Ghidra 12.2 is still unsupported) and JADX_RELEASE is still 0.7.1 with the same 6e5eacf5… sha256 (so jadx 0.8.0 is still rejected) — those pins stay put."
}
}
}
+24 -6
View File
@@ -1,8 +1,14 @@
{
"name": "dsh-plugin-rea",
"version": "2.0.1",
"version": "2.0.13",
"description": "One-plugin bundle: provisions Node/REA/JDK/Ghidra for you, then registers the upstream REA MCP server as mcp__rea__* tools. · 一体包:自动准备 Node/REA/JDK/Ghidra,并把上游 REA 的 MCP 服务接入 DeepSeek Harness。",
"type": "module",
"main": "./index.mjs",
"exports": {
".": "./index.mjs",
"./client": "./client.js",
"./package.json": "./package.json"
},
"license": "MIT",
"author": "czcdr",
"homepage": "https://github.com/czcdr/dsh-plugin-rea",
@@ -33,6 +39,8 @@
},
"files": [
"cordis.patch.yml",
"index.mjs",
"client.js",
"bin/",
"scripts/",
"manifest/",
@@ -43,24 +51,34 @@
"CHANGELOG.md",
"check-updates.ps1",
"screenshots.json",
"assets/",
"examples/"
"assets/"
],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slots"
]
}
},
"scripts": {
"check-updates": "powershell -NoProfile -File ./check-updates.ps1",
"check-updates": "node ./scripts/check-updates.mjs",
"bundle:setup": "node ./scripts/setup.mjs",
"bundle:status": "node ./bin/rea-shim.mjs --shim-status",
"bundle:dry-run": "node ./scripts/setup.mjs --reuse --dry-run",
"bundle:audit": "node ./scripts/audit-manifest.mjs",
"bundle:test": "node --test ./scripts/setup.test.mjs"
"bundle:test": "node --test ./scripts/setup.test.mjs",
"bundle:host-compat": "node ./scripts/host-compat.mjs"
},
"engines": {
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
"node": "^22.23.4 || ^24.22.1 || >=26.12.1",
"dsh": ">=0.1.7-rc.2 <0.3.0-0"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/"
+102
View File
@@ -430,6 +430,108 @@ if (manifest) {
}
}
/* ------------------------- 11. setup and the manifest describe the same set */
// A component added to the manifest but not to setup.mjs's INSTALL_ORDER was
// silently never provisioned, and nothing here said so. The reverse — a name in
// INSTALL_ORDER with no manifest entry — is skipped by the loop's
// `if (!component) continue`, so it is equally silent.
const installOrderMatch = setupText.match(/const INSTALL_ORDER = \[([^\]]*)\]/);
if (!installOrderMatch) {
add("error", "scripts/setup.mjs", "INSTALL_ORDER was not found, so manifest/setup agreement cannot be checked");
} else {
const order = [...installOrderMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
const manifestNames = Object.keys(manifest.components ?? {});
for (const name of manifestNames) {
if (!order.includes(name)) {
add("error", "manifest.components", `"${name}" is not in scripts/setup.mjs INSTALL_ORDER, so setup would silently never provision it`);
}
}
for (const name of order) {
if (!manifestNames.includes(name)) {
add("error", "scripts/setup.mjs", `INSTALL_ORDER names "${name}", which the manifest does not describe`);
}
}
}
/* ---------------------------- 12. the Node engines rule has one wording too */
// REA's engines are restated in the manifest pin, package.json and the node
// probe regex. Only the pin and package.json can be compared as text, but a
// drift between those two would advertise one range and enforce another.
const declaredEngines = manifest.pins?.nodeEngines;
let packageEngines = null;
try {
packageEngines = JSON.parse(readFileSync(path.join(HERE, "..", "package.json"), "utf8")).engines?.node ?? null;
} catch (error) {
add("error", "package.json", `could not be read: ${String(error && error.message)}`);
}
if (!declaredEngines) {
add("error", "manifest.pins.nodeEngines", "is missing, so nothing states REA's engines for the reuse path");
} else if (declaredEngines !== packageEngines) {
add(
"error",
"manifest.pins.nodeEngines",
`is ${JSON.stringify(declaredEngines)} but package.json engines.node is ${JSON.stringify(packageEngines)}`,
);
}
/* ------------------- 13. a pinned version's probe must not accept a prefix */
// The concern is a probe that accepts a *newer* release as the pin, so test the
// expression against the versions that would mean that. The audit is not a
// regex linter: `^6\.4\.0` legitimately rejects 6.4.1 and 6.5.0 — the failure
// mode is only real when the next patch or minor still matches.
const reaAgents = manifest.components?.reaAgents;
const pinnedRea = reaAgents?.sources?.npm?.version;
const reaProbe = reaAgents?.probe?.expect?.regex;
if (pinnedRea && reaProbe) {
let probe = null;
try {
probe = new RegExp(reaProbe, "m");
} catch (error) {
add("error", "reaAgents.probe.expect.regex", `is not a valid regular expression: ${String(error && error.message)}`);
}
if (probe) {
if (!probe.test(pinnedRea)) {
add("error", "reaAgents.probe.expect.regex", `does not match the pinned version ${pinnedRea}`);
}
const [major, minor, patch] = pinnedRea.split(".").map((part) => Number(part));
for (const [label, next] of [
["the next patch", `${major}.${minor}.${patch + 1}`],
["the next minor", `${major}.${minor + 1}.0`],
]) {
// `next` is a string and `Number.isFinite` does not coerce, so testing
// the bare split element was always false and this branch never ran —
// item 13 could not fail no matter what the probe accepted. Convert
// first; the guard exists only to skip a NaN major/minor/patch.
if (Number.isFinite(Number(next.split(".")[2])) && probe.test(next)) {
add("error", "reaAgents.probe.expect.regex", `accepts ${next} (${label}), so a newer release passes as the pin`);
}
}
}
}
/* --------------------- 14. a POSIX wrapper must be marked executable */
// copyFileSync carries the archive's 0644 and nothing else changes a mode, while
// REA spawns REA_APKTOOL_COMMAND without a shell: on Linux/macOS an unmarked
// wrapper fails with EACCES even though the component probe (`java -jar`) passes.
// Only the manifest can say which files need the bit, so require it here.
const apktoolWrappers = manifest.components?.apktool?.entries?.byPlatform ?? {};
for (const [platform, list] of Object.entries(apktoolWrappers)) {
if (platform.startsWith("win32")) continue;
for (const entry of list) {
if (entry.executable !== true) {
add(
"error",
`apktool.entries.byPlatform.${platform}`,
`the ${platform} wrapper is not marked "executable": true, so it lands 0644 and cannot be spawned without a shell`,
);
}
}
}
/* ------------------------------------------------------------- summary */
const pinned = fetchEntries.filter((entry) => entry.entry.sha256 && entry.entry.sha256 !== "unverified").length;
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* `npm run check-updates` — a portable entry for a Windows-only script.
*
* `check-updates.ps1` is a Windows PowerShell tool on purpose: it reads
* `%APPDATA%\npm`, prefers a `rea.cmd` shim over `rea.ps1`, and walks the
* profile the way the desktop app lays it out. On macOS/Linux even a working
* `pwsh` would run those branches against paths that do not exist there, so
* "make the .ps1 cross-platform" is not the fix — the honest answer is to say
* so, and to name the two commands that DO work everywhere.
*
* This wrapper therefore:
* - on Windows: runs `powershell` (the 5.1 the script is written for), and
* falls back to `pwsh` when 5.1 is absent;
* - elsewhere: prints why, plus the portable equivalents, and exits non-zero
* so a CI step cannot mistake "not applicable" for "all green".
*
* Its pure parts are exported so a unit test can pin both branches without
* spawning anything.
*/
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
/** Repository root: this file lives in <root>/scripts/. */
export const ROOT = fileURLToPath(new URL('..', import.meta.url))
/**
* PowerShell executables to try, in order, for a platform.
*
* Empty on POSIX: the script's own probes are Windows-specific, so running it
* there would produce a confident wrong answer instead of a clear "not here".
*/
export function powerShellCandidates(platform = process.platform) {
return platform === 'win32' ? ['powershell', 'pwsh'] : []
}
/** The message used when this platform (or this machine) cannot run the script. */
export function unsupportedMessage(platform = process.platform) {
const where = platform === 'win32'
? 'No PowerShell (powershell or pwsh) was found on this machine.'
: `check-updates.ps1 is a Windows PowerShell tool; this host is ${platform}.`
return [
where,
'',
'The same questions, answered with commands that work on every platform:',
' node bin/rea-shim.mjs --shim-status # provisioned bundle, launch choice, versions',
' node scripts/setup.mjs --dry-run # what a setup run would still fetch',
'',
'On Windows, `npm run check-updates` also compares the installed versions',
'against the upstream registries and the profile registration.',
].join('\n')
}
function main(argv) {
const script = join(ROOT, 'check-updates.ps1')
if (!existsSync(script)) {
process.stderr.write(`check-updates: ${script} is missing from this package.\n`)
return 1
}
const candidates = powerShellCandidates()
if (candidates.length === 0) {
process.stderr.write(`${unsupportedMessage()}\n`)
return 1
}
const env = process.env
const attempts = []
for (const command of candidates) {
const result = spawnSync(command, ['-NoProfile', '-File', script, ...argv], {
stdio: 'inherit',
cwd: ROOT,
env,
windowsHide: true,
})
if (result.error && result.error.code === 'ENOENT') {
attempts.push(command)
continue
}
if (result.error) {
process.stderr.write(`check-updates: ${command} failed to start: ${result.error.message}\n`)
return 1
}
return typeof result.status === 'number' ? result.status : 1
}
process.stderr.write(
`check-updates: tried ${attempts.join(', ')} — none is on PATH.\n\n${unsupportedMessage()}\n`,
)
return 1
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
process.exitCode = main(process.argv.slice(2))
}
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env node
/**
* ghidra-gate-override — run a Ghidra release REA has not verified, on purpose.
*
* Why this exists
* REA ships `SUPPORTED_GHIDRA_VERSION` (dist/ghidra/GhidraReleasePolicy.js, inlined
* into the CLI and MCP bundles). A build outside that major.minor is reported as
* `unsupported` and the whole Ghidra family is withdrawn, even when the bridge
* compiles and analyses fine against the newer jars. On 2026-10-26 Ghidra 12.2 was
* measured to produce byte-identical segments, procedure inventory and `entry`
* pseudocode on a PE sample, so the block is upstream's *unverified*, not broken.
*
* The release that fixes Ghidra 12.1.4's four CVEs is 12.2, so staying on 12.1.4 is
* a choice to keep an unpatched decompiler/loader in the chain. This script makes
* the opposite choice explicit, recorded and reversible — it never happens silently.
*
* What it changes
* Only the version constant inside the *vendored* rea-agents copy, and only for the
* files that declare it. Original values are recorded in <root>/ghidra-gate-override.json
* so --disable restores them exactly.
*
* What it does NOT do
* It does not verify the new release for you. Everything REA's own gate would have
* protected against — bridge API drift, decompiler-hook drift, non-PE loaders — is
* now your risk. Re-running `setup.mjs` re-provisions rea-agents and removes the
* patch, which puts the bundle back on the pinned, verified release.
*
* Usage
* node scripts/ghidra-gate-override.mjs --status
* node scripts/ghidra-gate-override.mjs --enable --version 12.2 [--root <dir>]
* node scripts/ghidra-gate-override.mjs --disable [--root <dir>]
*/
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
const HERE = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
const CONSTANT = "SUPPORTED_GHIDRA_VERSION";
const RECORD = "ghidra-gate-override.json";
function parseArgs(argv) {
const out = { mode: "status", version: null, root: null };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--enable") out.mode = "enable";
else if (arg === "--disable") out.mode = "disable";
else if (arg === "--status") out.mode = "status";
else if (arg === "--version") out.version = argv[++i] ?? null;
else if (arg === "--root") out.root = argv[++i] ?? null;
else if (arg === "--help" || arg === "-h") out.mode = "help";
else throw new Error(`unknown argument ${arg}`);
}
return out;
}
/** Same root rule as setup.mjs and the shim: DSH_HOME, else <home>/.dsh. */
function defaultRoot() {
const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== "" ? process.env.DSH_HOME : null;
if (home) return path.join(home, "plugins", "rea-bundle");
const profile = process.env.USERPROFILE || (process.env.HOMEDRIVE ?? "") + (process.env.HOMEPATH ?? "") || process.env.HOME;
return path.join(profile || homedir(), ".dsh", "plugins", "rea-bundle");
}
function walk(dir, out = []) {
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) walk(full, out);
else if (entry.endsWith(".js")) out.push(full);
}
return out;
}
function readJson(file) {
const text = readFileSync(file, "utf8").replace(/^\uFEFF/, "");
return JSON.parse(text);
}
/** Every file in the vendored REA that declares the constant, with its value. */
function findDeclarations(vendorRoot) {
const found = [];
for (const file of walk(vendorRoot)) {
const text = readFileSync(file, "utf8");
const match = new RegExp(`${CONSTANT}\\s*=\\s*"([^"]+)"`).exec(text);
if (match) found.push({ file, value: match[1] });
}
return found;
}
function replaceValue(file, from, to) {
const text = readFileSync(file, "utf8");
const pattern = new RegExp(`(${CONSTANT}\\s*=\\s*)"${from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`);
const next = text.replace(pattern, `$1"${to}"`);
if (next === text) throw new Error(`${path.basename(file)} did not contain ${CONSTANT} = "${from}"`);
writeFileSync(file, next, "utf8");
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.mode === "help") {
console.log("node scripts/ghidra-gate-override.mjs --status | --enable --version <x.y[.z]> | --disable [--root <dir>]");
return;
}
const root = args.root ? path.resolve(args.root) : defaultRoot();
const vendor = path.join(root, "vendor", "node_modules", "rea-agents");
const record = path.join(root, RECORD);
if (!existsSync(vendor)) throw new Error(`no vendored REA at ${vendor} — run scripts/setup.mjs first`);
const declarations = findDeclarations(vendor);
if (declarations.length === 0) throw new Error(`none of the ${CONSTANT} declarations were found under ${vendor}`);
const values = [...new Set(declarations.map((entry) => entry.value))];
const active = existsSync(record) ? readJson(record) : null;
if (args.mode === "status") {
console.log(
JSON.stringify(
{
root,
vendoredRea: vendor,
declarations: declarations.map((entry) => ({
file: path.relative(vendor, entry.file),
value: entry.value,
})),
override: active,
state: active ? `overridden → ${values.join(", ")}` : `upstream gate (${values.join(", ")})`,
},
null,
2,
),
);
return;
}
if (args.mode === "disable") {
if (!active) {
console.log("no override is recorded; nothing to restore");
return;
}
let restored = 0;
let stale = 0;
for (const entry of active.files) {
const file = path.join(vendor, entry.file);
if (!existsSync(file)) {
stale += 1;
continue;
}
const text = readFileSync(file, "utf8");
if (new RegExp(`${CONSTANT}\\s*=\\s*"${entry.patched.replace(/\./g, "\\.")}"`).test(text)) {
replaceValue(file, entry.patched, entry.original);
restored += 1;
} else if (new RegExp(`${CONSTANT}\\s*=\\s*"${entry.original.replace(/\./g, "\\.")}"`).test(text)) {
stale += 1;
} else {
throw new Error(`${entry.file} declares neither ${entry.patched} nor ${entry.original}; inspect it before restoring`);
}
}
unlinkSync(record);
console.log(
`restored ${CONSTANT} = "${active.originalVersion}" in ${restored} file(s)` +
(stale > 0 ? `; ${stale} file(s) had already been replaced by a re-provision` : "") +
"; override record removed",
);
return;
}
// enable
const version = args.version;
if (!version || !/^\d+\.\d+(\.\d+)?$/.test(version)) {
throw new Error("--version must look like 12.2 or 12.2.1");
}
if (values.includes(version)) {
console.log(`already ${version} — nothing to do`);
return;
}
if (active) {
// Re-provisioning rea-agents replaces the tree, so a record can outlive the patch
// it describes. That stale record must not block the next --enable, and it must
// not be trusted by --disable either: judge it by the files on disk.
const stillApplied = active.files.every((entry) => {
const file = path.join(vendor, entry.file);
if (!existsSync(file)) return false;
const text = readFileSync(file, "utf8");
return new RegExp(`${CONSTANT}\\s*=\\s*"${entry.patched.replace(/\./g, "\\.")}"`).test(text);
});
if (stillApplied) {
throw new Error(`an override is already recorded (→ ${active.requestedVersion}); run --disable first`);
}
console.log(`previous record is stale (rea-agents was re-provisioned); replacing it`);
unlinkSync(record);
}
const files = [];
for (const entry of declarations) {
const relative = path.relative(vendor, entry.file);
replaceValue(entry.file, entry.value, version);
files.push({ file: relative, original: entry.value, patched: version });
}
// Read back: the whole point is that the gate now reads a value REA accepts.
for (const entry of files) {
const text = readFileSync(path.join(vendor, entry.file), "utf8");
if (!new RegExp(`${CONSTANT}\\s*=\\s*"${version.replace(/\./g, "\\.")}"`).test(text)) {
throw new Error(`readback failed for ${entry.file}`);
}
}
const bundle = existsSync(path.join(root, "bundle.json")) ? readJson(path.join(root, "bundle.json")) : null;
const payload = {
schema: 1,
requestedVersion: version,
originalVersion: values.length === 1 ? values[0] : values.join(","),
files,
ghidraPath: bundle?.paths?.ghidra ?? null,
enabledAt: new Date().toISOString(),
warning:
"REA did not verify this Ghidra release. The gate existed because the bridge is only tested against the pinned one; re-running setup.mjs re-provisions rea-agents and removes this patch.",
};
writeFileSync(record, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
console.log(`patched ${files.length} file(s) → ${CONSTANT} = "${version}"`);
console.log(`recorded ${record}`);
console.log("restart dsh (or reload the profile) so the MCP row reconnects");
}
try {
main();
} catch (error) {
console.error(`ghidra-gate-override: ${error && error.message ? error.message : String(error)}`);
process.exitCode = 1;
}
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env node
/**
* host-compat — does the range this package declares cover the DSH releases it
* claims to support?
*
* `package.json` carries `engines.dsh`, and that single string is what the
* plugin market reads to say "compatible" instead of "unknown" (the market's
* `deriveHostCompatibility` treats `engines.dsh` as a host declaration and
* checks it against the running host version). A declaration is therefore load
* bearing twice over: it decides whether the market HIDES this plugin from users
* on a matching host, and it decides whether users on a host we never tested are
* told it works.
*
* The two ways it goes wrong are silent. A typo makes the range unparseable, and
* the market then reports `unknown` — a plugin that looks undeclared rather than
* mis-declared. A floor that drifted (say `>=0.2.0`) silently drops hosts the
* README still promises. Neither shows up as a test failure unless something
* compares the STRING to the release list, which is what this file does.
*
* `HOST_RELEASES` is the dsh release line this package is built against, newest
* first, as published on github.com/deepseek-ai/deepseek-harness. It is a
* literal list rather than a GitHub query on purpose: a check that needs the
* network fails on a plane and passes on a total outage, which is the wrong
* shape for a gate. When a release is added upstream, add it here too — that
* edit is the reminder to re-check the row against the new host.
*
* SEMANTICS: this file evaluates the declaration the way `includePrerelease:
* true` does, and that choice is not stylistic. Both consumers of `engines.dsh`
* in this ecosystem pass that option — dshmarket's discovery
* (`satisfiesRange(host, declared, { includePrerelease: true })` in
* `deriveHostCompatibility`) and the host's own peer gate — so a plain semver
* reading, where `0.2.0-rc.2` does NOT satisfy `>=0.1.7-rc.2 <0.3.0-0` (a
* prerelease must be named by a comparator on its own tuple), would disagree
* with the verdict users actually see. The tests pin the option's effect.
*
* Usage: `node scripts/host-compat.mjs` (exit 0 = every listed release is
* covered by the declared range; exit 1 = at least one is not).
*/
import { readFileSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = path.join(HERE, '..')
/**
* Every dsh release this bundle is checked against, newest first.
*
* Taken from the release list of github.com/deepseek-ai/deepseek-harness. The
* floor is the oldest release the plugin has been adapted to; everything above
* it up to the declared ceiling must keep working, so a release that is missing
* here is a release nobody checked.
*/
export const HOST_RELEASES = [
'0.2.1-rc.2',
'0.2.1-rc.1',
'0.2.1-alpha.5',
'0.2.1-alpha.4',
'0.2.1-alpha.3',
'0.2.1-alpha.2',
'0.2.1-alpha.1',
'0.2.0-rc.2',
'0.2.0-rc.1',
'0.1.7-rc.2',
]
/** Hosts that must fall OUTSIDE the declaration, for the other direction. */
export const OUTSIDE_HOSTS = [
'0.3.0',
// Prereleases of the NEXT line are the case a plain `<0.3.0` ceiling lets
// through: with `includePrerelease` they sort below `0.3.0` itself. The
// declared ceiling is `<0.3.0-0`, semver's idiom for "nothing from 0.3.0",
// and these entries are what proves it does what it says.
'0.3.0-rc.1',
'0.3.0-alpha.1',
'0.1.7-rc.1',
'0.1.7-alpha.2',
]
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
/**
* Split a version into comparable parts.
*
* Build metadata (`+sha`) is dropped, as semver says it must be for precedence
* and as node-semver does for comparison: a host that reports
* `0.2.0-rc.2+build.7` is running 0.2.0-rc.2. Everything else is deliberately
* strict — a leading `v` or a zero-padded identifier is rejected rather than
* guessed at, because this gate exists to catch a declaration nobody checked and
* a guess would put the guess in charge.
* @param {string} version - a plain semver string, no range operators.
* @returns {{ parts: number[], pre: (string|number)[] } | null} null when unparseable.
*/
export function parseVersion(version) {
const cleaned = String(version ?? '').trim().replace(/\+.*$/, '')
const match = SEMVER.exec(cleaned)
if (match === null) return null
const raw = match[4] === undefined ? [] : match[4].split('.')
// semver forbids a zero-padded numeric prerelease identifier (`0.2.1-01`);
// accepting one would make this gate read a typo as a version.
if (raw.some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0'))) return null
const pre = raw.map(part => (/^\d+$/.test(part) ? Number(part) : part))
return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], pre }
}
/**
* Compare two versions the way semver does, prerelease included.
*
* A prerelease sorts BELOW its release (`0.2.0-rc.2 < 0.2.0`), and among
* prereleases an identifier that is numeric sorts below one that is not, which
* is why `0.2.1-alpha.5 < 0.2.1-rc.1` — the whole reason this file cannot lean
* on a numeric comparison.
* @returns -1, 0 or 1; NaN-free because unparseable input throws.
*/
export function compareVersions(a, b) {
const left = parseVersion(a)
const right = parseVersion(b)
if (left === null) throw new Error(`not a version: ${a}`)
if (right === null) throw new Error(`not a version: ${b}`)
for (let index = 0; index < 3; index += 1) {
if (left.parts[index] !== right.parts[index]) return left.parts[index] < right.parts[index] ? -1 : 1
}
if (left.pre.length === 0 && right.pre.length === 0) return 0
if (left.pre.length === 0) return 1
if (right.pre.length === 0) return -1
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index += 1) {
const l = left.pre[index]
const r = right.pre[index]
if (l === undefined) return -1
if (r === undefined) return 1
if (l === r) continue
const lNum = typeof l === 'number'
const rNum = typeof r === 'number'
if (lNum && rNum) return l < r ? -1 : 1
// Numeric identifiers always have lower precedence than alphanumeric ones.
if (lNum !== rNum) return lNum ? -1 : 1
return String(l) < String(r) ? -1 : 1
}
return 0
}
/**
* Evaluate one `>=floor <ceiling` declaration (the only shape this package
* ships, and the shape the market's `engines.dsh` is compared with).
*
* Deliberately narrow: a wider parser here could disagree with the market's
* semver implementation on a range the manifest would then be trusted for. If a
* future declaration needs `||` or `^`, this function has to grow with it.
* @param {string} version - host version to test.
* @param {string} range - the declared range.
* @returns {boolean} whether the host is inside the declaration.
*/
export function satisfiesDeclaredRange(version, range) {
const match = /^>=\s*(\S+)\s+<\s*(\S+)$/.exec(range.trim())
if (match === null) throw new Error(`unsupported host range: ${range}`)
return compareVersions(version, match[1]) >= 0 && compareVersions(version, match[2]) < 0
}
/**
* The host range this repository declares, `engines.dsh`.
* @param {string} [root] - repository root, for tests.
* @returns {string | null} the declared range, or null when absent.
*/
export function declaredHostRange(root = REPO_ROOT) {
try {
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
const value = pkg?.engines?.dsh
return typeof value === 'string' ? value : null
} catch {
return null
}
}
/**
* Every release covered by the declaration, and every version that must not be.
* @param {string} range - declared host range.
* @returns {{ covered: string[], uncovered: string[], leaked: string[] }} findings.
*/
export function checkHostCoverage(range) {
const covered = []
const uncovered = []
for (const version of HOST_RELEASES) {
if (satisfiesDeclaredRange(version, range)) covered.push(version)
else uncovered.push(version)
}
const leaked = OUTSIDE_HOSTS.filter(version => satisfiesDeclaredRange(version, range))
return { covered, uncovered, leaked }
}
function main() {
const range = declaredHostRange()
if (range === null) {
console.error('FAIL: package.json declares no engines.dsh — the market would report this plugin as "unknown"')
process.exit(1)
}
let result
try {
result = checkHostCoverage(range)
} catch (error) {
console.error(`FAIL: ${error.message}`)
process.exit(1)
}
console.log(`engines.dsh = ${range}`)
for (const version of HOST_RELEASES) {
const inside = satisfiesDeclaredRange(version, range)
console.log(` ${inside ? 'ok ' : 'MISS'} ${version}`)
}
for (const version of OUTSIDE_HOSTS) {
const inside = satisfiesDeclaredRange(version, range)
console.log(` ${inside ? 'LEAK' : 'ok '} ${version} (must stay outside)`)
}
if (result.uncovered.length > 0) {
console.error(`FAIL: the declaration does not cover ${result.uncovered.join(', ')}`)
process.exit(1)
}
if (result.leaked.length > 0) {
console.error(`FAIL: the declaration also admits ${result.leaked.join(', ')}, which is not a tested host`)
process.exit(1)
}
console.log(`host range ok: ${HOST_RELEASES.length} releases covered, ${OUTSIDE_HOSTS.length} correctly outside`)
}
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
+28
View File
@@ -0,0 +1,28 @@
# Pin comparison for check-updates.ps1, kept in a dot-sourceable file so it can
# be tested without running the whole check (which talks to a registry).
#
# Why this is a function and not an inline condition: until 2.0.10 the comparison
# was "equal, OR shares the first dotted component with the pin", applied to every
# component. "6.5.0".StartsWith("6.") is true, so a bundle still on 6.5.0 reported
# "consistent with pin 6.7.0", and that line is the only signal a user has that a
# pin bump requires re-running setup. apktool had the same shape (3.0.9 vs 3.0.3,
# where the major-prefix test called them equal).
#
# The one pin that is genuinely major-only is the JDK ("21"): the download records
# 21.0.12.1+1 and both name the same family. A dotted pin names exactly one
# version. The reverse rule, prefix matching on a dotted pin, is the bug.
# Kept ASCII-only on purpose: Windows PowerShell 5.1 reads a BOM-less script as
# ANSI, and this file ships inside the package.
function Test-PinMatch {
param(
[string]$Installed,
[string]$Pinned
)
$installed = ([string]$Installed).Trim()
$pinned = ([string]$Pinned).Trim()
if ($installed -eq '' -or $pinned -eq '') { return $false }
$same = [string]::Equals($installed, $pinned, [System.StringComparison]::Ordinal)
if ($pinned.Contains('.')) { return $same }
# A major-only pin ("21") also matches its point releases ("21.0.12.1+1").
return ($same -or $installed.StartsWith($pinned + '.', [System.StringComparison]::Ordinal))
}
+57 -10
View File
@@ -1,4 +1,4 @@
# rea-bundle-bootstrap.ps1 — get from "no Node at all" to a provisioned bundle.
# rea-bundle-bootstrap.ps1 — get from "no Node at all" to a provisioned bundle.
#
# Why this exists: setup.mjs is written in Node, so a machine with no Node
# cannot run it. This script uses only PowerShell + Expand-Archive (no
@@ -30,6 +30,11 @@ function Write-Step([string]$Message) { Write-Host "[bootstrap] $Message" }
# --- where things go -------------------------------------------------------
# Same precedence as setup.mjs, the shim and check-updates.ps1: explicit override,
# then DSH_HOME, then ~/.dsh. An override this script alone ignored would put
# ~800 MB into <DSH_HOME>/plugins/rea-bundle while every reader looked in
# REA_BUNDLE_ROOT, saw an empty bundle, and started a second run.
if (-not $Root -or $Root.Trim() -eq '') { $Root = $env:REA_BUNDLE_ROOT }
if (-not $Root -or $Root.Trim() -eq '') {
$home2 = $env:DSH_HOME
if (-not $home2 -or $home2.Trim() -eq '') { $home2 = Join-Path $HOME '.dsh' }
@@ -39,8 +44,12 @@ $Root = [System.IO.Path]::GetFullPath($Root)
$stack = Join-Path $Root 'stack'
$cache = Join-Path $Root 'cache'
$logs = Join-Path $Root 'logs'
foreach ($dir in @($Root, $stack, $cache, $logs)) {
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
# -DryRun promises that nothing was changed, so it must not create these either:
# it only reads them, and Test-Path / Get-ChildItem handle a missing path.
if (-not $DryRun) {
foreach ($dir in @($Root, $stack, $cache, $logs)) {
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
}
}
$pluginRoot = Split-Path -Parent $PSScriptRoot
@@ -84,6 +93,47 @@ $nodeFolder = [System.IO.Path]::GetFileNameWithoutExtension($nodeZipName)
if ($nodeSha256 -eq 'unverified') { throw "the manifest pins no checksum for $hostKey; refusing to download unverified bytes" }
Write-Step "Node source: $hostKey v$nodeVersion"
$script:NodeEngines = [string]$manifest.pins.nodeEngines
if (-not $script:NodeEngines) { throw "the manifest pins no nodeEngines, so Node compatibility cannot be judged" }
# REA's engines come from the manifest pin, which the rest of provisioning already
# treats as the single source of truth. This used to be a second copy of the same
# numbers in a comment, and two copies drift: the audit now asserts that the pin
# keeps the shape this parser understands.
function Compare-SchemaVersion([int[]]$Left, [int[]]$Right) {
# Negative when $Left is older, 0 when equal, positive when newer. All three
# components are compared: `^24.11.1` must reject 24.11.0, and comparing only
# major/minor accepted it — the same engines range then meant two different
# things here and in setup.mjs's regex probe.
for ($i = 0; $i -lt 3; $i++) {
if ($Left[$i] -ne $Right[$i]) { return $(if ($Left[$i] -gt $Right[$i]) { 1 } else { -1 }) }
}
return 0
}
function Test-EnginesMatch([string]$Clean, [string]$Range) {
$versions = @()
foreach ($part in $Clean.Split('.')) {
$n = 0
if (-not [int]::TryParse($part, [ref]$n)) { return $false }
$versions += $n
}
if ($versions.Count -lt 2) { return $false }
# A two-component version (`v24.11`) still has a patch of 0 for comparison.
while ($versions.Count -lt 3) { $versions += 0 }
foreach ($clause in ($Range -split '\|\|')) {
$c = $clause.Trim()
if ($c -match '^\^(\d+)\.(\d+)\.(\d+)$') {
$want = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3])
if ($versions[0] -eq $want[0] -and (Compare-SchemaVersion $versions $want) -ge 0) { return $true }
} elseif ($c -match '^>=(\d+)\.(\d+)\.(\d+)$') {
$want = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3])
if ((Compare-SchemaVersion $versions $want) -ge 0) { return $true }
}
}
return $false
}
function Test-NodeUsable([string]$Exe) {
if (-not $Exe -or -not (Test-Path $Exe)) { return $null }
try {
@@ -91,13 +141,10 @@ function Test-NodeUsable([string]$Exe) {
} catch { return $null }
if (-not $version) { return $null }
$clean = $version.TrimStart('v').Trim()
$parts = $clean.Split('.')
if ($parts.Count -lt 2) { return $null }
$major = [int]$parts[0]
$minor = [int]$parts[1]
# REA's engines: ^22.19.0 || ^24.11.0 || >=26.0.0
$ok = ($major -eq 22 -and $minor -ge 19) -or ($major -eq 24 -and $minor -ge 11) -or ($major -ge 26)
if (-not $ok) { return $null }
# A version that is not three numbers (`vX.Y`) used to reach `[int]` and throw
# under $ErrorActionPreference='Stop', aborting the whole bootstrap instead of
# falling through to the portable download — the one path this script exists for.
if (-not (Test-EnginesMatch $clean $script:NodeEngines)) { return $null }
return [pscustomobject]@{ Path = (Resolve-Path $Exe).Path; Version = $clean }
}
+632 -98
View File
File diff suppressed because it is too large. Load diff
+1824 -8
View File
File diff suppressed because it is too large. Load diff
+122 -3
View File
@@ -12,11 +12,36 @@ function Check($name, [bool]$ok, $detail) {
}
Write-Host "`n=== 1. JS syntax ===" -ForegroundColor Cyan
foreach ($f in @('bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs')) {
foreach ($f in @('index.mjs','bin\rea-shim.mjs','bin\rea-guide.mjs','client.js','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs','scripts\host-compat.mjs','scripts\check-updates.mjs')) {
$out = & node --check $f 2>&1
Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
}
Write-Host "`n=== 1b. both JSON readers tolerate a BOM ===" -ForegroundColor Cyan
# bin/rea-shim.mjs and scripts/setup.mjs read the same mirror.json and bundle.json.
# A BOM strip on one side only makes one file mean "there is a checkout" to the
# shim and "there is none" to setup, and the mirrored setup then re-mirrors the
# previous manifest. A bare JSON.parse(readFileSync(...)) is that divergence.
$jsBom = Join-Path $env:TEMP 'rea-verify-bom.cjs'
@'
const fs = require("node:fs");
const blind = [];
for (const file of ["bin/rea-shim.mjs", "scripts/setup.mjs"]) {
const text = fs.readFileSync(file, "utf8");
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
// readJson() itself is the strip; anything else must not parse a file raw.
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
blind.push(file + "@" + text.slice(0, m.index).split("\n").length);
}
}
if (!text.includes("replace(/^\\uFEFF/")) blind.push(file + ": no U+FEFF strip");
}
console.log("bom-blind reads: " + JSON.stringify(blind));
if (blind.length) process.exit(1);
'@ | Set-Content $jsBom -Encoding utf8
& node $jsBom 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
Check 'no BOM-blind JSON read' ($LASTEXITCODE -eq 0) 'see above'
Write-Host "`n=== 2. PowerShell parse + BOM ===" -ForegroundColor Cyan
# This script checks itself too: it is edited as often as the others, and an editor
# that drops the BOM would otherwise go unnoticed until someone adds CJK text here.
@@ -69,14 +94,66 @@ if (bad.length) {
& node $jsProbe 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
Check 'evaluated literals contain no control characters' ($LASTEXITCODE -eq 0) 'see above'
Write-Host "`n=== 5b. the pin comparison is not a major-prefix match ===" -ForegroundColor Cyan
# 2.0.10 shipped a false green here: "6.5.0".StartsWith("6.") is true, so a bundle
# still on 6.5.0 reported "consistent with pin 6.7.0" — and that line is the only
# signal a user has that a pin bump requires re-running setup. The rule lives in
# scripts/pin-match.ps1 so it can be checked without the registry/profile sections.
$pinMatchFile = Join-Path $root 'scripts\pin-match.ps1'
Check 'scripts/pin-match.ps1 exists' (Test-Path $pinMatchFile) ''
Check 'check-updates.ps1 uses Test-PinMatch' ((Get-Content (Join-Path $root 'check-updates.ps1') -Raw -Encoding UTF8) -match 'Test-PinMatch') ''
if (Test-Path $pinMatchFile) {
. $pinMatchFile
$pinCases = @(
@('6.5.0', '6.7.0', $false, 'older REA under a newer pin must not be a match'),
@('6.7.0', '6.7.0', $true, 'exact match'),
@('3.0.9', '3.0.3', $false, 'apktool above the pin must not be a match'),
@('21.0.12.1+1', '21', $true, 'major-only jdk pin names the family'),
@('21', '21', $true, 'exact major-only pin'),
@('24.11.1', '24.21.0', $false, 'a dotted node pin is one version'),
@('', '6.7.0', $false, 'no recorded version is never a match')
)
foreach ($c in $pinCases) {
$got = Test-PinMatch -Installed $c[0] -Pinned $c[1]
Check ("pin '{0}' vs '{1}' -> {2}" -f $c[0], $c[1], $c[2]) ($got -eq $c[2]) ("got ${got}: {0}" -f $c[3])
}
}
Write-Host "`n=== 6. package payload lists what the bundle needs ===" -ForegroundColor Cyan
# -Encoding UTF8 is required: the package description contains CJK text, and
# PowerShell 5.1 reads a BOM-less file as ANSI, which breaks ConvertFrom-Json.
$pkg = Get-Content (Join-Path $root 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($entry in @('bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
foreach ($entry in @('index.mjs', 'bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
Check "files[] includes $entry" (@($pkg.files) -contains $entry) ''
}
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test')) {
# The market's update path asks whether package.json's main/exports points at a
# file that exists, and rolls the update back when it does not (dshmarket's
# hasLoadableEntry). This package declared neither until 2.0.5, so every update of
# a healthy profile failed with "updated build has no loadable entry". Asserting
# the pointer resolves keeps that from returning silently.
Check 'package.json declares main' (-not [string]::IsNullOrWhiteSpace($pkg.main)) ''
Check 'main resolves to a shipped file' ($pkg.main -and (Test-Path (Join-Path $root $pkg.main))) "main=$($pkg.main)"
# The market decides "compatible" vs "unknown" from engines.dsh, and hides the
# plugin from hosts whose version does not match. An absent or unparseable
# declaration therefore reads as "nothing to say" rather than as a defect, which
# is why the coverage check runs here as well as in CI.
Check 'package.json declares engines.dsh' (-not [string]::IsNullOrWhiteSpace($pkg.engines.dsh)) ''
$compat = & node scripts\host-compat.mjs 2>&1
Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($compat | Select-Object -Last 2) -join ' ')
# Both READMEs quote the range verbatim, and a stale quote is invisible: JSON,
# host-compat and the audit all agree while the sentence a human reads names a
# different ceiling. That is exactly how `<0.3.0` survived in the docs after the
# declaration moved to `<0.3.0-0`, so the comparison lives here now.
foreach ($doc in @('README.md', 'README.zh-CN.md')) {
$text = Get-Content (Join-Path $root $doc) -Raw -Encoding UTF8
Check "$doc quotes the declared engines.dsh" $text.Contains([string]$pkg.engines.dsh) "expected $($pkg.engines.dsh)"
# The pin example is copy-pasteable, so it must name a ref that will exist: the
# version this tree publishes. It has drifted twice (v2.0.0 long after 2.0.5,
# then v2.0.8 while the tree was 2.0.9), and both times every other signal was
# green because nothing compared the two.
Check "$doc pins the current version" $text.Contains("dsh-plugin-rea#v$($pkg.version)") "expected dsh-plugin-rea#v$($pkg.version)"
}
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test', 'bundle:host-compat')) {
Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
}
@@ -125,6 +202,48 @@ for ($i = 0; $i -lt 5; $i++) {
try { Remove-Item -Recurse -Force $bootRoot -ErrorAction Stop; break } catch { Start-Sleep -Milliseconds 300 }
}
Write-Host "`n=== 9. the one-shot setup guide is wired end to end ===" -ForegroundColor Cyan
# The guide spans four files with no type system between them: the route in
# bin/rea-guide.mjs, the mount in index.mjs, the fetch in client.js, and the row
# in cordis.patch.yml. Two of those used to be free-form strings, so a rename in
# one place produced a guide that silently never appeared (the client treats a
# 404 as "this host has no route"). Compare them here instead.
$clientText = if (Test-Path (Join-Path $root 'client.js')) { Get-Content (Join-Path $root 'client.js') -Raw -Encoding UTF8 } else { '' }
Check 'client.js ships with the package' ($clientText -ne '') ''
$routeMatch = Select-String -Path (Join-Path $root 'bin\rea-guide.mjs') -Pattern "export const GUIDE_ROUTE = '([^']+)'" | Select-Object -First 1
$guideRoute = if ($routeMatch) { $routeMatch.Matches[0].Groups[1].Value } else { '' }
Check 'bin/rea-guide.mjs declares the guide route' ($guideRoute -eq '/dsh-plugin-rea/guide-state') "got '$guideRoute'"
Check 'client.js loads through the client module loader' ($clientText.Contains('window.__ModuleLoader__.load')) ''
Check 'client.js registers the guide on shell.overlay' ($clientText.Contains('rea-setup-guide') -and $clientText.Contains('shell.overlay')) ''
Check 'client.js reads the same route the host serves' ($clientText.Contains($guideRoute)) "expected '$guideRoute'"
$indexText = Get-Content (Join-Path $root 'index.mjs') -Raw -Encoding UTF8
Check 'index.mjs serves the guide route' ($indexText.Contains('GUIDE_ROUTE') -and $indexText.Contains('mountGuideRoute')) ''
Check 'index.mjs mounts it only when a webServer exists' ($indexText.Contains("ctx.inject(['webServer']")) ''
Check 'package.json declares dsh.client for the web surface' ($pkg.dsh.client.platform -eq 'web') ''
Check 'package.json ships client.js' (@($pkg.files) -contains 'client.js') ''
$patchText = Get-Content (Join-Path $root 'cordis.patch.yml') -Raw -Encoding UTF8
Check 'cordis.patch.yml mounts the guide row' ($patchText.Contains('rea-guide') -and $patchText.Contains("name: 'dsh-plugin-rea'")) ''
$guideRaw = & node bin\rea-guide.mjs --json 2>&1
$guideParsed = $null
try { $guideParsed = ($guideRaw -join "`n") | ConvertFrom-Json } catch { $guideParsed = $null }
Check 'bin/rea-guide.mjs answers with JSON' ($null -ne $guideParsed -and $null -ne $guideParsed.needsSetup) (($guideRaw | Select-Object -Last 2) -join ' ')
Check 'the guide command points at this package scripts' ($null -ne $guideParsed -and [string]$guideParsed.setupCommand -match 'scripts[\\/]setup\.mjs') ''
Write-Host "`n=== 10. npm run check-updates is portable ===" -ForegroundColor Cyan
# check-updates.ps1 is Windows-only on purpose (it reads %APPDATA% and prefers a
# rea.cmd shim over the rea.ps1 one). The npm script used to BE the bare
# `powershell -File` command, so on macOS/Linux `npm run check-updates` was an
# exec error, and a pwsh there would have answered from paths that do not exist.
# The wrapper is the contract: Windows forwards, everything else explains and
# exits non-zero.
$wrapper = Join-Path $root 'scripts\check-updates.mjs'
Check 'scripts/check-updates.mjs exists' (Test-Path $wrapper) ''
$checkScript = [string]$pkg.scripts.'check-updates'
Check 'scripts.check-updates goes through the wrapper' ($checkScript -match 'check-updates\.mjs') $checkScript
$wrapperText = if (Test-Path $wrapper) { Get-Content $wrapper -Raw -Encoding UTF8 } else { '' }
Check 'the wrapper runs PowerShell on Windows only' ($wrapperText.Contains("platform === 'win32' ? ['powershell', 'pwsh'] : []")) ''
Check 'the wrapper names both portable fallbacks' ($wrapperText.Contains('--shim-status') -and $wrapperText.Contains('--dry-run')) ''
Write-Host ''
if ($fail -eq 0) { Write-Host 'ALL CHECKS PASSED' -ForegroundColor Green }
else { Write-Host "$fail CHECK(S) FAILED" -ForegroundColor Red }