11 Commits
Author SHA1 Message Date
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
21 changed files with 3754 additions and 296 deletions

No files matched your search

+19
View File
@@ -78,5 +78,24 @@ jobs:
npm install -g 'npm@^11'
npm --version
- name: The version being published is tagged and carries its release asset
# workflow_dispatch can start this job with no Release behind it, and then npm
# and the GitHub tarball drift apart: the market's fallback link points at the
# release asset, so publishing a version whose asset does not exist ships a 404
# to half the install paths.
env:
GH_TOKEN: ${{ github.token }}
run: |
ver="v$(node -p "require('./package.json').version")"
if ! git ls-remote --tags --exit-code origin "refs/tags/$ver" >/dev/null 2>&1; then
echo "FAIL: no tag $ver on origin — publish the GitHub Release first"
exit 1
fi
if ! gh release view "$ver" --json assets --jq '.assets[].name' | grep -qx 'dsh-plugin-rea.tgz'; then
echo "FAIL: release $ver has no dsh-plugin-rea.tgz asset — the market link would 404"
exit 1
fi
echo "$ver is tagged and carries the version-free tarball"
- name: Publish to npm via trusted publishing
run: npm publish --provenance --access public
+51 -10
View File
@@ -11,6 +11,11 @@ permissions:
jobs:
validate:
runs-on: ubuntu-latest
# The shim's launch decision reads this machine's REA. No runner has one, so
# the default `auto` would happen to take the bundle path today — pin it so
# that stays true if an image ever ships REA.
env:
REA_BUNDLE_LAUNCH: bundle
steps:
- uses: actions/checkout@v4
@@ -25,13 +30,27 @@ jobs:
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1) };
if (!p.dsh || !p.dsh.bundle || !p.dsh.bundle.patch) fail('package.json is missing dsh.bundle.patch');
if (p.private) fail('package.json must not be private to be publishable');
for (const f of ['cordis.patch.yml', 'README.md', 'LICENSE', 'check-updates.ps1']) {
for (const f of ['cordis.patch.yml', 'index.mjs', 'README.md', 'LICENSE', 'check-updates.ps1']) {
if (!p.files.includes(f)) fail('files[] must ship ' + f);
}
if (!require('fs').existsSync(p.dsh.bundle.patch)) fail('dsh.bundle.patch target does not exist');
// The market's update path rolls a build back when package.json points
// at no existing entry (dshmarket's hasLoadableEntry); this package
// shipped no main at all until 2.0.5, so every update of a healthy
// profile failed with "updated build has no loadable entry".
if (typeof p.main !== 'string') fail('package.json must declare main');
if (!require('fs').existsSync(p.main)) fail('main points at no shipped file: ' + p.main);
// engines.dsh is what the market reads to say "compatible" instead of
// "unknown"; without it the plugin is reported as undeclared.
if (!p.engines || typeof p.engines.dsh !== 'string') fail('package.json must declare engines.dsh');
console.log('manifest ok');
"
- name: Declared DSH range covers every released host
# A wrong range is silent on both sides: unparseable reads as "undeclared"
# and a drifted floor drops hosts the README still lists.
run: node scripts/host-compat.mjs
- name: JSON files parse
run: |
node -e "
@@ -44,16 +63,15 @@ jobs:
- name: The npm payload ships every market-facing file
run: |
# Regression guard for files[]: the market reads screenshots.json and the
# images next to package.json, and a user copies examples/ by hand — none of
# that is worth anything if npm pack drops it.
# images next to package.json — none of that is worth anything if npm pack
# drops it.
node -e "
const { execSync } = require('child_process');
const meta = JSON.parse(execSync('npm pack --dry-run --json', { encoding: 'utf8' }))[0];
const files = meta.files.map((f) => f.path);
const need = [
'cordis.patch.yml', 'README.md', 'README.zh-CN.md', 'LICENSE', 'CHANGELOG.md', 'check-updates.ps1',
'cordis.patch.yml', 'index.mjs', 'README.md', 'README.zh-CN.md', 'LICENSE', 'CHANGELOG.md', 'check-updates.ps1',
'screenshots.json', 'assets/screenshot-1.png', 'assets/screenshot-2.png',
'examples/cordis.patch.posix.yml', 'examples/cordis.patch.with-env.yml',
];
const missing = need.filter((f) => !files.includes(f));
if (missing.length) {
@@ -77,7 +95,7 @@ jobs:
'tag:yaml.org,2002:js',
lambda loader, node: loader.construct_scalar(node),
)
files = ['cordis.patch.yml', 'docs/marketplace-entry.yml'] + sorted(glob.glob('examples/*.yml')) + sorted(glob.glob('.github/workflows/*.yml'))
files = ['cordis.patch.yml', 'docs/marketplace-entry.yml'] + sorted(glob.glob('.github/workflows/*.yml'))
for f in files:
with open(f, encoding='utf-8') as fh:
yaml.safe_load(fh)
@@ -87,10 +105,9 @@ jobs:
- name: The shipped patches stay machine-independent
run: |
set -e
# The row DSH loads, plus every example a reader is told to copy: none of
# them may carry a real machine path. `<placeholders in angle brackets>`
# are the intended form.
targets=$(printf '%s\n' cordis.patch.yml examples/*.yml)
# The row DSH loads may carry no real machine path; `<placeholders in angle
# brackets>` are the intended form.
targets=$(printf '%s\n' cordis.patch.yml)
if grep -nE '[A-Za-z]:\\' $targets; then
echo "FAIL: a shipped patch contains an absolute Windows path"
exit 1
@@ -163,3 +180,27 @@ jobs:
# Checksum coverage, unresolved tokens, duplicate JSON keys, and the
# patch-wiring agreement between the row, the shim and setup.mjs.
run: npm run bundle:audit
windows:
# The PowerShell download route, the tar/Expand-Archive extraction path and
# scripts/rea-bundle-bootstrap.ps1 are Windows-only code, and an ubuntu-only
# matrix never executed any of them — they were verified by hand, if at all.
# The tests are platform-sensitive on purpose: each platform exercises the
# download route it actually chooses.
runs-on: windows-latest
# Same pin as the ubuntu job: the launch decision must not depend on what the
# runner image happens to have installed.
env:
REA_BUNDLE_LAUNCH: bundle
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Unit tests for the pure logic (Windows)
run: npm run bundle:test
- name: Bundle manifest audit (Windows)
run: npm run bundle:audit
+280
View File
@@ -2,6 +2,286 @@
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
## 2.0.8 — 2026-10-18
**2.0.7 的启动检测在两种边界上会失控:一个包着本 shim 的 `rea` 会让探针递归成进程链;超时探针会漏掉一个孙进程。两条都已修,并补了复现测试。**
### 修复
- **重复注册检测在自定义 `DSH_HOME` 下失效**:harness 会从 MCP 子进程里抹掉所有 `DSH_*`,镜像副本因此读
`~/.dsh` —— 那是错的文件,上游写进真实 home 的第二条 `serverName: rea` 永远报不出来。`setup.mjs` 现在把运行时
的 home 记进 `mirror.json` 的 `dshHome`,shim 优先读环境、其次读这条记录。`check-updates.ps1` 的 profile 段也
改用同一条 home 规则(此前硬编码 `$env:USERPROFILE`,自定义 home 时报「找不到 profile」)。
- **镜像启动器不会自己更新**:行执行的是 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 这份**副本**,而市场升级
只换包、不跑 setup —— 副本会一直跑它被复制时的代码,于是新版本里的修复对老用户静默失效(旧副本照常工作,最难发现)。
现在镜像启动时会与 `mirror.json` 记录的插件目录比对哈希,不同就转发执行插件里的那一份(一跳,带
`REA_BUNDLE_SHIM_REEXEC` 守卫,且只从镜像指向插件,不会反向)。`--shim-status` 新增 `mirror.stale`,
`check-updates.ps1` 也会报「镜像落后,重跑 setup」;`mirror.json` 增加 `shimSha256` 与 `pluginVersion` 便于追溯。
**2.0.8 之前的版本自身无法完成这一步**(副本里没有这段代码),从那些版本升级仍需手动跑一次 setup。
- **探针递归**:`probeRea` 派生候选时没带 `REA_BUNDLE_DELEGATED=1`,而防自委派的守卫只在**委派**前生效。若 PATH 上是「调用本 shim 的 wrapper」,
探针 → wrapper → 新 shim → 再探针……实测并发 node 峰值 83+、杀掉后仍自我再生。现在探针环境同时带 `REA_BUNDLE_DELEGATED=1` 与
`REA_BUNDLE_AUTO_PROVISION=0`:嵌套运行直接走 bundle 并返回,实测 0.2 秒、零额外进程。
- **超时探针泄漏孙进程**:`spawnSync` 的超时只杀它自己启动的 cmd.exe,而 `taskkill /T` 在 `spawnSync` 回收之后调用已找不到父进程 ——
每次超时留一个 REA/ping 在跑。探针改为异步 `spawn` + 自己的计时器:超时发生在父进程仍存活时,`taskkill /T /F` 能真正清掉整棵树。
实测 8.3 秒放弃、`rejected=["timeout"]`、无残留进程。
- **Windows 信号处理器是死代码**:`child.kill("SIGTERM")` 在 Windows 上是 TerminateProcess,不可捕获;控制台的 Ctrl+C 又直达子进程。
该分支已删除,并如实写明限制:**在 Windows 上强制结束 shim 不会带走被委派的 REA**(Node 没有 job object 可用)。
- **`comspec()` 不校验**:`ComSpec` 指向不存在的文件时,正常的 `.cmd` REA 被判 `spawn-failed`(`auto` 静默退回 bundle,`system` 直接 78)。
现在校验为常规文件,否则回落到 `%SystemRoot%\System32\cmd.exe`;实测 `ComSpec` 指向不存在路径仍能识别 6.9.0。
- **文案**:显式路径是目录时报「does not exist」,改为「is not a file」。
### 验证
- 单测 **67 条**(2.0.7 是 60 条,本轮 +7:wrapper 递归必须被判为 `exit 78` 且 20 秒内收敛;`ComSpec` 失效仍识别 `.cmd` REA;
镜像自愈的转发/守卫/`stale` 上报/同内容不转发;`DSH_HOME` 被抹时从 `mirror.json` 找到真实 home 并报出重复注册):
Windows 64 过 + 3 平台跳过。
- `verify-branch.ps1` 全绿(39 项),含「两份 README 必须写出 package.json 里的 `engines.dsh` 原文」;`audit-manifest` clean;
`host-compat` 10 covered / 5 correctly outside。
- 手工复现三例:wrapper 递归 0.2 秒收敛、0 残留进程;挂起候选 8.3 秒放弃且无 `PING` 残留;`ComSpec` 指向空路径仍识别系统 REA。
## 2.0.7 — 2026-10-18
**机器上已有可用的 REA 时,2.x 直接按 1.x 的方式跑它(环境检测 + 委派),不再供给;同时把 2.0.6 的宿主声明收紧到真的能证明的范围。**
### 新增:环境检测 → 跑 1.x 的那条路
- **启动时判断该跑谁**:`rea` 在 PATH 上且能应答 `--version`,就用它(`rea mcp`,用户自己的 Node 与 PATH);否则走供给的
bundle。这正是 1.x 的契约,现在从 2.x 的行里也能到。
- **版本优先级**:系统 REA 比供给的旧就不抢(6.4.0 是 170 个工具、6.5.0 是 207 个,静默回退工具数是真回归)。相等或更新
才切;预发布/无法比较时保守留 bundle。
- **覆盖**:`REA_BUNDLE_LAUNCH=auto|system|bundle`(默认 auto)、`REA_BUNDLE_SYSTEM_REA=<path>` 点名不在 PATH 上的二进制。
`system` 在找不到时**报错退出**,而不是替用户去下 800 MB。
- **`--shim-status` 报告决定**:`launch.{preference,mode,reason,unusable,systemRea,rejected}`;`--shim-print-env` 在 system
模式下打印 `{launch,rea,version}`。
- **防自委派**:被委派的子进程带 `REA_BUNDLE_DELEGATED=1`,一个调用本 shim 的 wrapper 不会让委派无限递归。
### 修复(两轮独立复核,逐条可复现)
- **委派参数被加引号**:`cmd /c` 下 `.cmd` 的 `%*` 收到的是 `"mcp"` 而不是 `mcp` —— 探针能过、委派是坏的。现在只给路径加
引号,参数原样传(行固定传 `mcp`;需要 shell 引号的参数本就不在这条路上)。
- **探测预算 3s 与真机同量级**:实测经 `.cmd` 的 `rea --version` 为 2.4s(并发 2.8–3.0s),3s 预算等于掷硬币。改为 8s;超时
用 `taskkill /T /F` 杀进程树,不再只杀 cmd.exe 而留下一个占着管道的 REA。
- **Windows 信号转发是错的**:`child.kill("SIGINT")` 只 TerminateProcess(cmd.exe),里面的 REA 继续跑;现在 Windows 走
`taskkill /T`,POSIX 不重复转发(stdio inherit 已由进程组投递)。
- **`--shim-status` 退出码**:`system` 被点名但不可用时曾返回 0(bundle 恰好已供给),实际启动却 78;现在按「这一行能不能
起来」返回,并新增 `launch.unusable` 字段。
- **候选判定**:`existsSync` 换成 `statSync().isFile()`(名为 `rea.cmd` 的目录曾被当成坏安装);PATH 项去引号、跳过相对项;
显式路径被拒时理由如实报告。
- **`fail()` 的补救命令补上 `--root`**:shim 用 `REA_BUNDLE_ROOT` 解析出的根与 setup 的默认根可以不同,照着旧提示执行会供给
到另一个根。
- **`engine.dsh` 上限收紧**:`<0.3.0` 在 includePrerelease 语义下会放行 `0.3.0-alpha.1`/`0.3.0-rc.1`;改为 `<0.3.0-0`,并用
市场自己的 `satisfiesRange` 逐版本核对。
- **`host-compat.mjs` 的测试加固**:发布清单逐项钉住;新增「低地板必须被判为泄漏」的反向对照(原实现把 `leaked` 改成 `[]`
仍然全绿);`+build` 元数据按 semver 接受、`v` 前缀与零填充预发布按 semver 拒绝。
- **环境独立性**:`setup.mjs` 自检的 shim 步骤钉 `bundle` 模式(否则机器上更新的 REA 会冒充本次供给的产物);CI 两个 job
各加 job 级 `REA_BUNDLE_LAUNCH=bundle`;测试里的每个 shim 调用点都钉住模式,测试不再随机器变。
- **`rea-bundle-bootstrap.ps1` 认 `REA_BUNDLE_ROOT`**:四份代码里只有它忽略这个覆盖,导致 bootstrap 把 800 MB 装到
`<DSH_HOME>/plugins/rea-bundle`,而 shim/setup/check-updates 去 `REA_BUNDLE_ROOT` 找,看到空 bundle 再起一次。
- 中文 README 的「更新到 2.0.4」措辞改正为「更新到 2.0.5」。
### 验证
- 单测 **60 条**(新增 12 条:6 条启动模式 + 1 条清单钉住 + 1 条泄漏反向对照 + 1 条 semver 容差 + 3 条 PATH/空格路径/委派退出码),
Windows 57 过 + 3 平台跳过。
- `verify-branch.ps1` 全绿;`audit-manifest.mjs` clean;`host-compat.mjs` 10 covered / 5 correctly outside。
- 真机:全局 `rea` 6.4.0 与供给的 6.5.0 并存时 `auto` 留 bundle(不降工具数),`REA_BUNDLE_LAUNCH=system` 才切过去。
## 2.0.6 — 2026-10-18
**声明宿主版本:`engines.dsh` 覆盖 `0.1.7-rc.2` … `0.2.1-rc.2` 全部发布版本,市场卡片因此显示「兼容」而不是「未声明」。**
### 兼容声明
- **`engines.dsh: ">=0.1.7-rc.2 <0.3.0"`**:市场侧的 `deriveHostCompatibility` 只认两类声明 —— `engines.dsh`
与指向 `@deepseek-ai/dsh*` 的 `peerDependencies`;两者都没有就是 `unknown`(卡片「兼容性未知」)。
这里只声明前者,刻意不声明 peer:`@deepseek-ai/dsh-mcp-client` 在 npm 上只有 `0.0.1-rc.1`,
而宿主随包内置的是 0.2.x,声明 peer 会让 pnpm 解析出一个与宿主不一致的版本,反而制造假冲突。
- **范围来自核对,不是估计**:`scripts/host-compat.mjs` 内置 dsh 发布列表(0.1.7-rc.2 至 0.2.1-rc.2 共 10 个),
逐个验证是否被声明覆盖,并反向确认 `0.3.0` 与 `0.1.7-rc.1`、`0.1.7-alpha.2` 落在范围之外。
- **跨版本比对过的东西**:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
配置面与发布的行一致(`serverName`/`transport`/`command`/`args`/`env`/`failOnStartupError` 均存在且同名),
且两个 tag 都有 `dsh.bundle.patch`。范围声明的是「核对过的宿主」,不是「应该能用」。
### 上游(只更新仓库,暂不带入)
- 上游已发布 **rea-agents 6.6.0**(GitHub release `rea-agents-6.6.0`,2026-10-18);npm `latest` 仍是 **6.5.0**,
所以本包的钉(`manifest/bundle.manifest.json` 的 `pins.reaAgents` 与 `components.reaAgents.sources.npm.version`)
**保持 6.5.0 不动**。已核 6.6.0 的 `engines.node`(`^22.19.0 || ^24.11.0 || >=26.0.0`)与 `bin`
(`scripts/rea.mjs`)均未变化,`optionalDependencies.frida` 为 17.23.2。
- 计划:**2026-10-19** 随下一版把钉升到 6.6.0 并复跑真机;届时工具数、探针正则与文档一并更新。
### 修复 / 工程
- 新增 `scripts/host-compat.mjs`(纯函数 + CLI):semver 预发布排序、`>=floor <ceiling` 判定、发布列表覆盖检查。
- `bundle:host-compat` npm script;`verify-branch.ps1` 新增 `engines.dsh` 存在性与覆盖率两项;
`validate.yml` 新增「Declared DSH range covers every released host」步骤,清单检查也要求 `engines.dsh`。
### 验证
- 单测 **46 条**(新增 4 条:声明可解析、10 个发布版本全覆盖且 3 个未核对版本被排除、反向对照能抓出
`>=0.2.0` 这种看似合理却漏掉整个 0.2.0-rc 线的写法、预发布排序符合 semver 而非字符串序):Windows 43 过 + 3 平台跳过。
- `node scripts/host-compat.mjs`:10 个发布版本 covered、3 个 correctly outside。
- `verify-branch.ps1` 全绿;`audit-manifest.mjs` clean(两份 README 结构仍平行)。
## 2.0.5 — 2026-10-18
**市场更新失败已定位并修复:清单里没有 `main`,市场因此在每次更新时判「新构建没有可加载入口」并回滚。**
### 修复
- **应用内市场更新到 2.0.4 失败**:`dshmarket` 的更新路径用 `hasLoadableEntry` 判断新构建能不能加载
(实现是 `entryArtifactExists`:读 `main`/`exports`,两者都没有就回落 `index.js`)。本包是纯 bundle
(只有 `dsh.bundle.patch`),从未声明 `main`;而它挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
构建、不在 `node_modules` 里,于是该检查要看的 profile / 包内 / workspace 三个位置全空。结果:2.0.1 → 2.0.4
的更新连续三次报「更新后缺少入口文件……且未能验证恢复原版本文件」,而回滚其实成功了 —— 旧版同样没有入口,
所以回滚后的复检依然为 false。现已声明 `main: ./index.mjs`,并让该文件进入 `files[]`。
- **`index.mjs` 是真空插件,不是占位**:导出 `name` 与空 `apply()`,不注册行、不起进程、不写状态;真正起作用的
行仍由 `cordis.patch.yml` 插入。若 harness 某天真把它当插件加载,行为仍是「什么都不做」,因此不会造出第二条
`serverName: rea`。
### 文档
- 两份 README 新增「在市场里更新 / Updating from the market」小节:说明该报错来自市场侧的入口判定、与 tarball
完整性无关、profile 未受损,以及 2.0.5 起的做法。
### 验证
- 用 `dshmarket` 自身的 `hasLoadableEntry` 实测:把本包按 profile 布局模拟安装,修复前 `false`、修复后 `true`;
对仓库目录 `entryArtifactExists` 修复后为 `true`。
- 单测 42 条(新增 2 条:清单声明的入口必须存在且被 `files[]` 收录;入口可 `import`、导出的 `name` 与包名一致、
`apply()` 返回 `undefined`):Windows 39 过 + 3 平台跳过。
- `verify-branch.ps1` 新增 `main` 存在性与 `files[]` 收录检查;`validate.yml` 的清单检查与 npm 载荷检查加了同一条守卫。
- `npm pack --dry-run`:20 个文件,含 `index.mjs`。
## 2.0.4 — 2026-10-17
**两处已核实的缺陷:只读 bundle 目录下 shim 启动即崩;与上游 `rea setup --client dsh` 的同名注册冲突现在会被明确提示。**
### 修复
- **只读 bundle 根目录 + 陈旧 lock → shim 未捕获 EACCES 崩溃**:2.0.3 把删锁移出了 `if (fresh)`,却没包
try/catch。目录只读(`chmod 555`、系统级安装目录)且锁已陈旧时,`rmSync(lock)` 抛 `EACCES`,shim 以
未捕获异常退出(exit 1 + 堆栈)—— 比原来的静默更吵,但同样起不来。沙箱已复现(WSL `chmod 555`)。
现在删锁失败返回 `unwritable`,走既有的「无法写入,请手动跑 setup」路径(exit 78,行不崩)。
- **与上游 `rea setup --client dsh` 的同名注册冲突**:该命令往 `<DSH_HOME>/cordis.patch.yml` 写入
`id: rea-mcp`、`serverName: rea`,而本包是 `id: mcp-rea`、同一个 `serverName`;DSH 对同名条目只保留
一个(后加载者报 duplicate server name)。已按上游 6.5.0 实测生成该文件确认。shim 现在启动时检测同
文件的 `serverName: rea`(id 不是 `mcp-rea`)并向 stderr 提示「二选一」,`--shim-status` 也会在
`duplicateRegistration` 下报告。
### 文档
- 两份 README 新增「只保留一条 REA 注册」小节:与 `rea setup --client dsh` 二选一,并说明两条路线的
取舍(零依赖供给 vs 上游官方纯配置注册)。
- v1 分支 README 顶部加横幅:指向 2.x(`main`,默认推荐)与上游官方注册。
### 验证
- 单测 40 条:Windows 37 过 + 3 平台跳过;WSL 39 过 + 1 跳过(本轮新增 3 条,其中只读目录一条仅 POSIX)。
- 反向验证:回退删锁的 try/catch → 只读目录测试在 WSL 必红(EACCES/exit 1);回退冲突检测 → 重复注册
测试必红。
- `verify-branch.ps1` 全绿。
## 2.0.3 — 2026-10-16
**修复 11 处已核实的缺陷(每条都先确认存在、并配了"回退即变红"的回归测试);上游 REA 钉子从 6.4.0 升到 6.5.0。**
### 上游
- `pins.reaAgents` 与 `components.reaAgents.sources.npm.version` → **6.5.0**:engines 与 bin 入口未变
(`^22.19.0 || ^24.11.0 || >=26.0.0`、`scripts/rea.mjs`),工具数 170 → **207**(运行时 `rea doctor --json` 实测;上游源码静态清点为 206,多出的 1 个注册在 contracts 之外。新增:Frida 9、
GDB 4、Rizin/Cutter 6、pcap/packet 4、flutter 2、sqlite/rust/go/wasm/electron/harmony/hopper 各 1 等)。
新增的 `optionalDependencies.frida` 在 `--ignore-scripts` 下不会执行安装脚本。REA 探针正则收紧为
`^6\.5\.0(\s|$)`,审计新增"下一个 patch/minor 不得通过"的断言。
- Ghidra 12.1.4 / apktool 3.0.3 / jadx-headless-mcp 0.7.1 **经核实仍是当前最新**;Node 24.22.0 虽已发布,
但晚于本插件的实测矩阵,故保持 24.21.0(engines 完全满足)。
### 修复
- **`--only` 抹掉其它组件的记录**:bundle.json 由本轮结果整份重写,未点名组件的 `paths.jdk/ghidra` 变
`null`,shim 不再导出 `JAVA_HOME`/`GHIDRA_INSTALL_DIR`——原生分析静默失效,下次完整跑还得重新解压
750 MB。未点名的组件现在按旧记录结转(且只在记录的路径仍然存在时),缺依赖时优先用旧记录/机器/运行
setup 的解释器来满足,而不是整段跳过检查。
- **完整的 `.part` 不被当作已完成**:只打一行日志,然后发 `Range: bytes=<全长>-`(416;四次重试后把
已经下好的文件报成失败)。现在校验通过即改名收下。
- **Windows 下载主路线没有续传**:PowerShell 路线每轮先删 `.part`;失败后 Node 兜底仍用进本轮前的
`have`,以 append 把 200 全量接在半成品后面(长度必然超标,只能重下)。两条路线现在都按盘上实际
长度发 Range 续传。
- **POSIX 的 apktool wrapper 没有执行位**(copyFileSync 落 0644,而 REA 不加 shell 直接 spawn → EACCES):
manifest 里标了 `executable` 的条目落盘时 chmod 0755。
- **apktool wrapper 钉在 `master` 分支 + 硬校验 sha256**:上游对 wrapper 的任何一次提交都会让全网新装
hash mismatch。改钉不可变的 `v3.0.3` tag,哈希按该 tag 重新实测。
- **`--reuse` 不校验版本**:JDK 只看 `javac` 存在、Ghidra 只看 `analyzeHeadless` 存在(JDK 8/11、Ghidra 11
照收)。现在候选必须通过该组件自己的 probe;同时修好"文件型 probe 从不检查 `expect`"(Ghidra 的
`application.version=` 断言此前是死配置)。
- **`bundle.json` 损坏即永久失败**:setup 读不了它、也没人启动 setup,用户只能手删文件。现在 setup 容忍
它(并按需重写),shim 对 `malformed` 与 `missing` 一样触发供给。
- **失败后 shim 谎报 6 小时**:lock 现在记录状态与 pid;失败态如实报告"上一次失败了",持有者已消失的
lock 不再阻塞重试。
- **成功的 `--json` 轮次从不删 lock**:删除语句原先在非 `--json` 分支里,--json 成功也留下 lock。
- **shim 兜底 REA 路径少了 `node_modules`**:旧 bundle.json 缺 `paths.reaEntry` 时会把可用安装判成"CLI 丢了"。
- **`scripts/rea-bundle-bootstrap.ps1`**:`node -v` 输出非数字时 `[int]` 强转在 `$ErrorActionPreference='Stop'`
下会炸掉整个 bootstrap(而它存在的意义就是这条路径);`&` 调用失败时 `$LASTEXITCODE` 可能为 `$null` →
`exit $null` = 假成功;engines 改从 manifest 的 `pins.nodeEngines` 读(不再抄第二份);`-DryRun` 不再创建
目录(它承诺"什么都没改")。
- **`check-updates.ps1` 的 1.x 残留**:REA 节默认找 npm 全局包(2.x 是 vendored)、Ghidra 节读用户级
`GHIDRA_INSTALL_DIR`、`rea doctor` 拿不到 vendored CLI(整段自检被静默跳过)、一体包对账对"未记录版本"
的组件静默 `continue` 却打印"全部一致"。现在一律先读 `bundle.json`,并如实报告无法对账的项。
### 审计与 CI
- `audit-manifest.mjs` 新增 4 项:manifest 组件 ⇄ `INSTALL_ORDER` 对账、`pins.nodeEngines` ⇄ `package.json
engines`、REA pin 探针不得放行下一个 patch/minor、POSIX wrapper 必须标 `executable`。
- `validate.yml` 新增 `windows-latest` job:PowerShell 下载路线、zip 解压与 bootstrap 此前是零 CI 覆盖的
Windows 专属代码。
- 单测 33 条(32 过 + 1 条按平台跳过);`--reuse adopts a compatible node` 在 engines 之外的宿主上改为显式
skip(此前在 Node 20 上必红,而 CI 钉 22 看不出来)。
### 第二轮修复(同一份**更新后**的清单,2026-10-16 晚)
- 🔴 **过期 lock 永久卡死自动供给(2.0.0–2.0.2 三版全带)**:`maybeAutoProvision()` 只在 lock 新鲜时
处理它,过期的 lock 既不清除也不报告 → 落到独占创建 → `EEXIST` → 返回 `in-flight`,而 `main()` 对
`in-flight` **一个字都不打印**。于是「下载被 kill / 断电」这一常态,在 6 小时后再启动就变成**永久不再
自动供给且完全静默**。已按清单复现(7 小时死 lock:不启动、lock 原样、无任何提示;5 分钟 lock 对照组
正常删锁重启),修法:**不新鲜或持有者已死就一律清锁**(这正是注释里"只在新鲜时尊重它"的本意),
并给 `in-flight` / `spawn-failed` / `unwritable` 补上如实提示。
- 🔴 **`examples/` 两个文件是 1.x 内容,却随 2.x 进了 npm 包**:`cordis.patch.posix.yml` 教人把整行换成
`command: rea`、`cordis.patch.with-env.yml` 给的是 `cmd.exe /c rea` 的 1.x 行——对 2.x 全错(2.x 的行是
跨平台的 `process.execPath` + bundle shim,照抄会整行绕过 bundle)。两份都已是孤儿(README 不再引用),
故从 `main` **删除**,并同步 `package.json files[]` 与 `validate.yml` 的载荷/YAML/机器路径三项检查
(v1 分支保留它们,在 1.x 语境里是对的)。npm 载荷由此 21 → 19 个文件。
- 🟡 **`--offline` 管不住 `reaAgents`**:`provisionReaAgents()` 里 0 处 offline 判断,`npm install` 永远
联网——与「`--offline` is a promise, not a hint」直接矛盾。现在 `--offline` 且内置副本不可用时**直接
FAIL** 并说明原因(而可用的内置副本仍照常跳过)。
- 🟡 **npm 那一步没有代理/镜像兜底**:压缩包有 direct→proxy→PowerShell 三路,npm 只读环境变量。失败时
现在会打印这句提示,两份 README 的能力/注意里也写明 `HTTPS_PROXY` / `NPM_CONFIG_REGISTRY`。
- 🟡 **POSIX 缺 `unzip`/`xz` 必现失败**:Ghidra 全平台只有 zip,而 POSIX 解压原先只有 `unzip` 一条路。
现在 zip 依次尝试 `unzip` → `python3 -m zipfile` → **已供给 JDK 自带的 `jar`**(JDK 在 Ghidra 之前供给,
等于保底),tar 依次 `tar` → `python3`;三者皆无时打印明确的「no zip extractor available」而不是干巴巴的
「extraction failed」。
- 🟡 **manifest 的 arm64 注释是错的**:Google 的 linux/darwin platform-tools 都是 x86_64 二进制,
「The same archive serves x86-64 and arm64」不成立。三个条目改成如实描述(macOS 需 Rosetta 2、
linux-arm64 上 probe 预期失败并被报为可选组件不可用)。
- 🟡 **`docs/bundle-design.md` 与实现漂移**:§3 的「row points at `<plugin>/bin/rea-shim.mjs`」改成
「按 `DSH_HOME` 算出 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`(镜像副本)」;§7 的
`vendor/rea-agents` 改成 `vendor/node_modules/rea-agents`。
- 🟢 **`release.yml` 的 `workflow_dispatch` 可绕过 Release 直接发 npm**:publish job 现在先断言
`v<package.json version>` 的 tag 在 origin 上存在、且该 Release 挂着版本无关的 `dsh-plugin-rea.tgz`
资产,否则拒绝发布(否则 npm 与市场兜底链接会岔开)。
- 🟢 **数值环境变量会变成 NaN**:`REA_BUNDLE_FETCH_RETRIES=abc` → `Math.max(1, NaN)` → 报
「gave up after NaN attempt(s)」;NaN 的 socket 超时等价于 0(立即超时)。现在非正有限值一律回落到默认,
且启动日志新增一行 `limits connect=… idle=… retries=…`,让覆盖生效可见。
- 🟢 注释与整洁:`downloadViaPowerShell` 的文档说自己是 fallback,实际在 Windows 上是主路线(已改口径);
去掉 setup.mjs 里 3 处对已静态导入成员的重复 `await import("node:fs")`(`readdirSync` 补进静态导入)。
## 2.0.2 — 2026-10-15
**仅文档,无行为变更。**
- 两份 README 的**末尾**(许可证之前)新增一节,说明**什么时候该用 1.x 线**以及它的安装方式
(`dsh-plugin-rea@1.0.6` 或 `github:czcdr/dsh-plugin-rea#v1.0.6`)—— 此前 1.x 只出现在开头的版本说明与升级章节里。
## 2.0.1 — 2026-10-15
**仅文档与版式,无行为变更。**
+130 -7
View File
@@ -82,6 +82,10 @@ Both lines ship the **same patch shape** — one `insert` row of `@deepseek-ai/d
same `mcp__rea__*` surface; they differ in who prepares the runtime. Moving from 1.x to 2.x takes two
commands: see [Upgrading from 1.x](#upgrading-from-1x).
Upstream also registers itself with dsh now (`rea setup --client dsh`); that route is essentially the
1.x model maintained upstream — configuration only, your own Node/REA/Ghidra. This bundle is the
zero-dependency route. Pick one of the three, and never keep two `serverName: rea` rows at once.
## Install
**Nothing to install first.** On a bare Windows x64 machine:
@@ -110,6 +114,10 @@ If Node is already installed, you can skip the bootstrap and run provisioning di
node scripts/setup.mjs --reuse # or --dry-run to see the plan first
```
Node, when you provide it instead of letting setup fetch one, must satisfy REA's engines —
`^22.19.0 || ^24.11.0 || >=26.0.0` (check with `node -v`). A lower Node does not fail at install
time; the MCP row fails later, which reads as silence.
Installing the plugin **first** also works, but it takes two steps. The row resolves to
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`, and that file is created by setup — so until the first
provisioning run the row exits with `MODULE_NOT_FOUND`, and nothing is there to start a background
@@ -117,6 +125,43 @@ provisioning (the harness itself is unaffected: `failOnStartupError: false` is d
`node scripts/setup.mjs` once; from then on the shim is in place, and every later missing state is picked up
by the shim itself, which provisions in the background.
### Which DSH versions
`package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`, and that declaration is the answer the plugin market
reads: with it the card says **compatible**; without it the market can only say *unknown*. The range is not a guess —
it is the dsh release list this plugin is checked against, and `node scripts/host-compat.mjs` fails the build when the
two disagree:
| Host | Covered |
|---|---|
| `0.1.7-rc.2` … `0.2.1-rc.2` — every release in between, alpha and rc | yes |
| `0.3.0` and later | no — a new minor line is a host nobody updated this row for yet |
What was actually compared across that range is the row provider: `packages/mcp/mcp-client` at `dsh-v0.1.7-rc.2` and at
`dsh-v0.2.1-rc.2` exposes the same config surface the shipped row uses (`serverName`, `transport`, `command`, `args`,
`env`, `failOnStartupError`), and `dsh.bundle.patch` exists at both tags. A host outside the range can still work; it is
simply not claimed.
### A REA you already have is used as-is
2.x exists to supply what a machine is missing. If REA is already installed — `rea` on PATH, the 1.x setup — the row
runs **that**, exactly as the 1.x line did, and nothing is provisioned:
| What the machine has | What the row runs |
|---|---|
| `rea` on PATH, at least as new as the provisioned build | your REA (`rea mcp`), with your Node and your PATH |
| `rea` on PATH but older than the provisioned build | the provisioned bundle — a stale global CLI would take the tool count backwards (6.4.0 ships 170 tools, 6.5.0 ships 207) |
| 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.
## Configure — nothing
The shipped row carries **no machine path**:
@@ -168,6 +213,27 @@ To pin a different launch command on this machine (for example your own globally
the row from the profile's own `cordis.patch.yml` — that file is machine-local and never published. Only one
row may use `serverName: rea` in a profile.
### 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
@@ -179,11 +245,11 @@ exact shim launch the MCP row will use.
## Verified against
Author's machine, 2026-10-13 / 14:
Author's machine, 2026-10-13 / 14 / 16:
| Component | Version |
|---|---|
| REA (`rea-agents`) | **6.4.0** (170 tools) |
| REA (`rea-agents`) | **6.5.0** (207 tools) |
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**, with its bundled dsh CLI 0.1.7-rc.2 |
| `@deepseek-ai/dsh-mcp-client` (row provider) | bundled by the harness |
| Ghidra | 12.1.4 |
@@ -192,7 +258,7 @@ Author's machine, 2026-10-13 / 14:
| Host | Windows 11 x64 (build 26200) |
Exercised live: a clean `setup.mjs --reuse` run reached `setup complete`; an MCP `initialize` handshake
**through the shim with no `rea` on PATH** answered `serverInfo {name: rea, version: 6.4.0}`; and the whole
**through the shim with no `rea` on PATH** answered `serverInfo {name: rea, version: 6.5.0}`; and the whole
chain ran under the real desktop host — its own Electron binary launched
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`, the shim started the **vendored** REA, and a live tool
call reported `path_source: environment` for a path only that machine's `bundle.json` knows.
@@ -203,22 +269,57 @@ macOS/Linux.
## Scope and caveats
- REA exposes 170 tools at 6.4.0, so every session's tool list grows.
- REA exposes 207 tools at 6.5.0, so every session's tool list grows.
- Provisioning is honest about checksums: where a vendor publishes one it is pinned and verified; where none
exists (jadx-headless-mcp, the apktool wrapper, Android platform-tools, the rolling "latest" URLs) the
hash is **measured once and recorded**, and later drift is reported rather than silently accepted.
`bundle.json` lists which components are in that state under `unverifiedChecksums`.
- Downloads are slow on some networks (measured 0.35–0.54 MiB/s from the GitHub release CDN here, so Ghidra
alone takes ~25–45 min). Fetching is resumable: the cache is kept and re-verified, and a killed run
continues instead of restarting.
alone takes ~25–45 min). Fetching is resumable on both routes: a partial `.part` is continued with a Range
request, and a `.part` that already holds the whole file is verified and kept instead of re-fetched.
- Some REA tools launch processes, write files and access the network — DSH's permission policy applies.
Analyse only artifacts you are authorized to inspect.
- The MCP child and the engines it launches are ordinary processes running as **you**. DSH's tool
approvals govern what the model asks for, not what that child may read, write or send on its own;
treat the plugin like any third-party code you install.
- On Linux, upstream's process-ownership checks need a **procps-compatible `ps`** on PATH; the
Ghidra/JADX and firmware paths use it. A minimal image without it fails those tools, not the install.
- `scripts/setup.mjs` runs `npm install` for the REA CLI with `--ignore-scripts`: the published tarball
carries no `node_modules`, so a dependency closure must be resolved, and package lifecycle scripts are
skipped on purpose during provisioning.
skipped on purpose during provisioning. That step is also the **only** one that cannot use this bundle's
own route/proxy resolution — npm reads `HTTPS_PROXY`/`HTTP_PROXY` and `NPM_CONFIG_REGISTRY` from the
environment, so on a network that needs a mirror, set one of those before provisioning
(`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`). With `--offline` the step is refused outright
instead of quietly going online.
- On POSIX, extraction falls back when the usual tools are missing: a zip is opened with `unzip`, then
`python3 -m zipfile`, then the provisioned JDK's `jar`; a tarball with `tar`, then `python3`. A minimal
image that has none of them fails with an explicit "no zip extractor available" line rather than a bare
"extraction failed". This matters because Ghidra is a zip on every platform and `unzip` is not installed
everywhere by default.
## Updating — three independent things
### Updating from the market
Update it where you installed it. Updating **to 2.0.5 or later** is routine; updating an installed 2.0.1–2.0.4
*through the in-app market* failed with this, three times on a healthy profile:
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
That message is not about a broken tarball. On every update the market asks whether the new build's
`package.json` points at an entry file that exists (`hasLoadableEntry` in `dshmarket`), and rolls the build back
when the answer is false. A bundle may legitimately own no entry — but only if a package it *mounts* has one, and
the package this bundle mounts (`@deepseek-ai/dsh-mcp-client`) ships inside the harness build rather than in
`node_modules`, so every location that test probes comes back empty. The 1.x and 2.0.x manifests also declared
no `main` at all, so the answer was false even though the profile was working; the rollback was then reported as
unverifiable for the same reason. Nothing in the profile was actually damaged — the update simply did not apply.
2.0.5 declares `main: ./index.mjs`. That file is a real, deliberately empty Cordis plugin: it registers nothing,
spawns nothing, touches no state — the working row is still inserted by `cordis.patch.yml` — and it exists so the
question "can this package load?" has an honest answer, and so `import 'dsh-plugin-rea'` resolves. On an older
version, update to `>=2.0.5`; if the market refuses before that version exists, install `[email protected]`
(Desktop: through the plugin manager) instead of updating in place.
### Upgrading from 1.x
Install 2.x, then run provisioning **once** before restarting dsh:
@@ -236,6 +337,12 @@ back on the next start.
- **This plugin** never updates itself. Installed from a market? Update it there. Installed from a `file:`
checkout? Overwrite the directory and reinstall.
- **The row runs a COPY of this plugin's launcher.** `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` is a mirror
made by setup, so a market upgrade replaces the package but not the file the row executes — until setup runs
again. From **2.0.8** the mirror notices that the installed plugin ships a different launcher and re-execs it
(one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`), so upgrades carry. On an older version the mirror stays behind
silently; `--shim-status` now reports `mirror.stale`, and `check-updates.ps1` says to re-run setup. If you are
upgrading from 2.0.0–2.0.7, run `node scripts/setup.mjs` once after the upgrade.
- **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
@@ -248,6 +355,22 @@ back on the next start.
`check-updates.ps1` (Windows PowerShell) reports the state of all of these at once.
## Still prefer the configuration-only 1.x line?
1.x is the adapter **without** provisioning: one row that launches `rea mcp` from PATH and expects Ghidra to be
installed by you. It is the right choice when you already manage REA and Ghidra yourself, when you do not want
the plugin fetching anything, or when you want the row to keep pointing at your own `rea` binary:
```
dsh plugin --profile <profile> add [email protected]
# or pin the branch instead of the version:
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v1.0.6
```
Both lines publish the same patch shape and the same `mcp__rea__*` surface. 1.x is maintained on the
[`v1` branch](https://github.com/czcdr/dsh-plugin-rea/tree/v1), where its own install notes live (Node on REA's
engines, `npm i -g rea-agents`, Ghidra 12.1.x for native analysis).
## License
MIT (this adapter). REA is MIT and owned by its authors; Ghidra is Apache-2.0 and owned by the NSA; Temurin
+118 -6
View File
@@ -77,6 +77,9 @@ patch 里不含任何机器路径**。
`mcp__rea__*` 工具面;区别只在"谁准备运行时"。从 1.x 到 2.x 只要两条命令,见
[从 1.x 升级](#从-1x-升级)。
上游现在也能自己注册进 dsh(`rea setup --client dsh`):那基本就是上游维护的 1.x 模式 —— 只写配置,
Node/REA/Ghidra 由你准备。本包是零依赖路线。三条路选一条,不要同时留两行 `serverName: rea`。
## 安装
**前置什么都不用装。** 在干净的 Windows x64 机器上:
@@ -104,11 +107,49 @@ dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
```
如果 Node 由你自备(而不是让 setup 去取),它必须满足 REA 的 engines:
`^22.19.0 || ^24.11.0 || >=26.0.0`(用 `node -v` 核对)。版本偏低不会在安装时失败,而是到 MCP 行
启动时才失败 —— 表现就是"没有反应"。
反过来**先装插件**也可以,但要分两步:发布的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
而那个文件**只有 setup 会创建**——所以在第一次供给之前,该行会以 `MODULE_NOT_FOUND` 退出,也就没有
任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
`node scripts/setup.mjs`;此后 shim 就位,之后每一次「状态缺失」都由它自己发现并在后台补供给。
### 支持哪些 DSH 版本
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`,这就是插件市场读的那句话:有它,卡片显示
**兼容**;没有它,市场只能说*未声明*。这个范围不是猜的——它对应本插件实际核对过的 dsh 发布列表,
`node scripts/host-compat.mjs` 会在两者不一致时让构建失败:
| 宿主版本 | 是否覆盖 |
|---|---|
| `0.1.7-rc.2` … `0.2.1-rc.2`(中间的 alpha 与 rc 全部在内) | 是 |
| `0.3.0` 及以后 | 否 —— 新的小版本线是还没为它核对过这一行的宿主 |
真正被比对过的是「行提供方」:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
暴露的配置面与发布的行完全一致(`serverName`、`transport`、`command`、`args`、`env`、`failOnStartupError`),
并且两个 tag 都有 `dsh.bundle.patch`。范围之外的宿主可能照样能用,只是没有替它声明。
### 机器上已有 REA 就直接用它
2.x 存在的意义是补上机器缺的东西。如果 REA 已经装好——`rea` 在 PATH 上,也就是 1.x 的形态——这一行就**直接跑它**,
和 1.x 一模一样,不再做任何供给:
| 机器上的情况 | 这一行跑什么 |
|---|---|
| `rea` 在 PATH 上,且不比供给的版本旧 | 你自己那份(`rea mcp`),用你的 Node 和你的 PATH |
| `rea` 在 PATH 上,但比供给的版本旧 | 供给出来的 bundle —— 旧的全局 CLI 会把工具数往回退(6.4.0 是 170 个,6.5.0 是 207 个) |
| 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 不会让它无限委派给自己。
## 配置 —— 不需要
发布的那一行**不含任何机器路径**:
@@ -159,6 +200,25 @@ node bin/rea-shim.mjs --shim-print-env # MCP 子进程将看到的环境变量
`cordis.patch.yml` 按 id 覆盖即可 —— 那个文件是机器本地的,永不发布。一个 profile 里只允许一行使用
`serverName: rea`。
### 只保留一条 REA 注册
上游 REA 6.5.0 起可以自己注册进 DSH:
```
rea setup --client dsh
```
它会把一行 `id: rea-mcp`、`serverName: rea` 写进 `<DSH_HOME>/cordis.patch.yml`。本包的行是
`id: mcp-rea`、同样 `serverName: rea`,而 DSH 对同名条目只保留一个:后加载的那行会以
duplicate server name 报错并加载失败,最终留下哪一行取决于加载顺序。**二选一,不要同时用。**
选哪个?上游那条是纯配置路线:需要 Node 满足 REA engines、`rea-agents` 可达,Ghidra/JDK 由你
自己准备。本包是零依赖路线:首次运行自动取 Node、REA、JDK、Ghidra。两者的 patch 形态与
`mcp__rea__*` 工具面相同。
shim 会在启动时提示冲突 —— `setup.mjs` 会把运行时的 DSH home 记进 `mirror.json`,镜像副本在 harness 抹掉
`DSH_*` 之后就用这条记录去找那份文件;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
## 验证
随便发一个 `mcp__rea__*` 调用 —— `binary_session` 会报引擎各项检查
@@ -170,11 +230,11 @@ shim 启动**。
## 实测环境
作者机器,2026-10-13 / 14:
作者机器,2026-10-13 / 14 / 16:
| 组件 | 版本 |
|---|---|
| REA(`rea-agents`) | **6.4.0**(170 个工具) |
| REA(`rea-agents`) | **6.5.0**(207 个工具) |
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**,内含 dsh CLI 0.1.7-rc.2 |
| `@deepseek-ai/dsh-mcp-client`(行 provider) | 随 harness 打包 |
| Ghidra | 12.1.4 |
@@ -183,7 +243,7 @@ shim 启动**。
| 宿主 | Windows 11 x64(build 26200) |
实测跑过:一次干净的 `setup.mjs --reuse` 走到 `setup complete`;一次**不经 PATH 上的 `rea`、完全经由
shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.4.0}`;以及**真实桌面宿主下的完整链路** ——
shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}`;以及**真实桌面宿主下的完整链路** ——
宿主自己的 Electron 二进制启动 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`,shim 拉起 **bundle 内置**
的 REA,随后一次真实工具调用报出 `path_source: environment`,而那个路径只有该机的 `bundle.json` 知道。
@@ -192,21 +252,53 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.4.0}
## 边界与注意事项
- REA 在 6.4.0 有 170 个工具,每个会话的工具列表都会变长。
- REA 在 6.5.0 有 207 个工具,每个会话的工具列表都会变长。
- 校验和对得起良心:厂商发布了的就固定并验证;没发布的(jadx-headless-mcp、apktool 包装脚本、
Android platform-tools、滚动的 `latest` 地址)则**实测一次并记录**,之后若漂移是**报告**而不是默默接受。
`bundle.json` 的 `unverifiedChecksums` 会列出处于该状态的组件。
- 某些网络下很慢(这里实测 GitHub release CDN 只有 0.35–0.54 MiB/s,光 Ghidra 就要 25–45 分钟)。
下载是**可续传**的:缓存保留并重新校验,被中断的一轮会继续而不是从头再来。
下载是**可续传**的:两条路线都按盘上已有的字节发 Range 继续;若 `.part` 恰好已经完整,会校验后直接收下
而不是重下。
- **代理**:Node 的全局 `fetch` 不读系统代理,而 .NET/PowerShell 会读。在开着 WinINET 代理的机器上,
同一个地址 `Invoke-WebRequest` 通、Node 却 `ECONNRESET`/超时。setup 自己解析代理
(环境变量优先,Windows 上回退读注册表)并用 CONNECT 隧道,`NO_PROXY` 也照常生效。
- REA 的部分工具会启动进程、写文件、访问网络 —— DSH 的权限策略照常适用。只分析你有权检视的样本。
- MCP 子进程与它拉起的引擎都是以**你的身份**运行的普通进程。DSH 的工具审批管的是模型发起的调用,
管不到这个子进程自己去读、写或发送什么;请把插件当作你安装的第三方代码来对待。
- Linux 上,上游的进程归属检查依赖 PATH 上**兼容 procps 的 `ps`**;Ghidra/JADX 与固件工具会用到它。
极简镜像里缺它只会让这些工具失败,不影响整体安装。
- `scripts/setup.mjs` 为 REA CLI 跑的是带 `--ignore-scripts` 的 `npm install`:官方 tarball 不带
`node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。
`node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。这一步也是**唯一**用不上
本插件自己的路由/代理回退的环节 —— npm 只从环境读 `HTTPS_PROXY`/`HTTP_PROXY` 与
`NPM_CONFIG_REGISTRY`,所以网络需要镜像时请先设其中一个(例如
`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`)。带 `--offline` 时这一步会被**直接拒绝**,
而不是悄悄联网。
- POSIX 上解压有兜底:zip 依次尝试 `unzip` → `python3 -m zipfile` → 已供给 JDK 自带的 `jar`;tar 包依次
尝试 `tar` → `python3`。三者都没有的最小镜像会得到一句明确的「no zip extractor available」,而不是
干巴巴的「extraction failed」。Ghidra 在所有平台都只有 zip,而 `unzip` 并非每个发行版默认安装,
所以这条不是理论问题。
## 更新 —— 三件互相独立的事
### 在市场里更新
在哪儿装的就在哪儿更新。更新到 **2.0.5 及以上**是常规操作;而把已装的 2.0.1–2.0.4 通过应用内市场**更新到
2.0.5**,在健康的 profile 上连续失败了三次:
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
这条消息与 tarball 是否完整无关。市场在每次更新时都会问一句:新构建的 `package.json` 是否指向一个真实存在的
入口文件(`dshmarket` 里的 `hasLoadableEntry`);答案是「否」,就把这次更新判为坏构建并回滚。bundle 本来允许
自己没有入口 —— 前提是它**挂载**的某个包有入口;而本包挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
构建,不在 `node_modules` 里,于是这项检查要看的每个位置都是空的。1.x 与 2.0.x 的清单又根本没有声明 `main`,
所以答案必然是「否」,哪怕 profile 一直是好的;紧接着的回滚也因此被报成「无法验证」。实际上 profile 没有被
破坏,只是这次更新没有生效。
2.0.5 声明了 `main: ./index.mjs`。那是一个真实存在、刻意留空的 Cordis 插件:不注册任何行、不启动任何进程、
不碰任何状态(真正起作用的行仍由 `cordis.patch.yml` 插入),它的作用只是让「这个包能不能加载」有一个诚实的
答案,并让 `import 'dsh-plugin-rea'` 能解析。还停在旧版本时,更新到 `>=2.0.5` 即可;若市场在 2.0.5 之前就拒绝,
就直接安装 `[email protected]`(桌面端:在插件管理器里装),不要原地更新。
### 从 1.x 升级
装上 2.x 后,**先跑一次供给**,再重启 dsh:
@@ -222,6 +314,11 @@ node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-boot
工具就回来了。
- **本插件**从不自更新。从市场装的就在市场更新;从 `file:` 检出的就覆盖目录重装。
- **行跑的是本插件启动器的「镜像副本」**:`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 是 setup 复制过去的,
所以市场升级只换包、不换行执行的那个文件 —— 直到再跑一次 setup。从 **2.0.8** 起,镜像会发现自己落后于已安装的
插件,并转发执行插件里的那一份(一跳,带 `REA_BUNDLE_SHIM_REEXEC` 守卫),升级因此能生效。更早的版本里镜像会
静默地停在旧代码;`--shim-status` 现在会报 `mirror.stale`,`check-updates.ps1` 也会提示重跑 setup。**从
2.0.0–2.0.7 升级的话,升级后跑一次 `node scripts/setup.mjs`。**
- **从 `2.0.0-bundle.1` 升级**:那一版镜像了 shim 与 `setup.mjs`,却漏了 manifest,也没有留下插件 checkout
的记录,所以它供给出来的 bundle **无法自愈** —— 后台供给会因缺 manifest 而崩溃且完全不可见,而它自己写下的
lock 又会让重试被抑制六小时。请**从插件目录**重跑一次 `node scripts/setup.mjs`:它会重新镜像三个文件,
@@ -233,6 +330,21 @@ node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-boot
`check-updates.ps1`(Windows PowerShell)会一次性报告以上各项的状态。
## 还想用纯配置的 1.x 线?
1.x 是**不带供给**的适配器:一行配置,从 PATH 上启动 `rea mcp`,并假设 Ghidra 由你自己装好。这些情况它是更合适的选择 ——
你已经自己管理 REA 与 Ghidra、不希望插件去下载任何东西、或者想让那一行继续指向你自己的 `rea`:
```
dsh plugin --profile <profile> add [email protected]
# 或钉住分支而不是版本号:
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v1.0.6
```
两条线发布的 patch 形态与 `mcp__rea__*` 工具面完全相同。1.x 维护在
[`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),它自己的安装说明也在那里(Node 满足 REA engines、
`npm i -g rea-agents`、原生分析需要 Ghidra 12.1.x)。
## 许可证
MIT(本适配器)。REA 是 MIT、归其作者;Ghidra 是 Apache-2.0、归 NSA;Temurin 是 GPLv2+CE;Node.js 是
+2 -2
View File
@@ -84,7 +84,7 @@ Draw-Text $g '第三方适配 · 自身不做逆向分析 · 自供给 Node/REA/
$rowY = 180; $boxH = 158; $boxW = 250
Draw-Box $g 60 $rowY $boxW $boxH $([System.Drawing.Color]::White) $line
Draw-Text $g 'DeepSeek Harness 会话' $fBox $ink 80 ($rowY + 18)
Draw-Text $g "mcp__rea__* 工具`n(6.4.0 暴露 170+)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
Draw-Text $g "mcp__rea__* 工具`n(6.5.0 暴露 207)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
Draw-Box $g 360 $rowY $boxW $boxH $([System.Drawing.Color]::FromArgb(255,239,246,255)) $accent
Draw-Text $g '官方 MCP 客户端行' $fBox $accent 380 ($rowY + 18)
@@ -112,7 +112,7 @@ Draw-Line $g 358 446 150 446 $muted
Draw-Arrow $g 150 446 150 342 $muted
Draw-Text $g '上游 REA(MIT):github.com/morluto/rea 本插件:github.com/czcdr/dsh-plugin-rea' $fSmall $muted 60 620
Draw-Text $g '实测 REA 6.4.0 + DSH 桌面运行时 0.2.0-rc.2 + Ghidra 12.1.4 + Node 24.21.0(Windows 11 x64,2026-10-14)' $fSmall $muted 60 646
Draw-Text $g '实测 REA 6.5.0 + DSH 桌面运行时 0.2.0-rc.2 + Ghidra 12.1.4 + Node 24.21.0(Windows 11 x64,2026-10-16)' $fSmall $muted 60 646
Draw-Text $g 'Ghidra 为 Apache-2.0(NSA);REA 为 MIT。本仓库不分发上游代码。' $fSmall $muted 60 672
Save-Png $bmp $g (Join-Path $OutDir 'screenshot-1.png')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+820 -33
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* rea-shim — the process the DSH MCP row launches.
* rea-shim 鈥?the process the DSH MCP row launches.
*
* Why this exists: the published `cordis.patch.yml` must carry no machine path,
* yet REA needs absolute paths for every dependency. The shim is the seam: it
@@ -10,12 +10,13 @@
* It downloads nothing, writes nothing, and never touches PATH.
*
* Exit codes: 0 child exited normally * child's own status
* 78 EX_CONFIG bundle not provisioned (or unusable) — the row has
* 78 EX_CONFIG bundle not provisioned (or unusable) 鈥?the row has
* `failOnStartupError: false`, so the harness stays up
* and the message is the operator's remediation.
*/
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
@@ -32,6 +33,36 @@ function dshHome() {
return path.join(homedir(), ".dsh");
}
/**
* The DSH home recorded by setup, or null.
*
* Only the mirrored copy needs this: the harness strips `DSH_*` from an MCP
* child, so with a custom home the environment cannot answer. Read once per
* process — there is one bundle root per process and this is a startup query.
*/
let recordedDshHomeCache = null;
function recordedDshHome(root) {
if (recordedDshHomeCache !== null) return recordedDshHomeCache;
let value = null;
try {
const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
if (typeof record?.dshHome === "string" && record.dshHome.trim() !== "" && path.isAbsolute(record.dshHome)) {
value = path.resolve(record.dshHome);
}
} catch {
value = null;
}
recordedDshHomeCache = value;
return value;
}
/** The home to look in when the environment has been scrubbed. */
function dshHomeFor(root) {
const fromEnv = process.env.DSH_HOME;
if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
return recordedDshHome(root) ?? path.join(homedir(), ".dsh");
}
/**
* Where setup writes the machine-local truth.
*
@@ -43,11 +74,11 @@ function dshHome() {
* DSH_HOME: the harness strips every `DSH_*` name from an MCP child's
* environment (case-insensitively, because Windows is), so a custom DSH_HOME
* would send it to `~/.dsh`, where it would report "not provisioned" for a
* bundle sitting right next to it — and start a second ~800 MB run there.
* bundle sitting right next to it 鈥?and start a second ~800 MB run there.
* * The **checkout** copy (`<plugin>/bin/rea-shim.mjs`) is what a human runs, e.g.
* `npm run bundle:status`, from a shell where DSH_HOME *is* visible. Its own
* directory is the plugin, not the bundle, so resolving to itself would report
* "not provisioned" on a fully provisioned machine — and a bare
* "not provisioned" on a fully provisioned machine 鈥?and a bare
* `node bin/rea-shim.mjs mcp` would provision ~800 MB into the checkout.
*
* Only the checkout ships `cordis.patch.yml`, so that file is the discriminator.
@@ -80,7 +111,7 @@ function usableSetup(dir) {
* The mirrored copy is a snapshot: after a plugin upgrade it still holds the
* previous manifest, so it would provision the previous versions forever. setup
* writes <bundle>/mirror.json naming the real checkout; prefer that while it is
* usable, and fall back to the local copy — which is also all a first run has.
* usable, and fall back to the local copy 鈥?which is also all a first run has.
*/
function provisioningScript(root) {
try {
@@ -93,6 +124,80 @@ function provisioningScript(root) {
return usableSetup(PLUGIN_ROOT);
}
/**
* Upstream REA 6.5.0 can add a second registration for the same MCP server:
* `rea setup --client dsh` writes a row with id "rea-mcp" and serverName "rea"
* into `<DSH_HOME>/cordis.patch.yml`, while this bundle ships id "mcp-rea" with
* the same serverName. DSH does not dedupe those rows: it rejects the later entry
* with a duplicate-server-name error, so which REA row survives depends on load
* order. The shim cannot prevent that, but it can name the conflict instead of
* leaving a missing row to be diagnosed from "the tools are gone".
*
* The parse is deliberately small: the two files it cares about are block
* mappings with one id/serverName per row, and anything it cannot attribute is
* ignored — a missed hint is acceptable, a false alarm is not.
*
* Finding the file is the part that needed care: the harness strips every DSH_*
* variable from an MCP child, so a mirrored copy with a custom DSH_HOME used to
* read `~/.dsh` — the wrong file, and therefore a conflict it could never report.
* `setup.mjs` writes the home it ran under into `mirror.json`, and that record is
* what this uses when the environment has nothing to say.
*/
function foreignReaRegistration(root) {
const patch = path.join(dshHomeFor(root), "cordis.patch.yml");
let text;
try {
text = readFileSync(patch, "utf8");
} catch {
return null;
}
const unquote = (value) => value.replace(/^(['"])(.*)\1$/, "$2").trim();
// A row starts at `- id:`; the block runs to the next row, so a serverName can
// never be attributed to a row that does not contain it.
for (const block of text.split(/\r?\n(?=\s*-\s+id:\s*)/)) {
const id = /^\s*-\s+id:\s*(.+?)\s*$/m.exec(block);
const server = /^\s+serverName:\s*(.+?)\s*$/m.exec(block);
if (!id || !server || unquote(server[1]) !== "rea") continue;
const rowId = unquote(id[1]);
if (rowId !== "mcp-rea") return { patch, id: rowId };
}
return null;
}
/**
* The provisioning lock is a small JSON record: { state, pid, at, exit? }.
* `state: "running"` is written when a run is started (setup's own pid once it
* exists), and the failing run rewrites it as `state: "failed"` 鈥?it is kept on
* purpose so a broken setup cannot be re-downloaded in a loop. A legacy
* plain-number lock is still honoured as "running".
*/
const STALE_LOCK_MS = 6 * 60 * 60 * 1000;
function readLock(lock) {
try {
const raw = readFileSync(lock, "utf8").trim();
if (raw.startsWith("{")) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed;
}
const at = Number(raw);
return Number.isFinite(at) ? { state: "running", at } : null;
} catch {
return null;
}
}
/** Is the process recorded in the lock still alive? null means "cannot tell". */
function pidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return null;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === "ESRCH" ? false : null;
}
}
/**
* On first run there is nothing to launch. Provisioning is a long download, so
* it is started detached: the harness may kill this row (or the whole session)
@@ -100,34 +205,67 @@ function provisioningScript(root) {
* a killed attempt is simply continued on the next start.
*
* Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
* Returns the log path when a run was started, otherwise null.
* Returns { started, reason, logPath } 鈥?the caller words its message from
* `reason`, because "provisioning started in the background" was also printed
* for a lock left behind by a run that had already failed, and for one whose
* process was long gone.
*/
function maybeAutoProvision(root) {
if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return null;
if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return { started: false, reason: "disabled" };
const setupScript = provisioningScript(root);
if (!setupScript) return null;
if (!setupScript) return { started: false, reason: "no-setup" };
const lock = path.join(root, "setup.lock");
const logPath = path.join(root, "logs", "setup.log");
if (existsSync(lock)) {
// A crashed run must not wedge provisioning forever: honour the lock only
// while it is fresh; setup removes it itself when it finishes cleanly.
let age = Infinity;
const record = readLock(lock);
const age = record && Number.isFinite(record.at) ? Date.now() - record.at : Infinity;
const fresh = Number.isFinite(age) && age < STALE_LOCK_MS;
if (fresh && record?.state === "failed") {
// The run gave up and left the lock behind so it would not be retried in a
// loop. Claiming it is still provisioning was a six-hour lie: nothing runs,
// and every restart repeated the sentence.
return {
started: false,
reason: "last-run-failed",
exit: record.exit,
failedAt: record.at,
ageMs: age,
logPath,
};
}
if (fresh) {
const alive = pidAlive(record?.pid);
if (alive !== false) return { started: false, reason: "in-flight", logPath, ageMs: age };
}
// Reached when the lock is older than the freshness window, or its holder is
// gone and never wrote an outcome (harness restart, power loss, a kill): what
// it records is not happening any more. This removal is the whole point of the
// freshness rule 鈥?without it the exclusive create below failed with EEXIST
// *forever*, auto-provisioning never ran again, and main() printed nothing at
// all for that outcome. The lock's own "honour it only while it is fresh"
// comment described behaviour the code did not have.
try {
age = Date.now() - Number(readFileSync(lock, "utf8").trim() || 0);
rmSync(lock, { force: true });
} catch {
age = Infinity;
// A read-only bundle root (system-wide install, chmod 555) cannot drop the
// stale lock either. Returning the same "cannot write here" outcome as the
// exclusive create below keeps the row alive: an uncaught EACCES killed the
// shim with a stack trace, which is louder than the old silence but just as
// unable to start REA.
return { started: false, reason: "unwritable", logPath };
}
if (Number.isFinite(age) && age < 6 * 60 * 60 * 1000) return logPath;
}
try {
mkdirSync(path.join(root, "logs"), { recursive: true });
// Exclusive create: check-then-write is not atomic, so two rows starting at
// once could both decide to provision. EEXIST means another run owns it.
writeFileSync(lock, String(Date.now()), { flag: "wx" });
writeFileSync(lock, JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }), { flag: "wx" });
} catch (error) {
if (error?.code === "EEXIST") return logPath;
if (error?.code === "EEXIST") return { started: false, reason: "in-flight", logPath };
/* a read-only bundle root must never break the harness */
return null;
return { started: false, reason: "unwritable" };
}
try {
const child = spawn(process.execPath, [setupScript, "--root", root], {
@@ -136,9 +274,18 @@ function maybeAutoProvision(root) {
windowsHide: true,
});
child.unref();
return logPath;
// Hand the lock to the detached downloader, so a later start that finds this
// row dead still sees the run that is actually doing the work as alive.
if (Number.isInteger(child.pid)) {
try {
writeFileSync(lock, JSON.stringify({ state: "running", pid: child.pid, at: Date.now(), supervisor: process.pid }));
} catch {
/* the lock is advisory: the row keeps working without it */
}
}
return { started: true, reason: "started", logPath };
} catch {
return null;
return { started: false, reason: "spawn-failed", logPath };
}
}
@@ -152,15 +299,20 @@ function fail(message, detail) {
if (detail) lines.push("", " detail: " + detail);
// Point at the real checkout when the record allows it: running the mirrored
// copy is fine, but it can only ever provision the manifest it was mirrored
// with — and a bundle mirrored before the manifest travelled with it has no
// with 鈥?and a bundle mirrored before the manifest travelled with it has no
// usable copy at all, so the honest answer is then "run the plugin's own script".
const setupScript = provisioningScript(bundleRoot());
const root = bundleRoot();
const setupScript = provisioningScript(root);
lines.push(
"",
" Finish setup with:",
setupScript
? " node \"" + setupScript + "\""
: " node \"<installed plugin directory>/scripts/setup.mjs\"",
// `--root` is not decoration: a shim started with REA_BUNDLE_ROOT (or with
// DSH_HOME unset in a row's scrubbed environment) resolves a root that
// setup's own default need not agree with, and a remediation command that
// provisions into another root leaves this row failing exactly as before.
? " node \"" + setupScript + "\" --root \"" + root + "\""
: " node \"<installed plugin directory>/scripts/setup.mjs\" --root \"" + root + "\"",
);
if (!setupScript) {
lines.push("", " (Run it from the installed plugin directory: the copy in this bundle has no manifest.)");
@@ -195,8 +347,13 @@ function readBundleConfig(root) {
function resolveRea(config, root) {
const paths = config.paths ?? {};
const node = paths.node;
// The fallback has to match where setup actually installs the package:
// `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
// fallback without that segment named a directory that never exists. It only
// fires when an older bundle.json has no paths.reaEntry 鈥?which is exactly when
// a wrong fallback turns a usable install into "the vendored REA CLI is gone".
const reaEntry = paths.reaEntry
?? path.join(root, "vendor", "rea-agents", "scripts", "rea.mjs");
?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
if (typeof node !== "string" || node.trim() === "") {
return { ok: false, reason: "bundle.json has no paths.node" };
@@ -245,11 +402,540 @@ function childEnvironment(config) {
return env;
}
/**
* Launch modes. 2.x exists to supply a runtime the machine does not have; when
* the machine already has one, the honest thing to do is run it.
*
* The 1.x line was exactly that: `rea mcp` off PATH, user-managed Node and
* Ghidra. So "use the system install" is not a new code path invented here 鈥?it
* is the 1.x contract, reachable from the 2.x row. `auto` (the default) prefers
* the system install and falls back to the provisioned bundle; `bundle` pins the
* old behaviour; `system` insists (and fails loudly instead of provisioning
* 800 MB behind an operator who asked for their own REA).
*/
const LAUNCH_MODES = ["auto", "system", "bundle"];
/**
* How long a candidate may take to answer `--version`.
*
* Measured on a real npm-global install: `rea --version` through its `.cmd`
* shim takes ~2.4 s cold (cmd.exe -> node -> the CLI's own module graph), and
* 2.8-3.0 s with four probes in flight. A 3 s budget was therefore a coin flip:
* half the runs rejected a perfectly good REA and silently fell back to the
* bundle. 8 s leaves room for a slow disk and a loaded machine while still
* bounding the one-off cost at startup.
*/
const PROBE_TIMEOUT_MS = 8_000;
/**
* What the environment says about the launch mode.
*
* An unknown value is reported and treated as `auto`: the row is launched by
* the harness with nobody watching stderr, so refusing to start over a typo
* would read as "the plugin is broken". `auto` is the mode that still works.
*/
function launchPreference(env = process.env) {
const raw = (env.REA_BUNDLE_LAUNCH ?? "").trim().toLowerCase();
if (raw === "") return { value: "auto", invalid: null };
return LAUNCH_MODES.includes(raw)
? { value: raw, invalid: null }
: { value: "auto", invalid: raw };
}
/** True when `child` is `parent` or lives under it (case-insensitive on Windows). */
function isInside(child, parent) {
const normalise = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
const target = normalise(path.resolve(child));
const base = normalise(path.resolve(parent));
if (target === base) return true;
const relative = path.relative(base, target);
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}
/**
* Executable names a REA install provides on this platform.
*
* npm's global install writes a shell script plus `.cmd`/`.ps1` shims on
* Windows, and only the `.cmd` one is spawnable without a shell. `rea.exe` is
* accepted for a native/standalone install.
*/
function systemReaNames(platform = process.platform) {
return platform === "win32" ? ["rea.cmd", "rea.exe", "rea.bat"] : ["rea"];
}
/**
* Every path worth probing, most explicit first.
*
* `REA_BUNDLE_SYSTEM_REA` names one binary outright 鈥?that is both the test
* seam and a real operator need (an install that is not on the harness's PATH,
* which on Desktop is not the user's shell PATH).
*/
function systemReaCandidates({ env = process.env, platform = process.platform } = {}) {
const candidates = [];
const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
if (explicit !== "") candidates.push(path.resolve(explicit));
const searchPath = env.PATH ?? env.Path ?? "";
for (const raw of searchPath.split(path.delimiter)) {
// A quoted PATH entry (`PATH="C:\Program Files\v66";…`) is legal in Windows
// environments and `path.join` would treat the quotes as part of the name, so
// the install would be invisible. A relative entry is skipped rather than
// resolved: its meaning depends on the cwd THIS process happens to have, and
// a candidate found that way would be launched by a path that means something
// else to whoever reads the status report.
const dir = raw.trim().replace(/^"(.*)"$/, "$1");
if (dir === "" || !path.isAbsolute(dir)) continue;
for (const name of systemReaNames(platform)) candidates.push(path.join(dir, name));
}
const seen = new Set();
return candidates.filter((candidate) => {
const key = process.platform === "win32" ? candidate.toLowerCase() : candidate;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/**
* Kill a process and its children.
*
* `spawnSync`'s timeout kills only the process it started — for a `.cmd` shim
* that is cmd.exe, and the `node` running inside it keeps the inherited stdio
* handles open and keeps working. Taskkill's `/T` is the one portable-enough way
* to get the tree on Windows; POSIX uses the process itself (the candidate is a
* shebang script there, so there is no intermediate shell).
*/
function killTree(pid, platform = process.platform) {
if (typeof pid !== "number" || pid <= 0) return;
try {
if (platform === "win32") {
const taskkill = path.join(process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows", "System32", "taskkill.exe");
spawnSync(taskkill, ["/pid", String(pid), "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
timeout: 5_000,
});
return;
}
try {
process.kill(pid, "SIGKILL");
} catch {
/* already gone */
}
} catch {
/* best effort: a failed cleanup must not change the verdict */
}
}
/** True when the path is a regular file (a directory named `rea.cmd` is not). */
function isFileSync(file) {
try {
return statSync(file).isFile();
} catch {
return false;
}
}
/** A command line that survives `cmd /c` on Windows: quoted, quotes doubled. */
function cmdQuote(value) {
return `"${String(value).replace(/"/g, '""')}"`;
}
/**
* The shell used to run a `.cmd`/`.bat` candidate.
*
* Absolute on purpose: `spawn(comspec())` is resolved against the CHILD's PATH,
* and an MCP row inherits a scrubbed environment. A machine with a working REA
* but a PATH that does not include System32 would then be judged "no REA",
* which is precisely the wrong answer for the feature that exists to find it.
*/
function comspec() {
const fromEnv = process.env.ComSpec ?? process.env.COMSPEC;
// A ComSpec that names nothing is a real (if unusual) state, and trusting it
// would judge every `.cmd` REA "spawn-failed" — i.e. report the machine as
// having no REA because its shell pointer is broken. Fall back to the path
// Windows itself would use.
if (typeof fromEnv === "string" && fromEnv.trim() !== "" && isFileSync(fromEnv)) return fromEnv;
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
return path.join(systemRoot, "System32", "cmd.exe");
}
/**
* Ask a candidate whether it is a working REA.
*
* A file existing on PATH is not the same as a working install: npx leftovers,
* half-removed globals and a shim whose Node is gone all sit on PATH and all
* fail at once. One bounded `--version` separates them, and a slow or hanging
* answer counts as "not usable" — the fallback (the bundle) still works, while
* delegating to a broken binary would take the tools down with it.
*
* Two things here are not incidental:
*
* * The probe runs with `REA_BUNDLE_DELEGATED=1`. A candidate that is itself a
* wrapper around this shim would otherwise be probed by a nested shim that
* probes the wrapper — measured: a process chain that keeps respawning
* itself. The flag makes the nested run take the bundle path and return.
* * It is asynchronous, not `spawnSync` with a timeout. `spawnSync` reaps the
* child it started, and its timeout kills only that child: for a `.cmd` shim
* that means cmd.exe dies while the REA inside it keeps running and holds
* the pipes. Killing the TREE is only possible while the parent is alive,
* which means owning the wait ourselves.
*/
function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_MS } = {}) {
return new Promise((resolve) => {
// `existsSync` says nothing about the shape: a DIRECTORY named `rea.cmd`
// exists, fails to spawn, and would be reported as a broken install instead
// of as a name collision. Only a regular file is a candidate.
if (!isFileSync(file)) {
resolve({ ok: false, reason: "not a file" });
return;
}
const useShell = platform === "win32" && /\.(cmd|bat)$/i.test(file);
// `REA_BUNDLE_AUTO_PROVISION=0` is part of the same guard: if the candidate
// turns out to be a wrapper around THIS shim, the nested run must not start
// an 800 MB provisioning job just because a probe asked it for a version.
const env = { ...process.env, REA_BUNDLE_DELEGATED: "1", REA_BUNDLE_AUTO_PROVISION: "0" };
// `cmd /d /s /c "<line>"`: Windows only. Two details are load-bearing.
// `/s` strips the first and last quote, so the whole line is quoted; and the
// arguments are passed VERBATIM, because Node otherwise backslash-escapes the
// quotes it sees and cmd.exe does not understand `\"` — every candidate then
// answers "exit 1", which reads as "no REA on this machine".
const child = useShell
? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(file), "--version"].join(" ")}"`], {
env,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
windowsVerbatimArguments: true,
})
: spawn(file, ["--version"], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
let output = "";
let settled = false;
let timer = null;
const finish = (result) => {
if (settled) return;
settled = true;
if (timer !== null) clearTimeout(timer);
resolve(result);
};
timer = setTimeout(() => {
killTree(child.pid, platform);
finish({ ok: false, reason: "timeout" });
}, timeout);
child.stdout.on("data", (chunk) => { output += String(chunk); });
child.stderr.on("data", (chunk) => { output += String(chunk); });
child.on("error", () => finish({ ok: false, reason: "spawn-failed" }));
child.on("close", (code) => {
if (code !== 0) {
finish({ ok: false, reason: `exit ${String(code)}` });
return;
}
// A REA install prints its version. Anything else (a shim that swallowed
// the flag, an unrelated program that shares the name) is not a usable CLI.
const version = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(output.trim())?.[1] ?? null;
finish(version === null
? { ok: false, reason: "no version in --version output" }
: { ok: true, version });
});
});
}
/**
* The system REA this machine already has, or why it was rejected.
*
* Candidates inside this bundle or this checkout are skipped: a REA that the
* provisioner installed is the `bundle` mode, and delegating to it would be a
* loop through a path the bundle already covers.
*/
async function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
const rejected = [];
let profiled = 0;
const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
// Probing costs a process start per candidate, and a row has to answer before
// its tools exist. The first existing entry is what the shell would run, so a
// couple of fallbacks is plenty; a machine whose first three `rea`s are all
// broken is a repair job, not something this decision should keep paying for.
const MAX_PROBES = 3;
for (const candidate of systemReaCandidates({ env, platform })) {
if (isInside(candidate, root) || isInside(candidate, pluginRoot)) {
// A REA the provisioner installed is `bundle` mode; delegating to it would
// be a loop back into the thing this decision is choosing between.
if (rejected.length < 8) rejected.push({ file: candidate, reason: "inside the bundle" });
continue;
}
// The candidate list is PATH x every executable name: one missing name per
// directory is normal and is not a finding. Only a regular file that EXISTS
// and then fails the probe is worth reporting.
try {
if (!statSync(candidate).isFile()) continue;
} catch {
continue;
}
if (profiled >= MAX_PROBES) {
if (rejected.length < 8) rejected.push({ file: candidate, reason: "not probed (budget)" });
continue;
}
profiled += 1;
const probe = await probeRea(candidate, { platform });
if (probe.ok) return { found: true, file: candidate, version: probe.version };
if (rejected.length < 8) rejected.push({ file: candidate, reason: probe.reason });
}
// Say what actually happened, not what a flag says: an explicit path that was
// skipped because it lives inside the bundle is a different answer from one
// that does not exist.
const explicitPath = explicit === "" ? null : path.resolve(explicit);
const explicitRejection = explicitPath === null
? null
: (rejected.find(entry => entry.file === explicitPath) ?? null);
let reason;
if (explicitPath !== null && explicitRejection !== null) {
reason = `REA_BUNDLE_SYSTEM_REA was rejected (${explicitRejection.reason}): ${explicitPath}`;
} else if (explicitPath !== null && !isFileSync(explicitPath)) {
reason = existsSync(explicitPath)
? `REA_BUNDLE_SYSTEM_REA is not a file: ${explicitPath}`
: `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
} else {
reason = profiled === 0 ? "no rea on PATH" : "no usable rea on PATH";
}
return { found: false, reason, rejected };
}
/**
* Numeric dotted comparison (6.4.0 vs 6.5.0). null when either side is not a
* plain x.y.z — a prerelease or a vendor tag is not something this decision
* should pretend to order.
*/
function compareDotted(a, b) {
const parse = (value) => {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value ?? "").trim());
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
};
const left = parse(a);
const right = parse(b);
if (left === null || right === null) return null;
for (let index = 0; index < 3; index += 1) {
if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1;
}
return 0;
}
/**
* Which REA this invocation should run.
*
* `auto` prefers the system install, but not blindly: a global REA older than
* the one this bundle provisioned would silently take the tool count back
* (6.4.0 ships 170 tools, 6.5.0 ships 207), so the newer of the two wins and
* the older is only used when asked for by name. `bundle` and `system` are
* honoured literally 鈥?`system` fails loudly rather than provisioning 800 MB
* behind an operator who asked for their own REA.
*/
async function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
const preference = launchPreference(env);
if (preference.value === "bundle") {
return { preference: preference.value, invalid: preference.invalid, mode: "bundle", reason: "pinned by REA_BUNDLE_LAUNCH=bundle" };
}
// A delegated child that ends up back in this shim (a wrapper script named
// `rea` that calls us, a PATH that resolves to a launcher) would recurse
// forever: the probe cannot break it, because the grandchild starts a fresh
// one. One flag, written into the child's environment, ends the loop — the
// nested run takes the bundle path, which either works or fails loudly.
if ((env.REA_BUNDLE_DELEGATED ?? "") === "1") {
return {
preference: preference.value,
invalid: preference.invalid,
mode: "bundle",
reason: "REA_BUNDLE_DELEGATED=1: already running under a delegated system REA, refusing to delegate again",
};
}
const system = await detectSystemRea({ env, platform, root });
if (system.found) {
const order = bundleVersion === null ? null : compareDotted(system.version, bundleVersion);
const pinned = preference.value === "system";
// No provisioned build at all: there is nothing to compare against, and the
// machine's own REA is exactly what this mode exists to use.
if (!bundleProvisioned || pinned || (order !== null && order >= 0)) {
return {
preference: preference.value,
invalid: preference.invalid,
mode: "system",
reason: pinned
? `pinned by REA_BUNDLE_LAUNCH=system (system ${system.version}, bundle ${bundleVersion ?? "none"})`
: !bundleProvisioned
? `a working REA is on PATH (${system.version}) and this machine has no provisioned bundle`
: `the REA on PATH (${system.version}) is at least as new as the provisioned one (${bundleVersion})`,
system,
};
}
return {
preference: preference.value,
invalid: preference.invalid,
mode: "bundle",
reason: order === null
? `the REA on PATH (${system.version}) cannot be compared with the provisioned one (${bundleVersion}); keeping the bundle — set REA_BUNDLE_LAUNCH=system to use the system install`
: `the REA on PATH (${system.version}) is older than the provisioned one (${bundleVersion}); set REA_BUNDLE_LAUNCH=system to use it anyway`,
system,
};
}
if (preference.value === "system") {
return {
preference: preference.value,
invalid: preference.invalid,
mode: "system",
reason: `REA_BUNDLE_LAUNCH=system, but no usable REA was found (${system.reason})`,
system,
unusable: true,
};
}
return {
preference: preference.value,
invalid: preference.invalid,
mode: "bundle",
reason: `no usable system REA (${system.reason})`,
system,
};
}
/** Launch the system REA exactly the way the 1.x row did. */
function launchSystemRea(system, argv) {
const args = argv.length > 0 ? argv : ["mcp"];
const useShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(system.file);
// `REA_BUNDLE_DELEGATED` marks the child as "already the system REA". Without
// it a wrapper on PATH that calls this shim would delegate to itself forever.
const env = { ...process.env, REA_BUNDLE_DELEGATED: "1" };
// Only the PATH is quoted. Quoting the arguments too would hand them to a
// `.cmd`'s `%*` WITH their quotes (`"mcp"` instead of `mcp`) — measured — which
// is a different command line than the 1.x row produced. The row passes `mcp`
// and nothing else, so plain tokens are the contract here; an argument that
// needs shell quoting is not something this path supports (cmd would also
// expand `%NAME%` inside it).
const child = useShell
? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(system.file), ...args].join(" ")}"`], {
env,
stdio: "inherit",
windowsHide: true,
windowsVerbatimArguments: true,
})
: spawn(system.file, args, { env, stdio: "inherit", windowsHide: true });
// No signal forwarding on Windows, deliberately. `child.kill()` there is
// TerminateProcess on the cmd.exe WRAPPER — the REA inside it survives, still
// holding the inherited pipes — and a console Ctrl+C is delivered to the whole
// process group anyway, so the child already has it. A hard kill of this shim
// (what a supervisor or Task Manager does) runs no JavaScript at all, so the
// wrapper must not pretend otherwise: on Windows the delegated REA outlives a
// forcibly killed shim. On POSIX the child is in this process's group and gets
// the terminal's signal directly; `process.exit` below then ends the shim.
child.on("error", (error) => {
process.stderr.write(`rea-shim: could not start ${system.file}\n ${String(error && error.message)}\n`);
process.exit(EX_CONFIG);
});
child.on("exit", (code, signal) => {
if (signal) {
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
process.exit(128 + (signals[signal] ?? 0));
}
process.exit(code ?? 0);
});
return null;
}
/**
* The mirror problem.
*
* The published row launches `<bundle>/bin/rea-shim.mjs` — a COPY made by
* `setup.mjs`. Upgrading the plugin from the market replaces the package but runs
* no setup, so the copy keeps executing the code it was made from: every fix in
* a new plugin version reaches a user only if they happen to re-run setup. That
* is invisible (the old copy keeps working) and it is exactly the wrong
* direction for a safety fix.
*
* The repair has to live in the copy, because the copy is what runs. So the
* copy compares itself with the plugin's own `bin/rea-shim.mjs`, recorded in
* `mirror.json` by setup, and re-execs that file when the two differ. One hop,
* guarded by an environment flag, and only from mirror to checkout — the
* checkout copy has no `mirror.json` and would find itself anyway.
*
* A plugin version older than 2.0.8 cannot do this for its own upgrades: there
* is nothing in it to run. `--shim-status` reports `mirror.stale` so the
* condition is at least visible, and `check-updates.ps1` says to re-run setup.
*/
function sha256(file) {
return createHash("sha256").update(readFileSync(file)).digest("hex");
}
/** Where the installed plugin lives according to setup's record, or null. */
function pluginCheckout(root) {
try {
const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
const dir = record?.pluginRoot;
if (typeof dir !== "string" || dir.trim() === "") return null;
const shim = path.join(dir, "bin", "rea-shim.mjs");
return isFileSync(shim) ? shim : null;
} catch {
return null;
}
}
/**
* Whether this copy is behind the plugin it was mirrored from.
*
* `self` is this file, `source` the checkout's. A checkout copy (it ships
* `cordis.patch.yml`) is never stale against itself; a mirror with no record, or
* whose recorded checkout is gone, is not re-pointed anywhere.
*/
function describeMirror(root, { self = fileURLToPath(import.meta.url) } = {}) {
let record = null;
try {
record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
} catch {
return null;
}
const source = pluginCheckout(root);
let stale = false;
let reason = "no usable plugin checkout in mirror.json";
if (source !== null) {
if (path.resolve(source) === path.resolve(self)) {
reason = "this copy is the plugin checkout";
} else {
try {
stale = sha256(source) !== sha256(self);
reason = stale ? "the plugin ships a different launcher than this mirrored copy" : "this copy matches the plugin";
} catch {
reason = "could not compare the two launchers";
}
}
}
return {
pluginRoot: typeof record?.pluginRoot === "string" ? record.pluginRoot : null,
mirroredAt: typeof record?.mirroredAt === "string" ? record.mirroredAt : null,
stale,
reason,
};
}
/** Run the checkout's launcher instead of this copy, once. */
function reexecFromCheckout(source, argv) {
const child = spawn(process.execPath, [source, ...argv], {
stdio: "inherit",
env: { ...process.env, REA_BUNDLE_SHIM_REEXEC: "1" },
windowsHide: true,
});
child.on("error", () => {
// Falling back is better than failing: the copy still knows how to launch.
process.stderr.write(`rea-shim: could not run ${source}; continuing with the mirrored copy.\n`);
});
child.on("exit", (code, signal) => {
if (signal) {
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
process.exit(128 + (signals[signal] ?? 0));
}
process.exit(code ?? 0);
});
return null;
}
/**
* Usage text, also what a bare invocation prints.
*
* `package.json` exposes this file as the `rea-bundle-status` bin, and npm's bin
* shim can only forward the arguments a user typed — it cannot append a flag. So a
* shim can only forward the arguments a user typed 鈥?it cannot append a flag. So a
* bare `rea-bundle-status`, which is exactly what that name invites, used to fall
* through to the launch path: it started REA, and on an unprovisioned machine it
* began an ~800 MB download. Reporting usage instead makes the command's behaviour
@@ -257,19 +943,23 @@ function childEnvironment(config) {
*/
function usage() {
return [
"rea-shim: launches the vendored REA MCP server for a DSH MCP row.",
"rea-shim: launches a REA MCP server for a DSH MCP row.",
"",
" node bin/rea-shim.mjs [rea arguments...] (normal use: 'mcp')",
" node bin/rea-shim.mjs --shim-status provisioning status, no launch",
" node bin/rea-shim.mjs --shim-status launch decision and provisioning status, no launch",
" node bin/rea-shim.mjs --shim-print-env the environment it would export",
"",
` bundle root: ${bundleRoot()}`,
` plugin root: ${PLUGIN_ROOT}`,
"",
" REA_BUNDLE_LAUNCH=auto|system|bundle (default auto: use the REA already",
" on PATH when it answers --version, otherwise the provisioned bundle)",
" REA_BUNDLE_SYSTEM_REA=<path> probe this binary instead of PATH",
"",
].join("\n");
}
function main() {
async function main() {
const argv = process.argv.slice(2);
if (argv.includes("--shim-help") || argv.length === 0) {
@@ -278,7 +968,38 @@ function main() {
}
const root = bundleRoot();
// Self-healing before anything else: a mirrored copy that predates the plugin
// it was made from would otherwise keep running that older code forever, and a
// market upgrade never re-runs setup. One hop, guarded, and never for the
// status query — there the staleness is reported instead.
const mirror = describeMirror(root);
if (mirror !== null && mirror.stale && process.env.REA_BUNDLE_SHIM_REEXEC !== "1" && !argv.includes("--shim-status")) {
const source = pluginCheckout(root);
if (source !== null) return reexecFromCheckout(source, argv);
}
const loaded = readBundleConfig(root);
// Decide what to run before asking whether the bundle is provisioned: a
// machine with its own working REA must not be sent into an ~800 MB
// provisioning run just because this bundle has no state yet.
const bundleVersion = loaded.ok ? (loaded.config.versions?.reaAgents ?? null) : null;
const launch = await resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
if (launch.invalid !== null) {
process.stderr.write(
`rea-shim: REA_BUNDLE_LAUNCH="${launch.invalid}" is not one of ${LAUNCH_MODES.join("/")}; using auto.\n`,
);
}
// Upstream can add a second row for the same serverName; the shim cannot stop
// DSH from loading both rows, but saying so is better than doubled tools with
// no stated cause. The status report carries the same fact for diagnostics.
const duplicate = foreignReaRegistration(root);
if (duplicate && !argv.includes("--shim-status")) {
process.stderr.write(
`\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
` ${duplicate.patch} also registers serverName "rea" (id "${duplicate.id}").\n` +
` This bundle's row is id "mcp-rea" with the same serverName; DSH rejects the later\n` +
` entry as a duplicate server name, so only one survives. Keep exactly one of them.\n`,
);
}
if (argv.includes("--shim-status")) {
const report = {
@@ -287,14 +1008,56 @@ function main() {
provisioned: loaded.ok,
reason: loaded.ok ? undefined : loaded.code,
autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
launch: {
preference: launch.preference,
mode: launch.mode,
reason: launch.reason,
// Reported, not implied: `mode: "system"` with `unusable: true` is a
// request that cannot be met, and a caller reading only the mode would
// draw the opposite conclusion.
unusable: launch.unusable === true,
systemRea: launch.system?.found ? { file: launch.system.file, version: launch.system.version } : null,
},
};
if (launch.system?.rejected?.length) report.launch.rejected = launch.system.rejected;
// A mirrored copy that lags the plugin keeps working, which is exactly why
// nobody notices it: the row runs old code until the mirror is refreshed.
// Reported here so `--shim-status` answers "am I running what I installed?".
if (mirror !== null) report.mirror = mirror;
if (duplicate) report.duplicateRegistration = duplicate;
if (loaded.ok) {
report.paths = loaded.config.paths ?? {};
report.versions = loaded.config.versions ?? {};
report.rea = resolveRea(loaded.config, root);
}
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
return loaded.ok ? 0 : EX_CONFIG;
// The row works either way round — a system launch needs no bundle, a bundle
// launch needs a provisioned one — but a PINNED system launch with nothing to
// launch is a failure whether or not the bundle happens to be provisioned,
// and reporting 0 there is how a broken row looks healthy until it starts.
const usable = launch.mode === "system" ? launch.unusable !== true : loaded.ok;
return usable ? 0 : EX_CONFIG;
}
if (launch.mode === "system") {
if (launch.unusable) {
process.stderr.write(
`\n dsh-plugin-rea (bundle): REA_BUNDLE_LAUNCH=system was requested, but no usable REA was found.\n` +
` ${launch.reason}\n\n` +
` Install REA (\`npm install -g rea-agents\`), point REA_BUNDLE_SYSTEM_REA at it,\n` +
` or unset REA_BUNDLE_LAUNCH to use the provisioned bundle instead.\n`,
);
return EX_CONFIG;
}
if (argv.includes("--shim-print-env")) {
process.stdout.write(
JSON.stringify({ launch: "system", rea: launch.system.file, version: launch.system.version }, null, 2) + "\n",
);
return 0;
}
// The 1.x contract, from the 2.x row: the machine's own REA, its own Node,
// its own PATH. Nothing is provisioned and no bundle state is required.
return launchSystemRea(launch.system, argv);
}
if (!loaded.ok) {
@@ -302,10 +1065,31 @@ function main() {
loaded.code === "missing"
? `No bundle state at ${loaded.file}.`
: `Bundle state at ${loaded.file} could not be parsed.`;
const logPath = loaded.code === "missing" ? maybeAutoProvision(root) : null;
if (logPath) {
// Both "missing" and "malformed" start a run. A malformed file used to fail
// permanently: setup overwrites a bundle.json it cannot parse, but nothing
// ever started setup, so every restart reported the same EX_CONFIG until the
// operator deleted the file by hand. The lock stops a provisioning loop when
// writing keeps failing.
const provisioning = maybeAutoProvision(root);
if (provisioning.started) {
process.stderr.write(
`\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${logPath}\n`,
`\n dsh-plugin-rea (bundle): first run 鈥?provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
);
} else if (provisioning.reason === "last-run-failed") {
process.stderr.write(
`\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
` Nothing is running right now 鈥?read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
);
} else if (provisioning.reason === "in-flight") {
// A live run owns the lock. Saying nothing here is what made a wedged
// provisioning attempt look like a machine that simply has no bundle.
process.stderr.write(
`\n dsh-plugin-rea (bundle): provisioning is already running in the background.\n progress: ${provisioning.logPath}\n`,
);
} else if (provisioning.reason === "spawn-failed" || provisioning.reason === "unwritable") {
process.stderr.write(
`\n dsh-plugin-rea (bundle): provisioning could not be started (${provisioning.reason}).\n` +
` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}" --root "${root}"\n`,
);
}
return fail(message, loaded.detail);
@@ -352,5 +1136,8 @@ function main() {
return null;
}
const status = main();
// The launch decision awaits a probe, so `main` is async; the status it returns
// is this process's exit code, and a launch path resolves to null while the
// child (already wired to `process.exit`) keeps the loop alive.
const status = await main();
if (typeof status === "number") process.exit(status);
+123 -20
View File
@@ -69,18 +69,53 @@ function Test-NodeEngines([string]$node, [string]$engines) {
}
# ------------------------------------------------------- 定位 REA 与 profile
if (-not $ReaPackageDir) {
$npmRoot = $null
if (Get-Command npm -ErrorAction SilentlyContinue) {
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
}
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
# 2.x 的 REA 在 <bundleRoot>\vendor\node_modules\rea-agents,不在 npm 全局。先读
# bundle.json:把全局路径当默认是 1.x 的答案,在 2.x 机器上会让这一节指向错误的
# 对象("全局包丢了"),并给出错误的升级动作(npm i -g)。
$bundleRootEarly = $env:REA_BUNDLE_ROOT
# Resolve the home unconditionally: the profile section below needs it whether or
# not REA_BUNDLE_ROOT was set, and computing it only inside the fallback branch
# left it $null — `Join-Path $null 'profiles'` throws, so the section degraded to
# "no profile" on exactly the machines that use the override.
$homeEarly = $env:DSH_HOME
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
if (-not $bundleRootEarly) {
$bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
}
$bundleState = $null
$bundleJsonEarly = Join-Path $bundleRootEarly 'bundle.json'
if (Test-Path $bundleJsonEarly) {
try { $bundleState = Get-Content $bundleJsonEarly -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $bundleState = $null }
}
$bundleReaPackageDir = $null
$bundleReaEntry = $null
if ($bundleState -and $bundleState.paths.reaEntry) {
$bundleReaEntry = [string]$bundleState.paths.reaEntry
# <vendor>\node_modules\rea-agents\scripts\rea.mjs -> <vendor>\node_modules\rea-agents
$bundleReaPackageDir = Split-Path -Parent (Split-Path -Parent $bundleReaEntry)
}
if (-not $ReaPackageDir) {
if ($bundleReaPackageDir -and (Test-Path (Join-Path $bundleReaPackageDir 'package.json'))) {
$ReaPackageDir = $bundleReaPackageDir
} else {
$npmRoot = $null
if (Get-Command npm -ErrorAction SilentlyContinue) {
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
}
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
}
}
$reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaPackageDir)
if (-not $ProfileDir) {
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
else {
$profiles = @(Get-ChildItem (Join-Path $env:USERPROFILE '.dsh\profiles') -Directory -ErrorAction SilentlyContinue)
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $env:USERPROFILE '.dsh\profiles\desktop' }
# Same home rule the bundle uses everywhere else: DSH_HOME, then ~/.dsh.
# Hardcoding $env:USERPROFILE here was a second algorithm in the same script,
# so on a custom DSH_HOME the profile section reported "no profile found"
# while the rest of the run had already resolved that home correctly.
$profiles = @(Get-ChildItem (Join-Path $homeEarly 'profiles') -Directory -ErrorAction SilentlyContinue)
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $homeEarly 'profiles\desktop' }
}
}
@@ -94,11 +129,21 @@ if (Test-Path $pkgFile) {
$installed = $pkg.version; $engines = $pkg.engines.node
$binPath = ($pkg.bin.PSObject.Properties | Select-Object -First 1).Value
if ($binPath) { $reaEntry = Join-Path $ReaPackageDir ($binPath -replace '/', '\') }
Ok "本地已安装: $installed"
Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
if ($reaFromBundle) {
Ok "一体包内置(vendored): $installed"
Info "位置: $ReaPackageDir"
Info "入口(bin): $binPath"
} else {
Ok "本地已安装: $installed"
Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
}
if ($engines) { Info "Node 要求: $engines" }
} catch { Bad "package.json 解析失败: $($_.Exception.Message)" }
} else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
} else {
if ($reaFromBundle) { Bad "找不到 $pkgFile —— 内置副本不完整?重跑 setup.mjs" }
elseif ($bundleReaPackageDir) { Bad "找不到 $pkgFile —— 一体包内置副本也缺($bundleReaPackageDir);记录里没有 reaEntry" }
else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
}
$latest = $null
foreach ($r in @($Registry, 'https://registry.npmjs.org')) {
@@ -110,7 +155,11 @@ if ($installed -and $latest) {
$vi = Get-NumericVersion $installed; $vl = Get-NumericVersion $latest
if ($vi -and $vl -and $vl -gt $vi) {
Warn "有新版本:$installed → $latest"
$actions.Add("升级上游 REA: npm i -g rea-agents@$latest (只更新 REA;本插件不会自动更新——从市场装的在市场里更新,file: 装的覆盖目录后重装)")
if ($reaFromBundle) {
$actions.Add("升级一体包内置 REA:把 manifest 的 pins.reaAgents / components.reaAgents.sources.npm.version 改成 $latest,然后重跑 node scripts\setup.mjs(内置副本不会自己更新)")
} else {
$actions.Add("升级上游 REA: npm i -g rea-agents@$latest (只更新 REA;本插件不会自动更新——从市场装的在市场里更新,file: 装的覆盖目录后重装)")
}
} elseif ($vi -and $vl -and $vl -eq $vi -and $installed -ne $latest) {
Warn "同版本号但通道不同:$installed → $latest(预发布/正式)"
} else { Ok '已是最新版本' }
@@ -139,7 +188,14 @@ if (Get-Command node -ErrorAction SilentlyContinue) {
# ------------------------------------------------------------ Ghidra 引擎
Head 'Ghidra 引擎'
$gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User')
# bundle.json 是 2.x 的真源:shim 只在 MCP 子进程里设 GHIDRA_INSTALL_DIR,用户级
# 变量是 1.x 的写法,没手工设过的机器会被误报"未设置"。
$gd = $null
if ($bundleState -and $bundleState.paths.ghidra) {
$bundleGhidra = [string]$bundleState.paths.ghidra
if (Test-Path $bundleGhidra) { $gd = $bundleGhidra }
}
if (-not $gd) { $gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User') }
if (-not $gd) { $gd = $env:GHIDRA_INSTALL_DIR }
if ($gd -and (Test-Path $gd)) {
Ok "GHIDRA_INSTALL_DIR = $gd"
@@ -162,11 +218,28 @@ try {
# --------------------------------------------------------- REA 引擎自检
Head 'REA 引擎自检(rea doctor)'
$env:GHIDRA_INSTALL_DIR = $gd
# 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
# 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
$reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
# 2.x:优先用 bundle 内置的 CLI 和它自己的 Node。PATH 上的 rea 可能是另一个,
# 甚至完全不在 PATH —— 那会让整段自检被静默跳过。这正是 1.x 口径的残留。
$reaCmd = $null
$reaArgs = @()
if ($bundleReaEntry -and (Test-Path $bundleReaEntry)) {
$bundleNode = $null
if ($bundleState -and $bundleState.paths.node) {
$candidateNode = [string]$bundleState.paths.node
if (Test-Path $candidateNode) { $bundleNode = $candidateNode }
}
if (-not $bundleNode) { $bundleNode = (Get-Command node -ErrorAction SilentlyContinue).Source }
if ($bundleNode) {
$reaCmd = $bundleNode; $reaArgs = @($bundleReaEntry)
Info '使用一体包内置 REA(node <bundle 入口>),不依赖 PATH 上的 rea'
}
}
if (-not $reaCmd) {
# 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
# 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
$reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
}
if (-not $reaCmd) {
if ($reaEntry -and (Test-Path $reaEntry) -and (Get-Command node -ErrorAction SilentlyContinue)) {
$reaCmd = 'node'; $reaArgs = @($reaEntry)
@@ -247,6 +320,8 @@ if (-not (Test-Path $manifestPath)) {
$state = Get-Content $bundleJson -Raw -Encoding UTF8 | ConvertFrom-Json
Ok "已供给:$($state.provisionedAt)"
$drift = 0
$compared = 0
$unrecorded = 0
foreach ($name in $manifest.pins.PSObject.Properties.Name) {
if ($name -eq 'nodeEngines') { continue }
$pinned = [string]$manifest.pins.$name
@@ -254,18 +329,46 @@ if (-not (Test-Path $manifestPath)) {
if ($state.versions -and ($state.versions.PSObject.Properties.Name -contains $name)) {
$installed = [string]$state.versions.$name
}
if (-not $installed) { continue }
if (-not $installed) {
# 被 --reuse 采纳的组件没有 version 字段(bundle.json 里是 null)。静默
# continue 会让这一项从未被比对,却在下面打印"全部一致"——那是假绿。
$unrecorded++
Warn " $name 未记录版本(reuse 采纳的组件?)—— 无法与 pin $pinned 对账"
continue
}
$compared++
# Node 的 pin 是引擎范围,不是版本号;其它组件比前缀即可
$same = $installed.Equals($pinned) -or $installed.StartsWith(($pinned -split '\.')[0] + '.')
if ($same) { Info " $name = $installed(pin $pinned)" }
else { Warn "$name 盘上为 $installed,manifest 已钉 $pinned —— 重跑 setup 对齐"; $drift++ }
}
if ($drift -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
if ($drift -eq 0 -and $unrecorded -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
elseif ($drift -eq 0) { Warn "已比对的 $compared 项与 pin 一致,但 $unrecorded 项没有记录版本(见上)—— 不是全绿" }
if ($state.unverifiedChecksums -and @($state.unverifiedChecksums).Count -gt 0) {
Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
}
$mirrored = Join-Path $bundleRoot 'bin\rea-shim.mjs'
if (Test-Path $mirrored) { Ok 'MCP 行指向的 shim 已就位' }
if (Test-Path $mirrored) {
Ok 'MCP 行指向的 shim 已就位'
# The row launches this COPY, so a plugin upgrade that does not re-run
# setup leaves the old launcher in place — visible only as "the new
# version behaves like the old one". Compare it with the checkout the
# mirror record names.
$mirrorRecord = Join-Path $bundleRoot 'mirror.json'
if (Test-Path $mirrorRecord) {
try {
$record = Get-Content $mirrorRecord -Raw -Encoding UTF8 | ConvertFrom-Json
$pluginShim = if ($record.pluginRoot) { Join-Path $record.pluginRoot 'bin\rea-shim.mjs' } else { $null }
if ($pluginShim -and (Test-Path $pluginShim)) {
$same = (Get-FileHash $mirrored -Algorithm SHA256).Hash -eq (Get-FileHash $pluginShim -Algorithm SHA256).Hash
if ($same) { Ok '镜像 shim 与插件一致(行跑的就是这个版本)' }
else { Warn "镜像 shim 落后于插件($($record.pluginRoot))—— 行仍在跑旧副本;重跑 node scripts/setup.mjs 让它换成新版本" }
} else {
Warn "mirror.json 指向的插件目录已不存在($($record.pluginRoot))—— 无法比对镜像 shim 是否最新"
}
} catch { Warn "mirror.json 解析失败: $($_.Exception.Message)" }
}
}
else { Bad "缺少 MCP 行指向的 shim: $mirrored —— 重跑 setup --reuse" }
# 镜像副本的 setup 用它自己所在的位置推 plugin root,所以 manifest 必须
# 一起镜像。旧版镜像缺这个文件:auto-provision 会以 ENOENT 崩掉且不可见,
+6 -5
View File
@@ -31,11 +31,11 @@ Two upstream rules shape the runtime wiring:
## 2. Verified facts this design rests on
Measured on Windows 11 x64, REA 6.4.0, Ghidra 12.1.4, Temurin JDK 25.0.4:
Measured on Windows 11 x64, REA 6.5.0, Ghidra 12.1.4, Temurin JDK 25.0.4:
| Fact | Evidence |
| --- | --- |
| A row can launch REA with **no PATH `rea`** | `node <pkg>/scripts/rea.mjs mcp` answered the MCP `initialize` handshake (`serverInfo.name = rea`, version 6.4.0) and `rea doctor --json` reported 170 tools, `healthy: true` |
| A row can launch REA with **no PATH `rea`** | `node <pkg>/scripts/rea.mjs mcp` answered the MCP `initialize` handshake (`serverInfo.name = rea`, version 6.5.0) and `rea doctor --json` reported 207 tools. `healthy` is `false` on a bundle-only install: 6.5.0 also checks the host's own setup (Hopper, IDA registration, `rea setup` skill/registrations), which provisioning deliberately never touches — the row does not require `healthy`, only a CLI that runs and a catalog it can read |
| `!!js` expressions are supported in this profile layer | the existing profile patch already used one; the loader evaluates them |
| A row-level `env:` block reaches the MCP child | `binary_session` reported `registrationEnvironment` = `GHIDRA_INSTALL_DIR`, `JAVA_HOME` |
| No fixed home/layout is promised | the disk layout differs per install; nothing may assume one |
@@ -54,8 +54,9 @@ Three responsibilities, kept strictly apart:
checksums, unpacks, runs the readiness probe, and writes `bundle.json`.
Idempotent and re-runnable; `--reuse` adopts pre-existing installs.
3. **Patch (wiring, published).** `cordis.patch.yml` on `main`, carrying
**no machine path**: the row points at `<plugin>/bin/rea-shim.mjs` and the
shim is responsible for the rest.
**no machine path**: the row computes `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`
at load time from `DSH_HOME` alone, and that mirrored shim is responsible for the
rest. (The checkout's own copy is what a human runs; the row never points at it.)
The published plugin therefore stays portable, while the machine-specific truth
lives in one small JSON file under the DSH home.
@@ -161,7 +162,7 @@ Windows and is what this bundle targets to complete.
| Component | Needed for | Provisioned as |
| --- | --- | --- |
| Node runtime | running REA at all | reuse a compatible existing node, else portable `node.exe` |
| `rea-agents` | the MCP server itself | vendored copy under `vendor/rea-agents` |
| `rea-agents` | the MCP server itself | vendored copy under `vendor/node_modules/rea-agents` |
| Temurin JDK 21/25 | Ghidra headless + JADX bridge | portable JDK zip (full JDK, not a JRE) |
| Ghidra 12.1.x | native analysis | official release zip, extracted |
| apktool | Android resource decode | `apktool.bat` + jar in one directory |
-18
View File
@@ -1,18 +0,0 @@
# macOS / Linux variant of the row.
#
# `npm i -g rea-agents` installs a `rea` executable on PATH, so no shell wrapper
# is needed here (on Windows the shim is a .cmd/.ps1 file, which is why the
# default cordis.patch.yml goes through cmd.exe).
#
# Copy the block below into cordis.patch.yml, replacing the Windows row.
- insert:
- id: mcp-rea
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: rea
transport: stdio
command: 'rea'
args:
- 'mcp'
failOnStartupError: false
-42
View File
@@ -1,42 +0,0 @@
# Windows variant that also pins the analysis-engine environment for the MCP child.
#
# Use this when GHIDRA_INSTALL_DIR is NOT set as an OS-level environment variable
# the harness can inherit — for example after setting it up just now, without
# restarting the harness.
#
# The value below is a placeholder in angle brackets, not a real directory:
# replace it with your own, or use the `!!js` form to read the variable the
# harness already has. REA reads only GHIDRA_INSTALL_DIR; `java` comes from PATH
# or JAVA_HOME.
#
# Copy the block into cordis.patch.yml, or — better — keep it out of the plugin
# entirely and override the row from your profile's own cordis.patch.yml:
#
# - id: mcp-rea
# config:
# env:
# GHIDRA_INSTALL_DIR: '<your Ghidra directory>'
#
# A profile-level override is machine-local and is never published.
- insert:
- id: mcp-rea
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: rea
transport: stdio
command: 'cmd.exe'
args:
- '/c'
- 'rea'
- 'mcp'
env:
# Replace with your own Ghidra 12.1.x extraction directory.
# Leaving the placeholder in place is harmless: REA then reports the
# native engine as unavailable, exactly as when the variable is unset.
GHIDRA_INSTALL_DIR: '<your Ghidra 12.1.x extraction directory>'
# Or read it from the environment the harness already has — the
# entry-list dialect evaluates `!!js` expressions at activation:
# GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR
# JAVA_HOME: '<your JDK 21 or newer home>' # only when java is not on PATH
failOnStartupError: false
+43
View File
@@ -0,0 +1,43 @@
/**
* Package entry — the smallest thing that makes this bundle a *loadable* package.
*
* Everything this plugin DOES lives in `cordis.patch.yml`: it inserts one row of
* `@deepseek-ai/dsh-mcp-client` that launches `bin/rea-shim.mjs`, and that row is
* what the harness loads. This file registers nothing, starts nothing and
* provisions nothing.
*
* It exists because a package that declares no `main`/`exports` is judged by its
* consumers as "has no entry file", whether or not the loader ever needs one:
*
* - The market's update path (`hasLoadableEntry` in dshmarket) asks that
* question on every update and, when the answer is false, treats the new
* build as broken and rolls back. A bundle with no entry of its own is
* *supposed* to survive the test through the packages it mounts — but the
* package this one mounts (`@deepseek-ai/dsh-mcp-client`) is part of the
* harness build, not of `node_modules`, so every location that test probes
* comes back empty. Measured on 2026-10-18: updating an installed, working
* 2.0.1 to 2.0.4 failed three times with "updated build has no loadable
* entry", and the rollback was then reported as unverifiable for the same
* reason.
* - `npm pack` metadata and `import 'dsh-plugin-rea'` both resolve `main`;
* absent, both fall back to `index.js`, which this repository never shipped.
*
* So this is a real module with a real Cordis plugin shape rather than a stub
* for a checker: if the harness ever does load this package as a plugin, it
* loads cleanly and does nothing.
*/
/** Cordis plugin name. Also the package name, which is what the loader expects. */
export const name = 'dsh-plugin-rea'
/**
* Intentionally empty.
*
* The row that does the work is inserted by the bundle patch, not by this
* function: registering the MCP client from here would create a second
* `serverName: rea` entry, which the harness refuses as a duplicate (the same
* collision `bin/rea-shim.mjs` warns about for upstream's `rea setup --client
* dsh`). Doing nothing is the correct behaviour, and the function present keeps
* this a valid Cordis plugin instead of an arbitrary module.
*/
export function apply() {}
+48 -41
View File
@@ -12,12 +12,14 @@
" binaries - workspace-relative entries inside the unpacked directory that are",
" directly spawnable; the first that exists becomes the component's",
" recorded path so another component can depend on it.",
" executable - a placed file that must carry the POSIX exec bit (a wrapper REA",
" spawns without a shell); setup chmods exactly these to 0755.",
"Nothing here is redistributed inside the npm package; setup downloads it."
],
"manifestVersion": 1,
"generatedFor": "dsh-plugin-rea one-plugin bundle",
"pins": {
"reaAgents": "6.4.0",
"reaAgents": "6.5.0",
"ghidra": "12.1.4",
"jdk": "21",
"apktool": "3.0.3",
@@ -203,16 +205,16 @@
"win32-x64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/windows/apktool.bat",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/windows/apktool.bat",
"format": "file",
"size": 2348,
"sha256": "9440ec9d98efdef14136f4cefd79ac200d72baa016b5991d128234a3a3ef3b43",
"sha256Source": "measured on 2026-10-13 (raw.githubusercontent.com publishes no checksum for the wrapper); a single 2,348-byte file, so the archive-free case needs no TOFU",
"sha256VerifiedOn": "2026-10-13",
"size": 2386,
"sha256": "d81a91bd694459a61a88e4a1a8184efc21130390ff7cb5c172f631551ad9842a",
"sha256Source": "measured on 2026-10-16 from the immutable v3.0.3 tag (raw.githubusercontent.com publishes no checksum for the wrapper). The tag, not master: pinning master meant any upstream commit to the wrapper broke every new install with a hash mismatch, and four retries could not fix a download whose content had legitimately changed.",
"sha256VerifiedOn": "2026-10-16",
"notes": [
"The wrapper pushd's to its own directory and resolves apktool.jar first, then globs apktool_*.jar, so the renamed jar next to it is found.",
"Its final line is `for %%i in (%cmdcmdline%) do if /i \"%%~i\"==\"/c\" pause & exit /b`: measured here it returns exit 0 in 0.2 s, but it appends 'Press any key to continue . . .' to stdout. That trailing text is upstream's, not a provisioning defect; tools that parse apktool's stdout should tolerate it.",
"The previous entry measured master's copy (2,348 bytes) and recorded that it returns exit 0 in 0.2 s while appending 'Press any key to continue . . .' to stdout. This is the tag's copy (2,386 bytes) and that runtime behaviour has NOT been re-measured; treat the trailing-text claim as unverified for this file.",
"REA is pointed at this .bat (REA_APKTOOL_COMMAND), not at the jar, exactly as the wrapper expects."
],
"moveTo": "apktool",
@@ -222,13 +224,14 @@
"darwin-x64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/osx/apktool",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/osx/apktool",
"format": "file",
"size": 2672,
"sha256": "d0c86f0475ed73bfffa6ac39faa69de7aa2dd40ff5e7e6540f82002be3a38f0b",
"sha256Source": "measured on 2026-10-13 from the osx wrapper (the correct one for macOS; the linux script is a different file)",
"sha256VerifiedOn": "2026-10-13",
"size": 2709,
"sha256": "3b9a95c41680c4a758b0b8d6e91e122b2ea3f1a189685a915613ca1ccba00fd9",
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag (the correct file for macOS; the linux script is a different file). The tag is immutable, so this pin cannot drift under a new install the way master's did.",
"sha256VerifiedOn": "2026-10-16",
"executable": true,
"moveTo": "apktool",
"saveAs": "apktool"
}
@@ -236,13 +239,14 @@
"darwin-arm64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/osx/apktool",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/osx/apktool",
"format": "file",
"size": 2672,
"sha256": "d0c86f0475ed73bfffa6ac39faa69de7aa2dd40ff5e7e6540f82002be3a38f0b",
"sha256Source": "measured on 2026-10-13 from the osx wrapper",
"sha256VerifiedOn": "2026-10-13",
"size": 2709,
"sha256": "3b9a95c41680c4a758b0b8d6e91e122b2ea3f1a189685a915613ca1ccba00fd9",
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag; the same osx wrapper serves arm64.",
"sha256VerifiedOn": "2026-10-16",
"executable": true,
"moveTo": "apktool",
"saveAs": "apktool"
}
@@ -250,13 +254,14 @@
"linux-x64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/linux/apktool",
"format": "file",
"size": 2648,
"sha256": "a851371023c43ca9b9a30218f87f9fd536304283de7b85447183ae811dfd033c",
"sha256Source": "measured on 2026-10-13 from the linux wrapper",
"sha256VerifiedOn": "2026-10-13",
"size": 2685,
"sha256": "493a9c98df968a25c373ca9df871ca77730c82954be4959b74e90adaf91a0e77",
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag.",
"sha256VerifiedOn": "2026-10-16",
"executable": true,
"moveTo": "apktool",
"saveAs": "apktool"
}
@@ -264,13 +269,14 @@
"linux-arm64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/linux/apktool",
"format": "file",
"size": 2648,
"sha256": "a851371023c43ca9b9a30218f87f9fd536304283de7b85447183ae811dfd033c",
"sha256Source": "measured on 2026-10-13 from the linux wrapper",
"sha256VerifiedOn": "2026-10-13",
"size": 2685,
"sha256": "493a9c98df968a25c373ca9df871ca77730c82954be4959b74e90adaf91a0e77",
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag; the same linux wrapper serves arm64. It is only correct where the platform-tools archive is too — see the arch caveat on androidPlatformTools.",
"sha256VerifiedOn": "2026-10-16",
"executable": true,
"moveTo": "apktool",
"saveAs": "apktool"
}
@@ -337,9 +343,9 @@
"format": "zip",
"size": 16110554,
"sha256": "ee39ad5967e95c2a07f04dbcbde96b1a0c916ba376096db5d2f498b7727a5d1d",
"sha256Source": "Same darwin archive as x64 (measured 2026-10-13); it serves Intel and Apple silicon.",
"sha256Source": "Same darwin archive as x64 (measured 2026-10-13); Google publishes one darwin zip for both architectures.",
"sha256VerifiedOn": "2026-10-13",
"note": "The same darwin archive serves Intel and Apple silicon."
"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",
@@ -349,7 +355,7 @@
"sha256": "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1",
"sha256Source": "Google publishes no checksum for this rolling asset; measured on 2026-10-13 by downloading the archive and hashing it locally (9,054,187 bytes)",
"sha256VerifiedOn": "2026-10-13",
"note": "The same linux archive serves x86-64 and arm64."
"note": "The linux archive contains x86_64 binaries. Google publishes no arm64 platform-tools and this bundle does not emulate one, so on linux-arm64 the entry below is offered but its probe is expected to fail — reported as an optional component being unavailable, never as a broken install."
},
"linux-arm64": {
"version": "latest",
@@ -357,8 +363,9 @@
"format": "zip",
"size": 9054187,
"sha256": "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1",
"sha256Source": "Same linux archive as x64 (measured 2026-10-13).",
"sha256VerifiedOn": "2026-10-13"
"sha256Source": "Google publishes no arm64 platform-tools; this is the same x86_64 linux archive as x64 (measured 2026-10-13), recorded so the mismatch is explicit instead of implied.",
"sha256VerifiedOn": "2026-10-13",
"note": "Kept so an arm64 host reports a precise failure instead of \"no source\". adb here is x86_64: it runs under qemu/binfmt where the host provides it, and otherwise the probe fails and the report names it."
}
}
},
@@ -370,14 +377,14 @@
"unpack": { "moveTo": "rea-agents" },
"probe": {
"argv": ["{node}", "{vendor}/node_modules/rea-agents/scripts/rea.mjs", "--version"],
"expect": { "regex": "^6\\.4\\.0" }
"expect": { "regex": "^6\\.5\\.0(\\s|$)" }
},
"sources": {
"npm": {
"version": "6.4.0",
"version": "6.5.0",
"registry": "https://registry.npmjs.org/rea-agents",
"integrity": "npm's own dist.integrity is the authoritative check for this tarball: the registry tarball ships NO node_modules (verified: 1161 entries, 0 under node_modules), so setup resolves the dependency closure with `npm install --ignore-scripts` instead of unpacking a tarball.",
"note": "`just unpacking the published tarball cannot work` is a verified fact, not a caution: the CLI would fail with ERR_MODULE_NOT_FOUND on zod."
"integrity": "npm's own dist.integrity is the authoritative check for this tarball: the registry tarball ships NO node_modules (verified: 1650 entries in 6.5.0, 0 under node_modules), so setup resolves the dependency closure with `npm install --ignore-scripts` instead of unpacking a tarball.",
"note": "`just unpacking the published tarball cannot work` is a verified fact, not a caution: the CLI would fail with ERR_MODULE_NOT_FOUND on zod. 6.5.0 is the same engines/bin contract as 6.4.0 (^22.19.0 || ^24.11.0 || >=26.0.0, scripts/rea.mjs) and adds 37 MCP tools (170 -> 207 by rea doctor --json on 2026-10-16; the upstream contract files count 206) plus an optional frida dependency; --ignore-scripts means its install script never runs."
}
}
}
+8 -5
View File
@@ -1,8 +1,9 @@
{
"name": "dsh-plugin-rea",
"version": "2.0.1",
"version": "2.0.8",
"description": "One-plugin bundle: provisions Node/REA/JDK/Ghidra for you, then registers the upstream REA MCP server as mcp__rea__* tools. · 一体包:自动准备 Node/REA/JDK/Ghidra,并把上游 REA 的 MCP 服务接入 DeepSeek Harness。",
"type": "module",
"main": "./index.mjs",
"license": "MIT",
"author": "czcdr",
"homepage": "https://github.com/czcdr/dsh-plugin-rea",
@@ -33,6 +34,7 @@
},
"files": [
"cordis.patch.yml",
"index.mjs",
"bin/",
"scripts/",
"manifest/",
@@ -43,8 +45,7 @@
"CHANGELOG.md",
"check-updates.ps1",
"screenshots.json",
"assets/",
"examples/"
"assets/"
],
"dsh": {
"bundle": {
@@ -57,10 +58,12 @@
"bundle:status": "node ./bin/rea-shim.mjs --shim-status",
"bundle:dry-run": "node ./scripts/setup.mjs --reuse --dry-run",
"bundle:audit": "node ./scripts/audit-manifest.mjs",
"bundle:test": "node --test ./scripts/setup.test.mjs"
"bundle:test": "node --test ./scripts/setup.test.mjs",
"bundle:host-compat": "node ./scripts/host-compat.mjs"
},
"engines": {
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
"node": "^22.19.0 || ^24.11.0 || >=26.0.0",
"dsh": ">=0.1.7-rc.2 <0.3.0-0"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/"
+98
View File
@@ -430,6 +430,104 @@ if (manifest) {
}
}
/* ------------------------- 11. setup and the manifest describe the same set */
// A component added to the manifest but not to setup.mjs's INSTALL_ORDER was
// silently never provisioned, and nothing here said so. The reverse — a name in
// INSTALL_ORDER with no manifest entry — is skipped by the loop's
// `if (!component) continue`, so it is equally silent.
const installOrderMatch = setupText.match(/const INSTALL_ORDER = \[([^\]]*)\]/);
if (!installOrderMatch) {
add("error", "scripts/setup.mjs", "INSTALL_ORDER was not found, so manifest/setup agreement cannot be checked");
} else {
const order = [...installOrderMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
const manifestNames = Object.keys(manifest.components ?? {});
for (const name of manifestNames) {
if (!order.includes(name)) {
add("error", "manifest.components", `"${name}" is not in scripts/setup.mjs INSTALL_ORDER, so setup would silently never provision it`);
}
}
for (const name of order) {
if (!manifestNames.includes(name)) {
add("error", "scripts/setup.mjs", `INSTALL_ORDER names "${name}", which the manifest does not describe`);
}
}
}
/* ---------------------------- 12. the Node engines rule has one wording too */
// REA's engines are restated in the manifest pin, package.json and the node
// probe regex. Only the pin and package.json can be compared as text, but a
// drift between those two would advertise one range and enforce another.
const declaredEngines = manifest.pins?.nodeEngines;
let packageEngines = null;
try {
packageEngines = JSON.parse(readFileSync(path.join(HERE, "..", "package.json"), "utf8")).engines?.node ?? null;
} catch (error) {
add("error", "package.json", `could not be read: ${String(error && error.message)}`);
}
if (!declaredEngines) {
add("error", "manifest.pins.nodeEngines", "is missing, so nothing states REA's engines for the reuse path");
} else if (declaredEngines !== packageEngines) {
add(
"error",
"manifest.pins.nodeEngines",
`is ${JSON.stringify(declaredEngines)} but package.json engines.node is ${JSON.stringify(packageEngines)}`,
);
}
/* ------------------- 13. a pinned version's probe must not accept a prefix */
// The concern is a probe that accepts a *newer* release as the pin, so test the
// expression against the versions that would mean that. The audit is not a
// regex linter: `^6\.4\.0` legitimately rejects 6.4.1 and 6.5.0 — the failure
// mode is only real when the next patch or minor still matches.
const reaAgents = manifest.components?.reaAgents;
const pinnedRea = reaAgents?.sources?.npm?.version;
const reaProbe = reaAgents?.probe?.expect?.regex;
if (pinnedRea && reaProbe) {
let probe = null;
try {
probe = new RegExp(reaProbe, "m");
} catch (error) {
add("error", "reaAgents.probe.expect.regex", `is not a valid regular expression: ${String(error && error.message)}`);
}
if (probe) {
if (!probe.test(pinnedRea)) {
add("error", "reaAgents.probe.expect.regex", `does not match the pinned version ${pinnedRea}`);
}
const [major, minor, patch] = pinnedRea.split(".").map((part) => Number(part));
for (const [label, next] of [
["the next patch", `${major}.${minor}.${patch + 1}`],
["the next minor", `${major}.${minor + 1}.0`],
]) {
if (Number.isFinite(next.split(".")[2]) && probe.test(next)) {
add("error", "reaAgents.probe.expect.regex", `accepts ${next} (${label}), so a newer release passes as the pin`);
}
}
}
}
/* --------------------- 14. a POSIX wrapper must be marked executable */
// copyFileSync carries the archive's 0644 and nothing else changes a mode, while
// REA spawns REA_APKTOOL_COMMAND without a shell: on Linux/macOS an unmarked
// wrapper fails with EACCES even though the component probe (`java -jar`) passes.
// Only the manifest can say which files need the bit, so require it here.
const apktoolWrappers = manifest.components?.apktool?.entries?.byPlatform ?? {};
for (const [platform, list] of Object.entries(apktoolWrappers)) {
if (platform.startsWith("win32")) continue;
for (const entry of list) {
if (entry.executable !== true) {
add(
"error",
`apktool.entries.byPlatform.${platform}`,
`the ${platform} wrapper is not marked "executable": true, so it lands 0644 and cannot be spawned without a shell`,
);
}
}
}
/* ------------------------------------------------------------- summary */
const pinned = fetchEntries.filter((entry) => entry.entry.sha256 && entry.entry.sha256 !== "unverified").length;
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env node
/**
* host-compat — does the range this package declares cover the DSH releases it
* claims to support?
*
* `package.json` carries `engines.dsh`, and that single string is what the
* plugin market reads to say "compatible" instead of "unknown" (the market's
* `deriveHostCompatibility` treats `engines.dsh` as a host declaration and
* checks it against the running host version). A declaration is therefore load
* bearing twice over: it decides whether the market HIDES this plugin from users
* on a matching host, and it decides whether users on a host we never tested are
* told it works.
*
* The two ways it goes wrong are silent. A typo makes the range unparseable, and
* the market then reports `unknown` — a plugin that looks undeclared rather than
* mis-declared. A floor that drifted (say `>=0.2.0`) silently drops hosts the
* README still promises. Neither shows up as a test failure unless something
* compares the STRING to the release list, which is what this file does.
*
* `HOST_RELEASES` is the dsh release line this package is built against, newest
* first, as published on github.com/deepseek-ai/deepseek-harness. It is a
* literal list rather than a GitHub query on purpose: a check that needs the
* network fails on a plane and passes on a total outage, which is the wrong
* shape for a gate. When a release is added upstream, add it here too — that
* edit is the reminder to re-check the row against the new host.
*
* SEMANTICS: this file evaluates the declaration the way `includePrerelease:
* true` does, and that choice is not stylistic. Both consumers of `engines.dsh`
* in this ecosystem pass that option — dshmarket's discovery
* (`satisfiesRange(host, declared, { includePrerelease: true })` in
* `deriveHostCompatibility`) and the host's own peer gate — so a plain semver
* reading, where `0.2.0-rc.2` does NOT satisfy `>=0.1.7-rc.2 <0.3.0-0` (a
* prerelease must be named by a comparator on its own tuple), would disagree
* with the verdict users actually see. The tests pin the option's effect.
*
* Usage: `node scripts/host-compat.mjs` (exit 0 = every listed release is
* covered by the declared range; exit 1 = at least one is not).
*/
import { readFileSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = path.join(HERE, '..')
/**
* Every dsh release this bundle is checked against, newest first.
*
* Taken from the release list of github.com/deepseek-ai/deepseek-harness. The
* floor is the oldest release the plugin has been adapted to; everything above
* it up to the declared ceiling must keep working, so a release that is missing
* here is a release nobody checked.
*/
export const HOST_RELEASES = [
'0.2.1-rc.2',
'0.2.1-rc.1',
'0.2.1-alpha.5',
'0.2.1-alpha.4',
'0.2.1-alpha.3',
'0.2.1-alpha.2',
'0.2.1-alpha.1',
'0.2.0-rc.2',
'0.2.0-rc.1',
'0.1.7-rc.2',
]
/** Hosts that must fall OUTSIDE the declaration, for the other direction. */
export const OUTSIDE_HOSTS = [
'0.3.0',
// Prereleases of the NEXT line are the case a plain `<0.3.0` ceiling lets
// through: with `includePrerelease` they sort below `0.3.0` itself. The
// declared ceiling is `<0.3.0-0`, semver's idiom for "nothing from 0.3.0",
// and these entries are what proves it does what it says.
'0.3.0-rc.1',
'0.3.0-alpha.1',
'0.1.7-rc.1',
'0.1.7-alpha.2',
]
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
/**
* Split a version into comparable parts.
*
* Build metadata (`+sha`) is dropped, as semver says it must be for precedence
* and as node-semver does for comparison: a host that reports
* `0.2.0-rc.2+build.7` is running 0.2.0-rc.2. Everything else is deliberately
* strict — a leading `v` or a zero-padded identifier is rejected rather than
* guessed at, because this gate exists to catch a declaration nobody checked and
* a guess would put the guess in charge.
* @param {string} version - a plain semver string, no range operators.
* @returns {{ parts: number[], pre: (string|number)[] } | null} null when unparseable.
*/
export function parseVersion(version) {
const cleaned = String(version ?? '').trim().replace(/\+.*$/, '')
const match = SEMVER.exec(cleaned)
if (match === null) return null
const raw = match[4] === undefined ? [] : match[4].split('.')
// semver forbids a zero-padded numeric prerelease identifier (`0.2.1-01`);
// accepting one would make this gate read a typo as a version.
if (raw.some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0'))) return null
const pre = raw.map(part => (/^\d+$/.test(part) ? Number(part) : part))
return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], pre }
}
/**
* Compare two versions the way semver does, prerelease included.
*
* A prerelease sorts BELOW its release (`0.2.0-rc.2 < 0.2.0`), and among
* prereleases an identifier that is numeric sorts below one that is not, which
* is why `0.2.1-alpha.5 < 0.2.1-rc.1` — the whole reason this file cannot lean
* on a numeric comparison.
* @returns -1, 0 or 1; NaN-free because unparseable input throws.
*/
export function compareVersions(a, b) {
const left = parseVersion(a)
const right = parseVersion(b)
if (left === null) throw new Error(`not a version: ${a}`)
if (right === null) throw new Error(`not a version: ${b}`)
for (let index = 0; index < 3; index += 1) {
if (left.parts[index] !== right.parts[index]) return left.parts[index] < right.parts[index] ? -1 : 1
}
if (left.pre.length === 0 && right.pre.length === 0) return 0
if (left.pre.length === 0) return 1
if (right.pre.length === 0) return -1
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index += 1) {
const l = left.pre[index]
const r = right.pre[index]
if (l === undefined) return -1
if (r === undefined) return 1
if (l === r) continue
const lNum = typeof l === 'number'
const rNum = typeof r === 'number'
if (lNum && rNum) return l < r ? -1 : 1
// Numeric identifiers always have lower precedence than alphanumeric ones.
if (lNum !== rNum) return lNum ? -1 : 1
return String(l) < String(r) ? -1 : 1
}
return 0
}
/**
* Evaluate one `>=floor <ceiling` declaration (the only shape this package
* ships, and the shape the market's `engines.dsh` is compared with).
*
* Deliberately narrow: a wider parser here could disagree with the market's
* semver implementation on a range the manifest would then be trusted for. If a
* future declaration needs `||` or `^`, this function has to grow with it.
* @param {string} version - host version to test.
* @param {string} range - the declared range.
* @returns {boolean} whether the host is inside the declaration.
*/
export function satisfiesDeclaredRange(version, range) {
const match = /^>=\s*(\S+)\s+<\s*(\S+)$/.exec(range.trim())
if (match === null) throw new Error(`unsupported host range: ${range}`)
return compareVersions(version, match[1]) >= 0 && compareVersions(version, match[2]) < 0
}
/**
* The host range this repository declares, `engines.dsh`.
* @param {string} [root] - repository root, for tests.
* @returns {string | null} the declared range, or null when absent.
*/
export function declaredHostRange(root = REPO_ROOT) {
try {
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
const value = pkg?.engines?.dsh
return typeof value === 'string' ? value : null
} catch {
return null
}
}
/**
* Every release covered by the declaration, and every version that must not be.
* @param {string} range - declared host range.
* @returns {{ covered: string[], uncovered: string[], leaked: string[] }} findings.
*/
export function checkHostCoverage(range) {
const covered = []
const uncovered = []
for (const version of HOST_RELEASES) {
if (satisfiesDeclaredRange(version, range)) covered.push(version)
else uncovered.push(version)
}
const leaked = OUTSIDE_HOSTS.filter(version => satisfiesDeclaredRange(version, range))
return { covered, uncovered, leaked }
}
function main() {
const range = declaredHostRange()
if (range === null) {
console.error('FAIL: package.json declares no engines.dsh — the market would report this plugin as "unknown"')
process.exit(1)
}
let result
try {
result = checkHostCoverage(range)
} catch (error) {
console.error(`FAIL: ${error.message}`)
process.exit(1)
}
console.log(`engines.dsh = ${range}`)
for (const version of HOST_RELEASES) {
const inside = satisfiesDeclaredRange(version, range)
console.log(` ${inside ? 'ok ' : 'MISS'} ${version}`)
}
for (const version of OUTSIDE_HOSTS) {
const inside = satisfiesDeclaredRange(version, range)
console.log(` ${inside ? 'LEAK' : 'ok '} ${version} (must stay outside)`)
}
if (result.uncovered.length > 0) {
console.error(`FAIL: the declaration does not cover ${result.uncovered.join(', ')}`)
process.exit(1)
}
if (result.leaked.length > 0) {
console.error(`FAIL: the declaration also admits ${result.leaked.join(', ')}, which is not a tested host`)
process.exit(1)
}
console.log(`host range ok: ${HOST_RELEASES.length} releases covered, ${OUTSIDE_HOSTS.length} correctly outside`)
}
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
+42 -10
View File
@@ -1,4 +1,4 @@
# rea-bundle-bootstrap.ps1 — get from "no Node at all" to a provisioned bundle.
# rea-bundle-bootstrap.ps1 — get from "no Node at all" to a provisioned bundle.
#
# Why this exists: setup.mjs is written in Node, so a machine with no Node
# cannot run it. This script uses only PowerShell + Expand-Archive (no
@@ -30,6 +30,11 @@ function Write-Step([string]$Message) { Write-Host "[bootstrap] $Message" }
# --- where things go -------------------------------------------------------
# Same precedence as setup.mjs, the shim and check-updates.ps1: explicit override,
# then DSH_HOME, then ~/.dsh. An override this script alone ignored would put
# ~800 MB into <DSH_HOME>/plugins/rea-bundle while every reader looked in
# REA_BUNDLE_ROOT, saw an empty bundle, and started a second run.
if (-not $Root -or $Root.Trim() -eq '') { $Root = $env:REA_BUNDLE_ROOT }
if (-not $Root -or $Root.Trim() -eq '') {
$home2 = $env:DSH_HOME
if (-not $home2 -or $home2.Trim() -eq '') { $home2 = Join-Path $HOME '.dsh' }
@@ -39,8 +44,12 @@ $Root = [System.IO.Path]::GetFullPath($Root)
$stack = Join-Path $Root 'stack'
$cache = Join-Path $Root 'cache'
$logs = Join-Path $Root 'logs'
foreach ($dir in @($Root, $stack, $cache, $logs)) {
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
# -DryRun promises that nothing was changed, so it must not create these either:
# it only reads them, and Test-Path / Get-ChildItem handle a missing path.
if (-not $DryRun) {
foreach ($dir in @($Root, $stack, $cache, $logs)) {
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
}
}
$pluginRoot = Split-Path -Parent $PSScriptRoot
@@ -84,6 +93,32 @@ $nodeFolder = [System.IO.Path]::GetFileNameWithoutExtension($nodeZipName)
if ($nodeSha256 -eq 'unverified') { throw "the manifest pins no checksum for $hostKey; refusing to download unverified bytes" }
Write-Step "Node source: $hostKey v$nodeVersion"
$script:NodeEngines = [string]$manifest.pins.nodeEngines
if (-not $script:NodeEngines) { throw "the manifest pins no nodeEngines, so Node compatibility cannot be judged" }
# REA's engines come from the manifest pin, which the rest of provisioning already
# treats as the single source of truth. This used to be a second copy of the same
# numbers in a comment, and two copies drift: the audit now asserts that the pin
# keeps the shape this parser understands.
function Test-EnginesMatch([string]$Clean, [string]$Range) {
$versions = @()
foreach ($part in $Clean.Split('.')) {
$n = 0
if (-not [int]::TryParse($part, [ref]$n)) { return $false }
$versions += $n
}
if ($versions.Count -lt 2) { return $false }
foreach ($clause in ($Range -split '\|\|')) {
$c = $clause.Trim()
if ($c -match '^\^(\d+)\.(\d+)\.(\d+)$') {
if ($versions[0] -eq [int]$Matches[1] -and $versions[1] -ge [int]$Matches[2]) { return $true }
} elseif ($c -match '^>=(\d+)\.(\d+)\.(\d+)$') {
if ($versions[0] -gt [int]$Matches[1] -or ($versions[0] -eq [int]$Matches[1] -and $versions[1] -ge [int]$Matches[2])) { return $true }
}
}
return $false
}
function Test-NodeUsable([string]$Exe) {
if (-not $Exe -or -not (Test-Path $Exe)) { return $null }
try {
@@ -91,13 +126,10 @@ function Test-NodeUsable([string]$Exe) {
} catch { return $null }
if (-not $version) { return $null }
$clean = $version.TrimStart('v').Trim()
$parts = $clean.Split('.')
if ($parts.Count -lt 2) { return $null }
$major = [int]$parts[0]
$minor = [int]$parts[1]
# REA's engines: ^22.19.0 || ^24.11.0 || >=26.0.0
$ok = ($major -eq 22 -and $minor -ge 19) -or ($major -eq 24 -and $minor -ge 11) -or ($major -ge 26)
if (-not $ok) { return $null }
# A version that is not three numbers (`vX.Y`) used to reach `[int]` and throw
# under $ErrorActionPreference='Stop', aborting the whole bootstrap instead of
# falling through to the portable download — the one path this script exists for.
if (-not (Test-EnginesMatch $clean $script:NodeEngines)) { return $null }
return [pscustomobject]@{ Path = (Resolve-Path $Exe).Path; Version = $clean }
}
+382 -88
View File
@@ -26,7 +26,7 @@
*/
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { appendFileSync, closeSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
import { appendFileSync, chmodSync, closeSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { homedir } from "node:os";
@@ -69,6 +69,22 @@ function defaultBundleRoot() {
return path.join(dshHome(), "plugins", "rea-bundle");
}
/**
* A numeric environment override, or the default.
*
* `Number("abc")` is NaN, and NaN does not stay local: `REA_BUNDLE_FETCH_RETRIES=abc`
* produced "gave up after NaN attempt(s)", and a NaN socket timeout behaves as 0
* (immediate expiry) rather than as "no override". Anything that is not a positive
* finite number is therefore ignored, and the effective values are logged below so
* an override is never invisible.
*/
function envNumber(name, fallback) {
const raw = process.env[name];
if (raw === undefined || String(raw).trim() === "") return fallback;
const value = Number(raw);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function parseArgs(argv) {
const options = {
root: defaultBundleRoot(),
@@ -82,9 +98,9 @@ function parseArgs(argv) {
// be declared dead quickly when the socket never opens, but a slow-but-alive
// transfer must not be killed for taking long overall (measured here:
// 0.35–0.54 MiB/s, where 570 MB legitimately takes ~30 minutes).
connectTimeoutMs: Number(process.env.REA_BUNDLE_CONNECT_TIMEOUT_MS ?? 15000),
idleTimeoutMs: Number(process.env.REA_BUNDLE_IDLE_TIMEOUT_MS ?? 120000),
retries: Number(process.env.REA_BUNDLE_FETCH_RETRIES ?? 4),
connectTimeoutMs: envNumber("REA_BUNDLE_CONNECT_TIMEOUT_MS", 15000),
idleTimeoutMs: envNumber("REA_BUNDLE_IDLE_TIMEOUT_MS", 120000),
retries: envNumber("REA_BUNDLE_FETCH_RETRIES", 4),
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
@@ -307,11 +323,20 @@ function openGet(url, { proxy, headers, connectTimeoutMs }) {
},
(res) => done(res, "direct"),
);
let connected = false;
req.on("socket", (socket) => {
socket.setTimeout(connectTimeout);
socket.once("connect", () => socket.setTimeout(idleTimeout));
socket.once("connect", () => {
connected = true;
socket.setTimeout(idleTimeout);
});
});
req.on("timeout", () => req.destroy(new Error(`no response within ${idleTimeout}ms`)));
// The socket carries the connect deadline until it connects and the idle
// deadline afterwards. Reporting the idle number for a connection that never
// happened sent the reader after the wrong setting.
req.on("timeout", () =>
req.destroy(new Error(connected ? `no data within ${idleTimeout}ms` : `no connection within ${connectTimeout}ms`)),
);
req.on("error", reject);
req.end();
return;
@@ -387,11 +412,27 @@ async function download(url, destination, expected, label) {
rmSync(temporary, { force: true });
have = 0;
}
if (have > 0 && have === expected.size) {
log(` ${label}: partial file already complete`);
} else {
log(` downloading ${label}${have > 0 ? ` (resuming at ${(have / 1048576).toFixed(1)} MiB)` : ""}`);
// A `.part` already holding every expected byte is finished work, not a resume
// point: a crash between the last write and the rename leaves exactly this
// shape. Resuming it sends `Range: bytes=<size>-`, which servers answer with
// 416, so four attempts later a complete download was reported failed (and on
// Windows the "resume" simply deleted it and fetched 570 MB again). Verify it
// and finish the rename instead.
if (have > 0 && expected.size && have === expected.size) {
const digest = sha256File(temporary);
const matches = !expected.sha256 || expected.sha256 === "unverified" || digest === expected.sha256;
if (matches) {
log(` ${label}: partial file was already complete; verified and kept`);
rmSync(destination, { force: true });
renameSync(temporary, destination);
return { sha256: digest, size: have, tofu: !expected.sha256 || expected.sha256 === "unverified" };
}
log(` ${label}: partial file is complete but the checksum does not match; starting over`);
rmSync(temporary, { force: true });
have = 0;
}
if (have > 0) log(` downloading ${label} (resuming at ${(have / 1048576).toFixed(1)} MiB)`);
else log(` downloading ${label}`);
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
@@ -412,29 +453,29 @@ async function download(url, destination, expected, label) {
let via = null;
let nodeFailure = null;
const tryPowerShellFirst = IS_WINDOWS;
const first = tryPowerShellFirst
? async () => {
rmSync(temporary, { force: true });
const written = downloadViaPowerShell(url, temporary);
log(` ${label}: via powershell (${(written / 1048576).toFixed(1)} MiB)`);
return "powershell";
}
: async () => {
const response = await openViaBestRoute(url, { connectTimeoutMs, have });
await writeBody(response.stream, response.headers, temporary, have, response.status, label);
return response.via;
};
const second = tryPowerShellFirst
? async () => {
const response = await openViaBestRoute(url, { connectTimeoutMs, have });
await writeBody(response.stream, response.headers, temporary, have, response.status, label);
return response.via;
}
: async () => {
rmSync(temporary, { force: true });
const written = downloadViaPowerShell(url, temporary);
return `powershell (${(written / 1048576).toFixed(1)} MiB)`;
};
const viaPowerShell = async () => {
// Resume through the .NET route as well: it is the Windows default, it
// honours the system proxy, and the 570 MB Ghidra archive is the transfer
// that matters. Deleting the partial file first — what this used to do —
// made every retry a full re-download, the opposite of the resumable
// behaviour this module promises. downloadViaPowerShell appends only when
// the server answers 206.
const written = downloadViaPowerShell(url, temporary, have);
log(` ${label}: via powershell (${(written / 1048576).toFixed(1)} MiB)`);
return "powershell";
};
const viaNode = async () => {
// Both routes write into the same .part. Trusting the `have` captured
// before this attempt appended a full 200 response onto the bytes the
// failed route had already written, producing a file that could only fail
// the size gate on the way out. Read the file's real length instead.
have = existsSync(temporary) ? statSync(temporary).size : 0;
const response = await openViaBestRoute(url, { connectTimeoutMs, have });
await writeBody(response.stream, response.headers, temporary, have, response.status, label);
return response.via;
};
const first = tryPowerShellFirst ? viaPowerShell : viaNode;
const second = tryPowerShellFirst ? viaNode : viaPowerShell;
try {
via = await first();
@@ -469,7 +510,6 @@ async function download(url, destination, expected, label) {
throw new Error(`sha256 mismatch: got ${digest}, expected ${expected.sha256}`);
}
rmSync(destination, { force: true });
const { renameSync } = await import("node:fs");
renameSync(temporary, destination);
return { sha256: digest, size: produced, tofu: !expected.sha256 || expected.sha256 === "unverified" };
} catch (error) {
@@ -487,30 +527,46 @@ async function download(url, destination, expected, label) {
}
/**
* Windows fallback route: let PowerShell/.NET perform the transfer.
* The PowerShell/.NET route: on Windows this is the **primary** route (see
* `tryPowerShellFirst` in download()), and everywhere else it exists only as the
* last-resort fallback.
*
* Measured here, this is not redundant with the Node routes. The local proxy
* Measured here, it is not redundant with the Node routes. The local proxy
* rewrites GitHub's release-asset redirect back to itself (an infinite 301
* loop), while a direct Node connection to github.com times out or is reset.
* The .NET stack honours the system proxy, follows the redirect chain to
* release-assets.githubusercontent.com correctly, and reports the full asset
* length. It is slower (~0.13 MiB/s vs ~0.5 MiB/s), so it is a fallback, and
* length. It is slower (~0.13 MiB/s vs ~0.5 MiB/s), which is why the routes are
* ordered per platform rather than universally, and
* the result is verified by the same size + checksum gate as every other route.
*/
function downloadViaPowerShell(url, destination) {
function downloadViaPowerShell(url, destination, have = 0) {
const resume = Number.isFinite(Number(have)) && Number(have) > 0;
const script = [
"$ErrorActionPreference = 'Stop'",
"$ProgressPreference = 'SilentlyContinue'",
`$url = '${url.replace(/'/g, "''")}'`,
`$out = '${destination.replace(/'/g, "''")}'`,
`$have = ${resume ? Math.trunc(Number(have)) : 0}`,
"$req = [System.Net.HttpWebRequest]::Create($url)",
"$req.UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) dsh-plugin-rea-bundle'",
"$req.AllowAutoRedirect = $true",
// The response stage. The body-copy stage needs its own deadline: with only
// Timeout set, a stalled socket inside CopyTo hung forever (spawnSync runs
// with no total timeout by design, because a slow-but-alive 570 MB transfer
// is legitimate).
"$req.Timeout = 60000",
"$req.ReadWriteTimeout = 120000",
// Resume instead of starting over. A server that ignores the range answers
// 200, and the file is then rewritten from the start rather than appended to.
"if ($have -gt 0) { $req.AddRange($have) }",
"$resp = $req.GetResponse()",
"$code = [int]$resp.StatusCode",
"$stream = $resp.GetResponseStream()",
"$file = [System.IO.File]::Create($out)",
"if ($have -gt 0 -and $code -eq 206) { $mode = [System.IO.FileMode]::Append } else { $mode = [System.IO.FileMode]::Create }",
"$file = New-Object System.IO.FileStream($out, $mode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)",
"$stream.CopyTo($file)",
"$file.Flush()",
"$file.Close()",
"$stream.Close()",
"$resp.Close()",
@@ -609,24 +665,72 @@ async function writeBody(stream, headers, temporary, have, status, label) {
return seen;
}
/** Extract with the platform's own tools. Returns true on success. */
function extract(archive, destination, format) {
/** The provisioned JDK's own `jar`, when one is resolved — a guaranteed unzipper. */
function jdkJarCommand(context) {
const jdk = context?.resolved?.jdk?.path;
if (typeof jdk !== "string" || jdk === "") return null;
const home = /[\\/]bin[\\/][^\\/]+$/.test(jdk) ? path.dirname(path.dirname(jdk)) : jdk;
const candidate = path.join(home, "bin", `jar${EXE}`);
return existsSync(candidate) ? candidate : null;
}
/**
* Extract with whatever the host actually has.
*
* Ghidra ships only as a zip on every platform, so "no unzip" was a certain
* failure on a minimal Linux image rather than an occasional one. The fallbacks
* are `python3 -m zipfile` and — since a JDK is provisioned before Ghidra — that
* JDK's own `jar`. Tarballs fall back to python3, whose tarfile reads xz without
* the xz binary. A host with none of them is told which tools were tried instead
* of getting a bare "extraction failed".
*/
function extract(archive, destination, format, context) {
mkdirSync(destination, { recursive: true });
const stdio = options.json ? "ignore" : "inherit";
const attempts = [];
if (format === "zip") {
if (IS_WINDOWS) {
const result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command",
`Expand-Archive -LiteralPath '${archive.replace(/'/g, "''")}' -DestinationPath '${destination.replace(/'/g, "''")}' -Force`],
{ stdio: options.json ? "ignore" : "inherit" });
return result.status === 0;
// bsdtar ships with Windows 10+ and unpacks a zip far faster than
// Expand-Archive's single-threaded .NET implementation — the 543 MB Ghidra
// archive is the case that matters. A failing tar still falls through to
// Expand-Archive.
attempts.push({ command: "tar", args: ["-xf", archive, "-C", destination] });
attempts.push({
command: "powershell",
args: [
"-NoProfile",
"-NonInteractive",
"-Command",
`Expand-Archive -LiteralPath '${archive.replace(/'/g, "''")}' -DestinationPath '${destination.replace(/'/g, "''")}' -Force`,
],
});
} else {
attempts.push({ command: "unzip", args: ["-q", "-o", archive, "-d", destination] });
attempts.push({ command: "python3", args: ["-m", "zipfile", "-e", archive, destination] });
const jar = jdkJarCommand(context);
// `jar xf` unpacks into the working directory, hence cwd.
if (jar) attempts.push({ command: jar, args: ["xf", archive], cwd: destination });
}
const result = spawnSync("unzip", ["-q", "-o", archive, "-d", destination], { stdio: options.json ? "ignore" : "inherit" });
return result.status === 0;
} else if (format === "tar.gz" || format === "tar.xz" || format === "tar") {
attempts.push({ command: "tar", args: ["-xf", archive, "-C", destination] });
attempts.push({
command: "python3",
args: ["-c", "import sys,tarfile;tarfile.open(sys.argv[1]).extractall(sys.argv[2])", archive, destination],
});
} else {
throw new Error(`unsupported archive format: ${format}`);
}
if (format === "tar.gz" || format === "tar.xz") {
const result = spawnSync("tar", ["-xf", archive, "-C", destination], { stdio: options.json ? "ignore" : "inherit" });
return result.status === 0;
let anyToolRan = false;
for (const attempt of attempts) {
const result = spawnSync(attempt.command, attempt.args, { stdio, cwd: attempt.cwd });
if (result.status === 0) return true;
if (!(result.error && result.error.code === "ENOENT")) anyToolRan = true;
}
throw new Error(`unsupported archive format: ${format}`);
if (!anyToolRan) {
log(` no ${format === "zip" ? "zip" : "tar"} extractor available (tried ${attempts.map((attempt) => path.basename(attempt.command)).join(", ")}); install unzip or python3`);
}
return false;
}
/* --------------------------------------------------------------- host probing */
@@ -704,7 +808,28 @@ function substituteProbe(value, context) {
function runProbe(probe, context) {
if (probe.file) {
const file = substituteProbe(probe.file, context);
return { ok: existsSync(file), detail: file };
if (!existsSync(file)) return { ok: false, detail: `${file} is missing` };
// `expect` used to be ignored for file probes, which made the Ghidra probe's
// `expect.contains: "application.version="` dead configuration: any file at
// that path passed, including a directory or a stray placeholder. Read the
// file — these are small metadata files — and honour the contract. The bound
// exists so a probe can never be pointed at a 543 MB tree by accident.
const expect = probe.expect ?? {};
if (expect.contains || expect.regex) {
let text = "";
try {
text = readFileSync(file, "utf8").slice(0, 65536);
} catch (error) {
return { ok: false, detail: `${file} could not be read: ${String(error && error.message)}` };
}
if (expect.contains && !text.includes(expect.contains)) {
return { ok: false, detail: `${file} does not contain "${expect.contains}"` };
}
if (expect.regex && !new RegExp(expect.regex, "m").test(text)) {
return { ok: false, detail: `${file} does not match /${expect.regex}/` };
}
}
return { ok: true, detail: file };
}
if (!probe.argv) return { ok: true, detail: "no probe" };
const argv = probe.argv.map((value) => substituteProbe(value, context));
@@ -866,6 +991,25 @@ function loadManifest() {
return parsed;
}
/**
* Read a bundle.json, tolerating one that is not valid JSON.
*
* A half-written file (disk full, a kill mid-write) used to throw straight out of
* provisioning — the run died before it could replace the file it could not
* parse, and the shim then failed on it forever. Treating it as absent lets the
* normal path read what is usable and rewrite the record at the end.
*/
function readBundleJson(file) {
if (!existsSync(file)) return null;
try {
const parsed = JSON.parse(readFileSync(file, "utf8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch (error) {
log(` ${path.basename(file)} is not readable JSON (${String(error && error.message)}); ignoring it for this run`);
return null;
}
}
const INSTALL_ORDER = ["node", "jdk", "ghidra", "apktool", "jadxHeadlessMcp", "androidPlatformTools", "reaAgents"];
/* -------------------------------------------------------------------- stages */
@@ -878,8 +1022,26 @@ async function provisionComponent(name, component, context) {
// makes the recorded-state check below redundant (and its message wrong).
const reused = reuseExisting(name, root);
if (reused) {
step(name, "skip", `reusing existing ${name} from ${reused.how}: ${reused.path}`);
return { path: reused.path, reused: true };
// A candidate is a claim until the component's own probe agrees. Adoption
// tested only that javac / analyzeHeadless / node exists, so a JDK 11 (Ghidra
// 12.1 needs 21+) or a Ghidra 11 install was recorded in bundle.json and the
// failure surfaced much later, inside REA. Run the same probe a provisioned
// copy has to pass, with the candidate planted so the probe's own tokens
// ({jvm}, {ghidra}) resolve to it instead of to a vendor path it never had.
const planted = context.resolved[name];
context.resolved[name] = { path: reused.path };
let probe;
try {
probe = runProbe(component.probe ?? {}, context);
} finally {
if (planted === undefined) delete context.resolved[name];
else context.resolved[name] = planted;
}
if (probe.ok) {
step(name, "skip", `reusing existing ${name} from ${reused.how}: ${reused.path}`);
return { path: reused.path, reused: true };
}
step(name, "plan", `not reusing ${reused.how} ${name} at ${reused.path}: ${probe.detail}`);
}
// 2) already present in the bundle and still verifiable?
@@ -889,8 +1051,8 @@ async function provisionComponent(name, component, context) {
// deleted afterwards. Only a recorded path that exists AND passes the probe is
// trusted; anything else falls through to provisioning again.
if (existsSync(path.join(root, "bundle.json"))) {
const current = JSON.parse(readFileSync(path.join(root, "bundle.json"), "utf8"));
const recorded = current.components?.[name];
const current = readBundleJson(path.join(root, "bundle.json"));
const recorded = current?.components?.[name];
if (recorded && typeof recorded.path === "string" && existsSync(recorded.path)) {
const probe = runProbe(component.probe ?? {}, context);
if (probe.ok) {
@@ -955,8 +1117,19 @@ async function provisionComponent(name, component, context) {
}
mkdirSync(targetDirectory, { recursive: true });
const target = path.join(targetDirectory, entry.saveAs ?? archiveName);
const { copyFileSync } = await import("node:fs");
copyFileSync(archivePath, target);
// A wrapper script has to be executable. copyFileSync lands the 0644 that
// the archive carries and nothing else in this file changes a mode, while
// REA starts REA_APKTOOL_COMMAND through execFile/no shell — so on POSIX a
// 0644 wrapper fails with EACCES. The component's own probe misses it
// because it runs `java -jar` and never executes the wrapper.
if (entry.executable === true) {
try {
chmodSync(target, 0o755);
} catch {
/* a filesystem that cannot carry the bit must not fail the component */
}
}
placed.push({ target, sha256: fetched.sha256, tofu: fetched.tofu });
}
if (options.dryRun) return { planned: true };
@@ -1015,7 +1188,6 @@ async function provisionComponent(name, component, context) {
const targetDirectory = path.join(vendor, component.unpack?.moveTo ?? name);
mkdirSync(targetDirectory, { recursive: true });
const target = path.join(targetDirectory, source.saveAs ?? archiveName);
const { copyFileSync } = await import("node:fs");
copyFileSync(archivePath, target);
const probe = runProbe(component.probe ?? {}, context);
step(name, probe.ok ? "ok" : "FAIL", `${target}${probe.ok ? "" : " — " + probe.detail}`);
@@ -1026,7 +1198,7 @@ async function provisionComponent(name, component, context) {
rmSync(target, { recursive: true, force: true });
const staging = target + ".staging";
rmSync(staging, { recursive: true, force: true });
const unpacked = extract(archivePath, staging, source.format);
const unpacked = extract(archivePath, staging, source.format, context);
if (!unpacked) {
step(name, "FAIL", `extraction failed for ${archiveName}`);
return null;
@@ -1036,12 +1208,10 @@ async function provisionComponent(name, component, context) {
for (let level = 0; level < strip; level += 1) {
// Descend into the archive's single root directory. If the level is not a
// single directory (some archives are already flat), stop rather than guess.
const { readdirSync } = await import("node:fs");
const entries = readdirSync(effective).filter((entry) => !entry.startsWith("."));
if (entries.length !== 1 || !statSync(path.join(effective, entries[0])).isDirectory()) break;
effective = path.join(effective, entries[0]);
}
const { renameSync } = await import("node:fs");
rmSync(target, { recursive: true, force: true });
renameSync(effective, target);
rmSync(staging, { recursive: true, force: true });
@@ -1094,6 +1264,15 @@ async function provisionReaAgents(component, context) {
return { planned: true, version };
}
// `--offline is a promise, not a hint` — and this is the one step that cannot be
// served from the cache: it is an npm install, not a download. It used to run
// anyway, so a machine with every archive pre-cached still went to the registry
// (or failed there) after the flag had promised it would not.
if (options.offline) {
step("reaAgents", "FAIL", `--offline and the vendored REA at ${vendor} is not usable; npm install needs the registry (drop --offline or pre-install it)`);
return null;
}
/*
* A plain pack+unpack cannot work here: the published tarball carries no
* node_modules, so the CLI would fail with ERR_MODULE_NOT_FOUND on its own
@@ -1149,6 +1328,10 @@ async function provisionReaAgents(component, context) {
});
if (install.status !== 0) {
step("reaAgents", "FAIL", `npm install failed (${install.status}): ${`${install.stderr ?? ""}${install.stdout ?? ""}`.trim().split("\n").slice(-3).join(" | ").slice(0, 300)}`);
// Every other component can fall back to the proxy route this script resolves
// itself; npm cannot, and this step is the one that fails most often on a
// network where the registry needs a mirror.
log(" npm reads HTTPS_PROXY/HTTP_PROXY and NPM_CONFIG_REGISTRY from the environment (this script's own proxy routing does not apply here)");
return null;
}
@@ -1174,6 +1357,9 @@ log(` root ${options.root}`);
log(` cache ${options.cache}`);
log(` host ${hostKey()} node ${process.versions.node}`);
log(` mode ${options.dryRun ? "dry-run " : ""}${options.reuse ? "reuse " : ""}${options.offline ? "offline" : "online"}`.trimEnd());
// Printed so an environment override is visible: a typo used to reach the socket
// layer as NaN (and as "gave up after NaN attempt(s)") with nothing to see.
log(` limits connect=${options.connectTimeoutMs}ms idle=${options.idleTimeoutMs}ms retries=${options.retries}`);
log("");
const manifest = loadManifest();
@@ -1185,17 +1371,72 @@ const context = {
};
const results = {};
/**
* What a previous run recorded.
*
* `--only` names a subset of components, so this run's `results` describes only
* that subset — and writing bundle.json straight from `results` erased every
* other component: the JDK and Ghidra disappeared from `paths`, the shim stopped
* exporting JAVA_HOME/GHIDRA_INSTALL_DIR, so `setup.mjs --only androidPlatformTools`
* silently disabled native analysis on a fully provisioned machine, and the next
* full run had to unpack 750 MB again. Components this run does not touch are
* carried over instead.
*/
const previous = readBundleJson(path.join(options.root, "bundle.json"));
/** One untouched component's record, kept only while its recorded path exists. */
function carriedOver(name) {
const record = previous?.components?.[name];
if (!record || typeof record !== "object") return null;
if (typeof record.path === "string" && record.path !== "") {
if (existsSync(record.path)) return record;
return { ...record, path: null, unavailable: true, reason: "the recorded path is gone" };
}
return record;
}
for (const name of INSTALL_ORDER) {
const component = manifest.components[name];
if (!component) continue;
if (options.only && !options.only.includes(name)) continue;
const missingRequirement = (component.requires ?? []).find((requirement) => !context.resolved[requirement]);
if (missingRequirement && !options.only) {
step(name, "FAIL", `skipped: required component "${missingRequirement}" is unavailable`);
results[name] = null;
continue;
if (missingRequirement) {
// Under --only the requirement is not being provisioned this run, but the
// previous run's record still answers it — and that is the same answer a full
// run would use. Skipping the check entirely made `--only apktool` resolve
// {jvm} to the vendor/jdk that only a full run creates and report FAIL, on a
// machine whose JDK was recorded right next to it.
let carried = options.only ? carriedOver(missingRequirement) : null;
if (!carried && options.only) {
// A requirement can also be answered by the machine instead of the record.
// `node` always can: a Node is already running this script, and
// provisionReaAgents falls back to exactly that interpreter — without this,
// `--only reaAgents` on a fresh root failed on a "missing" node the run is
// itself made of. The others are answered when the caller passed --reuse.
if (missingRequirement === "node") {
carried = { path: process.execPath, reused: true, how: "the interpreter running setup" };
} else {
const reused = reuseExisting(missingRequirement, options.root);
if (reused) carried = { path: reused.path, reused: true, how: reused.how };
}
}
if (!carried || typeof carried.path !== "string" || !existsSync(carried.path)) {
step(name, "FAIL", `skipped: required component "${missingRequirement}" is unavailable`);
results[name] = null;
continue;
}
context.resolved[missingRequirement] = carried;
}
let resolvedValue = null;
try {
resolvedValue = await provisionComponent(name, component, context);
} catch (error) {
// An unexpected throw used to abort the run before it could write a summary
// or record why its lock is still there. Report it as this component's
// failure instead, so the exit code and the log stay truthful.
step(name, "FAIL", `unexpected error: ${String(error?.message ?? error)}`);
resolvedValue = null;
}
const resolvedValue = await provisionComponent(name, component, context);
results[name] = resolvedValue;
// A component satisfies another's `requires` when it has a real path — or when
// this run only *planned* it. In dry-run every component returns
@@ -1208,27 +1449,36 @@ for (const name of INSTALL_ORDER) {
}
}
/* Components this run does not touch keep the record the previous run wrote. */
const merged = { ...results };
for (const name of INSTALL_ORDER) {
if (Object.prototype.hasOwnProperty.call(merged, name)) continue;
if (!manifest.components[name]) continue;
const carried = carriedOver(name);
if (carried) merged[name] = carried;
}
/* browser is adopted, never downloaded */
const browser = reuseExisting("browser", options.root);
if (browser) step("browser", "skip", `adopting ${browser.path}`);
/* --------------------------------------------------------------- wire + probe */
const nodePath = typeof results.node?.path === "string" && existsSync(results.node.path)
? results.node.path
const nodePath = typeof merged.node?.path === "string" && existsSync(merged.node.path)
? merged.node.path
: process.execPath;
const paths = {
node: nodePath,
reaEntry: path.join(options.root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs"),
ghidra: results.ghidra?.path ?? null,
jdk: results.jdk?.path ?? null,
adb: results.androidPlatformTools?.path ? path.join(results.androidPlatformTools.path, `adb${EXE}`) : null,
apktoolCommand: results.apktool?.path
? path.join(results.apktool.path, IS_WINDOWS ? "apktool.bat" : "apktool")
ghidra: merged.ghidra?.path ?? null,
jdk: merged.jdk?.path ?? null,
adb: merged.androidPlatformTools?.path ? path.join(merged.androidPlatformTools.path, `adb${EXE}`) : null,
apktoolCommand: merged.apktool?.path
? path.join(merged.apktool.path, IS_WINDOWS ? "apktool.bat" : "apktool")
: null,
jadxJar: results.jadxHeadlessMcp?.path
? path.join(results.jadxHeadlessMcp.path, results.jadxHeadlessMcp.jarName ?? "")
jadxJar: merged.jadxHeadlessMcp?.path
? path.join(merged.jadxHeadlessMcp.path, merged.jadxHeadlessMcp.jarName ?? "")
: null,
browser: browser?.path ?? null,
ilspycmd: process.env.REA_ILSPY_CMD_PATH ?? null,
@@ -1242,17 +1492,17 @@ if (!options.dryRun) {
plugin: { name: "dsh-plugin-rea", root: sourceRoot() },
paths,
versions: {
reaAgents: results.reaAgents?.version ?? null,
ghidra: results.ghidra?.version ?? null,
jdk: results.jdk?.version ?? null,
apktool: results.apktool?.version ?? null,
jadxHeadlessMcp: results.jadxHeadlessMcp?.version ?? null,
node: results.node?.version ?? process.versions.node,
reaAgents: merged.reaAgents?.version ?? null,
ghidra: merged.ghidra?.version ?? null,
jdk: merged.jdk?.version ?? null,
apktool: merged.apktool?.version ?? null,
jadxHeadlessMcp: merged.jadxHeadlessMcp?.version ?? null,
node: merged.node?.version ?? process.versions.node,
},
components: Object.fromEntries(
Object.entries(results).map(([name, value]) => [name, value ?? { path: null, unavailable: true }]),
Object.entries(merged).map(([name, value]) => [name, value ?? { path: null, unavailable: true }]),
),
unverifiedChecksums: Object.entries(results)
unverifiedChecksums: Object.entries(merged)
.filter(([, value]) => value?.tofu)
.map(([name]) => name),
};
@@ -1303,7 +1553,11 @@ if (!options.dryRun) {
const shim = spawnSync(process.execPath, [mirroredShim, "--version"], {
encoding: "utf8",
timeout: 120000,
env: { ...process.env, REA_BUNDLE_ROOT: options.root },
// Pin the bundle path: this step exists to confirm the shim relays the REA
// THIS run provisioned. Under the default `auto` mode a machine that also
// has a newer global REA answers with its own version, and the step would
// report success for something this run never verified.
env: { ...process.env, REA_BUNDLE_ROOT: options.root, REA_BUNDLE_LAUNCH: "bundle" },
});
const output = `${shim.stdout ?? ""}${shim.stderr ?? ""}`.trim();
const ok = shim.status === 0 && /\d+\.\d+\.\d+/.test(output);
@@ -1382,6 +1636,31 @@ function mirrorLauncher(context) {
*/
function writeMirrorRecord(context, source) {
const record = { schema: 1, pluginRoot: source, mirroredAt: new Date().toISOString() };
// Provenance for the self-healing check: the mirrored shim compares itself
// with `<pluginRoot>/bin/rea-shim.mjs` and re-execs that one when the plugin
// has moved on. These two fields are what `--shim-status` shows when the
// comparison cannot be made (missing checkout, unreadable file).
try {
record.shimSha256 = createHash("sha256")
.update(readFileSync(path.join(context.root, "bin", "rea-shim.mjs")))
.digest("hex");
} catch {
/* the copy is missing or unreadable; the shim reports the comparison itself */
}
try {
record.pluginVersion = JSON.parse(readFileSync(path.join(source, "package.json"), "utf8")).version ?? null;
} catch {
record.pluginVersion = null;
}
// The MCP child the harness starts has every DSH_* variable stripped, so the
// mirrored shim cannot re-derive a CUSTOM home from its environment — it would
// read `~/.dsh` and miss a duplicate `serverName: rea` living in the real one.
// Record the home setup ran under; the shim prefers the environment, then this.
try {
record.dshHome = dshHome();
} catch {
record.dshHome = null;
}
try {
mkdirSync(context.root, { recursive: true });
writeFileSync(path.join(context.root, "mirror.json"), JSON.stringify(record, null, 2) + "\n");
@@ -1417,6 +1696,25 @@ const failures = state.steps.filter((entry) => entry.status === "FAIL");
const failed = failures.filter((entry) => !optionalFailure(entry));
const optionalUnavailable = failures.filter(optionalFailure);
/*
* Lock bookkeeping is not presentation, and it used to sit inside the
* non-`--json` branch: a successful `--json` run therefore left the lock behind,
* and the shim then reported "provisioning started in the background" for six
* hours on a fully provisioned machine. A failing run keeps the lock on purpose —
* so a restart loop cannot re-download forever — but marks it failed, which lets
* the shim say what actually happened instead of claiming a run is in flight.
*/
const lockFile = path.join(options.root, "setup.lock");
if (failed.length === 0) {
rmSync(lockFile, { force: true });
} else {
try {
writeFileSync(lockFile, JSON.stringify({ state: "failed", pid: process.pid, at: Date.now(), exit: 1, log: logFilePath() }) + "\n");
} catch {
/* the lock is advisory: a read-only root must not break the report */
}
}
if (options.json) {
process.stdout.write(
JSON.stringify(
@@ -1436,10 +1734,6 @@ if (options.json) {
}
log("");
if (failed.length === 0) {
// Release the provisioning lock the shim set, so a later first run on a new
// machine (or after a manual wipe) can start again. A failed run keeps the
// lock, but the shim treats a lock older than six hours as stale.
rmSync(path.join(options.root, "setup.lock"), { force: true });
log("restart DSH (or reload the profile) so the MCP row reconnects.");
// Point at the mirrored copy, not at this checkout's: the mirror resolves its
// bundle from its own location, so this command is right for the root this run
+1335 -6
View File
File diff suppressed because it is too large. Load diff
+25 -3
View File
@@ -12,7 +12,7 @@ function Check($name, [bool]$ok, $detail) {
}
Write-Host "`n=== 1. JS syntax ===" -ForegroundColor Cyan
foreach ($f in @('bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs')) {
foreach ($f in @('index.mjs','bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs','scripts\host-compat.mjs')) {
$out = & node --check $f 2>&1
Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
}
@@ -73,10 +73,32 @@ Write-Host "`n=== 6. package payload lists what the bundle needs ===" -Foregroun
# -Encoding UTF8 is required: the package description contains CJK text, and
# PowerShell 5.1 reads a BOM-less file as ANSI, which breaks ConvertFrom-Json.
$pkg = Get-Content (Join-Path $root 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($entry in @('bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
foreach ($entry in @('index.mjs', 'bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
Check "files[] includes $entry" (@($pkg.files) -contains $entry) ''
}
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test')) {
# The market's update path asks whether package.json's main/exports points at a
# file that exists, and rolls the update back when it does not (dshmarket's
# hasLoadableEntry). This package declared neither until 2.0.5, so every update of
# a healthy profile failed with "updated build has no loadable entry". Asserting
# the pointer resolves keeps that from returning silently.
Check 'package.json declares main' (-not [string]::IsNullOrWhiteSpace($pkg.main)) ''
Check 'main resolves to a shipped file' ($pkg.main -and (Test-Path (Join-Path $root $pkg.main))) "main=$($pkg.main)"
# The market decides "compatible" vs "unknown" from engines.dsh, and hides the
# plugin from hosts whose version does not match. An absent or unparseable
# declaration therefore reads as "nothing to say" rather than as a defect, which
# is why the coverage check runs here as well as in CI.
Check 'package.json declares engines.dsh' (-not [string]::IsNullOrWhiteSpace($pkg.engines.dsh)) ''
$compat = & node scripts\host-compat.mjs 2>&1
Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($compat | Select-Object -Last 2) -join ' ')
# Both READMEs quote the range verbatim, and a stale quote is invisible: JSON,
# host-compat and the audit all agree while the sentence a human reads names a
# different ceiling. That is exactly how `<0.3.0` survived in the docs after the
# declaration moved to `<0.3.0-0`, so the comparison lives here now.
foreach ($doc in @('README.md', 'README.zh-CN.md')) {
$text = Get-Content (Join-Path $root $doc) -Raw -Encoding UTF8
Check "$doc quotes the declared engines.dsh" $text.Contains([string]$pkg.engines.dsh) "expected $($pkg.engines.dsh)"
}
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) ''
}