26 Commits
Author SHA1 Message Date
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
czcdr 3138bd15c3 2.0.1: put the version note at the top of both READMEs, and truth up the design notes
The npm package page renders the README straight from the published tarball, and its first
screen was title -> badge -> language line -> a 1280x720 screenshot, which pushed the 1.x/2.x
explanation below the fold — the 2.0.0 README therefore looked unchanged there.

- both READMEs: the version note (which line is this, what 1.x vs 2.x do, where 1.x lives)
  now sits directly under the language switcher; the comparison table and the upgrade
  section keep their places
- docs/bundle-design.md: main IS the bundle line now (it said "this branch, main unchanged"),
  and section 8 is about the approach, not a branch
- docs-only patch release on purpose: republishing is the only way npm re-renders that page
2026-10-15 16:40:08 +08:00
czcdr 83bd2c70fe 2.0.0: main becomes the one-plugin bundle line, with 1.x preserved on v1
main fast-forwards onto the bundle branch (no merge commit), so the default branch,
npm latest and the market all point at the all-in-one variant; the 1.x
configuration-only adapter is preserved on the new v1 branch and on npm 1.0.6.

- package.json 2.0.0-bundle.2 -> 2.0.0; CHANGELOG gains the 2.0.0 entry including the
  1.x -> 2.x upgrade steps (run setup once, then restart: the 2.x row launches a shim
  that only setup creates, so an unprovisioned upgrade loses the tools silently)
- both READMEs: a new "two lines" section comparing 1.x and 2.x, install commands
  switched to the npm name (plus a #v2.0.0 pin), an "Upgrading from 1.x" section, and
  a Verified-against note that separates what was exercised live from what was not
  (the JDK + Ghidra download branch, macOS/Linux first runs)
- assets/make-screenshots.ps1: the diagram now shows the 2.x chain (host Node ->
  mirrored shim -> vendored REA -> engines), and its param default no longer reads
  $PSScriptRoot, which is not populated while parameter defaults are bound under
  Windows PowerShell 5.1 — the documented invocation died there, so this script had
  never actually regenerated a screenshot. Both screenshots regenerated.
- docs/marketplace-entry.yml: 2.x description, and the stale "current v1.0.5" comment
2026-10-15 15:54:32 +08:00
czcdr 84fc9b1611 bundle: honour the manifest's optional field and check a reused Node
Two contracts this repository already states but the code did not keep, both
found by running the bundle on a real machine:

- manifest.components.androidPlatformTools declares optional: true (without a
  device these tools simply report no devices), but setup.mjs never read the
  field, so an unreachable dl.google.com - the only Google-hosted component -
  failed the whole run on a machine where everything REA needs had provisioned
  fine. An optional component's failure is now reported as such, kept out of the
  exit code, and left unrecorded so the next run retries it; a component that
  another component requires is never treated as optional.

- manifest.components.node.probe says the reuse path must reject an incompatible
  runtime, but adoption was fileExists-only: a machine whose PATH Node is 20
  recorded it in bundle.json, and the failure surfaced much later inside REA.
  Every candidate (REA_BUNDLE_NODE and each PATH hit, not just the first) now has
  to pass that probe; one that cannot even be run is rejected with its reason.

README: apktool's real Windows gate (upstream execFile's the launcher without a
shell, so the official .bat cannot work) and the fact that the CLI refuses to
manage the Electron app's desktop profile.

Tests: 21 (was 17); Windows 21/21 and WSL 21/21. Reverse-verified - disabling the
optional accounting reddens that test, disabling the reuse check reddens the
other two.
2026-10-14 20:04:49 +08:00
czcdr 9d86f828bf bundle: fix the bootstrap portable-Node path a bare machine hits
The clean-machine entry point threw AFTER a successful download, checksum and
extraction: \ stripped the extension twice, so it looked for
<stack>\node-v24.21\node.exe while the official zip unpacks to
node-v24.21.0-win-x64. Reachable only where no Node is on PATH — the entire
audience of this script — which is why unit tests, the audit, WSL and the
sandboxed end-to-end all missed it. Reproduced on a real machine by stripping
PATH down to the OS directories.

- strip the extension once, and accept any directory that actually carries
  node.exe: a naming drift must not strand a machine that already paid for the
  download and verified its checksum
- -DryRun now also recognises an already-unpacked portable Node (it looks,
  it never fetches) so the plan tells the truth about whether a download is
  needed
- verify-branch.ps1 section 8: with an empty PATH and the official layout, the
  bootstrap must find the portable Node and must plan no download — a
  zero-download regression guard, reverse-verified against HEAD (2 CHECK(S)
  FAILED, 'plan: download and extract ...' on the stack)
2026-10-14 19:50:07 +08:00
czcdr 65a2963029 bundle: fix the dry-run failure a bare machine sees, plus three side accounts
`--dry-run` reported the whole chain as broken on a clean machine. Every planned
component returns `{ planned: true }` with no path, and only a real path was
recorded in `context.resolved`, so each dependent plan entry was reported as
"required component ... is unavailable" — four FAIL lines and exit 1, for exactly
the reader the README tells to run `--dry-run` first. Adoption on a developer
machine hid it: with `--reuse` everything resolves and the identical command
exits 0.

Also:

* verify-branch.ps1 asserted "dry-run exits 0" through `--reuse`, so it passed
  precisely where the defect could not appear and would have failed on a bare
  machine. It now runs without `--reuse`, asserts that no dependency is reported
  unavailable, and checks its own BOM (it is the script edited most often).
* release.yml pinned its npm self-update to `npm@^11` rather than `latest`, so a
  publishing pipeline cannot acquire a new npm major on release day.
* check-updates.ps1's engines verdict answered "unsatisfied" for patterns it does
  not implement (`22.x`, `>=22`), sending users to upgrade a Node that is fine. It
  now answers "cannot be parsed". Verified through Windows PowerShell 5.1 against
  fake REA packages: 22.x → cannot parse, >=99.0.0 → unsatisfied, ^24.11.0 → ok,
  and removing the guard makes 22.x unsatisfied again.
* the audit compares the two READMEs' section and subsection counts, because the
  bilingual pair is maintained by hand and drifts the same way the patch did.

Verified: audit clean with 6/6 deliberate sabotages caught; 17/17 unit tests on
Windows and on Linux; `--dry-run` exits 0 both with and without `--reuse`; 25/25
end-to-end path checks; verify-branch ALL CHECKS PASSED; and the row → shim → MCP
handshake still returns rea 6.4.0 with 170 tools.
2026-10-14 19:19:31 +08:00
czcdr 419235ad45 bundle: make a bare rea-bundle-status report instead of provisioning
package.json exposes bin/rea-shim.mjs as the `rea-bundle-status` bin, and npm's
bin shim can only forward the arguments a user typed — it cannot append the
`--shim-status` the name implies. So the one command that most invites a bare run
fell through to the launch path: it started REA, and on an unprovisioned machine
it began an ~800 MB download into the bundle root.

An independent review found this while verifying the previous commit, and it also
correctly noted that the previous CHANGELOG overstated its fix: only the flagged
form (`rea-bundle-status --shim-status`) was corrected there. The claim is now
accurate, and the bare form is fixed.

A bare invocation prints usage and exits 0. The MCP row is unaffected because it
always passes `mcp` — confirmed by the handshake check (rea 6.4.0, 170 tools).

Pinned by a test that guards itself: it replaces the plugin's setup.mjs with a
stub, so even if this guard is ever removed the test reaches a stub rather than a
download. Deleting the guard turns that test red, which is how it was verified;
the sabotage run created no files and left no processes behind.
2026-10-14 18:53:51 +08:00
czcdr 30beb7f622 audit: pin the known rootless divergence, and note the npm bin entry
`!!js` cannot reconcile a machine with no DSH_HOME, USERPROFILE and HOME: the row
resolves to the filesystem root, while setup.mjs would go through os.homedir()
and land on the passwd entry. The behaviour is harmless — the row reports
MODULE_NOT_FOUND, writes nothing and downloads nothing, and the harness carries
on — so it is now pinned as a warning rather than left uncovered. An independent
review noticed the reason it had no coverage: all three environments the audit
evaluated kept either USERPROFILE or HOME, so nothing reached this case.

Also records that package.json's `rea-bundle-status` bin entry points at the same
checkout copy of the shim, which the previous commit therefore fixes as well —
verified against an installed layout (node_modules/dsh-plugin-rea with its
cordis.patch.yml): the reported bundle root matches <DSH_HOME>/plugins/rea-bundle.
2026-10-14 18:41:10 +08:00
czcdr 9f818e1f05 bundle: give each shim copy the bundle root it should have
The previous round made the shim resolve its bundle from its own location, so a
custom DSH_HOME could not send it looking in ~/.dsh. That is right for the
mirrored copy the published row launches — but wrong for the checkout copy a
human runs, whose own directory is the plugin, not a bundle:

* `npm run bundle:status` reported "not provisioned" on a fully provisioned
  machine, and the status line setup prints pointed at that same copy;
* a bare `node bin/rea-shim.mjs mcp` would have provisioned ~800 MB into the
  plugin directory.

Only a checkout ships cordis.patch.yml, so that file now discriminates: the
mirrored copy keeps using its own position (an MCP child has no DSH_HOME to
consult), while the checkout resolves the same root setup.mjs writes — including
a custom --root, which is why the status hint now names the mirrored copy.

The audit guards the discriminator by rejecting a mirror list that would copy it
along; mirroring it would silently flip a mirrored copy back to the DSH_HOME
rule.

The README's "install the plugin first" paragraph was also false: the row points
at a file setup creates, so before the first provisioning run it exits with
MODULE_NOT_FOUND and nothing starts a background provisioning. It now says so and
names the one manual step.

Verified: 16/16 unit tests on Windows and on Linux — three of them spawn the shim
in both layouts, and reverting the fix turns the checkout one red; audit clean
with 5/5 deliberate sabotages caught; 25/25 end-to-end path checks; and the row →
shim → MCP handshake still returning rea 6.4.0 with 170 tools.
2026-10-14 18:38:21 +08:00
czcdr 0bf5c3eee2 docs: record why the mirror list, the record and the quoting are what they are
The design document still showed the row launching `<plugin>/bin/rea-shim.mjs`,
but the patch points at the mirrored copy under the bundle root, and the shim
resolves its bundle from its own location rather than DSH_HOME — every DSH_* name
is stripped from an MCP child's environment, so the old description hid exactly
the failure mode that mattered.

It also now explains the two constraints this round established: a mirrored
`setup.mjs` reads `<bundle>/manifest/bundle.manifest.json`, so the manifest
travels with the copy, and `<bundle>/mirror.json` has to name the checkout or a
later mirrored run can only copy the bundle onto itself. The quoting rule for
`!!js` scalars containing a ternary is recorded next to the other design
constraints, since YAML reads the bare form as a key/value pair and the loader
then receives a mapping instead of an expression.
2026-10-14 13:41:31 +08:00
czcdr 8f1ea15f2a bundle: make the one-plugin flow actually work end to end
Three defects made the published flow impossible to complete:

* The !!js scalar in the MCP row was unquoted, and a bare scalar containing
  "? " and ": " is read by YAML as an explicit key/value pair. The loader
  therefore received a mapping, so args[0] was {"[object Object]": "..."} and
  the row could never start. Reproduced against the harness's own dialect
  (JSON_SCHEMA plus the !!js type definition read out of app.asar).
* The DSH_HOME fallback stopped one level above <home>/.dsh, while the shim,
  setup.mjs and bootstrap.ps1 all resolve to <home>/.dsh. DSH never sets
  DSH_HOME — zero writes anywhere in the archive — so this was the default
  path, not an edge case: the row pointed at a file provisioning never writes.
* The mirror step copied the shim and setup.mjs but not the manifest, and the
  mirrored setup derives its plugin root from its own location. Background
  provisioning therefore died on a bare ENOENT that stdio:"ignore" hid, while
  the lock it had written suppressed retries for six hours.

Also fixed:

* The shim locates its bundle from its own position. MCP children have every
  DSH_* name stripped (case-insensitively), so with a custom DSH_HOME the old
  shim looked in ~/.dsh, reported "not provisioned" and started a second
  ~800 MB run there.
* setup records the real checkout in <bundle>/mirror.json, so a mirrored or
  later-upgraded install finds the source instead of copying the bundle onto
  itself and provisioning the previous manifest forever.
* Empty REA_BUNDLE_ROOT / DSH_HOME no longer degrade the root to "" or a
  relative path, and --root/--cache are resolved (a relative root would be
  recorded in bundle.json and make every component look deleted).
* fail() no longer recommends a mirrored copy that cannot work, and
  check-updates.ps1 reports the migration state for old bundles.

Guarded by checks that were themselves reverse-tested — 4/4 deliberate
sabotages caught: unquoted !!js mapping detection, DSH_HOME agreement across
unset/empty/set, README-versus-patch drift, and mirror-list completeness. CI
now runs bundle:test and bundle:audit, and its YAML step registers the !!js
scalar constructor: safe_load had been rejecting the very row this repository
ships, so the workflow was permanently red.

Verified: audit clean; 13/13 unit tests on Windows and on Linux (WSL); 25/25
end-to-end path checks in a sandbox; and a full JSON-RPC handshake launched
from the row's own evaluated command line (rea 6.4.0, 170 tools) with DSH_HOME
absent from the child environment.
2026-10-14 13:40:16 +08:00
czcdr 422e4f2454 docs: record the mirror stage and the route-order rationale
bundle-design.md listed the setup stages without the mirror step, which is why
the missing mirror went unnoticed for so long: the document described a pipeline
that already assumed it. The stage list now matches the code, with the two
stages whose absence caused real failures explained in place.
2026-10-13 23:24:31 +08:00
czcdr 4a6d2e0a79 bundle: fix the four defects that made the one-plugin promise false
An external review of this branch found that the headline promise did not
actually work, and it was right. Each claim was checked before acting on it.

Fatal, now fixed:

- **The patch could never launch anything.** The JS string literal used
  backslashes, and a single-quoted YAML scalar keeps them literal for JavaScript
  to eat: '\plugins' is the BEL control character, '\rea-bundle' a carriage
  return, '\bin' a backspace. Evaluated, the path was
  "C:\...\.dshplugins<CR>ea-bundle<BS>in<CR>ea-shim.mjs". Forward slashes now,
  with a DSH_HOME fallback so an unset variable no longer yields
  "undefined/plugins/...".
- **Nothing created the file the patch points at.** `setup.mjs` contained no
  copy of the shim anywhere, so provisioning could finish and the row would
  still fail with MODULE_NOT_FOUND — and the documented "install the plugin
  first and it provisions in the background" could never happen, because the
  shim that would do that was never at the path the row launches. Setup now
  mirrors bin/rea-shim.mjs and scripts/setup.mjs into the bundle root, before
  the probe, which now tests the mirrored copy rather than the source tree.
- **`npm` was invoked from PATH.** The entire point of this branch is the
  machine without Node, where PATH has no npm, so resolving the REA dependency
  closure died with ENOENT on exactly the target machine. It now uses the npm
  that ships inside the provisioned Node (verified: the portable Windows zip
  puts it at <node>\node_modules\npm\bin\npm-cli.js and it runs).
- **`{exe}` was not substituted on POSIX.** The `resolved !== ""` guard in
  swap() rejected the empty string, so `adb{exe}` stayed literal on Linux and
  macOS and platform-tools failed its probe. Now replaced unconditionally, and
  the test mirror matches. (The review also claimed the existing test suite
  fails on ubuntu; it does not — the mirror derives EXE from the platform.)

Also from the review, verified and fixed:

- resolveProxy() is cached; it spawned reg.exe twice per download.
- sha256File() streams instead of reading the whole file, which for the Ghidra
  archive meant a 570 MB buffer.
- The provisioning lock uses an exclusive create, so two rows starting together
  cannot both provision.
- reaAgents' probe pointed at the pre-npm-install layout, reporting a healthy
  vendored copy as unverifiable on every run.
- The node probe's regex accepted Node 20, which REA's engines reject.
- bootstrap.ps1 read its Node version and checksum from a hard-coded pair that
  duplicated the manifest's pins; it now reads the manifest, refuses a TOFU
  pin, and its ARM64 check no longer passes while downloading the x64 zip.
- check-updates.ps1 gained the bundle section bundle-design.md promised.

Two bugs I introduced while fixing the above, both caught by running things
rather than reading them:

- Editing check-updates.ps1 stripped its BOM. PowerShell 5.1 then read the file
  as ANSI, mangling the CJK comments into parse errors. Restored, and the BOM
  is now asserted. bootstrap.ps1 was ASCII-only so it still parsed, but it has
  a BOM too now so a future Chinese comment cannot silently break it.
- The node probe's own-token fallback returned the vendor/node DIRECTORY, so
  the probe spawned a folder and failed with ENOENT while the extraction was
  fine — a self-inflicted replay of the bug class this commit fixes.

New: scripts/verify-branch.ps1, which checks syntax, PowerShell parse + BOM,
the manifest audit, the unit tests, that the patch is machine-path-free and
escape-safe, the published payload list, and a dry-run of the whole chain.

Verified end to end: with DSH_HOME set, provisioning reaches `setup complete`,
the file the patch computes exists, and launching it the way the row does
answers an MCP handshake as rea 6.4.0 with 170 tools.
2026-10-13 23:24:02 +08:00
czcdr 4689a53237 bundle: make --offline real, and pin the fragile logic with tests
Two more defects from exercising argument combinations rather than the happy
path, plus the regression tests that would have caught the earlier ones.

- `--offline` did not actually forbid the network. A cold cache still ran the
  first download route (the .NET route on Windows, the Node routes elsewhere)
  and only reported the offline constraint after that route failed. It is now
  refused before any route is attempted, naming the exact cache file it wanted.
  Verified on a cold root: it fails immediately with the reason and never opens
  a socket.
- `scripts/setup.test.mjs` (13 tests, `node --test`, runnable as
  `npm run bundle:test`) now pins the pure logic that broke during development:
  probe token substitution for every shape, including a JDK recorded as an
  executable and a component resolving its own token while it is being
  provisioned; the absence of the doubled `\bin\java.exe\bin\java.exe`; literal
  fallback instead of a throw for an unresolved token; root-directory stripping
  including the flat-archive and single-file cases; and the version/size
  helpers.

Two of the tests failed on first run, and in both cases the test was wrong
rather than the code: one fixture modelled `bin` as a file, and the stripping
fixtures used POSIX separators that `path.join` never produces on Windows. The
fixes are in the tests. That is worth stating plainly: a red test is a claim
about the code that has to be checked before it is believed.

Verified: 13/13 tests pass, the manifest audit is clean at 24/24 hash-pinned,
and `--offline` warm-cache, `--json`, and `--dry-run --reuse` all behave.
2026-10-13 22:44:45 +08:00
czcdr b10b016a35 bundle: pin every platform's checksum, add a manifest audit, fix a stale probe
All 24 fetch entries are now hash-pinned (was 17 pinned, 5 unverified and 1
missing size). Vendor-published values where one exists; measured-once values,
with their provenance, where the vendor publishes none:

- Node 24.21.0 for win32-x64, win32-arm64, darwin-x64, darwin-arm64, linux-x64
  and linux-arm64, all from SHASUMS256.txt, with the real sizes.
- Temurin 21.0.12.1+1 for win32-x64, darwin-x64, darwin-arm64, linux-x64 and
  linux-arm64 from the Adoptium API's binary.package.checksum.
- Android platform-tools for darwin and linux, measured by downloading both.
- The apktool wrappers, all three of them.

Bug fixes found while doing it:

- `darwin` had no Node source at all, so the manifest declared a platform it
  could not provision.
- The apktool wrapper for macOS pointed at scripts/linux/apktool. macOS needs
  scripts/osx/apktool, a different file with a different hash; it would have
  installed a wrapper that cannot start.
- The reaAgents probe still pointed at {vendor}/rea-agents, the pre-npm-install
  layout, so a healthy vendored copy was reported unverifiable on every run
  (harmless but misleading; it fell through to the reuse path).
- linux-arm64 and win32-arm64 only had a source in name; both are complete now.
- I briefly wrote an invented sha256 for the darwin platform-tools archive while
  editing. The audit caught it as a duplicate key, and the real hash replaced it.
  That is exactly why the audit exists and why `unverified` is a legal value
  while a guess is not.

New: scripts/audit-manifest.mjs, runnable as `npm run bundle:audit`. JSON.parse
cannot see duplicate keys, so it scans the raw text for them, then checks: every
entry has a legal sha256 (64-hex or the literal "unverified"), a size, and a
https URL; every declared platform has every source it needs; the apktool
wrapper matches the platform and lands in the same directory as the jar; every
probe token is one the substitution step understands; and `requires` names real
components without a cycle. Exit 0 clean, 1 with findings.

Verified: audit reports 24/24 pinned and clean, and a full provisioning run over
the rewritten manifest reaches `setup complete` with all seven components ok.
2026-10-13 22:43:17 +08:00
czcdr 727ead6a14 bundle: fix the probe token bugs that discarded downloaded components
A fresh-machine run now reaches `setup complete` with exit 0, provisioning
Node 24, Temurin 21, Ghidra 12.1.4, apktool 3.0.3, jadx-headless-mcp 0.7.1,
adb 37.0.1 and REA 6.4.0 from scratch with no reuse.

Three faults, all in this branch's own code, each of which threw away
components that had downloaded and unpacked correctly:

- A component could not resolve its own probe token before it was recorded, so
  the JDK and Ghidra steps failed their own probes with a literal `{jvm}` and
  `{ghidra}`. The JDK step alone discarded a fully downloaded 205 MB archive and
  a 543 MB Ghidra archive.
- `{jvm}` was built as `join({jdk}, "bin", "java.exe")`, but `{jdk}` resolves to
  the JDK's java executable (that is what `binaries` records). The result was
  `<...>\jdk\bin\java.exe\bin\java.exe`. {jvm} now derives JAVA_HOME defensively
  by stripping a trailing bin/java if present.
- Token expansion order produced the same double suffix from the other
  direction, so the ordering rule is now explicit and verified in isolation for
  every probe shape (apktool, adb, node, jdk, ghidra).

Also: the `binaries` entry is removed from the jdk component, because its
recorded path is used as JAVA_HOME and REA validates a JDK by looking for
bin/javac.exe there. `bundle.json` now records a JDK home, not java.exe.

Verified against the provisioned bundle only, with no system Node/JDK/Ghidra on
PATH: `rea doctor` reports ghidra configured=True available=True version=12.1.4,
native decompiler and java checks ok, 170 tools, and an MCP initialize handshake
through the shim answers serverInfo rea 6.4.0. The remaining unmet checks are
upstream host gates (Hopper is macOS-only, IDA needs user-owned IDA, two are
other clients' registrations).
2026-10-13 22:30:07 +08:00
czcdr f4d8910fcb bundle: make downloads survive this network (proxy, routes, resume, timeouts)
Provisioning could not complete on a machine whose network is selectively
broken, and the failures were not where they first appeared. Measured here:

- github.com release assets: direct Node = timeout/reset; through the local
  proxy = an infinite 301 rewriting the URL back to itself.
- nodejs.org: direct works but truncates the zip by exactly 860 bytes on every
  attempt, and the server advertises no accept-ranges, so no resume can close
  that gap. PowerShell/.NET completed both the 37 MB zip and a 205 MB JDK.
- dl.google.com: 404 through the proxy, fine directly.

So a single "use the proxy if configured" rule was wrong in both directions.
Changes:

- resolveProxy(): HTTPS_PROXY/HTTP_PROXY/ALL_PROXY, then the Windows registry
  (ProxyEnable/ProxyServer, including per-scheme values), with NO_PROXY honoured.
- Direct first, proxy second, and the winning route is logged. A proxy that
  answers 403/404 for a host is treated as a failed route, not a result.
- On Windows the .NET route runs first and the Node routes become the fallback,
  because .NET follows the redirect chain the Node routes cannot.
- Two clocks instead of one: a 15 s connect timeout so a dead route is declared
  dead quickly, and a 120 s idle timeout that every chunk pushes out, so a slow
  but healthy 570 MB transfer is not killed for taking long.
- writeBody now pipes and settles on the write stream's finish event. The
  previous out.end callback inside the response end handler raced the promise
  and left setup hanging (unsettled top-level await) after a transfer that
  looked complete.
- A short read now resumes from the exact offset; a local truncating-server
  test proves the resume path closes the gap (196481 to 200000 bytes).

Verified: node provisions to vendor/node, and the hang no longer reproduces.
2026-10-13 22:12:16 +08:00
czcdr 818eb082be bundle: fix defects found reviewing this branch's own code
Self-review (not upstream changes) caught real faults in the provisioning layer:

- A recorded component whose entry had no usable path (a previous run that
  could not provision it) was treated as ready, which permanently wedged every
  later run: 'node' reported available with path null, so reaAgents and the
  final probe failed forever. A recorded entry is now trusted only when its
  path exists AND the probe passes.
- Downloads had a single 10 s connect timeout and no resume. Measured CDN
  throughput here is 0.35-0.54 MiB/s, so a 570 MB Ghidra fetch could not
  finish. Transfers now retry with backoff, honour the 60 s default timeout
  (REA_BUNDLE_FETCH_TIMEOUT_MS / REA_BUNDLE_FETCH_RETRIES), resume a partial
  file with a Range request, restart cleanly if the server ignores Range, and
  report short transfers as resumable instead of fatal.
- process.kill(pid, signal) in the shim throws ERR_UNKNOWN_SIGNAL on Windows;
  the child's signal death is now reported as 128+n.
- {node} could not resolve while provisioning node itself (the token referred
  to a component not yet recorded), so the node step always failed its probe
  even though the extraction was correct.
- Root-directory stripping was a fragile two-step special case; it now descends
  one level per declared component. Removed dead code and a hard-coded jadx jar
  name, and dropped a stale comment about resume.

Verified: node now provisions from cache to vendor/node with the recorded
sha256, and 'recorded without a usable path; provisioning again' leads to a
passing probe instead of a stuck bundle.
2026-10-13 21:49:44 +08:00
czcdr 7aeb83ce30 bundle: one-plugin provisioning from a bare machine
Ship an all-in-one variant on its own branch: the published patch stays
machine-path-free and launches a shim that reads the per-machine state, so a
fresh DSH install gets working mcp__rea__* tools without hand-installing Node,
REA, a JDK or Ghidra.

- bin/rea-shim.mjs: resolves the bundle state, exports exactly the variables
  REA reads, execs the vendored CLI. Starts detached provisioning on first run
  and explains the remediation instead of breaking the harness.
- scripts/setup.mjs: staged, idempotent, resumable provisioning (detect,
  resolve, fetch, verify, unpack, wire, probe) with a manifest of pinned
  versions and checksums; --reuse adopts existing installs, --dry-run plans.
- scripts/rea-bundle-bootstrap.ps1: gets a Node-less Windows machine to a
  provisioned bundle using only PowerShell, then hands over to setup.
- manifest/bundle.manifest.json: component pins with the checksum provenance
  each one actually has (vendor-published vs measured-on-this-machine).
- docs/bundle-design.md: design, verified facts, and the upstream host gates
  that make some capabilities impossible on Windows rather than unconfigured.

Verified end to end on Windows 11 x64: setup reaches 'setup complete', and an
MCP initialize handshake through the shim answers serverInfo rea 6.4.0 with no
REA on PATH.
2026-10-13 21:44:06 +08:00
czcdr 3bcd313c7b 1.0.6: split README into English + zh-CN with a language switcher 2026-10-13 18:25:19 +08:00
czcdr 904425d992 check-updates: distinguish 'no installed version' from 'registry unreachable' 2026-10-13 18:15:41 +08:00
czcdr 4ce902d58d docs: state the real description length (201) 2026-10-13 18:05:46 +08:00
22 changed files with 6210 additions and 324 deletions

No files matched your search

+25 -1
View File
@@ -71,7 +71,31 @@ jobs:
registry-url: 'https://registry.npmjs.org'
- name: npm CLI must support OIDC trusted publishing (>= 11.15.0)
run: npm install -g npm@latest && npm --version
# Pinned to the major that provides trusted publishing. `npm@latest` would
# silently follow npm into a future major on release day, which is the one
# moment a publishing pipeline should not acquire new behaviour.
run: |
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
+92 -11
View File
@@ -25,10 +25,16 @@ 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);
console.log('manifest ok');
"
@@ -44,16 +50,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', '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) {
@@ -67,8 +72,17 @@ jobs:
run: |
python3 -m pip install --quiet --disable-pip-version-check pyyaml
python3 - <<'PY'
import glob, sys, yaml
files = ['cordis.patch.yml', 'docs/marketplace-entry.yml'] + sorted(glob.glob('examples/*.yml')) + sorted(glob.glob('.github/workflows/*.yml'))
import glob, yaml
# Every shipped row carries a `!!js` tag whose scalar the DSH loader
# evaluates at runtime. safe_load knows nothing about it and rejects the
# file this repository exists to ship, so register a scalar passthrough:
# indentation, quoting and structure are still validated, which is what
# this step is for.
yaml.SafeLoader.add_constructor(
'tag:yaml.org,2002:js',
lambda loader, node: loader.construct_scalar(node),
)
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)
@@ -78,10 +92,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
@@ -96,6 +109,12 @@ jobs:
run: |
python3 - <<'PY'
import sys, yaml
# The row carries `!!js` scalars, which the harness evaluates at load
# time. Register a passthrough so this step can inspect the structure.
yaml.SafeLoader.add_constructor(
'tag:yaml.org,2002:js',
lambda loader, node: loader.construct_scalar(node),
)
doc = yaml.safe_load(open('cordis.patch.yml', encoding='utf-8'))
row = doc[0]['insert'][0]
assert row['id'] == 'mcp-rea', row
@@ -104,5 +123,67 @@ jobs:
assert cfg['serverName'] == 'rea', cfg
assert cfg['transport'] == 'stdio', cfg
assert 'command' in cfg and 'args' in cfg, cfg
# A bare `? a : b` scalar is read as an explicit key/value pair, so the
# loader receives a mapping and the row cannot launch. Keep args[0] a
# scalar: this is the exact shape that shipped broken once.
for i, arg in enumerate(cfg['args']):
assert isinstance(arg, str), f'args[{i}] is not a scalar: {arg!r} — quote the !!js scalar'
print('row ok:', cfg['command'], cfg['args'])
PY
- name: The harness's own dialect still parses the row
run: |
# The steps above use pyyaml; this one loads the row through the same
# library the harness uses (js-yaml) with its own !!js type, so a shape
# that only pyyaml tolerates cannot ship.
npm install --no-save --silent --no-audit --no-fund js-yaml@4
node - <<'JS'
const { readFileSync } = require('node:fs');
const yaml = require('js-yaml');
const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: (data) => typeof data === 'string',
construct: (data) => ({ __jsExpr: data }),
});
const doc = yaml.load(readFileSync('cordis.patch.yml', 'utf8'), { schema: yaml.JSON_SCHEMA.extend([JsExpr]) });
const cfg = doc[0].insert[0].config;
const args = cfg.args;
if (typeof cfg.command !== 'object' || typeof cfg.command.__jsExpr !== 'string') throw new Error('command is not an expression node');
if (typeof args[0] !== 'object' || typeof args[0].__jsExpr !== 'string') {
throw new Error(`args[0] is not an expression node: ${JSON.stringify(args[0])}`);
}
const path = new Function('process', `return (${args[0].__jsExpr})`)({ env: { USERPROFILE: 'C:\\Users\\probe', HOME: '/home/probe' } });
if (!path.endsWith('/.dsh/plugins/rea-bundle/bin/rea-shim.mjs')) throw new Error(`unset DSH_HOME resolves to ${path}`);
console.log('dialect ok:', cfg.command.__jsExpr, '->', path);
JS
- name: Unit tests for the pure logic
# Probe-token substitution and root stripping broke silently during
# development; these tests pin them. They are platform-sensitive by
# design, which is exactly why they must run on Linux too.
run: npm run bundle:test
- name: Bundle manifest audit
# 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
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
+305 -1
View File
@@ -2,6 +2,310 @@
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
## 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
**仅文档与版式,无行为变更。**
- 两份 README 的**语言行正下方**现在先说明「这是哪个版本、1.x 与 2.x 各做什么」——此前版本说明排在
首屏那张大截图之后,npm 页面首屏看不到,容易被误判成「README 没改」;对比表与升级章节位置不变。
- `docs/bundle-design.md` 的措辞跟上「`main` 已经是一体包线」这一事实(原先写的是「本分支新增、
`main` 不变」)。
- 为什么纯文档也发一个补丁版:npm 的包页渲染的是**发布进 tarball 的 README**,不重新发布就更新不了首屏。
## 2.0.0 — 2026-10-15
**一体包线的首个正式版**(此前的 `2.0.0-bundle.1` / `2.0.0-bundle.2` 是它的两个预发布)。
`main` 现在就是这一条;1.x 的纯配置适配器保留在
[`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),npm 上仍可装 `dsh-plugin-rea@1.0.6`。
### 🔴 从 1.x 升级:先跑一次供给,再重启
1.x 的行指向 PATH 上的 `rea mcp`;2.x 的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
而**那个文件只有 `setup.mjs` 会创建**。所以从 1.x 升上来后直接重启会:
- 让该行以 `MODULE_NOT_FOUND` 退出(harness 本身不受影响 —— `failOnStartupError: false` 是刻意的),
于是 `mcp__rea__*` 工具消失,而且**不会得到任何修复提示**:会打提示的 shim 本身还不存在;
- 正确做法是在插件目录跑一次 `node scripts/setup.mjs`(或 `scripts\rea-bundle-bootstrap.ps1 -Reuse`),
再重启 dsh,工具就回来了。此后每一次「状态缺失」都由 shim 自己发现并在后台补供给。
(如果你在 profile 的 `cordis.patch.yml` 里按 id 覆盖过 `mcp-rea`(例如钉 `cmd.exe /c rea mcp`),
那条覆盖会继续生效,升级不影响你。)
### 三个致命缺陷(按旧版说明走的用户每一步都会撞上)
- **MCP 行里的 `!!js` 标量没有加引号。** 裸标量同时含 `? ` 与 `: ` 时,YAML 把它读成**显式键值对**,
loader 拿到的是 mapping 而不是表达式节点。用 harness 自己的方言实测(`JSON_SCHEMA` + `app.asar` 里的
`!!js` 类型定义),`args[0]` 变成 `{"[object Object]": "..."}` —— 这一行**从来没有可能启动**。
现改为双引号标量,内部 JS 字符串用单引号。
- **`DSH_HOME` 的兜底少了一层 `.dsh`。** 行回退到裸 home,而 shim、`setup.mjs`、`bootstrap.ps1` 都回退到
`<home>/.dsh`。全归档扫描确认 **DSH 从不设置 `DSH_HOME`**(宿主进程内 0 处写入,只在 shell 工具子进程的
执行覆盖层注入),所以这是**默认路径**而不是边缘情况:行永远指向供给不会写的位置。
- **镜像副本缺 manifest。** 镜像后的 `setup.mjs` 用它自己所在的位置推导 plugin root,于是 `loadManifest()`
去读 `<bundle>/manifest/bundle.manifest.json` —— 一个只有镜像步骤才能创建的文件。由 shim 触发的后台供给
会以裸 ENOENT 崩溃;`stdio: "ignore"` 让它完全不可见,而它已写下的 lock 又让重试被抑制六小时。
### 🟡 一并修掉的
- **shim 改用自身位置定位 bundle。** MCP 子进程的环境会剥掉所有 `DSH_*`(大小写不敏感),所以自定义
`DSH_HOME` 时旧 shim 会去 `~/.dsh` 找 `bundle.json`、报"未供给",然后**在错位置再供给一次 800 MB**。
`bundleRoot()` 现在返回 `<shim 所在目录>/..`,与 `DSH_HOME` 是否可见无关。
- **新增镜像记录 `mirror.json`。** 真源运行会写下插件 checkout 的位置;镜像副本据此回到真源,插件升级后
不会永远用旧 manifest 供给。记录失效(目录被移动/删除)时优雅回退到本地副本。
- **空值环境变量。** `REA_BUNDLE_ROOT=""` / `DSH_HOME=""` 原先会把 root 算成 `""` 或相对路径 —— 前者
`mkdir ''` 崩在任何日志之前,后者把 bundle 装进 cwd。现在统一 trim 后回退;`--root`/`--cache` 一律
`path.resolve`(相对 root 会让 `bundle.json` 记下相对路径,shim 随即误判组件"已消失"并重新供给)。
- **`fail()` 的修复命令**不再指向"存在但必然崩溃"的镜像副本;没有可信副本时给出明确指引。
- **`check-updates.ps1`** 增加「镜像 manifest 是否就位」检查,指向上面那条升级路径。
- **shim 的两份副本各自解析出正确的 bundle root。** 上面那条"用自身位置"只在**镜像副本**上成立:
插件 checkout 里的那份是插件而不是 bundle,于是 `npm run bundle:status` 会在已供给的机器上报
"未供给",而裸跑 `node bin/rea-shim.mjs mcp` 会把约 800 MB 下载进**插件目录**。现在用
`cordis.patch.yml`(只有 checkout 会带)区分两者:镜像副本用自身位置(MCP 子进程里读不到
`DSH_HOME`),checkout 副本按与 setup 相同的规则解析。setup 结尾打印的 `status:` 命令也改为指向
镜像副本,这样它对自定义 `--root` 同样成立。
- **裸跑 shim 不再启动任何东西。** `package.json` 把这个文件暴露为 `rea-bundle-status` bin,而 npm 的 bin
转发只能原样传参、**无法追加 flag**,于是"裸跑一个叫 status 的命令"会掉进启动路径:在已供给的机器上
启动 REA,在未供给的机器上**开始约 800 MB 下载** —— 与这个名字承诺的正好相反。现在无参数时打印用法并
退出(真实退出码 0),MCP 行不受影响,因为它始终显式传 `mcp`。
- **`--dry-run` 在裸机上误报失败并退出 1。** 计划项返回 `{ planned: true }`(没有 `path`),而只有真实
`path` 才被计入 `context.resolved`,于是每个依赖前序组件的计划项都被判成"依赖不可用":在**裸机** —— 恰恰
是 README 建议先跑 `--dry-run` 的那台机器 —— 整条链报 4 条 FAIL 且退出码为 1。现在计划项同样满足下游的
`requires`。(本机 `--reuse` 会 adopt 成功,所以这个缺陷只在干净机器上可见。)
- **`release.yml` 的 npm 自更新**由 `npm@latest` 钉到 `npm@^11`:发布当天不该让流水线获得新行为。
- **`check-updates.ps1` 的 engines 判定**:不认识的 pattern(`22.x`、`>=22` 之类)此前返回"不满足",误导
用户去升级一个本来就满足的 Node;现在返回"无法解析"。三条实测:`22.x` → 无法解析、`>=99.0.0` → 不满足、
`^24.11.0` → 满足。
- **README 的「先装插件也可以」改为诚实描述。** 发布的行指向的镜像 shim **由 setup 创建**,所以在第一次
供给之前该行以 `MODULE_NOT_FOUND` 退出 —— 没有任何东西能启动后台供给。首次必须手动跑一次
`setup.mjs`,此后才由 shim 自动补供给。
### 🔴 真机验证(Windows 11 x64,Electron 宿主 0.2.0-rc.2)当场抓到的
- **bootstrap 的便携 Node 路径必崩。** `$nodeFolder` 被连续剥了两次扩展名:`node-v24.21.0-win-x64.zip`
→ `node-v24.21`,而官方 zip 解出来的目录是 `node-v24.21.0-win-x64`。于是在**没有任何 Node 的机器**上
—— 也就是这个脚本唯一的读者 —— 它会先成功下载(35 MiB)、校验通过、解压完成,然后抛
`the Node zip did not contain node-v24.21`。本机有 Node,永远走不到这段分支,所以单测、审计、WSL 与
沙箱端到端全都看不见它;真机复现(清空 PATH 模拟无 Node)一次即中。
现在只剥一次,且解压后**接受任何真的带 `node.exe` 的目录**:已经付过下载代价的机器不该因为一次命名
漂移而卡住。`-DryRun` 也会去认已经解压好的便携 Node(只认、不下载),这正是下面那条回归检查能
**零下载**判定它的原因。
### 🟡 真机验证顺带修掉的(判据都来自仓库自身)
- **manifest 的 `optional: true` 被忽略。** `androidPlatformTools` 明写可选("没有设备时这些工具只是报告
没有设备"),而 `setup.mjs` 里 `optional` 出现 **0 次**:dl.google.com 不可达时整轮 exit 1 —— 其余组件
都供给好了,却因为 adb 失败。现在可选组件的失败**照实报告**(`optional component(s) unavailable`),但
不计入退出码、也不写进 `bundle.json`(shim 因此不会导出 `REA_ADB_PATH`);被别的组件 `requires` 的组件
永不被当作可选,免得"依赖失败却退出 0"。
- **`--reuse` 采纳 Node 只看"文件存在"。** manifest 的 `probe.why` 明写"reuse 路径必须在这里拒绝",但旧代码
只做 `fileExists`:PATH 上第一个是 Node 20 的机器会把它记进 `bundle.json`,失败推迟到 REA 启动才以难读的
形式出现。现在每个候选(`REA_BUNDLE_NODE` 与**每一个** PATH 命中)都要先过 manifest 的版本闸门;跑不起来
的候选(Windows 上 `.cmd`/`.bat` 包装器即 EINVAL)同样拒绝,并把原因打出来。
- **README:apktool 在 Windows 上实际不可用**(能力矩阵新增一行)——上游 `execFile` 不加 shell 直接起该命令,
而 Windows 不能这样执行 `.bat`/`.cmd`。setup 的 apktool 探针走 `java -jar`(绕过包装器)所以报 `[ok]`,
落差只在运行时暴露。
- **README:Electron 宿主不能用 `dsh plugin --profile desktop …`**(CLI 硬编码拒绝 `desktop` 这个 profile 名),
改为指向应用内插件管理器。
### 回归防护
- `audit-manifest.mjs` 新增六项,全部做过反向验证(**故意写坏 6/6 都被抓住**):`!!js` 裸标量 mapping
检测、`DSH_HOME` 三态(未设/空/已设)求值一致性、README 与 patch 表达式不得漂移、镜像清单必须含
manifest、镜像清单**不得**携带 `cordis.patch.yml`(它是 shim 区分两份副本的判据,被镜像进去会让镜像
副本退回 `DSH_HOME` 规则)、**两份 README 结构平行**(节/子节数量,双语靠手工同步)。另外把
「`DSH_HOME`/`USERPROFILE`/`HOME` 全空 → 根路径」这个**已知无害行为**钉成 warn(此前无覆盖):该情形下
该行报 `MODULE_NOT_FOUND`,不写文件、不下载,harness 照常。
- `validate.yml`:YAML 步骤注册 `!!js` 标量构造器(原先 `safe_load` 直接拒绝这个仓库要发布的行 ——
**CI 一直是红的**);行契约步骤断言 `args[0]` 是标量、不是 mapping;新增 `bundle:test` 与 `bundle:audit` 两步。
- `verify-branch.ps1`:dry-run 断言改为**不带 `--reuse`**(旧写法在本机 adopt 成功时正好掩盖了上面那个
裸机缺陷),并新增"不得出现依赖不可用"一条;它现在也检查**自己**的 BOM,因为它是被编辑得最频繁的脚本。
- `verify-branch.ps1` 新增第 8 节:**空 PATH + 官方 zip 布局**下,bootstrap 必须认出已解压的便携 Node、
且不得计划下载(`-DryRun`,零下载、零副作用)。反向验证:把该脚本 checkout 回 HEAD 版(双重剥离)
→ 两条断言立刻变红(`2 CHECK(S) FAILED`/`plan: download and extract ...`)。
- 单元测试新增四条**真的去 spawn shim**的用例(镜像布局、checkout 布局、`REA_BUNDLE_ROOT` 覆盖、裸跑只
打印用法),并逐条做过反向验证:把 shim 改回"一律用自身位置"时 checkout 那条会红;去掉裸跑守卫时裸跑
那条会红(后者自带桩保护,即使回退也只会跑到桩、不会下载)。
- 单元测试里的 POSIX 退化断言加平台守卫(`EXE` 为空时 `endsWith("java")` 恒真,在 Linux 上必红;
WSL 实测修前 12/13、修后 17/17)。
- 单元测试新增四条(共 **21** 条;Windows 21/21、WSL 真 Linux 21/21):`--reuse` 仍采纳兼容 Node、拒绝
manifest 版本闸门不认的候选(用**改了正则的合成 manifest** 判定,跨平台确定,不依赖机器上恰好有什么)、
拒绝跑不起来的候选、以及"可选组件失败不致命"。反向验证:把可选记账改回旧行为 → 那条立刻变红;把复用
检查改回旧行为 → 另两条立刻变红;两次都确认了源码按字节复原。
### 升级说明
已部署过 `2.0.0-bundle.1` 的机器**无法自愈**(旧镜像既缺 manifest 也没有真源记录):请从**插件目录**
重跑一次 `node scripts/setup.mjs`。
## 1.0.6 — 2026-10-13
文档结构调整,无行为变更。
- **README 拆成两份**:`README.md`(English,GitHub 默认展示)与 `README.zh-CN.md`(简体中文),
两份顶部互相链接。此前是单文件里英文、中文各占一半,另一种语言的读者要先滚过整段才能到自己的部分。
- **改为真正的分节标题**:原来大量用**粗体当小标题**,加上一个横跨半篇的引用块,整页读起来像一堵墙;
现在每种语言各有一份正常的 `##` 结构,并把「安装」拆出「前置条件 / 安装插件」两小节。
- **内容要点一条未删**:第三方适配声明(不含上游代码、不由上游维护、问题去向)、能力边界
(无法完整还原源码 / 不可恢复清单 / 置信度与限制 / 正规获取渠道)、实测矩阵、三种引擎配置写法
(OS 变量、`env:` 块、`!!js`)、profile 按 id 覆盖、`serverName` 唯一、验证步骤、
`failOnStartupError: false` 与权限注意、更新语义(本插件 / REA / Ghidra / harness)、许可证归属。
- `package.json` 的 `files` 与 `validate.yml` 的 npm 载荷断言同步收录 `README.zh-CN.md`。
## 1.0.5 — 2026-10-13
把 1.0.4 留下的形状问题收尾,并让承诺过的 CI 真正跑在仓库上。
@@ -17,7 +321,7 @@
两个 registry 都查不通时不再静默跳过版本比对;`rea` 优先解析 `rea.cmd`(与发布行的
`cmd.exe /c rea` 对齐,此前 `Get-Command rea` 会先命中 `.ps1` 垫片);`rea doctor` 非零退出码会提示。
- **`assets/make-screenshots.ps1` 补 BOM**:该文件自己的注释要求 UTF-8 with BOM,此前漏做。
- **`package.json` description 真正缩短**:239 → 约 186 字符(1.0.4 只是改写,仍会被 npm 列表页截断)。
- **`package.json` description 真正缩短**:239 → 201 字符(1.0.4 只是改写,仍会被 npm 列表页截断)。
- **文档口径修正**:README 的 harness 版本拆成「桌面运行时 / 自带 dsh CLI」两个口径;
投稿条目注释里的 Release 版本改为当前值。
+267 -187
View File
@@ -2,255 +2,335 @@
[![npm version](https://img.shields.io/npm/v/dsh-plugin-rea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/dsh-plugin-rea)
把 **[REA — Reverse Engineer Anything](https://github.com/morluto/rea)** 接入 **DeepSeek Harness (dsh)**:
一个**纯配置型 Cordis bundle**,复用官方 MCP 客户端插件 `@deepseek-ai/dsh-mcp-client`,
把本机已安装的 `rea mcp`(stdio)注册成原生工具。装载后模型直接拥有 `mcp__rea__*` 工具。
**English** · [简体中文](README.zh-CN.md)
> **与上游的关系(重要)**
> 本仓库是**第三方适配封装,不是 REA 项目的组成部分**,也不包含 REA 的任何源码,
> 更不由 REA 作者维护。它只做一件事:向 dsh 的配置组合里插入一条指向你本机 `rea` 安装的 MCP 服务器记录。
> - 上游项目:**REA — Reverse Engineer Anything** — <https://github.com/morluto/rea>(MIT)
> - 上游文档:<https://github.com/morluto/rea#what-you-can-analyze> · [中文](https://github.com/morluto/rea/blob/main/README_zh.md#可以分析什么) · 站点 <https://rea.tools/>
> - 上游 npm 包:`rea-agents`
> - 本插件仓库:**<https://github.com/czcdr/dsh-plugin-rea>** · npm:`dsh-plugin-rea`
> - 问题反馈:REA 本身的问题请提到上游;本插件的问题提到 [本仓库 Issues](https://github.com/czcdr/dsh-plugin-rea/issues)。
> **Which version is this? 2.x — and `main` is it.** 2.x provisions the runtime itself (Node, the REA CLI, a
> JDK, Ghidra) and then wires the row: install the plugin, run `scripts/setup.mjs` once, restart dsh.
> **1.x** is the older configuration-only adapter — it expects `rea` on PATH and Ghidra installed by you —
> 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).
---
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__*`.
![dsh-plugin-rea:会话经官方 MCP 客户端行调用本机 rea mcp,再把 Evidence 带回会话](assets/screenshot-1.png)
![dsh-plugin-rea: the session calls the official MCP client row, which launches the bundle's shim; the shim starts the provisioned REA and the Evidence comes back](assets/screenshot-1.png)
## English
## What this is
**What it does.** This plugin performs **no reverse engineering itself**. It connects the upstream REA
MCP server to DeepSeek Harness, so the tools REA ships become callable in a session as
`mcp__rea__*`.
A **Cordis bundle with a provisioning layer**. `package.json` declares `dsh.bundle.patch`, and that patch
inserts one row of the official MCP client plugin (`@deepseek-ai/dsh-mcp-client`). The row launches
`bin/rea-shim.mjs` with the running Node executable; the shim reads the machine-local state that
`scripts/setup.mjs` wrote and starts the REA CLI it provisioned. There is no analysis code here, and the
published patch contains no machine path.
In upstream's own words, REA is *"one MCP for reverse engineering across binaries, applications, and
runtime behavior"* — *"see a feature you like. Understand how it works, down to the binary level."* It
*connects your agent to tools for inspecting native binaries, JavaScript and Electron apps, .NET
assemblies, and websites*; analysis runs locally, and results include the evidence and limitations
behind each conclusion. Native analysis can use an existing Hopper, Ghidra, or IDA installation, and
static JavaScript analysis needs no native engine.
> **Third-party adapter.** This repository is not part of REA, ships none of REA's code, and is not
> maintained by REA's authors. It composes one configuration row and fetches upstream's own releases.
> Upstream: [github.com/morluto/rea](https://github.com/morluto/rea) (MIT) ·
> [docs](https://github.com/morluto/rea#what-you-can-analyze) ·
> [rea.tools](https://rea.tools/) · npm package `rea-agents`.
> REA's own problems go to its tracker; **this plugin's** problems go to
> [this repository's issues](https://github.com/czcdr/dsh-plugin-rea/issues).
Upstream's own coverage list (`README.md#what-you-can-analyze`): native binaries — pseudocode,
assembly, strings, symbols, calls and references; JavaScript/Electron apps — modules, imports, source
maps, routes, IPC; websites — page structure, scripts, network observations, screenshots; .NET
assemblies; Android APKs; Android devices over adb; JEB projects; Android resources; firmware;
packages and assets; process behavior; plus offline ELF layout, EVM bytecode, recorded Linux crashes
and saved network captures.
**It adds exactly three things:** the tool registration under the `mcp__rea__*` names, the per-machine
engine environment (`GHIDRA_INSTALL_DIR`, `JAVA_HOME`, `REA_ADB_PATH`, `REA_APKTOOL_COMMAND`,
`REA_JADX_MCP_JAR`, `REA_BROWSER_EXECUTABLE`, …) scoped to that MCP child instead of the whole harness, and
the provisioning that puts those components on disk.
**What this plugin adds.** Exactly two things: it registers that MCP server into DSH's tool layer
under the `mcp__rea__*` names, and it lets the engine environment (`GHIDRA_INSTALL_DIR`, …) be scoped
to that MCP child process instead of the whole harness.
## What it can and cannot do
**What it can and cannot do.** The analysis is upstream's; this section only restates upstream's own
contract so expectations are right.
The analysis is upstream's; this section only restates upstream's own contract so expectations are right.
- **It cannot recover complete source code.** For native binaries upstream returns *pseudocode and
assembly*, for Android APKs *decompiled methods*, for JEB units *pseudocode*, and for
JavaScript/Electron *modules and their relations*. Upstream's tool contract states the pseudocode
"is decompiler output, not original source and not text-equivalent" to another provider's output.
- Not recoverable in practice: original identifier names, types, comments, macros and template
expansion, build scripts and assets. Optimisation (inlining, reordering), symbol stripping,
obfuscation, packing and VM protection only make the output worse.
- Every result carries **confidence and limitations** (`observed` / `derived` / `inferred`, plus an
explicit limitations list). Read them as evidence, never as recovered source.
assembly*, for Android APKs *decompiled methods*, for JEB units *pseudocode*, and for JavaScript/Electron
*modules and their relations*. Upstream's tool contract states the pseudocode "is decompiler output, not
original source and not text-equivalent" to another provider's output.
- Not recoverable in practice: original identifier names, types, comments, macros and template expansion,
build scripts and assets. Optimisation (inlining, reordering), symbol stripping, obfuscation, packing and
VM protection only make the output worse.
- Every result carries **confidence and limitations** (`observed` / `derived` / `inferred`, plus an explicit
limitations list). Read them as evidence, never as recovered source.
- Good for: understanding behaviour, locating an implementation, interoperability and compatible
re-implementation, security review, migration reference. To obtain actual source code, use the
legitimate route — the upstream repository, vendor authorisation, or the vendor's own SDK.
re-implementation, security review, migration reference. To obtain actual source code, use the legitimate
route — the upstream repository, vendor authorisation, or the vendor's own SDK.
**Verified against** (author's machine, 2026-10-12/13):
**What provisioning cannot buy you.** These are upstream host gates, not settings this plugin forgot:
| Component | Version |
| Capability | Gate |
|---|---|
| REA (`rea-agents`) | **6.4.0** |
| 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; npm latest `0.0.1-rc.1` |
| Ghidra | 12.1.4 (REA currently accepts 12.1.x) |
| Node.js | 24.21.0 (REA engines: `^22.19.0 \|\| ^24.11.0 \|\| >=26.0.0`) |
| Host | Windows 11 x64 |
| Hopper provider | macOS only |
| IDA provider | needs your own IDA and `REA_IDA_MCP_CONFIG`; REA never launches IDA |
| firmware `binwalk` / `unblob` | Linux only |
| Wakaru JS recovery, pwntools ELF/recorded-crash paths | Linux x64 only |
| pwndbg / gdb core context | Linux, macOS |
| native mitmproxy captures | Linux (`REA_MITMDUMP_COMMAND`); HAR needs no engine |
| Apple toolchain (Mach-O, plist, asset catalog, LLDB) | macOS only |
| `capture_process_scenario` | Linux/macOS PTY |
| apktool (Android resource decoding) | available only when `REA_APKTOOL_COMMAND` points at something Node can **execute directly**: upstream `execFile`s that command without a shell, and Windows cannot execute a `.bat`/`.cmd` that way (EINVAL). This bundle wires the official `apktool.bat` as upstream documents, so on Windows the launcher reports no version and the capability stays unavailable; JADX (a jar) is unaffected |
The shipped row was exercised live: `list_documents` answered through the `cmd.exe` form, and
`open_binary` completed with the `ghidra` provider while the engine directory came only from a
profile-level override.
## Two lines: 1.x and 2.x
**Prerequisites.** Node.js (a version REA accepts) and the REA CLI installed globally
(`npm i -g rea-agents`). For native analysis, Ghidra 12.1.x (Hopper / IDA are alternatives under
their own licences). Static JavaScript/Electron and .NET analysis need no engine.
| | **2.x** — this line, `main` | **1.x** — [`v1`](https://github.com/czcdr/dsh-plugin-rea/tree/v1) |
|---|---|---|
| What it is | the adapter **plus provisioning**: it prepares Node, the REA CLI, a JDK and Ghidra, then wires the row | configuration-only adapter: one MCP row pointing at a `rea` you installed yourself |
| What you need first | nothing for the runtime — provisioning fetches what is missing (with `--reuse` it adopts an existing Ghidra/JDK instead) | Node.js on REA's engines, `npm i -g rea-agents`, and Ghidra 12.1.x for native analysis |
| First run | run `scripts/setup.mjs` (or the bootstrap) once, then restart dsh | nothing to run |
| The row launches | the running Node executable plus `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`, which reads that machine's state | `cmd.exe /c rea mcp` on Windows, `rea mcp` on macOS/Linux |
| Machine state | `<DSH_HOME>/plugins/rea-bundle/` — `bundle.json`, `vendor/`, `cache/` | your own REA and Ghidra installs |
| Install | `dsh plugin --profile <profile> add dsh-plugin-rea` (npm `latest` ≥ 2.0.0) | `… add [email protected]`, or `github:czcdr/dsh-plugin-rea#v1.0.6` |
**Install.**
Both lines ship the **same patch shape** — one `insert` row of `@deepseek-ai/dsh-mcp-client` — and offer the
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:
```
powershell -ExecutionPolicy Bypass -File scripts\rea-bundle-bootstrap.ps1 -Reuse
```
`-Reuse` adopts a Ghidra/JDK you already have; omit it to download both (about 775 MB from the official
releases, checksum-verified). Then install the plugin for your profile:
```
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea
# or, from npm:
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
```
A prebuilt tarball is attached to [Releases](https://github.com/czcdr/dsh-plugin-rea/releases)
(version-free asset name, so the link keeps resolving).
> **On an Electron host (the DeepSeek Harness desktop app) the profile is owned by the application.**
> `dsh plugin --profile desktop …` is refused by the CLI (`profile "desktop" is managed exclusively by
> the Electron application`) — install or update through the in-app plugin manager instead. The CLI form
> above is for a profile you created yourself with the CLI under another name.
**Configure — usually nothing.** The shipped row carries **no machine-specific path**:
If Node is already installed, you can skip the bootstrap and run provisioning directly:
- Windows: `command: cmd.exe`, `args: ['/c', 'rea', 'mcp']` — the npm shim is a `.cmd`, so a shell is
required.
- macOS/Linux: `command: rea`, `args: ['mcp']` — see
[`examples/cordis.patch.posix.yml`](examples/cordis.patch.posix.yml).
```
node scripts/setup.mjs --reuse # or --dry-run to see the plan first
```
The only optional piece is the analysis engine. REA reads `GHIDRA_INSTALL_DIR` from its environment,
so either:
Node, when you provide it instead of letting setup fetch one, must satisfy REA's engines —
`^22.19.0 || ^24.11.0 || >=26.0.0` (check with `node -v`). A lower Node does not fail at install
time; the MCP row fails later, which reads as silence.
1. set it as an **OS-level environment variable** and (re)start the harness, or
2. pin it for the MCP child only — see
[`examples/cordis.patch.with-env.yml`](examples/cordis.patch.with-env.yml).
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
provisioning (the harness itself is unaffected: `failOnStartupError: false` is deliberate). Run
`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.
Either way the variable reaches REA without any literal path: the client hands the parent
environment to the child (minus names that look like secrets), and the patch dialect evaluates
`!!js` expressions, so `GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR` also works.
## Configure — nothing
**Keep machine-specific values out of the plugin.** Override the row from your own profile's
`cordis.patch.yml` (id-targeted config override). That file is machine-local and never published:
The shipped row carries **no machine path**:
```yaml
- id: mcp-rea
config:
env:
GHIDRA_INSTALL_DIR: '<your Ghidra 12.1.x directory>'
- insert:
- id: mcp-rea
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: rea
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'"
- mcp
env:
ELECTRON_RUN_AS_NODE: '1'
failOnStartupError: false
```
Only one row may use `serverName: rea` in a profile.
Three details in that row are load-bearing, and all three were bugs first:
**Verify.** Ask for a `mcp__rea__*` call — `binary_session` reports the engine checks
- **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.
- **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.
`ELECTRON_RUN_AS_NODE` only matters if the harness itself runs on Electron; it is harmless for a plain Node host, and stating it explicitly beats relying on it surviving the child-env scrub.
`command` is the running Node executable and `args[0]` is computed from `DSH_HOME`, so the same patch works
on Windows, macOS and Linux without a PATH entry. Everything machine-specific lives in
`<DSH_HOME>/plugins/rea-bundle/bundle.json`, written by setup and readable with:
```
node bin/rea-shim.mjs --shim-status # resolved paths, versions, verification state
node bin/rea-shim.mjs --shim-print-env # the environment the MCP child will see
```
Both commands answer with the same bundle whether you run them from the plugin directory (the checkout copy)
or from `<DSH_HOME>/plugins/rea-bundle` (the mirrored copy): the checkout resolves the location by the same
rule setup uses, while the mirror uses its own position — it cannot rely on `DSH_HOME`, which the harness
strips from an MCP child's environment.
Setting `REA_BUNDLE_AUTO_PROVISION=0` disables the first-run background provisioning.
To pin a different launch command on this machine (for example your own globally installed `rea`), override
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.
### 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 when it can see that file — a custom `DSH_HOME` is hidden
from an MCP child, so it falls back to `~/.dsh` there — and `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`.
**Scope and caveats.** REA exposes 170+ tools at 6.4.0, so every session's tool list grows; the row
sets `failOnStartupError: false` so a missing REA never blocks harness startup. Some REA tools launch
processes, write files and access the network — DSH's permission policy applies. Analyse only
artifacts you are authorized to inspect.
Provisioning itself ends with two probes: `rea doctor --json` (reported, with unmet checks named) and the
exact shim launch the MCP row will use.
**Updating — two independent things, both manual.**
## Verified against
- **This plugin** never updates itself. Installed from a plugin market? Update it **in that market**.
Installed from a local `file:` checkout? Overwrite the directory and install it again. Updating REA
below does **not** update this plugin, and updating this plugin does not touch REA.
- **Upstream REA**: `npm i -g rea-agents@latest`. Its `bin` entry (`scripts/rea.mjs`) is stable across
releases, so this needs no change here.
- **Ghidra** is version-gated by REA (12.1.x today): update REA first, then check that it accepts a
newer Ghidra.
- **DeepSeek Harness**: a harness upgrade may change the MCP client's config schema — inspect the row
with `Config.listConfigs name=@deepseek-ai/dsh-mcp-client` and adjust the field names.
Author's machine, 2026-10-13 / 14 / 16:
`check-updates.ps1` (Windows PowerShell) reports the state of all four at once, and
`.github/workflows/validate.yml` enforces the bundle's shape — including "the shipped patch must not
contain a machine-specific path" — in CI.
| Component | Version |
|---|---|
| REA (`rea-agents`) | **6.5.0** (207 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`) |
| Host | Windows 11 x64 (build 26200) |
**License.** MIT (this adapter). REA is MIT and owned by its authors; Ghidra is Apache-2.0 and owned
by the NSA. No upstream code is redistributed here.
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.5.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.
---
Not exercised end to end here, and therefore not claimed: the ~775 MB JDK + Ghidra download branch (planned
by `--dry-run`, but this machine adopted its existing installs with `--reuse`), and first runs on
macOS/Linux.
## 中文
## Scope and caveats
**作用。** 本插件**自身不做任何逆向分析**。它把上游 REA 的 MCP 服务接入 DeepSeek Harness,
让 REA 提供的那套工具在会话里变成可直接调用的 `mcp__rea__*`。
- REA exposes 207 tools at 6.5.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 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. 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.
按上游自述,REA 是「**通过一个 MCP 服务,逆向分析二进制文件、应用程序和运行时行为**」——
「看到喜欢的功能,弄清它的工作原理,深入到二进制层面」。它「将智能体连接到分析工具,用于检查
原生二进制文件、JavaScript 和 Electron 应用、.NET 程序集以及网站」;分析在本机运行,结果包含
支撑各项结论的证据和相关限制。原生分析可以使用已有的 Hopper、Ghidra 或 IDA,静态 JavaScript 分析
不需要原生引擎。
## Updating — three independent things
上游列出的可分析目标(原文见 [README_zh.md#可以分析什么](https://github.com/morluto/rea/blob/main/README_zh.md#可以分析什么)):
原生二进制文件——伪代码、汇编、字符串、符号、调用和引用;JavaScript / Electron——模块、导入、
source map、路由与 IPC;网站——页面结构、脚本、网络观察结果与截图;.NET 程序集;Android APK;
Android 设备(adb);JEB 项目;Android 资源;固件;软件包与资源;进程行为;以及离线 ELF 布局、
EVM 字节码、已记录的 Linux 崩溃与已保存的网络捕获。
### Updating from the market
**本插件在此之上只增加两件事**:把该 MCP 服务注册进 DSH 的工具层(工具名 `mcp__rea__*`),
以及让引擎环境变量(`GHIDRA_INSTALL_DIR` 等)只作用于这个 MCP 子进程,而不是整个 harness。
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 指向的文件不存在),且未能验证恢复原版本文件
- **无法完整还原源代码。** 上游对原生二进制返回的是**伪代码与汇编**,对 Android APK 返回
**反编译的方法**,对 JEB 单元返回**伪代码**,对 JavaScript/Electron 返回**模块及其关系**。
上游的工具契约明确写着:伪代码是反编译器输出,**不是原始源码**,且与其他提供方的输出
**不保证文本等价**。
- 实践中不可恢复的内容:原始标识符命名、类型、注释、宏与模板展开、构建脚本与资源;
优化(内联、指令重排)、去符号(strip)、混淆、加壳与 VM 保护只会让可读性更差。
- 每条结论都自带**置信度与限制**(`observed` / `derived` / `inferred`,另有 limitations 列表):
请把它们当作证据使用,而不要当作"已还原的源码"。
- 适合做:理解行为、定位实现、互操作与兼容实现、安全审计、迁移参考。
**要拿到真正的源码,请走正规渠道**——上游代码仓库、厂商授权,或厂商自己的 SDK。
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.
**已验证版本**(作者机器,2026-10-12/13):REA `6.4.0`、DeepSeek Harness 桌面运行时 `0.2.0-rc.2`
(自带 dsh CLI `0.1.7-rc.2`)、Ghidra `12.1.4`、Node `24.21.0`、Windows 11 x64。行提供方
`@deepseek-ai/dsh-mcp-client` 由 harness 自带(npm 最新 `0.0.1-rc.1`)。发布用的这一行**实测跑通过**:
`list_documents` 在 `cmd.exe` 形式下正常应答,`open_binary` 走 `ghidra` 提供方成功,
而引擎目录仅来自本机 profile 覆盖。
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.
**前置条件。** 满足 REA 要求的 Node;全局安装 REA(`npm i -g rea-agents`)。原生分析需要
Ghidra 12.1.x(Hopper / IDA 为替代方案,各自需授权);静态 JavaScript/Electron 与 .NET 分析
不需要引擎。
### Upgrading from 1.x
**安装。**
Install 2.x, then run provisioning **once** before restarting dsh:
```
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea
# 或从 npm 安装:
dsh plugin --profile <profile> add dsh-plugin-rea
node scripts/setup.mjs # or: powershell -File scripts\rea-bundle-bootstrap.ps1 -Reuse
```
[Releases](https://github.com/czcdr/dsh-plugin-rea/releases) 附带预构建 tarball(资产名不带版本号,
链接不会失效)。
Why it matters: the 1.x row launched `rea mcp` from PATH, while the 2.x row launches
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` — a file only `setup.mjs` creates. Upgrade and restart
without provisioning and that row exits with `MODULE_NOT_FOUND`: the harness stays up
(`failOnStartupError: false` is deliberate), but the `mcp__rea__*` tools are gone and you get **no
remediation message**, because the shim that prints one does not exist yet. After the setup run they are
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.
- **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
six hours. Re-run `node scripts/setup.mjs` **from the plugin directory** once; it re-mirrors all three
files and writes the record that lets later runs find the checkout. `check-updates.ps1` reports this state.
- **Provisioned components**: re-run `node scripts/setup.mjs`; it re-verifies what is on disk and only
fetches what is missing or wrong. Pins live in `manifest/bundle.manifest.json`.
- **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.
- Windows:`command: cmd.exe`、`args: ['/c', 'rea', 'mcp']` —— npm 的 shim 是 `.cmd`,必须经 shell。
- macOS/Linux:`command: rea`、`args: ['mcp']` —— 见
[`examples/cordis.patch.posix.yml`](examples/cordis.patch.posix.yml)。
`check-updates.ps1` (Windows PowerShell) reports the state of all of these at once.
唯一可选的是分析引擎。REA 从环境读取 `GHIDRA_INSTALL_DIR`,二选一:
## Still prefer the configuration-only 1.x line?
1. 设为**系统级环境变量**,然后重启 harness(让 MCP 子进程继承);
2. 只为这个 MCP 子进程指定 —— 见
[`examples/cordis.patch.with-env.yml`](examples/cordis.patch.with-env.yml)。
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:
两种方式都不需要在插件里写死路径:客户端会把父进程环境传给子进程(只剔除名字像密钥的变量),
而补丁方言支持 `!!js` 表达式,所以 `GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR`
同样可用。
**机器相关值不要写进插件。** 用你自己的 profile `cordis.patch.yml` **按 id 覆盖**这一行即可;
该文件只在你本机、永不随插件发布:
```yaml
- id: mcp-rea
config:
env:
GHIDRA_INSTALL_DIR: '<你的 Ghidra 12.1.x 目录>'
```
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
```
同一 profile 内 `serverName: rea` 只能有一条记录。
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).
**验证。** 调用任一 `mcp__rea__*` 工具:`binary_session` 会给出引擎检查项
(configuration / installation / headless / native_decompiler / java),应全部 `ok: true`;
随后 `open_binary` 打开一个 PE → `binary_overview` → `procedure_pseudo_code`。
## License
**范围与注意。** REA 在 6.4.0 暴露 170+ 个工具,注入后每个会话的工具列表都会变大;行内设置
`failOnStartupError: false`,因此 REA 缺失也不会阻塞 harness 启动。部分 REA 工具会启动进程、
写文件、访问网络,按 DSH 的权限策略执行。请仅分析你已获得授权的目标。
**更新:两件事,且都要手动。**
- **本插件**不会自动更新。**从插件市场安装的,就在市场里更新**;本地 `file:` 目录方式安装的,
覆盖目录后在插件管理器里重装。下面更新 REA **不会**更新本插件,反过来也一样。
- **上游 REA**:用 `npm i -g rea-agents@latest` 更新。它的 `bin` 入口(`scripts/rea.mjs`)
跨版本稳定,所以本插件无需改动。
- **Ghidra**:受 REA 版本门槛限制(当前 12.1.x),先升 REA,再确认它接受新版 Ghidra。
- **DeepSeek Harness**:升级可能改变 MCP 客户端配置结构,用
`Config.listConfigs name=@deepseek-ai/dsh-mcp-client` 看该行 schema 并调整字段名。
`check-updates.ps1`(Windows PowerShell)会把上述四项一次跑完;
`.github/workflows/validate.yml` 在 CI 里守住 bundle 的形状(含「发布物不得含机器路径」这一条)。
**许可。** 本适配器为 MIT;REA 为 MIT,版权归其作者;Ghidra 为 Apache-2.0,版权归 NSA。
本仓库不再分发上游代码。
MIT (this adapter). REA is MIT and owned by its authors; Ghidra is Apache-2.0 and owned by the NSA; Temurin
is GPLv2+CE; Node.js is MIT; apktool, jadx-headless-mcp and Android platform-tools keep their own licences.
**No upstream code or binary is redistributed in this package** — provisioning fetches each component from
its official source.
+313
View File
@@ -0,0 +1,313 @@
# dsh-plugin-rea
[![npm version](https://img.shields.io/npm/v/dsh-plugin-rea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/dsh-plugin-rea)
[English](README.md) · **简体中文**
> **这是哪个版本?2.x —— 而 `main` 就是它。** 2.x 自己准备运行时(Node、REA CLI、JDK、Ghidra)再把那一行接好:
> 装插件 → 跑一次 `scripts/setup.mjs` → 重启 dsh。
> **1.x** 是更早的纯配置适配器 —— 它假设 `rea` 在 PATH 上、Ghidra 由你自己装好 —— 保留在
> [`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),npm 上仍是 `[email protected]`。
> 完整对比:[两条线:1.x 与 2.x](#两条线1x-与-2x) · 升级:[从 1.x 升级](#从-1x-升级)。
把上游 **[REA — Reverse Engineer Anything](https://github.com/morluto/rea)** 的 MCP 服务接入
**DeepSeek Harness**(dsh),让它的工具在会话里以 `mcp__rea__*` 出现。
![dsh-plugin-rea:会话经官方 MCP 客户端行启动本包供给的 shim,再由 shim 拉起 bundle 内置的 REA,最后把 Evidence 带回会话](assets/screenshot-1.png)
## 这是什么
一个**带供给层的 Cordis bundle**。`package.json` 声明 `dsh.bundle.patch`,该 patch 插入一行官方 MCP 客户端
插件(`@deepseek-ai/dsh-mcp-client`)。这一行用当前运行的 Node 可执行文件启动 `bin/rea-shim.mjs`,由 shim
读取 `scripts/setup.mjs` 写下的本机状态,再拉起它供给的 REA CLI。这里没有任何分析代码,且**发布出去的
patch 里不含任何机器路径**。
> **第三方适配。** 本仓库不属于 REA,不携带 REA 的任何代码,也不由 REA 作者维护;它只组装一行配置,
> 并从上游官方源拉取上游自己的发布物。
> 上游:[github.com/morluto/rea](https://github.com/morluto/rea)(MIT)·
> [中文文档](https://github.com/morluto/rea/blob/main/README_zh.md#可以分析什么) ·
> [rea.tools](https://rea.tools/) · npm 包 `rea-agents`。
> REA 自身的问题请提给上游;**本插件**的问题提到
> [本仓库 issues](https://github.com/czcdr/dsh-plugin-rea/issues)。
**它只做三件事**:注册 `mcp__rea__*` 这批工具名;把每台机器各自的引擎环境
(`GHIDRA_INSTALL_DIR`、`JAVA_HOME`、`REA_ADB_PATH`、`REA_APKTOOL_COMMAND`、`REA_JADX_MCP_JAR`、
`REA_BROWSER_EXECUTABLE` 等)限定在那一个 MCP 子进程上、而不是整个 harness;以及把这些组件装到盘上。
## 能做什么、不能做什么
分析能力属上游,本节只是复述上游自己的契约,避免预期错位。
- **它无法还原完整源码。** 原生二进制上游给的是*伪代码与汇编*,APK 是*反编译方法*,JEB 单元是*伪代码*,
JavaScript/Electron 是*模块及其关系*。上游的工具契约明确写着伪代码"是反编译器输出,不是原始源码,
也不与另一 provider 的输出文本等价"。
- 实际上拿不回来的:原始标识符名、类型、注释、宏与模板展开、构建脚本与资源。优化(内联、重排)、
符号剥离、混淆、加壳、VM 保护只会让输出更差。
- 每个结果都带**置信度与限制**(`observed` / `derived` / `inferred`,外加一份明确的 limitations 列表)。
请当证据读,别当还原出来的源码。
- 适合:理解行为、定位实现、互操作与兼容重写、安全审阅、迁移参考。要拿真正的源码,走正规渠道 ——
上游仓库、厂商授权,或厂商自己的 SDK。
**供给解决不了的部分。** 这些是上游的宿主门槛,不是本插件漏配了什么:
| 能力 | 门槛 |
|---|---|
| Hopper provider | 仅 macOS |
| IDA provider | 需要你自己的 IDA 与 `REA_IDA_MCP_CONFIG`;REA 从不启动 IDA |
| 固件 `binwalk` / `unblob` | 仅 Linux |
| Wakaru JS 还原、pwntools 的 ELF/宕机转储路径 | 仅 Linux x64 |
| pwndbg / gdb 核心上下文 | 仅 Linux、macOS |
| 原生 mitmproxy 抓包 | 仅 Linux(`REA_MITMDUMP_COMMAND`);HAR 不需要引擎 |
| 苹果工具链(Mach-O、plist、资源目录、LLDB) | 仅 macOS |
| `capture_process_scenario` | 仅 Linux/macOS 的 PTY |
| apktool(Android 资源解码) | 仅当 `REA_APKTOOL_COMMAND` 指向 Node 能**直接执行**的文件时才可用:上游用 `execFile` 不加 shell 直接起这个命令,而 Windows 上 Node 无法直接执行 `.bat`/`.cmd`(EINVAL)。本包按上游文档接的是官方 `apktool.bat`,所以在 Windows 上它拿不到版本、该能力不可用;JADX 走 jar,不受影响 |
## 两条线:1.x 与 2.x
| | **2.x** —— 当前这条,`main` | **1.x** —— [`v1`](https://github.com/czcdr/dsh-plugin-rea/tree/v1) |
|---|---|---|
| 是什么 | 适配器**加供给**:自己准备 Node、REA CLI、JDK、Ghidra,再把那一行接好 | 纯配置适配器:一行 MCP 行,指向你自己装好的 `rea` |
| 前置 | 运行时什么都不用装 —— 缺什么它去取(`--reuse` 则复用机器上已有的 Ghidra/JDK) | Node.js(满足 REA engines)、`npm i -g rea-agents`,原生分析还要 Ghidra 12.1.x |
| 首次运行 | 先跑一次 `scripts/setup.mjs`(或引导脚本),再重启 dsh | 不用跑任何东西 |
| 那一行启动什么 | 当前 Node 可执行文件 + `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`(由 shim 读本机状态) | Windows 上 `cmd.exe /c rea mcp`,macOS/Linux 上 `rea mcp` |
| 机器状态 | `<DSH_HOME>/plugins/rea-bundle/` —— `bundle.json`、`vendor/`、`cache/` | 你自己的 REA 与 Ghidra 安装 |
| 安装 | `dsh plugin --profile <profile> add dsh-plugin-rea`(npm `latest` ≥ 2.0.0) | `… add [email protected]`,或 `github:czcdr/dsh-plugin-rea#v1.0.6` |
两条线发布的是**同一种 patch 形态**(一条 `insert` 行,`@deepseek-ai/dsh-mcp-client`),对外也提供同一套
`mcp__rea__*` 工具面;区别只在"谁准备运行时"。从 1.x 到 2.x 只要两条命令,见
[从 1.x 升级](#从-1x-升级)。
上游现在也能自己注册进 dsh(`rea setup --client dsh`):那基本就是上游维护的 1.x 模式 —— 只写配置,
Node/REA/Ghidra 由你准备。本包是零依赖路线。三条路选一条,不要同时留两行 `serverName: rea`。
## 安装
**前置什么都不用装。** 在干净的 Windows x64 机器上:
```
powershell -ExecutionPolicy Bypass -File scripts\rea-bundle-bootstrap.ps1 -Reuse
```
`-Reuse` 会复用你已有的 Ghidra/JDK;去掉它则从官方发布下载两者(约 775 MB,逐个校验和验证)。然后为你的
profile 安装插件:
```
dsh plugin --profile <profile> add dsh-plugin-rea
# 或钉住这个版本,而不是跟着 npm latest 走:
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
```
> **Electron 宿主(DeepSeek Harness 桌面版)的 profile 由应用独占。** 那种机器上 `dsh plugin --profile desktop …`
> 会被 CLI 直接拒绝(`profile "desktop" is managed exclusively by the Electron application`),请改用**应用内的
> 插件管理器**安装/更新;上面这条 CLI 只适用于你自己用 CLI 建的非 `desktop` profile。
如果机器上已经有 Node,可以跳过引导脚本直接供给:
```
node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
```
如果 Node 由你自备(而不是让 setup 去取),它必须满足 REA 的 engines:
`^22.19.0 || ^24.11.0 || >=26.0.0`(用 `node -v` 核对)。版本偏低不会在安装时失败,而是到 MCP 行
启动时才失败 —— 表现就是"没有反应"。
反过来**先装插件**也可以,但要分两步:发布的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
而那个文件**只有 setup 会创建**——所以在第一次供给之前,该行会以 `MODULE_NOT_FOUND` 退出,也就没有
任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
`node scripts/setup.mjs`;此后 shim 就位,之后每一次「状态缺失」都由它自己发现并在后台补供给。
## 配置 —— 不需要
发布的那一行**不含任何机器路径**:
```yaml
- insert:
- id: mcp-rea
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: rea
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'"
- mcp
env:
ELECTRON_RUN_AS_NODE: '1'
failOnStartupError: false
```
这一行里有四个"承重"细节,而且**都先以 bug 的形式出现过**:
- **`!!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` 恰好被设置的机器上,这个错误完全不可见。
- **`!!js` 表达式里没有 `path`/`require`**:`ctx` 是 loader 作用域,所以 DSH_HOME 的兜底写成纯字符串拼接。
`npm run bundle:audit` 会把前三条钉住:它用「`DSH_HOME` 未设 / 为空 / 已设」三种情形求值这个表达式,并拒绝会被 YAML 读成 mapping 的裸标量。
`ELECTRON_RUN_AS_NODE` 只在 harness 本身跑在 Electron 上时才有意义;对纯 Node 宿主无害,而显式写出来胜过依赖它恰好能在子进程环境清洗中存活。
`command` 是当前运行的 Node 可执行文件,`args[0]` 由 `DSH_HOME` 在启动时算出,所以同一份 patch 在
Windows、macOS、Linux 上都成立,且不依赖 PATH 上的 `rea`。所有机器相关的真相都放在
`<DSH_HOME>/plugins/rea-bundle/bundle.json`,由 setup 写入,可随时查看:
```
node bin/rea-shim.mjs --shim-status # 解析到的路径、版本、校验状态
node bin/rea-shim.mjs --shim-print-env # MCP 子进程将看到的环境变量
```
这两个命令在**插件目录**(checkout 副本)或 `<DSH_HOME>/plugins/rea-bundle`(镜像副本)里跑都给出同一
答案:checkout 副本按与 setup 相同的规则解析供给位置,镜像副本用自身位置(因为 MCP 子进程的环境里
`DSH_*` 会被剥掉,它不能依赖 `DSH_HOME`)。
设 `REA_BUNDLE_AUTO_PROVISION=0` 可关掉首次运行的后台自动供给。
想在本机把这一行改成别的启动命令(例如用你自己全局装的 `rea`),从 profile 自己的
`cordis.patch.yml` 按 id 覆盖即可 —— 那个文件是机器本地的,永不发布。一个 profile 里只允许一行使用
`serverName: rea`。
### 只保留一条 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 在能看到该文件时会在启动时提示冲突 —— MCP 子进程里拿不到自定义 `DSH_HOME`,那种情况下它会
退回 `~/.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 启动**。
## 实测环境
作者机器,2026-10-13 / 14 / 16:
| 组件 | 版本 |
|---|---|
| REA(`rea-agents`) | **6.5.0**(207 个工具) |
| 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`) |
| 宿主 | Windows 11 x64(build 26200) |
实测跑过:一次干净的 `setup.mjs --reuse` 走到 `setup complete`;一次**不经 PATH 上的 `rea`、完全经由
shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.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 的首次运行。
## 边界与注意事项
- REA 在 6.5.0 有 207 个工具,每个会话的工具列表都会变长。
- 校验和对得起良心:厂商发布了的就固定并验证;没发布的(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 脚本**。这一步也是**唯一**用不上
本插件自己的路由/代理回退的环节 —— 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.0.5 及以上**是常规操作;而把已装的 2.0.1–2.0.4 通过应用内市场**更新到
2.0.4**,在健康的 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:
```
node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-bootstrap.ps1 -Reuse
```
为什么必须先跑:1.x 的行从 PATH 上启动 `rea mcp`,而 2.x 的行启动
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` —— 那个文件**只有 `setup.mjs` 会创建**。升级后直接重启,
该行会以 `MODULE_NOT_FOUND` 退出:harness 本身照常(`failOnStartupError: false` 是刻意的),但
`mcp__rea__*` 工具会消失,而且**不会有任何修复提示** —— 会打提示的 shim 本身还不存在。跑完 setup 再启动,
工具就回来了。
- **本插件**从不自更新。从市场装的就在市场更新;从 `file:` 检出的就覆盖目录重装。
- **从 `2.0.0-bundle.1` 升级**:那一版镜像了 shim 与 `setup.mjs`,却漏了 manifest,也没有留下插件 checkout
的记录,所以它供给出来的 bundle **无法自愈** —— 后台供给会因缺 manifest 而崩溃且完全不可见,而它自己写下的
lock 又会让重试被抑制六小时。请**从插件目录**重跑一次 `node scripts/setup.mjs`:它会重新镜像三个文件,
并写下让后续运行找到真源的记录。`check-updates.ps1` 会报告这个状态。
- **供给的组件**:重跑 `node scripts/setup.mjs`,它会重新校验盘上的东西,只补缺失或不对的。
版本钉在 `manifest/bundle.manifest.json`。
- **上游 REA / Ghidra**:改清单里的钉子再重跑 setup。REA 对 Ghidra 有版本闸门(目前 12.1.x),
所以先更新 REA。
`check-updates.ps1`(Windows PowerShell)会一次性报告以上各项的状态。
## 还想用纯配置的 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)。
## 许可证
MIT(本适配器)。REA 是 MIT、归其作者;Ghidra 是 Apache-2.0、归 NSA;Temurin 是 GPLv2+CE;Node.js 是
MIT;apktool、jadx-headless-mcp、Android platform-tools 各自保留自己的许可。
**本包不再分发任何上游代码或二进制** —— 供给过程是从各组件的官方源逐个拉取。
+15 -9
View File
@@ -11,7 +11,13 @@
(用 pwsh 7 编辑则无此限制,但保持 BOM 对两者都安全)。
#>
[CmdletBinding()]
param([string]$OutDir = (Join-Path $PSScriptRoot '.'))
param([string]$OutDir)
# The documented invocation (`powershell -File .\make-screenshots.ps1`) died right here:
# "Cannot bind argument to parameter 'Path' because it is an empty string" — $PSScriptRoot
# is not reliably populated while parameter defaults are bound, so this script had never
# actually regenerated a screenshot. Resolve the default in the body instead.
if (-not $OutDir -or $OutDir.Trim() -eq '') { $OutDir = $PSScriptRoot }
Add-Type -AssemblyName System.Drawing
@@ -73,25 +79,25 @@ function Save-Png($bmp, $g, $path) { $g.Dispose(); $bmp.Save($path, [System.Draw
# ---------------------------------------------------------------- 图 1:插件做了什么
$r = New-Canvas; $bmp = $r[0]; $g = $r[1]
Draw-Text $g 'dsh-plugin-rea:把 REA 接入 DeepSeek Harness' $fTitle $ink 60 46
Draw-Text $g '第三方适配 · 自身不做逆向分析 · 只插入一条 MCP 客户端记录,并把引擎环境变量限制在该子进程内' $fSub $muted 62 98
Draw-Text $g '第三方适配 · 自身不做逆向分析 · 自供给 Node/REA/JDK/Ghidra 后自动接好那一行' $fSub $muted 62 98
$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(6.5.0 暴露 207)`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)
Draw-Text $g "@deepseek-ai/dsh-mcp-client`nserverName = rea`ntransport = stdio`ncommand = node" $fBody $muted 380 ($rowY + 50)
Draw-Text $g "@deepseek-ai/dsh-mcp-client`nserverName = rea / transport = stdio`ncommand = 当前 Node 可执行文件`nargs[0] = <DSH_HOME>/plugins/rea-bundle/`n bin/rea-shim.mjs" $fBody $muted 380 ($rowY + 50)
Draw-Text $g '↑ 本插件插入的就是这一行' $fSmall $accent 360 ($rowY + $boxH + 8)
Draw-Box $g 660 $rowY $boxW $boxH $([System.Drawing.Color]::White) $line
Draw-Text $g 'rea mcp(本机进程)' $fBox $ink 680 ($rowY + 18)
Draw-Text $g "node …`n \rea-agents\scripts`n \rea.mjs mcp`nenv: GHIDRA_INSTALL_DIR`n JAVA_HOME(仅此处)" $fBody $muted 680 ($rowY + 50)
Draw-Text $g 'rea-shim(供给后镜像到此)' $fBox $ink 680 ($rowY + 18)
Draw-Text $g "读 bundle.json 里的本机真相`n导出 GHIDRA_INSTALL_DIR /`n JAVA_HOME / REA_*`n再拉起 bundle 内置的 REA" $fBody $muted 680 ($rowY + 50)
Draw-Box $g 960 $rowY $boxW $boxH $([System.Drawing.Color]::FromArgb(255,240,253,244)) $accent2
Draw-Text $g '分析引擎' $fBox $accent2 980 ($rowY + 18)
Draw-Text $g "Ghidra 12.1.4(本机已装)`nHopper / IDA(可选,`n各自需授权)`n静态 JS / .NET 无需引擎" $fBody $muted 980 ($rowY + 50)
Draw-Text $g '分析引擎(供给或复用)' $fBox $accent2 980 ($rowY + 18)
Draw-Text $g "Ghidra 12.1.x / JDK 21:`n缺则下载并校验,`n已有则 --reuse 复用`n静态 JS / .NET 无需引擎" $fBody $muted 980 ($rowY + 50)
Draw-Arrow $g 310 ($rowY + 40) 358 ($rowY + 40) $muted
Draw-Arrow $g 610 ($rowY + 40) 658 ($rowY + 40) $muted
@@ -106,7 +112,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-12)' $fSmall $muted 60 646
Draw-Text $g '实测 REA 6.5.0 + DSH 桌面运行时 0.2.0-rc.2 + Ghidra 12.1.4 + Node 24.21.0(Windows 11 x64,2026-10-16)' $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: 63 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+508
View File
@@ -0,0 +1,508 @@
#!/usr/bin/env node
/**
* rea-shim — the process the DSH MCP row launches.
*
* Why this exists: the published `cordis.patch.yml` must carry no machine path,
* yet REA needs absolute paths for every dependency. The shim is the seam: it
* reads one machine-local JSON file, exports exactly the variables REA reads,
* and execs the vendored REA CLI with the arguments the row was given.
*
* It downloads nothing, writes nothing, and never touches PATH.
*
* Exit codes: 0 child exited normally * child's own status
* 78 EX_CONFIG bundle not provisioned (or unusable) — the row has
* `failOnStartupError: false`, so the harness stays up
* and the message is the operator's remediation.
*/
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const EX_CONFIG = 78;
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = path.resolve(HERE, "..");
/** DSH home, by the same rule setup.mjs and the published patch use. */
function dshHome() {
const fromEnv = process.env.DSH_HOME;
if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
return path.join(homedir(), ".dsh");
}
/**
* Where setup writes the machine-local truth.
*
* Two copies of this file exist and they need different answers, so the copy has
* to be identified before the root can be:
*
* * The **mirrored** copy at `<bundle>/bin/rea-shim.mjs` is what the published
* row launches, and its own location is the bundle root. It must not consult
* DSH_HOME: the harness strips every `DSH_*` name from an MCP child's
* environment (case-insensitively, because Windows is), so a custom DSH_HOME
* would send it to `~/.dsh`, where it would report "not provisioned" for a
* bundle sitting right next to it — and start a second ~800 MB run there.
* * The **checkout** copy (`<plugin>/bin/rea-shim.mjs`) is what a human runs, e.g.
* `npm run bundle:status`, from a shell where DSH_HOME *is* visible. Its own
* directory is the plugin, not the bundle, so resolving to itself would report
* "not provisioned" on a fully provisioned machine — and a bare
* `node bin/rea-shim.mjs mcp` would provision ~800 MB into the checkout.
*
* Only the checkout ships `cordis.patch.yml`, so that file is the discriminator.
* REA_BUNDLE_ROOT stays as an explicit override for tests and unusual layouts.
*/
function bundleRoot() {
const override = process.env.REA_BUNDLE_ROOT;
if (override && override.trim() !== "") return path.resolve(override);
if (!existsSync(path.join(PLUGIN_ROOT, "cordis.patch.yml"))) return PLUGIN_ROOT;
return path.join(dshHome(), "plugins", "rea-bundle");
}
/**
* A directory can provision only when it holds both files a run reads: the script
* and the manifest it loads at startup. A bundle provisioned before the manifest
* was mirrored has the script but not the manifest, so recommending that copy
* would send the operator straight into an ENOENT.
*/
function usableSetup(dir) {
if (typeof dir !== "string" || dir.trim() === "") return null;
const base = path.resolve(dir);
const script = path.join(base, "scripts", "setup.mjs");
const manifest = path.join(base, "manifest", "bundle.manifest.json");
return existsSync(script) && existsSync(manifest) ? script : null;
}
/**
* Which setup.mjs to run.
*
* The mirrored copy is a snapshot: after a plugin upgrade it still holds the
* previous manifest, so it would provision the previous versions forever. setup
* writes <bundle>/mirror.json naming the real checkout; prefer that while it is
* usable, and fall back to the local copy — which is also all a first run has.
*/
function provisioningScript(root) {
try {
const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
const recorded = usableSetup(record && record.pluginRoot);
if (recorded) return recorded;
} catch {
/* no record, or not readable: the local copy is the only option */
}
return usableSetup(PLUGIN_ROOT);
}
/**
* Upstream REA 6.5.0 can add a second registration for the same MCP server:
* `rea setup --client dsh` writes a row with id "rea-mcp" and serverName "rea"
* into `<DSH_HOME>/cordis.patch.yml`, while this bundle ships id "mcp-rea" with
* the same serverName. DSH does not dedupe those rows: it rejects the later entry
* with a duplicate-server-name error, so which REA row survives depends on load
* order. The shim cannot prevent that, but it can name the conflict instead of
* leaving a missing row to be diagnosed from "the tools are gone".
*
* The parse is deliberately small: the two files it cares about are block
* mappings with one id/serverName per row, and anything it cannot attribute is
* ignored — a missed hint is acceptable, a false alarm is not. The mirrored shim
* cannot see a custom DSH_HOME (the harness strips DSH_* from an MCP child), so
* this falls back to `~/.dsh` like the rest of the bundle; the row has the same
* limitation.
*/
function foreignReaRegistration() {
const patch = path.join(dshHome(), "cordis.patch.yml");
let text;
try {
text = readFileSync(patch, "utf8");
} catch {
return null;
}
const unquote = (value) => value.replace(/^(['"])(.*)\1$/, "$2").trim();
// A row starts at `- id:`; the block runs to the next row, so a serverName can
// never be attributed to a row that does not contain it.
for (const block of text.split(/\r?\n(?=\s*-\s+id:\s*)/)) {
const id = /^\s*-\s+id:\s*(.+?)\s*$/m.exec(block);
const server = /^\s+serverName:\s*(.+?)\s*$/m.exec(block);
if (!id || !server || unquote(server[1]) !== "rea") continue;
const rowId = unquote(id[1]);
if (rowId !== "mcp-rea") return { patch, id: rowId };
}
return null;
}
/**
* The provisioning lock is a small JSON record: { state, pid, at, exit? }.
* `state: "running"` is written when a run is started (setup's own pid once it
* exists), and the failing run rewrites it as `state: "failed"` — it is kept on
* purpose so a broken setup cannot be re-downloaded in a loop. A legacy
* plain-number lock is still honoured as "running".
*/
const STALE_LOCK_MS = 6 * 60 * 60 * 1000;
function readLock(lock) {
try {
const raw = readFileSync(lock, "utf8").trim();
if (raw.startsWith("{")) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed;
}
const at = Number(raw);
return Number.isFinite(at) ? { state: "running", at } : null;
} catch {
return null;
}
}
/** Is the process recorded in the lock still alive? null means "cannot tell". */
function pidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return null;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === "ESRCH" ? false : null;
}
}
/**
* On first run there is nothing to launch. Provisioning is a long download, so
* it is started detached: the harness may kill this row (or the whole session)
* long before 800 MB arrive, and the downloader is resumable and idempotent, so
* a killed attempt is simply continued on the next start.
*
* Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
* Returns { started, reason, logPath } — the caller words its message from
* `reason`, because "provisioning started in the background" was also printed
* for a lock left behind by a run that had already failed, and for one whose
* process was long gone.
*/
function maybeAutoProvision(root) {
if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return { started: false, reason: "disabled" };
const setupScript = provisioningScript(root);
if (!setupScript) return { started: false, reason: "no-setup" };
const lock = path.join(root, "setup.lock");
const logPath = path.join(root, "logs", "setup.log");
if (existsSync(lock)) {
// A crashed run must not wedge provisioning forever: honour the lock only
// while it is fresh; setup removes it itself when it finishes cleanly.
const record = readLock(lock);
const age = record && Number.isFinite(record.at) ? Date.now() - record.at : Infinity;
const fresh = Number.isFinite(age) && age < STALE_LOCK_MS;
if (fresh && record?.state === "failed") {
// The run gave up and left the lock behind so it would not be retried in a
// loop. Claiming it is still provisioning was a six-hour lie: nothing runs,
// and every restart repeated the sentence.
return {
started: false,
reason: "last-run-failed",
exit: record.exit,
failedAt: record.at,
ageMs: age,
logPath,
};
}
if (fresh) {
const alive = pidAlive(record?.pid);
if (alive !== false) return { started: false, reason: "in-flight", logPath, ageMs: age };
}
// Reached when the lock is older than the freshness window, or its holder is
// gone and never wrote an outcome (harness restart, power loss, a kill): what
// it records is not happening any more. This removal is the whole point of the
// freshness rule — without it the exclusive create below failed with EEXIST
// *forever*, auto-provisioning never ran again, and main() printed nothing at
// all for that outcome. The lock's own "honour it only while it is fresh"
// comment described behaviour the code did not have.
try {
rmSync(lock, { force: true });
} catch {
// A read-only bundle root (system-wide install, chmod 555) cannot drop the
// stale lock either. Returning the same "cannot write here" outcome as the
// exclusive create below keeps the row alive: an uncaught EACCES killed the
// shim with a stack trace, which is louder than the old silence but just as
// unable to start REA.
return { started: false, reason: "unwritable", logPath };
}
}
try {
mkdirSync(path.join(root, "logs"), { recursive: true });
// Exclusive create: check-then-write is not atomic, so two rows starting at
// once could both decide to provision. EEXIST means another run owns it.
writeFileSync(lock, JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }), { flag: "wx" });
} catch (error) {
if (error?.code === "EEXIST") return { started: false, reason: "in-flight", logPath };
/* a read-only bundle root must never break the harness */
return { started: false, reason: "unwritable" };
}
try {
const child = spawn(process.execPath, [setupScript, "--root", root], {
detached: true,
stdio: "ignore",
windowsHide: true,
});
child.unref();
// Hand the lock to the detached downloader, so a later start that finds this
// row dead still sees the run that is actually doing the work as alive.
if (Number.isInteger(child.pid)) {
try {
writeFileSync(lock, JSON.stringify({ state: "running", pid: child.pid, at: Date.now(), supervisor: process.pid }));
} catch {
/* the lock is advisory: the row keeps working without it */
}
}
return { started: true, reason: "started", logPath };
} catch {
return { started: false, reason: "spawn-failed", logPath };
}
}
function fail(message, detail) {
const lines = [
"",
" dsh-plugin-rea (bundle): REA is not provisioned on this machine.",
"",
" " + message,
];
if (detail) lines.push("", " detail: " + detail);
// Point at the real checkout when the record allows it: running the mirrored
// copy is fine, but it can only ever provision the manifest it was mirrored
// with — and a bundle mirrored before the manifest travelled with it has no
// usable copy at all, so the honest answer is then "run the plugin's own script".
const setupScript = provisioningScript(bundleRoot());
lines.push(
"",
" Finish setup with:",
setupScript
? " node \"" + setupScript + "\""
: " node \"<installed plugin directory>/scripts/setup.mjs\"",
);
if (!setupScript) {
lines.push("", " (Run it from the installed plugin directory: the copy in this bundle has no manifest.)");
}
lines.push(
"",
" Then restart DSH (or reload the profile) so the MCP row reconnects.",
"",
" The harness keeps running: this row declares failOnStartupError: false.",
"",
);
process.stderr.write(lines.join("\n"));
return EX_CONFIG;
}
function readBundleConfig(root) {
const file = path.join(root, "bundle.json");
if (!existsSync(file)) return { ok: false, code: "missing", file };
let parsed;
try {
parsed = JSON.parse(readFileSync(file, "utf8"));
} catch (error) {
return { ok: false, code: "malformed", file, detail: String(error && error.message) };
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, code: "malformed", file, detail: "bundle.json is not an object" };
}
return { ok: true, file, config: parsed };
}
/** Resolve the vendored REA entry, or report precisely what is missing. */
function resolveRea(config, root) {
const paths = config.paths ?? {};
const node = paths.node;
// The fallback has to match where setup actually installs the package:
// `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
// fallback without that segment named a directory that never exists. It only
// fires when an older bundle.json has no paths.reaEntry — which is exactly when
// a wrong fallback turns a usable install into "the vendored REA CLI is gone".
const reaEntry = paths.reaEntry
?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
if (typeof node !== "string" || node.trim() === "") {
return { ok: false, reason: "bundle.json has no paths.node" };
}
if (!existsSync(node)) {
return { ok: false, reason: `the recorded Node runtime is gone: ${node}` };
}
if (!existsSync(reaEntry)) {
return { ok: false, reason: `the vendored REA CLI is gone: ${reaEntry}` };
}
return { ok: true, node, reaEntry };
}
/**
* Export only what REA actually reads, and only when the bundle recorded it.
* An absent variable is better than a wrong one: REA then reports its own
* availability with its own remediation text.
*/
function childEnvironment(config) {
const env = { ...process.env };
const paths = config.paths ?? {};
const set = (name, value) => {
if (typeof value === "string" && value.trim() !== "") env[name] = value;
};
// Analysis engines.
set("GHIDRA_INSTALL_DIR", paths.ghidra);
set("JAVA_HOME", paths.jdk);
// Optional providers/tools; wired only when the bundle provisioned them.
set("REA_ADB_PATH", paths.adb);
set("REA_APKTOOL_COMMAND", paths.apktoolCommand);
set("REA_JADX_MCP_JAR", paths.jadxJar);
set("REA_BROWSER_EXECUTABLE", paths.browser);
set("REA_ILSPY_CMD_PATH", paths.ilspycmd);
// Keep the vendored Node first for any grandchild the bridge may spawn.
if (typeof paths.node === "string" && paths.node.trim() !== "") {
const nodeDir = path.dirname(paths.node);
const current = env.PATH ?? "";
const parts = current.split(path.delimiter).filter((part) => part !== "");
if (!parts.some((part) => path.resolve(part) === path.resolve(nodeDir))) {
env.PATH = [nodeDir, ...parts].join(path.delimiter);
}
}
return env;
}
/**
* Usage text, also what a bare invocation prints.
*
* `package.json` exposes this file as the `rea-bundle-status` bin, and npm's bin
* shim can only forward the arguments a user typed — it cannot append a flag. So a
* bare `rea-bundle-status`, which is exactly what that name invites, used to fall
* through to the launch path: it started REA, and on an unprovisioned machine it
* began an ~800 MB download. Reporting usage instead makes the command's behaviour
* match its name, and the row is unaffected because it always passes `mcp`.
*/
function usage() {
return [
"rea-shim: launches the vendored REA MCP server for a DSH MCP row.",
"",
" node bin/rea-shim.mjs [rea arguments...] (normal use: 'mcp')",
" node bin/rea-shim.mjs --shim-status provisioning status, no launch",
" node bin/rea-shim.mjs --shim-print-env the environment it would export",
"",
` bundle root: ${bundleRoot()}`,
` plugin root: ${PLUGIN_ROOT}`,
"",
].join("\n");
}
function main() {
const argv = process.argv.slice(2);
if (argv.includes("--shim-help") || argv.length === 0) {
process.stdout.write(usage());
return 0;
}
const root = bundleRoot();
const loaded = readBundleConfig(root);
// Upstream can add a second row for the same serverName; the shim cannot stop
// DSH from loading both rows, but saying so is better than doubled tools with
// no stated cause. The status report carries the same fact for diagnostics.
const duplicate = foreignReaRegistration();
if (duplicate && !argv.includes("--shim-status")) {
process.stderr.write(
`\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
` ${duplicate.patch} also registers serverName "rea" (id "${duplicate.id}").\n` +
` This bundle's row is id "mcp-rea" with the same serverName; DSH rejects the later\n` +
` entry as a duplicate server name, so only one survives. Keep exactly one of them.\n`,
);
}
if (argv.includes("--shim-status")) {
const report = {
bundleRoot: root,
bundleFile: loaded.file,
provisioned: loaded.ok,
reason: loaded.ok ? undefined : loaded.code,
autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
};
if (duplicate) report.duplicateRegistration = duplicate;
if (loaded.ok) {
report.paths = loaded.config.paths ?? {};
report.versions = loaded.config.versions ?? {};
report.rea = resolveRea(loaded.config, root);
}
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
return loaded.ok ? 0 : EX_CONFIG;
}
if (!loaded.ok) {
const message =
loaded.code === "missing"
? `No bundle state at ${loaded.file}.`
: `Bundle state at ${loaded.file} could not be parsed.`;
// Both "missing" and "malformed" start a run. A malformed file used to fail
// permanently: setup overwrites a bundle.json it cannot parse, but nothing
// ever started setup, so every restart reported the same EX_CONFIG until the
// operator deleted the file by hand. The lock stops a provisioning loop when
// writing keeps failing.
const provisioning = maybeAutoProvision(root);
if (provisioning.started) {
process.stderr.write(
`\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
);
} else if (provisioning.reason === "last-run-failed") {
process.stderr.write(
`\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
` Nothing is running right now — read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
);
} else if (provisioning.reason === "in-flight") {
// A live run owns the lock. Saying nothing here is what made a wedged
// provisioning attempt look like a machine that simply has no bundle.
process.stderr.write(
`\n dsh-plugin-rea (bundle): provisioning is already running in the background.\n progress: ${provisioning.logPath}\n`,
);
} else if (provisioning.reason === "spawn-failed" || provisioning.reason === "unwritable") {
process.stderr.write(
`\n dsh-plugin-rea (bundle): provisioning could not be started (${provisioning.reason}).\n` +
` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}"\n`,
);
}
return fail(message, loaded.detail);
}
const rea = resolveRea(loaded.config, root);
if (!rea.ok) return fail(rea.reason);
const env = childEnvironment(loaded.config);
if (argv.includes("--shim-print-env")) {
const interesting = Object.fromEntries(
Object.entries(env).filter(([key]) =>
/^(GHIDRA_INSTALL_DIR|JAVA_HOME|REA_[A-Z0-9_]+|PATH)$/.test(key),
),
);
process.stdout.write(JSON.stringify(interesting, null, 2) + "\n");
return 0;
}
// Pass through whatever the row supplied; 'mcp' in the normal case.
const childArgs = [rea.reaEntry, ...(argv.length > 0 ? argv : ["mcp"])];
const child = spawn(rea.node, childArgs, {
env,
stdio: "inherit",
windowsHide: true,
});
child.on("error", (error) => {
process.stderr.write(
`rea-shim: could not start ${rea.node}\n ${String(error && error.message)}\n`,
);
process.exit(EX_CONFIG);
});
child.on("exit", (code, signal) => {
if (signal) {
// Re-raising the signal is not portable: process.kill(pid, "SIGTERM")
// throws ERR_UNKNOWN_SIGNAL on Windows. Report the conventional 128+n
// status instead, which the harness reads the same way.
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
process.exit(128 + (signals[signal] ?? 0));
}
process.exit(code ?? 0);
});
return null;
}
const status = main();
if (typeof status === "number") process.exit(status);
+161 -16
View File
@@ -46,6 +46,10 @@ function Test-NodeEngines([string]$node, [string]$engines) {
# 之后 $node.Major 为 $null,所有 ^ / ~ 分支都会判假(曾因此把 Node 24 判成不满足)。
$v = Get-NumericVersion $node
if (-not $v) { return $null }
# 只要有一个 term 的形态不认识,整个判断就只能给"无法解析":返回 $false 会把
# `22.x` / `>=22` 这类合法但未实现的 pattern 误报成"不满足",让用户去升级一个
# 本来就满足的 Node。
$recognized = $true
foreach ($term in ($engines -split '\|\|')) {
$t = $term.Trim()
if ($t -match '^\^(\d+\.\d+\.\d+)$') {
@@ -56,19 +60,49 @@ function Test-NodeEngines([string]$node, [string]$engines) {
} elseif ($t -match '^~(\d+\.\d+\.\d+)$') {
$base = [version]$Matches[1]
if ($v.Major -eq $base.Major -and $v.Minor -eq $base.Minor -and $v -ge $base) { return $true }
} else {
$recognized = $false
}
}
if (-not $recognized) { return $null }
return $false
}
# ------------------------------------------------------- 定位 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
if (-not $bundleRootEarly) {
$homeEarly = $env:DSH_HOME
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
$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 {
@@ -87,11 +121,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')) {
@@ -103,12 +147,18 @@ 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 '已是最新版本' }
} else {
} elseif (-not $latest) {
Warn '未能取到上游最新版本(两个 registry 都查不通?)—— 已跳过版本比对'
} else {
Warn '本地没读到已安装版本(见上面那条)—— 已跳过版本比对'
}
# ------------------------------------------------------------- Node 运行时
@@ -130,7 +180,14 @@ 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"
@@ -153,11 +210,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)
@@ -214,6 +288,77 @@ if (Test-Path $profPatch) {
}
}
# ------------------------------------------------- 一体包(bundle 分支)
# 设计文档承诺了这个分区:一体包把组件版本钉在 manifest 里,供应结果记在
# bundle.json 里。两者不一致 = 有一次 pin 更新还没重新供给,或盘上的组件被换过。
Head '一体包供给状态(bundle 分支)'
$pluginRoot = $PSScriptRoot
$manifestPath = Join-Path $pluginRoot 'manifest\bundle.manifest.json'
$bundleRoot = $env:REA_BUNDLE_ROOT
if (-not $bundleRoot) {
$homeDir = $env:DSH_HOME
if (-not $homeDir) { $homeDir = Join-Path $HOME '.dsh' }
$bundleRoot = Join-Path $homeDir 'plugins\rea-bundle'
}
$bundleJson = Join-Path $bundleRoot 'bundle.json'
if (-not (Test-Path $manifestPath)) {
Info '未找到 manifest/bundle.manifest.json —— 这不是一体包分支,跳过'
} else {
Info "bundle 根目录: $bundleRoot"
try {
$manifest = Get-Content $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
if (Test-Path $bundleJson) {
$state = Get-Content $bundleJson -Raw -Encoding UTF8 | ConvertFrom-Json
Ok "已供给:$($state.provisionedAt)"
$drift = 0
$compared = 0
$unrecorded = 0
foreach ($name in $manifest.pins.PSObject.Properties.Name) {
if ($name -eq 'nodeEngines') { continue }
$pinned = [string]$manifest.pins.$name
$installed = ''
if ($state.versions -and ($state.versions.PSObject.Properties.Name -contains $name)) {
$installed = [string]$state.versions.$name
}
if (-not $installed) {
# 被 --reuse 采纳的组件没有 version 字段(bundle.json 里是 null)。静默
# continue 会让这一项从未被比对,却在下面打印"全部一致"——那是假绿。
$unrecorded++
Warn " $name 未记录版本(reuse 采纳的组件?)—— 无法与 pin $pinned 对账"
continue
}
$compared++
# Node 的 pin 是引擎范围,不是版本号;其它组件比前缀即可
$same = $installed.Equals($pinned) -or $installed.StartsWith(($pinned -split '\.')[0] + '.')
if ($same) { Info " $name = $installed(pin $pinned)" }
else { Warn "$name 盘上为 $installed,manifest 已钉 $pinned —— 重跑 setup 对齐"; $drift++ }
}
if ($drift -eq 0 -and $unrecorded -eq 0) { Ok '所有组件与 manifest 的 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 已就位' }
else { Bad "缺少 MCP 行指向的 shim: $mirrored —— 重跑 setup --reuse" }
# 镜像副本的 setup 用它自己所在的位置推 plugin root,所以 manifest 必须
# 一起镜像。旧版镜像缺这个文件:auto-provision 会以 ENOENT 崩掉且不可见,
# 而且它已写下的 lock 会让重试被抑制 6 小时。
$mirroredManifest = Join-Path $bundleRoot 'manifest\bundle.manifest.json'
if (Test-Path $mirroredManifest) { Ok '镜像副本的 manifest 已就位' }
else { Bad "镜像副本缺少 manifest: $mirroredManifest —— 请从插件目录重跑一次 node scripts/setup.mjs(旧镜像无法自愈)" }
} else {
Warn "尚未供给:$bundleJson 不存在 —— 运行 node scripts/setup.mjs(或直接装插件,行会自行触发)"
}
$pins = @()
foreach ($name in $manifest.pins.PSObject.Properties.Name) {
if ($name -ne 'nodeEngines') { $pins += "$name=$($manifest.pins.$name)" }
}
Info ('manifest 钉子: ' + ($pins -join ', '))
} catch { Warn "bundle 状态解析失败: $($_.Exception.Message)" }
}
# ------------------------------------------------------------------ 汇总
Head '结论'
if ($actions.Count -eq 0) { Ok '无需动作:整条链路与上游保持一致' }
+39 -31
View File
@@ -1,33 +1,31 @@
# dsh-plugin-rea — REA (Reverse Engineer Anything) as native DSH tools
# dsh-plugin-rea — one-plugin bundle
#
# Configuration-only bundle: no Host/Client entry files. It inserts one row of
# the official MCP client plugin and points it at the installed `rea mcp`
# stdio server; the model then sees mcp__rea__<tool>.
# Registers the REA MCP server as mcp__rea__* and launches it through the
# bundle's own shim, which reads the machine-local state written by
# `scripts/setup.mjs` and exports exactly the variables REA reads.
#
# ---------------------------------------------------------------------------
# PORTABLE BY DEFAULT — this file carries no machine-specific path.
# PORTABLE BY CONSTRUCTION — no machine path appears in this file.
# * `command` is the running Node executable (`process.execPath`), so the
# launcher needs no PATH entry and no shim resolution by the OS.
# * `args[0]` is computed at boot from DSH_HOME, where setup mirrors the shim.
# * FORWARD SLASHES ONLY in the JS string literals below. A single-quoted YAML
# scalar keeps backslashes literal, and JavaScript then eats them: '\plugins'
# is not "\plugins" but the BEL control character, '\rea-bundle' becomes a
# carriage return and '\bin' a backspace, so the path is silently destroyed.
# Forward slashes are accepted on Windows and are correct on POSIX, so one
# expression serves every platform.
# * `path`/`require` are not in scope for a `!!js` expression (`ctx` is the
# loader scope), hence plain string concatenation.
# * No DSH_HOME (undefined/absent/empty) falls back to the SAME rule the shim
# and setup.mjs use — `<home>/.dsh` — never to the bare home directory.
# Getting this wrong is silent: setup would mirror the shim into
# `<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.
#
# Prerequisite: `rea` must be reachable from the PATH the harness sees.
# (`npm i -g rea-agents` puts it there.)
#
# Windows : command: cmd.exe args: ['/c', 'rea', 'mcp'] (as below)
# macOS/Linux: command: 'rea' args: ['mcp']
# → see examples/cordis.patch.posix.yml
#
# Analysis engines: REA reads GHIDRA_INSTALL_DIR from its environment.
# - Option 1 (recommended): set it as an OS-level environment variable,
# then start/restart the harness so the MCP child inherits it.
# - Option 2: put an `env:` block in the row —
# see examples/cordis.patch.with-env.yml.
# Without it, native analysis reports the engine as unavailable while every
# other REA capability keeps working. Static JavaScript/Electron and .NET
# analysis need no engine at all.
#
# Only ONE row may use serverName: rea in a profile. To adapt this bundle for
# your machine without touching the upstream file, override `mcp-rea` from
# your profile's own cordis.patch.yml (id-targeted config override) — that
# file is never published.
# ---------------------------------------------------------------------------
# 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
# cordis.patch.yml (id-targeted config override) — that file is never published.
- insert:
- id: mcp-rea
@@ -35,9 +33,19 @@
config:
serverName: rea
transport: stdio
command: 'cmd.exe'
command: !!js process.execPath
args:
- '/c'
- 'rea'
- 'mcp'
# DOUBLE-QUOTED on purpose. A bare scalar containing "? " and ": " is not
# one scalar at all: YAML reads `? a : b` as an explicit key/value pair,
# so js-yaml hands the loader a mapping instead of an expression node and
# 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'"
- mcp
env:
# Only meaningful when the harness itself runs on Electron; harmless
# for a plain Node host. Explicit is better than relying on this
# variable happening to survive the child-env scrub.
ELECTRON_RUN_AS_NODE: '1'
failOnStartupError: false
+188
View File
@@ -0,0 +1,188 @@
# one-plugin bundle — design notes
Goal: a fresh machine installs **one DSH plugin** and gets a working `mcp__rea__*`
tool surface, without first installing Node, REA, a JDK, or Ghidra by hand.
`main` **is** this variant: the self-provisioning one-plugin bundle. The earlier
**config-only adapter** (a single MCP row pointing at a `rea` the user installed
themselves) is preserved on the [`v1` branch](https://github.com/czcdr/dsh-plugin-rea/tree/v1)
and on npm as `[email protected]`; nothing here changes that line.
---
## 1. What upstream will and will not do
REA is explicit: it never installs or upgrades unrelated software, and every
dependency is "caller-supplied" (`SKILL.md`, `install.sh`, and the remediation
strings in `dist/`). That is a deliberate upstream boundary, not an oversight.
Consequently **all provisioning must live on our side**, and it must not pretend
to be REA.
Two upstream rules shape the runtime wiring:
- Only `GHIDRA_INSTALL_DIR` + `JAVA_HOME` (and `REA_IDA_MCP_CONFIG`,
`HOPPER_LAUNCHER_PATH`) are ever published by `rea setup`. Every other knob —
`REA_ADB_PATH`, `REA_APKTOOL_COMMAND`, `REA_JADX_MCP_JAR`,
`REA_BROWSER_EXECUTABLE`, `REA_ILSPY_CMD_PATH` — must be supplied by the
launching environment. We supply them per machine from one JSON file.
- REA hard-requires an **absolute** Node/runtime path in practice: it rejects
relative paths for `GHIDRA_INSTALL_DIR` and resolves Windows `java.exe` by
walking PATH itself. We therefore never rely on PATH at all.
## 2. Verified facts this design rests on
Measured on Windows 11 x64, REA 6.5.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.5.0) and `rea doctor --json` reported 207 tools. `healthy` is `false` on a bundle-only install: 6.5.0 also checks the host's own setup (Hopper, IDA registration, `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 |
| Windows is an explicit POC boundary upstream | `DoctorDiagnostics`: "the experimental Windows x64 Ghidra P0 boundary" |
## 3. Architecture
Three responsibilities, kept strictly apart:
1. **Shim (runtime, per launch).** A tiny Node script the MCP row points at. It
loads the machine-local `bundle.json`, exports the discovered paths into the
child environment, and execs REA. It never downloads anything on the normal
path and never writes to the plugin directory.
2. **Setup (provisioning, explicit or first-run).** A Node script that resolves
the component manifest, downloads what is missing into a cache, verifies
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 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.
```
<dsh home>/plugins/rea-bundle/
bundle.json # machine-local truth: resolved paths + versions + hashes
mirror.json # which plugin checkout the mirrored files came from
cache/ # downloaded archives (resumable, checksum-verified)
vendor/ # unpacked payloads (node, jdk, ghidra, apktool, jadx, adb)
logs/setup.log
```
## 4. Why a shim instead of an `!!js` expression
`!!js` can inject values into the row at load time, but it cannot:
download, verify, unpack, run a readiness probe, or report progress. It also
couples the published patch to expressions that must parse on every platform.
A shim is boring, testable, and can report an action's clear remediation text
through `failOnStartupError: false` without breaking the harness.
## 5. Control flow
```
dsh starts
└─ MCP row: node <bundle root>/bin/rea-shim.mjs (setup mirrors it there)
├─ bundle.json present & components verify → exec REA (normal path)
├─ missing, auto-provision allowed → start setup detached, print
│ remediation, exit non-zero
└─ missing, auto-provision refused → print exact command, exit
```
The shim resolves its bundle root from **its own location**, not from `DSH_HOME`:
the harness strips every `DSH_*` name from an MCP child's environment, so with a
custom `DSH_HOME` the shim would look in `~/.dsh`, find nothing, and start a second
provisioning run there.
`setup` stages, each idempotent and individually verifiable:
1. `detect` — host platform/arch, DSH home, plugin dir, existing installs
2. `resolve` — manifest entry per component; candidate URLs are ordered
3. `fetch` — download to cache with resume + retries; skip if checksum matches
4. `verify` — sha256 against the manifest (and vendor checksum file when present)
5. `unpack` — platform-native extraction, atomic into `vendor/`
6. `wire` — write `bundle.json`, export the environment variables REA reads
7. `mirror` — copy the launcher to the path the published patch computes
8. `probe` — `rea doctor --json`, then launch the **mirrored** shim exactly as the row would
9. `report` — human summary + exit code; every failure names its remediation
Two notes on stages that exist because their absence was a bug:
- **7 (mirror).** The patch points at `<bundle root>/bin/rea-shim.mjs`, and the
plugin itself is not guaranteed to live anywhere the patch can compute. Setup
therefore copies the launcher (and the setup script it re-invokes) into the
bundle root. Without this, provisioning could succeed and the row would still
fail — and the "install the plugin first, it provisions itself" path could
never start, because the shim that does the provisioning would not exist at
the path being launched. The copy list is exactly what a mirrored run reads
back, **including `manifest/bundle.manifest.json`**: a mirrored `setup.mjs`
resolves its plugin root to the bundle root, so `loadManifest()` reads
`<bundle>/manifest/bundle.manifest.json` — a file only this stage can create.
It also writes `<bundle>/mirror.json`, naming the checkout the files came
from: the bundle outlives any particular checkout location, and without that
record a later mirrored run can only copy the bundle onto itself (provisioning
the manifest it was mirrored with, forever) and its remediation text would
point the operator at a copy that cannot start.
- **The row's `!!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, `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.
- **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
cannot. Neither "always direct" nor "always proxy" is correct — measured on
the development machine, the local proxy answers github.com but returns 403
for nodejs.org and 404 for dl.google.com, while the reverse also happens.
## 6. What is genuinely impossible on Windows
Declared up front so no release note over-promises. These are **upstream
host gates**, not configuration gaps:
| Capability | Gate |
| --- | --- |
| Hopper provider | macOS only |
| IDA provider | requires a user-owned IDA + `REA_IDA_MCP_CONFIG`; REA never launches it |
| firmware `binwalk` / `unblob` | Linux only |
| Wakaru JS recovery | Linux x64 only |
| pwntools paths (ELF layout, recorded crash) | Linux x64 only |
| pwndbg / gdb core context | Linux/macOS |
| `mitmdump` native capture decode | Linux only (HAR needs no engine) |
| Apple toolchain (Mach-O, plist, asset catalog, LLDB) | macOS only |
| process capture (`capture_process_scenario`) | Linux/macOS PTY |
Everything else — native Ghidra analysis, static JS/Electron, .NET, archives,
Android (JADX/apktool/adb), browser scenarios, EVM, HAR — is reachable on
Windows and is what this bundle targets to complete.
## 7. Component matrix (Windows x64 first)
| 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/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 |
| jadx-headless-mcp jar | Android class/method decompile | pinned 0.7.1 jar (REA validates its classes) |
| Android platform-tools | `adb` device tools | official zip |
| a Chromium-family browser | browser/CDP scenarios | reuse an installed Edge/Chrome; never downloaded |
| `ilspycmd` (optional) | .NET decompilation | optional; only wired when present |
Ghidra itself is **not** redistributed inside the plugin package: the release is
~570 MB, which no npm package can carry. It is fetched from the official release
and checksum-verified, or adopted from an existing install with `--reuse`.
## 8. Honest limits of this approach
- npm's per-package limits (unpacked/compressed size) rule out shipping the JDK
and Ghidra inside the tarball. "One plugin install" therefore means *one
install plus an automatic, checksum-verified fetch*, not a 600 MB npm package.
- First run is network- and time-bound: the full payload is roughly 800 MB
(Ghidra 570 MB, JDK 205 MB) plus the REA package. Progress is reported and the
download is resumable; a pre-seeded cache makes it offline-capable.
- Version pins age. `manifest/bundle.manifest.json` carries the pins, and
`check-updates.ps1` gains a bundle section so drift is reported rather than
discovered later.
+3 -3
View File
@@ -10,7 +10,7 @@
# - 一个 PR 最多 3 条。
#
# 若已发布 Release tarball,可取消下面 tarball 行的注释(资产名必须不带版本号)。
# 本仓库的 Release 一直附带 dsh-plugin-rea.tgz(当前 v1.0.5),故该行保持启用。
# 本仓库的 Release 一直附带 dsh-plugin-rea.tgz(资产名不带版本号,永远指向最新 Release),故该行保持启用。
# 注意:脚本里 npm 映射优先于 tarball(installTargetFor:有 npm 名就走 npm),
# 所以这行只是 npm 不可用时的兜底,不会改变市场默认的安装方式。
@@ -18,6 +18,6 @@ url: https://github.com/czcdr/dsh-plugin-rea
name: czcdr/dsh-plugin-rea
category: tools
description:
en: 'Third-party adapter that registers the upstream REA (Reverse Engineer Anything) MCP server into DeepSeek Harness as mcp__rea__* tools. REA itself analyses native binaries (Hopper/Ghidra/IDA), JavaScript/Electron apps, .NET assemblies, websites, Android APKs and adb devices, firmware, and process captures.'
zh: '第三方适配插件:把上游 REA(逆向一切,github.com/morluto/rea)的 MCP 服务注册进 DeepSeek Harness,提供 mcp__rea__* 工具。逆向分析能力由 REA 提供,覆盖原生二进制(Hopper/Ghidra/IDA)、JavaScript/Electron 应用、.NET 程序集、网站、Android APK 与 adb 设备、固件与进程捕获。'
en: 'Third-party adapter that registers the upstream REA (Reverse Engineer Anything) MCP server into DeepSeek Harness as mcp__rea__* tools. 2.x provisions its own runtime (Node, the REA CLI, a JDK, Ghidra) under <DSH_HOME>/plugins/rea-bundle and launches REA through a shim, so a machine with nothing pre-installed works after one setup run; 1.x (branch v1, npm 1.0.6) is configuration-only and expects REA and Ghidra to be installed already. Analysis is upstream''s: native binaries (Hopper/Ghidra/IDA), JavaScript/Electron apps, .NET assemblies, websites, Android APKs and adb devices, firmware, and process captures.'
zh: '第三方适配插件:把上游 REA(逆向一切,github.com/morluto/rea)的 MCP 服务接入 DeepSeek Harness,提供 mcp__rea__* 工具。2.x 自带供给:把 Node、REA CLI、JDK、Ghidra 装到 <DSH_HOME>/plugins/rea-bundle 下,再经 shim 启动 REA —— 干净机器跑一次 setup 即可用;1.x(v1 分支 / npm 1.0.6)是纯配置适配器,需要你自己装好 REA 与 Ghidra。逆向分析能力由 REA 提供,覆盖原生二进制(Hopper/Ghidra/IDA)、JavaScript/Electron 应用、.NET 程序集、网站、Android APK 与 adb 设备、固件与进程捕获。'
tarball: https://github.com/czcdr/dsh-plugin-rea/releases/latest/download/dsh-plugin-rea.tgz
-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
+43
View File
@@ -0,0 +1,43 @@
/**
* Package entry — the smallest thing that makes this bundle a *loadable* package.
*
* Everything this plugin DOES lives in `cordis.patch.yml`: it inserts one row of
* `@deepseek-ai/dsh-mcp-client` that launches `bin/rea-shim.mjs`, and that row is
* what the harness loads. This file registers nothing, starts nothing and
* provisions nothing.
*
* It exists because a package that declares no `main`/`exports` is judged by its
* consumers as "has no entry file", whether or not the loader ever needs one:
*
* - The market's update path (`hasLoadableEntry` in dshmarket) asks that
* question on every update and, when the answer is false, treats the new
* build as broken and rolls back. A bundle with no entry of its own is
* *supposed* to survive the test through the packages it mounts — but the
* package this one mounts (`@deepseek-ai/dsh-mcp-client`) is part of the
* harness build, not of `node_modules`, so every location that test probes
* comes back empty. Measured on 2026-10-18: updating an installed, working
* 2.0.1 to 2.0.4 failed three times with "updated build has no loadable
* entry", and the rollback was then reported as unverifiable for the same
* reason.
* - `npm pack` metadata and `import 'dsh-plugin-rea'` both resolve `main`;
* absent, both fall back to `index.js`, which this repository never shipped.
*
* So this is a real module with a real Cordis plugin shape rather than a stub
* for a checker: if the harness ever does load this package as a plugin, it
* loads cleanly and does nothing.
*/
/** Cordis plugin name. Also the package name, which is what the loader expects. */
export const name = 'dsh-plugin-rea'
/**
* Intentionally empty.
*
* The row that does the work is inserted by the bundle patch, not by this
* function: registering the MCP client from here would create a second
* `serverName: rea` entry, which the harness refuses as a duplicate (the same
* collision `bin/rea-shim.mjs` warns about for upstream's `rea setup --client
* dsh`). Doing nothing is the correct behaviour, and the function present keeps
* this a valid Cordis plugin instead of an arbitrary module.
*/
export function apply() {}
+406
View File
@@ -0,0 +1,406 @@
{
"$comment": [
"Component manifest for the one-plugin bundle. Every entry is fetched from an",
"official vendor source and verified before use. Fields:",
" sha256 - REQUIRED for offline integrity. Vendor-published where one exists;",
" otherwise measured once and recorded here, and drift is reported",
" rather than silently accepted. 'unverified' means neither was done",
" yet, and setup then pins the hash it observes on first fetch.",
" size - expected byte count, used for truncation checks and resume math.",
" license - license of the upstream project, not a redistribution claim.",
" requires - components whose binaries this one needs to run.",
" 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.5.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"
},
"components": {
"node": {
"description": "Portable Node.js runtime used to execute vendored REA. Only provisioned when no compatible node is already reachable.",
"kind": "archive",
"license": "MIT (Node.js)",
"requires": [],
"unpack": { "stripRootComponents": 1, "moveTo": "node" },
"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."
},
"sources": {
"win32-x64": {
"version": "24.21.0",
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-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."
},
"win32-arm64": {
"version": "24.21.0",
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-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"
},
"darwin-x64": {
"version": "24.21.0",
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-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"
},
"darwin-arm64": {
"version": "24.21.0",
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-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"
},
"linux-x64": {
"version": "24.21.0",
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-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",
"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",
"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"
}
}
},
"jdk": {
"description": "Full JDK (not a JRE) for Ghidra headless and the JADX bridge. REA validates it with 'java --list-modules' and requires jdk.compiler.",
"kind": "archive",
"license": "GPL-2.0-with-classpath-exception (Eclipse Temurin)",
"requires": [],
"unpack": { "stripRootComponents": 1, "moveTo": "jdk" },
"probe": { "argv": ["{jvm}", "--list-modules"], "expect": { "contains": "jdk.compiler" } },
"sources": {
"win32-x64": {
"version": "21.0.12.1+1",
"url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_windows_hotspot_21.0.12.1_1.zip",
"format": "zip",
"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",
"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+."
},
"darwin-x64": {
"version": "21.0.12.1+1",
"url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_mac_hotspot_21.0.12.1_1.tar.gz",
"format": "tar.gz",
"size": 194316575,
"sha256": "44db0f08196daf19a47f90d13388b0c943b67663cb537f998fe29e836fa842ce",
"sha256Source": "Adoptium API (binary.package.checksum)",
"sha256VerifiedOn": "2026-10-13",
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=x64&image_type=jdk&os=mac&vendor=eclipse"
},
"darwin-arm64": {
"version": "21.0.12.1+1",
"url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_mac_hotspot_21.0.12.1_1.tar.gz",
"format": "tar.gz",
"size": 200073404,
"sha256": "3623232f33a9c3baadf304480b2535f9a3cba8a58d42ecbb438ba267315d9998",
"sha256Source": "Adoptium API (binary.package.checksum)",
"sha256VerifiedOn": "2026-10-13",
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=aarch64&image_type=jdk&os=mac&vendor=eclipse"
},
"linux-x64": {
"version": "21.0.12.1+1",
"url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_linux_hotspot_21.0.12.1_1.tar.gz",
"format": "tar.gz",
"size": 207473347,
"sha256": "ce79869e1307ed8ee1e2baa86a412b1eb5b75d10a01006d788a6f968bcfaee94",
"sha256Source": "Adoptium API (binary.package.checksum)",
"sha256VerifiedOn": "2026-10-13",
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=x64&image_type=jdk&os=linux&vendor=eclipse"
},
"linux-arm64": {
"version": "21.0.12.1+1",
"url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_linux_hotspot_21.0.12.1_1.tar.gz",
"format": "tar.gz",
"size": 205641175,
"sha256": "23e37e026f12f3e706f18938ff611db3032d075b09d0879a25d06718c773e223",
"sha256Source": "Adoptium API (binary.package.checksum)",
"sha256VerifiedOn": "2026-10-13",
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=aarch64&image_type=jdk&os=linux&vendor=eclipse"
}
}
},
"ghidra": {
"description": "Ghidra release, extracted. REA locates support/analyzeHeadless(.bat) and Features/Decompiler/os/<platform>/decompile[.exe] inside it.",
"kind": "archive",
"license": "Apache-2.0",
"requires": ["jdk"],
"unpack": { "stripRootComponents": 1, "moveTo": "ghidra" },
"probe": { "file": "{ghidra}/Ghidra/application.properties", "expect": { "contains": "application.version=" } },
"sources": {
"all": {
"version": "12.1.4",
"url": "https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.1.4_build/ghidra_12.1.4_PUBLIC_20260921.zip",
"format": "zip",
"size": 569649598,
"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",
"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."
}
}
},
"apktool": {
"description": "Android resource decoding. REA is pointed at the wrapper script, which must sit next to the jar.",
"kind": "multi-file",
"license": "Apache-2.0",
"requires": ["jdk"],
"entries": {
"all": [
{
"id": "jar",
"version": "3.0.3",
"url": "https://github.com/iBotPeaches/Apktool/releases/download/v3.0.3/apktool_3.0.3.jar",
"format": "file",
"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",
"moveTo": "apktool",
"saveAs": "apktool.jar"
}
],
"byPlatform": {
"win32-x64": [
{
"id": "wrapper",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/windows/apktool.bat",
"format": "file",
"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.",
"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",
"saveAs": "apktool.bat"
}
],
"darwin-x64": [
{
"id": "wrapper",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/osx/apktool",
"format": "file",
"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"
}
],
"darwin-arm64": [
{
"id": "wrapper",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/osx/apktool",
"format": "file",
"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"
}
],
"linux-x64": [
{
"id": "wrapper",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/linux/apktool",
"format": "file",
"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"
}
],
"linux-arm64": [
{
"id": "wrapper",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/linux/apktool",
"format": "file",
"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"
}
]
}
},
"probe": { "argv": ["{jvm}", "-jar", "{vendor}/apktool/apktool.jar", "--version"], "expect": { "regex": "\\d+\\.\\d+" } }
},
"jadxHeadlessMcp": {
"description": "REA validates this exact jar's bridge classes; it is not interchangeable with a plain jadx build.",
"kind": "file",
"license": "Apache-2.0",
"requires": ["jdk"],
"probe": { "file": "{vendor}/jadx/jadx-headless-mcp-0.7.1-all.jar" },
"entries": {
"all": [
{
"id": "jar",
"version": "0.7.1",
"url": "https://github.com/1013503897/jadx-headless-mcp/releases/download/v0.7.1/jadx-headless-mcp-0.7.1-all.jar",
"format": "file",
"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",
"moveTo": "jadx",
"saveAs": "jadx-headless-mcp-0.7.1-all.jar"
}
]
}
},
"androidPlatformTools": {
"description": "adb, for REA's device tools. Optional: without a device these tools simply report no devices.",
"kind": "archive",
"license": "Android SDK Terms (Google)",
"requires": [],
"optional": true,
"unpack": { "stripRootComponents": 1, "moveTo": "platform-tools" },
"probe": { "argv": ["{vendor}/platform-tools/adb{exe}", "version"], "expect": { "contains": "Android Debug Bridge" } },
"sources": {
"win32-x64": {
"version": "latest (37.0.1)",
"url": "https://dl.google.com/android/repository/platform-tools-latest-windows.zip",
"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",
"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": {
"version": "latest",
"url": "https://dl.google.com/android/repository/platform-tools-latest-darwin.zip",
"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",
"note": "Rolling 'latest' URL pinned by observation; setup re-verifies against this value and reports drift."
},
"darwin-arm64": {
"version": "latest",
"url": "https://dl.google.com/android/repository/platform-tools-latest-darwin.zip",
"format": "zip",
"size": 16110554,
"sha256": "ee39ad5967e95c2a07f04dbcbde96b1a0c916ba376096db5d2f498b7727a5d1d",
"sha256Source": "Same darwin archive as x64 (measured 2026-10-13); Google publishes one darwin zip for both architectures.",
"sha256VerifiedOn": "2026-10-13",
"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",
"url": "https://dl.google.com/android/repository/platform-tools-latest-linux.zip",
"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 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",
"url": "https://dl.google.com/android/repository/platform-tools-latest-linux.zip",
"format": "zip",
"size": 9054187,
"sha256": "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1",
"sha256Source": "Google publishes no arm64 platform-tools; this is the same x86_64 linux archive as x64 (measured 2026-10-13), recorded so the mismatch is explicit instead of implied.",
"sha256VerifiedOn": "2026-10-13",
"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."
}
}
},
"reaAgents": {
"description": "The REA MCP server itself, installed into vendor/node_modules so the bundle owns the runtime copy. The resolved entry point is vendor/node_modules/rea-agents/scripts/rea.mjs.",
"kind": "npm",
"license": "MIT",
"requires": ["node"],
"unpack": { "moveTo": "rea-agents" },
"probe": {
"argv": ["{node}", "{vendor}/node_modules/rea-agents/scripts/rea.mjs", "--version"],
"expect": { "regex": "^6\\.5\\.0(\\s|$)" }
},
"sources": {
"npm": {
"version": "6.5.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: 1650 entries in 6.5.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.5.0 is the same engines/bin contract as 6.4.0 (^22.19.0 || ^24.11.0 || >=26.0.0, scripts/rea.mjs) and adds 37 MCP tools (170 -> 207 by rea doctor --json on 2026-10-16; the upstream contract files count 206) plus an optional frida dependency; --ignore-scripts means its install script never runs."
}
}
}
},
"notProvisionable": {
"$comment": "Upstream host gates. Listed so the setup report can explain an unavailable capability instead of silently omitting it.",
"hopper": "macOS only (HOPPER_LAUNCHER_PATH).",
"ida": "requires a user-owned IDA plus REA_IDA_MCP_CONFIG; REA never launches IDA.",
"firmware": "binwalk/unblob are Linux-only (REA_BINWALK_COMMAND / REA_UNBLOB_COMMAND).",
"wakaru": "Linux x64 only (REA_WAKARU_COMMAND).",
"pwntools": "Linux x64 only (REA_PWNTOOLS_PYTHON) — ELF layout and recorded-crash tools.",
"pwndbg": "Linux/macOS only (gdb + REA_PWNDBG_GDB).",
"mitmdump": "Linux only, native mitmproxy captures (REA_MITMDUMP_COMMAND); HAR needs no engine.",
"appleToolchain": "macOS only (xcrun, plutil, assetutil, hdiutil, lldb).",
"processCapture": "Linux/macOS PTY only.",
"ilspycmd": "Optional .NET decompiler (REA_ILSPY_CMD_PATH). Only wired when a runnable ilspycmd already exists; the static managed provider works without it.",
"browser": "Reused from the host (REA_BROWSER_EXECUTABLE). Never downloaded by this bundle."
}
}
+26 -5
View File
@@ -1,8 +1,9 @@
{
"name": "dsh-plugin-rea",
"version": "1.0.5",
"description": "Registers the upstream REA MCP server into DeepSeek Harness as mcp__rea__* tools — a third-party adapter, analysis is upstream's. · 把上游 REA 的 MCP 服务接入 DeepSeek Harness,提供 mcp__rea__* 工具(第三方适配,分析能力属上游)。",
"version": "2.0.5",
"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",
"license": "MIT",
"author": "czcdr",
"homepage": "https://github.com/czcdr/dsh-plugin-rea",
@@ -27,15 +28,24 @@
"ghidra",
"binary-analysis"
],
"bin": {
"rea-bundle-setup": "./scripts/setup.mjs",
"rea-bundle-status": "./bin/rea-shim.mjs"
},
"files": [
"cordis.patch.yml",
"index.mjs",
"bin/",
"scripts/",
"manifest/",
"docs/bundle-design.md",
"README.md",
"README.zh-CN.md",
"LICENSE",
"CHANGELOG.md",
"check-updates.ps1",
"screenshots.json",
"assets/",
"examples/"
"assets/"
],
"dsh": {
"bundle": {
@@ -43,6 +53,17 @@
}
},
"scripts": {
"check-updates": "powershell -NoProfile -File ./check-updates.ps1"
"check-updates": "powershell -NoProfile -File ./check-updates.ps1",
"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"
},
"engines": {
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/"
}
}
+543
View File
@@ -0,0 +1,543 @@
#!/usr/bin/env node
/**
* manifest audit — validate the component manifest before it is published.
*
* JSON.parse cannot see duplicate keys (the last one silently wins), and this
* manifest is long enough that a duplicated field is easy to introduce while
* editing. So the checks are:
*
* 1. duplicate object keys inside any single object (parse-level)
* 2. every fetch entry has a sha256 that is either 64-hex or the literal
* "unverified", never a placeholder or an invented value
* 3. every fetch entry declares a size, except formats where the vendor does
* not publish one
* 4. every platform we claim to support has every entry it needs
* 5. the apktool wrapper for a platform is the right script (the osx wrapper
* is not the linux one) and jar+wrapper both land in the same directory
* 6. every probe token is one the substitution step actually understands
* 7. `requires` names refer to real components and cannot form a cycle
*
* Exit code 0 = clean, 1 = findings. Also usable as `npm run bundle:audit`.
*/
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 MANIFEST = path.join(HERE, "..", "manifest", "bundle.manifest.json");
const raw = readFileSync(MANIFEST, "utf8");
const findings = [];
const add = (severity, where, message) => findings.push({ severity, where, message });
/* -------------------------------------------------- 1. duplicate object keys */
/** Walk the raw text and report any key that appears twice in one object. */
function findDuplicateKeys(text) {
const duplicates = [];
const stack = [];
let i = 0;
while (i < text.length) {
const ch = text[i];
if (ch === "{") {
stack.push(new Set());
i += 1;
continue;
}
if (ch === "}") {
stack.pop();
i += 1;
continue;
}
if (ch === '"') {
const start = i;
i += 1;
while (i < text.length) {
if (text[i] === "\\") i += 2;
else if (text[i] === '"') break;
else i += 1;
}
const key = text.slice(start + 1, i);
i += 1;
// A key is followed by ':' after optional whitespace.
let j = i;
while (j < text.length && /\s/.test(text[j])) j += 1;
if (text[j] === ":" && stack.length > 0) {
const frame = stack[stack.length - 1];
if (frame.has(key)) duplicates.push({ key, index: start });
frame.add(key);
}
continue;
}
i += 1;
}
return duplicates;
}
const duplicates = findDuplicateKeys(raw);
if (duplicates.length > 0) {
const lineOf = (index) => raw.slice(0, index).split("\n").length;
for (const duplicate of duplicates) {
add("error", `line ${lineOf(duplicate.index)}`, `duplicate object key "${duplicate.key}" — JSON.parse silently keeps only the last one`);
}
}
let manifest;
try {
manifest = JSON.parse(raw);
} catch (error) {
add("error", "manifest", `not valid JSON: ${String(error.message)}`);
}
if (manifest) {
/* ------------------------------------------- 2/3. every fetch entry is sound */
const HEX64 = /^[0-9a-f]{64}$/;
const fetchEntries = [];
for (const [componentName, component] of Object.entries(manifest.components ?? {})) {
if (component.sources) {
for (const [key, source] of Object.entries(component.sources)) {
if (key === "npm") continue;
fetchEntries.push({ where: `${componentName}.sources.${key}`, entry: source });
}
}
if (component.entries) {
for (const [key, list] of Object.entries(component.entries)) {
if (key === "byPlatform") {
for (const [platform, platformList] of Object.entries(list)) {
for (const [index, entry] of platformList.entries()) {
fetchEntries.push({ where: `${componentName}.entries.byPlatform.${platform}[${index}]`, entry });
}
}
} else {
for (const [index, entry] of list.entries()) {
fetchEntries.push({ where: `${componentName}.entries.${key}[${index}]`, entry });
}
}
}
}
}
for (const { where, entry } of fetchEntries) {
const sha = entry.sha256;
if (sha === undefined) {
add("error", where, "has no sha256 field");
} else if (sha !== "unverified" && !HEX64.test(sha)) {
add("error", where, `sha256 is neither 64 lowercase hex nor "unverified": ${JSON.stringify(sha)}`);
}
if (entry.size === undefined && entry.format !== "npm") {
add("warn", where, "has no size, so a truncated transfer cannot be detected by length");
}
if (!entry.url) add("error", where, "has no url");
if (entry.url && /localhost|127\.0\.0\.1/.test(entry.url)) add("error", where, "url points at loopback");
if (entry.sha256 === "unverified" && !entry.sha256Source) {
add("warn", where, "is unverified but does not say why");
}
}
/* ------------------------------------------------- 4. platform coverage */
const DECLARED_PLATFORMS = ["win32-x64", "darwin-x64", "darwin-arm64", "linux-x64", "linux-arm64"];
for (const [name, component] of Object.entries(manifest.components ?? {})) {
if (!component.sources) continue;
// An `all` source is platform-independent and an npm source has no platform
// at all; neither is expected to enumerate every platform.
if (component.sources.all || component.kind === "npm" || component.sources.npm) continue;
for (const platform of DECLARED_PLATFORMS) {
if (!component.sources[platform]) {
add("error", `${name}.sources`, `no source for declared platform ${platform}`);
}
}
}
/* ------------------------------- 5. apktool wrapper mapping is not crossed */
const apktool = manifest.components?.apktool;
if (apktool?.entries?.byPlatform) {
const byPlatform = apktool.entries.byPlatform;
const jarDirectory = apktool.entries.all?.[0]?.moveTo;
for (const [platform, list] of Object.entries(byPlatform)) {
for (const entry of list) {
const wantsWindows = platform.startsWith("win32");
const wantsOsx = platform.startsWith("darwin");
if (wantsWindows && !entry.url.includes("/windows/")) {
add("error", `apktool.entries.byPlatform.${platform}`, `expects the windows wrapper but points at ${entry.url}`);
}
if (wantsOsx && !entry.url.includes("/osx/")) {
add("error", `apktool.entries.byPlatform.${platform}`, `macOS must use scripts/osx/apktool, not ${entry.url}`);
}
if (platform.startsWith("linux") && !entry.url.includes("/linux/")) {
add("error", `apktool.entries.byPlatform.${platform}`, `expects the linux wrapper but points at ${entry.url}`);
}
if (entry.moveTo !== jarDirectory) {
add("error", `apktool.entries.byPlatform.${platform}`, `wrapper lands in ${entry.moveTo} but the jar lands in ${jarDirectory}; the wrapper resolves the jar next to itself`);
}
}
}
}
/* ------------------------------------------------------- 6. probe tokens */
const KNOWN_TOKENS = ["{root}", "{vendor}", "{vendorDir}", "{node}", "{jdk}", "{jvm}", "{javahome}", "{ghidra}", "{exe}"];
const probes = [];
for (const [name, component] of Object.entries(manifest.components ?? {})) {
if (component.probe?.argv) for (const value of component.probe.argv) probes.push({ where: `${name}.probe`, value });
if (component.probe?.file) probes.push({ where: `${name}.probe`, value: component.probe.file });
}
for (const { where, value } of probes) {
for (const token of String(value).match(/\{[a-zA-Z]+\}/g) ?? []) {
if (!KNOWN_TOKENS.includes(token)) add("error", where, `unknown probe token ${token} in ${JSON.stringify(value)}`);
}
}
/* -------------------------------------------------- 7. requires integrity */
const names = new Set(Object.keys(manifest.components ?? {}));
for (const [name, component] of Object.entries(manifest.components ?? {})) {
for (const requirement of component.requires ?? []) {
if (!names.has(requirement)) add("error", `${name}.requires`, `unknown component "${requirement}"`);
if (requirement === name) add("error", `${name}.requires`, "depends on itself");
}
}
// Cycle check over `requires`.
const visiting = new Set();
const visited = new Set();
const walk = (name, trail) => {
if (visited.has(name)) return;
if (visiting.has(name)) {
add("error", `${name}.requires`, `dependency cycle: ${[...trail, name].join(" -> ")}`);
return;
}
visiting.add(name);
for (const requirement of manifest.components[name]?.requires ?? []) {
if (names.has(requirement)) walk(requirement, [...trail, name]);
}
visiting.delete(name);
visited.add(name);
};
for (const name of names) walk(name, []);
/* ------------------------------------- 8. the published patch's JS escapes */
// This check exists because of a real bug: the row used
// process.env.DSH_HOME + '\plugins\rea-bundle\bin\rea-shim.mjs'
// YAML single quotes keep the backslashes literal, and JavaScript then eats
// them — '\p' is not a path separator, '\r' is a carriage return and '\b' is a
// backspace, so the evaluated path was a string full of control characters
// that could never start. Nothing in a manifest audit would notice; evaluating
// the expression does.
const patchPath = path.join(HERE, "..", "cordis.patch.yml");
const patchText = readFileSync(patchPath, "utf8");
const controlChar = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/;
// Evaluate every JS string literal in the patch the way JavaScript would, so
// the escape handling is the real one and not a re-implementation.
const literals = [...patchText.matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((match) => match[1]);
const evaluated = literals.map((literal) => {
try {
// eslint-disable-next-line no-new-func
return new Function(`return '${literal}'`)();
} catch {
return literal;
}
});
for (const value of evaluated) {
if (controlChar.test(value)) {
add(
"error",
"cordis.patch.yml",
`a single-quoted literal evaluates to a string containing control characters (${JSON.stringify(value.slice(0, 60))}) — use forward slashes in JS string literals`,
);
}
}
if (/\\p|\\r|\\b|\\n|\\t/.test(patchText.replace(/^\s*#.*$/gm, ""))) {
add("error", "cordis.patch.yml", "a JS string literal contains a backslash escape (\\p, \\r, \\b, ...); use forward slashes");
}
if (!/rea-shim\.mjs/.test(patchText)) {
add("error", "cordis.patch.yml", "the patch does not reference rea-shim.mjs");
}
if (!/DSH_HOME/.test(patchText) || !/USERPROFILE|HOME/.test(patchText)) {
add("error", "cordis.patch.yml", "no fallback for an unset DSH_HOME was found");
}
/* --------------------------- 9. the row, the shim and setup agree on DSH_HOME */
// This check exists because of a real bug: the row fell back to the bare home
// directory while the shim, setup.mjs and bootstrap.ps1 all fall back to
// `<home>/.dsh`. setup therefore mirrored the launcher one level below where
// the row looked for it, and the row could never start — but only on a machine
// where DSH_HOME happened to be unset, so it stayed invisible. Evaluating the
// published expression is the only way to catch it.
// A bare `!!js` scalar containing "? " and ": " is not one scalar at all: YAML
// reads `? a : b` as an explicit key/value pair, so the loader receives a
// mapping instead of an expression node. Reproduced against the harness's own
// schema (JSON_SCHEMA + the !!js Type from app.asar): args[0] became
// {"[object Object]": "..."} and the row could not start. Quoting the scalar
// removes the ambiguity, so this check requires quotes around any expression
// that would otherwise be read as a mapping.
for (const match of patchText.matchAll(/^[ \t]*-[ \t]*!!js[ \t]+(\S.*)$/gm)) {
const scalar = match[1].trim();
if (scalar.startsWith('"') || scalar.startsWith("'")) continue;
if (/\?\s/.test(scalar) && /:\s/.test(scalar)) {
add(
"error",
"cordis.patch.yml",
`a bare !!js scalar contains "? " and ": " (${scalar.slice(0, 48)}...), which YAML reads as a mapping: quote the scalar so the loader receives an expression`,
);
}
}
// Pull the shim-path expression out of the row — quoted (required for a ternary)
// or bare. The trailing YAML quote follows the expression's own closing quote,
// so the match takes the rest of the line and strips the outer quoting.
const rowMatch = patchText.match(/^[ \t]*-[ \t]*!!js[ \t]+(.+rea-shim\.mjs.*)$/m);
let jsExpression = rowMatch ? rowMatch[1].trim() : null;
if (jsExpression) {
const first = jsExpression[0];
const last = jsExpression[jsExpression.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
jsExpression = jsExpression.slice(1, -1);
}
}
const shimSuffix = "/.dsh/plugins/rea-bundle/bin/rea-shim.mjs";
if (!jsExpression) {
add("error", "cordis.patch.yml", "the !!js expression that computes the shim path was not found");
} else {
const evaluate = (env) => {
// eslint-disable-next-line no-new-func
return new Function("process", `return ${jsExpression}`)({ env });
};
for (const [label, env] of [
["DSH_HOME unset", { USERPROFILE: "C:\\Users\\probe", HOME: "/home/probe" }],
["DSH_HOME empty", { DSH_HOME: " ", USERPROFILE: "C:\\Users\\probe", HOME: "/home/probe" }],
["DSH_HOME unset, POSIX only", { HOME: "/home/probe" }],
]) {
let value;
try {
value = evaluate(env);
} catch (error) {
add("error", "cordis.patch.yml", `the !!js expression throws with ${label}: ${error && error.message}`);
continue;
}
if (typeof value !== "string" || !value.endsWith(shimSuffix)) {
add(
"error",
"cordis.patch.yml",
`with ${label} the row resolves to ${JSON.stringify(value)}; it must end in ${shimSuffix} — the shim, setup.mjs and bootstrap.ps1 all fall back to <home>/.dsh, so the row would look one level too high for a file provisioning writes`,
);
}
}
let withHome;
try {
withHome = evaluate({ DSH_HOME: "/custom/home" });
} catch (error) {
withHome = `threw: ${error && error.message}`;
}
if (withHome !== "/custom/home/plugins/rea-bundle/bin/rea-shim.mjs") {
add("error", "cordis.patch.yml", `with DSH_HOME set the row resolves to ${JSON.stringify(withHome)}, expected /custom/home/plugins/rea-bundle/bin/rea-shim.mjs`);
}
// A machine with no DSH_HOME, USERPROFILE and HOME resolves the row to the
// filesystem root. That is a known, harmless divergence rather than a bug: the
// row reports MODULE_NOT_FOUND, writes and downloads nothing, and the harness
// carries on (`failOnStartupError: false`). It cannot be reconciled from inside
// a `!!js` expression either — setup.mjs resolves such a machine through
// `os.homedir()`, which falls back to the passwd entry on POSIX. Pinned as a
// warning so the behaviour stays known instead of being rediscovered as a bug.
let rootless;
try {
rootless = evaluate({});
} catch (error) {
rootless = `threw: ${error && error.message}`;
}
if (rootless !== "/.dsh/plugins/rea-bundle/bin/rea-shim.mjs") {
add(
"warn",
"cordis.patch.yml",
`with DSH_HOME, USERPROFILE and HOME all absent the row resolves to ${JSON.stringify(rootless)}; the documented behaviour is "/.dsh/plugins/rea-bundle/bin/rea-shim.mjs"`,
);
}
// Both READMEs print this row so a reader can copy it, and a documented row
// that has drifted from the shipped one is what people actually install.
for (const doc of ["README.md", "README.zh-CN.md"]) {
const text = readFileSync(path.join(HERE, "..", doc), "utf8");
if (!text.includes(jsExpression)) {
add("error", doc, "the documented !!js expression differs from cordis.patch.yml, so copied instructions would install the wrong row");
}
}
}
/* ---------------------------------- 9b. the two READMEs stay parallel */
// They are maintained by hand, and an edit that lands in only one of them leaves
// half the readers with stale instructions — the patch expression above is the
// one piece already compared, but sections drift the same way. The prose differs
// by language, so the check compares the structure that must stay parallel.
const headingCounts = (doc) => {
const text = readFileSync(path.join(HERE, "..", doc), "utf8");
const count = (marker) => (text.match(new RegExp(`^${marker} `, "gm")) ?? []).length;
return { sections: count("##"), subsections: count("###") };
};
const englishHeadings = headingCounts("README.md");
const chineseHeadings = headingCounts("README.zh-CN.md");
if (englishHeadings.sections !== chineseHeadings.sections || englishHeadings.subsections !== chineseHeadings.subsections) {
add(
"error",
"README.md",
`the two READMEs drifted apart in structure: README.md has ${englishHeadings.sections} sections / ${englishHeadings.subsections} subsections, README.zh-CN.md has ${chineseHeadings.sections} / ${chineseHeadings.subsections}`,
);
}
/* ------------------------------------------- 10. the mirror list is complete */
// Another real bug: mirrorLauncher() copied the shim and setup.mjs but not the
// manifest. The mirrored setup.mjs derives its plugin root from its own
// location, so loadManifest() then read <bundle>/manifest/bundle.manifest.json
// — a file nothing created — and every later run died with a bare ENOENT that
// the detached auto-provision swallowed.
const setupText = readFileSync(path.join(HERE, "..", "scripts", "setup.mjs"), "utf8");
const mirrorBody = setupText.match(/function mirrorLauncher\(context\) \{([\s\S]*?)\n\}/)?.[1] ?? "";
if (!mirrorBody) {
add("error", "scripts/setup.mjs", "mirrorLauncher() was not found, so the mirror list can no longer be verified");
} else {
// Only the array literal counts. Scanning the whole function body would also
// match the explanatory comment above it, which is exactly how this check
// first let a sabotaged mirror list pass.
const sourcesBody = mirrorBody.match(/const sources = \[([\s\S]*?)\];/)?.[1];
if (sourcesBody === undefined) {
add("error", "scripts/setup.mjs", "the mirror list (const sources = [...]) was not found");
} else {
for (const needed of ["bin/rea-shim.mjs", "scripts/setup.mjs", "manifest/bundle.manifest.json"]) {
if (!sourcesBody.includes(`"${needed}"`)) {
add("error", "scripts/setup.mjs", `mirrorLauncher() does not mirror ${needed}, which the mirrored copy reads at run time`);
}
}
// The shim tells its two copies apart by `cordis.patch.yml`, which only the
// checkout ships: a mirrored copy resolves its bundle from its own location
// (an MCP child has no DSH_HOME to consult), a checkout copy resolves the
// same root setup writes. Mirroring the discriminator would silently flip a
// mirrored copy back to the DSH_HOME rule — wrong wherever DSH_HOME is
// custom, and wrong in every MCP child, where it is stripped.
if (/cordis\.patch\.yml/.test(sourcesBody)) {
add(
"error",
"scripts/setup.mjs",
"the mirror list ships cordis.patch.yml, which bin/rea-shim.mjs uses to tell a checkout from a mirrored copy — a mirrored copy would then resolve its bundle through DSH_HOME, which an MCP child does not have",
);
}
}
}
/* ------------------------- 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`],
]) {
if (Number.isFinite(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;
const tofu = fetchEntries.length - pinned;
console.log(`manifest audit: ${fetchEntries.length} fetch entries — ${pinned} hash-pinned, ${tofu} TOFU`);
const errors = findings.filter((finding) => finding.severity === "error");
const warnings = findings.filter((finding) => finding.severity === "warn");
for (const finding of findings) {
console.log(` ${finding.severity === "error" ? "ERROR" : "warn "} ${finding.where}: ${finding.message}`);
}
console.log(errors.length === 0 ? `clean${warnings.length ? ` (${warnings.length} warning(s))` : ""}` : `${errors.length} error(s), ${warnings.length} warning(s)`);
process.exit(errors.length === 0 ? 0 : 1);
}
+219
View File
@@ -0,0 +1,219 @@
# 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
# downloads other than the official Node.js zip and then the components), gets a
# portable Node, and hands the rest to scripts/setup.mjs.
#
# powershell -ExecutionPolicy Bypass -File scripts\rea-bundle-bootstrap.ps1
#
# Options:
# -Root <dir> bundle root (default: $env:DSH_HOME\plugins\rea-bundle,
# falling back to ~\.dsh\plugins\rea-bundle)
# -Reuse adopt an existing Ghidra/JDK instead of downloading them
# -DryRun print the plan and stop
#
# Nothing is installed system-wide and no registry key is touched: the runtime
# is unpacked next to the bundle and used from there.
[CmdletBinding()]
param(
[string]$Root,
[switch]$Reuse,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
function Write-Step([string]$Message) { Write-Host "[bootstrap] $Message" }
# --- where things go -------------------------------------------------------
if (-not $Root -or $Root.Trim() -eq '') {
$home2 = $env:DSH_HOME
if (-not $home2 -or $home2.Trim() -eq '') { $home2 = Join-Path $HOME '.dsh' }
$Root = Join-Path $home2 'plugins\rea-bundle'
}
$Root = [System.IO.Path]::GetFullPath($Root)
$stack = Join-Path $Root 'stack'
$cache = Join-Path $Root 'cache'
$logs = Join-Path $Root 'logs'
# -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
$setupScript = Join-Path $pluginRoot 'scripts\setup.mjs'
if (-not (Test-Path $setupScript)) { throw "setup.mjs not found next to this script: $setupScript" }
Write-Step "bundle root : $Root"
Write-Step "plugin root : $pluginRoot"
Write-Step "platform : $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription)"
Write-Step "architecture: $env:PROCESSOR_ARCHITECTURE"
if ($env:PROCESSOR_ARCHITECTURE -notmatch '64') {
throw "This bootstrap supports 64-bit Windows (PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE)."
}
# --- Node: reuse if compatible, else fetch the portable zip -----------------
#
# The version and checksum come from the manifest, which is the single source of
# truth the rest of provisioning already reads. Hard-coding a second copy here
# meant two numbers that could drift apart silently.
$manifestPath = Join-Path $pluginRoot 'manifest\bundle.manifest.json'
if (-not (Test-Path $manifestPath)) { throw "manifest not found: $manifestPath" }
$manifest = Get-Content $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
$hostKey = 'win32-' + $(if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' })
$nodeSource = $manifest.components.node.sources.$hostKey
if (-not $nodeSource) { throw "the manifest has no Node source for $hostKey" }
$nodeVersion = $nodeSource.version
$nodeSha256 = $nodeSource.sha256
$nodeUrl = $nodeSource.url
$nodeZipName = [System.IO.Path]::GetFileName(([uri]$nodeUrl).AbsolutePath)
# The official archive unpacks to exactly one top-level directory named after the
# zip: node-v24.21.0-win-x64.zip -> node-v24.21.0-win-x64. The extension is
# therefore stripped EXACTLY ONCE. Stripping twice yielded 'node-v24.21', so the
# checks below looked for <stack>\node-v24.21\node.exe and the run threw
# "the Node zip did not contain node-v24.21" *after* a successful download,
# checksum and extraction — on every machine that had no Node, which is the only
# audience this script has. Invisible wherever a Node is already on PATH.
$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 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 }
foreach ($clause in ($Range -split '\|\|')) {
$c = $clause.Trim()
if ($c -match '^\^(\d+)\.(\d+)\.(\d+)$') {
if ($versions[0] -eq [int]$Matches[1] -and $versions[1] -ge [int]$Matches[2]) { return $true }
} elseif ($c -match '^>=(\d+)\.(\d+)\.(\d+)$') {
if ($versions[0] -gt [int]$Matches[1] -or ($versions[0] -eq [int]$Matches[1] -and $versions[1] -ge [int]$Matches[2])) { return $true }
}
}
return $false
}
function Test-NodeUsable([string]$Exe) {
if (-not $Exe -or -not (Test-Path $Exe)) { return $null }
try {
$version = (& $Exe -v 2>$null)
} catch { return $null }
if (-not $version) { return $null }
$clean = $version.TrimStart('v').Trim()
# 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 }
}
# The directory the zip actually created. The derived name is preferred, but any
# directory carrying node.exe is accepted: a naming drift must not strand a machine
# that has already downloaded and checksum-verified the bytes.
function Get-PortableNodeDir {
$exact = Join-Path $stack $nodeFolder
if (Test-Path (Join-Path $exact 'node.exe')) { return $exact }
$found = Get-ChildItem -Path $stack -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName 'node.exe') } |
Select-Object -First 1
if ($found) { return $found.FullName }
return $exact
}
function Get-PortableNode([switch]$NoDownload) {
$exe = Join-Path (Get-PortableNodeDir) 'node.exe'
$found = Test-NodeUsable $exe
if ($found) { Write-Step "portable Node present: $($found.Path) (v$($found.Version))"; return $found }
# -DryRun still *looks* for the portable copy (that is the only way the plan can
# tell the truth about whether a download is needed) but never fetches anything.
if ($NoDownload) { return $null }
$zip = Join-Path $cache $nodeZipName
if (Test-Path $zip) {
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
if ($hash -ne $nodeSha256) { Write-Step "cached Node zip failed its checksum; re-downloading"; Remove-Item $zip -Force }
}
if (-not (Test-Path $zip)) {
Write-Step "downloading $nodeZipName (~35 MiB)"
$partial = "$zip.part"
if (Test-Path $partial) { Remove-Item $partial -Force }
# Invoke-WebRequest buffers the whole body before writing, so a slow mirror
# looks frozen; say so plainly rather than appearing stuck.
Invoke-WebRequest -Uri $nodeUrl -OutFile $partial -UseBasicParsing -TimeoutSec 1800
Move-Item -LiteralPath $partial -Destination $zip -Force
}
$actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $nodeSha256) { throw "Node.js zip checksum mismatch.`n expected $nodeSha256`n actual $actual" }
Write-Step "checksum ok: $actual"
Write-Step "extracting into $stack"
Expand-Archive -LiteralPath $zip -DestinationPath $stack -Force
$unpacked = Get-PortableNodeDir
if (-not (Test-Path (Join-Path $unpacked 'node.exe'))) { throw "the Node zip left no node.exe under $stack (expected $nodeFolder)" }
$found = Test-NodeUsable (Join-Path $unpacked 'node.exe')
if (-not $found) { throw "the extracted node.exe could not be executed" }
Write-Step "portable Node ready: $($found.Path) (v$($found.Version))"
return $found
}
$node = $null
$candidates = @()
if ($env:REA_BUNDLE_NODE) { $candidates += $env:REA_BUNDLE_NODE }
$onPath = (Get-Command node -ErrorAction SilentlyContinue)
if ($onPath) { $candidates += $onPath.Source }
foreach ($candidate in $candidates) {
$node = Test-NodeUsable $candidate
if ($node) { Write-Step "using existing Node: $($node.Path) (v$($node.Version))"; break }
}
# A portable Node already unpacked next to the bundle counts as an existing
# install, and asking about it is what makes the -DryRun plan tell the truth.
if (-not $node) { $node = Get-PortableNode -NoDownload:$DryRun }
if ($DryRun) {
if ($node) { Write-Step "plan: use Node $($node.Path) (v$($node.Version))" }
else { Write-Step "plan: download and extract $nodeZipName into $stack" }
Write-Step "plan: node `"$setupScript`" --root `"$Root`"$(if ($Reuse) { ' --reuse' })"
Write-Step "dry run complete; nothing was changed."
exit 0
}
if (-not $node) { $node = Get-PortableNode }
# --- hand over to setup.mjs ------------------------------------------------
$setupArgs = @($setupScript, '--root', $Root)
if ($Reuse) { $setupArgs += '--reuse' }
Write-Step "running setup: node $($setupArgs -join ' ')"
& $node.Path @setupArgs
$code = $LASTEXITCODE
if ($code -eq 0) {
Write-Step "done. Restart DeepSeek Harness so the mcp-rea row reconnects."
} else {
Write-Step "setup reported unmet components (exit $code). Re-run after fixing the cause."
Write-Step "log: $(Join-Path $logs 'setup.log')"
}
exit $code
+1718
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+138
View File
@@ -0,0 +1,138 @@
# verify-branch.ps1 — one-shot structural verification of the bundle branch.
# Read-only: parses, audits and unit-tests; it does not provision anything.
# Run from the branch root: powershell -NoProfile -File scripts\verify-branch.ps1
$ErrorActionPreference = 'Continue'
$root = Split-Path -Parent $PSScriptRoot
Set-Location $root
$fail = 0
function Check($name, [bool]$ok, $detail) {
if ($ok) { Write-Host (" PASS " + $name) -ForegroundColor Green }
else { Write-Host (" FAIL " + $name + " " + $detail) -ForegroundColor Red; $script:fail++ }
}
Write-Host "`n=== 1. JS syntax ===" -ForegroundColor Cyan
foreach ($f in @('index.mjs','bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs')) {
$out = & node --check $f 2>&1
Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
}
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.
foreach ($f in @('check-updates.ps1','assets\make-screenshots.ps1','scripts\rea-bundle-bootstrap.ps1','scripts\verify-branch.ps1')) {
$p = Join-Path $root $f
$bytes = [System.IO.File]::ReadAllBytes($p)
$bom = ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)
$errs = $null
[System.Management.Automation.Language.Parser]::ParseFile($p, [ref]$null, [ref]$errs) | Out-Null
$count = @($errs).Count
Check "$f parses" ($count -eq 0) "$count error(s): $(($errs | Select-Object -First 1).Message)"
if ($f -ne 'scripts\rea-bundle-bootstrap.ps1') {
# These two carry CJK text, which PowerShell 5.1 misreads without a BOM.
Check "$f has UTF-8 BOM" $bom "first bytes: $(($bytes[0..2] | ForEach-Object { $_.ToString('x2') }) -join ' ')"
}
}
Write-Host "`n=== 3. manifest audit ===" -ForegroundColor Cyan
$audit = & node scripts\audit-manifest.mjs 2>&1
$audit | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
Check 'audit clean' ($LASTEXITCODE -eq 0) 'see findings above'
Write-Host "`n=== 4. unit tests ===" -ForegroundColor Cyan
$tests = & node --test scripts\setup.test.mjs 2>&1
$testExit = $LASTEXITCODE
$failing = @($tests | Select-String -Pattern 'not ok' ).Count
Check 'node --test' ($testExit -eq 0) "$failing failing"
Write-Host "`n=== 5. the published patch is machine-path-free and escape-safe ===" -ForegroundColor Cyan
$patch = Get-Content (Join-Path $root 'cordis.patch.yml') -Raw
Check 'no drive letter in patch' (-not ($patch -match '[A-Za-z]:[\\/]')) 'a machine path leaked into the published patch'
Check 'no backslash escape in JS literals' (-not ($patch -replace '(?m)^\s*#.*$','' -match '\\[prbnt]')) 'use forward slashes'
Check 'references rea-shim.mjs' ($patch -match 'rea-shim\.mjs') ''
Check 'has a DSH_HOME fallback' (($patch -match 'DSH_HOME') -and ($patch -match 'USERPROFILE|HOME')) ''
$jsProbe = Join-Path $env:TEMP 'rea-verify-literals.cjs'
@'
const fs = require("node:fs");
const text = fs.readFileSync("cordis.patch.yml", "utf8").replace(/^\s*#.*$/gm, "");
const control = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/;
const literals = [...text.matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((m) => {
try { return new Function("return '" + m[1] + "'")(); } catch { return m[1]; }
});
const bad = literals.filter((v) => control.test(v));
console.log("literals: " + JSON.stringify(literals));
if (bad.length) {
console.error("control characters in: " + bad.map((v) => JSON.stringify(v)).join(", "));
process.exit(1);
}
'@ | Set-Content $jsProbe -Encoding utf8
& 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=== 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 @('index.mjs', 'bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
Check "files[] includes $entry" (@($pkg.files) -contains $entry) ''
}
# 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)"
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test')) {
Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
}
Write-Host "`n=== 7. dry-run still plans the whole chain ===" -ForegroundColor Cyan
$env:REA_BUNDLE_ROOT = Join-Path $env:TEMP 'rea-verify-dryrun'
# Deliberately WITHOUT --reuse. Adoption of whatever this machine happens to have
# installed used to hide a defect that only a bare machine sees: every planned
# component returned `{ planned: true }` with no path, so the dependents of each
# plan entry were reported as "required component is unavailable" and the command
# exited 1 — for the exact reader the README sends to `--dry-run`.
$dry = & node scripts\setup.mjs --root $env:REA_BUNDLE_ROOT --dry-run 2>&1
$planned = @($dry | Select-String -Pattern '^\[plan\]').Count
$unmet = @($dry | Select-String -Pattern 'is unavailable').Count
Check 'dry-run exits 0 without --reuse' ($LASTEXITCODE -eq 0) (($dry | Select-Object -Last 3) -join ' ')
Check 'dry-run plans every component' ($planned -ge 4) "planned=$planned"
Check 'dry-run reports no unavailable dependency' ($unmet -eq 0) "unavailable=$unmet"
Remove-Item Env:\REA_BUNDLE_ROOT -ErrorAction SilentlyContinue
Write-Host "`n=== 8. bootstrap recognises an unpacked portable Node (no download) ===" -ForegroundColor Cyan
# A machine with no Node is this script's entire audience, so the portable path is
# only ever exercised where it is least observable: it must therefore be tested
# here, with an empty PATH and a stack laid out exactly as the official zip lays it
# out. The folder name is derived from the manifest by a SINGLE extension strip
# (node-v24.21.0-win-x64.zip -> node-v24.21.0-win-x64); a double strip used to make
# the bootstrap throw "the Node zip did not contain node-v24.21" after a successful
# download, checksum and extraction. -DryRun keeps this check download-free.
$bootRoot = Join-Path $env:TEMP 'rea-verify-bootstrap'
# -Encoding UTF8: the manifest carries non-ASCII text and PowerShell 5.1 would
# otherwise read a BOM-less file as ANSI and fail ConvertFrom-Json.
$manifest = Get-Content (Join-Path $root 'manifest\bundle.manifest.json') -Raw -Encoding UTF8 | ConvertFrom-Json
$bootLayoutName = [System.IO.Path]::GetFileNameWithoutExtension(
[System.IO.Path]::GetFileName(([uri]$manifest.components.node.sources.'win32-x64'.url).AbsolutePath))
$bootLayout = Join-Path (Join-Path $bootRoot 'stack') $bootLayoutName
New-Item -ItemType Directory -Path $bootLayout -Force | Out-Null
$hostNode = (Get-Command node -ErrorAction SilentlyContinue).Source
if ($hostNode) {
$target = Join-Path $bootLayout 'node.exe'
if (Test-Path $target) { Remove-Item $target -Force }
try { New-Item -ItemType HardLink -Path $target -Target $hostNode -ErrorAction Stop | Out-Null }
catch { Copy-Item $hostNode $target -Force }
}
$bootOut = & powershell -NoProfile -Command "`$env:PATH='C:\Windows\System32;C:\Windows'; & '$root\scripts\rea-bundle-bootstrap.ps1' -Root '$bootRoot' -Reuse -DryRun" 2>&1
Check 'bootstrap finds the portable Node in the official layout' (@($bootOut | Select-String 'portable Node present').Count -ge 1) (($bootOut | Select-Object -Last 2) -join ' ')
Check 'bootstrap plans no download when one is present' (@($bootOut | Select-String 'plan: download').Count -eq 0) (@($bootOut | Select-String 'plan:').Line -join ' | ')
for ($i = 0; $i -lt 5; $i++) {
try { Remove-Item -Recurse -Force $bootRoot -ErrorAction Stop; break } catch { Start-Sleep -Milliseconds 300 }
}
Write-Host ''
if ($fail -eq 0) { Write-Host 'ALL CHECKS PASSED' -ForegroundColor Green }
else { Write-Host "$fail CHECK(S) FAILED" -ForegroundColor Red }
exit $fail