94 Commits
Author SHA1 Message Date
czcdr b06b4ae987 chore: refresh the marketplace screenshots for 2.0.16
The market page renders `assets/screenshot-1.png` straight from GitHub HEAD, so
whatever is committed here is what shoppers see. It still said "DSH desktop runtime
0.2.0-rc.2" and "2026-10-26" — two months and several host releases behind what this
plugin was actually measured against.

- the host version became a variable (`$hostMeasured`); it has no manifest field to
  read because it is not a property of this package, so it carries a comment saying
  it must move with the measurement
- `$measuredOn` is 2026-11-07, the date of the last real `--reuse` run (smoke
  imported java.exe in 31 s), and the stale hardcoded defaults (6.7.0 / 24.21.0)
  were brought up to the current pins so a failed manifest read no longer paints
  old numbers
- both PNGs regenerated; screenshot-2 carries no version text and is unchanged in
  content

The npm tarball for 2.0.16 still holds the previous images — that is harmless, because
the market reads these files from HEAD, not from the package.
2026-11-07 14:46:21 +08:00
czcdr b5e915a1a5 fix: hash the frida binding before importing it, and quarantine a mismatch (2.0.16)
The third audit cleared the four P0s and the npm-fallback change, and rejected
2.0.15 on one point: the Frida gate hashed AFTER it imported. `verify()` — which
imports frida and enumerates devices — ran before `judgeBinding()` compared the
digest, so a mismatched native module was executed inside the provisioning process
and then reported as rejected (the audit measured `moduleImportedBeforeRejection:
true` on a Linux copy). Writing `available: false` afterwards does not undo an
import, and the shim does not consume that field either, so nothing stopped REA
from loading the same file on the next tool call.

- `inspect()` is now the first thing that touches the binding: path + SHA-256, no
  import. `verify()` only runs when the digest matches the recorded one, or when
  this platform has no expectation at all (then `verified: false`, as before).
- after `npm rebuild frida`, the result is inspected BEFORE it is imported too — a
  rebuild can produce a binding that exists, loads, and is not the measured one.
- a mismatch now MOVES the file to `frida_binding.node.rejected` instead of only
  recording a verdict: REA reaches `build/frida_binding.node` directly, so taking it
  off that path is the only thing that actually stops the load. Quarantined rather
  than deleted, so the mismatch stays inspectable; if the move itself fails the
  message says so and tells the operator to remove the file by hand.
- the message is now "moved it to … before any import, so nothing can load it",
  which is what the code does — the old "rather than loading an unverified native
  module" was the line 2.0.15 could not back up.

Two regression tests, both injecting a frida package whose `index.js` writes a
marker and then throws, so "was this module imported?" is a fact on disk:

- the mismatched binding is never imported (no marker, output says "before any
  import");
- the binding is gone from the load path and present as `.rejected`.

Both skip on targets without a pinned digest — the only measured one is
`17.23.2/win32-x64` — and the skip message says the reason is "no expectation to
mismatch", not that the platform does not matter.

Tests: 160 (157 pass / 0 fail / 3 skipped). audit 24/24 hash-pinned clean,
dependency gate clean, host-compat 16 covered, verify-branch ALL CHECKS PASSED.
On this machine `--reuse` reports `fridaBindings — FRIDA_OK 3 — digest matches the
recorded one`, the Ghidra smoke imports java.exe in 31 s, exit 0.

Still open, unchanged from the last round: the shim rewrites the lock from its
parent process without a token comparison (low-priority race, not release
blocking), and Frida has a pinned digest for win32-x64 only.
2026-11-07 13:58:02 +08:00
czcdr 273153243a fix: close the second audit — rollback, existing-closure, frida retry, swap order (2.0.15)
A second audit of 5c71bf8 found 4 release-blocking gaps, all reproduced, all real.
Two are things 2.0.14 left undone (one of them documented as done), two are
defects 2.0.14 itself introduced. The common shape this time is the mirror of the
first round: the fixed path was not the path a user actually walks — rollback only
in the multi-file branch, closure floors only after a fresh install, the Frida
verdict written once, the old closure deleted before the new one was verified.

P0 — left undone by 2.0.14
- a plain archive had no rollback: the 2.0.14 note claimed "renamed to .backup,
  rolled back if the probe fails", but that lived only in the multi-file branch and
  `archive` still did rm -> rename -> return on a failed probe. Measured: exit 1,
  old known-good tree gone, broken tree in place, no .backup. Both branches now
  share the staging -> backup -> swap -> probe -> drop contract, and the message
  distinguishes "put back", "parked at", "nothing to restore".
- the advisory fix never reached existing installs: the `recorded` branch and the
  "already vendored" branch both returned before any closure check, so a 2.0.13 ->
  2.0.14 upgrade printed `already provisioned (6.8.0)` with zod still 4.4.3.
  `closureAdvisories()` reads both node_modules roots and runs before BOTH early
  returns; below a floor it reinstalls, and offline it fails instead of reporting
  health. Verified on this machine, which was in exactly that state: it reported
  zod 4.4.3 < 4.6.6, minimatch 10.2.6 < 11.0.1, glob 13.0.6 < 14.0.2, reinstalled
  from the audited lock to 4.6.7 / 11.0.5 / 14.0.4, and the closure has been
  vulnerable-free since.

P0 — introduced by 2.0.14
- the Frida verdict was cached forever: gated on `frida === undefined`, one
  transient failure became permanent (every later run read `available: false` and
  skipped), and a binding deleted after a good record was never re-checked. The
  check now runs on every non-dry-run.
- the swap deleted the old closure before verifying the new one: `.retired` was
  removed one line after the rename, so a closure that renamed fine and then could
  not start left the machine with no usable REA. `retired` now survives until
  BOTH `--version` and `doctor --format json` answer, and `restorePreviousClosure()`
  puts it back otherwise. doctor's verdict is "produced parseable JSON", not
  "healthy" — it exits non-zero for any unconfigured optional capability.

P1
- `npm ci` no longer degrades silently to an unlocked install: that fallback traded
  byte-reproducibility for a green run. It now fails closed with the reason, and
  `--allow-unlocked-npm-fallback` is the explicit opt-in.
- Frida binding digests: FRIDA_BINDING_SHA256 holds the ONE measured value
  (17.23.2/win32-x64). Match -> verified: true; no expectation for this platform ->
  usable but verified: false and the step says the digest was recorded, not
  checked; MISMATCH -> Frida disabled rather than loading an unmeasured native
  module. "We hashed what we got" is no longer reported as a verified chain.

P2
- the run lock is only deleted or rewritten when it still carries this run's token
  (or pid); otherwise it is left alone, because removing somebody else's lock is
  how a live run loses its brake.

Also: the offline reinstall message now names which of the three reasons applies
instead of calling all of them "not usable".

Tests: 158 (155 pass / 0 fail / 3 skipped), including the four fault injections the
audit asked for — archive probe failure restores the old install, a recorded 6.8.0
with zod 4.4.3 is not reported as provisioned, a stale frida verdict is re-checked,
and the swap ordering contract (retired outlives both probes). audit 24/24
hash-pinned clean, dependency gate clean, host-compat 16 covered, verify-branch
ALL CHECKS PASSED.
2026-11-07 13:03:42 +08:00
czcdr 5c71bf87f7 fix: report a missing Get-FileHash as "cannot verify", not as a defect
The Windows runner came back red on three check-updates assertions for one
reason: `Get-FileHash` does not exist there, the mirror check threw, and the catch
reported `mirror.json 解析失败` as [注意] — which counts as "needs action". A host
that cannot hash two files therefore looked like a machine with a broken bundle.

That is the same false red this release already removed from the `rea doctor` exit
code and from the bare-major pin comparison, so it gets the same treatment:

- the mirror check asks `Get-Command Get-FileHash` first and says [上游] when the
  capability is absent, rather than implying the mirror is inconsistent
- "mirror.json points at a plugin directory that no longer exists" and a
  mirror.json that will not parse are also [上游]: both are "cannot compare",
  not "compared and differs"

The test that constructs the stale-mirror warning now skips on a host without
Get-FileHash, because that scenario is unreachable there — asserting on a warning
the platform cannot emit would only produce another red that means nothing.

Verified: hardening.test 14/14 locally, full suite 154 (151 pass / 3 skipped).
2026-11-06 23:31:05 +08:00
czcdr 849863841d ci: gate the vendored dependency closure in both validate jobs
`npm run bundle:deps` now runs after the manifest audit on Linux and on Windows,
so a lock that drifts back onto an advisory-affected zod/minimatch/glob — or onto
a rea-agents release the manifest does not pin — fails the build instead of only
being caught on the machine that provisioned it. The gate is offline, which is the
point: one that needs the registry fails on a plane and passes during an outage.

The earlier commit had to leave this out because the push credential had no
GitHub `workflow` scope; with a scoped credential it lands here.
2026-11-06 23:19:29 +08:00
czcdr 85933748ac fix: force UTF-8 out of the PowerShell host in the check-updates tests
The Windows CI job went red on five check-updates assertions while Linux passed.
Two separate causes, both in the test harness rather than the shipped script:

1. A Windows runner's console answers in CP437, where every CJK character
   collapses to "?" irreversibly — the conclusion line arrived as
   `[??] ????:pin ?? 1 ?;?? 2 ?(????)`, which decodeOutput's GBK fallback cannot
   rescue. The child process now sets [Console]::OutputEncoding and
   $OutputEncoding to UTF-8 before running the script. This is a harness concern:
   the product's own output behaviour is untouched.

2. Replacing `-File` with `-Command` broke parameter binding. `& 'script.ps1'
   '-Registry' 'url'` hands PowerShell a positional string "-Registry" instead of
   binding the named parameter, so it became $Registry's value and the lookup
   requested "-Registry/rea-agents/latest" (measured: HTTP 502). Parameter names
   stay unquoted now; only values are quoted.

Both were caught by CI and reproduced locally before the fix, verified after:
hardening.test 14/14, full suite 154 (151 pass / 3 skipped / 0 fail).
2026-11-06 23:18:08 +08:00
czcdr 8e2acfdffc fix: close the 18-item audit and adapt to DSH 0.2.2-rc.1 (2.0.14)
An external audit of 2.0.13 found that install state could lie: a partially
failed install was recorded as a success, a broken recorded path was vouched for
by a different working copy, and an expired lock was deleted out from under a
live process. Every finding shares one shape — the thing verified was not the
thing being delivered — so the fix in each case is to make the probe look at the
artefact that will actually be used, and to keep "cannot prove" distinct from
"proved false". All 18 items are addressed. No upstream component moved: REA
6.8.0 / Ghidra 12.1.4 / JDK 21 / apktool 3.0.3 / jadx 0.7.1 / Node 24.22.1.

Provisioning (scripts/setup.mjs)
- multi-file components are all-or-nothing: every entry must arrive, files are
  staged in <target>.staging and swapped in atomically only when complete, the
  previous directory is kept as .backup and restored if the swap or the probe
  fails, and the record carries one {id,version,sha256} per entry with the
  primary chosen by kind instead of by manifest order
- a recorded component is probed AT its recorded path (independent resolve
  context) and its version is checked against the manifest pin; JDK matches by
  major floor; an enabled Ghidra gate override is honoured so a plain setup can
  no longer silently re-pin over one
- JSON state files are written through writeJsonAtomic(); an unparseable
  setup.lock is replaced instead of throwing; --dry-run writes nothing at all
- Frida's native binding is repaired by naming the one package whose install
  script is load-bearing (npm rebuild frida) and then proved with
  import('frida') + enumerateDevices(); checked in main, so existing bundles
  (i.e. every upgrade) get fixed too, and reported as skip rather than failing
- the tar fallback passes filter='data' to Python's extractall

Runtime (bin/rea-shim.mjs)
- one definition of bundleUsable = loaded.ok && resolveRea(...).ok drives the
  launch decision, the --shim-status exit code and the repair path; an unusable
  bundle no longer wins against a working system REA, and status returns 78
- liveness outranks age for a running lock: a live pid is never deleted, an
  expired one is reported as in-flight-stale; locks with no pid keep the old
  time-based rule; setup heartbeats `at` from log(); shim->setup handoff uses a
  token (REA_BUNDLE_LOCK_TOKEN) instead of a pid

Dependencies (scripts/vendor-lock/, scripts/check-dependency-advisories.mjs)
- REA pins zod exactly at 4.4.3, which sits inside GHSA-mrhx-c7xc-m3w5; the
  closure is now replayed from an audited lock with npm ci and overrides to zod
  4.6.7 / minimatch 11.0.5 / glob 14.0.4, verified afterwards against 4.6.6
- an offline gate fails the build if the lock drifts back, and CI runs it

Host adaptation (scripts/host-compat.mjs)
- HOST_RELEASES 10 -> 16: 0.2.2-rc.1, 0.2.2-alpha.3, 0.2.1-rc.5, 0.2.2-alpha.2,
  0.2.1-rc.4, 0.2.1-rc.3, all three-source verified and in release-date order;
  engines.dsh and OUTSIDE_HOSTS unchanged

Also fixed while verifying (not in the audit)
- a bare-major pin ("21") was treated as a family prefix, so 25.0.5 counted as
  drift while setup counted it as satisfied; it is a floor, in all three files
- check-updates.ps1 no longer reports a false red: `rea doctor` exits non-zero
  for any unconfigured optional capability, so that exit code is now [upstream]
  and the parsed provider checks decide
- two new JSON reads went around the BOM-tolerant reader; routed through
  readJsonSafe

Tests: 154 (151 pass / 0 fail / 3 skipped), audit 24/24 hash-pinned clean,
dependency gate clean, host-compat 16 covered, verify-branch ALL CHECKS PASSED.
2026-11-06 23:08:33 +08:00
czcdr 4d8a08266d fix: derive the node-adoption guard from the manifest, not from 6.7.0's floor 2026-10-26 21:32:57 +08:00
czcdr ed6b3c99b8 docs: redraw the market screenshot for 6.8.0 (229 tools, measured 2026-10-26) 2026-10-26 21:27:32 +08:00
czcdr c55768233c fix: the setup card must say what setup resets
Restarting dsh with the 2.0.12 copy still in the profile re-provisioned
the bundle to 2.0.12's pin: REA went back to 6.7.0, the Ghidra gate
override went with it, and the MCP row failed with "REA could not start".
That is the card doing its job on a machine whose plugin copy was stale —
but the card never said the aligned run also wipes the override.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Docs-only patch release so the npm package page renders it.
2026-10-15 19:05:07 +08:00
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
czcdr 74ea74434c 1.0.5: widen the CI gate to examples, drop the fake path, harden check-updates 2026-10-13 17:56:56 +08:00
czcdr 0a8113c68f ci: ship the Validate and Release workflows promised by 1.0.4 2026-10-13 17:51:46 +08:00
czcdr bdb3a4fc18 1.0.4: configure section rewritten (profile override), examples, badge cleanup 2026-10-13 00:18:10 +08:00
czcdr 052a526b74 Add with-env example row 2026-10-13 00:17:04 +08:00
czcdr 5f594f20d8 Add POSIX example row 2026-10-13 00:16:54 +08:00
czcdr f712f24291 docs: note both powershell and pwsh invocations 2026-10-13 00:16:46 +08:00
czcdr eac1e66079 1.0.4: engines-aware Node check, PATH fallbacks, profile guards 2026-10-13 00:16:38 +08:00
czcdr e2e92f23b5 1.0.4 2026-10-13 00:16:26 +08:00
czcdr e53ac33ec9 1.0.4: shorten description, ship screenshots/examples 2026-10-13 00:16:16 +08:00
czcdr a1faeaa178 1.0.4: portable row (cmd.exe / rea from PATH), no machine-specific path 2026-10-13 00:15:41 +08:00
czcdr ae23840b51 README: add npm version badge (published as [email protected]) 2026-10-12 23:37:42 +08:00
czcdr 8f00a16ca1 Remove local-only maintenance record from the public repo 2026-10-12 23:05:13 +08:00
czcdr 4cc1cee632 Ignore the local-only maintenance record 2026-10-12 23:05:10 +08:00
czcdr 57caa9f654 CHANGELOG: 1.0.3 2026-10-12 23:05:07 +08:00
czcdr 48dcd9ca20 v1.0.3 2026-10-12 23:05:05 +08:00
czcdr f27514c38b README: embed screenshot; drop local-only notes link 2026-10-12 23:05:02 +08:00
czcdr d12b9b744d Add reproducible screenshot generator 2026-10-12 23:04:59 +08:00
czcdr c310a0f212 Add screenshot 2: upstream coverage and its hard limit 2026-10-12 23:04:56 +08:00
czcdr 22a4624942 Add screenshot 1: what the plugin composes 2026-10-12 23:04:53 +08:00
czcdr 8c554a316f Add screenshots.json for market storefronts 2026-10-12 23:04:49 +08:00
czcdr 8a9011ef42 check-updates: market-aware plugin update wording 2026-10-12 22:57:50 +08:00
czcdr b21b6d5abd docs: durable project notes (release state, decisions, todos) 2026-10-12 22:57:35 +08:00
czcdr 940e4fee1f CHANGELOG: 1.0.2 2026-10-12 22:57:32 +08:00
czcdr 81b8cf885e v1.0.2: ship project notes in the tarball 2026-10-12 22:57:30 +08:00
czcdr 82f06d97d1 README: add what it can and cannot do (no full source recovery); market-aware update wording 2026-10-12 22:57:27 +08:00
czcdr 917bf298f0 check-updates: no upstream updater command; note manual plugin reinstall 2026-10-12 22:47:39 +08:00
czcdr 646f8fab10 CHANGELOG: 1.0.1 2026-10-12 22:47:37 +08:00
czcdr 533a371f0f v1.0.1 2026-10-12 22:47:34 +08:00
czcdr 34ed59b75e README: separate manual plugin updates from upstream REA updates 2026-10-12 22:47:32 +08:00
czcdr 933e25fa09 docs: upstream-based marketplace description 2026-10-12 22:46:25 +08:00
czcdr f7f7d09a82 package.json: upstream-based description 2026-10-12 22:46:23 +08:00
czcdr 5cfcee73be README: state the function in upstream REA own words 2026-10-12 22:46:20 +08:00
czcdr 4938d42eb4 docs: precise marketplace description + tarball link 2026-10-12 22:44:55 +08:00
39 changed files with 18247 additions and 186 deletions

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
name: Release
# Two independent jobs, both triggered by publishing a GitHub Release:
# 1. pack — builds the tarball, renames it to the version-free asset name and
# attaches it to the release (the market's `latest/download` link
# depends on that exact name staying free of a version number).
# 2. publish — publishes to npm. Enabled only after this repository is
# configured as a Trusted Publisher on npmjs.com and the repo
# variable NPM_TRUSTED_PUBLISHING is set to "true"; until then the
# job is skipped, so a release never fails on missing credentials.
#
# Setup for job 2 (one time, by the package owner):
# npmjs.com → the package → Settings → Trusted Publisher → GitHub Actions:
# Organization/user: czcdr Repository: dsh-plugin-rea
# Workflow filename: release.yml Environment: (leave empty)
# then: repo Settings → Secrets and variables → Actions → Variables →
# NPM_TRUSTED_PUBLISHING = true
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
jobs:
pack:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Verify the released tag matches package.json
run: |
tag="${{ github.event.release.tag_name }}"
ver="v$(node -p "require('./package.json').version")"
if [ -n "$tag" ] && [ "$tag" != "$ver" ]; then
echo "FAIL: release tag $tag does not match package.json $ver"
exit 1
fi
echo "tag matches: ${tag:-<manual run>}"
- name: Pack and attach a version-free tarball
env:
GH_TOKEN: ${{ github.token }}
run: |
npm pack
mv dsh-plugin-rea-*.tgz dsh-plugin-rea.tgz
ls -l dsh-plugin-rea.tgz
if [ -n "${{ github.event.release.tag_name }}" ]; then
gh release upload "${{ github.event.release.tag_name }}" dsh-plugin-rea.tgz --clobber
fi
publish:
needs: pack
if: ${{ vars.NPM_TRUSTED_PUBLISHING == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: npm CLI must support OIDC trusted publishing (>= 11.15.0)
# 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
+265
View File
@@ -0,0 +1,265 @@
name: Validate
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
# The shim's launch decision reads this machine's REA. No runner has one, so
# the default `auto` would happen to take the bundle path today — pin it so
# that stays true if an image ever ships REA.
env:
REA_BUNDLE_LAUNCH: bundle
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Bundle manifest sanity
run: |
node -e "
const p = require('./package.json');
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', 'index.mjs', 'README.md', 'LICENSE', 'check-updates.ps1']) {
if (!p.files.includes(f)) fail('files[] must ship ' + f);
}
if (!require('fs').existsSync(p.dsh.bundle.patch)) fail('dsh.bundle.patch target does not exist');
// The market's update path rolls a build back when package.json points
// at no existing entry (dshmarket's hasLoadableEntry); this package
// shipped no main at all until 2.0.5, so every update of a healthy
// profile failed with "updated build has no loadable entry".
if (typeof p.main !== 'string') fail('package.json must declare main');
if (!require('fs').existsSync(p.main)) fail('main points at no shipped file: ' + p.main);
// engines.dsh is what the market reads to say "compatible" instead of
// "unknown"; without it the plugin is reported as undeclared.
if (!p.engines || typeof p.engines.dsh !== 'string') fail('package.json must declare engines.dsh');
console.log('manifest ok');
"
- name: Declared DSH range covers every released host
# A wrong range is silent on both sides: unparseable reads as "undeclared"
# and a drifted floor drops hosts the README still lists.
run: node scripts/host-compat.mjs
- name: The READMEs quote the declared DSH range verbatim
# The same comparison verify-branch.ps1 makes locally. Without it here, a
# stale quote survives every CI signal: the JSON, host-compat and the
# audit all agree while the sentence a human reads names another ceiling
# (that is how `<0.3.0` outlived the move to `<0.3.0-0`).
run: |
node -e "
const p = require('./package.json');
const { readFileSync } = require('fs');
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1) };
for (const doc of ['README.md', 'README.zh-CN.md']) {
const text = readFileSync(doc, 'utf8');
if (!text.includes(p.engines.dsh)) fail(doc + ' does not quote engines.dsh (' + p.engines.dsh + ')');
// The pin example is copy-pasteable: it must name the version this
// tree publishes, or the reader gets a ref that does not exist.
if (!text.includes('dsh-plugin-rea#v' + p.version)) fail(doc + ' does not pin dsh-plugin-rea#v' + p.version);
}
console.log('readme range ok');
"
- name: Both JSON readers tolerate a BOM
# verify-branch.ps1 runs the same check locally. bin/rea-shim.mjs and
# scripts/setup.mjs read the same mirror.json/bundle.json, so a strip on
# one side only makes the same file say "there is a checkout" to the shim
# and "there is none" to setup — which shipped once: setup re-mirrored the
# previous manifest. A bare JSON.parse(readFileSync(...)) is that bug.
run: |
node - <<'JS'
const { readFileSync } = require('node:fs');
const blind = [];
for (const file of ['bin/rea-shim.mjs', 'scripts/setup.mjs']) {
const text = readFileSync(file, 'utf8');
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
// readJson() itself is the strip; anything else must not parse a file raw.
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
blind.push(file + ':' + text.slice(0, m.index).split('\n').length);
}
}
if (!text.includes('replace(/^\\uFEFF/')) blind.push(file + ': no U+FEFF strip');
}
if (blind.length) {
console.error('FAIL: BOM-blind JSON read at ' + blind.join(', '));
process.exit(1);
}
console.log('bom-blind reads: none');
JS
- name: JSON files parse
run: |
node -e "
for (const f of ['package.json', 'screenshots.json']) {
JSON.parse(require('fs').readFileSync(f, 'utf8'));
console.log('json ok:', f);
}
"
- 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 — 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', 'index.mjs', 'README.md', 'README.zh-CN.md', 'LICENSE', 'CHANGELOG.md', 'check-updates.ps1',
'screenshots.json', 'assets/screenshot-1.png', 'assets/screenshot-2.png',
];
const missing = need.filter((f) => !files.includes(f));
if (missing.length) {
console.error('FAIL: the npm payload is missing ' + missing.join(', '));
process.exit(1);
}
console.log('npm payload ok: ' + files.length + ' files, all market-facing files included');
"
- name: YAML files parse
run: |
python3 -m pip install --quiet --disable-pip-version-check pyyaml
python3 - <<'PY'
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)
print('yaml ok:', f)
PY
- name: The shipped patches stay machine-independent
run: |
set -e
# 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
fi
if grep -nE '/(home|Users)/[A-Za-z0-9._-]+' $targets; then
echo "FAIL: a shipped patch contains an absolute POSIX home path"
exit 1
fi
echo "patches ok: no machine-specific path"
- name: The row keeps its contract
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
assert row['name'] == '@deepseek-ai/dsh-mcp-client', row
cfg = row['config']
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
- name: Vendored dependency advisories
# Provisioning resolves REA's closure from scripts/vendor-lock. This fails
# the build if that lock drifts back onto an advisory-affected zod,
# minimatch or glob, or onto a rea-agents release the manifest does not
# pin. It is offline on purpose: a gate that needs the registry fails on a
# plane and passes during a registry outage, which is the wrong shape for
# a build check.
run: npm run bundle:deps
windows:
# The PowerShell download route, the tar/Expand-Archive extraction path and
# scripts/rea-bundle-bootstrap.ps1 are Windows-only code, and an ubuntu-only
# matrix never executed any of them — they were verified by hand, if at all.
# The tests are platform-sensitive on purpose: each platform exercises the
# download route it actually chooses.
runs-on: windows-latest
# Same pin as the ubuntu job: the launch decision must not depend on what the
# runner image happens to have installed.
env:
REA_BUNDLE_LAUNCH: bundle
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Unit tests for the pure logic (Windows)
run: npm run bundle:test
- name: Bundle manifest audit (Windows)
run: npm run bundle:audit
- name: Vendored dependency advisories (Windows)
run: npm run bundle:deps
+3
View File
@@ -13,3 +13,6 @@ pnpm-debug.log*
# Local overrides: keep your machine-specific paths out of git
cordis.patch.local.yml
# Local-only maintenance record — never publish this file
docs/project-notes.md
+1107
View File
File diff suppressed because it is too large. Load diff
+474 -89
View File
@@ -1,133 +1,518 @@
# dsh-plugin-rea
[![awesome · DSH plugin](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
[![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)
> - 上游 npm 包:`rea-agents`
> - 本插件仓库:**<https://github.com/czcdr/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).
---
> REA **6.8.0** — this package's pin — still accepts Ghidra **12.1.x** only (12.2 is `unsupported`, and the whole
> Ghidra decompile/analysis group disappears) and the jadx bridge **0.7.1** only (0.8.0 fails on its first call with
> `Expected engine 0.7.1`, before any APK is loaded). Both gates were re-checked against 6.8.0 on 2026-10-26 and
> neither one moved. JDK **21.0.13** is out upstream, but Adoptium publishes no Windows build of it, so there is
> nothing to point at here — and it drops only three of the four eMudhra anchors. The trade-off is therefore "all
> tools green, CVE still open": Ghidra 12.1.4's CVE-2026-100504 / 100505 / 100503 / 113604 stay in the chain — **do
> not analyse untrusted samples with this environment** — and the JDK still trusts the eMudhra anchors. Evidence,
> mitigations and revisit triggers: [docs/update-review-2026-10-24.md](docs/update-review-2026-10-24.md) and
> [docs/update-review-2026-10-26.md](docs/update-review-2026-10-26.md).
>
> **Ghidra 12.2 itself works** — measured 2026-10-26: same 9 segments, same 341 procedures, byte-identical `entry`
> pseudocode as 12.1.4, through REA's own bridge. What blocks it is REA's version-equality check, not the bridge.
> If you want the CVE-fixed engine anyway, `node scripts/ghidra-gate-override.mjs --enable --version 12.2` rewrites
> that one constant in the vendored copy, records what it changed in `<bundle>/ghidra-gate-override.json`, and
> `--disable` restores it; re-running setup reverts it too. It needs **JDK 25+** (`application.java.min=25`).
## English
> **You must run `node scripts/setup.mjs` yourself — once after installing, and once after every plugin
> upgrade.** Nothing runs it for you: a market install or update replaces the package and stops there.
> Since **2.0.8** a market upgrade keeps the *row* working (the mirrored launcher re-execs the new one), but it
> does **not** move the bundle to the new pins — REA and the engines stay at the versions recorded by the last
> setup run. Skip it on a first install and the row exits `MODULE_NOT_FOUND` with no tools and no message;
> skip it after an upgrade and you keep running the old REA silently (`check-updates.ps1` reports the drift).
> If you had enabled the Ghidra gate override (see §9 of the 2026-10-26 review), that setup run re-provisions the
> bundled REA and the patch goes with it — re-enable it afterwards, or accept the pinned Ghidra again.
> The full rule set is under [Updating](#updating--three-independent-things).
**What it is.** A configuration-only Cordis bundle for DeepSeek Harness. It inserts one row of the
official `@deepseek-ai/dsh-mcp-client` plugin and points it at your local `rea mcp` stdio server, so
every session in the profile gets `mcp__rea__*` tools (`mcp__rea__binary_overview`,
`mcp__rea__procedure_pseudo_code`, `mcp__rea__trace_feature`, …).
> **A first install and every plugin upgrade also show a one-time guide in the Web GUI.** It says whether the
> runtime is missing or behind this package's pin, shows the exact `node "<install path>/scripts/setup.mjs"`
> command for that install, and remembers the dismissal per plugin version. It is a notice only: it never runs
> setup for you and never edits the bundle. Run it once, restart dsh, and the guide stays away until the next
> plugin version.
**Relationship to upstream.** This is a third-party adapter, not part of REA, and it ships no REA
code. Upstream: <https://github.com/morluto/rea> (MIT). It only composes configuration; the analysis
engines (Ghidra, IDA, JEB) stay owned and licensed by their respective vendors.
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__*`.
**Verified against** (author's machine, 2026-10-12):
![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)
| Component | Version |
## What this is
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.
> **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).
**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 it can and cannot do
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.
- 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.
**What provisioning cannot buy you.** These are upstream host gates, not settings this plugin forgot:
| Capability | Gate |
|---|---|
| REA (`rea-agents`) | **6.4.0** |
| DeepSeek Harness (dsh desktop runtime) | **0.2.0-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 |
**Prerequisites.** Node.js (a version REA accepts), the REA CLI installed globally
(`npm i -g rea-agents`), and for decompilation a Ghidra 12.1.x extraction directory. IDA Pro / JEB
need their own commercial licences; Hopper is macOS-only.
## Two lines: 1.x and 2.x
**Install.** Add this repository as a bundle (the repo declares `dsh.bundle.patch`, so it is
installable directly):
| | **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` |
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:
```
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea
powershell -ExecutionPolicy Bypass -File scripts\rea-bundle-bootstrap.ps1 -Reuse
```
Or install the plugin manager's bundle entry with a local checkout: `file:<path-to-this-repo>`.
`-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:
**Configure — two machine-specific values.** Edit `cordis.patch.yml` before or after installing:
```
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.16
```
1. `args[0]` — full path of the REA CLI entry: run `npm root -g`, then append
`\rea-agents\scripts\rea.mjs` (`/rea-agents/scripts/rea.mjs` on POSIX).
2. `env.GHIDRA_INSTALL_DIR` — your Ghidra extraction directory. Omit it only if you do not use Ghidra.
> **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.
`command: node` resolves through `PATH`; replace it with a full path if `node` is not on the PATH the
harness sees. `env.JAVA_HOME` is optional (only needed when `java` is not on `PATH`).
If Node is already installed, you can skip the bootstrap and run provisioning directly:
**Verify.** Ask for a `mcp__rea__*` call — `binary_session` reports the engine checks
```
node scripts/setup.mjs --reuse # or --dry-run to see the plan first
```
Node, when you provide it instead of letting setup fetch one, must satisfy REA's engines —
`^22.23.4 || ^24.22.1 || >=26.12.1` (check with `node -v`). REA raised those floors in 6.8.0: the pinned
24.22.1 is now the *oldest* acceptable 24.x, so a 24.11–24.21 runtime that 6.7.0 accepted is rejected here.
A lower Node does not fail at install time; the MCP row fails later, which reads as silence.
Installing the plugin **first** also works, but it takes two steps. The row resolves to
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`, and that file is created by setup — so until the first
provisioning run the row exits with `MODULE_NOT_FOUND`, and nothing is there to start a background
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.
### Which DSH versions
`package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`, and that declaration is the answer the plugin market
reads: with it the card says **compatible**; without it the market can only say *unknown*. The range is not a guess —
it is the dsh release list this plugin is checked against, and `node scripts/host-compat.mjs` fails the build when the
two disagree:
| Host | Covered |
|---|---|
| `0.1.7-rc.2` … `0.2.2-rc.1` — every release in between, alpha and rc (16 tags) | yes |
| `0.3.0` and later | no — a new minor line is a host nobody updated this row for yet |
2.0.14 checked the six tags published after 2026-10-23 individually (`0.2.1-rc.3`, `0.2.1-rc.4`, `0.2.2-alpha.2`,
`0.2.1-rc.5`, `0.2.2-alpha.3`, `0.2.2-rc.1`): none of their release notes touch the five surfaces this plugin depends on
(patch dialect, MCP row schema, host plugin API, client injection contract, market `engines` verdict). The one adjacent
item is `0.2.2-rc.1`'s Cordis Core 4.1.0 / Loader 1.1.0, and it was **measured on this machine**:
`GET /dsh-plugin-rea/guide-state` answered 200 and the `mcp__rea__*` tools were available.
What was actually compared across that range is the row provider: `packages/mcp/mcp-client` at `dsh-v0.1.7-rc.2`,
`dsh-v0.2.1-rc.2` and `dsh-v0.2.2-rc.1` exposes the same config surface the shipped row uses (`serverName`, `transport`,
`command`, `args`, `env`, `failOnStartupError`), and `dsh.bundle.patch` exists at all three. A host outside the range can
still work; it is simply not claimed.
> The market reads `engines.dsh` only, and **not** `engines.node` (measured in `dshmarket/lib/verify.js` and
> `discovery-compatibility.js`). A host whose bundled Node sits below this package's `engines.node` floor is therefore
> not blocked by the market; only someone running npm with `engine-strict=true` is stopped at install time. Run time is
> unaffected either way: REA is started with the `paths.node` recorded in `bundle.json`, not the host's Node.
### A REA you already have is used as-is
2.x exists to supply what a machine is missing. If REA is already installed — `rea` on PATH, the 1.x setup — the row
runs **that**, exactly as the 1.x line did, and nothing is provisioned:
| What the machine has | What the row runs |
|---|---|
| `rea` on PATH, at least as new as the provisioned build | your REA (`rea mcp`), with your Node and your PATH |
| `rea` on PATH but older than the provisioned build | the provisioned bundle — a stale global CLI would take the tool count backwards (6.4.0 ships 170 tools, 6.7.0 ships 207, 6.8.0 ships 229) |
| no `rea` on PATH | the provisioned bundle, provisioning itself on first use |
- `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the decision (`auto` is the default); `REA_BUNDLE_SYSTEM_REA=<path>`
names a binary that is not on PATH.
- The probe is the CLI's own `--version`, bounded at 8 s: a `.cmd` shim on Windows takes ~2.4 s to answer, so the check
runs once per start and only when a `rea` actually exists.
- `--shim-status` prints the decision — `launch.mode`, `launch.reason`, `launch.systemRea` — and its `launch.rejected`
list says why a candidate was skipped (`"exit 7"`, `"inside the bundle"`, `"timeout"`).
- A delegated child carries `REA_BUNDLE_DELEGATED=1`, so a wrapper that calls this shim cannot make it delegate to
itself forever.
- **On Windows a forced kill of the shim does not reap the delegated REA.** There is no job object here: Task Manager
or a supervisor terminating the shim leaves your `rea` running with the row's pipes (a console Ctrl+C reaches the
whole process group and does stop it). On POSIX the child is in the same group and dies with the terminal.
## Configure — nothing
The shipped row carries **no machine path**:
```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.HOMEDRIVE || '') + (process.env.HOMEPATH || '')) || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
- mcp
env:
ELECTRON_RUN_AS_NODE: '1'
failOnStartupError: false
```
Three details in that row are load-bearing, and all three were bugs first:
- **The `!!js` scalar is quoted.** A bare scalar containing `? ` and `: ` is not one scalar: YAML reads it as an explicit key/value pair, so the loader receives a *mapping* where an expression belongs. Measured against the harness's own dialect (`JSON_SCHEMA` plus the `!!js` type from `app.asar`), the unquoted form made `args[0]` into `{"[object Object]": "..."}` — an object the row cannot launch with. Quoting removes the ambiguity, and the JS string literals inside then use single quotes.
- **Forward slashes, never backslashes.** A single-quoted YAML scalar keeps `\` literal, and JavaScript then consumes it: `'\plugins'` is not a path but the BEL control character, `'\rea-bundle'` becomes a carriage return and `'\bin'` a backspace — the path is destroyed silently. Forward slashes work on Windows and on POSIX.
- **The `DSH_HOME` fallback is `<home>/.dsh`, not `<home>`.** The shim, `setup.mjs` and `bootstrap.ps1` all resolve an unset `DSH_HOME` to `<home>/.dsh/...`; a fallback that stopped one level higher made the row point at a file provisioning never writes — invisible on any machine where `DSH_HOME` happens to be set.
- **The home fallback mirrors `os.homedir()`: `USERPROFILE`, then `HOMEDRIVE`+`HOMEPATH`, then `HOME`.**
The middle pair is what a Windows service account gets instead of `USERPROFILE`; an expression that skipped
it produced `/.dsh/...` — the root of the current drive — for exactly the accounts that cannot read that
path. Only when all four are empty (a container account with no home at all) does the concatenation land on
`/.dsh/...`, and `failOnStartupError: false` keeps that quiet, so the only symptom is "no `mcp__rea__*`
tools". Set `DSH_HOME` explicitly on such an account.
- **No `path`/`require` in a `!!js` expression**: `ctx` is the loader scope, so the DSH_HOME fallback is written as plain concatenation.
`npm run bundle:audit` enforces the first three: it evaluates this exact expression with `DSH_HOME` unset, empty and set, and rejects a bare scalar that YAML would read as a mapping.
`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.
A patch replaces the row's `config` **wholesale** — it does not deep-merge — so a partial override such as
`config: {env: {...}}` also deletes `command`, `args` and `serverName`, and the row stops starting. Supply the
complete config, or use `REA_BUNDLE_LAUNCH=system` (above), which needs no patch at all.
Ghidra and the JDK are not read from your environment at launch: `setup.mjs` records their absolute paths in
`bundle.json` and the shim exports `GHIDRA_INSTALL_DIR` / `JAVA_HOME` from that record — which is why a
non-standard install location works without touching the row. To adopt an existing install, point
`GHIDRA_INSTALL_DIR` / `JAVA_HOME` at it and run `node scripts/setup.mjs --reuse`: the manifest's probe checks
the candidate and falls back to a download when it does not match, so a wrong path is reported during setup
instead of failing later inside a tool call.
### One REA registration only
Upstream REA 6.5.0 can register itself with DSH:
```
rea setup --client dsh
```
That writes a row with `id: rea-mcp` and `serverName: rea` into `<DSH_HOME>/cordis.patch.yml`. This
bundle's row is `id: mcp-rea` with the same `serverName`, and DSH rejects the later of two entries
that share a server name — so which REA row survives depends on load order. **Use one, not both.**
Which one? Upstream's is the configuration-only route: it expects Node on REA's engines,
`rea-agents` reachable, and a Ghidra/JDK you provisioned yourself. This bundle is the
zero-dependency route: it fetches Node, REA, JDK and Ghidra on first run. The patch shape and the
`mcp__rea__*` surface are the same either way.
The shim reports the conflict on startup — `setup.mjs` records the DSH home it ran under in
`mirror.json`, and the mirrored copy reads that when the harness has stripped `DSH_*` from its
environment; `node bin/rea-shim.mjs --shim-status` lists it under `duplicateRegistration`.
## Verify
Ask for a `mcp__rea__*` call — `binary_session` reports the engine checks
(`configuration` / `installation` / `headless` / `native_decompiler` / `java`), all of which should be
`ok: true`. Then `open_binary` a PE, `binary_overview`, and `procedure_pseudo_code`.
**Scope and caveats.** REA exposes 170+ tools, 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. Analysis only on
artifacts you are authorized to inspect.
Provisioning itself ends with three checks: `rea doctor --json` (reported, with unmet checks named), a
**Ghidra smoke import** that runs `rea analyze` on the JDK's own `bin/java` and fails the run when it
cannot import (`--skip-smoke` omits it), and the exact shim launch the MCP row will use. The smoke step
exists because `doctor` validates paths, versions and which Java would be chosen — it never imports a
binary, so a broken bridge or a mismatched native addon used to surface only as an MCP timeout.
**Upstream updates.** REA's `bin` entry (`scripts/rea.mjs`) is stable across releases, so upgrading
REA needs no change here: `npm i -g rea-agents@latest`. Ghidra is version-gated by REA (12.1.x today),
so upgrade REA first and check that it accepts the newer Ghidra. 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. `check-updates.ps1` (Windows PowerShell) reports all of this.
**The first analysis of each new target is slow.** Measured here: 36 s for a small PE, 64 s for a 6 MB
DLL — every import starts a JVM and runs Ghidra's default auto-analysis. If the first
`open_binary` → `list_segments` on a large file times out, that is this cost and not a broken engine:
call it again and the open target answers immediately, because it keeps its ephemeral project.
**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.
## Verified against
---
Author's machine, 2026-10-13 / 14 / 16 / 19 / 23 / 26:
## 中文
| Component | Version |
|---|---|
| REA (`rea-agents`) | **6.8.0** (229 tools) |
| DeepSeek Harness | desktop runtime **0.2.2-rc.1** (the host 2.0.14 was measured on; full range in the table above) |
| `@deepseek-ai/dsh-mcp-client` (row provider) | bundled by the harness |
| Ghidra | 12.1.4 (this machine also runs 12.2 through the gate override — see below) |
| JDK | Temurin 21 (provisioned) / Amazon Corretto 25.0.5 (adopted; its major satisfies the pin `21`) |
| Node.js | **24.22.1** adopted (REA engines: `^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`; ABI 137) |
| Host | Windows 11 x64 (build 26200) |
**这是什么。** 一个面向 DeepSeek Harness 的**纯配置型 Cordis bundle**:向配置里插入一条官方
`@deepseek-ai/dsh-mcp-client` 记录,指向你本机的 `rea mcp`(stdio)服务器。装载后该 profile 下
每个会话都会拥有 `mcp__rea__*` 工具(如 `mcp__rea__binary_overview`、
`mcp__rea__procedure_pseudo_code`、`mcp__rea__trace_feature`)。
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.8.0}`; and the whole
chain ran under the real desktop host — its own Electron binary launched
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`, the shim started the **vendored** REA, and a live tool
call reported `path_source: environment` for a path only that machine's `bundle.json` knows.
**与上游的关系。** 本仓库是**第三方适配封装**,不属于 REA,也不分发任何 REA 代码。
上游:<https://github.com/morluto/rea>(MIT)。它只做配置组合;分析引擎(Ghidra / IDA / JEB)
的归属与授权仍在各自厂商手里。
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.
**已验证版本**(作者机器,2026-10-12):REA `6.4.0`、DeepSeek Harness 桌面运行时 `0.2.0-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`)。
On 2026-10-23 the same chain was re-verified after the host Node moved to **24.22.1**: the vendored
`rea-windows-x64.node` and both `conpty` addons loaded under ABI 137; `binary_overview` on `conpty.node`
reported 10 segments / 1178 procedures / 618 strings; `procedure_pseudo_code` returned real decompiled
output; and `java -version` from the vendored JDK answered `21.0.12.1`. The one thing that did **not**
work on the first try was the first `list_segments` — it timed out at 64 s of cold Ghidra analysis, which
is what the smoke step and the note above are for.
**前置条件。** 满足 REA 要求的 Node;全局安装 REA(`npm i -g rea-agents`);如需反编译,准备
Ghidra 12.1.x 的解压目录。IDA Pro / JEB 需自有授权,Hopper 仅 macOS。
On 2026-10-26 the pin moved to **REA 6.8.0** and the chain was re-verified on the same machine: `setup.mjs
--reuse` reported `probe cli=6.8.0 tools=229` and its Ghidra smoke imported `java.exe` in 26 s; an independent
MCP handshake over stdio answered `serverInfo {name: rea, version: 6.8.0}` with **229 tools / 6 prompts** on
protocol `2024-11-05`; apktool still answers `3.0.3`, `adb` still reports 37.0.1 (1.0.41), and the jadx jar on
disk still hashes to `6e5eacf5…` — the value REA 6.8.0 requires. Executing 6.8.0's own release policy gave
`12.1.4 → verified`, `12.1.5 → compatible`, `12.2 → unsupported`, so Ghidra stays where it was.
**安装。**
On 2026-11-06 (2.0.14 — an 18-item external audit plus host adaptation) **no upstream component moved** (REA 6.8.0 /
Ghidra 12.1.4 / JDK 21 / apktool 3.0.3 / jadx 0.7.1 / Node 24.22.1). The same chain was re-verified: `--dry-run`
skipped all 7 components on their pins (the Ghidra 12.2 gate override and JDK 25 were not mistaken for drift) →
a forced apktool re-provision went through the full staging path (no leftover `.staging`/`.backup`, and the directory
held exactly the two files the manifest declares) → a full `--reuse` run covered 8 components plus
`fridaBindings FRIDA_OK 3` → `probe cli=6.8.0 tools=229`, the Ghidra smoke imported `java.exe` in 28 s, and the shim
resolved 6.8.0, exit 0. Gates: **154 tests** (151 pass / 0 fail / 3 skipped), `audit` 24/24 hash-pinned clean,
the dependency gate at zod 4.6.7 · minimatch 11.0.5 · glob 14.0.4, `host-compat` 16 releases, and
`verify-branch` ALL CHECKS PASSED. `check-updates.ps1` went from a false-green exit 0 to reporting honestly, and exits 0
on this machine once repaired.
## Scope and caveats
- REA exposes 229 tools at 6.8.0, so every session's tool list grows.
- Provisioning is honest about checksums: where a vendor publishes one it is pinned and verified; where none
exists (jadx-headless-mcp, the apktool wrapper, Android platform-tools, the rolling "latest" URLs) the
hash is **measured once and recorded**, and later drift is reported rather than silently accepted.
`bundle.json` lists which components are in that state under `unverifiedChecksums`.
- Downloads are slow on some networks (measured 0.35–0.54 MiB/s from the GitHub release CDN here, so Ghidra
alone takes ~25–45 min). Fetching is resumable 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` resolves the REA CLI's closure with **`npm ci`** against the shipped
`scripts/vendor-lock/` (`package.json` + `package-lock.json`). REA pins zod **exactly** (`"4.4.3"`), and 4.4.3 is
inside GHSA-mrhx-c7xc-m3w5, so the closure is raised through `overrides` to **zod 4.6.7 / minimatch 11.0.5 /
glob 14.0.4**. `npm ci` verifies every tarball's sha512; if a repackaging mirror makes that fail with `EINTEGRITY`,
it falls back to "same versions via overrides + `npm install`" (same versions, not byte-reproducible, and the log
says so). After landing, the version in `node_modules/zod/package.json` is read once more and anything below 4.6.6
is refused. `node scripts/check-dependency-advisories.mjs` is the offline gate, wired into CI.
Package lifecycle scripts are still skipped on purpose — with exactly one named exception: **frida**, whose native
binding (`build/frida_binding.node`) only exists after its install script runs, so setup runs `npm rebuild frida`
for that one package and then proves it with `import('frida')` plus device enumeration (recorded under
`components.reaAgents.frida` in `bundle.json`). A missing binding makes the Frida-backed tools unavailable and the
rest of REA untouched, so it is reported as `skip` rather than failing the whole provision.
This step is also the **only** one that cannot use this bundle's
own route/proxy resolution — npm reads `HTTPS_PROXY`/`HTTP_PROXY` and `NPM_CONFIG_REGISTRY` from the
environment, so on a network that needs a mirror, set one of those before provisioning
(`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`). With `--offline` the step is refused outright
instead of quietly going online.
- On POSIX, extraction falls back when the usual tools are missing: a zip is opened with `unzip`, then
`python3 -m zipfile`, then the provisioned JDK's `jar`; a tarball with `tar`, then `python3`. A minimal
image that has none of them fails with an explicit "no zip extractor available" line rather than a bare
"extraction failed". This matters because Ghidra is a zip on every platform and `unzip` is not installed
everywhere by default.
## Updating — three independent things
> **Two standing rules — for this release and for every later 2.x release.**
>
> `setup.mjs` reconciles the **bundle**, never the **plugin**: it reads the manifest of the plugin that is
> installed *right now*. Installed 2.0.9? setup provisions 6.5.0 even after a newer release exists. The package changes
> only through the market (or `npm i -g dsh-plugin-rea@<version>` / a `file:` reinstall); running setup more
> often never substitutes for that.
>
> **And nothing runs it for you.** A market install or update replaces the package and stops; the first
> `setup.mjs` — and one after every upgrade — is yours to run, from the plugin directory.
>
> 1. **The row executes a copy of this plugin's launcher.** `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` is a
> mirror made by setup, and a market upgrade replaces the package without replacing it — so the mirror can keep
> running old code with **no symptom at all**. Since **2.0.8** the mirror compares itself with the launcher
> recorded in `mirror.json` and re-execs the plugin's copy when the two differ (one hop, guarded by
> `REA_BUNDLE_SHIM_REEXEC`). A mirror **older than 2.0.8 cannot do this**, so upgrading from **2.0.0–2.0.7**
> still needs one `node scripts/setup.mjs`. `--shim-status` reports `mirror.stale`; `check-updates.ps1` says
> when to re-run.
> 2. **The mirrored `scripts/setup.mjs` is a copy too, and it has no self-heal of its own.** Running setup from the
> bundle root therefore executes the version from when the bundle was created, not the one you just installed.
> Run it once **from the plugin directory** to refresh that copy. Since **2.0.9** the copy strips the UTF-8 BOM
> from `mirror.json` / `bundle.json` exactly as the shim does, so a BOM-ed record no longer reads as "no record"
> on the setup side (which used to re-mirror the previous manifest).
### Updating from the market
Update it where you installed it. Updating **to 2.0.5 or later** is routine; updating an installed 2.0.1–2.0.4
*through the in-app market* failed with this, three times on a healthy profile:
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
That message is not about a broken tarball. On every update the market asks whether the new build's
`package.json` points at an entry file that exists (`hasLoadableEntry` in `dshmarket`), and rolls the build back
when the answer is false. A bundle may legitimately own no entry — but only if a package it *mounts* has one, and
the package this bundle mounts (`@deepseek-ai/dsh-mcp-client`) ships inside the harness build rather than in
`node_modules`, so every location that test probes comes back empty. The 1.x and 2.0.x manifests also declared
no `main` at all, so the answer was false even though the profile was working; the rollback was then reported as
unverifiable for the same reason. Nothing in the profile was actually damaged — the update simply did not apply.
2.0.5 declares `main: ./index.mjs`. That file is a real, deliberately empty Cordis plugin: it registers nothing,
spawns nothing, touches no state — the working row is still inserted by `cordis.patch.yml` — and it exists so the
question "can this package load?" has an honest answer, and so `import 'dsh-plugin-rea'` resolves. On an older
version, update to `>=2.0.5`; if the market refuses before that version exists, install `[email protected]`
(Desktop: through the plugin manager) instead of updating in place.
### Upgrading from 1.x
Install 2.x, then run provisioning **once** before restarting dsh:
```
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea
node scripts/setup.mjs # or: powershell -File scripts\rea-bundle-bootstrap.ps1 -Reuse
```
或在插件管理器里用本地目录安装:`file:<本仓库路径>`。
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.
**配置(两个随机器不同的值)**:改 `cordis.patch.yml`
- **This plugin** never updates itself. Installed from a market? Update it there. Installed from a `file:`
checkout? Overwrite the directory and reinstall.
- **The row runs a COPY of this plugin's launcher.** `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` is a mirror
made by setup, so a market upgrade replaces the package but not the file the row executes — until setup runs
again. From **2.0.8** the mirror notices that the installed plugin ships a different launcher and re-execs it
(one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`), so upgrades carry. On an older version the mirror stays behind
silently; `--shim-status` now reports `mirror.stale`, and `check-updates.ps1` says to re-run setup. If you are
upgrading from 2.0.0–2.0.7, run `node scripts/setup.mjs` once after the upgrade.
- **The mirrored `scripts/setup.mjs` is a copy too.** A market upgrade replaces the package but not that copy,
so a remediation command that points at `<DSH_HOME>/plugins/rea-bundle/scripts/setup.mjs` runs the setup from
when the bundle was created — not the one you just installed. From **2.0.9** both the shim and setup strip a
UTF-8 BOM from `mirror.json` / `bundle.json` (a BOM-ed record used to read as "no record" on the setup side,
which re-mirrored the previous manifest). Running `node scripts/setup.mjs` once **from the plugin directory**
refreshes that copy too.
- **Upgrading from `2.0.0-bundle.1`**: that release mirrored the shim and `setup.mjs` but not the manifest,
and recorded nothing about the plugin checkout, so a bundle it provisioned cannot repair itself — its
background provisioning dies on the missing manifest, invisibly, and its own lock suppresses retries for
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.
1. `args[0]` —— REA 入口文件的绝对路径:先跑 `npm root -g`,再拼上
`\rea-agents\scripts\rea.mjs`(POSIX 用 `/`)。
2. `env.GHIDRA_INSTALL_DIR` —— 你的 Ghidra 解压目录;不用 Ghidra 时可删掉这一行。
`check-updates.ps1` (Windows PowerShell) reports the state of all of these at once. `npm run check-updates`
forwards to it on Windows (PowerShell 5.1, then `pwsh`); on macOS/Linux it prints why the script cannot
answer there and names the two portable commands instead — `node bin/rea-shim.mjs --shim-status` and
`node scripts/setup.mjs --dry-run` — exiting non-zero rather than reporting a green it did not earn.
`command: node` 走 PATH;若 harness 的 PATH 里没有 `node`,改成绝对路径。`env.JAVA_HOME`
可选,仅当 `java` 不在 PATH 上时需要。
## Still prefer the configuration-only 1.x line?
**验证。** 调用任一 `mcp__rea__*` 工具:`binary_session` 会给出引擎检查项
(configuration / installation / headless / native_decompiler / java),应全部 `ok: true`;
随后 `open_binary` 打开一个 PE → `binary_overview` → `procedure_pseudo_code`。
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:
**范围与注意。** REA 暴露 170+ 个工具,注入后每个会话的工具列表都会变大;行内设置
`failOnStartupError: false`,因此 REA 缺失也不会阻塞 harness 启动。部分 REA 工具会启动进程、
写文件、访问网络,按 DSH 的权限策略执行。请仅分析你已获得授权的目标。
```
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
```
**上游更新怎么办。** REA 的 `bin` 入口(`scripts/rea.mjs`)跨版本稳定,所以
`npm i -g rea-agents@latest` 之后本插件**无需改动**。Ghidra 受 REA 版本门槛限制(当前 12.1.x),
顺序必须是先升 REA、确认它接受新版 Ghidra 再升级。harness 升级可能改变 MCP 客户端配置结构,
用 `Config.listConfigs name=@deepseek-ai/dsh-mcp-client` 看该行 schema 并调整字段名。
`check-updates.ps1`(Windows PowerShell)会把上述四类检查一次跑完。
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).
**许可。** 本适配器为 MIT;REA 为 MIT,版权归其作者;Ghidra 为 Apache-2.0,版权归 NSA。
本仓库不再分发上游代码。
## License
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.
+466
View File
@@ -0,0 +1,466 @@
# 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 **6.8.0**(本包钉子)只认 Ghidra **12.1.x**(12.2 判 `unsupported`,
> Ghidra 系的反编译/分析工具整组消失)和 jadx 桥接 **0.7.1**(0.8.0 会在第一次调用就报
> `Expected engine 0.7.1`,失败点在加载 APK 之前)。这两道闸门 2026-10-26 对着 6.8.0 重新核过,**一道都没动**。
> JDK **21.0.13** 上游已发,但 Adoptium 仍没给 Windows 构建,本机无处可指,而且它只移除 4 个 eMudhra 锚里的
> 3 个。所以现在的取舍是「工具全绿、CVE 未清」:Ghidra 12.1.4 的 CVE-2026-100504 / 100505 / 100503 / 113604
> 仍在链上 —— **别拿这套环境分析来源不可信的样本**;JDK 也仍信任 eMudhra 锚。证据、缓解与重开条件:
> [docs/update-review-2026-10-24.md](docs/update-review-2026-10-24.md) 与
> [docs/update-review-2026-10-26.md](docs/update-review-2026-10-26.md)。
>
> **Ghidra 12.2 本身是可用的** —— 2026-10-26 实测:经 REA 自己的桥接,段表(9 段)、函数表(341 个)与 `entry`
> 伪代码与 12.1.4 **逐字节相同**。挡住它的不是兼容性,是 REA 那条版本等值判定。如果你就是要用修掉 CVE 的那一版:
> `node scripts/ghidra-gate-override.mjs --enable --version 12.2` 只改 vendored 副本里那一个常量、把改动记进
> `<bundle>/ghidra-gate-override.json`,`--disable` 还原;重跑 setup 也会还原。它要求 **JDK 25+**
> (`application.java.min=25`)。
> **`node scripts/setup.mjs` 要你自己跑 —— 装完跑一次,之后每次升级插件版本再跑一次。** 没有东西会替你跑:
> 市场里的安装/更新只替换包,然后到此为止。从 **2.0.8** 起,市场升级能保证**那一行**继续可用(镜像启动器会
> 转发执行新的那份),但它**不会**把 bundle 换到新的钉子 —— REA 与引擎仍是上一次 setup 时记下的版本。
> 首次安装不跑:行以 `MODULE_NOT_FOUND` 退出,工具消失且没有任何提示;升级后不跑:仍旧跑旧 REA 且没有症状
>(`check-updates.ps1` 会报出这条漂移)。若你开过 Ghidra 闸门覆盖,这一次 setup 会重新供给内置 REA、覆盖随之
> 消失 —— 跑完再 `--enable` 恢复,或者接受回到钉住的那一版。完整规则见[更新](#更新--三件互相独立的事)。
> **首次安装与每次升级插件后,Web GUI 会显示一次操作指南。** 它会说明运行时是缺失、还是落后于本插件钉的版本,
> 给出这次安装对应的完整 `node "<安装路径>/scripts/setup.mjs"` 命令,并按插件版本记住“已看过”。
> 它只是提示:不会替你运行 setup,也不会改动 bundle。跑一次、重启 dsh,提示就会消失,直到下一个插件版本。
把上游 **[REA — Reverse Engineer Anything](https://github.com/morluto/rea)** 的 MCP 服务接入
**DeepSeek Harness**(dsh),让它的工具在会话里以 `mcp__rea__*` 出现。
![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.16
```
> **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.23.4 || ^24.22.1 || >=26.12.1`(用 `node -v` 核对)。REA 在 6.8.0 抬了这条下限:钉住的 24.22.1
现在是 24 线**最低**可接受版本,6.7.0 能吃下的 24.11–24.21 在这里会被拒。版本偏低不会在安装时失败,
而是到 MCP 行启动时才失败 —— 表现就是"没有反应"。
反过来**先装插件**也可以,但要分两步:发布的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
而那个文件**只有 setup 会创建**——所以在第一次供给之前,该行会以 `MODULE_NOT_FOUND` 退出,也就没有
任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
`node scripts/setup.mjs`;此后 shim 就位,之后每一次「状态缺失」都由它自己发现并在后台补供给。
### 支持哪些 DSH 版本
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`,这就是插件市场读的那句话:有它,卡片显示
**兼容**;没有它,市场只能说*未声明*。这个范围不是猜的——它对应本插件实际核对过的 dsh 发布列表,
`node scripts/host-compat.mjs` 会在两者不一致时让构建失败:
| 宿主版本 | 是否覆盖 |
|---|---|
| `0.1.7-rc.2` … `0.2.2-rc.1`(中间的 alpha 与 rc 全部在内,共 16 个 tag) | 是 |
| `0.3.0` 及以后 | 否 —— 新的小版本线是还没为它核对过这一行的宿主 |
2.0.14 逐个核对了 2026-10-23 之后发布的 6 个 tag(`0.2.1-rc.3`、`0.2.1-rc.4`、`0.2.2-alpha.2`、`0.2.1-rc.5`、
`0.2.2-alpha.3`、`0.2.2-rc.1`):发布说明里没有触及本插件依赖的五处接口面(patch 方言、MCP 行 schema、
宿主插件 API、客户端注入契约、市场 engines 判定)。唯一沾边的是 `0.2.2-rc.1` 的 Cordis Core 4.1.0 / Loader 1.1.0,
**已在本机 0.2.2-rc.1 上实测**:`GET /dsh-plugin-rea/guide-state` 返回 200,`mcp__rea__*` 工具可用。
真正被比对过的是「行提供方」:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2`、`dsh-v0.2.1-rc.2` 与
`dsh-v0.2.2-rc.1` 上暴露的配置面与发布的行完全一致(`serverName`、`transport`、`command`、`args`、`env`、
`failOnStartupError`),并且都带 `dsh.bundle.patch`。范围之外的宿主可能照样能用,只是没有替它声明。
> 市场只读 `engines.dsh`(`dshmarket/lib/verify.js` 与 `discovery-compatibility.js` 现场取证),**不读
> `engines.node`**。所以宿主自带的 Node 比本包 `engines.node` 的下限低时,市场不会拦你;只有把 npm 配成
> `engine-strict=true` 的人会在 `npm install` 阶段被挡。运行期不受影响:REA 是用 `bundle.json` 里记录的
> `paths.node` 启动的,不是宿主那个 Node。
### 机器上已有 REA 就直接用它
2.x 存在的意义是补上机器缺的东西。如果 REA 已经装好——`rea` 在 PATH 上,也就是 1.x 的形态——这一行就**直接跑它**,
和 1.x 一模一样,不再做任何供给:
| 机器上的情况 | 这一行跑什么 |
|---|---|
| `rea` 在 PATH 上,且不比供给的版本旧 | 你自己那份(`rea mcp`),用你的 Node 和你的 PATH |
| `rea` 在 PATH 上,但比供给的版本旧 | 供给出来的 bundle —— 旧的全局 CLI 会把工具数往回退(6.4.0 是 170 个,6.7.0 是 207 个,6.8.0 是 229 个) |
| PATH 上没有 `rea` | 供给出来的 bundle,首次使用时自行供给 |
- `REA_BUNDLE_LAUNCH=auto|system|bundle` 覆盖这个判断(默认 `auto`);`REA_BUNDLE_SYSTEM_REA=<path>` 用来点名一个
不在 PATH 上的可执行文件。
- 探测用的是 CLI 自己的 `--version`,上限 8 秒:Windows 上的 `.cmd` 垫片要 ~2.4 秒才答得出来,所以这个检查每次启动
只做一次,而且只在真的存在 `rea` 时才做。
- `--shim-status` 会把判断结果打出来 —— `launch.mode`、`launch.reason`、`launch.systemRea`,`launch.rejected` 里
写的是每个候选被跳过的原因(`"exit 7"`、`"inside the bundle"`、`"timeout"`)。
- 被委派的子进程带着 `REA_BUNDLE_DELEGATED=1`,所以一个调用本 shim 的 wrapper 不会让它无限委派给自己。
- **Windows 上强制结束 shim 不会带走被委派的 REA**:这里没有 job object,任务管理器或 supervisor 终止 shim 时,
你自己的 `rea` 会继续占着这一行的管道(控制台 Ctrl+C 会发给整个进程组,那种情况下会一起停)。POSIX 上子进程
在同一进程组,随终端一起退出。
## 配置 —— 不需要
发布的那一行**不含任何机器路径**:
```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.HOMEDRIVE || '') + (process.env.HOMEPATH || '')) || 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` 恰好被设置的机器上,这个错误完全不可见。
- **主目录兜底与 `os.homedir()` 同序:`USERPROFILE` → `HOMEDRIVE`+`HOMEPATH` → `HOME`。** 中间那一对是
Windows 服务账户拿到的形态(没有 `USERPROFILE`);跳过它,表达式会给这类账户算出 `/.dsh/...` —— 当前盘的根,
而它们恰好读不到那个路径。只有四者全空(连主目录都没有的容器账户)才会落到 `/.dsh/...`,而
`failOnStartupError: false` 会让它保持安静,症状只有「没有 `mcp__rea__*` 工具」。这类账户请显式设置 `DSH_HOME`。
- **`!!js` 表达式里没有 `path`/`require`**:`ctx` 是 loader 作用域,所以 DSH_HOME 的兜底写成纯字符串拼接。
`npm run bundle:audit` 会把前三条钉住:它用「`DSH_HOME` 未设 / 为空 / 已设」三种情形求值这个表达式,并拒绝会被 YAML 读成 mapping 的裸标量。
`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`。
覆盖是**整块替换** `config`(不做深合并):只写 `config: {env: …}` 会把 `command`、`args`、`serverName`
一起删掉,这一行就起不来了。要么写完整的 config,要么改用 `REA_BUNDLE_LAUNCH=system`(见上),它不需要改 patch。
Ghidra 和 JDK **不是启动时从环境里读的**:`setup.mjs` 把它们的绝对路径记进 `bundle.json`,shim 再从那份
记录导出 `GHIDRA_INSTALL_DIR` / `JAVA_HOME` —— 所以装在非标准路径也能用,不必动这一行。要采纳现成的安装,
把 `GHIDRA_INSTALL_DIR` / `JAVA_HOME` 指向它并跑 `node scripts/setup.mjs --reuse`:manifest 里的探针会验证
候选,不符合就回落到下载,**路径写错会在供给时就报出来**,而不是等到某次工具调用里才失效。
### 只保留一条 REA 注册
上游 REA 6.5.0 起可以自己注册进 DSH:
```
rea setup --client dsh
```
它会把一行 `id: rea-mcp`、`serverName: rea` 写进 `<DSH_HOME>/cordis.patch.yml`。本包的行是
`id: mcp-rea`、同样 `serverName: rea`,而 DSH 对同名条目只保留一个:后加载的那行会以
duplicate server name 报错并加载失败,最终留下哪一行取决于加载顺序。**二选一,不要同时用。**
选哪个?上游那条是纯配置路线:需要 Node 满足 REA engines、`rea-agents` 可达,Ghidra/JDK 由你
自己准备。本包是零依赖路线:首次运行自动取 Node、REA、JDK、Ghidra。两者的 patch 形态与
`mcp__rea__*` 工具面相同。
shim 会在启动时提示冲突 —— `setup.mjs` 会把运行时的 DSH home 记进 `mirror.json`,镜像副本在 harness 抹掉
`DSH_*` 之后就用这条记录去找那份文件;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
## 验证
随便发一个 `mcp__rea__*` 调用 —— `binary_session` 会报引擎各项检查
(`configuration` / `installation` / `headless` / `native_decompiler` / `java`),应当全为 `ok: true`。
然后 `open_binary` 一个 PE、`binary_overview`、`procedure_pseudo_code`。
供给本身结束时跑三道检查:`rea doctor --json`(如实报告,并点名未满足项)、一次 **Ghidra 冒烟导入**
(用 JDK 自带的 `bin/java` 跑 `rea analyze`,导不进就判本次供给失败;`--skip-smoke` 可关),以及
**与 MCP 行完全相同的 shim 启动**。之所以要冒烟:`doctor` 只校验路径、版本与会用哪个 Java,**从不导入
二进制**,桥接或 native addon 坏了,过去只能在第一次 MCP 调用超时时才撞见。
**每个新目标的第一次分析都很慢。** 本机实测:小 PE 36 s,6 MB 的 DLL 64 s —— 每次导入都要起 JVM 并跑
Ghidra 的默认自动分析。如果大文件的第一次 `open_binary` → `list_segments` 超时,那是这个成本、不是引擎坏了:
再调一次,已打开的目标会立刻返回(它保留着自己的临时项目)。
## 实测环境
作者机器,2026-10-13 / 14 / 16 / 19 / 23 / 26:
| 组件 | 版本 |
|---|---|
| REA(`rea-agents`) | **6.8.0**(229 个工具) |
| DeepSeek Harness | desktop runtime **0.2.2-rc.1**(2.0.14 实测宿主;全范围见上面的版本表) |
| `@deepseek-ai/dsh-mcp-client`(行 provider) | 随 harness 打包 |
| Ghidra | 12.1.4(本机另有 12.2 走闸门覆盖,见下) |
| JDK | Temurin 21(供给)/ Amazon Corretto 25.0.5(宿主复用;主版本下限满足 pin `21`) |
| Node.js | **24.22.1**(复用;REA engines:`^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`,ABI 137) |
| 宿主 | Windows 11 x64(build 26200) |
实测跑过:一次干净的 `setup.mjs --reuse` 走到 `setup complete`;一次**不经 PATH 上的 `rea`、完全经由
shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.8.0}`;以及**真实桌面宿主下的完整链路** ——
宿主自己的 Electron 二进制启动 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`,shim 拉起 **bundle 内置**
的 REA,随后一次真实工具调用报出 `path_source: environment`,而那个路径只有该机的 `bundle.json` 知道。
本机**没有**端到端跑过、因此不做承诺的:约 775 MB 的 JDK + Ghidra 下载分支(`--dry-run` 有计划,本机用
`--reuse` 复用了已有安装),以及 macOS/Linux 的首次运行。
2026-10-23 宿主 Node 换到 **24.22.1** 后,同一条链路重验过:vendor 里的 `rea-windows-x64.node` 与两个
`conpty` addon 在 ABI 137 下加载成功;`conpty.node` 的 `binary_overview` 报出 10 个段 / 1178 个函数 /
618 个字符串;`procedure_pseudo_code` 返回了真实反编译结果;vendor 里的 JDK `java -version` 回 `21.0.12.1`。
唯一**第一次没成**的是 `list_segments`:它撞上 Ghidra 冷分析的 64 s 而超时 —— 这正是上面冒烟步与那条说明的由来。
2026-10-26 钉子换到 **REA 6.8.0** 后,同一条链路重验过:`setup.mjs --reuse` 报 `probe cli=6.8.0 tools=229`,
末尾的 Ghidra 冒烟用 26 s 导入 `java.exe`;另起一次独立的 stdio MCP 握手回 `serverInfo {name: rea, version:
6.8.0}`,协议 `2024-11-05`,**229 个工具 / 6 个 prompt**;apktool 仍答 `3.0.3`,`adb` 仍报 37.0.1 (1.0.41),
盘上的 jadx jar 仍是 `6e5eacf5…` —— 正是 6.8.0 要求的那个值。把 6.8.0 自己的发布策略加载执行,得到
`12.1.4 → verified`、`12.1.5 → compatible`、`12.2 → unsupported`,所以 Ghidra 原地不动。
2026-11-06(2.0.14,外部审计 18 项修复 + 宿主适配):**上游组件一个都没动**(REA 6.8.0 / Ghidra 12.1.4 / JDK 21 /
apktool 3.0.3 / jadx 0.7.1 / Node 24.22.1)。同一条链路重验:`--dry-run` 7 个组件全部按 pin 正确 skip(Ghidra 12.2
的闸门覆盖与 JDK 25 都没有被误判成漂移)→ 强制重装 apktool 走完整 staging 路径(无残留 `.staging`/`.backup`,
目录内容恰好等于 manifest 声明的两个文件)→ `--reuse` 全量 8 个组件 + `fridaBindings FRIDA_OK 3` →
`probe cli=6.8.0 tools=229`、Ghidra 冒烟 28 s 导入 `java.exe`、shim 解析 6.8.0,退出 0。
门禁:单测 **154 项**(151 过 / 0 失败 / 3 跳过)、`audit` 24/24 hash-pinned clean、
依赖闸门 zod 4.6.7 · minimatch 11.0.5 · glob 14.0.4、`host-compat` 16 releases、`verify-branch` ALL CHECKS PASSED。
`check-updates.ps1` 从「假绿退出 0」变成「如实报告」,本机修完后退出 0。
## 边界与注意事项
- REA 在 6.8.0 有 229 个工具,每个会话的工具列表都会变长。
- 校验和对得起良心:厂商发布了的就固定并验证;没发布的(jadx-headless-mcp、apktool 包装脚本、
Android platform-tools、滚动的 `latest` 地址)则**实测一次并记录**,之后若漂移是**报告**而不是默默接受。
`bundle.json` 的 `unverifiedChecksums` 会列出处于该状态的组件。
- 某些网络下很慢(这里实测 GitHub release CDN 只有 0.35–0.54 MiB/s,光 Ghidra 就要 25–45 分钟)。
下载是**可续传**的:两条路线都按盘上已有的字节发 Range 继续;若 `.part` 恰好已经完整,会校验后直接收下
而不是重下。
- **代理**:Node 的全局 `fetch` 不读系统代理,而 .NET/PowerShell 会读。在开着 WinINET 代理的机器上,
同一个地址 `Invoke-WebRequest` 通、Node 却 `ECONNRESET`/超时。setup 自己解析代理
(环境变量优先,Windows 上回退读注册表)并用 CONNECT 隧道,`NO_PROXY` 也照常生效。
- REA 的部分工具会启动进程、写文件、访问网络 —— DSH 的权限策略照常适用。只分析你有权检视的样本。
- MCP 子进程与它拉起的引擎都是以**你的身份**运行的普通进程。DSH 的工具审批管的是模型发起的调用,
管不到这个子进程自己去读、写或发送什么;请把插件当作你安装的第三方代码来对待。
- Linux 上,上游的进程归属检查依赖 PATH 上**兼容 procps 的 `ps`**;Ghidra/JADX 与固件工具会用到它。
极简镜像里缺它只会让这些工具失败,不影响整体安装。
- `scripts/setup.mjs` 为 REA CLI 跑的是 **`npm ci`**,复放随包发布的 `scripts/vendor-lock/`
(`package.json` + `package-lock.json`):REA 对 zod 是精确锁 `"4.4.3"`,而 4.4.3 落在
GHSA-mrhx-c7xc-m3w5 里,所以闭包通过 `overrides` 抬到 **zod 4.6.7 / minimatch 11.0.5 / glob 14.0.4**。
`npm ci` 会逐 tarball 校验 sha512;若镜像重打包导致 `EINTEGRITY`,则退到「同版本 overrides + `npm install`」
(同版本、字节不可复现,日志会明说)。落地后还会再读一次 `node_modules/zod/package.json` 的版本,低于 4.6.6 直接拒绝。
`node scripts/check-dependency-advisories.mjs` 是离线闸门,已接进 CI。
供给过程**刻意跳过所有 lifecycle 脚本** —— 唯一的例外是 **frida**:它的原生绑定
(`build/frida_binding.node`)只在 install 脚本里才会放下,所以 setup 会点名这一个包跑 `npm rebuild frida`,
然后用 `import('frida')` + 设备枚举验证(记录进 `bundle.json` 的 `components.reaAgents.frida`)。
绑定缺失时 Frida 系工具不可用,但其余 200 多个工具不受影响,所以这一项报 `skip` 而不是让整次供给失败。
这一步也是**唯一**用不上
本插件自己的路由/代理回退的环节 —— npm 只从环境读 `HTTPS_PROXY`/`HTTP_PROXY` 与
`NPM_CONFIG_REGISTRY`,所以网络需要镜像时请先设其中一个(例如
`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`)。带 `--offline` 时这一步会被**直接拒绝**,
而不是悄悄联网。
- POSIX 上解压有兜底:zip 依次尝试 `unzip` → `python3 -m zipfile` → 已供给 JDK 自带的 `jar`;tar 包依次
尝试 `tar` → `python3`。三者都没有的最小镜像会得到一句明确的「no zip extractor available」,而不是
干巴巴的「extraction failed」。Ghidra 在所有平台都只有 zip,而 `unzip` 并非每个发行版默认安装,
所以这条不是理论问题。
## 更新 —— 三件互相独立的事
> **两条长期规则 —— 本版如此,以后每个 2.x 版本都如此。**
>
> `setup.mjs` 只对齐 **bundle**,不更新**插件包**:它读的是「当前已安装插件」的 manifest。装着 2.0.9 时跑 setup
> 仍旧供给 6.5.0,哪怕已经有更新的一版发布。插件包只能从市场(或 `npm i -g dsh-plugin-rea@<版本>` / `file:` 重装)换,
> **多跑几次 setup 也替代不了那一步**。
>
> **而且没有人会替你跑它。** 市场里的安装/更新只替换包,然后到此为止;第一次的 `setup.mjs`,以及之后每次升级后的
> 那一次,都要你自己从插件目录跑。
>
> 1. **行执行的是本插件启动器的「镜像副本」**:`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 由 setup 复制,
> 市场升级只换包、不换它,所以副本可能一直跑旧代码而**完全没有症状**。从 **2.0.8** 起,副本会与 `mirror.json`
> 记录的启动器比对,不同就一跳转发执行插件里的那份(带 `REA_BUNDLE_SHIM_REEXEC` 守卫)。**2.0.0–2.0.7 的镜像
> 做不到**,所以从它们升级仍需跑一次 `node scripts/setup.mjs`。`--shim-status` 会报 `mirror.stale`,
> `check-updates.ps1` 会提示重跑。
> 2. **镜像的 `scripts/setup.mjs` 同样是副本,而且它自己不会自愈**:从 bundle 根目录跑它,跑的是建 bundle 那一版
> 的 setup,不是你刚装的这一版。要从**插件目录**跑一次来刷新这份副本。从 **2.0.9** 起,它会像 shim 一样剥掉
> `mirror.json` / `bundle.json` 的 UTF-8 BOM —— 带 BOM 的记录以前在 setup 眼里等于「没有记录」,会把上一版
> manifest 又镜一遍。
### 在市场里更新
在哪儿装的就在哪儿更新。更新到 **2.0.5 及以上**是常规操作;而把已装的 2.0.1–2.0.4 通过应用内市场**更新到
2.0.5**,在健康的 profile 上连续失败了三次:
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
这条消息与 tarball 是否完整无关。市场在每次更新时都会问一句:新构建的 `package.json` 是否指向一个真实存在的
入口文件(`dshmarket` 里的 `hasLoadableEntry`);答案是「否」,就把这次更新判为坏构建并回滚。bundle 本来允许
自己没有入口 —— 前提是它**挂载**的某个包有入口;而本包挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
构建,不在 `node_modules` 里,于是这项检查要看的每个位置都是空的。1.x 与 2.0.x 的清单又根本没有声明 `main`,
所以答案必然是「否」,哪怕 profile 一直是好的;紧接着的回滚也因此被报成「无法验证」。实际上 profile 没有被
破坏,只是这次更新没有生效。
2.0.5 声明了 `main: ./index.mjs`。那是一个真实存在、刻意留空的 Cordis 插件:不注册任何行、不启动任何进程、
不碰任何状态(真正起作用的行仍由 `cordis.patch.yml` 插入),它的作用只是让「这个包能不能加载」有一个诚实的
答案,并让 `import 'dsh-plugin-rea'` 能解析。还停在旧版本时,更新到 `>=2.0.5` 即可;若市场在 2.0.5 之前就拒绝,
就直接安装 `[email protected]`(桌面端:在插件管理器里装),不要原地更新。
### 从 1.x 升级
装上 2.x 后,**先跑一次供给**,再重启 dsh:
```
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:` 检出的就覆盖目录重装。
- **行跑的是本插件启动器的「镜像副本」**:`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 是 setup 复制过去的,
所以市场升级只换包、不换行执行的那个文件 —— 直到再跑一次 setup。从 **2.0.8** 起,镜像会发现自己落后于已安装的
插件,并转发执行插件里的那一份(一跳,带 `REA_BUNDLE_SHIM_REEXEC` 守卫),升级因此能生效。更早的版本里镜像会
静默地停在旧代码;`--shim-status` 现在会报 `mirror.stale`,`check-updates.ps1` 也会提示重跑 setup。**从
2.0.0–2.0.7 升级的话,升级后跑一次 `node scripts/setup.mjs`。**
- **镜像的 `scripts/setup.mjs` 同样是副本。** 市场升级只换包、不换它,所以照着提示去跑
`<DSH_HOME>/plugins/rea-bundle/scripts/setup.mjs` 时,跑的是建 bundle 那一版的 setup,而不是你刚装的这一版。
从 **2.0.9** 起,shim 与 setup 都会剥掉 `mirror.json` / `bundle.json` 的 UTF-8 BOM(此前 setup 侧把带 BOM
的记录读成「没有记录」,会把上一版 manifest 再镜一遍)。**从插件目录**跑一次 `node scripts/setup.mjs`,
这份镜像副本也会一起刷新。
- **从 `2.0.0-bundle.1` 升级**:那一版镜像了 shim 与 `setup.mjs`,却漏了 manifest,也没有留下插件 checkout
的记录,所以它供给出来的 bundle **无法自愈** —— 后台供给会因缺 manifest 而崩溃且完全不可见,而它自己写下的
lock 又会让重试被抑制六小时。请**从插件目录**重跑一次 `node scripts/setup.mjs`:它会重新镜像三个文件,
并写下让后续运行找到真源的记录。`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)会一次性报告以上各项的状态。`npm run check-updates` 在 Windows
上转发给它(优先 PowerShell 5.1,其次 `pwsh`);在 macOS/Linux 上它会说明这个脚本为何在那里给不出答案,
并列出两条可移植命令 —— `node bin/rea-shim.mjs --shim-status` 与 `node scripts/setup.mjs --dry-run` ——
且以非零码退出,不会报一个自己没查出来的「全绿」。
## 还想用纯配置的 1.x 线?
1.x 是**不带供给**的适配器:一行配置,从 PATH 上启动 `rea mcp`,并假设 Ghidra 由你自己装好。这些情况它是更合适的选择 ——
你已经自己管理 REA 与 Ghidra、不希望插件去下载任何东西、或者想让那一行继续指向你自己的 `rea`:
```
dsh plugin --profile <profile> add [email protected]
# 或钉住分支而不是版本号:
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v1.0.6
```
两条线发布的 patch 形态与 `mcp__rea__*` 工具面完全相同。1.x 维护在
[`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),它自己的安装说明也在那里(Node 满足 REA engines、
`npm i -g rea-agents`、原生分析需要 Ghidra 12.1.x)。
## 许可证
MIT(本适配器)。REA 是 MIT、归其作者;Ghidra 是 Apache-2.0、归 NSA;Temurin 是 GPLv2+CE;Node.js 是
MIT;apktool、jadx-headless-mcp、Android platform-tools 各自保留自己的许可。
**本包不再分发任何上游代码或二进制** —— 供给过程是从各组件的官方源逐个拉取。
+199
View File
@@ -0,0 +1,199 @@
<#
生成市场展示图(assets/screenshot-*.png)。
可复现:改完文案后重跑本脚本即可,无需设计工具。
运行方式:
Windows PowerShell 5.1 : powershell -NoProfile -File .\make-screenshots.ps1
PowerShell 7 (pwsh) : pwsh -NoProfile -File ./make-screenshots.ps1
两者都可用;脚本内部用 OutDir 参数,默认写到本文件所在目录。
注意:Windows PowerShell 5.1 按 ANSI 读取 .ps1,本文件必须保存为 UTF-8 with BOM
(用 pwsh 7 编辑则无此限制,但保持 BOM 对两者都安全)。
#>
[CmdletBinding()]
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 }
# 版本号从 manifest 读,不再手写成固定值。2.0.10 就踩过:pin 升到 6.7.0 之后,
# 这个脚本还写着 6.5.0 —— 现有 PNG 没问题(它们是 6.5.0 时生成的),但下一次
# 重绘会把旧版本号带进市场页,而且没有人会注意到,因为图片是二进制。
# 工具数(229)不是 manifest 字段,是人工核对过的常量:升 pin 时一起核一遍。
$reaPin = '6.8.0'
$reaTools = 229
$ghidraPin = '12.1.4'
$nodePin = '24.22.1'
# 截图是一次「实测」,这个日期必须是真的测量日;重绘时改成当天。
# 2026-11-07:2.0.16 发布前的最后一次真机 --reuse(smoke 31 s 导入 java.exe)。
$measuredOn = '2026-11-07'
# 宿主版本没有 manifest 字段可读 —— 它不是本包的属性。写死但必须跟着实测更新:
# 2.0.16 的实测宿主是 desktop runtime 0.2.2-rc.1,之前这里写的是 0.2.0-rc.2,
# 于是市场页在展示一个两个月前的宿主。
$hostMeasured = '0.2.2-rc.1'
$manifestFile = Join-Path (Split-Path -Parent $PSScriptRoot) 'manifest\bundle.manifest.json'
if (Test-Path $manifestFile) {
try {
$pinDoc = Get-Content $manifestFile -Raw -Encoding UTF8 | ConvertFrom-Json
if ($pinDoc.pins.reaAgents) { $reaPin = [string]$pinDoc.pins.reaAgents }
if ($pinDoc.pins.ghidra) { $ghidraPin = [string]$pinDoc.pins.ghidra }
if ($pinDoc.pins.node) { $nodePin = [string]$pinDoc.pins.node }
} catch {
Write-Warning "manifest 里的钉子读不到($($_.Exception.Message));用脚本内的默认值。"
}
}
# 实测版本未必等于钉:本机若开过 Ghidra 闸门覆盖(scripts/ghidra-gate-override.mjs),
# 跑的是 REA 未验证的那一版。图上写实测的那个,并标出钉值 —— 否则图会谎报「跑过什么」。
$ghidraMeasured = $ghidraPin
$overrideRecord = $null
if ($env:DSH_HOME) {
$overrideFile = Join-Path $env:DSH_HOME 'plugins\rea-bundle\ghidra-gate-override.json'
if (Test-Path $overrideFile) {
try {
$overrideRecord = Get-Content $overrideFile -Raw -Encoding UTF8 | ConvertFrom-Json
if ($overrideRecord.requestedVersion) { $ghidraMeasured = "$($overrideRecord.requestedVersion)(闸门覆盖,钉 $ghidraPin)" }
} catch { $overrideRecord = $null }
}
}
Add-Type -AssemblyName System.Drawing
$W = 1280; $H = 720
$bg = [System.Drawing.Color]::FromArgb(255, 248, 250, 252)
$ink = [System.Drawing.Color]::FromArgb(255, 15, 23, 42)
$muted = [System.Drawing.Color]::FromArgb(255, 100, 116, 139)
$accent = [System.Drawing.Color]::FromArgb(255, 37, 99, 235)
$accent2 = [System.Drawing.Color]::FromArgb(255, 22, 163, 74)
$warn = [System.Drawing.Color]::FromArgb(255, 220, 38, 38)
$line = [System.Drawing.Color]::FromArgb(255, 203, 213, 225)
$fTitle = New-Object System.Drawing.Font('Microsoft YaHei UI', 30, [System.Drawing.FontStyle]::Bold)
$fSub = New-Object System.Drawing.Font('Microsoft YaHei UI', 15)
$fBox = New-Object System.Drawing.Font('Microsoft YaHei UI', 15, [System.Drawing.FontStyle]::Bold)
$fBody = New-Object System.Drawing.Font('Microsoft YaHei UI', 13)
$fSmall = New-Object System.Drawing.Font('Microsoft YaHei UI', 11)
$fChip = New-Object System.Drawing.Font('Microsoft YaHei UI', 13)
function New-Canvas {
$bmp = New-Object System.Drawing.Bitmap $W, $H
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit
$g.Clear($bg)
return @($bmp, $g)
}
function Draw-Text($g, $text, $font, $color, $x, $y, $w = 0) {
$br = New-Object System.Drawing.SolidBrush $color
if ($w -gt 0) { $r = New-Object System.Drawing.RectangleF $x, $y, $w, 400; $g.DrawString($text, $font, $br, $r) }
else { $g.DrawString($text, $font, $br, $x, $y) }
$br.Dispose()
}
function Draw-Box($g, $x, $y, $w, $h, $fill, $border, $radius = 12) {
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
$d = $radius * 2
$path.AddArc($x, $y, $d, $d, 180, 90); $path.AddArc($x + $w - $d, $y, $d, $d, 270, 90)
$path.AddArc($x + $w - $d, $y + $h - $d, $d, $d, 0, 90); $path.AddArc($x, $y + $h - $d, $d, $d, 90, 90)
$path.CloseFigure()
$fb = New-Object System.Drawing.SolidBrush $fill
$g.FillPath($fb, $path)
$p = New-Object System.Drawing.Pen $border, 1.6
$g.DrawPath($p, $path)
$fb.Dispose(); $p.Dispose(); $path.Dispose()
}
function Draw-Arrow($g, $x1, $y1, $x2, $y2, $color, $width = 2.4) {
$p = New-Object System.Drawing.Pen $color, $width
$p.CustomEndCap = New-Object System.Drawing.Drawing2D.AdjustableArrowCap 6, 7
$g.DrawLine($p, $x1, $y1, $x2, $y2)
$p.Dispose()
}
function Draw-Line($g, $x1, $y1, $x2, $y2, $color, $width = 2.4) {
$p = New-Object System.Drawing.Pen $color, $width
$g.DrawLine($p, $x1, $y1, $x2, $y2)
$p.Dispose()
}
function Save-Png($bmp, $g, $path) { $g.Dispose(); $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); $bmp.Dispose() }
# ---------------------------------------------------------------- 图 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 '第三方适配 · 自身不做逆向分析 · 自供给 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($reaPin 暴露 $reaTools)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
Draw-Box $g 360 $rowY $boxW $boxH $([System.Drawing.Color]::FromArgb(255,239,246,255)) $accent
Draw-Text $g '官方 MCP 客户端行' $fBox $accent 380 ($rowY + 18)
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-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.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
Draw-Arrow $g 910 ($rowY + 40) 958 ($rowY + 40) $muted
# Evidence 回传:引擎 → 结果框 → 会话(左侧折返)
Draw-Box $g 360 404 850 84 $([System.Drawing.Color]::FromArgb(255,254,249,195)) $([System.Drawing.Color]::FromArgb(255,202,138,4))
Draw-Text $g 'Evidence 回到会话(置信度 observed / derived / inferred + 明确的 limitations)' $fBox $ink 380 416
Draw-Text $g '反编译器输出的是伪代码与汇编 —— 不是原始源码,也不保证文本等价' $fBody $muted 380 448
Draw-Arrow $g 1085 340 1085 400 $muted
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 $reaPin($reaTools 个工具) + DSH 桌面运行时 $hostMeasured + Ghidra $ghidraMeasured + Node ${nodePin}(Windows 11 x64,$measuredOn)" $fSmall $muted 60 646
Draw-Text $g 'Ghidra 为 Apache-2.0(NSA);REA 为 MIT。本仓库不分发上游代码。' $fSmall $muted 60 672
Save-Png $bmp $g (Join-Path $OutDir 'screenshot-1.png')
# ---------------------------------------------------------------- 图 2:范围与边界
$r = New-Canvas; $bmp = $r[0]; $g = $r[1]
Draw-Text $g '上游 REA 的分析范围(插件原样提供)' $fTitle $ink 60 44
Draw-Text $g '下列目标类型来自上游 README「可以分析什么」;本插件不改变、不扩展这些能力' $fSub $muted 62 96
$chips = @(
'原生二进制:伪代码/汇编/字符串/符号/调用与引用',
'JavaScript / Electron:模块、导入、source map、路由、IPC',
'.NET 程序集:元数据、CIL、原生依赖声明',
'网站:页面结构、脚本、网络观察、截图',
'Android APK:清单、类、反编译的方法、引用',
'Android 设备:adb 包/进程/日志/截图/文件传输',
'JEB 项目:单元、工件摘要、类型与方法伪代码',
'Android 资源:解码清单、字符串表、语言区域',
'固件:区域、提取结果、转交原生分析',
'软件包与资源:清单、摘要、plist、Apple bundle',
'进程行为:终端输出、交互、退出、文件系统观察',
'离线 ELF 布局:节、段、符号/重定位、防护候选项',
'EVM 字节码:分派选择器、字节偏移、可变性',
'已记录的 Linux 崩溃:note、线程寄存器、映射候选',
'已保存的网络捕获:HAR / mitmproxy 记录'
)
$cx = 60; $cy = 146; $cw = 570; $ch = 40; $gap = 8
for ($i = 0; $i -lt $chips.Count; $i++) {
$col = $i % 2; $rowi = [math]::Floor($i / 2)
$x = $cx + $col * ($cw + 20); $y = $cy + $rowi * ($ch + $gap)
Draw-Box $g $x $y $cw $ch $([System.Drawing.Color]::White) $line 8
Draw-Text $g $chips[$i] $fChip $ink ($x + 14) ($y + 9)
}
$by = $cy + 8 * ($ch + $gap) + 16
Draw-Box $g 60 $by 1190 92 $([System.Drawing.Color]::FromArgb(255,254,242,242)) $warn
Draw-Text $g '不能完整还原源代码' $fBox $warn 80 ($by + 10)
Draw-Text $g '返回的是伪代码/汇编/反编译方法,不是原始源码;标识符、类型、注释、宏与模板、构建脚本与资源多不可恢复;不保证文本等价或可直接编译。' $fBody $ink 80 ($by + 38)
Draw-Text $g '优化(内联/重排)、strip、混淆、加壳与 VM 保护会让可读性更差。要真正的源码,请走上游仓库、厂商授权或官方 SDK。' $fBody $muted 80 ($by + 60)
Draw-Text $g '上游 REA(MIT):github.com/morluto/rea 本插件:github.com/czcdr/dsh-plugin-rea' $fSmall $muted 60 686
Save-Png $bmp $g (Join-Path $OutDir 'screenshot-2.png')
Write-Host "written: $(Join-Path $OutDir 'screenshot-1.png')"
Write-Host "written: $(Join-Path $OutDir 'screenshot-2.png')"
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env node
/**
* Facts behind the one-shot "run setup" guide.
*
* The Web GUI needs three things the browser cannot know by itself:
*
* - which version of THIS package is installed (the guide re-arms on upgrade),
* - which REA version the manifest pins,
* - which REA version the machine's bundle actually holds.
*
* The third one is the point. A plugin upgrade replaces the package and stops
* there; `scripts/setup.mjs` is what moves the runtime to the new pin. So the
* honest trigger is not "the page was never shown" but "the bundle on disk does
* not match the pin this package ships". `computeGuideState` answers exactly
* that, and stays a pure function of three values so it can be tested without a
* harness, a browser or a provisioned bundle.
*
* `index.mjs` serves the result as GET /dsh-plugin-rea/guide-state; `client.js`
* renders the guide once per plugin version. This file also runs standalone:
*
* node bin/rea-guide.mjs --json [--bundle <bundle.json> | --root <dir>]
*/
import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
/** Route served by index.mjs and read by client.js. One definition, two readers. */
export const GUIDE_ROUTE = '/dsh-plugin-rea/guide-state'
/** Package root (the parent of bin/), used to build a copy-pasteable command. */
export const PLUGIN_ROOT = fileURLToPath(new URL('..', import.meta.url))
/**
* Read a JSON file the way the shim and setup.mjs do.
*
* PowerShell 5.1's `>` and `Out-File` write UTF-16 LE by default, so a record a
* user hand-edited can be UTF-16 while a record setup wrote is UTF-8. Treating
* only UTF-8 as valid made a present bundle look absent, which is the same
* silent re-provision the shim's reader had to be fixed for in 2.0.11.
*/
export function readJsonFile(file) {
const bytes = readFileSync(file)
if (bytes.length >= 2) {
const [first, second] = bytes
if (first === 0xff && second === 0xfe) {
return JSON.parse(bytes.subarray(2).toString('utf16le'))
}
if (first === 0xfe && second === 0xff) {
const swapped = Buffer.from(bytes.subarray(2))
swapped.swap16()
return JSON.parse(swapped.toString('utf16le'))
}
}
let text = bytes.toString('utf8')
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
return JSON.parse(text)
}
/** `$DSH_HOME` when set, otherwise the same `<home>/.dsh` fallback the shim uses. */
export function dshHome(env = process.env) {
const explicit = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
return explicit !== '' ? resolve(explicit) : join(homedir(), '.dsh')
}
/**
* The bundle root, by the same order scripts/setup.mjs (`defaultBundleRoot`),
* bin/rea-shim.mjs (`bundleRoot`) and scripts/rea-bundle-bootstrap.ps1 use:
* `REA_BUNDLE_ROOT` first, then `<DSH_HOME>/plugins/rea-bundle`.
*
* Reading only DSH_HOME here made a machine that provisions into a custom root
* look `missing` to the guide — the one screen whose entire job is to decide
* "does setup still have to run" — so it told an already-provisioned machine to
* provision again. The guide must answer for the root the rest of the system
* agrees on.
*/
export function bundleRoot(env = process.env) {
const override = typeof env.REA_BUNDLE_ROOT === 'string' ? env.REA_BUNDLE_ROOT.trim() : ''
return override !== '' ? resolve(override) : join(dshHome(env), 'plugins', 'rea-bundle')
}
/** Where setup.mjs records what it provisioned. */
export function defaultBundlePath(root = bundleRoot()) {
return join(root, 'bundle.json')
}
/** Windows paths are case-insensitive; comparing `resolve`d strings is not. */
function samePath(left, right) {
const a = resolve(left)
const b = resolve(right)
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
}
/**
* The exact command a user should run, quoted so a path with spaces survives.
*
* `--reuse` is not cosmetic. The guide appears mostly after an upgrade, when the
* runtime is already on the machine: without the flag, setup re-fetches the
* ~570 MB JDK + Ghidra pair even though the machine can answer for both, which is
* both slow and the opposite of what "align the runtime" means. On a first
* install the flag costs nothing — every candidate is probed, and a machine with
* none downloads exactly as before.
*
* `--root` is added whenever the guide looked somewhere other than the default
* `<DSH_HOME>/plugins/rea-bundle` (a `REA_BUNDLE_ROOT` override, or an explicit
* `--bundle`/`--root`). Without it the copy-pasted command would provision the
* default root and leave the one the guide just complained about untouched.
*/
export function setupCommandFor(pluginRoot = PLUGIN_ROOT, root = null) {
const command = `node ${quotePath(join(pluginRoot, 'scripts', 'setup.mjs'))} --reuse`
return root ? `${command} --root ${quotePath(root)}` : command
}
/** Quote a path so a copy-pasted command survives spaces and quotes. */
function quotePath(value) {
return `"${String(value).replace(/"/g, '\\"')}"`
}
/**
* Pin comparison, mirroring scripts/pin-match.ps1.
*
* A pin that carries a dot is a version: `6.7.0` matches only `6.7.0`.
* A pin that is a bare major is a family: `21` also matches `21.0.12.1+1`.
* The distinction exists because pins like `jdk: 21` legitimately resolve to a
* patched build, while `reaAgents: 6.7.0` must not silently accept `6.7.0-rc.1`
* or `6.5.0`.
*/
export function reaPinMatches(installed, pinned) {
const have = String(installed ?? '').trim()
const want = String(pinned ?? '').trim()
if (have === '' || want === '') return false
if (have === want) return true
if (want.includes('.')) return false
// A bare-major pin ("21") is a FLOOR, not a family prefix — the same rule
// `scripts/setup.mjs` applies when it decides a recorded component still
// satisfies the manifest. The prefix form accepted 21.0.12.1+1 and rejected
// 25.0.5, so the guide said "stale, run setup" on a machine that setup had just
// called "already provisioned (25.0.5) — major 25 satisfies the pin's major 21".
const haveMajor = Number.parseInt(have.split('.')[0], 10)
const wantMajor = Number.parseInt(want, 10)
if (Number.isFinite(haveMajor) && Number.isFinite(wantMajor)) return haveMajor >= wantMajor
return false
}
/**
* Compute the guide's state.
*
* Returned `state`:
* - `missing` no bundle.json — a first install that never ran setup
* - `unreadable` the file exists but cannot be parsed
* - `unknown` parsed, but records no reaAgents version
* - `stale` a REA version is recorded and it is not the pin
* - `ok` recorded version matches the pin
*
* `needsSetup` is true for everything except `ok`: a guide that stays silent on
* an unreadable record is a guide that hides the exact case it exists for.
*/
export function computeGuideState(options = {}) {
const {
pluginVersion = '',
pinnedRea = '',
bundleRoot: rootOption = null,
bundlePath: bundlePathOption = null,
pluginRoot = PLUGIN_ROOT,
} = options
// Resolve the root the way setup/shim/bootstrap do, then honour a path a caller
// handed in directly (tests and `--bundle` pass a file, not a root).
const root = rootOption ? resolve(rootOption) : bundleRoot()
const bundlePath = bundlePathOption ? resolve(bundlePathOption) : defaultBundlePath(root)
const rootInEffect = bundlePathOption ? dirname(bundlePath) : root
// Only the default root needs no flag: setup.mjs resolves DSH_HOME itself, so
// the command stays shorter there and identical to what it always printed.
const setupRoot = samePath(rootInEffect, join(dshHome(), 'plugins', 'rea-bundle')) ? null : rootInEffect
const base = {
pluginVersion: String(pluginVersion),
pinnedRea: String(pinnedRea),
installedRea: null,
provisionedAt: null,
state: 'missing',
needsSetup: true,
bundleRoot: rootInEffect,
bundlePath,
setupCommand: setupCommandFor(pluginRoot, setupRoot),
detail: '',
}
if (!existsSync(bundlePath)) {
return { ...base, detail: 'no bundle.json on this machine' }
}
let record
try {
record = readJsonFile(bundlePath)
} catch (error) {
return {
...base,
state: 'unreadable',
detail: `bundle.json could not be read: ${error && error.message ? error.message : String(error)}`,
}
}
const installed = record && record.versions ? record.versions.reaAgents : record && record.reaAgents
const provisionedAt = record && typeof record.provisionedAt === 'string' ? record.provisionedAt : null
if (typeof installed !== 'string' || installed.trim() === '') {
return {
...base,
provisionedAt,
state: 'unknown',
detail: 'bundle.json records no reaAgents version',
}
}
const matched = reaPinMatches(installed, pinnedRea)
return {
...base,
installedRea: installed,
provisionedAt,
state: matched ? 'ok' : 'stale',
needsSetup: !matched,
detail: matched
? `bundle reaAgents ${installed} matches pin ${pinnedRea}`
: `bundle reaAgents ${installed} does not match pin ${pinnedRea}`,
}
}
/** Read this package's own version and REA pin, for the route and the CLI. */
export function localPackageFacts(pluginRoot = PLUGIN_ROOT) {
const pkg = readJsonFile(join(pluginRoot, 'package.json'))
let pinnedRea = ''
try {
const manifest = readJsonFile(join(pluginRoot, 'manifest', 'bundle.manifest.json'))
pinnedRea = manifest && manifest.pins && typeof manifest.pins.reaAgents === 'string'
? manifest.pins.reaAgents
: ''
} catch {
/* A missing manifest is the audit's problem, not the guide's. */
}
return { pluginVersion: pkg && typeof pkg.version === 'string' ? pkg.version : '', pinnedRea }
}
function main(argv) {
const asJson = argv.includes('--json')
const flagValue = (name) => {
const index = argv.indexOf(name)
return index !== -1 && argv[index + 1] ? argv[index + 1] : null
}
// `--root` mirrors setup.mjs; `--bundle` names the record file itself and is
// what the old CLI offered. Both are overrides of the same resolution chain.
const bundlePath = flagValue('--bundle')
const root = flagValue('--root')
const facts = localPackageFacts()
const state = computeGuideState({
pluginVersion: facts.pluginVersion,
pinnedRea: facts.pinnedRea,
...(bundlePath ? { bundlePath } : {}),
...(root ? { bundleRoot: root } : {}),
})
if (asJson) {
process.stdout.write(`${JSON.stringify(state, null, 2)}\n`)
return
}
process.stdout.write([
`plugin ${state.pluginVersion}`,
`pin ${state.pinnedRea}`,
`bundle ${state.bundlePath}`,
`state ${state.state}${state.needsSetup ? ' (setup needed)' : ''}`,
`reaAgents ${state.installedRea || '-'}`,
state.provisionedAt ? `provisionedAt ${state.provisionedAt}` : '',
'',
state.needsSetup ? `run: ${state.setupCommand}` : 'nothing to do',
].filter((line) => line !== '').join('\n') + '\n')
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
main(process.argv.slice(2))
}
+1415
View File
File diff suppressed because it is too large. Load diff
+452 -66
View File
@@ -1,10 +1,11 @@
<#
.SYNOPSIS
检查 dsh-plugin-rea 的整条链路是否需要跟进上游更新。
检查 dsh-plugin-rea 整条链路是否需要跟进上游更新。
.DESCRIPTION
覆盖 4 类上游:REA(npm) / Node 运行时 / Ghidra 引擎 / DSH 插件登记。
只读:不安装、不修改任何配置,只报告现状与建议动作。
对 PATH 缺失、profile 结构缺失、预发布版本号均有防御。
.EXAMPLE
powershell -NoProfile -File .\check-updates.ps1
@@ -20,119 +21,504 @@ param(
[string]$ProfileDir
)
# 默认值:自动定位 REA 全局包与 dsh profile(均可用参数覆盖)
if (-not $ReaPackageDir) {
$npmRoot = $null
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' }
}
if (-not $ProfileDir) {
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
else {
$profiles = @(Get-ChildItem "$env:USERPROFILE\.dsh\profiles" -Directory -ErrorAction SilentlyContinue)
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { "$env:USERPROFILE\.dsh\profiles\desktop" }
}
}
$ErrorActionPreference = 'Continue'
# 判绿计数器。「注意」必须计入最终结论:2.0.13 里 pin 漂移只写一行 Warn、不进
# $actions,末尾照样打印「无需动作」,而那一行正是 pin 升级后唯一面向用户的信号。
$warnings = 0
$badCount = 0
# bundle 分支只在 bundle.json 存在时赋这三个值;结论处直接拿它们比较,所以先给 0
# ($null -eq 0 是 $false,会让「根本没供给」也走进「另有 N 项采纳」那条绿分支)。
$drift = 0
$unrecorded = 0
$adopted = 0
function Head($t) { Write-Host "`n=== $t ===" -ForegroundColor Cyan }
function Ok($t) { Write-Host " [OK] $t" -ForegroundColor Green }
function Warn($t) { Write-Host " [注意] $t" -ForegroundColor Yellow }
function Bad($t) { Write-Host " [动作] $t" -ForegroundColor Red }
function Warn($t) { $script:warnings++; Write-Host " [注意] $t" -ForegroundColor Yellow }
# 「上游」不是本机状态:registry 查不通、GitHub 最新版不是 12.1.x、本机没有 rea 可跑
# doctor —— 这些既不能证明 bundle 一致,也不构成待办,所以单独一类,不参与判绿。
function Note($t) { Write-Host " [上游] $t" -ForegroundColor DarkYellow }
function Bad($t) { $script:badCount++; Write-Host " [动作] $t" -ForegroundColor Red }
function Info($t) { Write-Host " $t" -ForegroundColor Gray }
$actions = [System.Collections.Generic.List[string]]::new()
# 只取数字部分,丢掉 -rc.1 / +build 之类的后缀,避免 [version] 转换抛异常
function Get-NumericVersion([string]$v) {
if (-not $v) { return $null }
$core = ($v -replace '^v', '') -replace '[-+].*$', ''
try { return [version]$core } catch { return $null }
}
# 版本比对规则放在独立文件里:这一行是用户唯一能看到的「pin 变了,该重跑 setup」
# 信号,2.0.10 之前它假绿过(6.5.0 被判成与 pin 6.7.0 一致),所以需要一条不依赖
# 网络、不依赖 profile 的回归测试 —— verify-branch.ps1 直接 dot-source 这个文件。
$pinMatchFile = Join-Path $PSScriptRoot 'scripts\pin-match.ps1'
if (Test-Path $pinMatchFile) {
. $pinMatchFile
} else {
# 目录不完整(旧拷贝、手工搬走了一个文件):退化为精确比对。
# 宁可多报一次「重跑 setup」,也不能再假绿。
function Test-PinMatch {
param([string]$Installed, [string]$Pinned)
return [string]::Equals(([string]$Installed).Trim(), ([string]$Pinned).Trim(), [System.StringComparison]::Ordinal)
}
}
# 判断 Node 版本是否满足 engines 字符串(支持 ^X.Y.Z / ~X.Y.Z / >=X.Y.Z,用 || 分隔)
# 参数收 string 不收 [version]:类型约束在函数体之前生效,调用方一旦传入
# 'abc' / '' / 'v24.21.0' 这类值就会抛 ParameterBindingArgumentTransformationException。
function Test-NodeEngines([string]$node, [string]$engines) {
if (-not $engines -or -not $node) { return $null }
# 必须用新变量接收:$node 带 [string] 约束,赋 [version] 会被强制转回字符串,
# 之后 $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+)$') {
$base = [version]$Matches[1]
if ($v.Major -eq $base.Major -and $v -ge $base) { return $true }
} elseif ($t -match '^>=(\d+\.\d+\.\d+)$') {
if ($v -ge [version]$Matches[1]) { return $true }
} 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
# 2.x 的 REA 在 <bundleRoot>\vendor\node_modules\rea-agents,不在 npm 全局。先读
# bundle.json:把全局路径当默认是 1.x 的答案,在 2.x 机器上会让这一节指向错误的
# 对象("全局包丢了"),并给出错误的升级动作(npm i -g)。
$bundleRootEarly = $env:REA_BUNDLE_ROOT
# Resolve the home unconditionally: the profile section below needs it whether or
# not REA_BUNDLE_ROOT was set, and computing it only inside the fallback branch
# left it $null — `Join-Path $null 'profiles'` throws, so the section degraded to
# "no profile" on exactly the machines that use the override.
$homeEarly = $env:DSH_HOME
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
if (-not $bundleRootEarly) {
$bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
}
$bundleState = $null
$bundleJsonEarly = Join-Path $bundleRootEarly 'bundle.json'
if (Test-Path $bundleJsonEarly) {
try { $bundleState = Get-Content $bundleJsonEarly -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $bundleState = $null }
}
$bundleReaPackageDir = $null
$bundleReaEntry = $null
if ($bundleState -and $bundleState.paths.reaEntry) {
$bundleReaEntry = [string]$bundleState.paths.reaEntry
# <vendor>\node_modules\rea-agents\scripts\rea.mjs -> <vendor>\node_modules\rea-agents
$bundleReaPackageDir = Split-Path -Parent (Split-Path -Parent $bundleReaEntry)
}
if (-not $ReaPackageDir) {
if ($bundleReaPackageDir -and (Test-Path (Join-Path $bundleReaPackageDir 'package.json'))) {
$ReaPackageDir = $bundleReaPackageDir
} else {
$npmRoot = $null
if (Get-Command npm -ErrorAction SilentlyContinue) {
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
}
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
}
}
$reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaPackageDir)
if (-not $ProfileDir) {
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
else {
# Same home rule the bundle uses everywhere else: DSH_HOME, then ~/.dsh.
# Hardcoding $env:USERPROFILE here was a second algorithm in the same script,
# so on a custom DSH_HOME the profile section reported "no profile found"
# while the rest of the run had already resolved that home correctly.
$profiles = @(Get-ChildItem (Join-Path $homeEarly 'profiles') -Directory -ErrorAction SilentlyContinue)
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $homeEarly 'profiles\desktop' }
}
}
# ---------------------------------------------------------------- REA (npm)
Head 'REA 本体(npm 包 rea-agents)'
$installed = $null; $engines = $null; $binPath = $null
$installed = $null; $engines = $null; $binPath = $null; $reaEntry = $null
$pkgFile = Join-Path $ReaPackageDir 'package.json'
if (Test-Path $pkgFile) {
$pkg = Get-Content $pkgFile -Raw | ConvertFrom-Json
$installed = $pkg.version; $engines = $pkg.engines.node
$binPath = ($pkg.bin.PSObject.Properties | Select-Object -First 1).Value
Ok "本地已安装: $installed"
Info "入口(bin): $binPath ← 插件引用的就是它,跨版本稳定"
Info "Node 要求: $engines"
} else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
try {
$pkg = Get-Content $pkgFile -Raw -Encoding UTF8 | ConvertFrom-Json
$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 '/', '\') }
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 {
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')) {
if (-not $r) { continue }
try { $latest = (Invoke-RestMethod "$r/rea-agents/latest" -TimeoutSec 20).version; Info "最新版本($r): $latest"; break }
catch { Warn "查询失败 $r : $($_.Exception.Message)" }
catch { Note "查询失败 $r : $($_.Exception.Message)" }
}
if ($installed -and $latest) {
if ([version]$latest -gt [version]$installed) {
$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 (插件无需改动,热重载后生效)")
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 '已是最新版本' }
} elseif (-not $latest) {
Note '未能取到上游最新版本(两个 registry 都查不通?)—— 已跳过版本比对'
} else {
Note '本地没读到已安装版本(见上面那条)—— 已跳过版本比对'
}
# ------------------------------------------------------------- Node 运行时
Head 'Node 运行时'
$nodeV = (& node -v) -replace '^v', ''
Info "当前 node: $nodeV"
if ($engines) {
if ($engines -match '\^24\.(\d+)\.' -and [version]$nodeV -ge [version]('24.' + $Matches[1] + '.0')) { Ok '满足当前 engines 要求' }
elseif ($engines -match '\^22\.(\d+)\.' -and [version]$nodeV -ge [version]('22.' + $Matches[1] + '.0')) { Ok '满足当前 engines 要求' }
else { Warn "请对照 engines 范围自行确认: $engines" }
Info "上游若抬高 Node 下限,插件里的 command('node') 需换成满足要求的 node 可执行文件"
if (Get-Command node -ErrorAction SilentlyContinue) {
$nodeRaw = (& node -v 2>$null) -replace '^v', ''
$nodeV = Get-NumericVersion $nodeRaw
Info "当前 node: $nodeRaw"
if ($nodeV -and $engines) {
$ok = Test-NodeEngines $nodeV $engines
if ($ok -eq $true) { Ok "满足当前 engines($engines)" }
elseif ($ok -eq $false) { Bad "不满足 engines($engines)—— 请升级 Node;插件侧走 PATH,无需改配置" }
else { Note "无法解析 engines:$engines" }
}
} else {
Warn 'node 不在 PATH —— 跳过 Node 检查;若 harness 也看不到它,MCP 服务器将无法启动'
$actions.Add('把 node 加入 PATH,或在本机 profile 覆盖里给 mcp-rea 的 command 写绝对路径')
}
# ------------------------------------------------------------ Ghidra 引擎
Head 'Ghidra 引擎'
$gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User')
# bundle.json 是 2.x 的真源:shim 只在 MCP 子进程里设 GHIDRA_INSTALL_DIR,用户级
# 变量是 1.x 的写法,没手工设过的机器会被误报"未设置"。
$gd = $null
if ($bundleState -and $bundleState.paths.ghidra) {
$bundleGhidra = [string]$bundleState.paths.ghidra
if (Test-Path $bundleGhidra) { $gd = $bundleGhidra }
}
if (-not $gd) { $gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User') }
if (-not $gd) { $gd = $env:GHIDRA_INSTALL_DIR }
if ($gd -and (Test-Path $gd)) {
Ok "GHIDRA_INSTALL_DIR = $gd"
$props = Join-Path $gd 'Ghidra\application.properties'
if (Test-Path $props) {
$v = (Select-String -Path $props -Pattern '^application\.version=' | Select-Object -First 1).Line -replace '^application\.version=', ''
Ok "已安装版本: $v"
if ($v -notmatch '^12\.1\.') { Bad "REA 当前要求 Ghidra 12.1.x,而你装的是 $v —— 升级 REA 后才有望支持更高版本" }
} else { Bad "缺少 Ghidra\application.properties —— 目录不完整" }
} else { Bad "GHIDRA_INSTALL_DIR 未设置或目录不存在(当前值: $gd)" }
$line = (Select-String -Path $props -Pattern '^application\.version=' | Select-Object -First 1).Line
if (-not $line) {
# "not 12.1.x" for an unreadable version sent the reader looking for a
# newer Ghidra when the file is simply incomplete — a different problem
# with a different fix.
Bad 'Ghidra\application.properties 缺少 application.version 行 —— 文件不完整(重装该组件)'
} else {
$v = $line -replace '^application\.version=', ''
Ok "已安装版本: $v"
if ($v -notmatch '^12\.1\.') {
# 本机 --enable 过的闸门覆盖是**有记录的**偏离:REA 的闸门确实钉在 12.1.x,
# 但这台机器故意跑了更新的版本。把它报成 [动作] 等于劝人撤掉覆盖,而
# override 记录已经在下方的 bundle 分支单独列出 —— 所以这里只说明,不计数。
$gateOverride = $null
$gateOverrideFile = Join-Path $bundleRootEarly 'ghidra-gate-override.json'
if (Test-Path $gateOverrideFile) {
try { $gateOverride = Get-Content $gateOverrideFile -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $gateOverride = $null }
}
if ($gateOverride -and [string]$gateOverride.requestedVersion -eq $v) {
Info "REA 闸门钉在 12.1.x;当前 $v 是本机 --enable 的覆盖(不是 pin 漂移,记录见 bundle 分支)"
} else {
Bad "REA 当前要求 Ghidra 12.1.x,当前为 $v —— 先确认新版 REA 是否放开版本门槛"
}
}
}
} else { Bad '缺少 Ghidra\application.properties —— 目录不完整' }
} else {
Warn "GHIDRA_INSTALL_DIR 未设置或目录不存在(当前值: $gd)"
Info '原生分析会报引擎不可用;静态 JavaScript/Electron 与 .NET 分析不受影响'
}
try {
$rel = Invoke-RestMethod 'https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/latest' -TimeoutSec 25 -Headers @{ 'User-Agent' = 'rea-check' }
Info "Ghidra 官方最新: $($rel.tag_name) ($($rel.published_at))"
if ($rel.tag_name -notmatch 'Ghidra_12\.1') { Warn '官方已有更新;请先确认 REA 是否放开版本门槛,再升级 Ghidra' }
} catch { Warn "GitHub API 查询失败(可跳过): $($_.Exception.Message)" }
if ($rel.tag_name -notmatch 'Ghidra_12\.1') { Note '官方已有更新;升级前先确认 REA 是否接受该版本' }
} catch { Note "GitHub API 查询失败(可跳过): $($_.Exception.Message)" }
# --------------------------------------------------------- REA 引擎自检
Head 'REA 引擎自检(rea doctor)'
$env:GHIDRA_INSTALL_DIR = $gd
try {
$raw = & rea doctor --format json 2>$null
$doc = ($raw -join "`n") | ConvertFrom-Json
$g = $doc.providerInspections | Where-Object { $_.id -eq 'ghidra' }
if ($g) {
if ($g.available) { Ok "ghidra provider 可用,版本 $($g.providerVersion)" } else { Bad 'ghidra provider 不可用 —— 运行 rea doctor 看 remediation' }
$fails = @($g.checks | Where-Object { -not $_.ok })
if ($fails.Count -eq 0) { Ok '全部子项通过(configuration/installation/headless/native_decompiler/java)' }
else { $fails | ForEach-Object { Bad "$($_.name): $($_.detail)" } }
# 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 ($doc.identity.cli_package_version) { Info "doctor 识别到的 CLI 版本: $($doc.identity.cli_package_version),MCP 工具数: $($doc.identity.catalog.counts.mcp_tools)" }
} catch { Warn "rea doctor 解析失败,请手动运行: rea doctor" }
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)
Note 'rea 不在 PATH —— 回退到 node <包内入口>'
} else { Note 'rea 与 node 均不可用 —— 跳过引擎自检' }
}
if ($reaCmd) {
try {
$raw = & $reaCmd @reaArgs doctor --format json 2>$null
if ($LASTEXITCODE -ne 0) {
# Not a machine-state warning.
#
# `rea doctor` exits non-zero while ANY optional capability is unconfigured:
# Hopper, IDA, Kuna, the skill copies, the five client registrations. A
# perfectly provisioned bundle therefore still exits 1, and treating that as
# a warning made every healthy machine report "需要动作" — a false red, which
# is the mirror image of the false green this script was just fixed for. The
# state that actually matters is parsed out of the output below (the provider
# inspection and its checks), and THAT drives Ok/Bad.
Note "rea doctor 退出码 $LASTEXITCODE(包含可选能力未配置;实际状态按下方输出解析)"
}
$doc = ($raw -join "`n") | ConvertFrom-Json
$g = @($doc.providerInspections) | Where-Object { $_.id -eq 'ghidra' }
if ($g) {
if ($g.available) { Ok "ghidra provider 可用,版本 $($g.providerVersion)" }
else { Bad 'ghidra provider 不可用 —— 运行 rea doctor 看 remediation' }
$fails = @($g.checks | Where-Object { -not $_.ok })
if ($fails.Count -eq 0) { Ok '全部子项通过(configuration/installation/headless/native_decompiler/java)' }
else { $fails | ForEach-Object { Bad "$($_.name): $($_.detail)" } }
}
if ($doc.identity.cli_package_version) {
$toolCount = $null
if ($doc.identity.catalog -and $doc.identity.catalog.counts) { $toolCount = $doc.identity.catalog.counts.mcp_tools }
Info "doctor 识别到的 CLI 版本: $($doc.identity.cli_package_version)$(if ($toolCount) { ",MCP 工具数: $toolCount" })"
}
} catch { Note "rea doctor 解析失败,请手动运行: rea doctor" }
}
# ---------------------------------------------------- DSH 插件登记状态
Head 'DSH 插件登记'
$profPkg = Join-Path $ProfileDir 'package.json'
if (Test-Path $profPkg) {
$p = Get-Content $profPkg -Raw | ConvertFrom-Json
$dep = $p.dependencies.'dsh-plugin-rea'
$bundled = @($p.dsh.profile.bundles) -contains 'dsh-plugin-rea'
if ($dep) { Ok "依赖已登记: dsh-plugin-rea = $dep" } else { Bad '依赖未登记 —— 用 plugin_manager 重新安装本插件' }
if ($bundled) { Ok '已列入 dsh.profile.bundles(装载层已生效)' } else { Bad '未列入 bundles —— 插件不会加载' }
if ($dep -like 'file:*') {
$p2 = $dep -replace '^file:', ''
if (Test-Path $p2) { Ok "插件目录存在: $p2" } else { Bad "插件目录不存在: $p2 —— 移走了目录就会加载失败" }
try {
$p = Get-Content $profPkg -Raw -Encoding UTF8 | ConvertFrom-Json
$dep = $null
if ($p.PSObject.Properties.Name -contains 'dependencies' -and $p.dependencies) { $dep = $p.dependencies.'dsh-plugin-rea' }
$bundles = @()
if ($p.PSObject.Properties.Name -contains 'dsh' -and $p.dsh -and $p.dsh.profile -and $p.dsh.profile.bundles) { $bundles = @($p.dsh.profile.bundles) }
if ($dep) { Ok "依赖已登记: dsh-plugin-rea = $dep" } else { Bad '依赖未登记 —— 用插件管理器重新安装本插件' }
if ($bundles -contains 'dsh-plugin-rea') { Ok '已列入 dsh.profile.bundles(装载层已生效)' }
elseif ($bundles.Count -eq 0) { Note '未读到 dsh.profile.bundles(profile 结构可能不同)' }
else { Bad '未列入 bundles —— 插件不会加载' }
Info '本插件不会自动更新:从插件市场安装的在市场里更新;本地 file: 目录方式则覆盖目录后在插件管理器里重装'
if ($dep -like 'file:*') {
$p2 = $dep -replace '^file:', ''
if (Test-Path $p2) { Ok "插件目录存在: $p2" } else { Bad "插件目录不存在: $p2 —— 移走目录会加载失败" }
}
} catch { Note "profile package.json 解析失败: $($_.Exception.Message)" }
} else { Note "找不到 profile: $profPkg" }
$profPatch = Join-Path $ProfileDir 'cordis.patch.yml'
if (Test-Path $profPatch) {
if (Select-String -Path $profPatch -Pattern 'id:\s*mcp-rea' -Quiet) {
Info '检测到本机 profile 覆盖了 mcp-rea(机器相关值应放在这里,不随插件发布)'
}
} else { Warn "找不到 profile: $profPkg" }
}
# ------------------------------------------------- 一体包(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
# 采纳的本机运行时(reused)不按 pin 精确比对,但也不能算进「全绿」。
$adopted = 0
# 本机 Ghidra 闸门覆盖(scripts/ghidra-gate-override.mjs):盘上跑的是 REA 未
# 验证的 Ghidra 版本。这是一次**有记录的、可还原的**偏离,不是「忘了对齐」——
# 不这样标注的话,这行会劝人去重跑 setup,而重跑正好会把覆盖悄悄抹掉。
$ghidraOverride = $null
$overrideFile = Join-Path (Split-Path $bundleJson -Parent) 'ghidra-gate-override.json'
if (Test-Path $overrideFile) {
try { $ghidraOverride = Get-Content $overrideFile -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $ghidraOverride = $null }
}
foreach ($name in $manifest.pins.PSObject.Properties.Name) {
if ($name -eq 'nodeEngines') { continue }
$pinned = [string]$manifest.pins.$name
$installed = ''
if ($state.versions -and ($state.versions.PSObject.Properties.Name -contains $name)) {
$installed = [string]$state.versions.$name
}
if ($name -eq 'ghidra' -and $ghidraOverride -and [string]$ghidraOverride.requestedVersion -eq $installed) {
Info " ghidra = $installed(本机覆盖 Ghidra 闸门:REA 未验证这一版;要用 pin 就 --disable 再重跑 setup)"
$adopted++
continue
}
if (-not $installed) {
# 被 --reuse 采纳的组件没有 version 字段(bundle.json 里是 null)。静默
# continue 会让这一项从未被比对,却在下面打印"全部一致"——那是假绿。
$unrecorded++
Warn " $name 未记录版本(reuse 采纳的组件?)—— 无法与 pin $pinned 对账"
continue
}
$compared++
$component = $null
if ($state.components -and ($state.components.PSObject.Properties.Name -contains $name)) {
$component = $state.components.$name
}
# 采纳的本机 Node(setup --reuse)不是 bundle 安装的版本,拿它跟「下载版本」
# 精确比对必然误报;它要满足的是 engines。采纳时 setup 跑过同一份探针,这里
# 再用同一个 Test-NodeEngines 对一次账,而不是把它当成「与 pin 一致」。
if ($name -eq 'node' -and $component -and $component.reused -eq $true) {
$engines = [string]$manifest.pins.nodeEngines
$enginesOk = Test-NodeEngines $installed $engines
if ($enginesOk -eq $true) {
Info " node = $installed(采纳本机运行时;pin $pinned 指下载版本,满足 engines)"
$adopted++
} elseif ($enginesOk -eq $false) {
Warn "node 盘上为 $installed(采纳的本机运行时),不满足 REA engines $engines —— 换一个 Node 或重跑 setup"
$drift++
} else {
Warn "node 盘上为 $installed,但 engines 无法解析($engines)—— 未比对"
$unrecorded++
}
continue
}
# 2.0.10 修掉的假绿:此前对所有组件都比「主版本前缀」,
# "6.5.0".StartsWith("6.") 为真 → bundle 还在 6.5.0 时报「与 pin 6.7.0 一致」,
# 而这行正是 pin 升级后唯一面向用户的「该重跑 setup」信号。apktool 同理
# (3.0.9 对 pin 3.0.3 也假绿)。主版本前缀只对 pin 本身只有主版本号时成立(jdk 21)。
if (Test-PinMatch -Installed $installed -Pinned $pinned) { Info " $name = $installed(pin $pinned)" }
else { Warn "$name 盘上为 $installed,manifest 已钉 $pinned —— 重跑 setup 对齐"; $drift++ }
}
# 采纳(--reuse 的本机运行时)和本机覆盖(ghidra-gate-override)是**有记录的**
# 偏离,不是「忘了对齐」:它们既不是 drift,也不该变成一条警告把每台正常机器
# 都判成需要动作。所以这里用 [上游] 如实说明「并非全部精确等于 pin」。
if ($drift -eq 0 -and $unrecorded -eq 0 -and $adopted -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
elseif ($drift -eq 0 -and $unrecorded -eq 0) { Note "共比对 $compared 项:其中 $adopted 项为采纳的本机运行时 / 本机覆盖(见上),其余与 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 已就位'
# The row launches this COPY, so a plugin upgrade that does not re-run
# setup leaves the old launcher in place — visible only as "the new
# version behaves like the old one". Compare it with the checkout the
# mirror record names.
$mirrorRecord = Join-Path $bundleRoot 'mirror.json'
if (Test-Path $mirrorRecord) {
try {
$record = Get-Content $mirrorRecord -Raw -Encoding UTF8 | ConvertFrom-Json
$pluginShim = if ($record.pluginRoot) { Join-Path $record.pluginRoot 'bin\rea-shim.mjs' } else { $null }
if ($pluginShim -and (Test-Path $pluginShim)) {
# `Get-FileHash` is missing on hosted Windows runners and in any host
# running PowerShell in a restricted language mode. That is a
# "cannot verify" answer, not an "inconsistent" one: reporting it as
# [注意] made such a machine look like it needed action, which is the
# same false red this script was just fixed for in the other
# direction. Ask whether the comparison is even possible first.
if (-not (Get-Command Get-FileHash -ErrorAction SilentlyContinue)) {
Note '本机没有 Get-FileHash,无法比对镜像 shim 是否最新 —— 跳过这一项'
} else {
$same = (Get-FileHash $mirrored -Algorithm SHA256).Hash -eq (Get-FileHash $pluginShim -Algorithm SHA256).Hash
if ($same) { Ok '镜像 shim 与插件一致(行跑的就是这个版本)' }
else { Warn "镜像 shim 落后于插件($($record.pluginRoot))—— 行仍在跑旧副本;重跑 node scripts/setup.mjs 让它换成新版本" }
}
} else {
Note "mirror.json 指向的插件目录已不存在($($record.pluginRoot))—— 无法比对镜像 shim 是否最新"
}
} catch { Note "mirror.json 解析失败: $($_.Exception.Message)" }
}
}
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 '无需动作:整条链路与上游保持一致' }
else { $actions | ForEach-Object { Bad $_ } }
# 判绿条件:没有待办、没有 pin 漂移、没有未记录版本、没有「注意」、没有「动作」行。
# 此前只看 $actions,于是「jdk 盘上为 25.0.5,manifest 已钉 21」这种漂移照样以
# 「无需动作:整条链路与上游保持一致」收尾、退出码 0 —— 假绿正是这么产生的。
$reasons = @()
if ($actions.Count -gt 0) { $reasons += "待办 $($actions.Count) 项" }
if ($drift -gt 0) { $reasons += "pin 漂移 $drift 项" }
if ($unrecorded -gt 0) { $reasons += "未记录版本 $unrecorded 项" }
if ($warnings -gt 0) { $reasons += "提醒 $warnings 条" }
if ($badCount -gt 0) { $reasons += "问题 $badCount 处" }
Write-Host ''
Write-Host '提示:DSH 侧更新(应用升级可能改变 MCP 客户端配置结构)后,请重跑本脚本并调用一次 mcp__rea__binary_session 验证。' -ForegroundColor Gray
if ($reasons.Count -eq 0) {
if ($drift -eq 0 -and $unrecorded -eq 0 -and $adopted -eq 0) { Ok '无需动作:整条链路与上游保持一致' }
else { Ok "无需动作:能比对的项目均与 pin 一致(另有 $adopted 项采纳 / 本机覆盖,见上)" }
exit 0
}
# 先列待办,最后打印结论:末行必须永远是判绿/判红的结论,而不是它上面某条动作。
$actions | ForEach-Object { Bad $_ }
Write-Host (' [结论] 需要动作:' + ($reasons -join ';') + '(逐条见上)') -ForegroundColor Red
exit 1
+400
View File
@@ -0,0 +1,400 @@
/**
* dsh-plugin-rea — the one-shot "run setup" guide.
*
* Why this exists: a market install or update replaces the package and stops
* there. The runtime (Node, REA, a JDK, Ghidra) lives in the profile's
* `plugins/rea-bundle` and is only moved by `scripts/setup.mjs`, which nobody
* runs for the user. Skipping it fails twice, silently: on a first install the
* MCP row exits MODULE_NOT_FOUND with no tools, and after an upgrade the old
* REA keeps answering. The README says so; nobody reads the README.
*
* So the guide appears once per plugin version — first install, and again after
* every upgrade — states which side is out of date, and shows the exact command
* for THIS install (the path depends on where the market unpacked the package).
* "Later" and "I ran it" both record the version and stop the reminder until
* the next upgrade. It is a notice, not a runner: this file never spawns setup.
*
* Facts come from GET /dsh-plugin-rea/guide-state (index.mjs → bin/rea-guide.mjs),
* which compares the recorded bundle against this package's pin. When that route
* is absent (a host without a webServer, an older build) the guide stays silent
* rather than guessing.
*/
window.__ModuleLoader__.load({
id: "dsh-plugin-rea",
factory: (require) => {
const module = { exports: {} };
const exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const react = require("react");
const h = react.createElement;
const { useEffect, useState } = react;
const name = "dsh-plugin-rea";
const inject = ["slots"];
const ROUTE = "/dsh-plugin-rea/guide-state";
const ACK_KEY = "dsh-plugin-rea.guide.ack";
const TEXT = {
zh: {
badge: "需要一次性配置",
title: "还要跑一次 setup",
leadMissing: "插件装好了,但运行时不在这个包里。",
leadStale: "插件更新了,机器上的运行时还是旧的。",
leadUnknown: "运行时记录不完整,无法确认它和本插件是否一致。",
body: "setup.mjs 负责把 Node、REA、JDK、Ghidra 下载或对齐到本机。市场里的安装和更新只替换插件包,不会替你跑它。",
statusMissing: "未检测到运行时 —— 这是一次全新的安装。",
statusStale: "插件 {v}:运行时里是 REA {installed},本插件钉 {pin}。",
statusUnknown: "运行时记录里没有 REA 版本。",
statusUnreadable: "运行时记录读不出来(bundle.json 无法解析)。",
commandLabel: "在任意目录运行(路径已指向这次安装的插件)",
copy: "复制命令",
copied: "已复制",
copyFailed: "复制失败,请手动选中",
done: "我已运行",
later: "稍后",
hint: "跑完重启 dsh。这一步只对齐运行时,不会更新插件包本身;升级后同样再跑一次。",
noteSmoke: "setup 结束时会用 JDK 自带的 java 跑一次真实分析来验证引擎 —— 那里 30–60 秒没有任何输出是正常的。",
noteLock: "若它报 “another provisioning run holds …”,说明后台已有一个 setup 在跑,等它结束再试即可。",
noteOverride: "若你开过 Ghidra 闸门覆盖(让本机跑 REA 未验证的 Ghidra),setup 会重新供给内置 REA,那层覆盖随之消失 —— 跑完执行 node scripts/ghidra-gate-override.mjs --enable --version 12.2 恢复。",
},
en: {
badge: "ONE-TIME SETUP",
title: "One more step: run setup",
leadMissing: "The plugin is installed. Its runtime is not.",
leadStale: "The plugin was updated; the runtime on this machine is still the old one.",
leadUnknown: "The runtime record is incomplete, so it cannot be compared with this package.",
body: "setup.mjs downloads or aligns Node, REA, a JDK and Ghidra on this machine. A market install or update only replaces the package — nothing runs it for you.",
statusMissing: "No runtime found — this is a first install.",
statusStale: "Plugin {v}: the runtime holds REA {installed}, this package pins {pin}.",
statusUnknown: "The runtime record has no REA version.",
statusUnreadable: "The runtime record cannot be read (bundle.json did not parse).",
commandLabel: "Run it from any directory (the path points at this install)",
copy: "Copy command",
copied: "Copied",
copyFailed: "Copy failed — select it manually",
done: "I ran it",
later: "Later",
hint: "Restart dsh afterwards. This only aligns the runtime; it does not update the plugin package — run it again after the next upgrade.",
noteSmoke: "It finishes by importing a small file to prove the analysis engine works — 30–60 s with no output there is normal.",
noteLock: "If it reports “another provisioning run holds …”, a setup is already running in the background; wait for it.",
noteOverride: "If you had enabled the Ghidra gate override (running a Ghidra REA has not verified), setup re-provisions the bundled REA and the patch goes with it — run node scripts/ghidra-gate-override.mjs --enable --version 12.2 afterwards to restore it.",
},
};
function pickLang() {
try {
const declared = String((document.documentElement && document.documentElement.lang) || "");
if (/^zh/i.test(declared)) return "zh";
if (declared) return "en";
const nav = String((typeof navigator !== "undefined" && navigator.language) || "");
return /^zh/i.test(nav) ? "zh" : "en";
} catch {
return "en";
}
}
function readAck() {
try {
return window.localStorage.getItem(ACK_KEY) || "";
} catch {
return "";
}
}
function writeAck(version) {
try {
window.localStorage.setItem(ACK_KEY, String(version));
} catch {
/* private mode: the guide simply reappears next time */
}
}
async function copyText(text) {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
/* fall through to the selection path */
}
try {
const area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.top = "-9999px";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
const ok = document.execCommand("copy");
document.body.removeChild(area);
return ok;
} catch {
return false;
}
}
function statusLine(state, t) {
if (state.state === "missing") return t.statusMissing;
if (state.state === "stale") {
return t.statusStale
.replace("{v}", String(state.pluginVersion || "?"))
.replace("{installed}", String(state.installedRea || "?"))
.replace("{pin}", String(state.pinnedRea || "?"));
}
if (state.state === "unreadable") return t.statusUnreadable;
return t.statusUnknown;
}
function leadLine(state, t) {
if (state.state === "missing") return t.leadMissing;
if (state.state === "stale") return t.leadStale;
return t.leadUnknown;
}
const maskStyle = {
position: "fixed",
inset: 0,
zIndex: 2400,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
background: "rgba(6, 8, 12, 0.55)",
pointerEvents: "auto",
};
const cardStyle = {
width: "min(620px, 100%)",
maxHeight: "82vh",
overflow: "auto",
padding: "22px 24px 18px",
borderRadius: 14,
border: "1px solid var(--dsw-alias-border-secondary, rgba(255,255,255,0.14))",
background: "var(--dsw-alias-bg-elevated, #171a20)",
color: "var(--dsw-alias-label-primary, #e9e9e9)",
boxShadow: "0 24px 64px rgba(0,0,0,0.45)",
lineHeight: 1.6,
fontSize: 14,
};
const codeStyle = {
margin: "10px 0 0",
padding: "10px 12px",
borderRadius: 9,
background: "var(--dsw-alias-bg-secondary, rgba(255,255,255,0.06))",
border: "1px solid var(--dsw-alias-border-secondary, rgba(255,255,255,0.10))",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
fontSize: 12.5,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
userSelect: "all",
};
const buttonBase = {
appearance: "none",
border: "1px solid var(--dsw-alias-border-secondary, rgba(255,255,255,0.18))",
background: "transparent",
color: "inherit",
borderRadius: 8,
padding: "6px 12px",
fontSize: 13,
cursor: "pointer",
font: "inherit",
};
const primaryButton = Object.assign({}, buttonBase, {
background: "var(--dsw-alias-label-brand, #4f8cff)",
borderColor: "var(--dsw-alias-label-brand, #4f8cff)",
color: "#fff",
fontWeight: 600,
});
function Guide() {
const [state, setState] = useState(null);
const [closed, setClosed] = useState(false);
const [copyLabel, setCopyLabel] = useState("");
useEffect(() => {
let alive = true;
const ctrl = typeof AbortController === "function" ? new AbortController() : null;
const timer = setTimeout(() => {
try {
if (ctrl) ctrl.abort();
} catch {
/* ignore */
}
}, 5000);
fetch(ROUTE, {
signal: ctrl ? ctrl.signal : undefined,
credentials: "same-origin",
headers: { accept: "application/json" },
})
.then((response) => (response && response.ok ? response.json() : null))
.then((data) => {
if (alive && data && typeof data === "object") setState(data);
})
.catch(() => {
/* no route (headless host, older build): stay silent */
})
.finally(() => clearTimeout(timer));
return () => {
alive = false;
clearTimeout(timer);
try {
if (ctrl) ctrl.abort();
} catch {
/* ignore */
}
};
}, []);
const lang = pickLang();
const t = TEXT[lang];
const acked = state ? readAck() === String(state.pluginVersion) : true;
const visible = !!state && !closed && state.needsSetup === true && !acked;
useEffect(() => {
if (!visible) return undefined;
const onKey = (event) => {
if (event && (event.key === "Escape" || event.key === "Esc")) {
writeAck(state.pluginVersion);
setClosed(true);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [visible, state]);
if (!visible) return null;
const dismiss = () => {
writeAck(state.pluginVersion);
setClosed(true);
};
const onCopy = () => {
copyText(String(state.setupCommand || "")).then((ok) => {
setCopyLabel(ok ? t.copied : t.copyFailed);
setTimeout(() => setCopyLabel(""), 2500);
});
};
const node = h(
"div",
{ style: maskStyle, role: "dialog", "aria-modal": "true", "aria-label": t.title },
h(
"div",
{ style: cardStyle },
h(
"div",
{ style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 } },
h(
"span",
{
style: {
fontSize: 11,
letterSpacing: "0.08em",
padding: "2px 8px",
borderRadius: 999,
background: "var(--dsw-alias-bg-secondary, rgba(255,255,255,0.08))",
color: "var(--dsw-alias-label-secondary, #a9a9a9)",
},
},
t.badge,
),
h(
"span",
{ style: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #a9a9a9)" } },
`dsh-plugin-rea ${state.pluginVersion || ""}`,
),
),
h("h2", { style: { margin: "0 0 6px", fontSize: 19, fontWeight: 700 } }, t.title),
h(
"p",
{ style: { margin: "0 0 4px", color: "var(--dsw-alias-label-secondary, #b6b6b6)" } },
leadLine(state, t),
),
h("p", { style: { margin: "0 0 10px" } }, t.body),
h(
"p",
{
style: {
margin: "0 0 6px",
padding: "8px 10px",
borderRadius: 8,
background: "var(--dsw-alias-bg-secondary, rgba(255,255,255,0.05))",
color: "var(--dsw-alias-label-secondary, #c2c2c2)",
fontSize: 13,
},
},
statusLine(state, t),
),
h("div", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #9a9a9a)" } }, t.commandLabel),
h("pre", { style: codeStyle }, h("code", null, String(state.setupCommand || ""))),
h(
"p",
{ style: { margin: "10px 0 8px", fontSize: 12.5, color: "var(--dsw-alias-label-secondary, #9a9a9a)" } },
t.hint,
),
h(
"ul",
{
style: {
margin: "0 0 16px",
paddingLeft: 18,
fontSize: 12.5,
color: "var(--dsw-alias-label-secondary, #9a9a9a)",
},
},
h("li", null, t.noteSmoke),
h("li", null, t.noteLock),
h("li", null, t.noteOverride),
),
h(
"div",
{ style: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" } },
h("button", { type: "button", style: primaryButton, onClick: onCopy }, copyLabel || t.copy),
h("button", { type: "button", style: buttonBase, onClick: dismiss }, t.done),
h("span", { style: { flex: 1 } }),
h("button", { type: "button", style: buttonBase, onClick: dismiss }, t.later),
),
),
);
try {
const dom = require("react-dom");
if (dom && typeof dom.createPortal === "function" && typeof document !== "undefined" && document.body) {
return dom.createPortal(node, document.body);
}
} catch {
/* no react-dom: the overlay still renders in place */
}
return node;
}
function apply(ctx) {
if (!ctx || !ctx.slots || typeof ctx.slots.register !== "function") return;
const mount = () =>
ctx.slots.register(
{ name: "shell.overlay", id: "rea-setup-guide", order: 70 },
() => h(Guide),
);
if (typeof ctx.slots.inject === "function") ctx.slots.inject("shell.overlay", mount);
else mount();
}
exports.name = name;
exports.inject = inject;
exports.apply = apply;
return module.exports;
},
});
+50 -24
View File
@@ -1,26 +1,36 @@
# 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.
#
# ---------------------------------------------------------------------------
# TWO VALUES ARE MACHINE-SPECIFIC — edit them for your machine:
# 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.
# * The home fallback mirrors Node's own `os.homedir()`: USERPROFILE, then
# HOMEDRIVE+HOMEPATH (the shape a service account gets), then HOME. An
# expression that checked only USERPROFILE and HOME produced `/.dsh/...` —
# the root of the current drive — for a service account, which is neither
# where setup mirrors the shim nor a path anything can read.
#
# 1. args[0] full path to the rea CLI entry file.
# Run: npm root -g
# then append: \rea-agents\scripts\rea.mjs
# (POSIX: <npm root -g>/rea-agents/scripts/rea.mjs)
#
# 2. env.GHIDRA_INSTALL_DIR
# the directory where Ghidra was extracted,
# e.g. D:\ghidra\ghidra_12.1.4_PUBLIC
# Leave it out only if you do not use Ghidra.
#
# `command: node` is resolved through PATH; point it at a full path (for
# example D:\nodejs\node.exe) if node is not on the PATH the harness sees.
# env.JAVA_HOME is optional — keep it when `java` is not on PATH.
# ---------------------------------------------------------------------------
# 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
@@ -28,11 +38,27 @@
config:
serverName: rea
transport: stdio
command: 'node'
command: !!js process.execPath
args:
- 'D:\dev\npm-global\node_modules\rea-agents\scripts\rea.mjs'
# 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.HOMEDRIVE || '') + (process.env.HOMEPATH || '')) || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
- mcp
env:
GHIDRA_INSTALL_DIR: 'D:\ghidra\ghidra_12.1.4_PUBLIC'
JAVA_HOME: 'F:\game\xbox'
# 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
# The Web GUI's one-shot setup guide. This row loads only the package entry
# (index.mjs), which serves a read-only GET /dsh-plugin-rea/guide-state and
# mounts nothing when the host has no webServer. It does not launch REA, does
# not provision, and never runs setup for the user — the guide only reports
# that the recorded bundle does not match the pin this package ships.
- id: rea-guide
name: 'dsh-plugin-rea'
+223
View File
@@ -0,0 +1,223 @@
# 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.8.0, Ghidra 12.1.4, Temurin JDK 25.0.4:
| Fact | Evidence |
| --- | --- |
| A row can launch REA with **no PATH `rea`** | `node <pkg>/scripts/rea.mjs mcp` answered the MCP `initialize` handshake (`serverInfo.name = rea`, version 6.8.0) and `rea doctor --json` reported 229 tools (measured again after the 6.8.0 pin; 6.7.0 reported 207). `healthy` is `false` on a bundle-only install: 6.8.0 also checks the host's own setup (Hopper, IDA registration, Kuna, `rea setup` skill/registrations), which provisioning deliberately never touches — the row does not require `healthy`, only a CLI that runs and a catalog it can read |
| `!!js` expressions are supported in this profile layer | the existing profile patch already used one; the loader evaluates them |
| A row-level `env:` block reaches the MCP child | `binary_session` reported `registrationEnvironment` = `GHIDRA_INSTALL_DIR`, `JAVA_HOME` |
| No fixed home/layout is promised | the disk layout differs per install; nothing may assume one |
| 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.
A fourth, deliberately passive piece sits beside the three: the **guide**
(`client.js` + `index.mjs` → `bin/rea-guide.mjs`). It exists because the setup
step is mandatory but invisible — a market install or update replaces the
package and stops there, and a skipped setup fails silently in both directions
(no tools on a first install, the old REA after an upgrade). The patch mounts one
extra row for the package entry, which serves a read-only
`GET /dsh-plugin-rea/guide-state`; the client half renders that verdict once per
plugin version and remembers the dismissal in `localStorage`. It is a notice:
it never spawns setup, never downloads, and never writes `bundle.json`. The row
is mounted only when the host has a `webServer`, so headless and ACP hosts load
the plugin exactly as before.
```
<dsh home>/plugins/rea-bundle/
bundle.json # machine-local truth: resolved paths + versions + hashes
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)
├─ mirrored copy differs from the installed plugin → re-exec that copy, once
├─ a working `rea` on PATH is at least as new as the bundle
│ → exec it (the 1.x contract)
├─ bundle.json present & components verify → exec the provisioned REA
├─ missing, auto-provision allowed → start setup detached, print
│ remediation, exit non-zero
└─ missing, auto-provision refused → print exact command, exit
```
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.
- **Launch mode (2.0.7).** 2.x exists to supply a runtime a machine lacks, so a
machine that already has one should not be provisioned. Before touching bundle
state the shim asks whether `rea` is on PATH and answers `--version`; if it
does, and its version is not older than `bundle.json`'s `versions.reaAgents`,
the row execs that install exactly as the 1.x row did. The probe is asynchronous
and bounded (8 s: a `.cmd` shim needs ~2.4 s), runs with
`REA_BUNDLE_DELEGATED=1` and `REA_BUNDLE_AUTO_PROVISION=0` so a candidate that
wraps this shim cannot recurse or start an 800 MB run, and kills its whole tree
on timeout. `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the choice; an
unorderable pair (a prerelease on either side) keeps the bundle.
- **Self-healing mirror (2.0.8).** The row launches a COPY, and a market upgrade
runs no setup, so without this the copy keeps executing the code it was made
from — invisibly, because it still works. The copy therefore compares its own
hash with `<mirror.json.pluginRoot>/bin/rea-shim.mjs` and re-execs that file
when they differ: one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`, and only from a
mirror (a checkout ships `cordis.patch.yml`, so it never hops into another
copy). `mirror.json` carries `shimSha256`, `pluginVersion` and `dshHome` —
the last one because the harness strips `DSH_*` from an MCP child, so the
duplicate-registration check would otherwise read `~/.dsh` on a machine with a
custom home. `--shim-status` reports `mirror.stale` instead of hopping.
- **3 (fetch).** Route order is deliberate and environment-dependent: direct
first, then the system proxy, and on Windows the .NET route ahead of both
because it follows the GitHub release-asset redirect that the Node routes
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.
+404
View File
@@ -0,0 +1,404 @@
# 宿主兼容性:DSH 0.2.1-rc.3 → 0.2.2-rc.1(2026-11-06)
`package.json` 的 `engines.dsh = ">=0.1.7-rc.2 <0.3.0-0"` 是市场判"兼容/未知"的唯一依据,
`scripts/host-compat.mjs` 的 `HOST_RELEASES` 是"本包适配过的 dsh 清单"。本轮把清单从 10 条补到 16 条,
并逐个核对新 release 有没有动本插件依赖的接口面。**结论:范围不用改,六个新 release 无一改动四个接口面;
清单补齐即可;另有两处与本次 release 无关、但需要 Lead 决策的旧问题(§4、§6)。**
---
## 0. 结论
| 项 | 结论 | 依据 |
|---|---|---|
| `engines.dsh` 要不要动 | **不动**。`0.2.2-rc.1` 仍在 `<0.3.0-0` 以内 | `node scripts/host-compat.mjs` 16 条全 `ok`,exit 0 |
| `HOST_RELEASES` | 10 → **16**(新增 6 条) | 6 个 tag 在 GitHub releases 与 npm 双向核实存在(§1) |
| floor 是否移动 | **不动**,仍是 `0.1.7-rc.2` | 没有更老版本被适配,也没有理由放弃它 |
| `OUTSIDE_HOSTS` | **不动**,仍 5 条 | 本期没有任何 0.3.x 发布,边界未变 |
| `satisfiesDeclaredRange` 窄语义 | **不动**,仍只认 `>=X <Y` | 放宽会与市场实现分叉 |
| 六个新 release 的接口面影响 | **全部无变化** | 逐条原文 + 本机运行时代码(§2) |
| 本机宿主版本 | **0.2.2-rc.1**(桌面版) | `app.asar` 内 `package.json`(§3) |
| Web 卡片在新宿主能否渲染 | **能** | `dsh-client-runtime` 是死条目但被静默跳过,`slots` 服务走的是宿主自身约定(§4) |
| 需要 Lead 改 `package.json` 的事项 | 2 条(可选清理 1 条、待决 1 条) | §6 |
---
## 1. 版本核实表
三个独立来源交叉:GitHub releases 页(`?page=1` / `?page=2`)、GitHub releases atom feed、npm registry。
- npm:`https://registry.npmjs.org/@deepseek-ai/dsh`(`versions` 42 条,`time` 给出每条的发布时间;**已发布版本在 npm 上不会被静默删除**,所以"在 `versions` 里"就是存在性的硬证据)
- GitHub 页:`https://github.com/deepseek-ai/deepseek-harness/releases?page=1` / `?page=2`(tag 形如 `dsh-v<ver>`)
- atom:`https://github.com/deepseek-ai/deepseek-harness/releases.atom`(只带最近 10 条,正好覆盖全部 6 个新版本,且**带 release body 全文**)
### 1.1 本轮新增的 6 个(用户截图里看到的)
| GitHub tag | npm 版本 | npm 发布(UTC) | GitHub release 发布(UTC) | 存在性 | 证据 |
|---|---|---|---|---|---|
| `dsh-v0.2.2-rc.1` | 0.2.2-rc.1 | 2026-11-04T09:20:32Z | 2026-11-04T13:07:14Z | **存在** | [npm](https://registry.npmjs.org/@deepseek-ai/dsh)、[releases](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.2-rc.1) |
| `dsh-v0.2.2-alpha.3` | 0.2.2-alpha.3 | 2026-11-03T07:48:26Z | 2026-11-03T08:27:26Z | **存在** | 同上 |
| `dsh-v0.2.1-rc.5` | 0.2.1-rc.5 | 2026-10-29T14:15:23Z | 2026-10-30T02:12:26Z | **存在** | 同上 |
| `dsh-v0.2.2-alpha.2` | 0.2.2-alpha.2 | 2026-10-27T19:10:28Z | 2026-10-28T07:00:42Z | **存在** | 同上 |
| `dsh-v0.2.1-rc.4` | 0.2.1-rc.4 | 2026-10-26T15:28:13Z | 2026-10-28T03:08:57Z | **存在** | 同上 |
| `dsh-v0.2.1-rc.3` | 0.2.1-rc.3 | 2026-10-23T07:54:30Z | 2026-10-23T15:26:39Z | **存在** | 同上 |
两个时间列不同是正常的:npm `time` 是 tarball 发布时刻,GitHub `<updated>` 是 release 页面发布时刻,通常相差数小时。
`0.2.2-rc.1` 是**当前最新**:npm `dist-tags.latest = 0.2.2-rc.1`、`next = 0.2.2-rc.1`、`alpha = 0.2.2-alpha.3`;
GitHub releases 第 1 页第一条就是它。截至 2026-11-06 没有更新的发布。
### 1.2 清单里原有的 10 个
| npm 版本 | npm 发布(UTC) | GitHub tag | 证据 |
|---|---|---|---|
| 0.2.1-rc.2 | 2026-10-17T08:36:31Z | `dsh-v0.2.1-rc.2`(page 1) | npm `versions` + releases page 1 |
| 0.2.1-rc.1 | 2026-10-17T03:17:24Z | `dsh-v0.2.1-rc.1`(page 1) | 同上 |
| 0.2.1-alpha.5 | 2026-10-16T12:31:49Z | `dsh-v0.2.1-alpha.5`(page 1) | 同上 |
| 0.2.1-alpha.4 | 2026-10-15T13:08:08Z | `dsh-v0.2.1-alpha.4`(page 1) | 同上 |
| 0.2.1-alpha.3 | 2026-10-14T12:35:33Z | `dsh-v0.2.1-alpha.3`(page 2) | npm `versions` + releases page 2 |
| 0.2.1-alpha.2 | 2026-10-09T08:18:00Z | `dsh-v0.2.1-alpha.2`(page 2) | 同上 |
| 0.2.1-alpha.1 | 2026-10-03T04:53:22Z | `dsh-v0.2.1-alpha.1`(page 2) | 同上 |
| 0.2.0-rc.2 | 2026-09-29T09:56:27Z | `dsh-v0.2.0-rc.2`(page 2) | 同上 |
| 0.2.0-rc.1 | 2026-09-28T12:34:03Z | `dsh-v0.2.0-rc.1`(page 2) | 同上 |
| 0.1.7-rc.2 | 2026-09-24T14:18:11Z | `dsh-v0.1.7-rc.2`(page 2) | 同上 |
**10 条全部真实存在,没有一条被撤下**:全部出现在 npm `versions` 里(撤下的版本会从 `versions` 消失、只在 `time` 留痕),
且全部在 GitHub releases 页以 tag 出现。
`OUTSIDE_HOSTS` 里的 `0.1.7-rc.1`、`0.1.7-alpha.2` 也是真实 tag(page 2 可见)——它们是**真实存在但我们刻意不覆盖**的版本,
不是虚构的对照物。
### 1.3 排序说明(会误导下一个人,所以写进代码注释)
清单按**发布日**排序(GitHub 页面顺序),不是按 semver 优先级降序。两者在这一个位置冲突:
```
0.2.1-rc.5 发布于 2026-10-29/30
0.2.2-alpha.2 发布于 2026-10-27/28
```
按日期 `rc.5` 在前,按 semver `0.2.2-alpha.2`(更高)在前。清单取**日期序**,因为那是用户实际升级经过的顺序。
`scripts/host-compat.test.mjs` 专门 pin 了"全表 semver 降序、只有这一处例外",所以谁想把它"修正"成 semver 序都会红。
---
## 2. 六个新 release 对本插件的接口面影响
先把"接口面"从代码里问出来,而不是凭印象列。本插件实际依赖的只有五处:
| # | 接口面 | 出处 |
|---|---|---|
| A | `cordis.patch.yml` 的 Loader YAML 方言:`insert` 行、`id` 定向、`!!js` 表达式 | `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它) |
| B | MCP 行配置 schema:`name: '@deepseek-ai/dsh-mcp-client'`、`config.serverName: rea`、`config.transport: stdio`、`command`/`args`/`env`/`failOnStartupError` | `cordis.patch.yml` |
| C | 宿主插件 API:`export const name`、`export function apply(ctx)`、`ctx.inject(['webServer'], cb)`、`ctx.effect(disposer)`、`webServer.register({ kind: 'exact', path, handler })` | `index.mjs` |
| D | 客户端 bundle 契约:`window.__ModuleLoader__.load({ id, factory })`、`factory(require)` 里 `require('react')` / `require('react-dom')`、Cordis 服务级 `inject = ["slots"]`、`ctx.slots.inject('shell.overlay', …)` | `client.js` |
| E | 市场侧 `engines.dsh` 判定:`satisfiesRange(host, declared, { includePrerelease: true })` | 本机 `[email protected]` 的 `lib/discovery-compatibility.js:74`(见下) |
**E 的现场取证**(`D:\dsh-home\profiles\desktop\node_modules\dshmarket\lib\discovery-compatibility.js`):
```js
function rangeResult(hostVersion, declared) {
return satisfiesRange(hostVersion, declared, { includePrerelease: true });
}
```
与 `host-compat.mjs` 文件头写的语义完全一致,包括"市场读**两处**申报位置、顶层 `engines.dsh` 优先"
(同文件 `manifestFacts` 里的 `engines.dsh` / `dsh.engines.dsh` 双读,注释指向 issue #577)。
### 2.1 `dsh-v0.2.2-rc.1`(2026-11-04)—— 唯一需要实证的一个
相关原文([release](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.2-rc.1)):
> * Update to Cordis Core 4.1.0, Loader 1.1.0, and Include 1.1.0, unifying configuration access, entry changes, and persistence behavior. by @shigma, @imccyu
> * Provide unified Cordis Fiber access to raw configuration, candidate resolution, and effective configuration, used consistently by settings and hot reload. by @shigma
> * Raise the minimum supported Node.js version to 24.9. Upgrade older Node.js runtimes before upgrading DSH. by @tianyicui
> * Activate tools conditionally per step with consistent model declarations, PTC bindings, and execution admission. **Plugins must migrate tools.resolve/executable and prompt-assembly integration.** by @LegGasai
> * Make Session Projection registration asynchronous, with plugins retaining a handle at startup. by @kermanx, @Magolor
> * Rename Custom plugin markets to Custom plugin indexes. Discover reads user-added index files, and index generation excludes private versions, tags, and default-branch packages. by @turtle2099, @turtle1999
> * Official bundle lists are now exported by `@deepseek-ai/dsh-official-bundles`; custom integrations must update imports from app-boot. by @turtle2099
> * Show rich-text plugin details in the plugin manager and improve page layout and navigation. by @turtle1999, @turtle2099
> * Support updating third-party plugin packages and attempt to restore the previous version if hot updates fail. by @turtle1999, @turtle2099
逐条判定:
| 条目 | 是否触及本插件 | 理由 |
|---|---|---|
| Cordis Core 4.0.0-rc.11 → **4.1.0**、Loader 1.0.0-rc.8 → **1.1.0**、Include → 1.1.0 | **触及(接口面 A)** | 本插件的 `cordis.patch.yml` 就是 Loader 的方言。release body 未宣告破坏性变更;**实测见下** |
| Fiber 统一配置访问 | 否 | 影响经 Fiber 读配置的插件;`index.mjs` 不读 `ctx` 配置,两行的 `config` 由 Loader 直接交给被插入的插件 |
| Node 下限 24.9 | 否(满足) | 本机宿主自带 node **24.21.0 ≥ 24.9** |
| `tools.resolve/executable` 迁移 | 否 | 本插件不注册原生 agent 工具,只有一条 MCP 行 + 一个 `webServer` 只读路由 |
| Session Projection 异步化 | 否 | 全仓 grep 无 `SessionProjection` / `sessionProjection` |
| 市场 markets → indexes、索引生成排除 private/tags/default-branch | 否(不影响判定语义) | 影响索引**生成**流程;`engines.dsh` 判定逻辑在 1.66.39 里仍是上面那行 `includePrerelease: true` |
| `app-boot` → `@deepseek-ai/dsh-official-bundles` | 否 | 本插件不 import `app-boot` |
| plugin manager 富文本 / 第三方包可更新 | 否 | 纯 UI + 更新流程 |
**接口面 A 的实测**(本会话就跑在这台 0.2.2-rc.1 宿主的 Web GUI 上):
```
GET http://127.0.0.1:31896/dsh-plugin-rea/guide-state
→ HTTP 200
{"pluginVersion":"2.0.13","pinnedRea":"6.8.0","installedRea":"6.8.0",
"provisionedAt":"2026-10-26T13:15:52.343Z","state":"ok","needsSetup":false, …}
```
这个 200 同时证明了三件事:`cordis.patch.yml` 的 `insert` 行被 4.1.0 的 Loader 接受并执行了(否则 `index.mjs` 不会 `apply`)、
`ctx.inject(['webServer'])` 在 4.1.0 上仍按预期解析、`webServer.register({kind:'exact'})` 的签名没变。
另外 `mcp__rea__*` 工具在本会话可用,说明接口面 B(`serverName: rea` + `transport: stdio`)在 0.2.2-rc.1 上正常工作。
> 未验证补记:Cordis 4.1.0 对 `!!js` 表达式求值作用域是否有变 —— 增量结论只覆盖**本插件实际用到的表达式形态**
> (`process.execPath`、`process.env.*` 字符串拼接、无 `path`/`require`),没做语法面全覆盖。
### 2.2 `dsh-v0.2.1-rc.4`(2026-10-26)
> * Fix diagnostics leaking into stdout in ACP mode. by @LegGasai, @tianyicui
接口面:否。这是宿主自身 ACP 模式的 stdout 洁净度修复,不改 MCP 行 schema。**值得记一笔**:它说明宿主在意
"诊断信息不能混进协议通道",与本插件 MCP 子进程只允许 stdout 输出协议帧的约定同向,不冲突。
### 2.3 `dsh-v0.2.2-alpha.2`(2026-10-27)
与 rc.1 同批 chores 的前身,条目基本一致(Node 24.9、Session Projection 异步化、Cordis Timer 同步、
`app-boot` → `dsh-official-bundles`、市场改名 indexes、`tools` 按步条件激活、PTC/birpc、SSH 协议 2→3)。逐条判定同 §2.1 表。
两条本版特有、需要单独说明的:
> * Synchronize Cordis Timer with its upstream implementation; **custom plugins must adapt to changed return values**. by @shigma
> * Report caller-cancelled run_code calls in PTC Mode as ABORTED rather than CODE_RUN_FAILED. SDK and Session consumers must update cancellation handling. by @kermanx
- Cordis Timer:本插件用的是**全局 `setTimeout`**,不是 `ctx.setTimeout` / Cordis Timer 服务 —— `client.js:223`、`client.js:283`、
`bin/rea-shim.mjs:756` 三处全部是 Node/浏览器的 `setTimeout`。不适用。
- PTC / `run_code`:本插件不使用 PTC。不适用。
### 2.4 `dsh-v0.2.1-rc.5`(2026-10-29)
只有一条:Improve how thinking content is displayed in conversations. by @LegGasai —— 纯展示层,接口面:否。
### 2.5 `dsh-v0.2.2-alpha.3`(2026-11-03)
新增/修复集中在统一右键与键盘菜单、文件预览目录树、变更请求与工作树、移动端布局、Trajectory 子调用折叠、
pi-ai 图片数量上限卸载、Markdown 链接行号、历史会话加载。chores 侧两条可能沾边的:
> * Allow plugins to supply dynamic pi-ai headers using Session identity and request purpose, rejecting invalid headers before dispatch. by @LegGasai
> * Allow plugins to supply navigation icons for their settings pages, restoring the default icon when unloaded. by @Magolor
两者都是**给插件的可选新增能力**(pi-ai 请求头、设置页导航图标),不注册就不用,不构成破坏。接口面:否。
### 2.6 `dsh-v0.2.1-rc.3`(2026-10-23)
> * Add More menus to the left sidebar, conversation views, and right sidebar for browsing and creating plugins. by @Yifffan, @imccyu
> * Fix New Session losing an unsent draft after switching sessions. by @imccyu
> * The DeepSeek API key adapter now updates its model list the same way as account sign-in. by @LegGasai
第一条沾"浏览和创建插件",但落在 UI 菜单层;不触及 `engines.dsh` 判定、不触及 `dsh.client.inject`。接口面:否。
### 2.7 小结
| release | A patch 方言 | B MCP 行 | C 宿主插件 API | D 客户端契约 | E 市场判定 |
|---|---|---|---|---|---|
| 0.2.2-rc.1 | 可能有变(Cordis 4.1.0)→ **实测通过** | 无变化 | 无变化(实测通过) | 无变化 | 无变化 |
| 0.2.2-alpha.3 | 无变化 | 无变化 | 无变化 | 无变化 | 无变化 |
| 0.2.1-rc.5 | 无变化 | 无变化 | 无变化 | 无变化 | 无变化 |
| 0.2.2-alpha.2 | 见 rc.1 | 无变化 | 无变化 | 无变化 | 无变化 |
| 0.2.1-rc.4 | 无变化 | 无变化 | 无变化 | 无变化 | 无变化 |
| 0.2.1-rc.3 | 无变化 | 无变化 | 无变化 | 无变化 | 无变化 |
---
## 3. 本机宿主实测
| 项 | 值 | 来源 |
|---|---|---|
| 桌面宿主版本 | **`@deepseek-ai/[email protected]`** | `D:\deepseekharnes\新建文件夹\resources\app.asar` 内 `package.json`(asar header → `files.package.json`,size 882,offset 207407341) |
| 运行时包 | `@deepseek-ai/[email protected]` | 同 asar 的 `dsh/package.json` |
| `hostProtocolVersion` | 4 | 同 asar 的 `dsh/desktop-runtime.json` |
| 宿主自带 Node | **v24.21.0** | `resources\runtime\primary-runtime\dependencies\node\bin\node.exe --version`;`runtime\versions.json` 里 `"node": "24.21.0"` |
| 宿主自带 pnpm | 11.28.5 | `runtime\versions.json` |
| `dsh-home` 运行时副本 | v24.21.0 | `D:\dsh-home\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe --version` |
| PATH 上的 node | **v24.22.1**(`F:\jiuguan\node.exe`) | `node --version` / `where.exe node` |
| npm-global CLI | `@deepseek-ai/[email protected]`,`dsh --version` → `0.1.7-rc.2` | `D:\dev\npm-global\node_modules\@deepseek-ai\dsh\package.json` |
| 已安装插件副本 | `[email protected]`,`engines.dsh` 同串 | `D:\dsh-home\profiles\desktop\node_modules\dsh-plugin-rea\package.json` |
| 已装的 REA | 6.8.0(provisionedAt 2026-10-26T13:15:52Z) | `D:\dsh-home\plugins\rea-bundle\bundle.json` + guide-state 路由 |
| 当前会话宿主 | 0.2.2-rc.1(本会话的 GUI 与 `mcp__rea__*` 工具都来自它) | 运行时上下文 + `GET /` 端口 31896 |
**注意两个容易混淆的"dsh 版本"**:`dsh --version` 报的 `0.1.7-rc.2` 是 npm 全局装的 **CLI**,
不是跑本会话的桌面宿主(0.2.2-rc.1)。用 CLI 的版本号判断本机宿主会得出完全相反的结论。
### 3.1 同机历史 asar 对照(用来定位"某个包什么时候消失")
| asar 路径 | 版本 | `@deepseek-ai/*` 包数 | 有 `dsh-client-ui-slots` 目录 | 有 `dsh-mcp-client` | 有 `dsh-official-bundles` |
|---|---|---|---|---|---|
| `D:\deepseekharnes\deepseekharnes\resources\app.asar` | 0.1.7-rc.2 | 284 | 有 | 有 | 无 |
| `D:\dsh-snapshot\20261106-2125\install\resources\app.asar` | 0.2.0-rc.2 | 289 | 有 | 有 | 无 |
| `D:\kimicode\修复\dsh-backup-20261106\program-before-repair\resources\app.asar` | 0.2.2-rc.1 | 331 | **无** | 有 | **有** |
| `D:\deepseekharnes\新建文件夹\resources\app.asar` | 0.2.2-rc.1 | 331 | **无** | 有 | **有** |
本机**没有** 0.2.1-rc.3/rc.4/rc.5、0.2.2-alpha.2/alpha.3 的桌面构建,所以"哪个版本把 `dsh-client-ui-slots` 从
`app.asar` 的 `node_modules` 里移走"**定位不到具体版本**。但 §4 证明这不需要定位——它不是接口面缺失。
---
## 4. `dsh.client.inject` 专项核实(Lead 的核心问题)
`package.json` 声明:
```json
"dsh": { "client": { "platform": "web", "inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slots" ] } }
```
### 4.1 四个包在 0.2.2-rc.1 宿主里到底在不在
方法:解 asar header,遍历全部 3316 个目录 / 22102 个文件;再对整份 213 MB 做字节扫描定位每个名字的所有出现。
证据路径全部在 `D:\deepseekharnes\新建文件夹\resources\app.asar` 内。
| 包名 | 有 `node_modules/@deepseek-ai/<name>/` 目录? | 全 asar 出现次数 | 结论 |
|---|---|---|---|
| `dsh-client-connection` | **有**(`dsh/node_modules/@deepseek-ai/dsh-client-connection`,含 `lib/`、`locale/`) | 1 | **存在** |
| `dsh-client-locale` | **有**(同上结构) | 1 | **存在** |
| `dsh-client-ui-slots` | **无目录** | **89**(分布在 87 个文件的 `package.json` / `README` / `lib/client.js`,以及 `dsh-web-frontend/dist/assets/index-BgUyXZcc.js`) | **以"被引用"形式存在**:宿主自己的 8+ 个客户端包把它写进 `dsh.client.inject`,它被编进 Web 前端 bundle,不以独立包目录形式落盘 |
| `dsh-client-runtime` | **无目录** | **0(整份 asar 一次都没出现)** | **不存在**。npm 上最后一次发布是 `0.1.1-rc.2`(2026-08-21),在 0.1.7-rc.2 及之后的所有桌面宿主里都找不到它 |
`dsh-client-runtime` 的现实身份:它是 **build-time devDependency**。本机 `[email protected]` 的 `devDependencies` 里就有
`"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7"`,而同一个包的 `dsh.client.inject` 只列 `dsh-client-locale`、
`dsh-client-ui-settings`、`dsh-client-ui-theme` 三个**真实宿主包**。也就是说:我们的 `inject` 把构建期依赖误写成了运行期注入项。
### 4.2 宿主拿 `inject` 干什么,目标不存在会怎样
消费方是宿主包 `@deepseek-ai/dsh-client-modules`(node 半边扫 Loader 条目里的 `dsh.client`,
把 `window.__DSH_BOOT__` 的模块图组出来;client 半边按图装载)。三段源码:
**(1)校验**(`lib/index.js` 的 `parseDshClient`,`lib/client.js` 同款)——只校验类型,不校验存在:
```js
const inject = optionalStringArray(pkgName, "dsh.client.inject", decl.inject);
if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
throw new Error(`client-modules: ${subject} ${field} must be a string array`);
```
**(2)装载**(`lib/client.js:791-794`)——**这就是答案**:
```js
for (const packageName of row.inject) {
const dependency = this.graphRows.get(packageName);
if (dependency !== void 0) await this.arriveDependency(row.id, dependency, [], visited);
}
```
`graphRows.get()` 取不到就**什么都不做**:不抛错、不告警、不写日志。**未知 inject 项 = 静默跳过。**
**(3)反证:宿主自己就在这么用。** 统计宿主 331 个 `@deepseek-ai` 包的 `dsh.client.inject`,共 412 条边,
其中有一类目标**根本没有包目录**,却被核心面板引用:
```
dsh-client-ui-primitives <- 6 个消费者:
dsh-client-ui-jobs, dsh-client-ui-schedule, dsh-client-ui-subagent,
dsh-experimental-client-ui-agent-team, …
```
这 6 个面板在 0.2.2-rc.1 上正常工作。所以"`inject` 目标没有独立包目录"是**宿主自身也在用的形态**,不是错误配置。
### 4.3 本插件客户端代码真正要的是什么
`client.js` 的 shape:
```js
window.__ModuleLoader__.load({
id: "dsh-plugin-rea",
factory: (require) => {
const react = require("react");
const reactDom = require("react-dom"); // 只在需要时
const inject = ["slots"]; // ← Cordis 服务名,不是包名
…
exports.inject = inject;
```
- `require(...)` 只要求 `react` / `react-dom`,**四个 inject 包一个都没 require**。所以 `dsh-client-runtime`
即使不存在,也不会在 `factory` 里炸。
- 真正决定"卡片能不能挂上去"的是 Cordis **服务级** `inject = ["slots"]`。这是宿主自己的约定,不是我们发明的:
在 0.2.2-rc.1 里,`dsh-client-resources`、`dsh-client-ui-brand-official`、`dsh-client-ui-action-cards`、
`dsh-client-ui-attachment`、`dsh-client-ui-conversation` 等 8+ 个核心客户端包写的都是 `inject = ["slots"]`(或含 `slots`)。
- 挂载点是 `ctx.slots.inject("shell.overlay", mount)`(`client.js:391`),并带 `typeof ctx.slots.inject === "function"`
的能力守卫——服务不在时不炸,只是不挂。
### 4.4 最终结论
> **能渲染。** 本插件的 Web 卡片(一次性 setup 引导)在 `0.2.2-rc.1` 上可以正常渲染,不需要为此改 `package.json`。
依据链:
1. `dsh.client.inject` 里的未知项被宿主**静默跳过**(`dsh-client-modules/lib/client.js:791-794` 源码);
2. 本插件的 `factory` 不 `require` 那四个包,缺任何一个都不会在 factory 内失败;
3. 卡片真正的闸门是 Cordis 服务 `slots`,宿主 8+ 核心客户端包用的是同一条约定,0.2.2-rc.1 仍然成立;
4. `dsh-client-ui-slots` 在宿主里"有引用、无目录"与 `dsh-client-ui-primitives` 完全同形,后者支撑着 6 个正常工作的核心面板;
5. 宿主日志(`…\@deepseek-ai\dsh-desktop\logs\crash-2026-11-06T*`)中没有任何 `client-modules` / `inject` 相关记录;
6. 旁证:同一宿主的 node 半边(`index.mjs`)已被证明在跑(`GET /dsh-plugin-rea/guide-state` → 200)。
### 4.5 交 Lead 的 `package.json` 待办(我不动 `package.json`)
| 优先级 | 改动 | 理由 | 不改的后果 |
|---|---|---|---|
| 低(清理) | `dsh.client.inject` 删掉 `"@deepseek-ai/dsh-client-runtime"` | 该包最后一次发布 0.1.1-rc.2(2026-08-21),早于本插件声明的整个 `>=0.1.7-rc.2` 区间;它是构建期 devDependency,被误写成了运行期注入项 | 无功能后果,但会误导后续维护者,并在 `dsh.client.external`/顺序敏感的场景下留一个永假的依赖边 |
留 `@deepseek-ai/dsh-client-connection` / `-locale` / `-ui-slots` 三个:前两个有实体目录,第三个与宿主自身核心包的用法同形。
---
## 5. 本次改动清单
| 文件 | 改动 |
|---|---|
| `scripts/host-compat.mjs` | `HOST_RELEASES` 10 → 16 条,新增 `0.2.2-rc.1` / `0.2.2-alpha.3` / `0.2.1-rc.5` / `0.2.2-alpha.2` / `0.2.1-rc.4` / `0.2.1-rc.3`(发布日序,最新在前);条目上方注释补"按发布日而非 semver 序"及唯一一处倒序;文件头补 "FLOOR AND CEILING" 段,记录 floor 未动、ceiling 未动。`OUTSIDE_HOSTS`、`satisfiesDeclaredRange`、`parseVersion`、`compareVersions`、`checkHostCoverage` **全部未动** |
| `scripts/host-compat.test.mjs` | **新增**,25 条测试(正反两面 + 三个负控) |
| `scripts/setup.test.mjs` | 仅 lead 授权的一处:`test("the release list this gate checks is the published one")` 里的 10 条字面量 → 16 条,附一句"不要改成 semver 序"的注释。其他区域未碰 |
| `docs/host-compat-2026-11-06.md` | **新增**(本文件) |
验证:
```
$ node scripts/host-compat.mjs
engines.dsh = >=0.1.7-rc.2 <0.3.0-0
ok 0.2.2-rc.1
…(16 条全 ok)…
ok 0.3.0 (must stay outside)
…(5 条全 ok)…
host range ok: 16 releases covered, 5 correctly outside
exit 0
$ node --test scripts/host-compat.test.mjs → tests 25, pass 25, fail 0
$ node --test ./scripts/setup.test.mjs → tests 88, pass 85, skipped 3, fail 0
```
---
## 6. 未覆盖 / 未验证(诚实清单)
1. **0.2.1-rc.3 / rc.4 / rc.5、0.2.2-alpha.2 / alpha.3 的桌面构建没有在本机实测过。** 本机只有 0.2.2-rc.1 的
`app.asar` 和两份更老的(0.1.7-rc.2、0.2.0-rc.2)。这五个版本的"接口面无变化"是**据 release body 原文 + 0.2.2-rc.1
运行时代码推出来的**,不是逐版本跑出来的。要变成"已验证"需要逐个装一遍。
2. **GitHub REST API 两次不可用**:第一次 `504`(网关超时),第二次 `403`(`API rate limit exceeded for 39.144.92.96`)。
版本、日期、release body 因此取自 releases HTML 页 + atom feed + npm registry 三处交叉,没有用 API。
3. **release body 只读了最近 10 条**(atom feed 的上限)。这正好覆盖全部 6 个新版本 + 原有 4 条,但 0.2.1-alpha.3 及更早的
原文没有逐条读(它们不是本轮新增,且 `HOST_RELEASES` 里早已在册)。
4. **中文段落是乱码来源**:atom feed 是 UTF-8,PowerShell `Get-Content` 按 ANSI 读,中文全部成 mojibake。
本文件引用的原文**一律取英文段落**,没有从乱码回推中文。
5. **Web GUI 的视觉验证没做**:`GET http://127.0.0.1:31896/` 返回 `401 dsh web authentication required`,
拿不到 `window.__DSH_BOOT__` 的运行时模块图(需要 `dsh web` 打印的 token)。§4.4 的"能渲染"结论来自
源码路径 + 服务名约定 + 日志无错,**不是截图**。要补的话:拿到 token 后取 `GET /` 的 boot graph,
确认存在 `id: "dsh-plugin-rea"` 的 row 且 `inject` 里那两条缺失项被跳过。
6. **`dsh-client-ui-slots` 到底哪个版本从 `app.asar/node_modules` 里消失,定位不到**(本机没有 0.2.1-rc.x 的桌面 asar)。
但这不影响结论:它在 0.2.2-rc.1 上是"有引用无目录",与宿主自身的 `dsh-client-ui-primitives` 同形。
7. **`@deepseek-ai/dsh-client-ui-primitives` 这类"无目录但被注入"的包在 Loader 侧如何解析,没有读到实现**。
只证明了结果(引用它们的 6 个核心面板正常工作)。
8. **`engines.node` 与宿主自带 node 的矛盾未决**:`package.json` 声明 `^22.23.4 || ^24.22.1 || >=26.12.1`,
而宿主自带 node 是 **24.21.0**(低于 24.22.1)。实际运行没出事,因为:
`cordis.patch.yml` 的 `command: process.execPath` 只用来起 `bin/rea-shim.mjs`(跑在宿主 24.21.0 上),
而 shim 随后 `spawn(rea.node, …)`(`bin/rea-shim.mjs:1386`),`rea.node` 取自 `bundle.json` 的
`paths.node = F:\jiuguan\node.exe` = **24.22.1** —— 真正跑 REA 的是满足声明的那个 node。
**未验证**的是:市场/安装前置校验是否会拿 `engines.node` 跟宿主自带 node 比。如果会,这条比 `dsh.client.inject`
更可能挡住安装。**需要 Lead 决策**(放宽下限到 `^24.21.0`,或维持现状并要求宿主升级),我不动 `package.json`。
9. **`0.3.0` 尚未发布**:`OUTSIDE_HOSTS` 里三条 `0.3.0*` 是防线,不是已存在的版本,无法核实。
10. **`deriveHostCompatibility` 的完整判定链没有逐条验证**:只核对了 `rangeResult` 那一行
(`includePrerelease: true`)与双读申报位置的事实。它内部对 peer 依赖、隐式上限的处理(`classifyPeer`、
`crossesImplicitCeiling`)没有展开 —— 本插件不声明 `peerDependencies`,不进入那条路径。
+6 -3
View File
@@ -10,11 +10,14 @@
# - 一个 PR 最多 3 条。
#
# 若已发布 Release tarball,可取消下面 tarball 行的注释(资产名必须不带版本号)。
# 本仓库的 Release 一直附带 dsh-plugin-rea.tgz(资产名不带版本号,永远指向最新 Release),故该行保持启用。
# 注意:脚本里 npm 映射优先于 tarball(installTargetFor:有 npm 名就走 npm),
# 所以这行只是 npm 不可用时的兜底,不会改变市场默认的安装方式。
url: https://github.com/czcdr/dsh-plugin-rea
name: czcdr/dsh-plugin-rea
category: tools
description:
en: 'Third-party adapter that registers the local REA (Reverse Engineer Anything) MCP server as native DeepSeek Harness tools, adding mcp__rea__* tools for binary, APK, web and runtime analysis through Ghidra.'
zh: '第三方适配插件:把本机 REA(Reverse Engineer Anything)的 MCP 服务器注册成 DeepSeek Harness 原生工具,通过 Ghidra 提供 mcp__rea__* 系列二进制、APK、网页与运行时分析能力。'
# tarball: https://github.com/czcdr/dsh-plugin-rea/releases/latest/download/dsh-plugin-rea.tgz
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
+200
View File
@@ -0,0 +1,200 @@
# 更新审查:为什么现在不动这些钉子(2026-10-24)
一次针对“能不能把运行时升到最新”的核查。结论写在最前面,证据按下表逐条可复现;
最后是重开这个决定的条件,以及本轮**没有**做的事。
## 0. 结论
| 组件 | 上游最新 | 本机/钉子 | 能否升 | 依据 |
|---|---|---|---|---|
| REA(`rea-agents`) | 6.7.0 | 6.7.0 | 已是最新 | npm `dist-tags.latest = 6.7.0` |
| Node | 24.22.1 | 24.22.1 | 已是最新 | `nodejs.org/dist/index.json`:24 线最高 24.22.1(2026-10-20) |
| apktool | 3.0.3 | 3.0.3 | 已是最新 | GitHub latest = v3.0.3(2026-07-20),jar 15,478,013 B / `dbf930b0…` 一致 |
| platform-tools | 滚动 | 钉了当前字节 | 无需动 | 下载今日 `platform-tools-latest-windows.zip`:8,044,989 B / `45f4d631…` 与钉子逐字节一致 |
| Ghidra | **12.2**(2026-10-22) | 12.1.4 | **不能** | REA 只接受 12.1.x,12.2 判 `unsupported` |
| jadx 桥接 | **0.8.0**(2026-10-19) | 0.7.1 | **不能** | REA 硬校验引擎自报版本必须等于 0.7.1 |
| JDK | 21.0.13+9 / 25.0.5+8 | 21.0.12.1(vendor)/ Corretto 25.0.5(运行) | **Windows 上不能** | 两个 release 都没有任何 Windows 资产 |
**净结论:本轮不移动任何 pin。** 能升的两项会当场打掉整组工具;JDK 在 win32-x64 上没有可指的构建;
其余三项已是上游最新。现有组合实测 207 个工具全绿(见 §3)。
## 1. Ghidra 12.2:被 REA 的发布线挡住
REA 运行时的策略常量:
```
dist/ghidra/GhidraReleasePolicy.js:2 export const SUPPORTED_GHIDRA_VERSION = "12.1.4";
dist/ghidra/GhidraReleasePolicy.js:13 if (raw === SUPPORTED_GHIDRA_VERSION) → verified
else version.major/minor 相同 → compatible
else → unsupported
dist/ghidra/GhidraReleasePolicy.js:54 ghidraReleaseAccepted = status === "verified" || "compatible"
```
把该模块直接加载执行的结果:
```
12.1.4 → verified
12.1.5 → compatible ← 若上游发 12.1.5,会自动放行
12.2 → unsupported ← 12.2.0 同样
12.0.4 → unsupported
```
`GhidraInstallation.js` 在 `:515` 用 `ghidraReleaseAccepted(raw)` 决定 `passed`,`:107/:135-145` 一旦
有 failed check 就返回 `unavailable`。也就是说升 12.2 之后:provider 直接不可用,
`analyze_function` / `procedure_pseudo_code` / `list_segments` / `search_bytes` 等整组消失——不是降级。
插件的 `check-updates.ps1:228` 也钉着 `^12\.1\.`,会同步报“REA 当前要求 Ghidra 12.1.x”。
**代价**:Ghidra 12.1.4 的四个 CVE 继续留在链上(见 §4)。
## 2. jadx 桥接 0.8.0:一条版本等值断言
REA 对 jar 的准入分三层,前两层 0.8.0 都能过,第三层过不去:
```
dist/android/JadxConfiguration.js:224-241 要求 jar 内含 5 个桥接类(0.8.0 全部存在)
dist/android/JadxSession.js:55-57 serverInfo 必须是 rea-jadx-bridge/4(由 REA 自己的桥提供,不在 jar 里)
dist/android/JadxSession.js:59-62 const runtime = await tools.json("rea_jvm_status", {});
if (runtime.engine_reported_version !== JADX_RELEASE.version) throw
`Expected engine 0.7.1; JAR reported …`
dist/android/JadxRelease.js:3 version: "0.7.1"
dist/android/JadxRelease.js:5 sha256: 6e5eacf5…ff75(仅用于 source_revision,不参与准入)
```
实测两个 jar 的 `com/atxx/jhmcp/BuildInfo.class`(`javap -v`,`VERSION` 是 `ConstantValue`,
只有 `-c` 看不到赋值):
```
0.7.1-all.jar → ConstantValue: String 0.7.1
0.8.0-all.jar → ConstantValue: String 0.8.0
```
失败点在 `load_apk`(`JadxSession.js:64`)之前,即 JVM 起 + MCP initialize + 一次工具往返之后,
抛出的是明确错误,不是静默不可用。
另一条有用的事实:两个 jar 只有 `BuildInfo` 与 `JadxSession` 两个类字节不同,
`SessionHolder`、`jadx/api/JadxDecompiler`、`gson/Gson`、MCP `Server` 逐字节相同——
所以坏的纯粹是那一条版本断言,不是 ABI 或编译不兼容。**只要 REA 放开这个常量,0.8.0 是安全的。**
## 3. JDK:上游有新版,Windows 没有构建
`adoptium/temurin21-binaries` 的 `jdk-21.0.13+9`(2026-10-22T08:58Z)共 204 个资产,
**没有任何 `windows` 资产**;`temurin25-binaries` 的 `jdk-25.0.5+8`(147 个资产)同样没有。
| manifest 钉的平台 | 21.0.13+9 是否存在 |
|---|---|
| win32-x64 | **缺失**(`OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip` 不存在) |
| darwin-x64 | `OpenJDK21U-jdk_x64_mac_hotspot_21.0.13_9.tar.gz` 193,957,782 B |
| darwin-arm64 | `OpenJDK21U-jdk_aarch64_mac_hotspot_21.0.13_9.tar.gz` 199,754,598 B |
| linux-x64 | `OpenJDK21U-jdk_x64_linux_hotspot_21.0.13_9.tar.gz` 207,858,352 B |
| linux-arm64 | `OpenJDK21U-jdk_aarch64_linux_hotspot_21.0.13_9.tar.gz` 205,995,218 B |
顺带:`jdk-25.0.5+8` 连 `x64_mac` 的 JDK 资产都没有,darwin-x64 也跟不了 25.x 线。
所以“把 manifest 的 JDK 钉到 21.0.13”这件事在 win32-x64 上**无法执行**;只改另外 4 个平台会制造
平台间版本分裂,而 Windows(本插件的主平台)仍是旧信任库。
REA 侧对 JDK 没有版本钉子:Ghidra 12.1.4 的 `application.properties` 是 `application.java.min=21`、
`max=`(空=无上限),接受逻辑是 `major >= min && (max 为空 || major <= max)`
(`dist/ghidra/GhidraInstallationPolicy.js:74-76`),所以 21.0.13 与今天的 25.0.5 都能过。
`SUPPORTED_GHIDRA_JAVA_MAJOR = 21` 只是缺属性时的兜底常量,不是“必须等于 21”。
## 4. 风险与缓解
**Ghidra 12.1.4 的四个 CVE(NVD 实测编号与分数)**
| CVE | 分数 | 影响 |
|---|---|---|
| CVE-2026-100504 | 7.3 | 反编译器 `leftshift128` 负移位导致栈越界写,可致代码执行 |
| CVE-2026-100505 | 4.8 | `StringManager::getCodepoint` 多字节解码堆越界读(9.2–12.1.4) |
| CVE-2026-100503 | 4.8 | 反编译器 `Funcdata::opInsertAfter` UAF 拒绝服务 |
| CVE-2026-113604 | 6.8 | ELF 加载器 `ElfHeader` 内存耗尽拒绝服务(10.2–12.1.4) |
缓解只能是流程上的:**不要用这套环境分析来源不可信的样本**;根治要等 REA 放开 12.1.x 这条线。
**CVE-2026-91403(9.1,JSSE,eMudhra 中间 CA)**
Oracle 的 Security Alert 把 `21.0.13` 和 `25.0.5` 都列为受影响版本。实测信任库里的 eMudhra 锚:
| 锚(SHA-256 指纹前缀) | Temurin 21.0.12.1 | Temurin 21.0.13 | Corretto 25.0.5(本机运行) |
|---|---|---|---|
| g1 `40:F6:AF:03…` | 有 | **有** | 有 |
| c1 `12:56:09:AA…` | 有 | 无 | 有 |
| c3 `BC:4D:80:9B…` | 有 | 无 | 有 |
| g3 `86:A1:EC:BA…` | 有 | 无 | 有 |
| g2 `1A:A0:C2:70…` | 无 | 无 | 有 |
即:21.0.12.1 → 21.0.13 会移除 c1/c3/g3,**但保留 g1**;本机运行的 Corretto 25.0.5 反而五张全在。
**没有任何一份已发布版本是“干净”的**,所以“升到 21.0.13 就修掉这个 9.1”不成立。
可用的缓解有两条,都要动信任库,需人工确认:
1. 给 REA 单独指定 truststore(`-Djavax.net.ssl.trustStore=<剔除了 emsign 锚的副本>`),不动系统 JDK——
`F:\game\xbox` 同时是别的程序(Minecraft)的运行时,直接删证书会波及它们。
2. 等 Adoptium 发布带 Windows 资产、且移除了全新 eMudhra 锚的下一版,再统一升 manifest。
注:插件自身的下载都是 sha256 固定的,伪造证书无法改写字节,只能误导版本探测;这条风险主要落在
“REA/Java 去访问任意 HTTPS 站点”上,而不是供给链路。
**信任库统计陷阱(记录一下,避免再犯)**:只 grep `emudhra` 会漏掉 Corretto 的 `emsignrootca-*` 别名,
把“5 张锚”读成“0 张”。正确做法是 `(?i)emsign|emudhra`,或直接按 SHA-256 指纹比对。
## 5. 本轮实测:工具面全绿
| 检查 | 结果 |
|---|---|
| Ghidra(`C:\Windows\System32\whoami.exe`) | 9 段 / 341 函数 / 308 字符串;`entry` 反编译出 `FUN_1400021b4(); FUN_140001af0();` |
| Ghidra 冷启动 | CLI `rea analyze` 52 s;同进程内后续 `list_segments` 立即返回 |
| apktool | `inspect_apktool_client` → 3.0.3;wrapper 直接跑 `--version` → 3.0.3(280 ms) |
| adb | `inspect_adb_client` → 37.0.1-15733141;`adb version` → 1.0.41 |
| jadx 桥接 | 0.7.1-all.jar 在 JDK 25.0.5 下正常启动(输出 usage) |
| managed(mscorlib.dll) | 20,899 行元数据,MVID 2acc5ce0-… |
| sqlite | 3.53.4,schema + 行读取正常 |
| 仓库门禁 | `audit-manifest` 24/24 hash-pinned clean;单测 85 过 / 3 平台跳过 / 0 失败;`verify-branch.ps1` ALL CHECKS PASSED |
不可用的工具族都是“需要外部工具”的那批,与版本无关:`inspect_with_objdump`(本机无 GNU objdump)、
`inspect_pcap_*`(无 Wireshark/tshark)、`extract_firmware`(无 binwalk/unblob)、`inspect_wasm_artifact`
(无 WABT)、`recover_javascript_sources`(无 Wakaru)、`inspect_binary_layout`(无 pwntools)、
`inspect_evm_interface`、JEB 家族、macOS 专属项。
## 6. 什么时候重开这个决定
1. **Ghidra 12.2**:`rea-agents` 新版本把 `SUPPORTED_GHIDRA_VERSION` 提到 12.2(或新增 12.2 兼容分支)
→ 同步放宽 `check-updates.ps1:228` 的 `^12\.1\.`,改 manifest 的 `ghidra` 源,重跑
`bundle:audit` + `bundle:test` + `setup --reuse`(含末尾 smoke)。
2. **jadx 0.8.0**:`JadxRelease.js` 的 `version` / `sha256` 变化 → 换 jar、改 manifest 的
`jadxHeadlessMcp` 与 `probe`、重跑一次真实 APK 分析。
3. **JDK**:Adoptium 发布带 Windows 资产的 21.0.13+(或更新的 LTS 补丁)→ 一次改 5 个平台的
url/size/sha256,跑 audit + test + `setup --reuse --skip-smoke`,再用 vendor JDK 跑一次 smoke。
4. 任何一项落地时,都保持“先验证再切换”:本机当前的运行组合随时可回退。
## 7. 本轮的已知弱点(未修,留待决定)
- `manifest/bundle.manifest.json:293` 的 jadx probe 只检查 jar 路径存在(`{"file": …}` 没有 `expect`),
`setup.mjs` 的 `runProbe` 对 file probe 只做 `existsSync`。换一个同名但版本不同的 jar,setup 会静默放行,
直到第一次 Android 调用才报 `Expected engine 0.7.1`。
- JDK probe 只要求 `java --list-modules` 含 `jdk.compiler`,一个 JDK 11 也能通过(`setup.mjs:1116-1118`
的注释正描述了这类事故)。Ghidra 的 `application.java.min` 会在运行期兜住,但那时已经装完了。
- `setup.mjs:1147-1155` 的“已供给”分支只跑组件 probe,不比对落盘文件哈希,所以装在机器上的
`vendor/apktool/apktool.bat` 一度是旧 master 副本(2,348 B / `9440ec9d…`)而 manifest 钉的是 tag 副本
(2,386 B / `d81a91bd…`)。本轮已把该文件对齐到钉子(备份 `apktool.bat.bak-2348`),两者行为实测一致。
## 8. 本轮没有做的事
- 没有移动任何 pin(`manifest`/`package.json` 未改)。
- 没有提交、没有推送;工作树保持干净(本文件与 README 的说明除外)。
- 没有重启 dsh / REA:运行中的 MCP 进程与 Ghidra 会话不受影响。
## 附:外部评审文档的核对结果
| 说法 | 判定 |
|---|---|
| Ghidra 12.2 被 REA 的 12.1.x 硬线挡住 | ✅ |
| 不存在 12.1.5 | ✅(Ghidra release 列表里 12.1.4 之后直接是 12.2) |
| jadx 0.8.0“不会被硬拒绝”“大概率能用”“失败是静默的” | ❌ 硬拒绝、明确报错,见 §2 |
| 升 JDK 21.0.13 能修掉 CVE-2026-91403 | ❌ 只移除 4 张锚里的 3 张,g1 仍在;Oracle 把 21.0.13 列为受影响 |
| 改 manifest 的六个平台条目即可升 JDK | ❌ 只有 5 个平台条目;win32-x64 无资产 |
| `SUPPORTED_GHIDRA_JAVA_MAJOR = 21` 意味着“只接受主版本 21” | ❌ 实际是 `>= application.java.min`(25.0.5 今天就被接受) |
| apktool 3.0.3 已是上游最新、哈希与官方一致 | ✅ |
| platform-tools 无钉子、始终取最新 | ⚠️ URL 是滚动的,但 manifest 钉了当前字节的 sha256(今日仍一致) |
| jadx 0.7.1 受 CVE-2022-39259 / CVE-2024-32653 影响 | ❌ 类目错误:0.7.1 是 jadx-headless-mcp 的版本号,不是 jadx-core 的 |
+301
View File
@@ -0,0 +1,301 @@
# 更新落地:REA 6.7.0 → 6.8.0(2026-10-26)
上游在 2026-10-25 放出 `[email protected]`(npm `latest`)。本轮按以往的做法核对全部钉子、只升级能升的那一个,
并在真机上把整条链路重跑一遍。**结论:只动 REA 一个 pin,但它连带改了 Node 的 engines 下限,所以三处措辞一起动;
Ghidra / jadx / JDK / apktool / platform-tools / Node 全部原地不动。**
---
## 0. 结论
| 组件 | 上游最新 | 本轮钉子 | 动作 | 依据 |
|---|---|---|---|---|
| REA(`rea-agents`) | **6.8.0**(2026-10-25) | **6.8.0** | **升** | npm `dist-tags.latest = 6.8.0`,`time.modified 2026-10-25T09:07:31Z` |
| Node | LTS 24.22.1(2026-10-20);current 26.12.1(非 LTS) | 24.22.1 | 不动 | 24.22.1 仍是 24 线最新 LTS,且**正好等于 6.8.0 的 24 线下限** |
| Ghidra | **12.2**(2026-10-22) | 12.1.4 | **不动**(本机可覆盖,见 §9) | 6.8.0 的 `SUPPORTED_GHIDRA_VERSION` 仍是 `12.1.4`;12.2 判 `unsupported`,Ghidra 系工具整组消失 |
| jadx 桥接 | **0.8.0**(2026-10-19) | 0.7.1 | **不动** | 6.8.0 的 `JADX_RELEASE` 仍是 0.7.1,`sha256` 与盘上 jar 逐字节相同 |
| JDK | 21.0.13+9(无 Windows 资产)/ 25.0.4.1+1(Windows 线) | 21.0.12.1 | 不动 | Adoptium `/v3/assets/latest/21/...os=windows` 仍返回 21.0.12.1+1(`updated 2026-08-21`) |
| apktool | 3.0.3(2026-07-20) | 3.0.3 | 不动 | GitHub latest 未变;jar 15,478,013 B / `dbf930b0…` 现场复核 |
| platform-tools | 滚动 | 37.0.1 | 不动 | 现场 `adb version` = 37.0.1 (1.0.41),`source.properties` `Pkg.Revision=37.0.1` |
本轮唯一变化的 pin:`reaAgents 6.7.0 → 6.8.0`。唯一连带变化:`nodeEngines` 与 node probe。
**例外:Ghidra 12.2 在本机是「有记录的本机覆盖」,不是 pin 变更**(§9)。四项 CVE 的修复版就是 12.2,实测桥接可用、
分析结果与 12.1.4 逐字节一致,所以给了 `scripts/ghidra-gate-override.mjs` 这条显式通道;`setup.mjs` 仍按 12.1.4 供给。
---
## 1. 6.8.0 是什么
不是修 bug 的补丁版,**是工具扩容**:
| 项 | 6.7.0 | 6.8.0 |
|---|---|---|
| tarball 条目 | 1775 | **2398** |
| `node_modules` 条目 | 0 | 0(仍由 `npm install --ignore-scripts` 解闭包) |
| 解包体积 | — | 49,251,336 B(压缩包 9,944,902 B) |
| MCP 工具 | 207 | **229**(+22) |
| CLI 命令 / prompt | — | 175 / 6 |
| catalog 记录 | 无 | `dist/mcp-catalog.json`(含 `identity.counts` 与三个 digest) |
| engines.node | `^22.19.0 \|\| ^24.11.0 \|\| >=26.0.0` | **`^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`** |
| 依赖闭包 | 42 条 | 44 条(+`@bufbuild/protobuf 2.16.0`、+`@msgpack/msgpack 3.1.3`) |
文件级差异(按路径比对):**+843 / −225**。新增族从目录名就能看出来:`dist/apkSignature/*`(APK 签名与证书)、
`dist/application/binaryDiagnostics/*`(PE 证书/导入/加载配置、ELF notes、内核模块、devicetree、Apple IPS、
字节熵)、`inspect_model_artifact`(GGUF v2/v3、safetensors 声明)、`inspect_gpu_code_objects`(CUDA fatbin /
CUBIN / PTX / HIP bundle 的静态解析)。后两者**不需要外部引擎**(描述里明写 "without loading models or
executing code" / "No GPU tools"),所以 `notProvisionable` 不用新增条目。
catalog 自带的三个 digest(可用来判定「装的是不是同一份工具表」):
```
tools_sha256 a98940ba69acd9c1781d180cee362d4ddab18f77eefdd3e3216f10e1bf98e5e9
prompts_sha256 9609379442110570cd52fdea61a777242350873bc28958f54827ca3590e680b7
combined_sha256 fe938a7c6eba3763b22ae2022de76dc4a7812d35d86f1d4efd36b9962b04da0b
```
---
## 2. 为什么只有 REA 动了:两道闸门实测
把 6.8.0 的策略模块直接加载执行(不是读代码推断):
```
dist/ghidra/GhidraReleasePolicy.js
12.1.4 → verified
12.1.5 → compatible (这个 release 并不存在,12.1.x 最后一版就是 12.1.4)
12.2 → unsupported
12.2.0 → unsupported
dist/android/JadxRelease.js
JADX_RELEASE = { version: "0.7.1",
revision: "5844800a486d2248fa949b74c9896285f5b54de2",
sha256: "6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75" }
↑ 与盘上 vendor/jadx/jadx-headless-mcp-0.7.1-all.jar 的实测哈希一致
```
也就是说:**6.8.0 没有放宽任何一条版本线**。Ghidra 12.2 会照旧让整个 Ghidra 族消失,jadx 0.8.0 会照旧在
`load_apk` 之前抛 `Expected engine 0.7.1`。这两项维持 10-24 那轮的决定。
---
## 3. engines 抬升:一处声明,三处执行
REA 抬了下限,本包有三处必须同时跟上,否则会出现「文档说一套、reuse 路径执行另一套」:
| 位置 | 值 | 谁在校验 |
|---|---|---|
| `manifest.pins.nodeEngines` | `^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1` | `audit-manifest.mjs` §12 与 `package.json engines.node` 逐字比对 |
| `package.json engines.node` | 同上 | npm / DSH 市场 |
| `components.node.probe.expect.regex` | 见下 | setup 的 reuse 路径(把一个 24.11 的 Node 判成可用就是静默降级) |
probe 只有一条正则、没有版本比较器,所以写成一条交替:
```
^v(22\.23\.(?:[4-9]|[1-9][0-9]+) # 22.23.4 … 22.23.x
|22\.(?:2[4-9]|[3-9][0-9])\.\d+ # 22.24+ … 22.9x
|24\.22\.(?:[1-9]|[1-9][0-9]+) # 24.22.1 … 24.22.x
|24\.(?:2[3-9]|[3-9][0-9])\.\d+ # 24.23+ … 24.9x
|26\.12\.(?:[1-9]|[1-9][0-9]+) # 26.12.1 … 26.12.x
|26\.(?:1[3-9]|[2-9][0-9])\.\d+ # 26.13+ … 26.9x
|2[7-9]\.\d+\.\d+|[3-9][0-9]\.\d+\.\d+)
```
用 24 组版本正反验证(正例 12 / 反例 12,0 处不符):
```
接受 v22.23.4 v22.23.10 v22.24.0 v22.30.1 v24.22.1 v24.22.9 v24.22.12
v24.23.0 v26.12.1 v26.12.15 v26.13.0 v26.20.0 v27.0.0 v30.1.2
拒绝 v22.19.0 v22.23.3 v24.11.0 v24.21.0 v24.22.0 v25.0.0 v26.0.0
v26.12.0 v20.19.0 v18.20.0
```
**用户可见的后果**:6.7.0 时代合法的 Node 22.19–22.23.3 与 24.11–24.22.0,现在会被 reuse 路径拒绝。
钉住的 24.22.1 现在是 24 线**既最新、又最低**可接受的那一个 —— 这种「钉值同时是下限」的情况本包第一次遇到。
---
## 4. 实测记录(Windows 11 x64)
`node scripts/setup.mjs --reuse`:
```
[skip] node — reusing existing node from PATH: F:\jiuguan\node.exe
[skip] jdk — reusing existing jdk from JAVA_HOME: F:\game\xbox\
[skip] ghidra — reusing existing ghidra from GHIDRA_INSTALL_DIR: D:\ghidra\ghidra_12.1.4_PUBLIC
[skip] apktool — already provisioned (3.0.3)
[skip] jadxHeadlessMcp — already provisioned (0.7.1)
[skip] androidPlatformTools — already provisioned (latest (37.0.1))
[ok ] reaAgents — …\vendor\node_modules\rea-agents
[ok ] probe — cli=6.8.0 healthy=false tools=229 unmet=10
[ok ] smoke — imported java.exe in 26s via ghidra
[ok ] shim — resolved to REA 6.8.0
setup complete.
```
`healthy=false` 与 `unmet=10` 是**预期**:Hopper / IDA / Kuna / 五个客户端的 registration / skill 都属「调用方自备」,
供给刻意不碰(与 6.7.0 时同样的 10 条)。
独立 stdio MCP 握手(不经 PATH 的 `rea`):
```
initialize → serverInfo {name: rea, version: 6.8.0}, protocol 2024-11-05
tools/list → 229, nextCursor null
prompts/list → 6
```
`rea doctor --json` 的 identity 段与 tarball 里的 catalog 一致:
```
cli_package_version 6.8.0 · expected_skill_version 49 · sdk 2.3.1
catalog.counts {cli_commands: 175, mcp_tools: 229, mcp_prompts: 6}
```
各引擎现场复核:`java --version` = openjdk 25.0.5(宿主 Corretto);`java -jar vendor/apktool/apktool.jar --version`
= 3.0.3;`vendor/platform-tools/adb.exe version` = 1.0.41 / 37.0.1;`vendor/jadx/jadx-headless-mcp-0.7.1-all.jar`
= 42,619,858 B / `6e5eacf5…`。
门禁:单测 **88(85 过 / 3 平台跳过 / 0 失败)**、`audit-manifest` **24/24 hash-pinned clean**、
`verify-branch.ps1` **ALL CHECKS PASSED**、`host-compat` 10 covered / 5 outside。
---
## 5. 供给过程里的一个坑(值得写进排障)
第一次 `setup --reuse` 在 reaAgents 这一步失败:
```
[FAIL] reaAgents — npm install failed (4294963219): npm error network … ECONNRESET
```
npm 日志显示 `rea-agents-6.8.0.tgz` 取包时 `read ECONNRESET`(`errno -4077`),reify 回滚后 probe 仍读 6.7.0 /
207 工具 —— 读起来像「升级根本没生效」。原因不是哈希、不是权限:**setup 的 npm 子进程不继承本脚本自己的代理
路由**,而直连 registry 的 tarball 在这条网络上是断的(同一个 URL 用 `Invoke-WebRequest` 却能下完,所以只看
浏览器/`curl` 会误判)。
解法:给 setup 的进程环境加代理后重跑即可。
```powershell
$env:HTTPS_PROXY="http://127.0.0.1:7890"; $env:HTTP_PROXY="http://127.0.0.1:7890"
node scripts/setup.mjs --reuse
```
---
## 6. 仍不能升的四项(与 10-24 结论一致)
1. **Ghidra 12.2**:`SUPPORTED_GHIDRA_VERSION = "12.1.4"` 未变;跨 minor 即 `unsupported`,Ghidra 族工具整组不可用。
—— 但这条闸门**不等于 12.2 坏了**:实际测下来它可用,见 §9。
2. **jadx 0.8.0**:`JADX_RELEASE.version` 仍是 0.7.1,`JadxSession` 在其自报版本不等时立刻抛错。
3. **JDK 21.0.13**:Adoptium 的 Windows 资产仍停在 21.0.12.1+1;且 21.0.13 只移除 4 个 eMudhra 锚里的 3 个。
4. **Node 26**:`26.12.1` 是本轮唯一的 26 线新版本,但它是 current 而非 LTS;本包钉 LTS,且 24.22.1 已满足
6.8.0 的 `^24.22.1`。
---
## 7. 重开条件
| 触发 | 动作 |
|---|---|
| REA 把 `SUPPORTED_GHIDRA_VERSION` 提到 `12.2.x` | 换 Ghidra 源(一个 zip 通吃三平台),重跑冒烟(首次导入 26–64 s) |
| REA 把 `JADX_RELEASE` 提到 0.8.0 | 换 jar 的 url/size/sha256(vendor 侧 sha256 可自算),并确认桥接类仍能加载 |
| Adoptium 为 21 线发布 Windows 资产 | 换 5 条 jdk 源(win32-x64 / darwin-x64 / darwin-arm64 / linux-x64 / linux-arm64) |
| Node 26 转为 LTS | 重新评估是否把 `pins.node` 抬到 26(engines 已允许 `>=26.12.1`) |
---
## 8. 本轮没做的事
- **没有 npm publish、没有 GitHub Release**:`package.json` 到 2.0.13,仓库与 tag 先行,npm `latest` 仍是 2.0.12。
- **没有动 Ghidra / jadx / JDK / apktool / platform-tools 的任何一个字节**。
- **没有清理 `C:\Users\lenovo\.dsh`** 下的旧 bundle(本机 `DSH_HOME=D:\dsh-home`,那份是历史遗留,与当前宿主无关)。
- **没有跑 macOS / Linux 的首次供给分支**(本机只有 Windows)。
---
## 9. 能不能升到 Ghidra 12.2(这四个 CVE 的修复版):能,但要覆盖上游闸门
用户问的是「这些不是漏洞吗,能不能升到最新版、同时保证 REA 能用」。答案分两半:**「升」可以,「默认升」不行**,
因为挡住 12.2 的不是兼容性,是 REA 自己的一条版本等值判定。
### 9.1 试了什么
```
下载 ghidra_12.2_PUBLIC_20261016.zip 569,712,708 B
sha256 2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67 ← 与 release body 一致
解包 application.version=12.2 · application.java.min=25 · analyzeHeadless.bat / win_x86_64 decompile.exe 均在
```
只改了 **vendored 副本**里那三处常量(`dist/ghidra/GhidraReleasePolicy.js`、`dist/cli-runtime/KunaCommand-*.js`、
`dist/mcp/binary-*.js`,都是 `SUPPORTED_GHIDRA_VERSION = "12.1.4"`),把值换成 `12.2`。
### 9.2 结果
| 检查 | 12.1.4(pin) | 12.2(覆盖后) |
|---|---|---|
| `rea doctor` ghidra-version | `12.1.4` ok | `12.2; compatible 12.2.x release` ok |
| `rea doctor` ghidra-java | `25.0.5 … accepted a 64-bit full JDK 21 or newer` | `25.0.5 … accepted a 64-bit full JDK 25 or newer` |
| `rea analyze whoami.exe` | 52 s · 9 段 / 341 函数 / 308 字符串 | 52 s · 9 段 / 341 函数 / 308 字符串 |
| `list_segments`(MCP,经 shim) | 基准 | **逐字节相同** |
| `list_procedures`(MCP) | 基准 | **逐字节相同**(74,483 字符) |
| `procedure_pseudo_code("entry")` | `FUN_1400021b4(); FUN_140001af0();` | **逐字节相同**(101 字符) |
| `provider.version` | `12.1.4` | `12.2` |
即:**段表、函数表、入口伪代码三样在两个版本上完全一致**,桥接也没有出现 API 漂移的编译/运行错误。
### 9.3 所以怎么用
```powershell
node scripts/ghidra-gate-override.mjs --status # 现在是什么状态
node scripts/ghidra-gate-override.mjs --enable --version 12.2 # 打补丁 + 记录
node scripts/ghidra-gate-override.mjs --disable # 还原成上游闸门
```
- 状态写进 `<bundle>/ghidra-gate-override.json`(原值、目标值、改了哪几个文件、何时启用)。
- **重跑 `setup.mjs` 会重新供给 rea-agents,补丁随之消失** —— 这是刻意的:选错方向的默认值应该是「回到钉住的
已验证组合」,而不是「悄悄继续跑未验证的版本」。
- `check-updates.ps1` 认得这份记录:盘上是 12.2 时它打印「本机覆盖 Ghidra 闸门」,不再劝你「重跑 setup 对齐」。
- 12.2 要求 **JDK ≥ 25**(`application.java.min=25`)。本机 `JAVA_HOME=F:\game\xbox` 是 Corretto **25.0.5**,
正好满足;而本包钉的 vendor JDK 是 **21**,所以「干净机器 + 12.2」还得把 JDK 钉到 25
(Adoptium 25 线 Windows 资产是 25.0.4.1+1)。
### 9.4 没被验证的部分(说清楚)
只测了 PE 的 `open_binary` / `list_segments` / `list_procedures` / `procedure_pseudo_code` / `analyze`。
**没测**:ELF / macOS / COM / DOL / raw 这些走各自 `ReaGhidraPrepare*` 的路径、PSP 扩展、
值流(`trace_native_values`)、decompiler hook(`ReaGhidraNoReturnFix`、`ReaGhidraMipsSoftFloat`)的边角。
上游没有验证 12.2,这些边角现在记在使用者名下 —— 这正是上游闸门存在的理由,覆盖它就得承担它。
---
## 10. CVE-2026-91403(JSSE / eMudhra)为什么不靠「升级」解决
- **没有已发布的 JDK 移除全部 5 张 eMudhra 锚**:Temurin 21.0.13 只掉了 c1/c3/g3(保留 g1),Corretto 25.0.5 五张全在。
所以「升到某个版本就干净」在任何厂商都不成立。
- **这条链上没有 Java 侧出站 TLS**:REA 本体是 Node(用 Node 自带的 CA 集,不用 Java truststore);Ghidra 是本地
headless,jadx 桥接也是本地进程。Java truststore 里那几张锚在这条链上**没有被使用的调用点**。
- 真要清理,只能给 REA 单独做一份剔除了 emsign 锚的 truststore 并通过 JVM 参数指过去;那属于硬化动作,
不改变本链路的实际暴露面。本轮没有做。
---
## 11. jadx 0.8.0 为什么不在这次
它没有对应的 CVE 修复诉求(用户的问题是关于漏洞的),而 REA 对它的要求是**精确等于 0.7.1 + 该 jar 的 sha256**;
0.8.0 会在 `load_apk` 之前抛 `Expected engine 0.7.1`。要升它同样得覆盖 `JADX_RELEASE`,风险与本轮无关,暂不动。
---
## 附:WSL 侧核对
用户的会话标题带「WSL 环境更新」,所以顺带查了一遍:
| 项 | 实测 | 判断 |
|---|---|---|
| WSL 发行版 | Ubuntu 22.04.5 LTS(WSL2,内核 6.18.54.1) | 与本次更新无关 |
| WSL 原生 Node | `/usr/bin/node` = **v22.23.4** | 正好等于 6.8.0 的 22 线下限(旧 engines 的 `^22.19.0` 也吃它)→ 无可升级项 |
| WSL 侧 `~/.dsh` | 不存在 | 没有第二份 bundle 要供 |
| WSL 里的 `dsh` | `/mnt/d/dev/npm-global/dsh`(Windows 侧实体) | 用的仍是 `D:\dsh-home` 那一份 → 更新一处即覆盖两边 |
结论:**WSL 侧不需要单独更新**,REA 6.8.0 通过共享的 `D:\dsh-home\plugins\rea-bundle` 同时生效。
+225
View File
@@ -0,0 +1,225 @@
# 2.0.14 — 外部审计的逐条修复与 DSH 宿主适配(2026-11-06)
输入是一份针对 `dsh-plugin-rea 2.0.13`(提交 `4d8a082`)的外部审计(18 项),外加一次宿主版本适配请求。
本轮**没有升级任何上游组件**:REA 6.8.0 / Ghidra 12.1.4 / JDK 21 / apktool 3.0.3 / jadx 0.7.1 / Node 24.22.1 全部原地。
结论先说:审计的主线判断是对的,而且比它自己写的更一致 —— **这七项动态缺陷的共同点是「验证的对象不是要交付的那个对象」**。
一次部分失败被记录成成功、一个坏路径被另一份好安装背书、一条超时的锁被从活进程手里删掉、一个探针只验证「同名组件在某处存在」。
所以本轮的修法也统一:让探针去看真正要交付的那份东西,并把「无法证明」和「证明失败」分开。
---
## 一、18 项审计条目:逐条状态
| # | 审计条目 | 严重性 | 状态 | 落点 |
|---|---|---|---|---|
| 1 | 多文件组件部分失败会记录成新版本并假绿 | 高 | **已修** | `scripts/setup.mjs` multi-file 段 |
| 2 | 已记录组件的探针没绑定 `recorded.path`(且不校验 pin) | 高 | **已修** | `scripts/setup.mjs` `recordedMatchesPin()` |
| 3 | 损坏 bundle 阻止 auto 回退,状态命令仍退出 0 | 中 | **已修** | `bin/rea-shim.mjs` `bundleUsable` |
| 4 | 六小时过期会删掉活进程的锁 | 中 | **已修** | `bin/rea-shim.mjs` + `setup.mjs` 心跳/token |
| 5 | JDK 探针不保证 21+ | 中 | **已修** | `manifest/bundle.manifest.json` |
| 6 | 当前策略使 Frida 原生绑定缺失 | 中 | **已修** | `scripts/setup.mjs` `ensureFridaBindings()` |
| 7 | 损坏的 `setup.lock` 让手动 setup 抛 `SyntaxError` | 低 | **已修** | `scripts/setup.mjs` `acquireRunLock()` |
| 8 | `check-updates.ps1` 假绿(drift/warn 不进结论) | 中 | **已修** | `check-updates.ps1` 判绿条件 |
| 9 | `bin/rea-guide.mjs` 忽略 `REA_BUNDLE_ROOT` | 低 | **已修** | root 解析链统一 |
| 10 | `ghidra-gate-override.mjs` 忽略自定义 root | 低 | **已修** | 同上 |
| 11 | `--dry-run` 仍创建 root/cache/log | 低 | **已修** | 一个字节都不写 |
| 12 | 归档替换先删后 rename,失败丢两份 | 中 | **已修** | `.backup` + 回滚 |
| 13 | `bundle.json`/`mirror.json` 非原子写 | 中 | **已修** | `writeJsonAtomic()` |
| 14 | override 先 patch 后写恢复记录 | 低 | **已修** | 先落盘再改 + 回滚 |
| 15 | override `--disable` 缺 vendor containment | 低 | **已修** | `containedPath()` + `lstat` |
| 16 | `tarfile.extractall()` 未用安全 filter | 纵深 | **已修** | `filter='data'` |
| 17 | Zod 4.4.3 落在 advisory 内(依赖闭包未锁) | 高 | **已修** | `scripts/vendor-lock/` + `npm ci` |
| 18 | `engines.node` 与宿主自带 Node 不一致 | 未列入 | **未解决,已记录** | 见 §五-1 |
---
## 二、关键缺陷的修法与实测证据
### 1. 多文件组件全有或全无(#1、#12)
复现形状:apktool 的 jar 下载失败、wrapper 成功,目标目录里残留 2.0.0 的 jar 通过了 `java -jar --version`,
记录写成 3.0.3 且哈希取自 wrapper,下一次运行 `already provisioned (3.0.3)` 不再下载。
修法四层:
1. **全部 entry 先取全**,缺一个即整体失败并打印 `N of M entries were available; nothing was installed`;
2. 所有文件先进 `<target>.staging`,**全就位后才原子换入**;
3. 旧目录改名 `.backup`,rename 失败或**探针失败**都回滚并明说 `the previous install was put back`;
4. 记录改成**每 entry 独立**的 `{id, version, sha256, tofu}`,`jarName`/`sha256` 按类型(`.jar`)而非列表顺序选主。
真机实测(本机 `D:\dsh-home\plugins\rea-bundle`,先手工备份 `vendor/apktool`):摘掉 `bundle.json` 的 apktool 记录后
`--only apktool` 走完整路径 —— jar 用缓存、wrapper 重新下载(2386 B,pin 版本),目录里**恰好两个文件**
(历史遗留的 `apktool.bat.bak-2348` 随旧目录被丢弃,这是「目录内容严格等于 manifest」的预期结果),
无残留 `.staging`/`.backup`,`java -jar apktool.jar --version` 答 `3.0.3`,退出 0。
### 2. 探针绑定记录路径 + pin 校验(#2)
`recorded` 分支现在用**独立 context** 把该组件解析到 `recorded.path` 再探针(与 reuse 分支同一手法),
并新增 `recordedMatchesPin()`:记录版本必须等于 manifest 的 pin;JDK 按**主版本下限**匹配。
一个必须处理的例外:**已启用的 Ghidra 闸门覆盖**。本机 manifest 钉 12.1.4、盘上是 12.2,
若 pin 校验不认 `ghidra-gate-override.json`,一次普通 setup 会把操作员显式打开的覆盖**悄悄重钉回 12.1.4** ——
这是「修一个新缺陷时制造另一个」。实测 `--dry-run`:
```
[skip] node — already provisioned (24.22.1) — pinned 24.22.1
[skip] jdk — already provisioned (25.0.5) — major 25 satisfies the pin's major 21
[skip] ghidra — already provisioned (12.2) — recorded 12.2 matches the enabled gate override
[skip] apktool — already provisioned (3.0.3) — pinned 3.0.3
[skip] jadxHeadlessMcp — already provisioned (0.7.1) — pinned 0.7.1
[skip] androidPlatformTools — already provisioned (latest (37.0.1)) — pinned latest (37.0.1)
[skip] reaAgents — already provisioned (6.8.0) — pinned 6.8.0
```
### 3. 统一「bundle 可用」判定(#3)
`loaded.ok`(JSON 解析成功)曾被当成「已供给」,一口气决定三件事:启动选择、状态退出码、是否进入修复。
现在 `bundleUsable = loaded.ok && resolveRea(...).ok`,三处共用:
- 启动选择:不可用的 bundle 不再凭「记录版本更新」赢过可用的系统 REA;
- `--shim-status`:不可用且无系统 REA → 退出 **78**(原来 0),报告新增 `bundleUsable` 字段;
- 修复路径:状态可解析但运行时缺失,现在也会进入 auto-provision(原来直接 `fail()`)。
### 4. 锁:存活 pid 是硬证据,过期只是软证据(#4)
原规则只信任 6 小时内的锁,超时后**不问持有者是否还活着**就删。慢下载确实会跑过六小时,删锁后第二个
provisioning 会写同一份 cache、同一批 `.staging` 和同一份 `bundle.json`。
现在:
- pid 活着 → **永不删**;超过窗口报 `in-flight-stale`(附 pid 与「多久没汇报」),由操作员决定;
- pid 证明已消失 → 回收;
- **无 pid 的旧式数字锁**(没有人可查)→ 仍按时间处理,否则「无法判断」会永远读成「活着」;
- 配套:`setup` 的 `log()` 兼作**心跳**(30 s 节流刷新 `at`)—— `spawnSync` 下载会饿死定时器,所以心跳挂在进度输出上;
- shim→setup 的交接从 **PID 交接改为 token 交接**(`REA_BUNDLE_LOCK_TOKEN`):原窗口里 setup 可能在 shim 改写
`child.pid` 之前就读到一个不属于自己的活 pid,于是倒在自己的锁上、退出 75(审计把它列为静态风险,100 次自然启动未命中)。
`failed` 记录**仍然优先于 pid 检查**:失败进程的 pid 必然已消失,顺序颠倒会把「阻止无限重试的刹车」当成陈旧锁删掉。
### 5. JDK 探针(#5)
`jdk.compiler` 只区分完整 JDK 与裁剪运行时 —— 真实 Java 17.0.20 同样打印 `[email protected]`。
`expect` 现在同时要求两件事(`runProbe` 对 `contains` 和 `regex` 是 AND):
`contains: jdk.compiler` **且** `regex` 匹配版本段 ≥ 21(21–29 / 30–99 / 100+)。8 组版本串正反验证通过,
含本机真实输出 `[email protected]`。
### 6. Frida 原生绑定(#6)
实测复现:`import('frida')` → `Could not locate the bindings file. Tried: … frida\build\frida_binding.node`。
原因是 frida 的 `install` 脚本(`prebuild-install`)是放置这个 .node 的**唯一**途径,而 `--ignore-scripts` 跳过了它。
修法不是删 `--ignore-scripts`(那是正确的加固),而是**点名这一个包**:`npm rebuild frida` → 用
`import('frida')` + `enumerateDevices()` 验证。修好后 135.7 MB 的 `frida_binding.node` 落地,
实测 `IMPORT OK` + 3 个设备(`local/local`、`socket/remote`、`barebone/remote`),即 `FRIDA_OK 3`。
**检查放在 `main` 层而不是 provisioning 路径里** —— 否则「已记录 6.8.0 的存量 bundle」(也就是每一次升级)
永远不会进入那段代码,绑定会一直缺着而每行都在说 `already provisioned`。结果写进
`components.reaAgents.frida = {available, binding, sha256, devices}`;不可用时按 `browser` 的先例报 `skip`,
不把一次可用供给判成失败(`step(..., "FAIL")` 会进 `failed` 数组并让 setup 退出 1)。
### 7. 依赖闭包(#17)
`[email protected]` 对 zod 是**精确锁 `"4.4.3"`**,所以「装一个 4.6.7 覆盖它」会得到**两份 zod**,REA 仍吃坏的那份
(实测否决)。`overrides` 是唯一不改发布 tarball 的杠杆,`npm ci` + lockfile 再补上「每次同一份字节」。
放大实测(同一个 `.max(2)` schema 喂 25 个元素):zod 4.4.3 → **26 条 issue**,4.6.7 → **1 条**;
REA 自己的 `inspectAnalysisSettingsInputSchema` 同样 26 → 1。
落地:`scripts/vendor-lock/`(lockfileVersion 3,183 条目)→ `zod 4.6.7` / `minimatch 11.0.5` / `glob 14.0.4`,
各单副本。兼容性实测:`@electron/asar` 在 glob 14 / minimatch 11 下 create/list/extract/stat **往返通过**;
REA 真实 MCP stdio 握手 `tools/list` 仍是 **229/229 且每个都有 inputSchema**(与 4.4.3 基线一致)。
---
## 三、修复过程中发现的、审计没提的问题
三处都是「修完之后自查」抓到的,不是审计输入的:
1. **`"21"` 这种只有主版本的 pin 被当成「家族前缀」**(`scripts/pin-match.ps1`、`bin/rea-guide.mjs`)。
`25.0.5` 对 pin `21` 被判成漂移 → `check-updates.ps1` 说「重跑 setup 对齐」,而 setup 说
「already provisioned (25.0.5) — major 25 satisfies the pin's major 21」。Ghidra 要求的是「JDK 21 **或更新**」,
所以**只有主版本的 pin 是下限,不是前缀**。三个文件现在的判定一致。
2. **`check-updates.ps1` 的假红**:`rea doctor` 退出码 1 涵盖「任何可选能力未配置」(Hopper / IDA / Kuna /
skill 副本 / 五个客户端登记),于是一台供给完好的机器恒报「需要动作」。退出码降为 `[上游]`,
实际状态由输出解析出的 provider 检查决定 —— 假绿修完之后不能顺手留下假红。
3. **依赖锁的新读取绕过了 BOM 处理**:`.mjs` 里新增的两处 `JSON.parse(readFileSync(...))` 会被
`verify-branch.ps1` 的「no BOM-blind JSON read」判定拦下(确有其事:带 BOM 的 lock 会被读成「锁里什么都没钉」)。
改为走 `readJsonSafe()`。附带发现 `verify-branch.ps1` 的这条检查会把**注释里**的同名字面量也算进去 ——
所以注释措辞一并改掉了。
---
## 四、DSH 宿主适配(0.2.1-rc.3 → 0.2.2-rc.1)
细节见 [`host-compat-2026-11-06.md`](host-compat-2026-11-06.md)。要点:
- `HOST_RELEASES` 10 → **16**,`engines.dsh` 声明与 `OUTSIDE_HOSTS` 都没动(6 个新版本全在范围内)。
- 6 个 tag 三源交叉核实(releases 页 / atom / npm registry)**全部真实存在**,原有 10 条一条未被撤下;
`0.2.2-rc.1` 是当前 `latest`。排序按**发布日**而非 semver(`0.2.1-rc.5` 晚于 `0.2.2-alpha.2`),这处倒序钉死在测试里。
- 接口面逐条核对:6 个版本都没有触及本插件依赖的五处表面。唯一沾边的是 `0.2.2-rc.1` 的
Cordis Core 4.1.0 / Loader 1.1.0,**已在本机 0.2.2-rc.1 上实测**:`GET /dsh-plugin-rea/guide-state` → 200。
- 现场取证市场语义:`dshmarket/lib/verify.js` 与 `discovery-compatibility.js` 都**只读 `engines.dsh`**,
不读 `engines.node`。
---
## 五、没有解决的,以及为什么
1. **`engines.node` 仍是 `^22.23.4 || ^24.22.1 || >=26.12.1`**,而 `0.2.2-rc.1` 桌面宿主自带 Node **24.21.0**。
实测市场不读这个字段,npm 默认也只警告 —— **但 `engine-strict=true` 的用户会装不上**。
没放宽的原因不是怕,而是它现在被 `audit-manifest.mjs` 强制与 `manifest.pins.nodeEngines`(REA 的真实要求)
**逐字相等**;拆开「插件的 Node 要求」与「REA 的 Node 要求」是一次独立改动,塞进本轮会让那条守护变模糊。
运行期不受影响:REA 用 `bundle.json` 的 `paths.node`(24.22.1)启动。
2. **`dsh.client.inject` 仍列出 `@deepseek-ai/dsh-client-runtime`**,该包在 `0.2.2-rc.1` 宿主里**一次都不出现**
(331 个 `@deepseek-ai` 包目录、整份 213 MB asar 字节扫描,0 命中;它是构建期 `devDependency`)。
宿主对未知 inject 项**静默跳过**(`dsh-client-modules/lib/client.js` 的 `graphRows.get()` 取不到就什么也不做),
且本插件 `client.js` 不 require 它,真实闸门是服务 `["slots"]`。所以卡片照常渲染;保留它是为了不损害旧宿主适配。
属清理项,不是缺陷。
3. **Ghidra 默认 pin 仍是 12.1.4**(带那四个 CVE)。审计明确说这不是本轮新发现,也明确说「不能只把 manifest 改成
12.2」—— 12.2 需要 JDK 25+ 且 REA 未验证,只能通过 `ghidra-gate-override.mjs` 显式开启。
**在升级这个 pin 之前,不要用内置 Ghidra 分析来源不可信的样本。**
4. **硬链接逃逸没做 containment**:同卷硬链接与 bundle 内文件共享 inode,纯路径检查区分不了。
本轮拒绝的是 `../`、绝对路径、符号链接/junction(均已实测)。
5. **minimatch / glob 在 REA 默认 MCP 路径上的可达性仍未证实**:本轮把它们抬到修复线上,没有证明可利用性。
`glob 13 → 14` 是跨大版本(asar 声明 `^13.0.2`),已用 asar 往返与 229 工具 schema 验证,但**没有**把真实
`.asar` 喂进 REA 的 asar 工具端到端跑一遍(没有现成样本)。
6. **lock 放在 `scripts/vendor-lock/` 而不是仓库根**:根级新目录不在 `package.json` 的 `files` 白名单里,
发出去会静默走降级路径。setup 同时认 `HERE/vendor-lock` 与 `PLUGIN_ROOT/vendor-lock`,
所以将来搬到根级只需加一条 `files`,代码不用动。已实测 `npm pack` 确实带上了这两个文件。
7. **CI 时序**:`bundle:deps` 已作为独立 step 接进 `validate.yml` 的两个 job,但它是在本轮**第二个**提交里补上的
—— 第一次推送用的凭据缺 GitHub `workflow` scope,服务端拒绝改 workflow 文件
(`refusing to allow an OAuth App to create or update workflow … without 'workflow' scope`),
所以那一次提交刻意排除了 workflow 改动。第二次换用带 `workflow` scope 的凭据后才补上,
期间 CI 的 Windows job 先红了一次(原因见 §三 的测试夹具 bug),已修复。
---
## 六、验证清单
```
单测 154 项:151 过 / 0 失败 / 3 跳过
setup.test 98 · deps.test 17 · host-compat.test 25 · hardening.test 14
manifest audit 24 条下载 entry 全部 SHA-256 钉死,0 TOFU,clean
依赖闸门 zod 4.6.7 · minimatch 11.0.5 · glob 14.0.4,各单副本
宿主兼容 16 releases covered,5 correctly outside
verify-branch ALL CHECKS PASSED
```
新增回归测试(**在 2.0.13 的代码上会红**):部分失败不成假绿且第二次仍重试、记录路径不被 vendor 背书、
pin 漂移不被信任、JDK 17/11/9 被拒、`--dry-run` 不写盘、坏锁不抛异常、`tarfile` 带 filter、
坏 bundle 回退系统 REA、`--shim-status` 对不可用 bundle 返回 78、活 pid 的过期锁不被删。
真机链路:`--dry-run` 7 组件正确 skip → 强制重装 apktool 走 staging(无残留)→ `--reuse` 全量 8 组件 +
`fridaBindings FRIDA_OK 3` → `probe 229 tools` / `smoke 28 s` / `shim 6.8.0`,退出 0;
`check-updates.ps1` 由退出 1(假绿/假红混合)转为退出 0。
## 七、复现命令
```powershell
cd D:\dsh-plugin-rea
node --test ./scripts/setup.test.mjs ./scripts/deps.test.mjs ./scripts/host-compat.test.mjs ./scripts/hardening.test.mjs
node scripts/audit-manifest.mjs
node scripts/check-dependency-advisories.mjs
node scripts/host-compat.mjs
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-branch.ps1
node scripts/setup.mjs --dry-run --root "$env:DSH_HOME\plugins\rea-bundle"
```
+109
View File
@@ -0,0 +1,109 @@
/**
* Package entry — a loadable package, plus the read-only route behind the
* one-shot setup guide.
*
* Two jobs, deliberately kept apart:
*
* 1. Make this package loadable. The row that does the real work lives in
* `cordis.patch.yml`: it inserts one row of `@deepseek-ai/dsh-mcp-client`
* that launches `bin/rea-shim.mjs`. A package that declares no
* `main`/`exports` is judged by its consumers as "has no entry file",
* whether or not the loader needs one: dshmarket's `hasLoadableEntry`
* asks that on every update and rolls a working build back when the answer
* is false (measured 2026-10-18, 2.0.1 → 2.0.4), and npm metadata resolves
* `main` too. So this is a real module with a real Cordis shape.
*
* 2. Answer GET /dsh-plugin-rea/guide-state for `client.js`. The guide has to
* tell a first install apart from an upgrade — not by guessing, but by
* comparing the REA version recorded in `plugins/rea-bundle/bundle.json`
* with the version this package pins. `bin/rea-guide.mjs` computes it as a
* pure function; this file only serves the result. Nothing here provisions,
* spawns or mutates: the route is read-only, and the guide never runs
* setup for the user.
*
* The route is mounted only when the host has a webServer (the Web GUI), through
* `ctx.inject`, so a headless/ACP host loads this plugin exactly like before.
*/
import { GUIDE_ROUTE, computeGuideState, localPackageFacts } from './bin/rea-guide.mjs'
/** Cordis plugin name. Also the package name, which is what the loader expects. */
export const name = 'dsh-plugin-rea'
/** Cached package facts; the files change only when the package is replaced. */
let cachedFacts = null
function packageFacts() {
if (!cachedFacts) cachedFacts = localPackageFacts()
return cachedFacts
}
function sendJson(res, status, payload, headOnly = false) {
const body = JSON.stringify(payload)
res.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
'content-length': Buffer.byteLength(body),
})
res.end(headOnly ? undefined : body)
}
/**
* Register the read-only guide route on a host that has a webServer.
*
* `webServer.register` takes `{ kind: 'exact', path, handler }` and returns a
* disposer; `ctx.effect` ties that disposer to this plugin's lifetime, so a
* hot disable removes the route instead of leaving a handler over unloaded code.
*/
function mountGuideRoute(host) {
const server = host && host.webServer
if (!server || typeof server.register !== 'function') return
if (typeof host.effect !== 'function') return
host.effect(() =>
server.register({
kind: 'exact',
path: GUIDE_ROUTE,
handler: (req, res) => {
try {
if (req.method !== 'GET' && req.method !== 'HEAD') {
sendJson(res, 405, { error: 'GET only' })
return
}
const facts = packageFacts()
const state = computeGuideState({
pluginVersion: facts.pluginVersion,
pinnedRea: facts.pinnedRea,
})
sendJson(res, 200, state, req.method === 'HEAD')
} catch (error) {
sendJson(res, 500, {
error: error && error.message ? error.message : String(error),
})
}
},
}),
)
}
/**
* Mount the guide route when the host can serve it.
*
* `ctx.inject` is the normal path: it runs the callback once `webServer` exists,
* and never on a host that has none. The direct fallback keeps older Cordis
* shapes working. Neither path touches the MCP row — that row is the bundle
* patch's, and registering a second `serverName: rea` here would be the exact
* duplicate the shim warns about.
*/
export function apply(ctx) {
if (!ctx) return
if (typeof ctx.inject === 'function') {
try {
ctx.inject(['webServer'], (host) => mountGuideRoute(host))
return
} catch {
/* fall through to the direct check */
}
}
if (ctx.webServer) mountGuideRoute(ctx)
}
+413
View File
@@ -0,0 +1,413 @@
{
"$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.8.0",
"ghidra": "12.1.4",
"jdk": "21",
"apktool": "3.0.3",
"jadxHeadlessMcp": "0.7.1",
"node": "24.22.1",
"nodeEngines": "^22.23.4 || ^24.22.1 || >=26.12.1"
},
"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\\.23\\.(?:[4-9]|[1-9][0-9]+)|22\\.(?:2[4-9]|[3-9][0-9])\\.\\d+|24\\.22\\.(?:[1-9]|[1-9][0-9]+)|24\\.(?:2[3-9]|[3-9][0-9])\\.\\d+|26\\.12\\.(?:[1-9]|[1-9][0-9]+)|26\\.(?:1[3-9]|[2-9][0-9])\\.\\d+|2[7-9]\\.\\d+\\.\\d+|[3-9][0-9]\\.\\d+\\.\\d+)" },
"why": "REA 6.8.0 raised its engines to ^22.23.4 || ^24.22.1 || >=26.12.1, so the pinned Node 24.22.1 is now exactly the floor of the 24 line. A loose '^v\\d+' accepted Node 20, which then fails at REA runtime; the reuse path must reject it here. Enforced as one alternation because a probe has no version comparator: 22.23.4-22.23.x, 22.24+, 24.22.1-24.22.x, 24.23+, 26.12.1-26.12.x, 26.13+, 27+. 22.23.3, 24.22.0 and 26.12.0 are below the declared floor and must not pass."
},
"sources": {
"win32-x64": {
"version": "24.22.1",
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip",
"format": "zip",
"size": 37748064,
"sha256": "d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5",
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-win-x64.zip)",
"sha256VerifiedOn": "2026-10-23",
"note": "Checksum and unpack verified on this machine: the extracted node.exe runs standalone (v24.22.1), no installer and no registry writes. 24.22.1 is Node's 2026-10-21 security release (Brotli decoder out-of-bounds write RCE, two TLS hostname bypasses, eMudhra intermediate removal); 24.21.0 must not be redistributed. REA 6.8.0 declares ^24.22.1 as the floor of the 24 line, so this version is now both the newest 24.x and the oldest acceptable one — a 24.21.x runtime must be rejected by the reuse path even though it was fine under 6.7.0."
},
"win32-arm64": {
"version": "24.22.1",
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-arm64.zip",
"format": "zip",
"size": 33786263,
"sha256": "a0c807319fede24c4a0d8f84564c82a619bd313b16b10c007c383c2609e99171",
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-win-arm64.zip), size confirmed by HEAD",
"sha256VerifiedOn": "2026-10-23"
},
"darwin-x64": {
"version": "24.22.1",
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-x64.tar.gz",
"format": "tar.gz",
"size": 54382566,
"sha256": "d132557e7ee2b85af08b0c8c34338e65c6430f2238d01f727728ef42dc83b035",
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-darwin-x64.tar.gz)",
"sha256VerifiedOn": "2026-10-23"
},
"darwin-arm64": {
"version": "24.22.1",
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-arm64.tar.gz",
"format": "tar.gz",
"size": 53062778,
"sha256": "89c682593d76cd44233fa53b1563264e7de9cbb189cad169135c5b4fadfbc0a7",
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-darwin-arm64.tar.gz)",
"sha256VerifiedOn": "2026-10-23"
},
"linux-x64": {
"version": "24.22.1",
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.xz",
"format": "tar.xz",
"size": 32026916,
"sha256": "6c172a9ab6718f7b801fb5a7bd486af2ee5e32758086a04b84fcce2aa77095e6",
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-linux-x64.tar.xz)",
"sha256VerifiedOn": "2026-10-23",
"note": ".tar.xz is not unpackable by Windows Expand-Archive; bsdtar handles it. A .tar.gz twin exists for every platform if xz support is ever in doubt."
},
"linux-arm64": {
"version": "24.22.1",
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-arm64.tar.xz",
"format": "tar.xz",
"size": 30978868,
"sha256": "dbabf7a1cbf947e6059ffad2c27b80041db97dea1e553b62afdee7f13fca336e",
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-linux-arm64.tar.xz)",
"sha256VerifiedOn": "2026-10-23"
}
}
},
"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",
"regex": "jdk\\.compiler@(?:2[1-9]|[3-9]\\d|\\d{3,})\\b"
},
"why": "Two independent facts, and BOTH are required (runProbe applies contains and regex as an AND). `contains: jdk.compiler` separates a full JDK from a trimmed runtime, but it says nothing about the VERSION: a real Java 17.0.20 also prints `[email protected]`, and setup accepted a JAVA_HOME pointing at it, recorded `{version: 17.0.20, reused: true}`, and only then did Ghidra 12.1 (JDK 21+) fail later in a different process. The regex matches the version segment of that same line and demands major >= 21 (21-29 / 30-99 / 100+); 1x, single digits and two-digit values below 21 do not match. "
},
"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-23",
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=x64&image_type=jdk&os=windows&vendor=eclipse",
"note": "Unpacked and exercised on this machine: bin/javac.exe and jmods are present, java --list-modules reports jdk.compiler, and REA's Ghidra provider accepted it as a 64-bit full JDK 21+."
},
"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-23",
"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-23",
"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-23",
"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-23",
"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-23",
"note": "No platform split for the public release: one zip serves Windows/macOS/Linux. Unpacked and sniffed here (support/analyzeHeadless.bat and the win_x86_64 native decompiler are present, and REA reported provider version 12.1.4). Windows traps: a path containing '!' or '^' breaks Ghidra, and macOS native components are not shipped (they must be built with support/gradle/gradlew buildNatives). Ghidra requires JDK 21+; application.java.max is empty at this tag, so a newer JDK is permitted."
}
}
},
"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-23",
"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-23",
"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-23 and the archive verified readable (15 entries incl. adb.exe). The archive's own platform-tools/source.properties reports Pkg.Revision=37.0.1, so the real pin is 'platform-tools 37.0.1'. Google's repository2-3.xml carries size+checksum for versioned archives if a vendor hash is ever required.",
"sha256VerifiedOn": "2026-10-23",
"note": "Rolling 'latest' URL pinned by observation; setup re-verifies against this value and reports drift. Android SDK components are not OSI-licensed and Google restricts redistribution, so this is fetched from Google at install time and never re-hosted. Also contains fastboot, sqlite3, mke2fs and the USB API DLLs."
},
"darwin-x64": {
"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-23 by downloading the archive and hashing it locally (16,110,554 bytes)",
"sha256VerifiedOn": "2026-10-23",
"note": "Rolling 'latest' URL pinned by observation; setup re-verifies against this value and reports drift."
},
"darwin-arm64": {
"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-23); Google publishes one darwin zip for both architectures.",
"sha256VerifiedOn": "2026-10-23",
"note": "The darwin archive contains x86_64 binaries. On Apple silicon they run only through Rosetta 2; without it adb cannot start and this optional component is reported unavailable."
},
"linux-x64": {
"version": "latest",
"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-23 by downloading the archive and hashing it locally (9,054,187 bytes)",
"sha256VerifiedOn": "2026-10-23",
"note": "The linux archive contains x86_64 binaries. Google publishes no arm64 platform-tools and this bundle does not emulate one, so on linux-arm64 the entry below is offered but its probe is expected to fail — reported as an optional component being unavailable, never as a broken install."
},
"linux-arm64": {
"version": "latest",
"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-23), recorded so the mismatch is explicit instead of implied.",
"sha256VerifiedOn": "2026-10-23",
"note": "Kept so an arm64 host reports a precise failure instead of \"no source\". adb here is x86_64: it runs under qemu/binfmt where the host provides it, and otherwise the probe fails and the report names it."
}
}
},
"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\\.8\\.0(\\s|$)" }
},
"sources": {
"npm": {
"version": "6.8.0",
"registry": "https://registry.npmjs.org/rea-agents",
"integrity": "npm's own dist.integrity is the authoritative check for this tarball: the registry tarball ships NO node_modules (verified: 2398 entries in 6.8.0, 0 under node_modules), so setup resolves the dependency closure with `npm install --ignore-scripts` instead of unpacking a tarball.",
"note": "`just unpacking the published tarball cannot work` is a verified fact, not a caution: the CLI would fail with ERR_MODULE_NOT_FOUND on zod. 6.8.0 (published 2026-10-25, npm latest; 6.6.0 was never on npm) does raise the runtime floor: engines.node went from ^22.19.0 || ^24.11.0 || >=26.0.0 to ^22.23.4 || ^24.22.1 || >=26.12.1, which makes the pinned Node 24.22.1 exactly the floor of its line — hence pins.nodeEngines and the node probe had to move with it. bin is still scripts/rea.mjs, and the dependency closure gained exactly two packages (@bufbuild/protobuf 2.16.0, @msgpack/msgpack 3.1.3), so the same npm install path resolves it. This one IS a tool expansion: the shipped dist/mcp-catalog.json records identity.counts = 229 MCP tools / 175 CLI commands / 6 prompts (6.7.0 shipped no catalog and exposed 207), with new families for APK signatures, binary diagnostics, model artifacts (GGUF/safetensors) and CUDA/HIP code objects. The two version gates this bundle cares about did NOT move: SUPPORTED_GHIDRA_VERSION is still 12.1.4 (so Ghidra 12.2 is still unsupported) and JADX_RELEASE is still 0.7.1 with the same 6e5eacf5… sha256 (so jadx 0.8.0 is still rejected) — those pins stay put."
}
}
}
},
"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."
}
}
+46 -4
View File
@@ -1,8 +1,14 @@
{
"name": "dsh-plugin-rea",
"version": "1.0.0",
"description": "REA (Reverse Engineer Anything) as native DeepSeek Harness tools: a configuration-only Cordis bundle that composes the official @deepseek-ai/dsh-mcp-client row for the rea MCP server, so sessions get mcp__rea__* tools. Third-party adapter — not part of REA. · 把 REA 逆向工具接入 DeepSeek Harness:纯配置型 Cordis bundle,复用官方 MCP 客户端行注册 rea 服务器,会话内直接出现 mcp__rea__* 工具。第三方适配,非 REA 官方组成部分。",
"version": "2.0.16",
"description": "One-plugin bundle: provisions Node/REA/JDK/Ghidra for you, then registers the upstream REA MCP server as mcp__rea__* tools. · 一体包:自动准备 Node/REA/JDK/Ghidra,并把上游 REA 的 MCP 服务接入 DeepSeek Harness。",
"type": "module",
"main": "./index.mjs",
"exports": {
".": "./index.mjs",
"./client": "./client.js",
"./package.json": "./package.json"
},
"license": "MIT",
"author": "czcdr",
"homepage": "https://github.com/czcdr/dsh-plugin-rea",
@@ -27,19 +33,55 @@
"ghidra",
"binary-analysis"
],
"bin": {
"rea-bundle-setup": "./scripts/setup.mjs",
"rea-bundle-status": "./bin/rea-shim.mjs"
},
"files": [
"cordis.patch.yml",
"index.mjs",
"client.js",
"bin/",
"scripts/",
"manifest/",
"docs/bundle-design.md",
"README.md",
"README.zh-CN.md",
"LICENSE",
"CHANGELOG.md",
"check-updates.ps1"
"check-updates.ps1",
"screenshots.json",
"assets/"
],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slots"
]
}
},
"scripts": {
"check-updates": "powershell -NoProfile -File ./check-updates.ps1"
"check-updates": "node ./scripts/check-updates.mjs",
"bundle:setup": "node ./scripts/setup.mjs",
"bundle:status": "node ./bin/rea-shim.mjs --shim-status",
"bundle:dry-run": "node ./scripts/setup.mjs --reuse --dry-run",
"bundle:audit": "node ./scripts/audit-manifest.mjs",
"bundle:deps": "node ./scripts/check-dependency-advisories.mjs",
"bundle:test": "node --test ./scripts/setup.test.mjs ./scripts/deps.test.mjs ./scripts/host-compat.test.mjs ./scripts/hardening.test.mjs",
"bundle:host-compat": "node ./scripts/host-compat.mjs"
},
"engines": {
"node": "^22.23.4 || ^24.22.1 || >=26.12.1",
"dsh": ">=0.1.7-rc.2 <0.3.0-0"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/"
}
}
+4
View File
@@ -0,0 +1,4 @@
[
"assets/screenshot-1.png",
"assets/screenshot-2.png"
]
+547
View File
@@ -0,0 +1,547 @@
#!/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`],
]) {
// `next` is a string and `Number.isFinite` does not coerce, so testing
// the bare split element was always false and this branch never ran —
// item 13 could not fail no matter what the probe accepted. Convert
// first; the guard exists only to skip a NaN major/minor/patch.
if (Number.isFinite(Number(next.split(".")[2])) && probe.test(next)) {
add("error", "reaAgents.probe.expect.regex", `accepts ${next} (${label}), so a newer release passes as the pin`);
}
}
}
}
/* --------------------- 14. a POSIX wrapper must be marked executable */
// copyFileSync carries the archive's 0644 and nothing else changes a mode, while
// REA spawns REA_APKTOOL_COMMAND without a shell: on Linux/macOS an unmarked
// wrapper fails with EACCES even though the component probe (`java -jar`) passes.
// Only the manifest can say which files need the bit, so require it here.
const apktoolWrappers = manifest.components?.apktool?.entries?.byPlatform ?? {};
for (const [platform, list] of Object.entries(apktoolWrappers)) {
if (platform.startsWith("win32")) continue;
for (const entry of list) {
if (entry.executable !== true) {
add(
"error",
`apktool.entries.byPlatform.${platform}`,
`the ${platform} wrapper is not marked "executable": true, so it lands 0644 and cannot be spawned without a shell`,
);
}
}
}
/* ------------------------------------------------------------- summary */
const pinned = fetchEntries.filter((entry) => entry.entry.sha256 && entry.entry.sha256 !== "unverified").length;
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);
}
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env node
/**
* Offline advisory gate for the vendored REA dependency closure.
*
* `scripts/setup.mjs` installs `rea-agents` into `<root>/vendor` from
* `vendor-lock/package-lock.json`. That lock is the only thing that decides which
* zod / minimatch / glob the vendored REA actually runs on, and it is regenerated
* by hand, so it can silently fall behind the advisories it was created for. This
* gate is the part that fails loudly:
*
* - every copy of each advisory-affected package in the lock must be at or above
* the first fixed version;
* - the lock must still pin the REA version the manifest asks for (a stale lock
* would install the wrong server, and setup.mjs would skip the lock only after
* burning a run);
* - the `overrides` that setup.mjs falls back to and the `overrides` recorded in
* vendor-lock/package.json must agree with each other and with the lock, so the
* degraded install path cannot resolve something the audited path would not.
*
* It reads files only: no registry, no network, no npm. Run it in CI next to the
* test suite (`node ./scripts/check-dependency-advisories.mjs`), or by hand after
* changing a pin.
*
* Exit codes: 0 = every floor met, 1 = a floor is violated / the lock is stale or
* inconsistent, 2 = the lockfile could not be read at all.
*
* Regenerating the lock after an upstream pin bump:
*
* cd scripts/vendor-lock
* npm install --package-lock-only --ignore-scripts
*
* and commit the resulting package-lock.json together with the manifest change.
*/
import { existsSync, 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 PLUGIN_ROOT = path.resolve(HERE, "..");
/**
* First fixed version per advisory, with the advisory that set it. The floors are
* the lower bound of "not affected"; the pins in vendor-lock are the versions the
* closure actually resolves and are expected to sit above them.
*/
export const ADVISORY_FLOORS = [
{
name: "zod",
floor: "4.6.6",
advisory: "GHSA-mrhx-c7xc-m3w5",
impact: "memory exhaustion while validating oversized constrained collections",
},
{
name: "minimatch",
floor: "11.0.1",
advisory: "GHSA-g3hv-75wg-m249",
impact: "globparts amplification on an attacker-chosen pattern",
},
{
name: "glob",
floor: "14.0.2",
advisory: "GHSA-3g3m-2fjr-6pw4",
impact: "AbortSignal cancellation bypass keeps traversing after a timeout",
},
];
/* Same comparison setup.mjs uses: dotted numerics, prerelease suffix ignored. */
export function compareVersions(left, right) {
const parts = (value) => String(value).split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
const a = parts(left);
const b = parts(right);
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
const one = a[index] ?? 0;
const other = b[index] ?? 0;
if (one !== other) return one < other ? -1 : 1;
}
return 0;
}
/** Every installed copy of `name` in a lockfile, including nested duplicates. */
export function lockCopies(lock, name) {
const suffix = `node_modules/${name}`;
const found = [];
for (const [key, entry] of Object.entries(lock?.packages ?? {})) {
if (key !== suffix && !key.endsWith(`/${suffix}`)) continue;
found.push({ location: key, version: entry?.version ?? null });
}
return found;
}
/**
* Judge one parsed lockfile.
*
* `setupSource` and `lockPackageSource` are raw texts (or null); the overrides they
* declare are compared against each other and against the resolved versions.
*/
export function evaluateAdvisories(lock, { manifestPin = null, setupSource = null, lockPackageSource = null } = {}) {
const rows = [];
const problems = [];
for (const advisory of ADVISORY_FLOORS) {
const copies = lockCopies(lock, advisory.name);
if (copies.length === 0) {
rows.push({ ...advisory, status: "absent", copies, detail: "not in the closure; no advisory applies" });
continue;
}
const below = copies.filter((copy) => !copy.version || compareVersions(copy.version, advisory.floor) < 0);
if (below.length > 0) {
rows.push({
...advisory,
status: "unsafe",
copies,
detail: `${below.map((copy) => `${copy.version ?? "unknown"} at ${copy.location}`).join(", ")} is below ${advisory.floor}`,
});
problems.push(`${advisory.name}: ${below.map((copy) => copy.version ?? "unknown").join(", ")} < ${advisory.floor} (${advisory.advisory})`);
continue;
}
const distinct = [...new Set(copies.map((copy) => copy.version))].join(", ");
rows.push({
...advisory,
status: "ok",
copies,
detail: `${distinct} across ${copies.length} cop${copies.length === 1 ? "y" : "ies"}`,
});
}
/* The lock has to match the manifest, or provisioning installs a different REA. */
const lockedRea = lock?.packages?.[""]?.dependencies?.["rea-agents"] ?? null;
if (manifestPin && lockedRea !== manifestPin) {
problems.push(
`the lock pins rea-agents@${lockedRea ?? "nothing"} but the manifest pins ${manifestPin}; regenerate scripts/vendor-lock/package-lock.json`,
);
}
/*
* setup.mjs falls back to `npm install` with its own overrides when the lock
* cannot be replayed. That path must not be able to resolve anything the audited
* one would refuse, so the same floors are applied to it, and the two override
* sets must name the same versions.
*/
const declared = [];
const fromSetup = setupSource ? parseOverrides(setupSource, "const CLOSURE_OVERRIDES") : null;
const fromLockPackage = lockPackageSource ? parseOverrides(lockPackageSource, '"overrides"') : null;
if (fromSetup) declared.push({ origin: "scripts/setup.mjs", overrides: fromSetup });
if (fromLockPackage) declared.push({ origin: "vendor-lock/package.json", overrides: fromLockPackage });
for (const { origin, overrides } of declared) {
for (const advisory of ADVISORY_FLOORS) {
const pinned = overrides[advisory.name];
if (!pinned) continue;
if (compareVersions(pinned, advisory.floor) < 0) {
problems.push(`${origin} overrides ${advisory.name} to ${pinned}, below the ${advisory.floor} floor (${advisory.advisory})`);
}
const resolved = lockCopies(lock, advisory.name).map((copy) => copy.version);
if (resolved.length > 0 && !resolved.every((version) => version === pinned)) {
problems.push(
`${origin} overrides ${advisory.name} to ${pinned} but the lock resolves ${[...new Set(resolved)].join(", ")}; the degraded install path would not match the audited one`,
);
}
}
}
if (declared.length === 2) {
for (const advisory of ADVISORY_FLOORS) {
const [first, second] = declared.map((entry) => entry.overrides[advisory.name] ?? null);
if (first !== second) {
problems.push(`overrides disagree for ${advisory.name}: setup.mjs ${first ?? "unset"} vs vendor-lock/package.json ${second ?? "unset"}`);
}
}
}
return { ok: problems.length === 0, rows, problems, lockedRea };
}
/**
* Reads a `{ name: "version", ... }` object out of JavaScript source or JSON text.
*
* setup.mjs is a script, not a module, so its override table is read as text; this
* is deliberately tolerant (it only fails when a key of interest is unreadable).
*/
export function parseOverrides(source, anchor) {
const objectStart = source.indexOf(anchor);
if (objectStart < 0) return null;
const braceAt = source.indexOf("{", objectStart + anchor.length);
const braceEnd = source.indexOf("}", braceAt);
if (braceAt < 0 || braceEnd < 0) return null;
const body = source.slice(braceAt + 1, braceEnd);
const overrides = {};
// JSON form: "zod": "4.6.7" | JS form: zod: "4.6.7" (also '"key": "value"')
for (const match of body.matchAll(/"?(?<name>[A-Za-z0-9@/._-]+)"?\s*:\s*"(?<version>[^"]+)"/g)) {
overrides[match.groups.name] = match.groups.version;
}
return overrides;
}
/** The shipped lock, or a caller-supplied one. */
export function resolveLockPath(candidates) {
const searched = candidates ?? [
path.join(HERE, "vendor-lock", "package-lock.json"),
path.join(PLUGIN_ROOT, "vendor-lock", "package-lock.json"),
];
return searched.find((file) => existsSync(file)) ?? null;
}
export function readJson(file) {
return JSON.parse(readFileSync(file, "utf8"));
}
function main(argv) {
if (argv.includes("--help") || argv.includes("-h")) {
process.stdout.write("usage: node ./scripts/check-dependency-advisories.mjs [--lock <package-lock.json>] [--json]\n");
return 0;
}
const lockFlag = argv.indexOf("--lock");
const lockPath = lockFlag >= 0 ? path.resolve(argv[lockFlag + 1] ?? "") : resolveLockPath();
if (!lockPath || !existsSync(lockPath)) {
process.stderr.write(
`dependency advisories: no lockfile at ${lockPath ?? "scripts/vendor-lock/package-lock.json"}\n` +
" the vendored closure is unverifiable without it; regenerate it with\n" +
" cd scripts/vendor-lock && npm install --package-lock-only --ignore-scripts\n",
);
return 2;
}
let lock;
try {
lock = readJson(lockPath);
} catch (error) {
process.stderr.write(`dependency advisories: ${lockPath} is not readable JSON: ${String(error?.message ?? error)}\n`);
return 2;
}
const manifestPath = path.join(PLUGIN_ROOT, "manifest", "bundle.manifest.json");
const manifestPin = existsSync(manifestPath) ? (readJson(manifestPath)?.pins?.reaAgents ?? null) : null;
const setupPath = path.join(PLUGIN_ROOT, "scripts", "setup.mjs");
const lockPackagePath = path.join(path.dirname(lockPath), "package.json");
const verdict = evaluateAdvisories(lock, {
manifestPin,
setupSource: existsSync(setupPath) ? readFileSync(setupPath, "utf8") : null,
lockPackageSource: existsSync(lockPackagePath) ? readFileSync(lockPackagePath, "utf8") : null,
});
if (argv.includes("--json")) {
process.stdout.write(`${JSON.stringify({ lock: lockPath, manifestPin, ...verdict }, null, 2)}\n`);
return verdict.ok ? 0 : 1;
}
const relative = path.relative(PLUGIN_ROOT, lockPath) || lockPath;
process.stdout.write("dependency advisories — vendored REA closure\n");
process.stdout.write(` lock ${relative} (lockfileVersion ${lock?.lockfileVersion ?? "?"}, ${Object.keys(lock?.packages ?? {}).length} entries)\n`);
process.stdout.write(` manifest rea-agents@${manifestPin ?? "unpinned"}, lock pins ${verdict.lockedRea ?? "nothing"}\n`);
for (const row of verdict.rows) {
const label = row.status === "ok" ? "ok " : row.status === "absent" ? "n/a " : "FAIL";
process.stdout.write(` ${label} ${row.name.padEnd(10)} ${row.detail} [floor ${row.floor}, ${row.advisory}]\n`);
}
if (!verdict.ok) {
for (const problem of verdict.problems) process.stderr.write(`FAIL ${problem}\n`);
return 1;
}
process.stdout.write("ok: every advisory-affected package is at or above its fixed version\n");
return 0;
}
const invokedDirectly =
typeof process.argv[1] === "string" && path.resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase();
if (invokedDirectly) {
process.exitCode = main(process.argv.slice(2));
}
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* `npm run check-updates` — a portable entry for a Windows-only script.
*
* `check-updates.ps1` is a Windows PowerShell tool on purpose: it reads
* `%APPDATA%\npm`, prefers a `rea.cmd` shim over `rea.ps1`, and walks the
* profile the way the desktop app lays it out. On macOS/Linux even a working
* `pwsh` would run those branches against paths that do not exist there, so
* "make the .ps1 cross-platform" is not the fix — the honest answer is to say
* so, and to name the two commands that DO work everywhere.
*
* This wrapper therefore:
* - on Windows: runs `powershell` (the 5.1 the script is written for), and
* falls back to `pwsh` when 5.1 is absent;
* - elsewhere: prints why, plus the portable equivalents, and exits non-zero
* so a CI step cannot mistake "not applicable" for "all green".
*
* Its pure parts are exported so a unit test can pin both branches without
* spawning anything.
*/
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
/** Repository root: this file lives in <root>/scripts/. */
export const ROOT = fileURLToPath(new URL('..', import.meta.url))
/**
* PowerShell executables to try, in order, for a platform.
*
* Empty on POSIX: the script's own probes are Windows-specific, so running it
* there would produce a confident wrong answer instead of a clear "not here".
*/
export function powerShellCandidates(platform = process.platform) {
return platform === 'win32' ? ['powershell', 'pwsh'] : []
}
/** The message used when this platform (or this machine) cannot run the script. */
export function unsupportedMessage(platform = process.platform) {
const where = platform === 'win32'
? 'No PowerShell (powershell or pwsh) was found on this machine.'
: `check-updates.ps1 is a Windows PowerShell tool; this host is ${platform}.`
return [
where,
'',
'The same questions, answered with commands that work on every platform:',
' node bin/rea-shim.mjs --shim-status # provisioned bundle, launch choice, versions',
' node scripts/setup.mjs --dry-run # what a setup run would still fetch',
'',
'On Windows, `npm run check-updates` also compares the installed versions',
'against the upstream registries and the profile registration.',
].join('\n')
}
function main(argv) {
const script = join(ROOT, 'check-updates.ps1')
if (!existsSync(script)) {
process.stderr.write(`check-updates: ${script} is missing from this package.\n`)
return 1
}
const candidates = powerShellCandidates()
if (candidates.length === 0) {
process.stderr.write(`${unsupportedMessage()}\n`)
return 1
}
const env = process.env
const attempts = []
for (const command of candidates) {
const result = spawnSync(command, ['-NoProfile', '-File', script, ...argv], {
stdio: 'inherit',
cwd: ROOT,
env,
windowsHide: true,
})
if (result.error && result.error.code === 'ENOENT') {
attempts.push(command)
continue
}
if (result.error) {
process.stderr.write(`check-updates: ${command} failed to start: ${result.error.message}\n`)
return 1
}
return typeof result.status === 'number' ? result.status : 1
}
process.stderr.write(
`check-updates: tried ${attempts.join(', ')} — none is on PATH.\n\n${unsupportedMessage()}\n`,
)
return 1
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
process.exitCode = main(process.argv.slice(2))
}
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env node
/**
* Tests for the vendored dependency closure: the audited lockfile, the advisory
* gate that guards it, and the provisioning path that replays it.
*
* The defect these pin, measured on the closure `[email protected]` resolves from
* the registry alone: it pins zod exactly (`"zod": "4.4.3"`), 4.4.3 is inside
* GHSA-mrhx-c7xc-m3w5 (fixed in 4.6.6), and its `.max()` array validators turn a
* malformed tool call into an issue-object amplifier — 25 elements into a
* `.max(2)` schema produced 26 issues on 4.4.3 and 1 on 4.6.7. The same closure
* carried [email protected] (GHSA-g3hv-75wg-m249) and [email protected]
* (GHSA-3g3m-2fjr-6pw4) through @electron/asar and frida, and nothing recorded
* which versions an install produced, so two machines a month apart disagreed.
*
* No network and no npm: every check here is a file read or a spawned script.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
ADVISORY_FLOORS,
compareVersions,
evaluateAdvisories,
lockCopies,
parseOverrides,
} from "./check-dependency-advisories.mjs";
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const LOCK_DIR = path.join(REPO_ROOT, "scripts", "vendor-lock");
const LOCK_PATH = path.join(LOCK_DIR, "package-lock.json");
const SETUP_SOURCE = path.join(REPO_ROOT, "scripts", "setup.mjs");
const GATE = path.join(REPO_ROOT, "scripts", "check-dependency-advisories.mjs");
const MANIFEST = path.join(REPO_ROOT, "manifest", "bundle.manifest.json");
const REA_PIN = JSON.parse(readFileSync(MANIFEST, "utf8")).pins.reaAgents;
const readLock = () => JSON.parse(readFileSync(LOCK_PATH, "utf8"));
const cloneLock = () => JSON.parse(JSON.stringify(readLock()));
/** Runs the gate as CI would, against a caller-selected lockfile. */
function runGate(lockFile, extra = []) {
return spawnSync(process.execPath, [GATE, "--lock", lockFile, ...extra], {
cwd: REPO_ROOT,
encoding: "utf8",
timeout: 120000,
});
}
/** Writes `lock` into a throwaway directory and returns the file path. */
function fixtureLock(lock) {
const dir = mkdtempSync(path.join(tmpdir(), "rea-deps-lock-"));
const file = path.join(dir, "package-lock.json");
writeFileSync(file, `${JSON.stringify(lock, null, 2)}\n`);
return { dir, file };
}
/* ------------------------------------------- the shipped closure is not advisory-affected */
test("the shipped lock resolves zod, minimatch and glob at or above every floor", () => {
const lock = readLock();
for (const advisory of ADVISORY_FLOORS) {
const copies = lockCopies(lock, advisory.name);
assert.ok(copies.length > 0, `${advisory.name} must stay in the closure; the escape hatch below would hide its removal`);
for (const copy of copies) {
assert.ok(
compareVersions(copy.version, advisory.floor) >= 0,
`${advisory.name}@${copy.version} at ${copy.location} is below the ${advisory.floor} floor of ${advisory.advisory}`,
);
}
}
});
test("the lock pins the REA the manifest names", () => {
// A lock that pins a different REA is not a fix, it is a different server: the
// closure is resolved for one exact tarball's dependency list.
assert.equal(readLock().packages[""].dependencies["rea-agents"], REA_PIN);
});
test("every advisory-affected package resolves to exactly one version", () => {
// Two copies would mean one of them is the unpatched one; the gate checks all
// copies, and this asserts the closure does not rely on that leniency.
const lock = readLock();
for (const advisory of ADVISORY_FLOORS) {
const versions = [...new Set(lockCopies(lock, advisory.name).map((copy) => copy.version))];
assert.deepEqual(versions.length, 1, `${advisory.name} resolves to ${versions.join(", ")}`);
}
});
/* --------------------------------------------------------------- the gate itself */
test("the gate passes the shipped lock and prints the three rows", () => {
const run = spawnSync(process.execPath, [GATE], { cwd: REPO_ROOT, encoding: "utf8", timeout: 120000 });
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
assert.match(run.stdout, /dependency advisories/);
for (const advisory of ADVISORY_FLOORS) {
assert.match(run.stdout, new RegExp(`${advisory.name}\\s+\\S+`), `the report must name ${advisory.name}`);
assert.match(run.stdout, new RegExp(advisory.advisory), `the report must cite ${advisory.advisory}`);
}
assert.doesNotMatch(run.stderr, /FAIL/);
});
test("the gate fails a copy below the zod floor", () => {
const lock = cloneLock();
lock.packages["node_modules/zod"].version = "4.4.3";
const fixture = fixtureLock(lock);
try {
const run = runGate(fixture.file);
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
assert.match(run.stderr, /zod: 4\.4\.3 < 4\.6\.6 \(GHSA-mrhx-c7xc-m3w5\)/);
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
test("the gate fails a NESTED copy below the minimatch floor even when the top copy is fine", () => {
// The amplification advisory is about whichever copy is imported; a patched
// top-level package with an unpatched duplicate underneath is the realistic
// regression when an upstream dependency gains its own minimatch.
const lock = cloneLock();
lock.packages["node_modules/incur/node_modules/minimatch"] = { version: "10.2.6", resolved: "fixture", integrity: "" };
const fixture = fixtureLock(lock);
try {
const run = runGate(fixture.file);
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
assert.match(run.stderr, /minimatch: 10\.2\.6 < 11\.0\.1 \(GHSA-g3hv-75wg-m249\)/);
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
test("the gate fails a glob below the AbortSignal floor", () => {
const lock = cloneLock();
lock.packages["node_modules/glob"].version = "14.0.1";
const fixture = fixtureLock(lock);
try {
const run = runGate(fixture.file);
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
assert.match(run.stderr, /glob: 14\.0\.1 < 14\.0\.2 \(GHSA-3g3m-2fjr-6pw4\)/);
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
test("a missing lockfile is unverifiable, not safe", () => {
const dir = mkdtempSync(path.join(tmpdir(), "rea-deps-missing-"));
try {
const run = runGate(path.join(dir, "package-lock.json"));
assert.equal(run.status, 2, `${run.stdout}\n${run.stderr}`);
assert.match(run.stderr, /no lockfile/);
assert.match(run.stderr, /npm install --package-lock-only/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a package that left the closure is reported, and is not a violation", () => {
// An upstream REA that stops depending on minimatch must not leave a gate that
// fails forever for a package nobody imports.
const lock = cloneLock();
delete lock.packages["node_modules/minimatch"];
const fixture = fixtureLock(lock);
try {
const run = runGate(fixture.file);
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
assert.match(run.stdout, /n\/a\s+minimatch\s+not in the closure/);
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
test("a lock that lags the manifest is refused as stale", () => {
const verdict = evaluateAdvisories(readLock(), { manifestPin: "6.9.0" });
assert.equal(verdict.ok, false);
assert.ok(
verdict.problems.some((problem) => /pins rea-agents@6\.8\.0 but the manifest pins 6\.9\.0/.test(problem)),
verdict.problems.join("\n"),
);
});
/* --------------------------------- the degraded install path cannot undo the fix */
test("setup.mjs's fallback overrides sit at or above the floors", () => {
const overrides = parseOverrides(readFileSync(SETUP_SOURCE, "utf8"), "const CLOSURE_OVERRIDES");
assert.ok(overrides, "provisionReaAgents must keep a readable CLOSURE_OVERRIDES table for the no-lock fallback");
for (const advisory of ADVISORY_FLOORS) {
assert.equal(typeof overrides[advisory.name], "string", `${advisory.name} must be overridden explicitly`);
assert.ok(
compareVersions(overrides[advisory.name], advisory.floor) >= 0,
`setup.mjs overrides ${advisory.name} to ${overrides[advisory.name]}, below ${advisory.floor}`,
);
}
});
test("setup.mjs and vendor-lock/package.json declare the same overrides", () => {
const fromSetup = parseOverrides(readFileSync(SETUP_SOURCE, "utf8"), "const CLOSURE_OVERRIDES");
const fromLockPackage = parseOverrides(readFileSync(path.join(LOCK_DIR, "package.json"), "utf8"), '"overrides"');
for (const advisory of ADVISORY_FLOORS) {
assert.equal(
fromSetup[advisory.name],
fromLockPackage[advisory.name],
`the lock and the fallback disagree on ${advisory.name}; one of the two install paths would resolve differently`,
);
}
});
test("an override below a floor is caught even when the lock is clean", () => {
// The lock is what runs when `ci` works; the override table is what runs when it
// does not, and only the second one is edited by hand.
const sabotaged = `const CLOSURE_OVERRIDES = { zod: "4.4.3", minimatch: "10.2.6", glob: "13.0.7" };\n`;
const verdict = evaluateAdvisories(readLock(), {
manifestPin: REA_PIN,
setupSource: sabotaged,
lockPackageSource: readFileSync(path.join(LOCK_DIR, "package.json"), "utf8"),
});
assert.equal(verdict.ok, false);
for (const advisory of ADVISORY_FLOORS) {
assert.ok(
verdict.problems.some((problem) => problem.includes(advisory.name) && problem.includes(advisory.floor)),
`expected a floor complaint for ${advisory.name}; got:\n${verdict.problems.join("\n")}`,
);
}
});
test("setup.mjs's own zod floor is the advisory's fixed version", () => {
// ZOD_FLOOR decides whether a provisioned closure is accepted, so it has to be
// the advisory's number, not a value that drifted with a pin bump.
const source = readFileSync(SETUP_SOURCE, "utf8");
const match = source.match(/const ZOD_FLOOR = "([^"]+)";/);
assert.ok(match, "provisionReaAgents must verify the resolved zod against an explicit floor");
const zodFloor = ADVISORY_FLOORS.find((advisory) => advisory.name === "zod").floor;
assert.equal(compareVersions(match[1], zodFloor), 0, `setup.mjs floor ${match[1]} != advisory floor ${zodFloor}`);
});
/* ---------------------------------------------- provisioning replays the lock, in staging */
test("provisionReaAgents installs from the shipped lock, after staging, and never blind", () => {
const source = readFileSync(SETUP_SOURCE, "utf8");
const start = source.indexOf("async function provisionReaAgents(component, context) {");
assert.ok(start > 0, "provisionReaAgents must still exist");
const end = source.slice(start).search(/\r?\n\}\r?\n/);
assert.ok(end > 0, "provisionReaAgents must have a closing brace");
const body = source.slice(start, start + end);
// The old shape: one `npm install rea-agents@x --no-save` straight into the live
// vendor tree, which is neither reproducible nor safe to interrupt.
assert.doesNotMatch(body, /--no-save/, "a replayed lock replaces the blind `--no-save` install");
assert.match(body, /vendor-lock/, "the function must look for the audited lock");
assert.match(body, /npmRun\("ci"\)/, "the lock must be replayed with `npm ci`");
assert.match(body, /--ignore-scripts/, "lifecycle scripts stay off on both routes");
// Staging before the swap: `npm ci` deletes node_modules first, so running it in
// place would destroy a working install on any network failure.
const stagingAt = body.indexOf('.npm-staging');
const swapAt = body.indexOf("renameSync(modules, live)");
assert.ok(stagingAt > 0 && swapAt > stagingAt, "the closure must be installed into staging and renamed in afterwards");
// The fallback route must keep the patched pins, and the result is verified
// whatever route produced it.
assert.match(body, /npmRun\("install"\)/);
assert.match(body, /atLeast\(zodVersion, ZOD_FLOOR\)/);
});
test("compareVersions orders dotted versions numerically, not as strings", () => {
assert.equal(compareVersions("4.6.7", "4.6.6"), 1);
assert.equal(compareVersions("4.4.3", "4.6.6"), -1);
assert.equal(compareVersions("4.6.6", "4.6.6"), 0);
assert.equal(compareVersions("11.0.5", "11.0.1"), 1);
assert.equal(compareVersions("14.0.4", "14.0.2"), 1);
// The string-comparison trap: "10" < "9" lexically, but 10 is newer than 9.
assert.equal(compareVersions("10.2.6", "9.9.9"), 1);
});
test("the shipped lock is what the gate reads by default", () => {
assert.ok(existsSync(LOCK_PATH), "scripts/vendor-lock/package-lock.json must be committed");
assert.ok(existsSync(path.join(LOCK_DIR, "package.json")), "the lock's package.json must be committed next to it");
});
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env node
/**
* ghidra-gate-override — run a Ghidra release REA has not verified, on purpose.
*
* Why this exists
* REA ships `SUPPORTED_GHIDRA_VERSION` (dist/ghidra/GhidraReleasePolicy.js, inlined
* into the CLI and MCP bundles). A build outside that major.minor is reported as
* `unsupported` and the whole Ghidra family is withdrawn, even when the bridge
* compiles and analyses fine against the newer jars. On 2026-10-26 Ghidra 12.2 was
* measured to produce byte-identical segments, procedure inventory and `entry`
* pseudocode on a PE sample, so the block is upstream's *unverified*, not broken.
*
* The release that fixes Ghidra 12.1.4's four CVEs is 12.2, so staying on 12.1.4 is
* a choice to keep an unpatched decompiler/loader in the chain. This script makes
* the opposite choice explicit, recorded and reversible — it never happens silently.
*
* What it changes
* Only the version constant inside the *vendored* rea-agents copy, and only for the
* files that declare it. Every original value — plus the SHA-256 of each original
* file — is written to <root>/ghidra-gate-override.json BEFORE the first byte is
* patched, so an interrupted run is recoverable and --disable can prove it restored
* exactly what was there. A patch that fails partway through rolls back the files it
* already wrote and says so.
*
* What it does NOT do
* It does not verify the new release for you. Everything REA's own gate would have
* protected against — bridge API drift, decompiler-hook drift, non-PE loaders — is
* now your risk. Re-running `setup.mjs` re-provisions rea-agents and removes the
* patch, which puts the bundle back on the pinned, verified release.
*
* Usage
* node scripts/ghidra-gate-override.mjs --status [--root <dir>]
* node scripts/ghidra-gate-override.mjs --enable --version 12.2 [--root <dir>]
* node scripts/ghidra-gate-override.mjs --disable [--root <dir>]
*
* The root is `--root`, else REA_BUNDLE_ROOT, else <DSH_HOME>/plugins/rea-bundle,
* else <home>/.dsh/plugins/rea-bundle — the same chain setup.mjs, the shim and
* scripts/rea-bundle-bootstrap.ps1 use.
*/
import { createHash } from "node:crypto";
import { existsSync, lstatSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
const HERE = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
const CONSTANT = "SUPPORTED_GHIDRA_VERSION";
const RECORD = "ghidra-gate-override.json";
function parseArgs(argv) {
const out = { mode: "status", version: null, root: null };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--enable") out.mode = "enable";
else if (arg === "--disable") out.mode = "disable";
else if (arg === "--status") out.mode = "status";
else if (arg === "--version") out.version = argv[++i] ?? null;
else if (arg === "--root") out.root = argv[++i] ?? null;
else if (arg === "--help" || arg === "-h") out.mode = "help";
else throw new Error(`unknown argument ${arg}`);
}
return out;
}
/**
* Same root rule as scripts/setup.mjs, bin/rea-shim.mjs and
* scripts/rea-bundle-bootstrap.ps1: `--root`, then REA_BUNDLE_ROOT, then
* <DSH_HOME>/plugins/rea-bundle, then <home>/.dsh/plugins/rea-bundle.
*
* REA_BUNDLE_ROOT used to be ignored here, so on a machine that provisions into
* a custom root `--status` (and every patch) silently addressed the default
* root: either "no vendored REA" or — worse — a different installation.
*/
function defaultRoot() {
const override = process.env.REA_BUNDLE_ROOT;
if (override && override.trim() !== "") return path.resolve(override);
const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== "" ? path.resolve(process.env.DSH_HOME) : null;
if (home) return path.join(home, "plugins", "rea-bundle");
const profile = process.env.USERPROFILE || (process.env.HOMEDRIVE ?? "") + (process.env.HOMEPATH ?? "") || process.env.HOME;
return path.join(profile || homedir(), ".dsh", "plugins", "rea-bundle");
}
/** Case-insensitive on Windows, where two spellings can name one directory. */
function within(base, target) {
const norm = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
const from = norm(base);
const to = norm(target);
return to === from || to.startsWith(from + path.sep);
}
/**
* Resolve one record-supplied path inside the bundle, refusing escapes.
*
* The record is written by this script, but it lives in a directory the user can
* edit and a half-finished restore can mangle, and `--disable` joined
* `entry.file` straight onto the vendor root: a `"file": "../../../elsewhere"`
* entry would have made this script rewrite a file outside the bundle. Local
* write access is still required, so this is containment, not a remote escape.
*/
function containedPath(baseDir, relative, label) {
if (typeof relative !== "string" || relative.trim() === "") {
throw new Error(`${label} names no file`);
}
const base = path.resolve(baseDir);
const target = path.resolve(base, relative);
if (!within(base, target)) {
throw new Error(`${label} escapes ${base} (${relative}); refusing to touch it`);
}
// Resolve every segment by hand so a symlinked directory cannot carry the
// target outside the bundle after the string check above passed.
let current = base;
for (const segment of path.relative(base, target).split(path.sep)) {
if (segment === "") continue;
current = path.join(current, segment);
let stat;
try {
stat = lstatSync(current);
} catch {
break; // absent: the caller's existsSync decides what that means
}
if (stat.isSymbolicLink()) {
throw new Error(`${label} goes through the symbolic link ${current}; refusing to follow it`);
}
}
return target;
}
function walk(dir, out = []) {
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) walk(full, out);
else if (entry.endsWith(".js")) out.push(full);
}
return out;
}
function readJson(file) {
const text = readFileSync(file, "utf8").replace(/^\uFEFF/, "");
return JSON.parse(text);
}
/** Every file in the vendored REA that declares the constant, with its value. */
function findDeclarations(vendorRoot) {
const found = [];
for (const file of walk(vendorRoot)) {
const text = readFileSync(file, "utf8");
const match = new RegExp(`${CONSTANT}\\s*=\\s*"([^"]+)"`).exec(text);
if (match) found.push({ file, value: match[1] });
}
return found;
}
function replaceValue(file, from, to) {
const text = readFileSync(file, "utf8");
const pattern = new RegExp(`(${CONSTANT}\\s*=\\s*)"${from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`);
const next = text.replace(pattern, `$1"${to}"`);
if (next === text) throw new Error(`${path.basename(file)} did not contain ${CONSTANT} = "${from}"`);
writeFileSync(file, next, "utf8");
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.mode === "help") {
console.log("node scripts/ghidra-gate-override.mjs --status | --enable --version <x.y[.z]> | --disable [--root <dir>]");
return;
}
const root = args.root ? path.resolve(args.root) : defaultRoot();
const vendor = path.join(root, "vendor", "node_modules", "rea-agents");
const record = path.join(root, RECORD);
if (!existsSync(vendor)) throw new Error(`no vendored REA at ${vendor} — run scripts/setup.mjs first`);
const declarations = findDeclarations(vendor);
if (declarations.length === 0) throw new Error(`none of the ${CONSTANT} declarations were found under ${vendor}`);
const values = [...new Set(declarations.map((entry) => entry.value))];
const active = existsSync(record) ? readJson(record) : null;
if (args.mode === "status") {
console.log(
JSON.stringify(
{
root,
vendoredRea: vendor,
declarations: declarations.map((entry) => ({
file: path.relative(vendor, entry.file),
value: entry.value,
})),
override: active,
state: active ? `overridden → ${values.join(", ")}` : `upstream gate (${values.join(", ")})`,
},
null,
2,
),
);
return;
}
if (args.mode === "disable") {
if (!active) {
console.log("no override is recorded; nothing to restore");
return;
}
let restored = 0;
let stale = 0;
for (const entry of active.files) {
// `entry.file` comes out of a JSON file, not out of the vendor tree: keep
// every restore inside the bundle (no `..`, no symlinks) before touching it.
const file = containedPath(vendor, entry.file, `record entry ${JSON.stringify(entry.file)}`);
if (!existsSync(file)) {
stale += 1;
continue;
}
const text = readFileSync(file, "utf8");
const patchedPattern = new RegExp(`${CONSTANT}\\s*=\\s*"${entry.patched.replace(/\./g, "\\.")}"`);
if (patchedPattern.test(text)) {
replaceValue(file, entry.patched, entry.original);
if (typeof entry.originalSha256 === "string") {
const digest = createHash("sha256").update(readFileSync(file)).digest("hex");
if (digest !== entry.originalSha256) {
throw new Error(
`${entry.file} was restored but does not match the recorded original hash (${digest}); ` +
"the file changed outside this script — inspect it before restoring the rest",
);
}
}
restored += 1;
} else if (new RegExp(`${CONSTANT}\\s*=\\s*"${entry.original.replace(/\./g, "\\.")}"`).test(text)) {
stale += 1;
} else {
throw new Error(`${entry.file} declares neither ${entry.patched} nor ${entry.original}; inspect it before restoring`);
}
}
unlinkSync(record);
console.log(
`restored ${CONSTANT} = "${active.originalVersion}" in ${restored} file(s)` +
(stale > 0 ? `; ${stale} file(s) had already been replaced by a re-provision` : "") +
"; override record removed",
);
return;
}
// enable
const version = args.version;
if (!version || !/^\d+\.\d+(\.\d+)?$/.test(version)) {
throw new Error("--version must look like 12.2 or 12.2.1");
}
if (values.includes(version)) {
console.log(`already ${version} — nothing to do`);
return;
}
if (active) {
// A record that says its own rollback failed describes files that are still
// patched. It must not be mistaken for the "stale record" case below, which
// unlinks it: that would strand those files with no record of their originals.
if (active.status === "rollback-failed") {
throw new Error(
`${record} records a patch that could not be rolled back; run --disable to finish restoring before enabling again`,
);
}
// Re-provisioning rea-agents replaces the tree, so a record can outlive the patch
// it describes. That stale record must not block the next --enable, and it must
// not be trusted by --disable either: judge it by the files on disk.
const stillApplied = active.files.every((entry) => {
const file = containedPath(vendor, entry.file, `record entry ${JSON.stringify(entry.file)}`);
if (!existsSync(file)) return false;
const text = readFileSync(file, "utf8");
return new RegExp(`${CONSTANT}\\s*=\\s*"${entry.patched.replace(/\./g, "\\.")}"`).test(text);
});
if (stillApplied) {
throw new Error(`an override is already recorded (→ ${active.requestedVersion}); run --disable first`);
}
console.log(`previous record is stale (rea-agents was re-provisioned); replacing it`);
unlinkSync(record);
}
// Record every original BEFORE the first write to a vendored file: a failure
// halfway through used to leave patched files that no record described, and a
// record written afterwards cannot describe a process that died mid-loop.
const originals = declarations.map((entry) => {
const bytes = readFileSync(entry.file);
return {
file: path.relative(vendor, entry.file),
absolute: entry.file,
original: entry.value,
patched: version,
originalSha256: createHash("sha256").update(bytes).digest("hex"),
bytes,
};
});
const bundle = existsSync(path.join(root, "bundle.json")) ? readJson(path.join(root, "bundle.json")) : null;
const payload = {
schema: 2,
requestedVersion: version,
originalVersion: values.length === 1 ? values[0] : values.join(","),
files: originals.map(({ file, original, patched: patchedValue, originalSha256 }) => ({
file,
original,
patched: patchedValue,
originalSha256,
})),
ghidraPath: bundle?.paths?.ghidra ?? null,
enabledAt: new Date().toISOString(),
status: "patching",
warning:
"REA did not verify this Ghidra release. The gate existed because the bridge is only tested against the pinned one; re-running setup.mjs re-provisions rea-agents and removes this patch.",
};
// A crash between here and the final write still leaves a complete, verifiable
// description of what must be put back.
writeFileSync(record, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
const patched = [];
try {
for (const entry of originals) {
replaceValue(entry.absolute, entry.original, version);
patched.push(entry);
}
// Read back: the whole point is that the gate now reads a value REA accepts.
for (const entry of originals) {
const text = readFileSync(entry.absolute, "utf8");
if (!new RegExp(`${CONSTANT}\\s*=\\s*"${version.replace(/\./g, "\\.")}"`).test(text)) {
throw new Error(`readback failed for ${entry.file}`);
}
}
} catch (error) {
const failed = [];
for (const entry of patched) {
try {
writeFileSync(entry.absolute, entry.bytes);
} catch (rollbackError) {
failed.push(`${entry.file}: ${rollbackError && rollbackError.message ? rollbackError.message : String(rollbackError)}`);
}
}
const reason = error && error.message ? error.message : String(error);
if (failed.length > 0) {
// Keep the record: it still names every original value and hash, so a
// `--disable` can finish the restore this run could not.
writeFileSync(
record,
`${JSON.stringify({ ...payload, status: "rollback-failed", rollbackErrors: failed }, null, 2)}\n`,
"utf8",
);
throw new Error(
`patch failed (${reason}); ${patched.length - failed.length} file(s) rolled back, ` +
`${failed.length} could not be restored (see ${record}) — run --disable`,
);
}
unlinkSync(record);
throw new Error(`patch failed (${reason}); rolled back ${patched.length} file(s), nothing left changed`);
}
writeFileSync(record, `${JSON.stringify({ ...payload, status: "applied" }, null, 2)}\n`, "utf8");
console.log(`patched ${originals.length} file(s) → ${CONSTANT} = "${version}"`);
console.log(`recorded ${record}`);
console.log("restart dsh (or reload the profile) so the MCP row reconnects");
}
try {
main();
} catch (error) {
console.error(`ghidra-gate-override: ${error && error.message ? error.message : String(error)}`);
process.exitCode = 1;
}
+517
View File
@@ -0,0 +1,517 @@
#!/usr/bin/env node
/**
* Regression tests for the "script consistency / state integrity" fixes.
*
* Three defects, one theme: a tool that answers for a *different* machine state
* than the one in front of it.
*
* 1. `check-updates.ps1` printed "无需动作" (and exited 0) while the same run
* had already reported a pin drift. Green now requires no actions, no
* drift, no unrecorded versions and no warning — and the exit code follows.
* 2. `bin/rea-guide.mjs` ignored REA_BUNDLE_ROOT, so a custom-root machine was
* reported `missing` by the one screen that exists to decide "run setup?".
* 3. `scripts/ghidra-gate-override.mjs` ignored REA_BUNDLE_ROOT too, patched
* files before it wrote the recovery record, and trusted `entry.file` from
* that record without keeping the target inside the bundle.
*
* The PowerShell tests build a throwaway bundle in a temp directory and point
* REA_BUNDLE_ROOT at it — the live install under DSH_HOME is never touched. The
* registry is a local stub, so these tests do not need the network and do not
* turn "upstream published a newer version today" into a red build.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = path.resolve(HERE, "..");
const GUIDE = path.join(PLUGIN_ROOT, "bin", "rea-guide.mjs");
const GHIDRA_OVERRIDE = path.join(PLUGIN_ROOT, "scripts", "ghidra-gate-override.mjs");
const CHECK_UPDATES = path.join(PLUGIN_ROOT, "check-updates.ps1");
const MANIFEST = JSON.parse(readFileSync(path.join(PLUGIN_ROOT, "manifest", "bundle.manifest.json"), "utf8"));
const PINS = MANIFEST.pins;
/** A temp directory that removes itself when the test ends. */
function tempDir(t, label) {
const dir = mkdtempSync(path.join(tmpdir(), `rea-hardening-${label}-`));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
/** Run one Node script and return its UTF-8 output. */
function runNode(script, args, env) {
const result = spawnSync(process.execPath, [script, ...args], {
env: { ...process.env, ...env },
encoding: "utf8",
windowsHide: true,
});
return { status: result.status, output: `${result.stdout ?? ""}${result.stderr ?? ""}` };
}
/* ------------------------------------------------------- 2. bin/rea-guide.mjs */
test("rea-guide: REA_BUNDLE_ROOT is the root the guide answers for", (t) => {
const base = tempDir(t, "guide-root");
const home = path.join(base, "home");
const root = path.join(base, "custom-root");
mkdirSync(home, { recursive: true });
mkdirSync(root, { recursive: true });
writeFileSync(path.join(root, "bundle.json"), JSON.stringify({ versions: { reaAgents: PINS.reaAgents } }));
const env = { DSH_HOME: home, REA_BUNDLE_ROOT: root };
const run = runNode(GUIDE, ["--json"], env);
assert.equal(run.status, 0, run.output);
const state = JSON.parse(run.output);
assert.equal(state.state, "ok", `a provisioned custom root must not read as missing: ${run.output}`);
assert.equal(path.resolve(state.bundlePath), path.resolve(root, "bundle.json"));
assert.equal(path.resolve(state.bundleRoot), path.resolve(root));
// The remedy has to name the root it is talking about, or the copy-pasted
// command provisions the default root and leaves this one untouched.
assert.match(state.setupCommand, /--root/);
assert.ok(state.setupCommand.includes(root), state.setupCommand);
});
test("rea-guide: a missing custom root is reported as missing, still with --root", (t) => {
const base = tempDir(t, "guide-missing");
const home = path.join(base, "home");
const root = path.join(base, "never-provisioned");
mkdirSync(home, { recursive: true });
const run = runNode(GUIDE, ["--json"], { DSH_HOME: home, REA_BUNDLE_ROOT: root });
assert.equal(run.status, 0, run.output);
const state = JSON.parse(run.output);
assert.equal(state.state, "missing");
assert.match(state.setupCommand, /--root/);
});
test("rea-guide: the default root keeps producing the plain --reuse command", (t) => {
const home = tempDir(t, "guide-default");
const run = runNode(GUIDE, ["--json"], { DSH_HOME: home, REA_BUNDLE_ROOT: "" });
assert.equal(run.status, 0, run.output);
const state = JSON.parse(run.output);
assert.equal(state.state, "missing");
assert.equal(path.resolve(state.bundlePath), path.resolve(home, "plugins", "rea-bundle", "bundle.json"));
assert.doesNotMatch(state.setupCommand, /--root/);
});
test("rea-guide: --root overrides both the environment and the default", (t) => {
const base = tempDir(t, "guide-flag");
const root = path.join(base, "from-flag");
mkdirSync(root, { recursive: true });
writeFileSync(path.join(root, "bundle.json"), JSON.stringify({ versions: { reaAgents: PINS.reaAgents } }));
const run = runNode(GUIDE, ["--json", "--root", root], { DSH_HOME: path.join(base, "home") });
assert.equal(run.status, 0, run.output);
const state = JSON.parse(run.output);
assert.equal(state.state, "ok", run.output);
assert.equal(path.resolve(state.bundlePath), path.resolve(root, "bundle.json"));
});
/* --------------------------------------------- 3/4/5. ghidra-gate-override.mjs */
const FILES = ["dist/a-policy.js", "dist/b-cli.js", "dist/c-mcp.js"];
/** A throwaway vendored rea-agents tree with the gate constant in three files. */
function makeVendor(t, label) {
const base = tempDir(t, label);
const root = path.join(base, "root");
const vendor = path.join(root, "vendor", "node_modules", "rea-agents");
mkdirSync(vendor, { recursive: true });
for (const relative of FILES) {
mkdirSync(path.join(vendor, path.dirname(relative)), { recursive: true });
writeFileSync(path.join(vendor, relative), `const SUPPORTED_GHIDRA_VERSION = "12.1.4";\n`);
}
writeFileSync(path.join(root, "bundle.json"), JSON.stringify({ paths: { ghidra: path.join(base, "ghidra") } }));
return { base, root, vendor, env: { DSH_HOME: path.join(base, "home"), REA_BUNDLE_ROOT: root } };
}
function declared(vendor) {
return FILES.map((relative) => {
const match = /SUPPORTED_GHIDRA_VERSION = "([^"]+)"/.exec(readFileSync(path.join(vendor, relative), "utf8"));
return match ? match[1] : null;
});
}
test("ghidra-gate-override: REA_BUNDLE_ROOT and --root select the root", (t) => {
const fixture = makeVendor(t, "gate-status");
const fromEnv = runNode(GHIDRA_OVERRIDE, ["--status"], fixture.env);
assert.equal(fromEnv.status, 0, fromEnv.output);
assert.equal(path.resolve(JSON.parse(fromEnv.output).root), path.resolve(fixture.root));
const fromFlag = runNode(GHIDRA_OVERRIDE, ["--status", "--root", fixture.root], {
DSH_HOME: path.join(fixture.base, "elsewhere"),
REA_BUNDLE_ROOT: "",
});
assert.equal(fromFlag.status, 0, fromFlag.output);
assert.equal(path.resolve(JSON.parse(fromFlag.output).root), path.resolve(fixture.root));
});
test("ghidra-gate-override: --enable then --disable round-trips on a custom root", (t) => {
const fixture = makeVendor(t, "gate-roundtrip");
const enable = runNode(GHIDRA_OVERRIDE, ["--enable", "--version", "12.2"], fixture.env);
assert.equal(enable.status, 0, enable.output);
assert.deepEqual(declared(fixture.vendor), ["12.2", "12.2", "12.2"]);
const recordPath = path.join(fixture.root, "ghidra-gate-override.json");
const record = JSON.parse(readFileSync(recordPath, "utf8"));
assert.equal(record.status, "applied");
assert.equal(record.files.length, FILES.length);
for (const entry of record.files) {
// The point of recording first: --disable can prove it restored the bytes.
assert.equal(typeof entry.originalSha256, "string");
assert.equal(entry.originalSha256.length, 64);
}
const disable = runNode(GHIDRA_OVERRIDE, ["--disable"], fixture.env);
assert.equal(disable.status, 0, disable.output);
assert.deepEqual(declared(fixture.vendor), ["12.1.4", "12.1.4", "12.1.4"]);
assert.equal(existsSync(recordPath), false);
});
test("ghidra-gate-override: originals are recorded before the first patch", (t) => {
const fixture = makeVendor(t, "gate-record-first");
// A read-only last file fails the third write. The first two must be rolled
// back, and the run must say so — a half-applied patch with no record is what
// the old order produced when it died in the middle.
const readOnly = path.join(fixture.vendor, FILES[FILES.length - 1]);
chmodSync(readOnly, 0o444);
let enable;
try {
enable = runNode(GHIDRA_OVERRIDE, ["--enable", "--version", "12.2"], fixture.env);
} finally {
chmodSync(readOnly, 0o666);
}
assert.equal(enable.status, 1, enable.output);
assert.match(enable.output, /rolled back 2 file\(s\), nothing left changed/);
assert.deepEqual(declared(fixture.vendor), ["12.1.4", "12.1.4", "12.1.4"]);
assert.equal(existsSync(path.join(fixture.root, "ghidra-gate-override.json")), false);
});
test("ghidra-gate-override: --disable refuses a record that escapes the vendor tree", (t) => {
const fixture = makeVendor(t, "gate-escape");
const outside = path.join(fixture.base, "outside.js");
writeFileSync(outside, `const SUPPORTED_GHIDRA_VERSION = "12.2";\n`);
const escape = path.relative(fixture.vendor, outside); // ../../../../outside.js
writeFileSync(
path.join(fixture.root, "ghidra-gate-override.json"),
JSON.stringify({
schema: 1,
requestedVersion: "12.2",
originalVersion: "12.1.4",
files: [{ file: escape, original: "12.1.4", patched: "12.2" }],
}),
);
const disable = runNode(GHIDRA_OVERRIDE, ["--disable"], fixture.env);
assert.equal(disable.status, 1, disable.output);
assert.match(disable.output, /escapes/);
assert.match(readFileSync(outside, "utf8"), /"12\.2"/);
});
test("ghidra-gate-override: --disable refuses a record that goes through a symlink", (t) => {
const fixture = makeVendor(t, "gate-symlink");
const outside = path.join(fixture.base, "outside-dir");
mkdirSync(outside, { recursive: true });
writeFileSync(path.join(outside, "x.js"), `const SUPPORTED_GHIDRA_VERSION = "12.2";\n`);
const link = path.join(fixture.vendor, "linked");
try {
symlinkSync(outside, link, "junction");
} catch (error) {
t.skip(`this host cannot create a link: ${error.code || error.message}`);
return;
}
writeFileSync(
path.join(fixture.root, "ghidra-gate-override.json"),
JSON.stringify({
schema: 1,
requestedVersion: "12.2",
originalVersion: "12.1.4",
files: [{ file: "linked/x.js", original: "12.1.4", patched: "12.2" }],
}),
);
const disable = runNode(GHIDRA_OVERRIDE, ["--disable"], fixture.env);
assert.equal(disable.status, 1, disable.output);
assert.match(disable.output, /symbolic link/);
assert.match(readFileSync(path.join(outside, "x.js"), "utf8"), /"12\.2"/);
});
/* ------------------------------------------------ 1. check-updates.ps1 (drift) */
function powerShell() {
const candidates = process.platform === "win32" ? ["powershell", "pwsh"] : [];
for (const command of candidates) {
const probe = spawnSync(command, ["-NoProfile", "-Command", "exit 0"], { windowsHide: true });
if (!probe.error && probe.status === 0) return command;
}
return null;
}
/**
* Windows PowerShell 5.1 writes host output in the console code page, so the
* bytes are not always UTF-8. Decode as UTF-8 first and fall back to GBK when
* the result contains replacement characters.
*/
function decodeOutput(buffer) {
if (!buffer || buffer.length === 0) return "";
const utf8 = buffer.toString("utf8");
if (!utf8.includes("\uFFFD")) return utf8;
try {
return new TextDecoder("gbk").decode(buffer);
} catch {
return utf8;
}
}
function runPowerShell(command, args, env) {
// Force UTF-8 out of the host process.
//
// A Windows CI runner answers in CP437, where every CJK character collapses to
// "?" — irreversibly, so `decodeOutput`'s GBK fallback cannot rescue it and
// every assertion below fails on a host where the script is perfectly fine.
// (Measured: the conclusion line arrived as
// `[??] ????:pin ?? 1 ?;?? 2 ?(????)`.) Setting this in the CHILD keeps the
// shipped script's behaviour untouched — a test harness must not change what
// the product prints just to make its own assertions match.
// Parameter NAMES must stay unquoted: `& 'script.ps1' '-Registry' 'url'` hands
// PowerShell a positional string "-Registry" instead of binding the parameter,
// which then became $Registry's VALUE and turned the lookup into a request for
// "-Registry/rea-agents/latest" (measured: a 502 and a red suite). `-File` did
// the parsing for us; `-Command` does not.
const token = (value) =>
String(value).startsWith("-") ? String(value) : `"${String(value).replace(/"/g, '""')}"`;
const invocation = [
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;",
"$OutputEncoding=[System.Text.Encoding]::UTF8;",
`& "${CHECK_UPDATES}"`,
...args.map(token),
].join(" ");
const result = spawnSync(command, ["-NoProfile", "-Command", invocation], {
env: { ...process.env, ...env },
windowsHide: true,
maxBuffer: 8 * 1024 * 1024,
});
return { status: result.status, output: decodeOutput(result.stdout) + decodeOutput(result.stderr) };
}
function lastMeaningfulLine(output) {
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line !== "")
.pop();
}
/** A bundle whose recorded versions all equal the manifest pins. */
function makeBundleFixture(t, label) {
const base = tempDir(t, label);
const root = path.join(base, "bundle");
const vendor = path.join(root, "vendor", "node_modules", "rea-agents");
const ghidra = path.join(root, "ghidra");
mkdirSync(path.join(vendor, "scripts"), { recursive: true });
mkdirSync(path.join(root, "bin"), { recursive: true });
mkdirSync(path.join(root, "manifest"), { recursive: true });
mkdirSync(path.join(ghidra, "Ghidra"), { recursive: true });
writeFileSync(
path.join(vendor, "package.json"),
JSON.stringify({ name: "rea-agents", version: PINS.reaAgents, engines: { node: PINS.nodeEngines } }),
);
// An empty entry is enough: the script only needs the path to exist, and an
// empty CLI exits 0, so the doctor section degrades to "cannot parse".
writeFileSync(path.join(vendor, "scripts", "rea.mjs"), "");
writeFileSync(path.join(ghidra, "Ghidra", "application.properties"), "application.version=12.1.4\n");
copyFileSync(path.join(PLUGIN_ROOT, "bin", "rea-shim.mjs"), path.join(root, "bin", "rea-shim.mjs"));
copyFileSync(
path.join(PLUGIN_ROOT, "manifest", "bundle.manifest.json"),
path.join(root, "manifest", "bundle.manifest.json"),
);
writeFileSync(
path.join(root, "mirror.json"),
JSON.stringify({ schema: 1, pluginRoot: PLUGIN_ROOT, mirroredAt: "2026-10-26T13:15:52.351Z", dshHome: base }),
);
const bundle = {
schema: 1,
provisionedAt: "2026-10-26T13:15:52.343Z",
paths: { reaEntry: path.join(vendor, "scripts", "rea.mjs"), ghidra },
versions: {
reaAgents: PINS.reaAgents,
ghidra: "12.1.4",
jdk: "21.0.12",
apktool: PINS.apktool,
jadxHeadlessMcp: PINS.jadxHeadlessMcp,
node: PINS.node,
},
components: {
node: { path: "node", how: "PATH", version: PINS.node, reused: true },
apktool: { path: path.join(root, "vendor", "apktool"), jarName: "apktool.jar", version: PINS.apktool, tofu: false },
jadxHeadlessMcp: { path: path.join(root, "vendor", "jadx"), jarName: "jadx.jar", version: PINS.jadxHeadlessMcp, tofu: false },
},
unverifiedChecksums: [],
};
writeFileSync(path.join(root, "bundle.json"), JSON.stringify(bundle, null, 2));
const home = path.join(base, "home");
const profile = path.join(home, "profiles", "desktop");
mkdirSync(profile, { recursive: true });
writeFileSync(
path.join(profile, "package.json"),
JSON.stringify({
dependencies: { "dsh-plugin-rea": `file:${PLUGIN_ROOT.replace(/\\/g, "/")}` },
dsh: { profile: { bundles: ["dsh-plugin-rea"] } },
}),
);
writeFileSync(path.join(profile, "cordis.patch.yml"), "plugins:\n - id: mcp-rea\n");
const env = { DSH_HOME: path.join(home), REA_BUNDLE_ROOT: root, DSH_PROFILE_DIR: profile };
return { base, root, vendor, ghidra, bundle, env };
}
/** A registry stub that answers the pin, so these tests never need the network. */
async function stubRegistry(t) {
const server = createServer((req, res) => {
if (req.url === "/rea-agents/latest") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ version: PINS.reaAgents }));
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end("{}");
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
return `http://127.0.0.1:${server.address().port}`;
}
test("check-updates: a clean bundle is the only green run", async (t) => {
const command = powerShell();
if (!command) {
t.skip("PowerShell is not available on this host");
return;
}
const registry = await stubRegistry(t);
const fixture = makeBundleFixture(t, "check-clean");
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
assert.equal(run.status, 0, `clean bundle must exit 0:\n${run.output}`);
assert.match(lastMeaningfulLine(run.output), /无需动作/, run.output);
});
test("check-updates: a pin drift is reported as needing action, not as green", async (t) => {
const command = powerShell();
if (!command) {
t.skip("PowerShell is not available on this host");
return;
}
const registry = await stubRegistry(t);
const fixture = makeBundleFixture(t, "check-drift");
const bundle = JSON.parse(readFileSync(path.join(fixture.root, "bundle.json"), "utf8"));
bundle.versions.apktool = "3.0.2"; // pin says 3.0.3
writeFileSync(path.join(fixture.root, "bundle.json"), JSON.stringify(bundle, null, 2));
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
assert.equal(run.status, 1, `drift must not exit 0:\n${run.output}`);
assert.doesNotMatch(run.output, /无需动作/, run.output);
assert.match(run.output, /apktool 盘上为 3\.0\.2/);
const last = lastMeaningfulLine(run.output);
assert.match(last, /需要动作/, last);
assert.match(last, /pin 漂移 1 项/, last);
});
function hasFileHash(command) {
// The stale-mirror warning below is produced by comparing two file hashes, so a
// host without `Get-FileHash` (hosted Windows runners, restricted language mode)
// cannot construct that scenario at all. Skip honestly instead of asserting on a
// warning the platform can never emit — the script itself already reports the
// missing capability as [上游] rather than as "needs action".
const probe = spawnSync(command, ["-NoProfile", "-Command", "if (Get-Command Get-FileHash -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }"], {
windowsHide: true,
});
return probe.status === 0;
}
test("check-updates: a warning alone is enough to stop the green verdict", async (t) => {
const command = powerShell();
if (!command) {
t.skip("PowerShell is not available on this host");
return;
}
if (!hasFileHash(command)) {
t.skip("this host has no Get-FileHash, so the stale-mirror warning cannot be produced");
return;
}
const registry = await stubRegistry(t);
const fixture = makeBundleFixture(t, "check-warn");
// Every pin matches; only the mirrored shim is stale (plugin upgraded, setup
// not re-run). That used to be a warning next to a green verdict.
writeFileSync(path.join(fixture.root, "bin", "rea-shim.mjs"), "// stale mirror\n");
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
assert.equal(run.status, 1, `a warning must not exit 0:\n${run.output}`);
assert.doesNotMatch(run.output, /无需动作/, run.output);
assert.match(run.output, /镜像 shim 落后于插件/);
const last = lastMeaningfulLine(run.output);
assert.match(last, /需要动作/, last);
assert.match(last, /提醒/, last);
});
test("check-updates: an action item also produces a non-zero exit", async (t) => {
const command = powerShell();
if (!command) {
t.skip("PowerShell is not available on this host");
return;
}
const registry = await stubRegistry(t);
const fixture = makeBundleFixture(t, "check-action");
// components.node.reused stays true, but the recorded runtime no longer
// satisfies REA's engines — the case that used to print an action and exit 0.
const bundle = JSON.parse(readFileSync(path.join(fixture.root, "bundle.json"), "utf8"));
bundle.versions.node = "16.20.2";
writeFileSync(path.join(fixture.root, "bundle.json"), JSON.stringify(bundle, null, 2));
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
assert.equal(run.status, 1, `an unhealthy node must not exit 0:\n${run.output}`);
const last = lastMeaningfulLine(run.output);
assert.match(last, /需要动作/, last);
});
/* ------------------------------------------------------------------- safety net */
test("check-updates: a recorded gate override is not drift", async (t) => {
const command = powerShell();
if (!command) {
t.skip("PowerShell is not available on this host");
return;
}
const registry = await stubRegistry(t);
const fixture = makeBundleFixture(t, "check-override");
// Ghidra pinned at 12.1.4 would match anyway; record 12.2 plus its override to
// prove the pair is reported as a deliberate local divergence, never as drift.
const bundle = JSON.parse(readFileSync(path.join(fixture.root, "bundle.json"), "utf8"));
bundle.versions.ghidra = "12.2";
writeFileSync(path.join(fixture.root, "bundle.json"), JSON.stringify(bundle, null, 2));
writeFileSync(path.join(fixture.ghidra, "Ghidra", "application.properties"), "application.version=12.2\n");
writeFileSync(
path.join(fixture.root, "ghidra-gate-override.json"),
JSON.stringify({ schema: 1, requestedVersion: "12.2", originalVersion: "12.1.4", files: [] }),
);
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
assert.doesNotMatch(run.output, /ghidra 盘上为 12\.2/, run.output);
// The version gate still knows REA pins 12.1.x; it must attribute the 12.2 to
// the recorded override instead of printing an action item.
assert.match(run.output, /本机 --enable 的覆盖/);
assert.equal(run.status, 0, `a recorded override is not a defect:\n${run.output}`);
const last = lastMeaningfulLine(run.output);
assert.doesNotMatch(last, /pin 漂移/, last);
});
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env node
/**
* host-compat — does the range this package declares cover the DSH releases it
* claims to support?
*
* `package.json` carries `engines.dsh`, and that single string is what the
* plugin market reads to say "compatible" instead of "unknown" (the market's
* `deriveHostCompatibility` treats `engines.dsh` as a host declaration and
* checks it against the running host version). A declaration is therefore load
* bearing twice over: it decides whether the market HIDES this plugin from users
* on a matching host, and it decides whether users on a host we never tested are
* told it works.
*
* The two ways it goes wrong are silent. A typo makes the range unparseable, and
* the market then reports `unknown` — a plugin that looks undeclared rather than
* mis-declared. A floor that drifted (say `>=0.2.0`) silently drops hosts the
* README still promises. Neither shows up as a test failure unless something
* compares the STRING to the release list, which is what this file does.
*
* `HOST_RELEASES` is the dsh release line this package is built against, newest
* first, as published on github.com/deepseek-ai/deepseek-harness. It is a
* literal list rather than a GitHub query on purpose: a check that needs the
* network fails on a plane and passes on a total outage, which is the wrong
* shape for a gate. When a release is added upstream, add it here too — that
* edit is the reminder to re-check the row against the new host.
*
* FLOOR AND CEILING: the floor stays at 0.1.7-rc.2 (nothing older was ever
* adapted), and the newest entry must still sit below the declared ceiling.
* The 2026-11-06 adaptation added the 0.2.2 line and 0.2.1-rc.3/4/5, all of
* which remain below `<0.3.0-0` — the ceiling was NOT raised to reach them, so
* a release past 0.3.0 arrives here as an `uncovered` failure and as a decision
* for a human, never as a silent widening of what the market advertises.
*
* SEMANTICS: this file evaluates the declaration the way `includePrerelease:
* true` does, and that choice is not stylistic. Both consumers of `engines.dsh`
* in this ecosystem pass that option — dshmarket's discovery
* (`satisfiesRange(host, declared, { includePrerelease: true })` in
* `deriveHostCompatibility`) and the host's own peer gate — so a plain semver
* reading, where `0.2.0-rc.2` does NOT satisfy `>=0.1.7-rc.2 <0.3.0-0` (a
* prerelease must be named by a comparator on its own tuple), would disagree
* with the verdict users actually see. The tests pin the option's effect.
*
* Usage: `node scripts/host-compat.mjs` (exit 0 = every listed release is
* covered by the declared range; exit 1 = at least one is not).
*/
import { readFileSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = path.join(HERE, '..')
/**
* Every dsh release this bundle is checked against, newest first.
*
* Taken from the release list of github.com/deepseek-ai/deepseek-harness. The
* floor is the oldest release the plugin has been adapted to; everything above
* it up to the declared ceiling must keep working, so a release that is missing
* here is a release nobody checked.
*
* "Newest first" means BY PUBLISH DATE — the order the release page shows — and
* not semver precedence. The two disagree exactly once here: 0.2.1-rc.5
* (published 2026-10-30) came out after 0.2.2-alpha.2 (2026-10-27), so a reader
* who assumes descending semver will take 0.2.2-alpha.2 to be the newer of the
* two. Publication order is the sequence hosts actually upgrade through, so
* that is the order recorded; per-release dates are in
* docs/host-compat-2026-11-06.md rather than beside the literals, because a
* date on every line goes stale the first time someone appends without one.
*/
export const HOST_RELEASES = [
'0.2.2-rc.1', // 2026-11-04 — the host the 2026-11-06 adaptation was measured on
'0.2.2-alpha.3', // 2026-11-03
'0.2.1-rc.5', // 2026-10-30
'0.2.2-alpha.2', // 2026-10-27
'0.2.1-rc.4', // 2026-10-26
'0.2.1-rc.3', // 2026-10-23
'0.2.1-rc.2',
'0.2.1-rc.1',
'0.2.1-alpha.5',
'0.2.1-alpha.4',
'0.2.1-alpha.3',
'0.2.1-alpha.2',
'0.2.1-alpha.1',
'0.2.0-rc.2',
'0.2.0-rc.1',
'0.1.7-rc.2',
]
/** Hosts that must fall OUTSIDE the declaration, for the other direction. */
export const OUTSIDE_HOSTS = [
'0.3.0',
// Prereleases of the NEXT line are the case a plain `<0.3.0` ceiling lets
// through: with `includePrerelease` they sort below `0.3.0` itself. The
// declared ceiling is `<0.3.0-0`, semver's idiom for "nothing from 0.3.0",
// and these entries are what proves it does what it says.
'0.3.0-rc.1',
'0.3.0-alpha.1',
'0.1.7-rc.1',
'0.1.7-alpha.2',
]
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
/**
* Split a version into comparable parts.
*
* Build metadata (`+sha`) is dropped, as semver says it must be for precedence
* and as node-semver does for comparison: a host that reports
* `0.2.0-rc.2+build.7` is running 0.2.0-rc.2. Everything else is deliberately
* strict — a leading `v` or a zero-padded identifier is rejected rather than
* guessed at, because this gate exists to catch a declaration nobody checked and
* a guess would put the guess in charge.
* @param {string} version - a plain semver string, no range operators.
* @returns {{ parts: number[], pre: (string|number)[] } | null} null when unparseable.
*/
export function parseVersion(version) {
const cleaned = String(version ?? '').trim().replace(/\+.*$/, '')
const match = SEMVER.exec(cleaned)
if (match === null) return null
const raw = match[4] === undefined ? [] : match[4].split('.')
// semver forbids a zero-padded numeric prerelease identifier (`0.2.1-01`);
// accepting one would make this gate read a typo as a version.
if (raw.some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0'))) return null
const pre = raw.map(part => (/^\d+$/.test(part) ? Number(part) : part))
return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], pre }
}
/**
* Compare two versions the way semver does, prerelease included.
*
* A prerelease sorts BELOW its release (`0.2.0-rc.2 < 0.2.0`), and among
* prereleases an identifier that is numeric sorts below one that is not, which
* is why `0.2.1-alpha.5 < 0.2.1-rc.1` — the whole reason this file cannot lean
* on a numeric comparison.
* @returns -1, 0 or 1; NaN-free because unparseable input throws.
*/
export function compareVersions(a, b) {
const left = parseVersion(a)
const right = parseVersion(b)
if (left === null) throw new Error(`not a version: ${a}`)
if (right === null) throw new Error(`not a version: ${b}`)
for (let index = 0; index < 3; index += 1) {
if (left.parts[index] !== right.parts[index]) return left.parts[index] < right.parts[index] ? -1 : 1
}
if (left.pre.length === 0 && right.pre.length === 0) return 0
if (left.pre.length === 0) return 1
if (right.pre.length === 0) return -1
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index += 1) {
const l = left.pre[index]
const r = right.pre[index]
if (l === undefined) return -1
if (r === undefined) return 1
if (l === r) continue
const lNum = typeof l === 'number'
const rNum = typeof r === 'number'
if (lNum && rNum) return l < r ? -1 : 1
// Numeric identifiers always have lower precedence than alphanumeric ones.
if (lNum !== rNum) return lNum ? -1 : 1
return String(l) < String(r) ? -1 : 1
}
return 0
}
/**
* Evaluate one `>=floor <ceiling` declaration (the only shape this package
* ships, and the shape the market's `engines.dsh` is compared with).
*
* Deliberately narrow: a wider parser here could disagree with the market's
* semver implementation on a range the manifest would then be trusted for. If a
* future declaration needs `||` or `^`, this function has to grow with it.
* @param {string} version - host version to test.
* @param {string} range - the declared range.
* @returns {boolean} whether the host is inside the declaration.
*/
export function satisfiesDeclaredRange(version, range) {
const match = /^>=\s*(\S+)\s+<\s*(\S+)$/.exec(range.trim())
if (match === null) throw new Error(`unsupported host range: ${range}`)
return compareVersions(version, match[1]) >= 0 && compareVersions(version, match[2]) < 0
}
/**
* The host range this repository declares, `engines.dsh`.
* @param {string} [root] - repository root, for tests.
* @returns {string | null} the declared range, or null when absent.
*/
export function declaredHostRange(root = REPO_ROOT) {
try {
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
const value = pkg?.engines?.dsh
return typeof value === 'string' ? value : null
} catch {
return null
}
}
/**
* Every release covered by the declaration, and every version that must not be.
* @param {string} range - declared host range.
* @returns {{ covered: string[], uncovered: string[], leaked: string[] }} findings.
*/
export function checkHostCoverage(range) {
const covered = []
const uncovered = []
for (const version of HOST_RELEASES) {
if (satisfiesDeclaredRange(version, range)) covered.push(version)
else uncovered.push(version)
}
const leaked = OUTSIDE_HOSTS.filter(version => satisfiesDeclaredRange(version, range))
return { covered, uncovered, leaked }
}
function main() {
const range = declaredHostRange()
if (range === null) {
console.error('FAIL: package.json declares no engines.dsh — the market would report this plugin as "unknown"')
process.exit(1)
}
let result
try {
result = checkHostCoverage(range)
} catch (error) {
console.error(`FAIL: ${error.message}`)
process.exit(1)
}
console.log(`engines.dsh = ${range}`)
for (const version of HOST_RELEASES) {
const inside = satisfiesDeclaredRange(version, range)
console.log(` ${inside ? 'ok ' : 'MISS'} ${version}`)
}
for (const version of OUTSIDE_HOSTS) {
const inside = satisfiesDeclaredRange(version, range)
console.log(` ${inside ? 'LEAK' : 'ok '} ${version} (must stay outside)`)
}
if (result.uncovered.length > 0) {
console.error(`FAIL: the declaration does not cover ${result.uncovered.join(', ')}`)
process.exit(1)
}
if (result.leaked.length > 0) {
console.error(`FAIL: the declaration also admits ${result.leaked.join(', ')}, which is not a tested host`)
process.exit(1)
}
console.log(`host range ok: ${HOST_RELEASES.length} releases covered, ${OUTSIDE_HOSTS.length} correctly outside`)
}
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env node
/**
* Tests for scripts/host-compat.mjs — the gate that compares the declared
* `engines.dsh` string against the list of DSH releases this bundle was adapted
* to.
*
* Every test here exists because one of these failures is silent. The gate has
* no runtime feedback: it prints a line per release and exits, and the wrong
* verdict looks exactly like the right one in a CI log nobody reads closely. So
* each way it can lie gets a test that names the lie:
*
* - a range that is unparseable reads as `unknown` (an undeclared plugin,
* not a mis-declared one) — so the parser refuses shapes it cannot judge
* - a floor that drifted drops hosts the README still promises — so the
* drifted-floor control asserts the exact set it loses
* - a ceiling of `<0.3.0` admits `0.3.0-rc.1`, because a prerelease sorts
* BELOW its release once prereleases are included — the single most
* expensive mistake this package ever shipped
* - a zero-padded prerelease or a `v` prefix is a typo, and a gate that
* guesses at one puts the guess in charge of what the market advertises
* - an appended release lands in the wrong place in the list, so the next
* person reads the coverage claim backwards
*
* Runs with `node --test`. No network, no filesystem writes.
*/
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
HOST_RELEASES,
OUTSIDE_HOSTS,
checkHostCoverage,
compareVersions,
declaredHostRange,
parseVersion,
satisfiesDeclaredRange,
} from "./host-compat.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.join(HERE, "..");
/** The declaration this package actually ships; pinned by its own test too. */
const RANGE = ">=0.1.7-rc.2 <0.3.0-0";
/* --------------------------------------------------------- parseVersion */
test("parseVersion splits the tuple and keeps a prerelease", () => {
assert.deepEqual(parseVersion("0.2.1-rc.2"), { parts: [0, 2, 1], pre: ["rc", 2] });
assert.deepEqual(parseVersion("0.1.7-rc.2"), { parts: [0, 1, 7], pre: ["rc", 2] });
// A numeric prerelease identifier becomes a number, not the string "2": the
// comparison in compareVersions branches on that type.
assert.deepEqual(parseVersion("0.2.1-alpha.10"), { parts: [0, 2, 1], pre: ["alpha", 10] });
});
test("parseVersion drops build metadata, because precedence has none", () => {
// A host reporting `0.2.0-rc.2+build.7` is running 0.2.0-rc.2. node-semver and
// this file both ignore the suffix; a gate that kept it would compare the
// string "7" against an alpha identifier and answer with nonsense.
assert.deepEqual(parseVersion("0.2.0-rc.2+build.7"), { parts: [0, 2, 0], pre: ["rc", 2] });
assert.deepEqual(parseVersion("0.2.0+build.7"), { parts: [0, 2, 0], pre: [] });
assert.equal(compareVersions("0.2.0-rc.2+build.7", "0.2.0-rc.2"), 0);
});
test("parseVersion refuses the shapes a typo produces instead of guessing", () => {
// Zero-padded numeric identifier: semver forbids it. Accepting one would let
// `0.2.1-01` compare equal to `0.2.1-1` and hide a typo in the release list.
assert.equal(parseVersion("0.2.1-01"), null);
assert.equal(parseVersion("0.2.1-rc.01"), null);
// A leading `v` is a tag, not a version.
assert.equal(parseVersion("v0.2.0-rc.2"), null);
// Too few / too many components, and outright garbage.
assert.equal(parseVersion("0.2"), null);
assert.equal(parseVersion("0.2.0.1"), null);
assert.equal(parseVersion(""), null);
assert.equal(parseVersion(undefined), null);
assert.equal(parseVersion(null), null);
assert.equal(parseVersion("latest"), null);
});
test("parseVersion accepts a single zero and a prerelease of one identifier", () => {
// `0` is legal and is load bearing: `<0.3.0-0` ends at the bare zero
// identifier, which is what makes every 0.3.0 prerelease sort above it.
assert.deepEqual(parseVersion("0.3.0-0"), { parts: [0, 3, 0], pre: [0] });
assert.deepEqual(parseVersion("0.2.1-rc"), { parts: [0, 2, 1], pre: ["rc"] });
});
/* ------------------------------------------------------- compareVersions */
test("a prerelease sorts below its release", () => {
assert.equal(compareVersions("0.2.1-rc.2", "0.2.1"), -1);
assert.equal(compareVersions("0.2.1", "0.2.1-rc.2"), 1);
assert.equal(compareVersions("0.2.2-rc.1", "0.2.2"), -1);
assert.equal(compareVersions("0.2.1", "0.2.1"), 0);
});
test("prerelease identifiers order the way semver says, not the way strings do", () => {
// Numeric identifiers sort below alphanumeric ones.
assert.equal(compareVersions("0.3.0-0", "0.3.0-alpha.1"), -1);
assert.equal(compareVersions("0.3.0-rc.1", "0.3.0-0"), 1);
// alpha < rc. String order agrees with this one, so it alone proves nothing.
assert.equal(compareVersions("0.2.1-alpha.5", "0.2.1-rc.1"), -1);
// But string order DISAGREES here: "alpha.10" < "alpha.5" as text, and semver
// compares 10 > 5. This is the assertion that catches a localeCompare slip.
assert.equal(compareVersions("0.2.1-alpha.10", "0.2.1-alpha.5"), 1);
// A shorter identifier list sorts below a longer one that shares its prefix.
assert.equal(compareVersions("0.2.1-rc", "0.2.1-rc.1"), -1);
assert.equal(compareVersions("0.2.1-rc.2", "0.2.1-rc.1"), 1);
});
test("compareVersions compares the tuple before the prerelease", () => {
// 0.2.2-alpha.3 is NEWER than 0.2.1-rc.5 even though "alpha" < "rc": the
// tuple decides first. The release list's one ordering exception depends on
// the opposite direction of this rule, so get it pinned.
assert.equal(compareVersions("0.2.2-alpha.3", "0.2.1-rc.5"), 1);
assert.equal(compareVersions("0.2.1-rc.5", "0.2.2-alpha.2"), -1);
});
test("compareVersions refuses unparseable input rather than returning a verdict", () => {
assert.throws(() => compareVersions("v0.2.0", "0.2.0"), /not a version/);
assert.throws(() => compareVersions("0.2.0", "0.2.1-01"), /not a version/);
});
/* ------------------------------------------------ satisfiesDeclaredRange */
test("the declared range admits every release from the floor up", () => {
assert.equal(satisfiesDeclaredRange("0.1.7-rc.2", RANGE), true, "the floor itself is inside");
assert.equal(satisfiesDeclaredRange("0.2.0-rc.1", RANGE), true);
assert.equal(satisfiesDeclaredRange("0.2.1-rc.5", RANGE), true);
assert.equal(satisfiesDeclaredRange("0.2.2-rc.1", RANGE), true, "the host this list was last measured on");
});
test("the next minor line falls outside, prereleases included", () => {
// This is the assertion the `<0.3.0-0` ceiling exists for. With
// `includePrerelease: true`, `0.3.0-rc.1 < 0.3.0` is true, so a plain
// `<0.3.0` ceiling would have admitted a host nobody audited.
assert.equal(satisfiesDeclaredRange("0.3.0", RANGE), false);
assert.equal(satisfiesDeclaredRange("0.3.0-rc.1", RANGE), false);
assert.equal(satisfiesDeclaredRange("0.3.0-alpha.1", RANGE), false);
// And the boundary node itself is excluded: `< 0.3.0-0` is strict.
assert.equal(satisfiesDeclaredRange("0.3.0-0", RANGE), false);
assert.equal(satisfiesDeclaredRange("0.4.0", RANGE), false);
});
test("versions below the floor fall outside", () => {
assert.equal(satisfiesDeclaredRange("0.1.7-rc.1", RANGE), false);
assert.equal(satisfiesDeclaredRange("0.1.7-alpha.2", RANGE), false);
assert.equal(satisfiesDeclaredRange("0.1.6-alpha.2", RANGE), false);
assert.equal(satisfiesDeclaredRange("0.1.7", RANGE), true, "the release of the floor line is above its rc");
});
test("build metadata does not change the verdict", () => {
assert.equal(satisfiesDeclaredRange("0.2.0-rc.2+build.7", RANGE), true);
assert.equal(satisfiesDeclaredRange("0.3.0-rc.1+build.7", RANGE), false);
});
test("only the `>=X <Y` shape is evaluated; anything else is refused", () => {
// Deliberately narrow. A wider parser here could disagree with the market's
// semver implementation on a range the manifest would then be trusted for,
// and the disagreement would surface as a wrong "compatible" badge.
for (const range of ["^0.2.0", "~0.2.0", "0.2.0", ">=0.1.7-rc.2", "<0.3.0-0", ">=0.1.7-rc.2 <0.3.0-0 || >=0.4.0-0", ">= 0.1.7-rc.2 <0.3.0-0 <0.4.0-0"]) {
assert.throws(() => satisfiesDeclaredRange("0.2.0-rc.2", range), /unsupported host range/, `must refuse ${range}`);
}
});
test("extra whitespace around the two comparators is tolerated", () => {
// A pasted range often carries it, and node-semver tolerates it too.
assert.equal(satisfiesDeclaredRange("0.2.0-rc.2", " >=0.1.7-rc.2 <0.3.0-0 "), true);
});
/* ------------------------------------------------------ declaredHostRange */
test("declaredHostRange reads the real manifest and returns null when absent", () => {
assert.equal(typeof declaredHostRange(REPO_ROOT), "string");
assert.equal(declaredHostRange(path.join(HERE, "no-such-root-for-host-compat")), null);
});
/* ----------------------------------------------------- checkHostCoverage */
test("every listed release is covered and no outsider leaks in", () => {
const { covered, uncovered, leaked } = checkHostCoverage(RANGE);
assert.deepEqual(uncovered, [], `these releases fall outside ${RANGE}`);
assert.deepEqual(leaked, [], `the declaration also admits ${leaked.join(", ")}, which nobody tested`);
assert.deepEqual(covered, HOST_RELEASES, "covered must report the list in list order");
});
test("the drifted-floor control loses exactly the hosts a 0.2.0 floor would drop", () => {
// A negative control: a gate that always passes proves nothing. `>=0.2.0` is
// the shape someone writes after testing only on their own machine — and
// because a prerelease sorts BELOW its release, it drops the whole 0.2.0-rc
// line as well as 0.1.7, while looking like it names the running host.
const { uncovered } = checkHostCoverage(">=0.2.0 <0.3.0-0");
assert.deepEqual(uncovered, ["0.2.0-rc.2", "0.2.0-rc.1", "0.1.7-rc.2"]);
});
test("the leaked-host control notices a low floor and a raised ceiling", () => {
assert.deepEqual(checkHostCoverage(">=0.1.0 <0.3.0-0").leaked, ["0.1.7-rc.1", "0.1.7-alpha.2"]);
// The other half: a ceiling that reaches into 0.3.0 must be reported, not
// silently accepted. Every 0.3.0 entry has to show up.
assert.deepEqual(checkHostCoverage(">=0.1.7-rc.2 <0.4.0-0").leaked, ["0.3.0", "0.3.0-rc.1", "0.3.0-alpha.1"]);
// A plain `<0.3.0` ceiling is the expensive one: it admits the 0.3.0
// prereleases while still excluding 0.3.0 itself, so only the two prerelease
// entries surface. That asymmetry is exactly why this list is pinned.
assert.deepEqual(checkHostCoverage(">=0.1.7-rc.2 <0.3.0").leaked, ["0.3.0-rc.1", "0.3.0-alpha.1"]);
});
test("checkHostCoverage propagates the refusal for an unparseable range", () => {
assert.throws(() => checkHostCoverage("^0.2.0"), /unsupported host range/);
});
/* ------------------------------------------------------- the release list */
test("the 2026-11-06 adaptation is present, and the newest six are in front", () => {
const added = ["0.2.2-rc.1", "0.2.2-alpha.3", "0.2.1-rc.5", "0.2.2-alpha.2", "0.2.1-rc.4", "0.2.1-rc.3"];
assert.deepEqual(HOST_RELEASES.slice(0, added.length), added, "newest-first by publish date");
for (const version of added) {
assert.ok(HOST_RELEASES.includes(version), `${version} must be listed; an unlisted release is an unchecked one`);
}
// 0.2.2-rc.1 is the host this adaptation was measured on: if it is not the
// first entry, someone prepended without a reason.
assert.equal(HOST_RELEASES[0], "0.2.2-rc.1");
});
test("the list stays newest-first, with exactly one documented publication-order exception", () => {
// "Newest first" means by publish date. That is semver-descending everywhere
// EXCEPT 0.2.1-rc.5 (published after 0.2.2-alpha.2), which is the one
// inversion the list carries on purpose. Pinning the exception list rather
// than the whole array keeps this test from being a third copy of the data
// while still catching an append in the wrong place.
const inversions = [];
for (let index = 1; index < HOST_RELEASES.length; index += 1) {
const previous = HOST_RELEASES[index - 1];
const current = HOST_RELEASES[index];
if (compareVersions(previous, current) <= 0) inversions.push(`${previous} < ${current}`);
}
assert.deepEqual(inversions, ["0.2.1-rc.5 < 0.2.2-alpha.2"]);
});
test("the floor is still 0.1.7-rc.2, the oldest release ever adapted to", () => {
// Raising the floor silently drops hosts the README promises, which is the
// failure the file header calls out. If a future adaptation really does drop
// 0.1.7, this test is where the decision gets recorded.
assert.equal(HOST_RELEASES[HOST_RELEASES.length - 1], "0.1.7-rc.2");
// And the list never runs below the floor it claims.
for (const version of HOST_RELEASES) {
assert.ok(compareVersions(version, "0.1.7-rc.2") >= 0, `${version} sorts below the floor`);
}
});
test("the list carries no duplicate and every entry parses", () => {
assert.equal(new Set(HOST_RELEASES).size, HOST_RELEASES.length, "a duplicate would double-count coverage");
for (const version of HOST_RELEASES) {
assert.notEqual(parseVersion(version), null, `${version} is not a version this gate can evaluate`);
}
});
test("the outsider list is the audited one, and none of it is a shipped release", () => {
// Pinned so that "make the gate pass" can never be done by deleting an
// outsider. These five are the ones a wrong ceiling or a wrong floor admits.
assert.deepEqual(OUTSIDE_HOSTS, ["0.3.0", "0.3.0-rc.1", "0.3.0-alpha.1", "0.1.7-rc.1", "0.1.7-alpha.2"]);
for (const version of OUTSIDE_HOSTS) {
assert.ok(!HOST_RELEASES.includes(version), `${version} is both covered and required to be outside`);
}
});
test("the newest release stays inside the declared ceiling, without the ceiling moving", () => {
// The declared range is the thing the market reads. An adaptation that needed
// a wider ceiling would have to change `engines.dsh` — a decision for a human,
// and this assertion is what forces it to be one.
assert.equal(declaredHostRange(REPO_ROOT), RANGE);
assert.equal(satisfiesDeclaredRange(HOST_RELEASES[0], RANGE), true);
});
+43
View File
@@ -0,0 +1,43 @@
# Pin comparison for check-updates.ps1, kept in a dot-sourceable file so it can
# be tested without running the whole check (which talks to a registry).
#
# Why this is a function and not an inline condition: until 2.0.10 the comparison
# was "equal, OR shares the first dotted component with the pin", applied to every
# component. "6.5.0".StartsWith("6.") is true, so a bundle still on 6.5.0 reported
# "consistent with pin 6.7.0", and that line is the only signal a user has that a
# pin bump requires re-running setup. apktool had the same shape (3.0.9 vs 3.0.3,
# where the major-prefix test called them equal).
#
# The one pin that is genuinely major-only is the JDK ("21"): the download records
# 21.0.12.1+1 and both name the same family. A dotted pin names exactly one
# version. The reverse rule, prefix matching on a dotted pin, is the bug.
# Kept ASCII-only on purpose: Windows PowerShell 5.1 reads a BOM-less script as
# ANSI, and this file ships inside the package.
function Test-PinMatch {
param(
[string]$Installed,
[string]$Pinned
)
$installed = ([string]$Installed).Trim()
$pinned = ([string]$Pinned).Trim()
if ($installed -eq '' -or $pinned -eq '') { return $false }
$same = [string]::Equals($installed, $pinned, [System.StringComparison]::Ordinal)
if ($pinned.Contains('.')) { return $same }
# A major-only pin ("21") names a FLOOR, not a family prefix.
#
# Ghidra 12.1 requires "JDK 21 or newer", so a machine whose JAVA_HOME holds
# 25.0.5 satisfies a pin that names 21. This function used to accept 21.0.12.1+1
# and reject 25.0.5, which told the operator to "re-run setup to align" while
# setup itself answered "already provisioned (25.0.5) - major 25 satisfies the
# pin's major 21". One of the two had to be wrong, and it was this one: the
# prefix rule cannot express "or newer", and a bare-major pin can mean nothing
# else.
$installedMajor = 0
$pinnedMajor = 0
$installedHead = ($installed -split '\.')[0]
if ([int]::TryParse($installedHead, [ref]$installedMajor) -and [int]::TryParse($pinned, [ref]$pinnedMajor)) {
return ($installedMajor -ge $pinnedMajor)
}
# Not two numbers (a "21-rc.1"-shaped pin, say): fall back to the literal match.
return $same
}
+239
View File
@@ -0,0 +1,239 @@
# 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 -------------------------------------------------------
# Same precedence as setup.mjs, the shim and check-updates.ps1: explicit override,
# then DSH_HOME, then ~/.dsh. An override this script alone ignored would put
# ~800 MB into <DSH_HOME>/plugins/rea-bundle while every reader looked in
# REA_BUNDLE_ROOT, saw an empty bundle, and started a second run.
if (-not $Root -or $Root.Trim() -eq '') { $Root = $env:REA_BUNDLE_ROOT }
if (-not $Root -or $Root.Trim() -eq '') {
$home2 = $env:DSH_HOME
if (-not $home2 -or $home2.Trim() -eq '') { $home2 = Join-Path $HOME '.dsh' }
$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 Compare-SchemaVersion([int[]]$Left, [int[]]$Right) {
# Negative when $Left is older, 0 when equal, positive when newer. All three
# components are compared: `^24.11.1` must reject 24.11.0, and comparing only
# major/minor accepted it — the same engines range then meant two different
# things here and in setup.mjs's regex probe.
for ($i = 0; $i -lt 3; $i++) {
if ($Left[$i] -ne $Right[$i]) { return $(if ($Left[$i] -gt $Right[$i]) { 1 } else { -1 }) }
}
return 0
}
function Test-EnginesMatch([string]$Clean, [string]$Range) {
$versions = @()
foreach ($part in $Clean.Split('.')) {
$n = 0
if (-not [int]::TryParse($part, [ref]$n)) { return $false }
$versions += $n
}
if ($versions.Count -lt 2) { return $false }
# A two-component version (`v24.11`) still has a patch of 0 for comparison.
while ($versions.Count -lt 3) { $versions += 0 }
foreach ($clause in ($Range -split '\|\|')) {
$c = $clause.Trim()
if ($c -match '^\^(\d+)\.(\d+)\.(\d+)$') {
$want = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3])
if ($versions[0] -eq $want[0] -and (Compare-SchemaVersion $versions $want) -ge 0) { return $true }
} elseif ($c -match '^>=(\d+)\.(\d+)\.(\d+)$') {
$want = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3])
if ((Compare-SchemaVersion $versions $want) -ge 0) { return $true }
}
}
return $false
}
function Test-NodeUsable([string]$Exe) {
if (-not $Exe -or -not (Test-Path $Exe)) { return $null }
try {
$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
+2998
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "rea-bundle-vendor",
"version": "1.0.0",
"private": true,
"description": "Install root for the vendored REA MCP server closure. scripts/setup.mjs copies this pair into <root>/vendor/.npm-staging and runs `npm ci --ignore-scripts`, so provisioning resolves exactly the versions in package-lock.json. The `overrides` block is the audit fix: rea-agents pins zod exactly (\"4.4.3\"), which is inside GHSA-mrhx-c7xc-m3w5 (<4.6.6), and only an override can move an exact pin without editing the published tarball.",
"dependencies": {
"rea-agents": "6.8.0"
},
"overrides": {
"zod": "4.6.7",
"minimatch": "11.0.5",
"glob": "14.0.4"
}
}
+250
View File
@@ -0,0 +1,250 @@
# 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','bin\rea-guide.mjs','client.js','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs','scripts\host-compat.mjs','scripts\check-updates.mjs')) {
$out = & node --check $f 2>&1
Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
}
Write-Host "`n=== 1b. both JSON readers tolerate a BOM ===" -ForegroundColor Cyan
# bin/rea-shim.mjs and scripts/setup.mjs read the same mirror.json and bundle.json.
# A BOM strip on one side only makes one file mean "there is a checkout" to the
# shim and "there is none" to setup, and the mirrored setup then re-mirrors the
# previous manifest. A bare JSON.parse(readFileSync(...)) is that divergence.
$jsBom = Join-Path $env:TEMP 'rea-verify-bom.cjs'
@'
const fs = require("node:fs");
const blind = [];
for (const file of ["bin/rea-shim.mjs", "scripts/setup.mjs"]) {
const text = fs.readFileSync(file, "utf8");
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
// readJson() itself is the strip; anything else must not parse a file raw.
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
blind.push(file + "@" + text.slice(0, m.index).split("\n").length);
}
}
if (!text.includes("replace(/^\\uFEFF/")) blind.push(file + ": no U+FEFF strip");
}
console.log("bom-blind reads: " + JSON.stringify(blind));
if (blind.length) process.exit(1);
'@ | Set-Content $jsBom -Encoding utf8
& node $jsBom 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
Check 'no BOM-blind JSON read' ($LASTEXITCODE -eq 0) 'see above'
Write-Host "`n=== 2. PowerShell parse + BOM ===" -ForegroundColor Cyan
# This script checks itself too: it is edited as often as the others, and an editor
# that drops the BOM would otherwise go unnoticed until someone adds CJK text here.
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=== 5b. the pin comparison is not a major-prefix match ===" -ForegroundColor Cyan
# 2.0.10 shipped a false green here: "6.5.0".StartsWith("6.") is true, so a bundle
# still on 6.5.0 reported "consistent with pin 6.7.0" — and that line is the only
# signal a user has that a pin bump requires re-running setup. The rule lives in
# scripts/pin-match.ps1 so it can be checked without the registry/profile sections.
$pinMatchFile = Join-Path $root 'scripts\pin-match.ps1'
Check 'scripts/pin-match.ps1 exists' (Test-Path $pinMatchFile) ''
Check 'check-updates.ps1 uses Test-PinMatch' ((Get-Content (Join-Path $root 'check-updates.ps1') -Raw -Encoding UTF8) -match 'Test-PinMatch') ''
if (Test-Path $pinMatchFile) {
. $pinMatchFile
$pinCases = @(
@('6.5.0', '6.7.0', $false, 'older REA under a newer pin must not be a match'),
@('6.7.0', '6.7.0', $true, 'exact match'),
@('3.0.9', '3.0.3', $false, 'apktool above the pin must not be a match'),
@('21.0.12.1+1', '21', $true, 'major-only jdk pin names the family'),
@('21', '21', $true, 'exact major-only pin'),
@('24.11.1', '24.21.0', $false, 'a dotted node pin is one version'),
@('', '6.7.0', $false, 'no recorded version is never a match')
)
foreach ($c in $pinCases) {
$got = Test-PinMatch -Installed $c[0] -Pinned $c[1]
Check ("pin '{0}' vs '{1}' -> {2}" -f $c[0], $c[1], $c[2]) ($got -eq $c[2]) ("got ${got}: {0}" -f $c[3])
}
}
Write-Host "`n=== 6. package payload lists what the bundle needs ===" -ForegroundColor Cyan
# -Encoding UTF8 is required: the package description contains CJK text, and
# PowerShell 5.1 reads a BOM-less file as ANSI, which breaks ConvertFrom-Json.
$pkg = Get-Content (Join-Path $root 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($entry in @('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)"
# The market decides "compatible" vs "unknown" from engines.dsh, and hides the
# plugin from hosts whose version does not match. An absent or unparseable
# declaration therefore reads as "nothing to say" rather than as a defect, which
# is why the coverage check runs here as well as in CI.
Check 'package.json declares engines.dsh' (-not [string]::IsNullOrWhiteSpace($pkg.engines.dsh)) ''
$compat = & node scripts\host-compat.mjs 2>&1
Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($compat | Select-Object -Last 2) -join ' ')
# Both READMEs quote the range verbatim, and a stale quote is invisible: JSON,
# host-compat and the audit all agree while the sentence a human reads names a
# different ceiling. That is exactly how `<0.3.0` survived in the docs after the
# declaration moved to `<0.3.0-0`, so the comparison lives here now.
foreach ($doc in @('README.md', 'README.zh-CN.md')) {
$text = Get-Content (Join-Path $root $doc) -Raw -Encoding UTF8
Check "$doc quotes the declared engines.dsh" $text.Contains([string]$pkg.engines.dsh) "expected $($pkg.engines.dsh)"
# The pin example is copy-pasteable, so it must name a ref that will exist: the
# version this tree publishes. It has drifted twice (v2.0.0 long after 2.0.5,
# then v2.0.8 while the tree was 2.0.9), and both times every other signal was
# green because nothing compared the two.
Check "$doc pins the current version" $text.Contains("dsh-plugin-rea#v$($pkg.version)") "expected dsh-plugin-rea#v$($pkg.version)"
}
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test', 'bundle:host-compat')) {
Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
}
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 "`n=== 9. the one-shot setup guide is wired end to end ===" -ForegroundColor Cyan
# The guide spans four files with no type system between them: the route in
# bin/rea-guide.mjs, the mount in index.mjs, the fetch in client.js, and the row
# in cordis.patch.yml. Two of those used to be free-form strings, so a rename in
# one place produced a guide that silently never appeared (the client treats a
# 404 as "this host has no route"). Compare them here instead.
$clientText = if (Test-Path (Join-Path $root 'client.js')) { Get-Content (Join-Path $root 'client.js') -Raw -Encoding UTF8 } else { '' }
Check 'client.js ships with the package' ($clientText -ne '') ''
$routeMatch = Select-String -Path (Join-Path $root 'bin\rea-guide.mjs') -Pattern "export const GUIDE_ROUTE = '([^']+)'" | Select-Object -First 1
$guideRoute = if ($routeMatch) { $routeMatch.Matches[0].Groups[1].Value } else { '' }
Check 'bin/rea-guide.mjs declares the guide route' ($guideRoute -eq '/dsh-plugin-rea/guide-state') "got '$guideRoute'"
Check 'client.js loads through the client module loader' ($clientText.Contains('window.__ModuleLoader__.load')) ''
Check 'client.js registers the guide on shell.overlay' ($clientText.Contains('rea-setup-guide') -and $clientText.Contains('shell.overlay')) ''
Check 'client.js reads the same route the host serves' ($clientText.Contains($guideRoute)) "expected '$guideRoute'"
$indexText = Get-Content (Join-Path $root 'index.mjs') -Raw -Encoding UTF8
Check 'index.mjs serves the guide route' ($indexText.Contains('GUIDE_ROUTE') -and $indexText.Contains('mountGuideRoute')) ''
Check 'index.mjs mounts it only when a webServer exists' ($indexText.Contains("ctx.inject(['webServer']")) ''
Check 'package.json declares dsh.client for the web surface' ($pkg.dsh.client.platform -eq 'web') ''
Check 'package.json ships client.js' (@($pkg.files) -contains 'client.js') ''
$patchText = Get-Content (Join-Path $root 'cordis.patch.yml') -Raw -Encoding UTF8
Check 'cordis.patch.yml mounts the guide row' ($patchText.Contains('rea-guide') -and $patchText.Contains("name: 'dsh-plugin-rea'")) ''
$guideRaw = & node bin\rea-guide.mjs --json 2>&1
$guideParsed = $null
try { $guideParsed = ($guideRaw -join "`n") | ConvertFrom-Json } catch { $guideParsed = $null }
Check 'bin/rea-guide.mjs answers with JSON' ($null -ne $guideParsed -and $null -ne $guideParsed.needsSetup) (($guideRaw | Select-Object -Last 2) -join ' ')
Check 'the guide command points at this package scripts' ($null -ne $guideParsed -and [string]$guideParsed.setupCommand -match 'scripts[\\/]setup\.mjs') ''
Write-Host "`n=== 10. npm run check-updates is portable ===" -ForegroundColor Cyan
# check-updates.ps1 is Windows-only on purpose (it reads %APPDATA% and prefers a
# rea.cmd shim over the rea.ps1 one). The npm script used to BE the bare
# `powershell -File` command, so on macOS/Linux `npm run check-updates` was an
# exec error, and a pwsh there would have answered from paths that do not exist.
# The wrapper is the contract: Windows forwards, everything else explains and
# exits non-zero.
$wrapper = Join-Path $root 'scripts\check-updates.mjs'
Check 'scripts/check-updates.mjs exists' (Test-Path $wrapper) ''
$checkScript = [string]$pkg.scripts.'check-updates'
Check 'scripts.check-updates goes through the wrapper' ($checkScript -match 'check-updates\.mjs') $checkScript
$wrapperText = if (Test-Path $wrapper) { Get-Content $wrapper -Raw -Encoding UTF8 } else { '' }
Check 'the wrapper runs PowerShell on Windows only' ($wrapperText.Contains("platform === 'win32' ? ['powershell', 'pwsh'] : []")) ''
Check 'the wrapper names both portable fallbacks' ($wrapperText.Contains('--shim-status') -and $wrapperText.Contains('--dry-run')) ''
Write-Host ''
if ($fail -eq 0) { Write-Host 'ALL CHECKS PASSED' -ForegroundColor Green }
else { Write-Host "$fail CHECK(S) FAILED" -ForegroundColor Red }
exit $fail