6 Commits
Author SHA1 Message Date
czcdr d0e2ffd7a6 package: declare the entry file the market's update check requires (2.0.5)
dshmarket's update path asks hasLoadableEntry whether the new build has an entry file that exists (main/exports, else index.js) and rolls back when it does not. This package declared neither, and the package it mounts (@deepseek-ai/dsh-mcp-client) ships inside the harness build rather than in node_modules, so every location the check probes was empty: 2.0.1 -> 2.0.4 failed three times with 'updated build has no loadable entry' on a healthy profile, and the rollback was reported unverifiable for the same reason.

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

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

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

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

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

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

Docs-only patch release so the npm package page renders it.
2026-10-15 19:05:07 +08:00
czcdr 3138bd15c3 2.0.1: put the version note at the top of both READMEs, and truth up the design notes
The npm package page renders the README straight from the published tarball, and its first
screen was title -> badge -> language line -> a 1280x720 screenshot, which pushed the 1.x/2.x
explanation below the fold — the 2.0.0 README therefore looked unchanged there.

- both READMEs: the version note (which line is this, what 1.x vs 2.x do, where 1.x lives)
  now sits directly under the language switcher; the comparison table and the upgrade
  section keep their places
- docs/bundle-design.md: main IS the bundle line now (it said "this branch, main unchanged"),
  and section 8 is about the approach, not a branch
- docs-only patch release on purpose: republishing is the only way npm re-renders that page
2026-10-15 16:40:08 +08:00
20 changed files with 2051 additions and 288 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
+34 -10
View File
@@ -25,10 +25,16 @@ jobs:
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1) };
if (!p.dsh || !p.dsh.bundle || !p.dsh.bundle.patch) fail('package.json is missing dsh.bundle.patch');
if (p.private) fail('package.json must not be private to be publishable');
for (const f of ['cordis.patch.yml', 'README.md', 'LICENSE', 'check-updates.ps1']) {
for (const f of ['cordis.patch.yml', 'index.mjs', 'README.md', 'LICENSE', 'check-updates.ps1']) {
if (!p.files.includes(f)) fail('files[] must ship ' + f);
}
if (!require('fs').existsSync(p.dsh.bundle.patch)) fail('dsh.bundle.patch target does not exist');
// The market's update path rolls a build back when package.json points
// at no existing entry (dshmarket's hasLoadableEntry); this package
// shipped no main at all until 2.0.5, so every update of a healthy
// profile failed with "updated build has no loadable entry".
if (typeof p.main !== 'string') fail('package.json must declare main');
if (!require('fs').existsSync(p.main)) fail('main points at no shipped file: ' + p.main);
console.log('manifest ok');
"
@@ -44,16 +50,15 @@ jobs:
- name: The npm payload ships every market-facing file
run: |
# Regression guard for files[]: the market reads screenshots.json and the
# images next to package.json, and a user copies examples/ by hand — none of
# that is worth anything if npm pack drops it.
# images next to package.json — none of that is worth anything if npm pack
# drops it.
node -e "
const { execSync } = require('child_process');
const meta = JSON.parse(execSync('npm pack --dry-run --json', { encoding: 'utf8' }))[0];
const files = meta.files.map((f) => f.path);
const need = [
'cordis.patch.yml', 'README.md', '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 +82,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 +92,9 @@ jobs:
- name: The shipped patches stay machine-independent
run: |
set -e
# The row DSH loads, plus every example a reader is told to copy: none of
# them may carry a real machine path. `<placeholders in angle brackets>`
# are the intended form.
targets=$(printf '%s\n' cordis.patch.yml examples/*.yml)
# The row DSH loads may carry no real machine path; `<placeholders in angle
# brackets>` are the intended form.
targets=$(printf '%s\n' cordis.patch.yml)
if grep -nE '[A-Za-z]:\\' $targets; then
echo "FAIL: a shipped patch contains an absolute Windows path"
exit 1
@@ -163,3 +167,23 @@ 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
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
+166
View File
@@ -2,6 +2,172 @@
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
## 2.0.5 — 2026-10-18
**市场更新失败已定位并修复:清单里没有 `main`,市场因此在每次更新时判「新构建没有可加载入口」并回滚。**
### 修复
- **应用内市场更新到 2.0.4 失败**:`dshmarket` 的更新路径用 `hasLoadableEntry` 判断新构建能不能加载
(实现是 `entryArtifactExists`:读 `main`/`exports`,两者都没有就回落 `index.js`)。本包是纯 bundle
(只有 `dsh.bundle.patch`),从未声明 `main`;而它挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
构建、不在 `node_modules` 里,于是该检查要看的 profile / 包内 / workspace 三个位置全空。结果:2.0.1 → 2.0.4
的更新连续三次报「更新后缺少入口文件……且未能验证恢复原版本文件」,而回滚其实成功了 —— 旧版同样没有入口,
所以回滚后的复检依然为 false。现已声明 `main: ./index.mjs`,并让该文件进入 `files[]`。
- **`index.mjs` 是真空插件,不是占位**:导出 `name` 与空 `apply()`,不注册行、不起进程、不写状态;真正起作用的
行仍由 `cordis.patch.yml` 插入。若 harness 某天真把它当插件加载,行为仍是「什么都不做」,因此不会造出第二条
`serverName: rea`。
### 文档
- 两份 README 新增「在市场里更新 / Updating from the market」小节:说明该报错来自市场侧的入口判定、与 tarball
完整性无关、profile 未受损,以及 2.0.5 起的做法。
### 验证
- 用 `dshmarket` 自身的 `hasLoadableEntry` 实测:把本包按 profile 布局模拟安装,修复前 `false`、修复后 `true`;
对仓库目录 `entryArtifactExists` 修复后为 `true`。
- 单测 42 条(新增 2 条:清单声明的入口必须存在且被 `files[]` 收录;入口可 `import`、导出的 `name` 与包名一致、
`apply()` 返回 `undefined`):Windows 39 过 + 3 平台跳过。
- `verify-branch.ps1` 新增 `main` 存在性与 `files[]` 收录检查;`validate.yml` 的清单检查与 npm 载荷检查加了同一条守卫。
- `npm pack --dry-run`:20 个文件,含 `index.mjs`。
## 2.0.4 — 2026-10-17
**两处已核实的缺陷:只读 bundle 目录下 shim 启动即崩;与上游 `rea setup --client dsh` 的同名注册冲突现在会被明确提示。**
### 修复
- **只读 bundle 根目录 + 陈旧 lock → shim 未捕获 EACCES 崩溃**:2.0.3 把删锁移出了 `if (fresh)`,却没包
try/catch。目录只读(`chmod 555`、系统级安装目录)且锁已陈旧时,`rmSync(lock)` 抛 `EACCES`,shim 以
未捕获异常退出(exit 1 + 堆栈)—— 比原来的静默更吵,但同样起不来。沙箱已复现(WSL `chmod 555`)。
现在删锁失败返回 `unwritable`,走既有的「无法写入,请手动跑 setup」路径(exit 78,行不崩)。
- **与上游 `rea setup --client dsh` 的同名注册冲突**:该命令往 `<DSH_HOME>/cordis.patch.yml` 写入
`id: rea-mcp`、`serverName: rea`,而本包是 `id: mcp-rea`、同一个 `serverName`;DSH 对同名条目只保留
一个(后加载者报 duplicate server name)。已按上游 6.5.0 实测生成该文件确认。shim 现在启动时检测同
文件的 `serverName: rea`(id 不是 `mcp-rea`)并向 stderr 提示「二选一」,`--shim-status` 也会在
`duplicateRegistration` 下报告。
### 文档
- 两份 README 新增「只保留一条 REA 注册」小节:与 `rea setup --client dsh` 二选一,并说明两条路线的
取舍(零依赖供给 vs 上游官方纯配置注册)。
- v1 分支 README 顶部加横幅:指向 2.x(`main`,默认推荐)与上游官方注册。
### 验证
- 单测 40 条:Windows 37 过 + 3 平台跳过;WSL 39 过 + 1 跳过(本轮新增 3 条,其中只读目录一条仅 POSIX)。
- 反向验证:回退删锁的 try/catch → 只读目录测试在 WSL 必红(EACCES/exit 1);回退冲突检测 → 重复注册
测试必红。
- `verify-branch.ps1` 全绿。
## 2.0.3 — 2026-10-16
**修复 11 处已核实的缺陷(每条都先确认存在、并配了"回退即变红"的回归测试);上游 REA 钉子从 6.4.0 升到 6.5.0。**
### 上游
- `pins.reaAgents` 与 `components.reaAgents.sources.npm.version` → **6.5.0**:engines 与 bin 入口未变
(`^22.19.0 || ^24.11.0 || >=26.0.0`、`scripts/rea.mjs`),工具数 170 → **207**(运行时 `rea doctor --json` 实测;上游源码静态清点为 206,多出的 1 个注册在 contracts 之外。新增:Frida 9、
GDB 4、Rizin/Cutter 6、pcap/packet 4、flutter 2、sqlite/rust/go/wasm/electron/harmony/hopper 各 1 等)。
新增的 `optionalDependencies.frida` 在 `--ignore-scripts` 下不会执行安装脚本。REA 探针正则收紧为
`^6\.5\.0(\s|$)`,审计新增"下一个 patch/minor 不得通过"的断言。
- Ghidra 12.1.4 / apktool 3.0.3 / jadx-headless-mcp 0.7.1 **经核实仍是当前最新**;Node 24.22.0 虽已发布,
但晚于本插件的实测矩阵,故保持 24.21.0(engines 完全满足)。
### 修复
- **`--only` 抹掉其它组件的记录**:bundle.json 由本轮结果整份重写,未点名组件的 `paths.jdk/ghidra` 变
`null`,shim 不再导出 `JAVA_HOME`/`GHIDRA_INSTALL_DIR`——原生分析静默失效,下次完整跑还得重新解压
750 MB。未点名的组件现在按旧记录结转(且只在记录的路径仍然存在时),缺依赖时优先用旧记录/机器/运行
setup 的解释器来满足,而不是整段跳过检查。
- **完整的 `.part` 不被当作已完成**:只打一行日志,然后发 `Range: bytes=<全长>-`(416;四次重试后把
已经下好的文件报成失败)。现在校验通过即改名收下。
- **Windows 下载主路线没有续传**:PowerShell 路线每轮先删 `.part`;失败后 Node 兜底仍用进本轮前的
`have`,以 append 把 200 全量接在半成品后面(长度必然超标,只能重下)。两条路线现在都按盘上实际
长度发 Range 续传。
- **POSIX 的 apktool wrapper 没有执行位**(copyFileSync 落 0644,而 REA 不加 shell 直接 spawn → EACCES):
manifest 里标了 `executable` 的条目落盘时 chmod 0755。
- **apktool wrapper 钉在 `master` 分支 + 硬校验 sha256**:上游对 wrapper 的任何一次提交都会让全网新装
hash mismatch。改钉不可变的 `v3.0.3` tag,哈希按该 tag 重新实测。
- **`--reuse` 不校验版本**:JDK 只看 `javac` 存在、Ghidra 只看 `analyzeHeadless` 存在(JDK 8/11、Ghidra 11
照收)。现在候选必须通过该组件自己的 probe;同时修好"文件型 probe 从不检查 `expect`"(Ghidra 的
`application.version=` 断言此前是死配置)。
- **`bundle.json` 损坏即永久失败**:setup 读不了它、也没人启动 setup,用户只能手删文件。现在 setup 容忍
它(并按需重写),shim 对 `malformed` 与 `missing` 一样触发供给。
- **失败后 shim 谎报 6 小时**:lock 现在记录状态与 pid;失败态如实报告"上一次失败了",持有者已消失的
lock 不再阻塞重试。
- **成功的 `--json` 轮次从不删 lock**:删除语句原先在非 `--json` 分支里,--json 成功也留下 lock。
- **shim 兜底 REA 路径少了 `node_modules`**:旧 bundle.json 缺 `paths.reaEntry` 时会把可用安装判成"CLI 丢了"。
- **`scripts/rea-bundle-bootstrap.ps1`**:`node -v` 输出非数字时 `[int]` 强转在 `$ErrorActionPreference='Stop'`
下会炸掉整个 bootstrap(而它存在的意义就是这条路径);`&` 调用失败时 `$LASTEXITCODE` 可能为 `$null` →
`exit $null` = 假成功;engines 改从 manifest 的 `pins.nodeEngines` 读(不再抄第二份);`-DryRun` 不再创建
目录(它承诺"什么都没改")。
- **`check-updates.ps1` 的 1.x 残留**:REA 节默认找 npm 全局包(2.x 是 vendored)、Ghidra 节读用户级
`GHIDRA_INSTALL_DIR`、`rea doctor` 拿不到 vendored CLI(整段自检被静默跳过)、一体包对账对"未记录版本"
的组件静默 `continue` 却打印"全部一致"。现在一律先读 `bundle.json`,并如实报告无法对账的项。
### 审计与 CI
- `audit-manifest.mjs` 新增 4 项:manifest 组件 ⇄ `INSTALL_ORDER` 对账、`pins.nodeEngines` ⇄ `package.json
engines`、REA pin 探针不得放行下一个 patch/minor、POSIX wrapper 必须标 `executable`。
- `validate.yml` 新增 `windows-latest` job:PowerShell 下载路线、zip 解压与 bootstrap 此前是零 CI 覆盖的
Windows 专属代码。
- 单测 33 条(32 过 + 1 条按平台跳过);`--reuse adopts a compatible node` 在 engines 之外的宿主上改为显式
skip(此前在 Node 20 上必红,而 CI 钉 22 看不出来)。
### 第二轮修复(同一份**更新后**的清单,2026-10-16 晚)
- 🔴 **过期 lock 永久卡死自动供给(2.0.0–2.0.2 三版全带)**:`maybeAutoProvision()` 只在 lock 新鲜时
处理它,过期的 lock 既不清除也不报告 → 落到独占创建 → `EEXIST` → 返回 `in-flight`,而 `main()` 对
`in-flight` **一个字都不打印**。于是「下载被 kill / 断电」这一常态,在 6 小时后再启动就变成**永久不再
自动供给且完全静默**。已按清单复现(7 小时死 lock:不启动、lock 原样、无任何提示;5 分钟 lock 对照组
正常删锁重启),修法:**不新鲜或持有者已死就一律清锁**(这正是注释里"只在新鲜时尊重它"的本意),
并给 `in-flight` / `spawn-failed` / `unwritable` 补上如实提示。
- 🔴 **`examples/` 两个文件是 1.x 内容,却随 2.x 进了 npm 包**:`cordis.patch.posix.yml` 教人把整行换成
`command: rea`、`cordis.patch.with-env.yml` 给的是 `cmd.exe /c rea` 的 1.x 行——对 2.x 全错(2.x 的行是
跨平台的 `process.execPath` + bundle shim,照抄会整行绕过 bundle)。两份都已是孤儿(README 不再引用),
故从 `main` **删除**,并同步 `package.json files[]` 与 `validate.yml` 的载荷/YAML/机器路径三项检查
(v1 分支保留它们,在 1.x 语境里是对的)。npm 载荷由此 21 → 19 个文件。
- 🟡 **`--offline` 管不住 `reaAgents`**:`provisionReaAgents()` 里 0 处 offline 判断,`npm install` 永远
联网——与「`--offline` is a promise, not a hint」直接矛盾。现在 `--offline` 且内置副本不可用时**直接
FAIL** 并说明原因(而可用的内置副本仍照常跳过)。
- 🟡 **npm 那一步没有代理/镜像兜底**:压缩包有 direct→proxy→PowerShell 三路,npm 只读环境变量。失败时
现在会打印这句提示,两份 README 的能力/注意里也写明 `HTTPS_PROXY` / `NPM_CONFIG_REGISTRY`。
- 🟡 **POSIX 缺 `unzip`/`xz` 必现失败**:Ghidra 全平台只有 zip,而 POSIX 解压原先只有 `unzip` 一条路。
现在 zip 依次尝试 `unzip` → `python3 -m zipfile` → **已供给 JDK 自带的 `jar`**(JDK 在 Ghidra 之前供给,
等于保底),tar 依次 `tar` → `python3`;三者皆无时打印明确的「no zip extractor available」而不是干巴巴的
「extraction failed」。
- 🟡 **manifest 的 arm64 注释是错的**:Google 的 linux/darwin platform-tools 都是 x86_64 二进制,
「The same archive serves x86-64 and arm64」不成立。三个条目改成如实描述(macOS 需 Rosetta 2、
linux-arm64 上 probe 预期失败并被报为可选组件不可用)。
- 🟡 **`docs/bundle-design.md` 与实现漂移**:§3 的「row points at `<plugin>/bin/rea-shim.mjs`」改成
「按 `DSH_HOME` 算出 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`(镜像副本)」;§7 的
`vendor/rea-agents` 改成 `vendor/node_modules/rea-agents`。
- 🟢 **`release.yml` 的 `workflow_dispatch` 可绕过 Release 直接发 npm**:publish job 现在先断言
`v<package.json version>` 的 tag 在 origin 上存在、且该 Release 挂着版本无关的 `dsh-plugin-rea.tgz`
资产,否则拒绝发布(否则 npm 与市场兜底链接会岔开)。
- 🟢 **数值环境变量会变成 NaN**:`REA_BUNDLE_FETCH_RETRIES=abc` → `Math.max(1, NaN)` → 报
「gave up after NaN attempt(s)」;NaN 的 socket 超时等价于 0(立即超时)。现在非正有限值一律回落到默认,
且启动日志新增一行 `limits connect=… idle=… retries=…`,让覆盖生效可见。
- 🟢 注释与整洁:`downloadViaPowerShell` 的文档说自己是 fallback,实际在 Windows 上是主路线(已改口径);
去掉 setup.mjs 里 3 处对已静态导入成员的重复 `await import("node:fs")`(`readdirSync` 补进静态导入)。
## 2.0.2 — 2026-10-15
**仅文档,无行为变更。**
- 两份 README 的**末尾**(许可证之前)新增一节,说明**什么时候该用 1.x 线**以及它的安装方式
(`dsh-plugin-rea@1.0.6` 或 `github:czcdr/dsh-plugin-rea#v1.0.6`)—— 此前 1.x 只出现在开头的版本说明与升级章节里。
## 2.0.1 — 2026-10-15
**仅文档与版式,无行为变更。**
- 两份 README 的**语言行正下方**现在先说明「这是哪个版本、1.x 与 2.x 各做什么」——此前版本说明排在
首屏那张大截图之后,npm 页面首屏看不到,容易被误判成「README 没改」;对比表与升级章节位置不变。
- `docs/bundle-design.md` 的措辞跟上「`main` 已经是一体包线」这一事实(原先写的是「本分支新增、
`main` 不变」)。
- 为什么纯文档也发一个补丁版:npm 的包页渲染的是**发布进 tarball 的 README**,不重新发布就更新不了首屏。
## 2.0.0 — 2026-10-15
**一体包线的首个正式版**(此前的 `2.0.0-bundle.1` / `2.0.0-bundle.2` 是它的两个预发布)。
+93 -13
View File
@@ -4,17 +4,17 @@
**English** · [简体中文](README.zh-CN.md)
> **Which version is this? 2.x — and `main` is it.** 2.x provisions the runtime itself (Node, the REA CLI, a
> JDK, Ghidra) and then wires the row: install the plugin, run `scripts/setup.mjs` once, restart dsh.
> **1.x** is the older configuration-only adapter — it expects `rea` on PATH and Ghidra installed by you —
> kept on the [`v1` branch](https://github.com/czcdr/dsh-plugin-rea/tree/v1) and on npm as `[email protected]`.
> Full comparison: [Two lines: 1.x and 2.x](#two-lines-1x-and-2x) · upgrading: [Upgrading from 1.x](#upgrading-from-1x).
Brings the upstream **[REA — Reverse Engineer Anything](https://github.com/morluto/rea)** MCP server into
**DeepSeek Harness** (dsh), so its tools show up in a session as `mcp__rea__*`.
![dsh-plugin-rea: the session calls the official MCP client row, which launches the bundle's shim; the shim starts the provisioned REA and the Evidence comes back](assets/screenshot-1.png)
> **This is the 2.x line, and `main` is it.** The all-in-one variant: it provisions the runtime (Node, the REA
> CLI, a JDK, Ghidra) and then wires the row itself. The original configuration-only adapter — 1.x, which
> assumed you had installed Node, `rea-agents` and Ghidra yourself — is preserved on the
> [`v1` branch](https://github.com/czcdr/dsh-plugin-rea/tree/v1) and on npm as `[email protected]`.
> The two lines are compared in [Two lines: 1.x and 2.x](#two-lines-1x-and-2x).
## What this is
A **Cordis bundle with a provisioning layer**. `package.json` declares `dsh.bundle.patch`, and that patch
@@ -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
@@ -168,6 +176,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 when it can see that file — a custom `DSH_HOME` is hidden
from an MCP child, so it falls back to `~/.dsh` there — and `node bin/rea-shim.mjs --shim-status`
lists it under `duplicateRegistration`.
## Verify
Ask for a `mcp__rea__*` call — `binary_session` reports the engine checks
@@ -179,11 +208,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 +221,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 +232,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:
@@ -248,6 +312,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
+85 -11
View File
@@ -4,16 +4,17 @@
[English](README.md) · **简体中文**
> **这是哪个版本?2.x —— 而 `main` 就是它。** 2.x 自己准备运行时(Node、REA CLI、JDK、Ghidra)再把那一行接好:
> 装插件 → 跑一次 `scripts/setup.mjs` → 重启 dsh。
> **1.x** 是更早的纯配置适配器 —— 它假设 `rea` 在 PATH 上、Ghidra 由你自己装好 —— 保留在
> [`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),npm 上仍是 `[email protected]`。
> 完整对比:[两条线:1.x 与 2.x](#两条线1x-与-2x) · 升级:[从 1.x 升级](#从-1x-升级)。
把上游 **[REA — Reverse Engineer Anything](https://github.com/morluto/rea)** 的 MCP 服务接入
**DeepSeek Harness**(dsh),让它的工具在会话里以 `mcp__rea__*` 出现。
![dsh-plugin-rea:会话经官方 MCP 客户端行启动本包供给的 shim,再由 shim 拉起 bundle 内置的 REA,最后把 Evidence 带回会话](assets/screenshot-1.png)
> **这是 2.x 线,而 `main` 就是它。** 一体包版本:它会自己准备运行时(Node、REA CLI、JDK、Ghidra),
> 再把那一行接好。原本的纯配置适配器(1.x —— 假设 Node、`rea-agents`、Ghidra 都是你自己装好的)保留在
> [`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),npm 上仍是 `[email protected]`。
> 两条线的对比见[两条线:1.x 与 2.x](#两条线1x-与-2x)。
## 这是什么
一个**带供给层的 Cordis bundle**。`package.json` 声明 `dsh.bundle.patch`,该 patch 插入一行官方 MCP 客户端
@@ -76,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 机器上:
@@ -103,6 +107,10 @@ 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` 是刻意的)。先手动跑一次
@@ -158,6 +166,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 在能看到该文件时会在启动时提示冲突 —— MCP 子进程里拿不到自定义 `DSH_HOME`,那种情况下它会
退回 `~/.dsh`;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
## 验证
随便发一个 `mcp__rea__*` 调用 —— `binary_session` 会报引擎各项检查
@@ -169,11 +196,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 |
@@ -182,7 +209,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` 知道。
@@ -191,21 +218,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.4**,在健康的 profile 上连续失败了三次:
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
这条消息与 tarball 是否完整无关。市场在每次更新时都会问一句:新构建的 `package.json` 是否指向一个真实存在的
入口文件(`dshmarket` 里的 `hasLoadableEntry`);答案是「否」,就把这次更新判为坏构建并回滚。bundle 本来允许
自己没有入口 —— 前提是它**挂载**的某个包有入口;而本包挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
构建,不在 `node_modules` 里,于是这项检查要看的每个位置都是空的。1.x 与 2.0.x 的清单又根本没有声明 `main`,
所以答案必然是「否」,哪怕 profile 一直是好的;紧接着的回滚也因此被报成「无法验证」。实际上 profile 没有被
破坏,只是这次更新没有生效。
2.0.5 声明了 `main: ./index.mjs`。那是一个真实存在、刻意留空的 Cordis 插件:不注册任何行、不启动任何进程、
不碰任何状态(真正起作用的行仍由 `cordis.patch.yml` 插入),它的作用只是让「这个包能不能加载」有一个诚实的
答案,并让 `import 'dsh-plugin-rea'` 能解析。还停在旧版本时,更新到 `>=2.0.5` 即可;若市场在 2.0.5 之前就拒绝,
就直接安装 `[email protected]`(桌面端:在插件管理器里装),不要原地更新。
### 从 1.x 升级
装上 2.x 后,**先跑一次供给**,再重启 dsh:
@@ -232,6 +291,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

+169 -17
View File
@@ -15,7 +15,7 @@
* and the message is the operator's remediation.
*/
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
@@ -93,6 +93,77 @@ 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. The mirrored shim
* cannot see a custom DSH_HOME (the harness strips DSH_* from an MCP child), so
* this falls back to `~/.dsh` like the rest of the bundle; the row has the same
* limitation.
*/
function foreignReaRegistration() {
const patch = path.join(dshHome(), "cordis.patch.yml");
let text;
try {
text = readFileSync(patch, "utf8");
} catch {
return null;
}
const unquote = (value) => value.replace(/^(['"])(.*)\1$/, "$2").trim();
// A row starts at `- id:`; the block runs to the next row, so a serverName can
// never be attributed to a row that does not contain it.
for (const block of text.split(/\r?\n(?=\s*-\s+id:\s*)/)) {
const id = /^\s*-\s+id:\s*(.+?)\s*$/m.exec(block);
const server = /^\s+serverName:\s*(.+?)\s*$/m.exec(block);
if (!id || !server || unquote(server[1]) !== "rea") continue;
const rowId = unquote(id[1]);
if (rowId !== "mcp-rea") return { patch, id: rowId };
}
return null;
}
/**
* The provisioning lock is a small JSON record: { state, pid, at, exit? }.
* `state: "running"` is written when a run is started (setup's own pid once it
* exists), and the failing run rewrites it as `state: "failed"` — it is kept on
* purpose so a broken setup cannot be re-downloaded in a loop. A legacy
* plain-number lock is still honoured as "running".
*/
const STALE_LOCK_MS = 6 * 60 * 60 * 1000;
function readLock(lock) {
try {
const raw = readFileSync(lock, "utf8").trim();
if (raw.startsWith("{")) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed;
}
const at = Number(raw);
return Number.isFinite(at) ? { state: "running", at } : null;
} catch {
return null;
}
}
/** Is the process recorded in the lock still alive? null means "cannot tell". */
function pidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return null;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === "ESRCH" ? false : null;
}
}
/**
* On first run there is nothing to launch. Provisioning is a long download, so
* it is started detached: the harness may kill this row (or the whole session)
@@ -100,34 +171,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 +240,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 };
}
}
@@ -195,8 +308,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" };
@@ -279,6 +397,18 @@ function main() {
const root = bundleRoot();
const loaded = readBundleConfig(root);
// Upstream can add a second row for the same serverName; the shim cannot stop
// DSH from loading both rows, but saying so is better than doubled tools with
// no stated cause. The status report carries the same fact for diagnostics.
const duplicate = foreignReaRegistration();
if (duplicate && !argv.includes("--shim-status")) {
process.stderr.write(
`\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
` ${duplicate.patch} also registers serverName "rea" (id "${duplicate.id}").\n` +
` This bundle's row is id "mcp-rea" with the same serverName; DSH rejects the later\n` +
` entry as a duplicate server name, so only one survives. Keep exactly one of them.\n`,
);
}
if (argv.includes("--shim-status")) {
const report = {
@@ -288,6 +418,7 @@ function main() {
reason: loaded.ok ? undefined : loaded.code,
autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
};
if (duplicate) report.duplicateRegistration = duplicate;
if (loaded.ok) {
report.paths = loaded.config.paths ?? {};
report.versions = loaded.config.versions ?? {};
@@ -302,10 +433,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")}"\n`,
);
}
return fail(message, loaded.detail);
+92 -17
View File
@@ -69,13 +69,40 @@ 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
if (-not $bundleRootEarly) {
$homeEarly = $env:DSH_HOME
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
$bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
}
$bundleState = $null
$bundleJsonEarly = Join-Path $bundleRootEarly 'bundle.json'
if (Test-Path $bundleJsonEarly) {
try { $bundleState = Get-Content $bundleJsonEarly -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $bundleState = $null }
}
$bundleReaPackageDir = $null
$bundleReaEntry = $null
if ($bundleState -and $bundleState.paths.reaEntry) {
$bundleReaEntry = [string]$bundleState.paths.reaEntry
# <vendor>\node_modules\rea-agents\scripts\rea.mjs -> <vendor>\node_modules\rea-agents
$bundleReaPackageDir = Split-Path -Parent (Split-Path -Parent $bundleReaEntry)
}
if (-not $ReaPackageDir) {
if ($bundleReaPackageDir -and (Test-Path (Join-Path $bundleReaPackageDir 'package.json'))) {
$ReaPackageDir = $bundleReaPackageDir
} else {
$npmRoot = $null
if (Get-Command npm -ErrorAction SilentlyContinue) {
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
}
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
}
}
$reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaPackageDir)
if (-not $ProfileDir) {
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
else {
@@ -94,11 +121,21 @@ if (Test-Path $pkgFile) {
$installed = $pkg.version; $engines = $pkg.engines.node
$binPath = ($pkg.bin.PSObject.Properties | Select-Object -First 1).Value
if ($binPath) { $reaEntry = Join-Path $ReaPackageDir ($binPath -replace '/', '\') }
Ok "本地已安装: $installed"
Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
if ($reaFromBundle) {
Ok "一体包内置(vendored): $installed"
Info "位置: $ReaPackageDir"
Info "入口(bin): $binPath"
} else {
Ok "本地已安装: $installed"
Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
}
if ($engines) { Info "Node 要求: $engines" }
} catch { Bad "package.json 解析失败: $($_.Exception.Message)" }
} else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
} else {
if ($reaFromBundle) { Bad "找不到 $pkgFile —— 内置副本不完整?重跑 setup.mjs" }
elseif ($bundleReaPackageDir) { Bad "找不到 $pkgFile —— 一体包内置副本也缺($bundleReaPackageDir);记录里没有 reaEntry" }
else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
}
$latest = $null
foreach ($r in @($Registry, 'https://registry.npmjs.org')) {
@@ -110,7 +147,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 +180,14 @@ if (Get-Command node -ErrorAction SilentlyContinue) {
# ------------------------------------------------------------ Ghidra 引擎
Head 'Ghidra 引擎'
$gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User')
# bundle.json 是 2.x 的真源:shim 只在 MCP 子进程里设 GHIDRA_INSTALL_DIR,用户级
# 变量是 1.x 的写法,没手工设过的机器会被误报"未设置"。
$gd = $null
if ($bundleState -and $bundleState.paths.ghidra) {
$bundleGhidra = [string]$bundleState.paths.ghidra
if (Test-Path $bundleGhidra) { $gd = $bundleGhidra }
}
if (-not $gd) { $gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User') }
if (-not $gd) { $gd = $env:GHIDRA_INSTALL_DIR }
if ($gd -and (Test-Path $gd)) {
Ok "GHIDRA_INSTALL_DIR = $gd"
@@ -162,11 +210,28 @@ try {
# --------------------------------------------------------- REA 引擎自检
Head 'REA 引擎自检(rea doctor)'
$env:GHIDRA_INSTALL_DIR = $gd
# 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
# 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
$reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
# 2.x:优先用 bundle 内置的 CLI 和它自己的 Node。PATH 上的 rea 可能是另一个,
# 甚至完全不在 PATH —— 那会让整段自检被静默跳过。这正是 1.x 口径的残留。
$reaCmd = $null
$reaArgs = @()
if ($bundleReaEntry -and (Test-Path $bundleReaEntry)) {
$bundleNode = $null
if ($bundleState -and $bundleState.paths.node) {
$candidateNode = [string]$bundleState.paths.node
if (Test-Path $candidateNode) { $bundleNode = $candidateNode }
}
if (-not $bundleNode) { $bundleNode = (Get-Command node -ErrorAction SilentlyContinue).Source }
if ($bundleNode) {
$reaCmd = $bundleNode; $reaArgs = @($bundleReaEntry)
Info '使用一体包内置 REA(node <bundle 入口>),不依赖 PATH 上的 rea'
}
}
if (-not $reaCmd) {
# 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
# 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
$reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
}
if (-not $reaCmd) {
if ($reaEntry -and (Test-Path $reaEntry) -and (Get-Command node -ErrorAction SilentlyContinue)) {
$reaCmd = 'node'; $reaArgs = @($reaEntry)
@@ -247,6 +312,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,13 +321,21 @@ 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 ', '))
}
+12 -10
View File
@@ -3,9 +3,10 @@
Goal: a fresh machine installs **one DSH plugin** and gets a working `mcp__rea__*`
tool surface, without first installing Node, REA, a JDK, or Ghidra by hand.
`main` keeps the original **config-only adapter** (a single MCP row pointing at a
`rea` the user installed themselves). This branch adds the self-provisioning
variant next to it. Nothing on `main` changes.
`main` **is** this variant: the self-provisioning one-plugin bundle. The earlier
**config-only adapter** (a single MCP row pointing at a `rea` the user installed
themselves) is preserved on the [`v1` branch](https://github.com/czcdr/dsh-plugin-rea/tree/v1)
and on npm as `[email protected]`; nothing here changes that line.
---
@@ -30,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 |
@@ -52,9 +53,10 @@ Three responsibilities, kept strictly apart:
the component manifest, downloads what is missing into a cache, verifies
checksums, unpacks, runs the readiness probe, and writes `bundle.json`.
Idempotent and re-runnable; `--reuse` adopts pre-existing installs.
3. **Patch (wiring, published).** `cordis.patch.yml` from this branch, carrying
**no machine path**: the row points at `<plugin>/bin/rea-shim.mjs` and the
shim is responsible for the rest.
3. **Patch (wiring, published).** `cordis.patch.yml` on `main`, carrying
**no machine path**: the row computes `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`
at load time from `DSH_HOME` alone, and that mirrored shim is responsible for the
rest. (The checkout's own copy is what a human runs; the row never points at it.)
The published plugin therefore stays portable, while the machine-specific truth
lives in one small JSON file under the DSH home.
@@ -160,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 |
@@ -173,7 +175,7 @@ Ghidra itself is **not** redistributed inside the plugin package: the release is
~570 MB, which no npm package can carry. It is fetched from the official release
and checksum-verified, or adopted from an existing install with `--reuse`.
## 8. Honest limits of this branch
## 8. Honest limits of this approach
- npm's per-package limits (unpacked/compressed size) rule out shipping the JDK
and Ghidra inside the tarball. "One plugin install" therefore means *one
-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."
}
}
}
+4 -3
View File
@@ -1,8 +1,9 @@
{
"name": "dsh-plugin-rea",
"version": "2.0.0",
"version": "2.0.5",
"description": "One-plugin bundle: provisions Node/REA/JDK/Ghidra for you, then registers the upstream REA MCP server as mcp__rea__* tools. · 一体包:自动准备 Node/REA/JDK/Ghidra,并把上游 REA 的 MCP 服务接入 DeepSeek Harness。",
"type": "module",
"main": "./index.mjs",
"license": "MIT",
"author": "czcdr",
"homepage": "https://github.com/czcdr/dsh-plugin-rea",
@@ -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": {
+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;
+37 -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
@@ -39,8 +39,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 +88,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 +121,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 }
}
+352 -87
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),
};
@@ -1417,6 +1667,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 +1705,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
+788 -5
View File
@@ -11,12 +11,14 @@
* Runs with `node --test`. No network, no filesystem writes outside a temp dir.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
/* ------------------------------------------------- probe token substitution */
@@ -319,7 +321,17 @@ function runSetupNodeOnly(setupPath, root, env) {
});
}
test("--reuse still adopts a compatible node", () => {
test("--reuse still adopts a compatible node", (t) => {
// The candidate is the host's own interpreter, so this test only means anything
// where the host satisfies REA's engines: on a Node 20 runner it fails for a
// reason that has nothing to do with the reuse check (the check is doing its
// job), while CI's pinned Node 22 never notices.
const [major, minor] = process.versions.node.split(".").map(Number);
const compatible = (major === 22 && minor >= 19) || (major === 24 && minor >= 11) || major >= 26;
if (!compatible) {
t.skip(`host Node ${process.versions.node} is outside REA's engines; the candidate would be rejected on purpose`);
return;
}
const root = mkdtempSync(path.join(tmpdir(), "rea-node-ok-"));
try {
const run = runSetupNodeOnly(SETUP_SOURCE, root, { REA_BUNDLE_NODE: process.execPath });
@@ -395,7 +407,7 @@ test("an unavailable optional component is reported, not fatal", () => {
mkdirSync(stub, { recursive: true });
writeFileSync(
path.join(stub, "rea.mjs"),
'if (process.argv.includes("--version")) { console.log("6.4.0"); } else { console.log(JSON.stringify({ checks: [], tools: [] })); }\n',
'if (process.argv.includes("--version")) { console.log("6.5.0"); } else { console.log(JSON.stringify({ checks: [], tools: [] })); }\n',
);
const run = spawnSync(process.execPath, [SETUP_SOURCE, "--offline", "--only", "androidPlatformTools", "--root", root], {
@@ -416,3 +428,774 @@ test("an unavailable optional component is reported, not fatal", () => {
rmSync(root, { recursive: true, force: true });
}
});
/* =========================== this round's regressions =========================
*
* Each test below pins a defect that was verified in the code before it was
* changed. They run setup.mjs itself (never a mirror of it) against a throwaway
* plugin tree, so the whole wiring — manifest → download → place → wire — is the
* real one. Nothing here reaches the network: a component is either satisfied
* from a primed cache, or its download is *supposed* to return before opening a
* socket.
*/
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const REPO_SHIM = path.join(REPO_ROOT, "bin", "rea-shim.mjs");
/** A throwaway plugin tree: setup.mjs, the real manifest, and the shim. */
function makePluginTree(root, mutateManifest) {
const plugin = path.join(root, "plugin");
mkdirSync(path.join(plugin, "scripts"), { recursive: true });
mkdirSync(path.join(plugin, "manifest"), { recursive: true });
mkdirSync(path.join(plugin, "bin"), { recursive: true });
copyFileSync(SETUP_SOURCE, path.join(plugin, "scripts", "setup.mjs"));
copyFileSync(REPO_SHIM, path.join(plugin, "bin", "rea-shim.mjs"));
const manifest = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8"));
if (mutateManifest) mutateManifest(manifest);
writeFileSync(path.join(plugin, "manifest", "bundle.manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
return path.join(plugin, "scripts", "setup.mjs");
}
/** A `file`-kind component, used to replace a real one in a test manifest. */
function testComponent(url, payload, extra = {}) {
return {
description: "test vehicle",
kind: "file",
license: "test",
requires: extra.requires ?? [],
optional: extra.optional ?? false,
probe: extra.probe ?? {},
entries: {
all: [
{
id: "payload",
version: "1",
url,
format: "file",
size: payload.length,
sha256: createHash("sha256").update(payload).digest("hex"),
moveTo: extra.moveTo ?? "payload",
saveAs: extra.saveAs ?? "payload.bin",
...(extra.executable ? { executable: true } : {}),
},
],
},
};
}
/** A stub vendored CLI, so the probe/shim stages have something to run. */
function addReaStub(bundleRoot, version = "6.5.0") {
const dir = path.join(bundleRoot, "vendor", "node_modules", "rea-agents", "scripts");
mkdirSync(dir, { recursive: true });
const identity = JSON.stringify({ cli_package_version: version, catalog: { counts: { mcp_tools: 206 } } });
writeFileSync(
path.join(dir, "rea.mjs"),
'if (process.argv.includes("--version")) { console.log(' + JSON.stringify(version) + '); }\n' +
"else { console.log(JSON.stringify({ healthy: true, checks: [], identity: " + identity + " })); }\n",
);
}
function runToCompletion(command, args, options) {
return new Promise((resolve) => {
const child = spawn(command, args, options);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
child.on("error", (error) => resolve({ status: -1, stdout, stderr: String(error) }));
child.on("close", (code) => resolve({ status: code, stdout, stderr }));
});
}
async function waitFor(predicate, timeoutMs = 8000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (predicate()) return true;
if (Date.now() > deadline) return false;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
/* ------------------------------- --only keeps what it did not touch -------- */
test("--only keeps the records of the components it did not touch", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-only-keep-"));
try {
const bundle = path.join(root, "bundle");
mkdirSync(bundle, { recursive: true });
const jdkHome = path.join(root, "jdk-home");
mkdirSync(jdkHome, { recursive: true });
// A provisioned machine: the JDK and Node are recorded, and only adb is being
// topped up. Writing bundle.json from this run's results alone erased the
// other two, so the shim stopped exporting JAVA_HOME/GHIDRA_INSTALL_DIR.
writeFileSync(
path.join(bundle, "bundle.json"),
JSON.stringify(
{
schema: 1,
paths: { node: process.execPath, jdk: jdkHome, ghidra: null, adb: null },
versions: { reaAgents: "6.5.0", node: process.versions.node, jdk: "21" },
components: {
node: { path: process.execPath, version: process.versions.node },
jdk: { path: jdkHome, version: "21" },
},
},
null,
2,
) + "\n",
);
addReaStub(bundle);
const setup = makePluginTree(root, (manifest) => {
manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", Buffer.from("adb"), { optional: true });
});
const run = spawnSync(process.execPath, [setup, "--offline", "--only", "androidPlatformTools", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
const written = JSON.parse(readFileSync(path.join(bundle, "bundle.json"), "utf8"));
assert.equal(written.components.jdk.path, jdkHome, "the jdk record must survive an --only run");
assert.equal(written.paths.jdk, jdkHome, "and so must the path the shim exports as JAVA_HOME");
assert.equal(written.components.node.path, process.execPath);
assert.equal(written.paths.node, process.execPath);
// The component that did run failed (offline), and stays unavailable: the
// merge must not invent a path for it.
assert.equal(written.paths.adb, null);
assert.equal(written.components.androidPlatformTools.unavailable, true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("--only satisfies a requirement from the record instead of skipping the check", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-only-requires-"));
try {
const bundle = path.join(root, "bundle");
mkdirSync(bundle, { recursive: true });
const jdkHome = path.join(root, "jdk-home");
mkdirSync(jdkHome, { recursive: true });
const setup = makePluginTree(root, () => {});
const args = (extra) => [setup, "--reuse", "--dry-run", "--only", "apktool", "--root", bundle, ...extra];
// JAVA_HOME is also a way "the machine" answers this requirement, so it is
// cleared: the first case needs the requirement to be genuinely unavailable.
const env = { ...process.env, JAVA_HOME: "" };
// No record at all: the requirement is genuinely unavailable, and that must be
// reported exactly as a full run reports it.
const missing = spawnSync(process.execPath, args([]), { encoding: "utf8", timeout: 120000, env });
assert.match(missing.stdout, /required component "jdk" is unavailable/);
// With a recorded JDK the requirement is answered by the record, so apktool
// plans instead of failing on a vendor/jdk that --only never provisions.
writeFileSync(
path.join(bundle, "bundle.json"),
JSON.stringify({ schema: 1, paths: { jdk: jdkHome }, versions: { jdk: "21" }, components: { jdk: { path: jdkHome, version: "21" } } }, null, 2) + "\n",
);
const recorded = spawnSync(process.execPath, args([]), { encoding: "utf8", timeout: 120000, env });
assert.equal(recorded.status, 0, `${recorded.stdout}\n${recorded.stderr}`);
assert.doesNotMatch(recorded.stdout, /required component "jdk" is unavailable/);
assert.match(recorded.stdout, /\[plan\] apktool/);
// And `--only reaAgents` on a fresh root must still work: the node it needs is
// the interpreter running setup, which is what provisionReaAgents already uses.
const fresh = path.join(root, "fresh");
const reaOnly = spawnSync(process.execPath, [setup, "--dry-run", "--only", "reaAgents", "--root", fresh], { encoding: "utf8", timeout: 120000, env });
assert.equal(reaOnly.status, 0, `${reaOnly.stdout}\n${reaOnly.stderr}`);
assert.doesNotMatch(reaOnly.stdout, /required component "node" is unavailable/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------------------- a complete .part is finished work */
test("a complete .part is verified and kept instead of re-requested", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-part-complete-"));
try {
const bundle = path.join(root, "bundle");
mkdirSync(path.join(bundle, "cache"), { recursive: true });
const payload = Buffer.from("the whole file, already on disk\n");
// The URL points at a closed port on purpose: the fixed code must complete
// without opening a socket. Before the fix it sent `Range: bytes=<size>-`,
// which a real server answers with 416, and four attempts later it reported a
// download that was already finished as failed.
const setup = makePluginTree(root, (manifest) => {
manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", payload);
});
addReaStub(bundle);
writeFileSync(path.join(bundle, "cache", "payload.bin.part"), payload);
const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
assert.match(run.stdout, /was already complete; verified and kept/);
assert.deepEqual(readFileSync(path.join(bundle, "vendor", "payload", "payload.bin")), payload);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------------------------- the PowerShell route resumes */
test("the PowerShell route resumes from the bytes already on disk", async (t) => {
if (process.platform !== "win32") {
// The Node route pins TLS (`httpsRequest`) whatever the URL scheme says, so a
// plain-HTTP fixture can only exercise the PowerShell route — which is also the
// Windows default and the one the resume defect was in.
t.skip("the PowerShell download route only exists on Windows");
return;
}
const payload = Buffer.alloc(1024 * 1024, 11);
const alreadyHave = 256 * 1024;
const seen = [];
const server = createServer((req, res) => {
req.on("error", () => {});
res.on("error", () => {});
const range = req.headers.range ?? null;
seen.push(range);
const start = range ? Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0) : 0;
if (range && start >= payload.length) {
res.writeHead(416);
res.end();
return;
}
const slice = payload.subarray(start);
const headers = { "content-type": "application/octet-stream", "content-length": String(slice.length) };
if (start > 0) headers["content-range"] = `bytes ${start}-${payload.length - 1}/${payload.length}`;
res.writeHead(start > 0 ? 206 : 200, headers);
res.end(slice);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const root = mkdtempSync(path.join(tmpdir(), "rea-ps-resume-"));
try {
const bundle = path.join(root, "bundle");
mkdirSync(path.join(bundle, "cache"), { recursive: true });
const setup = makePluginTree(root, (manifest) => {
manifest.components.androidPlatformTools = testComponent(`http://127.0.0.1:${port}/payload.bin`, payload);
});
addReaStub(bundle);
writeFileSync(path.join(bundle, "cache", "payload.bin.part"), payload.subarray(0, alreadyHave));
const run = await runToCompletion(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
assert.ok(
seen.some((header) => header && Number(/bytes=(\d+)-/.exec(header)?.[1]) === alreadyHave),
`expected a resume request starting at ${alreadyHave}; saw ${JSON.stringify(seen)}`,
);
assert.deepEqual(readFileSync(path.join(bundle, "vendor", "payload", "payload.bin")), payload);
} finally {
await new Promise((resolve) => server.close(resolve));
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------------------------- a wrapper must be executable */
test("a wrapper marked executable lands with the exec bit on POSIX", (t) => {
if (process.platform === "win32") {
t.skip("POSIX exec bits do not exist on Windows; the flag is honoured but meaningless there");
return;
}
const root = mkdtempSync(path.join(tmpdir(), "rea-exec-bit-"));
try {
const bundle = path.join(root, "bundle");
mkdirSync(path.join(bundle, "cache"), { recursive: true });
const marked = Buffer.from("#!/bin/sh\necho wrapper\n");
const plain = Buffer.from("not a script\n");
const setup = makePluginTree(root, (manifest) => {
const component = testComponent("http://127.0.0.1:9/wrapper.sh", marked, { executable: true, saveAs: "wrapper.sh", moveTo: "tool" });
component.entries.all.push({
id: "plain",
version: "1",
url: "http://127.0.0.1:9/plain.sh",
format: "file",
size: plain.length,
sha256: createHash("sha256").update(plain).digest("hex"),
moveTo: "tool",
saveAs: "plain.sh",
});
manifest.components.androidPlatformTools = component;
});
addReaStub(bundle);
// Prime the cache so no socket is opened and the mode is the only variable.
writeFileSync(path.join(bundle, "cache", "wrapper.sh"), marked);
writeFileSync(path.join(bundle, "cache", "plain.sh"), plain);
const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
const wrapperMode = statSync(path.join(bundle, "vendor", "tool", "wrapper.sh")).mode;
const plainMode = statSync(path.join(bundle, "vendor", "tool", "plain.sh")).mode;
assert.notEqual(wrapperMode & 0o111, 0, `the marked wrapper must be executable, mode ${wrapperMode.toString(8)}`);
assert.equal(plainMode & 0o111, 0, "an unmarked file must keep the mode the archive gave it");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------------------- a file probe's expect is honoured */
test("a file probe honours expect.contains", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-file-probe-"));
try {
const make = (content) => {
const bundle = path.join(root, `bundle-${content.length}`);
mkdirSync(path.join(bundle, "cache"), { recursive: true });
const payload = Buffer.from(content);
const component = testComponent("http://127.0.0.1:9/marker.txt", payload, { saveAs: "marker.txt", moveTo: "probe" });
component.probe = { file: "{vendor}/probe/marker.txt", expect: { contains: "application.version=" } };
const setup = makePluginTree(path.join(root, `tree-${content.length}`), (manifest) => {
manifest.components.androidPlatformTools = component;
});
addReaStub(bundle);
writeFileSync(path.join(bundle, "cache", "marker.txt"), payload);
return spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
};
const ok = make("application.version=12.1.4\n");
assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
// The file exists either way; only its content decides. Before the fix the
// `expect` on a file probe was dead configuration and this case passed.
const wrong = make("this is not a Ghidra properties file\n");
assert.equal(wrong.status, 1, `${wrong.stdout}\n${wrong.stderr}`);
assert.match(wrong.stdout, /does not contain "application\.version="/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------------- a reused engine must pass its own probe */
test("a reused component must pass the probe a provisioned copy would face", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-reuse-probe-"));
try {
const install = path.join(root, "ghidra");
mkdirSync(path.join(install, "support"), { recursive: true });
mkdirSync(path.join(install, "Ghidra"), { recursive: true });
writeFileSync(path.join(install, "support", process.platform === "win32" ? "analyzeHeadless.bat" : "analyzeHeadless"), "");
const properties = path.join(install, "Ghidra", "application.properties");
const setup = makePluginTree(root, (manifest) => {
// The real manifest needs a JDK for ghidra; this test is about the reuse
// probe, so the requirement is removed rather than faked.
manifest.components.ghidra.requires = [];
});
const bundle = path.join(root, "bundle");
const args = ["--reuse", "--dry-run", "--only", "ghidra", "--root", bundle];
const env = { ...process.env, GHIDRA_INSTALL_DIR: install };
writeFileSync(properties, "application.version=12.1.4\n");
const good = spawnSync(process.execPath, [setup, ...args], { encoding: "utf8", env, timeout: 120000 });
assert.equal(good.status, 0, `${good.stdout}\n${good.stderr}`);
assert.match(good.stdout, /reusing existing ghidra from GHIDRA_INSTALL_DIR/);
// Adoption used to test only that analyzeHeadless exists, so any directory
// shaped like a Ghidra install was recorded and the failure surfaced inside REA.
writeFileSync(properties, "not a Ghidra properties file\n");
const bad = spawnSync(process.execPath, [setup, ...args], { encoding: "utf8", env, timeout: 120000 });
assert.doesNotMatch(bad.stdout, /reusing existing ghidra/);
assert.match(bad.stdout, /not reusing GHIDRA_INSTALL_DIR ghidra/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------------------------------------- the shim's lock */
function makeBundleTree(root, { marker, lock }) {
const bundle = path.join(root, "bundle");
mkdirSync(path.join(bundle, "bin"), { recursive: true });
mkdirSync(path.join(bundle, "scripts"), { recursive: true });
mkdirSync(path.join(bundle, "manifest"), { recursive: true });
copyFileSync(REPO_SHIM, path.join(bundle, "bin", "rea-shim.mjs"));
writeFileSync(path.join(bundle, "scripts", "setup.mjs"), 'import { writeFileSync } from "node:fs";\nwriteFileSync(' + JSON.stringify(marker) + ', "ran");\n');
writeFileSync(path.join(bundle, "manifest", "bundle.manifest.json"), "{}\n");
if (lock !== undefined) writeFileSync(path.join(bundle, "setup.lock"), lock);
return bundle;
}
function runShim(bundle, args, extraEnv = {}) {
// A fresh home keeps the duplicate-registration check from reading this
// machine's real ~/.dsh/cordis.patch.yml and making the suite depend on it.
const env = {
...process.env,
DSH_HOME: path.join(path.dirname(bundle), "dsh-home"),
REA_BUNDLE_ROOT: bundle,
...extraEnv,
};
delete env.REA_BUNDLE_AUTO_PROVISION;
mkdirSync(env.DSH_HOME, { recursive: true });
return spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), ...args], { encoding: "utf8", timeout: 60000, env });
}
test("a malformed bundle.json starts a provisioning run instead of failing forever", async () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-malformed-"));
try {
const marker = path.join(root, "setup-ran");
const bundle = makeBundleTree(root, { marker, lock: undefined });
writeFileSync(path.join(bundle, "bundle.json"), "{ this is not json");
const run = runShim(bundle, ["mcp"]);
assert.equal(run.status, 78, run.stderr);
assert.match(run.stderr, /provisioning started in the background/);
assert.ok(await waitFor(() => existsSync(marker)), "setup was never started for a malformed bundle.json");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a failed lock is reported as failed, not as provisioning in progress", async () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-failed-lock-"));
try {
const marker = path.join(root, "setup-ran");
const lock = JSON.stringify({ state: "failed", pid: 1, at: Date.now(), exit: 1 });
const bundle = makeBundleTree(root, { marker, lock });
// No bundle.json: the "missing" path, which used to print the "provisioning
// started" sentence for six hours while nothing was running at all.
const run = runShim(bundle, ["mcp"]);
assert.equal(run.status, 78, run.stderr);
assert.match(run.stderr, /the last provisioning attempt failed/);
assert.doesNotMatch(run.stderr, /provisioning started in the background/);
assert.equal(existsSync(marker), false, "a fresh failed lock must not trigger another run");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a lock whose holder is gone does not block provisioning", async () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-dead-lock-"));
try {
const marker = path.join(root, "setup-ran");
const dead = spawnSync(process.execPath, ["-e", ""]);
assert.ok(Number.isInteger(dead.pid), "could not obtain a pid to outlive");
const lock = JSON.stringify({ state: "running", pid: dead.pid, at: Date.now() });
const bundle = makeBundleTree(root, { marker, lock });
const run = runShim(bundle, ["mcp"]);
assert.equal(run.status, 78, run.stderr);
assert.match(run.stderr, /provisioning started in the background/);
assert.ok(await waitFor(() => existsSync(marker)), "a dead lock holder must not suppress the run");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("an expired lock is cleared instead of wedging provisioning forever", async () => {
const stale = Date.now() - 7 * 60 * 60 * 1000;
const dead = spawnSync(process.execPath, ["-e", ""]).pid;
const cases = {
"running lock with a dead holder": JSON.stringify({ state: "running", pid: dead, at: stale }),
"failed lock past the freshness window": JSON.stringify({ state: "failed", pid: dead, at: stale, exit: 1 }),
"legacy numeric lock": String(stale),
};
for (const [label, lock] of Object.entries(cases)) {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-stale-"));
try {
const marker = path.join(root, "setup-ran");
const bundle = makeBundleTree(root, { marker, lock });
const run = runShim(bundle, ["mcp"]);
assert.equal(run.status, 78, `${label}: ${run.stderr}`);
// Reproduced before the fix: all three cases were silent AND blocked, because
// the stale lock was never removed and the exclusive create then failed with
// EEXIST forever. A lock older than the freshness window is not evidence of a
// run, so it must be dropped and the attempt started again.
assert.match(run.stderr, /provisioning started in the background/, label);
assert.ok(await waitFor(() => existsSync(marker)), `${label}: setup was never started`);
} finally {
rmSync(root, { recursive: true, force: true });
}
}
});
test("a read-only bundle root reports unwritable instead of crashing on the stale lock", { skip: process.platform === "win32" ? "POSIX directory permissions only" : false }, () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-readonly-"));
try {
const stale = Date.now() - 7 * 60 * 60 * 1000;
const lock = JSON.stringify({ state: "running", pid: 999999, at: stale });
const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock });
chmodSync(bundle, 0o555);
try {
const run = runShim(bundle, ["mcp"], { DSH_HOME: path.join(root, "dsh-home") });
assert.equal(run.status, 78, run.stderr);
// Before the fix this was an uncaught EACCES stack trace and exit 1: the
// stale lock could not be removed, and nothing caught the throw.
assert.match(run.stderr, /provisioning could not be started \(unwritable\)/);
assert.doesNotMatch(run.stderr, /EACCES/);
} finally {
chmodSync(bundle, 0o755);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a second REA registration is reported instead of silently duplicated", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-duplicate-"));
try {
const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock: undefined });
const home = path.join(root, "dsh-home");
mkdirSync(home, { recursive: true });
writeFileSync(
path.join(home, "cordis.patch.yml"),
["- insert:", " - id: rea-mcp", " name: '@deepseek-ai/dsh-mcp-client'", " config:", " serverName: rea", " transport: stdio", ""].join("\n"),
);
const run = runShim(bundle, ["mcp"], { DSH_HOME: home });
assert.equal(run.status, 78, run.stderr);
assert.match(run.stderr, /duplicate REA registration/);
assert.match(run.stderr, /id "rea-mcp"/);
const status = spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), "--shim-status"], {
encoding: "utf8",
env: { ...process.env, DSH_HOME: home, REA_BUNDLE_ROOT: bundle },
});
assert.equal(JSON.parse(status.stdout).duplicateRegistration.id, "rea-mcp");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("this bundle's own row is not reported as a duplicate", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-own-row-"));
try {
const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock: undefined });
const home = path.join(root, "dsh-home");
mkdirSync(home, { recursive: true });
writeFileSync(
path.join(home, "cordis.patch.yml"),
["- insert:", " - id: mcp-rea", " name: '@deepseek-ai/dsh-mcp-client'", " config:", " serverName: rea", ""].join("\n"),
);
const run = runShim(bundle, ["mcp"], { DSH_HOME: home });
assert.equal(run.status, 78, run.stderr);
assert.doesNotMatch(run.stderr, /duplicate REA registration/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------- --offline is a promise, and npm is the one step
* that cannot keep it: it is an install, not a download. -------------------- */
test("--offline refuses the npm step instead of going online anyway", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-offline-rea-"));
try {
const setup = makePluginTree(root, () => {});
const bundle = path.join(root, "bundle");
const run = spawnSync(process.execPath, [setup, "--offline", "--only", "reaAgents", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
assert.match(run.stdout, /--offline and the vendored REA/);
// The other direction: a usable vendored copy is still accepted offline, so the
// flag does not turn into "reaAgents can never be provisioned".
const second = path.join(root, "bundle2");
mkdirSync(second, { recursive: true });
addReaStub(second);
const ok = spawnSync(process.execPath, [setup, "--offline", "--only", "reaAgents", "--root", second], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
assert.match(ok.stdout, /already vendored/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------- numeric overrides must not become NaN ------ */
test("a non-numeric numeric override falls back to the default", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-env-"));
try {
const setup = makePluginTree(root, () => {});
const run = (env) =>
spawnSync(process.execPath, [setup, "--dry-run", "--only", "node", "--root", path.join(root, "bundle")], {
encoding: "utf8",
timeout: 120000,
env: { ...process.env, ...env },
});
// `Number("abc")` is NaN, and NaN reached the socket layer as 0-ish behaviour;
// `REA_BUNDLE_FETCH_RETRIES=abc` once produced "gave up after NaN attempt(s)".
const bogus = run({ REA_BUNDLE_FETCH_RETRIES: "abc", REA_BUNDLE_IDLE_TIMEOUT_MS: "abc", REA_BUNDLE_CONNECT_TIMEOUT_MS: "abc" });
assert.equal(bogus.status, 0, `${bogus.stdout}\n${bogus.stderr}`);
assert.match(bogus.stdout, /retries=4\b/);
assert.match(bogus.stdout, /idle=120000ms/);
assert.doesNotMatch(bogus.stdout, /NaN/);
const explicit = run({ REA_BUNDLE_FETCH_RETRIES: "7" });
assert.match(explicit.stdout, /retries=7\b/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------- POSIX extraction fallbacks ----------------- */
/** An `archive`-kind component, for the extraction paths. */
function testArchive(url, payload, extra = {}) {
return {
description: "test archive",
kind: "archive",
license: "test",
requires: [],
optional: false,
unpack: { stripRootComponents: extra.strip ?? 0, moveTo: extra.moveTo ?? "arch" },
probe: extra.probe ?? {},
sources: {
all: {
version: "1",
url,
format: extra.format ?? "zip",
size: payload.length,
sha256: createHash("sha256").update(payload).digest("hex"),
},
},
};
}
test("a zip still extracts when unzip is absent (POSIX fallbacks)", (t) => {
if (process.platform === "win32") {
t.skip("Windows extracts through tar/Expand-Archive; the POSIX fallbacks do not exist there");
return;
}
const root = mkdtempSync(path.join(tmpdir(), "rea-extract-"));
try {
// A real zip, built with the very tool the fallback will use.
const zipPath = path.join(root, "fixture.zip");
const built = spawnSync("python3", [
"-c",
"import sys,zipfile;z=zipfile.ZipFile(sys.argv[1],'w');z.writestr('inside.txt','hello from inside\\n');z.close()",
zipPath,
]);
if (built.status !== 0) {
t.skip("python3 is unavailable, so neither the fixture nor the fallback can run");
return;
}
const payload = readFileSync(zipPath);
const python = spawnSync("sh", ["-c", "command -v python3"], { encoding: "utf8" }).stdout.trim();
assert.ok(python, "python3 was just used to build the fixture");
// A PATH that has python3 and nothing else: unzip cannot be found, which is
// exactly the bare-server case. Ghidra ships only as a zip, so this used to be
// a certain failure rather than an occasional one.
const binDir = path.join(root, "bin");
mkdirSync(binDir, { recursive: true });
const onlyPython = path.join(binDir, "python3");
symlinkSync(python, onlyPython);
const setup = makePluginTree(root, (manifest) => {
manifest.components.androidPlatformTools = testArchive("http://127.0.0.1:9/fixture.zip", payload);
});
const cases = [
{ label: "python3 fallback", path: binDir, expected: 0 },
{ label: "no extractor at all", path: path.join(root, "empty-bin"), expected: 1 },
];
for (const testCase of cases) {
mkdirSync(testCase.path, { recursive: true });
const bundle = path.join(root, `bundle-${testCase.expected}`);
mkdirSync(path.join(bundle, "cache"), { recursive: true });
writeFileSync(path.join(bundle, "cache", "fixture.zip"), payload);
addReaStub(bundle);
const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
env: { ...process.env, PATH: testCase.path },
});
assert.equal(run.status, testCase.expected, `${testCase.label}: ${run.stdout}\n${run.stderr}`);
if (testCase.expected === 0) {
assert.equal(readFileSync(path.join(bundle, "vendor", "arch", "inside.txt"), "utf8"), "hello from inside\n");
} else {
assert.match(run.stdout, /no zip extractor available/);
assert.match(run.stdout, /install unzip or python3/);
}
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("the fallback vendored-REA path matches where setup installs it", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-fallback-"));
try {
const bundle = path.join(root, "bundle");
// An older bundle.json has paths.node but no paths.reaEntry. The fallback used
// to omit `node_modules`, so a working install was reported as gone.
mkdirSync(bundle, { recursive: true });
addReaStub(bundle);
writeFileSync(path.join(bundle, "bundle.json"), JSON.stringify({ schema: 1, paths: { node: process.execPath } }, null, 2) + "\n");
const run = spawnSync(process.execPath, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), "--shim-status"], {
encoding: "utf8",
timeout: 60000,
env: { ...process.env, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_AUTO_PROVISION: "0" },
});
const report = JSON.parse(run.stdout);
assert.equal(report.rea.ok, true, JSON.stringify(report.rea));
assert.match(report.rea.reaEntry, /node_modules/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("--json releases the lock on success and marks it failed on failure", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-lock-json-"));
try {
const bundle = path.join(root, "bundle");
mkdirSync(bundle, { recursive: true });
addReaStub(bundle);
const component = (optional) =>
makePluginTree(path.join(root, optional ? "ok" : "bad"), (manifest) => {
manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", Buffer.from("adb"), { optional });
});
// The lock is what tells the shim a run is in flight. A successful --json run
// used to leave it behind (the removal sat in the non---json branch), so a
// fully provisioned machine advertised "provisioning started" for six hours.
writeFileSync(path.join(bundle, "setup.lock"), JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }));
const ok = spawnSync(process.execPath, [component(true), "--json", "--offline", "--only", "androidPlatformTools", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
assert.equal(existsSync(path.join(bundle, "setup.lock")), false, "a successful --json run must not leave the lock behind");
// A failing run keeps the lock on purpose, but records why, so the shim can
// report a failure instead of pretending a run is still going.
const bad = spawnSync(process.execPath, [component(false), "--json", "--offline", "--only", "androidPlatformTools", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(bad.status, 1, `${bad.stdout}\n${bad.stderr}`);
const lock = JSON.parse(readFileSync(path.join(bundle, "setup.lock"), "utf8"));
assert.equal(lock.state, "failed");
assert.equal(lock.exit, 1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* --------------------------------------------------------- the package entry */
// The market judges an update by asking whether the new build's package.json
// points at an entry file that exists (`hasLoadableEntry` -> `entryArtifactExists`
// in dshmarket). This package declared neither main nor exports, and the package
// it mounts (`@deepseek-ai/dsh-mcp-client`) ships inside the harness build rather
// than in node_modules, so every location that test probes came back empty:
// updating a healthy 2.0.1 profile to 2.0.4 failed with "updated build has no
// loadable entry", and the rollback was then reported unverifiable for the same
// reason (measured 2026-10-18). These two tests are that regression.
test("package.json declares an entry file that is actually shipped", () => {
const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"));
assert.equal(typeof pkg.main, "string", "package.json must declare main; without it consumers probe index.js, which does not exist");
assert.equal(existsSync(path.join(REPO_ROOT, pkg.main)), true, `${pkg.main} is declared but missing`);
// `main` alone is not enough: npm pack ships only what files[] lists, and a
// tarball without the entry reproduces the very failure this fixes.
const shipped = String(pkg.main).replace(/^\.\//, "");
assert.equal(pkg.files.includes(shipped), true, `files[] must ship ${shipped}`);
});
test("the entry loads as a cordis plugin and does nothing", async () => {
const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"));
const mod = await import(pathToFileURL(path.join(REPO_ROOT, pkg.main)).href);
assert.equal(mod.name, pkg.name, "the loader resolves the plugin by this name");
assert.equal(typeof mod.apply, "function", "a cordis plugin needs apply()");
// Loading and applying must not register a row, spawn anything or touch state:
// registering the MCP client here would add a second `serverName: rea` entry,
// which the harness refuses. Returning undefined is the whole contract.
assert.equal(mod.apply(), undefined);
});
+9 -2
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')) {
$out = & node --check $f 2>&1
Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
}
@@ -73,9 +73,16 @@ 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) ''
}
# The market's update path asks whether package.json's main/exports points at a
# file that exists, and rolls the update back when it does not (dshmarket's
# hasLoadableEntry). This package declared neither until 2.0.5, so every update of
# a healthy profile failed with "updated build has no loadable entry". Asserting
# the pointer resolves keeps that from returning silently.
Check 'package.json declares main' (-not [string]::IsNullOrWhiteSpace($pkg.main)) ''
Check 'main resolves to a shipped file' ($pkg.main -and (Test-Path (Join-Path $root $pkg.main))) "main=$($pkg.main)"
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test')) {
Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
}