dsh-plugin-rea 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,127 @@
2
2
 
3
3
  本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
4
4
 
5
+ ## 2.0.4 — 2026-10-17
6
+
7
+ **两处已核实的缺陷:只读 bundle 目录下 shim 启动即崩;与上游 `rea setup --client dsh` 的同名注册冲突现在会被明确提示。**
8
+
9
+ ### 修复
10
+
11
+ - **只读 bundle 根目录 + 陈旧 lock → shim 未捕获 EACCES 崩溃**:2.0.3 把删锁移出了 `if (fresh)`,却没包
12
+ try/catch。目录只读(`chmod 555`、系统级安装目录)且锁已陈旧时,`rmSync(lock)` 抛 `EACCES`,shim 以
13
+ 未捕获异常退出(exit 1 + 堆栈)—— 比原来的静默更吵,但同样起不来。沙箱已复现(WSL `chmod 555`)。
14
+ 现在删锁失败返回 `unwritable`,走既有的「无法写入,请手动跑 setup」路径(exit 78,行不崩)。
15
+ - **与上游 `rea setup --client dsh` 的同名注册冲突**:该命令往 `<DSH_HOME>/cordis.patch.yml` 写入
16
+ `id: rea-mcp`、`serverName: rea`,而本包是 `id: mcp-rea`、同一个 `serverName`;DSH 对同名条目只保留
17
+ 一个(后加载者报 duplicate server name)。已按上游 6.5.0 实测生成该文件确认。shim 现在启动时检测同
18
+ 文件的 `serverName: rea`(id 不是 `mcp-rea`)并向 stderr 提示「二选一」,`--shim-status` 也会在
19
+ `duplicateRegistration` 下报告。
20
+
21
+ ### 文档
22
+
23
+ - 两份 README 新增「只保留一条 REA 注册」小节:与 `rea setup --client dsh` 二选一,并说明两条路线的
24
+ 取舍(零依赖供给 vs 上游官方纯配置注册)。
25
+ - v1 分支 README 顶部加横幅:指向 2.x(`main`,默认推荐)与上游官方注册。
26
+
27
+ ### 验证
28
+
29
+ - 单测 40 条:Windows 37 过 + 3 平台跳过;WSL 39 过 + 1 跳过(本轮新增 3 条,其中只读目录一条仅 POSIX)。
30
+ - 反向验证:回退删锁的 try/catch → 只读目录测试在 WSL 必红(EACCES/exit 1);回退冲突检测 → 重复注册
31
+ 测试必红。
32
+ - `verify-branch.ps1` 全绿。
33
+
34
+ ## 2.0.3 — 2026-10-16
35
+
36
+ **修复 11 处已核实的缺陷(每条都先确认存在、并配了"回退即变红"的回归测试);上游 REA 钉子从 6.4.0 升到 6.5.0。**
37
+
38
+ ### 上游
39
+
40
+ - `pins.reaAgents` 与 `components.reaAgents.sources.npm.version` → **6.5.0**:engines 与 bin 入口未变
41
+ (`^22.19.0 || ^24.11.0 || >=26.0.0`、`scripts/rea.mjs`),工具数 170 → **207**(运行时 `rea doctor --json` 实测;上游源码静态清点为 206,多出的 1 个注册在 contracts 之外。新增:Frida 9、
42
+ GDB 4、Rizin/Cutter 6、pcap/packet 4、flutter 2、sqlite/rust/go/wasm/electron/harmony/hopper 各 1 等)。
43
+ 新增的 `optionalDependencies.frida` 在 `--ignore-scripts` 下不会执行安装脚本。REA 探针正则收紧为
44
+ `^6\.5\.0(\s|$)`,审计新增"下一个 patch/minor 不得通过"的断言。
45
+ - Ghidra 12.1.4 / apktool 3.0.3 / jadx-headless-mcp 0.7.1 **经核实仍是当前最新**;Node 24.22.0 虽已发布,
46
+ 但晚于本插件的实测矩阵,故保持 24.21.0(engines 完全满足)。
47
+
48
+ ### 修复
49
+
50
+ - **`--only` 抹掉其它组件的记录**:bundle.json 由本轮结果整份重写,未点名组件的 `paths.jdk/ghidra` 变
51
+ `null`,shim 不再导出 `JAVA_HOME`/`GHIDRA_INSTALL_DIR`——原生分析静默失效,下次完整跑还得重新解压
52
+ 750 MB。未点名的组件现在按旧记录结转(且只在记录的路径仍然存在时),缺依赖时优先用旧记录/机器/运行
53
+ setup 的解释器来满足,而不是整段跳过检查。
54
+ - **完整的 `.part` 不被当作已完成**:只打一行日志,然后发 `Range: bytes=<全长>-`(416;四次重试后把
55
+ 已经下好的文件报成失败)。现在校验通过即改名收下。
56
+ - **Windows 下载主路线没有续传**:PowerShell 路线每轮先删 `.part`;失败后 Node 兜底仍用进本轮前的
57
+ `have`,以 append 把 200 全量接在半成品后面(长度必然超标,只能重下)。两条路线现在都按盘上实际
58
+ 长度发 Range 续传。
59
+ - **POSIX 的 apktool wrapper 没有执行位**(copyFileSync 落 0644,而 REA 不加 shell 直接 spawn → EACCES):
60
+ manifest 里标了 `executable` 的条目落盘时 chmod 0755。
61
+ - **apktool wrapper 钉在 `master` 分支 + 硬校验 sha256**:上游对 wrapper 的任何一次提交都会让全网新装
62
+ hash mismatch。改钉不可变的 `v3.0.3` tag,哈希按该 tag 重新实测。
63
+ - **`--reuse` 不校验版本**:JDK 只看 `javac` 存在、Ghidra 只看 `analyzeHeadless` 存在(JDK 8/11、Ghidra 11
64
+ 照收)。现在候选必须通过该组件自己的 probe;同时修好"文件型 probe 从不检查 `expect`"(Ghidra 的
65
+ `application.version=` 断言此前是死配置)。
66
+ - **`bundle.json` 损坏即永久失败**:setup 读不了它、也没人启动 setup,用户只能手删文件。现在 setup 容忍
67
+ 它(并按需重写),shim 对 `malformed` 与 `missing` 一样触发供给。
68
+ - **失败后 shim 谎报 6 小时**:lock 现在记录状态与 pid;失败态如实报告"上一次失败了",持有者已消失的
69
+ lock 不再阻塞重试。
70
+ - **成功的 `--json` 轮次从不删 lock**:删除语句原先在非 `--json` 分支里,--json 成功也留下 lock。
71
+ - **shim 兜底 REA 路径少了 `node_modules`**:旧 bundle.json 缺 `paths.reaEntry` 时会把可用安装判成"CLI 丢了"。
72
+ - **`scripts/rea-bundle-bootstrap.ps1`**:`node -v` 输出非数字时 `[int]` 强转在 `$ErrorActionPreference='Stop'`
73
+ 下会炸掉整个 bootstrap(而它存在的意义就是这条路径);`&` 调用失败时 `$LASTEXITCODE` 可能为 `$null` →
74
+ `exit $null` = 假成功;engines 改从 manifest 的 `pins.nodeEngines` 读(不再抄第二份);`-DryRun` 不再创建
75
+ 目录(它承诺"什么都没改")。
76
+ - **`check-updates.ps1` 的 1.x 残留**:REA 节默认找 npm 全局包(2.x 是 vendored)、Ghidra 节读用户级
77
+ `GHIDRA_INSTALL_DIR`、`rea doctor` 拿不到 vendored CLI(整段自检被静默跳过)、一体包对账对"未记录版本"
78
+ 的组件静默 `continue` 却打印"全部一致"。现在一律先读 `bundle.json`,并如实报告无法对账的项。
79
+
80
+ ### 审计与 CI
81
+
82
+ - `audit-manifest.mjs` 新增 4 项:manifest 组件 ⇄ `INSTALL_ORDER` 对账、`pins.nodeEngines` ⇄ `package.json
83
+ engines`、REA pin 探针不得放行下一个 patch/minor、POSIX wrapper 必须标 `executable`。
84
+ - `validate.yml` 新增 `windows-latest` job:PowerShell 下载路线、zip 解压与 bootstrap 此前是零 CI 覆盖的
85
+ Windows 专属代码。
86
+ - 单测 33 条(32 过 + 1 条按平台跳过);`--reuse adopts a compatible node` 在 engines 之外的宿主上改为显式
87
+ skip(此前在 Node 20 上必红,而 CI 钉 22 看不出来)。
88
+
89
+ ### 第二轮修复(同一份**更新后**的清单,2026-10-16 晚)
90
+
91
+ - 🔴 **过期 lock 永久卡死自动供给(2.0.0–2.0.2 三版全带)**:`maybeAutoProvision()` 只在 lock 新鲜时
92
+ 处理它,过期的 lock 既不清除也不报告 → 落到独占创建 → `EEXIST` → 返回 `in-flight`,而 `main()` 对
93
+ `in-flight` **一个字都不打印**。于是「下载被 kill / 断电」这一常态,在 6 小时后再启动就变成**永久不再
94
+ 自动供给且完全静默**。已按清单复现(7 小时死 lock:不启动、lock 原样、无任何提示;5 分钟 lock 对照组
95
+ 正常删锁重启),修法:**不新鲜或持有者已死就一律清锁**(这正是注释里"只在新鲜时尊重它"的本意),
96
+ 并给 `in-flight` / `spawn-failed` / `unwritable` 补上如实提示。
97
+ - 🔴 **`examples/` 两个文件是 1.x 内容,却随 2.x 进了 npm 包**:`cordis.patch.posix.yml` 教人把整行换成
98
+ `command: rea`、`cordis.patch.with-env.yml` 给的是 `cmd.exe /c rea` 的 1.x 行——对 2.x 全错(2.x 的行是
99
+ 跨平台的 `process.execPath` + bundle shim,照抄会整行绕过 bundle)。两份都已是孤儿(README 不再引用),
100
+ 故从 `main` **删除**,并同步 `package.json files[]` 与 `validate.yml` 的载荷/YAML/机器路径三项检查
101
+ (v1 分支保留它们,在 1.x 语境里是对的)。npm 载荷由此 21 → 19 个文件。
102
+ - 🟡 **`--offline` 管不住 `reaAgents`**:`provisionReaAgents()` 里 0 处 offline 判断,`npm install` 永远
103
+ 联网——与「`--offline` is a promise, not a hint」直接矛盾。现在 `--offline` 且内置副本不可用时**直接
104
+ FAIL** 并说明原因(而可用的内置副本仍照常跳过)。
105
+ - 🟡 **npm 那一步没有代理/镜像兜底**:压缩包有 direct→proxy→PowerShell 三路,npm 只读环境变量。失败时
106
+ 现在会打印这句提示,两份 README 的能力/注意里也写明 `HTTPS_PROXY` / `NPM_CONFIG_REGISTRY`。
107
+ - 🟡 **POSIX 缺 `unzip`/`xz` 必现失败**:Ghidra 全平台只有 zip,而 POSIX 解压原先只有 `unzip` 一条路。
108
+ 现在 zip 依次尝试 `unzip` → `python3 -m zipfile` → **已供给 JDK 自带的 `jar`**(JDK 在 Ghidra 之前供给,
109
+ 等于保底),tar 依次 `tar` → `python3`;三者皆无时打印明确的「no zip extractor available」而不是干巴巴的
110
+ 「extraction failed」。
111
+ - 🟡 **manifest 的 arm64 注释是错的**:Google 的 linux/darwin platform-tools 都是 x86_64 二进制,
112
+ 「The same archive serves x86-64 and arm64」不成立。三个条目改成如实描述(macOS 需 Rosetta 2、
113
+ linux-arm64 上 probe 预期失败并被报为可选组件不可用)。
114
+ - 🟡 **`docs/bundle-design.md` 与实现漂移**:§3 的「row points at `<plugin>/bin/rea-shim.mjs`」改成
115
+ 「按 `DSH_HOME` 算出 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`(镜像副本)」;§7 的
116
+ `vendor/rea-agents` 改成 `vendor/node_modules/rea-agents`。
117
+ - 🟢 **`release.yml` 的 `workflow_dispatch` 可绕过 Release 直接发 npm**:publish job 现在先断言
118
+ `v<package.json version>` 的 tag 在 origin 上存在、且该 Release 挂着版本无关的 `dsh-plugin-rea.tgz`
119
+ 资产,否则拒绝发布(否则 npm 与市场兜底链接会岔开)。
120
+ - 🟢 **数值环境变量会变成 NaN**:`REA_BUNDLE_FETCH_RETRIES=abc` → `Math.max(1, NaN)` → 报
121
+ 「gave up after NaN attempt(s)」;NaN 的 socket 超时等价于 0(立即超时)。现在非正有限值一律回落到默认,
122
+ 且启动日志新增一行 `limits connect=… idle=… retries=…`,让覆盖生效可见。
123
+ - 🟢 注释与整洁:`downloadViaPowerShell` 的文档说自己是 fallback,实际在 Windows 上是主路线(已改口径);
124
+ 去掉 setup.mjs 里 3 处对已静态导入成员的重复 `await import("node:fs")`(`readdirSync` 补进静态导入)。
125
+
5
126
  ## 2.0.2 — 2026-10-15
6
127
 
7
128
  **仅文档,无行为变更。**
package/README.md CHANGED
@@ -82,6 +82,10 @@ Both lines ship the **same patch shape** — one `insert` row of `@deepseek-ai/d
82
82
  same `mcp__rea__*` surface; they differ in who prepares the runtime. Moving from 1.x to 2.x takes two
83
83
  commands: see [Upgrading from 1.x](#upgrading-from-1x).
84
84
 
85
+ Upstream also registers itself with dsh now (`rea setup --client dsh`); that route is essentially the
86
+ 1.x model maintained upstream — configuration only, your own Node/REA/Ghidra. This bundle is the
87
+ zero-dependency route. Pick one of the three, and never keep two `serverName: rea` rows at once.
88
+
85
89
  ## Install
86
90
 
87
91
  **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
110
114
  node scripts/setup.mjs --reuse # or --dry-run to see the plan first
111
115
  ```
112
116
 
117
+ Node, when you provide it instead of letting setup fetch one, must satisfy REA's engines —
118
+ `^22.19.0 || ^24.11.0 || >=26.0.0` (check with `node -v`). A lower Node does not fail at install
119
+ time; the MCP row fails later, which reads as silence.
120
+
113
121
  Installing the plugin **first** also works, but it takes two steps. The row resolves to
114
122
  `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`, and that file is created by setup — so until the first
115
123
  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
168
176
  the row from the profile's own `cordis.patch.yml` — that file is machine-local and never published. Only one
169
177
  row may use `serverName: rea` in a profile.
170
178
 
179
+ ### One REA registration only
180
+
181
+ Upstream REA 6.5.0 can register itself with DSH:
182
+
183
+ ```
184
+ rea setup --client dsh
185
+ ```
186
+
187
+ That writes a row with `id: rea-mcp` and `serverName: rea` into `<DSH_HOME>/cordis.patch.yml`. This
188
+ bundle's row is `id: mcp-rea` with the same `serverName`, and DSH rejects the later of two entries
189
+ that share a server name — so which REA row survives depends on load order. **Use one, not both.**
190
+
191
+ Which one? Upstream's is the configuration-only route: it expects Node on REA's engines,
192
+ `rea-agents` reachable, and a Ghidra/JDK you provisioned yourself. This bundle is the
193
+ zero-dependency route: it fetches Node, REA, JDK and Ghidra on first run. The patch shape and the
194
+ `mcp__rea__*` surface are the same either way.
195
+
196
+ The shim reports the conflict on startup when it can see that file — a custom `DSH_HOME` is hidden
197
+ from an MCP child, so it falls back to `~/.dsh` there — and `node bin/rea-shim.mjs --shim-status`
198
+ lists it under `duplicateRegistration`.
199
+
171
200
  ## Verify
172
201
 
173
202
  Ask for a `mcp__rea__*` call — `binary_session` reports the engine checks
@@ -179,11 +208,11 @@ exact shim launch the MCP row will use.
179
208
 
180
209
  ## Verified against
181
210
 
182
- Author's machine, 2026-10-13 / 14:
211
+ Author's machine, 2026-10-13 / 14 / 16:
183
212
 
184
213
  | Component | Version |
185
214
  |---|---|
186
- | REA (`rea-agents`) | **6.4.0** (170 tools) |
215
+ | REA (`rea-agents`) | **6.5.0** (207 tools) |
187
216
  | DeepSeek Harness | desktop runtime **0.2.0-rc.2**, with its bundled dsh CLI 0.1.7-rc.2 |
188
217
  | `@deepseek-ai/dsh-mcp-client` (row provider) | bundled by the harness |
189
218
  | Ghidra | 12.1.4 |
@@ -192,7 +221,7 @@ Author's machine, 2026-10-13 / 14:
192
221
  | Host | Windows 11 x64 (build 26200) |
193
222
 
194
223
  Exercised live: a clean `setup.mjs --reuse` run reached `setup complete`; an MCP `initialize` handshake
195
- **through the shim with no `rea` on PATH** answered `serverInfo {name: rea, version: 6.4.0}`; and the whole
224
+ **through the shim with no `rea` on PATH** answered `serverInfo {name: rea, version: 6.5.0}`; and the whole
196
225
  chain ran under the real desktop host — its own Electron binary launched
197
226
  `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`, the shim started the **vendored** REA, and a live tool
198
227
  call reported `path_source: environment` for a path only that machine's `bundle.json` knows.
@@ -203,19 +232,33 @@ macOS/Linux.
203
232
 
204
233
  ## Scope and caveats
205
234
 
206
- - REA exposes 170 tools at 6.4.0, so every session's tool list grows.
235
+ - REA exposes 207 tools at 6.5.0, so every session's tool list grows.
207
236
  - Provisioning is honest about checksums: where a vendor publishes one it is pinned and verified; where none
208
237
  exists (jadx-headless-mcp, the apktool wrapper, Android platform-tools, the rolling "latest" URLs) the
209
238
  hash is **measured once and recorded**, and later drift is reported rather than silently accepted.
210
239
  `bundle.json` lists which components are in that state under `unverifiedChecksums`.
211
240
  - Downloads are slow on some networks (measured 0.35–0.54 MiB/s from the GitHub release CDN here, so Ghidra
212
- alone takes ~25–45 min). Fetching is resumable: the cache is kept and re-verified, and a killed run
213
- continues instead of restarting.
241
+ alone takes ~25–45 min). Fetching is resumable on both routes: a partial `.part` is continued with a Range
242
+ request, and a `.part` that already holds the whole file is verified and kept instead of re-fetched.
214
243
  - Some REA tools launch processes, write files and access the network — DSH's permission policy applies.
215
244
  Analyse only artifacts you are authorized to inspect.
245
+ - The MCP child and the engines it launches are ordinary processes running as **you**. DSH's tool
246
+ approvals govern what the model asks for, not what that child may read, write or send on its own;
247
+ treat the plugin like any third-party code you install.
248
+ - On Linux, upstream's process-ownership checks need a **procps-compatible `ps`** on PATH; the
249
+ Ghidra/JADX and firmware paths use it. A minimal image without it fails those tools, not the install.
216
250
  - `scripts/setup.mjs` runs `npm install` for the REA CLI with `--ignore-scripts`: the published tarball
217
251
  carries no `node_modules`, so a dependency closure must be resolved, and package lifecycle scripts are
218
- skipped on purpose during provisioning.
252
+ skipped on purpose during provisioning. That step is also the **only** one that cannot use this bundle's
253
+ own route/proxy resolution — npm reads `HTTPS_PROXY`/`HTTP_PROXY` and `NPM_CONFIG_REGISTRY` from the
254
+ environment, so on a network that needs a mirror, set one of those before provisioning
255
+ (`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`). With `--offline` the step is refused outright
256
+ instead of quietly going online.
257
+ - On POSIX, extraction falls back when the usual tools are missing: a zip is opened with `unzip`, then
258
+ `python3 -m zipfile`, then the provisioned JDK's `jar`; a tarball with `tar`, then `python3`. A minimal
259
+ image that has none of them fails with an explicit "no zip extractor available" line rather than a bare
260
+ "extraction failed". This matters because Ghidra is a zip on every platform and `unzip` is not installed
261
+ everywhere by default.
219
262
 
220
263
  ## Updating — three independent things
221
264
 
package/README.zh-CN.md CHANGED
@@ -77,6 +77,9 @@ patch 里不含任何机器路径**。
77
77
  `mcp__rea__*` 工具面;区别只在"谁准备运行时"。从 1.x 到 2.x 只要两条命令,见
78
78
  [从 1.x 升级](#从-1x-升级)。
79
79
 
80
+ 上游现在也能自己注册进 dsh(`rea setup --client dsh`):那基本就是上游维护的 1.x 模式 —— 只写配置,
81
+ Node/REA/Ghidra 由你准备。本包是零依赖路线。三条路选一条,不要同时留两行 `serverName: rea`。
82
+
80
83
  ## 安装
81
84
 
82
85
  **前置什么都不用装。** 在干净的 Windows x64 机器上:
@@ -104,6 +107,10 @@ dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
104
107
  node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
105
108
  ```
106
109
 
110
+ 如果 Node 由你自备(而不是让 setup 去取),它必须满足 REA 的 engines:
111
+ `^22.19.0 || ^24.11.0 || >=26.0.0`(用 `node -v` 核对)。版本偏低不会在安装时失败,而是到 MCP 行
112
+ 启动时才失败 —— 表现就是"没有反应"。
113
+
107
114
  反过来**先装插件**也可以,但要分两步:发布的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
108
115
  而那个文件**只有 setup 会创建**——所以在第一次供给之前,该行会以 `MODULE_NOT_FOUND` 退出,也就没有
109
116
  任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
@@ -159,6 +166,25 @@ node bin/rea-shim.mjs --shim-print-env # MCP 子进程将看到的环境变量
159
166
  `cordis.patch.yml` 按 id 覆盖即可 —— 那个文件是机器本地的,永不发布。一个 profile 里只允许一行使用
160
167
  `serverName: rea`。
161
168
 
169
+ ### 只保留一条 REA 注册
170
+
171
+ 上游 REA 6.5.0 起可以自己注册进 DSH:
172
+
173
+ ```
174
+ rea setup --client dsh
175
+ ```
176
+
177
+ 它会把一行 `id: rea-mcp`、`serverName: rea` 写进 `<DSH_HOME>/cordis.patch.yml`。本包的行是
178
+ `id: mcp-rea`、同样 `serverName: rea`,而 DSH 对同名条目只保留一个:后加载的那行会以
179
+ duplicate server name 报错并加载失败,最终留下哪一行取决于加载顺序。**二选一,不要同时用。**
180
+
181
+ 选哪个?上游那条是纯配置路线:需要 Node 满足 REA engines、`rea-agents` 可达,Ghidra/JDK 由你
182
+ 自己准备。本包是零依赖路线:首次运行自动取 Node、REA、JDK、Ghidra。两者的 patch 形态与
183
+ `mcp__rea__*` 工具面相同。
184
+
185
+ shim 在能看到该文件时会在启动时提示冲突 —— MCP 子进程里拿不到自定义 `DSH_HOME`,那种情况下它会
186
+ 退回 `~/.dsh`;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
187
+
162
188
  ## 验证
163
189
 
164
190
  随便发一个 `mcp__rea__*` 调用 —— `binary_session` 会报引擎各项检查
@@ -170,11 +196,11 @@ shim 启动**。
170
196
 
171
197
  ## 实测环境
172
198
 
173
- 作者机器,2026-10-13 / 14:
199
+ 作者机器,2026-10-13 / 14 / 16:
174
200
 
175
201
  | 组件 | 版本 |
176
202
  |---|---|
177
- | REA(`rea-agents`) | **6.4.0**(170 个工具) |
203
+ | REA(`rea-agents`) | **6.5.0**(207 个工具) |
178
204
  | DeepSeek Harness | desktop runtime **0.2.0-rc.2**,内含 dsh CLI 0.1.7-rc.2 |
179
205
  | `@deepseek-ai/dsh-mcp-client`(行 provider) | 随 harness 打包 |
180
206
  | Ghidra | 12.1.4 |
@@ -183,7 +209,7 @@ shim 启动**。
183
209
  | 宿主 | Windows 11 x64(build 26200) |
184
210
 
185
211
  实测跑过:一次干净的 `setup.mjs --reuse` 走到 `setup complete`;一次**不经 PATH 上的 `rea`、完全经由
186
- shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.4.0}`;以及**真实桌面宿主下的完整链路** ——
212
+ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}`;以及**真实桌面宿主下的完整链路** ——
187
213
  宿主自己的 Electron 二进制启动 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`,shim 拉起 **bundle 内置**
188
214
  的 REA,随后一次真实工具调用报出 `path_source: environment`,而那个路径只有该机的 `bundle.json` 知道。
189
215
 
@@ -192,18 +218,31 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.4.0}
192
218
 
193
219
  ## 边界与注意事项
194
220
 
195
- - REA 在 6.4.0 有 170 个工具,每个会话的工具列表都会变长。
221
+ - REA 在 6.5.0 有 207 个工具,每个会话的工具列表都会变长。
196
222
  - 校验和对得起良心:厂商发布了的就固定并验证;没发布的(jadx-headless-mcp、apktool 包装脚本、
197
223
  Android platform-tools、滚动的 `latest` 地址)则**实测一次并记录**,之后若漂移是**报告**而不是默默接受。
198
224
  `bundle.json` 的 `unverifiedChecksums` 会列出处于该状态的组件。
199
225
  - 某些网络下很慢(这里实测 GitHub release CDN 只有 0.35–0.54 MiB/s,光 Ghidra 就要 25–45 分钟)。
200
- 下载是**可续传**的:缓存保留并重新校验,被中断的一轮会继续而不是从头再来。
226
+ 下载是**可续传**的:两条路线都按盘上已有的字节发 Range 继续;若 `.part` 恰好已经完整,会校验后直接收下
227
+ 而不是重下。
201
228
  - **代理**:Node 的全局 `fetch` 不读系统代理,而 .NET/PowerShell 会读。在开着 WinINET 代理的机器上,
202
229
  同一个地址 `Invoke-WebRequest` 通、Node 却 `ECONNRESET`/超时。setup 自己解析代理
203
230
  (环境变量优先,Windows 上回退读注册表)并用 CONNECT 隧道,`NO_PROXY` 也照常生效。
204
231
  - REA 的部分工具会启动进程、写文件、访问网络 —— DSH 的权限策略照常适用。只分析你有权检视的样本。
232
+ - MCP 子进程与它拉起的引擎都是以**你的身份**运行的普通进程。DSH 的工具审批管的是模型发起的调用,
233
+ 管不到这个子进程自己去读、写或发送什么;请把插件当作你安装的第三方代码来对待。
234
+ - Linux 上,上游的进程归属检查依赖 PATH 上**兼容 procps 的 `ps`**;Ghidra/JADX 与固件工具会用到它。
235
+ 极简镜像里缺它只会让这些工具失败,不影响整体安装。
205
236
  - `scripts/setup.mjs` 为 REA CLI 跑的是带 `--ignore-scripts` 的 `npm install`:官方 tarball 不带
206
- `node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。
237
+ `node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。这一步也是**唯一**用不上
238
+ 本插件自己的路由/代理回退的环节 —— npm 只从环境读 `HTTPS_PROXY`/`HTTP_PROXY` 与
239
+ `NPM_CONFIG_REGISTRY`,所以网络需要镜像时请先设其中一个(例如
240
+ `NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`)。带 `--offline` 时这一步会被**直接拒绝**,
241
+ 而不是悄悄联网。
242
+ - POSIX 上解压有兜底:zip 依次尝试 `unzip` → `python3 -m zipfile` → 已供给 JDK 自带的 `jar`;tar 包依次
243
+ 尝试 `tar` → `python3`。三者都没有的最小镜像会得到一句明确的「no zip extractor available」,而不是
244
+ 干巴巴的「extraction failed」。Ghidra 在所有平台都只有 zip,而 `unzip` 并非每个发行版默认安装,
245
+ 所以这条不是理论问题。
207
246
 
208
247
  ## 更新 —— 三件互相独立的事
209
248
 
@@ -84,7 +84,7 @@ Draw-Text $g '第三方适配 · 自身不做逆向分析 · 自供给 Node/REA/
84
84
  $rowY = 180; $boxH = 158; $boxW = 250
85
85
  Draw-Box $g 60 $rowY $boxW $boxH $([System.Drawing.Color]::White) $line
86
86
  Draw-Text $g 'DeepSeek Harness 会话' $fBox $ink 80 ($rowY + 18)
87
- Draw-Text $g "mcp__rea__* 工具`n(6.4.0 暴露 170+)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
87
+ Draw-Text $g "mcp__rea__* 工具`n(6.5.0 暴露 207)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
88
88
 
89
89
  Draw-Box $g 360 $rowY $boxW $boxH $([System.Drawing.Color]::FromArgb(255,239,246,255)) $accent
90
90
  Draw-Text $g '官方 MCP 客户端行' $fBox $accent 380 ($rowY + 18)
@@ -112,7 +112,7 @@ Draw-Line $g 358 446 150 446 $muted
112
112
  Draw-Arrow $g 150 446 150 342 $muted
113
113
 
114
114
  Draw-Text $g '上游 REA(MIT):github.com/morluto/rea 本插件:github.com/czcdr/dsh-plugin-rea' $fSmall $muted 60 620
115
- 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
115
+ 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
116
116
  Draw-Text $g 'Ghidra 为 Apache-2.0(NSA);REA 为 MIT。本仓库不分发上游代码。' $fSmall $muted 60 672
117
117
  Save-Png $bmp $g (Join-Path $OutDir 'screenshot-1.png')
118
118
 
Binary file
package/bin/rea-shim.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  * and the message is the operator's remediation.
16
16
  */
17
17
  import { spawn } from "node:child_process";
18
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import path from "node:path";
21
21
  import process from "node:process";
@@ -93,6 +93,77 @@ function provisioningScript(root) {
93
93
  return usableSetup(PLUGIN_ROOT);
94
94
  }
95
95
 
96
+ /**
97
+ * Upstream REA 6.5.0 can add a second registration for the same MCP server:
98
+ * `rea setup --client dsh` writes a row with id "rea-mcp" and serverName "rea"
99
+ * into `<DSH_HOME>/cordis.patch.yml`, while this bundle ships id "mcp-rea" with
100
+ * the same serverName. DSH does not dedupe those rows: it rejects the later entry
101
+ * with a duplicate-server-name error, so which REA row survives depends on load
102
+ * order. The shim cannot prevent that, but it can name the conflict instead of
103
+ * leaving a missing row to be diagnosed from "the tools are gone".
104
+ *
105
+ * The parse is deliberately small: the two files it cares about are block
106
+ * mappings with one id/serverName per row, and anything it cannot attribute is
107
+ * ignored — a missed hint is acceptable, a false alarm is not. The mirrored shim
108
+ * cannot see a custom DSH_HOME (the harness strips DSH_* from an MCP child), so
109
+ * this falls back to `~/.dsh` like the rest of the bundle; the row has the same
110
+ * limitation.
111
+ */
112
+ function foreignReaRegistration() {
113
+ const patch = path.join(dshHome(), "cordis.patch.yml");
114
+ let text;
115
+ try {
116
+ text = readFileSync(patch, "utf8");
117
+ } catch {
118
+ return null;
119
+ }
120
+ const unquote = (value) => value.replace(/^(['"])(.*)\1$/, "$2").trim();
121
+ // A row starts at `- id:`; the block runs to the next row, so a serverName can
122
+ // never be attributed to a row that does not contain it.
123
+ for (const block of text.split(/\r?\n(?=\s*-\s+id:\s*)/)) {
124
+ const id = /^\s*-\s+id:\s*(.+?)\s*$/m.exec(block);
125
+ const server = /^\s+serverName:\s*(.+?)\s*$/m.exec(block);
126
+ if (!id || !server || unquote(server[1]) !== "rea") continue;
127
+ const rowId = unquote(id[1]);
128
+ if (rowId !== "mcp-rea") return { patch, id: rowId };
129
+ }
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * The provisioning lock is a small JSON record: { state, pid, at, exit? }.
135
+ * `state: "running"` is written when a run is started (setup's own pid once it
136
+ * exists), and the failing run rewrites it as `state: "failed"` — it is kept on
137
+ * purpose so a broken setup cannot be re-downloaded in a loop. A legacy
138
+ * plain-number lock is still honoured as "running".
139
+ */
140
+ const STALE_LOCK_MS = 6 * 60 * 60 * 1000;
141
+
142
+ function readLock(lock) {
143
+ try {
144
+ const raw = readFileSync(lock, "utf8").trim();
145
+ if (raw.startsWith("{")) {
146
+ const parsed = JSON.parse(raw);
147
+ if (parsed && typeof parsed === "object") return parsed;
148
+ }
149
+ const at = Number(raw);
150
+ return Number.isFinite(at) ? { state: "running", at } : null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /** Is the process recorded in the lock still alive? null means "cannot tell". */
157
+ function pidAlive(pid) {
158
+ if (!Number.isInteger(pid) || pid <= 0) return null;
159
+ try {
160
+ process.kill(pid, 0);
161
+ return true;
162
+ } catch (error) {
163
+ return error?.code === "ESRCH" ? false : null;
164
+ }
165
+ }
166
+
96
167
  /**
97
168
  * On first run there is nothing to launch. Provisioning is a long download, so
98
169
  * it is started detached: the harness may kill this row (or the whole session)
@@ -100,34 +171,67 @@ function provisioningScript(root) {
100
171
  * a killed attempt is simply continued on the next start.
101
172
  *
102
173
  * Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
103
- * Returns the log path when a run was started, otherwise null.
174
+ * Returns { started, reason, logPath } — the caller words its message from
175
+ * `reason`, because "provisioning started in the background" was also printed
176
+ * for a lock left behind by a run that had already failed, and for one whose
177
+ * process was long gone.
104
178
  */
105
179
  function maybeAutoProvision(root) {
106
- if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return null;
180
+ if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return { started: false, reason: "disabled" };
107
181
  const setupScript = provisioningScript(root);
108
- if (!setupScript) return null;
182
+ if (!setupScript) return { started: false, reason: "no-setup" };
109
183
  const lock = path.join(root, "setup.lock");
110
184
  const logPath = path.join(root, "logs", "setup.log");
111
185
  if (existsSync(lock)) {
112
186
  // A crashed run must not wedge provisioning forever: honour the lock only
113
187
  // while it is fresh; setup removes it itself when it finishes cleanly.
114
- let age = Infinity;
188
+ const record = readLock(lock);
189
+ const age = record && Number.isFinite(record.at) ? Date.now() - record.at : Infinity;
190
+ const fresh = Number.isFinite(age) && age < STALE_LOCK_MS;
191
+ if (fresh && record?.state === "failed") {
192
+ // The run gave up and left the lock behind so it would not be retried in a
193
+ // loop. Claiming it is still provisioning was a six-hour lie: nothing runs,
194
+ // and every restart repeated the sentence.
195
+ return {
196
+ started: false,
197
+ reason: "last-run-failed",
198
+ exit: record.exit,
199
+ failedAt: record.at,
200
+ ageMs: age,
201
+ logPath,
202
+ };
203
+ }
204
+ if (fresh) {
205
+ const alive = pidAlive(record?.pid);
206
+ if (alive !== false) return { started: false, reason: "in-flight", logPath, ageMs: age };
207
+ }
208
+ // Reached when the lock is older than the freshness window, or its holder is
209
+ // gone and never wrote an outcome (harness restart, power loss, a kill): what
210
+ // it records is not happening any more. This removal is the whole point of the
211
+ // freshness rule — without it the exclusive create below failed with EEXIST
212
+ // *forever*, auto-provisioning never ran again, and main() printed nothing at
213
+ // all for that outcome. The lock's own "honour it only while it is fresh"
214
+ // comment described behaviour the code did not have.
115
215
  try {
116
- age = Date.now() - Number(readFileSync(lock, "utf8").trim() || 0);
216
+ rmSync(lock, { force: true });
117
217
  } catch {
118
- age = Infinity;
218
+ // A read-only bundle root (system-wide install, chmod 555) cannot drop the
219
+ // stale lock either. Returning the same "cannot write here" outcome as the
220
+ // exclusive create below keeps the row alive: an uncaught EACCES killed the
221
+ // shim with a stack trace, which is louder than the old silence but just as
222
+ // unable to start REA.
223
+ return { started: false, reason: "unwritable", logPath };
119
224
  }
120
- if (Number.isFinite(age) && age < 6 * 60 * 60 * 1000) return logPath;
121
225
  }
122
226
  try {
123
227
  mkdirSync(path.join(root, "logs"), { recursive: true });
124
228
  // Exclusive create: check-then-write is not atomic, so two rows starting at
125
229
  // once could both decide to provision. EEXIST means another run owns it.
126
- writeFileSync(lock, String(Date.now()), { flag: "wx" });
230
+ writeFileSync(lock, JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }), { flag: "wx" });
127
231
  } catch (error) {
128
- if (error?.code === "EEXIST") return logPath;
232
+ if (error?.code === "EEXIST") return { started: false, reason: "in-flight", logPath };
129
233
  /* a read-only bundle root must never break the harness */
130
- return null;
234
+ return { started: false, reason: "unwritable" };
131
235
  }
132
236
  try {
133
237
  const child = spawn(process.execPath, [setupScript, "--root", root], {
@@ -136,9 +240,18 @@ function maybeAutoProvision(root) {
136
240
  windowsHide: true,
137
241
  });
138
242
  child.unref();
139
- return logPath;
243
+ // Hand the lock to the detached downloader, so a later start that finds this
244
+ // row dead still sees the run that is actually doing the work as alive.
245
+ if (Number.isInteger(child.pid)) {
246
+ try {
247
+ writeFileSync(lock, JSON.stringify({ state: "running", pid: child.pid, at: Date.now(), supervisor: process.pid }));
248
+ } catch {
249
+ /* the lock is advisory: the row keeps working without it */
250
+ }
251
+ }
252
+ return { started: true, reason: "started", logPath };
140
253
  } catch {
141
- return null;
254
+ return { started: false, reason: "spawn-failed", logPath };
142
255
  }
143
256
  }
144
257
 
@@ -195,8 +308,13 @@ function readBundleConfig(root) {
195
308
  function resolveRea(config, root) {
196
309
  const paths = config.paths ?? {};
197
310
  const node = paths.node;
311
+ // The fallback has to match where setup actually installs the package:
312
+ // `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
313
+ // fallback without that segment named a directory that never exists. It only
314
+ // fires when an older bundle.json has no paths.reaEntry — which is exactly when
315
+ // a wrong fallback turns a usable install into "the vendored REA CLI is gone".
198
316
  const reaEntry = paths.reaEntry
199
- ?? path.join(root, "vendor", "rea-agents", "scripts", "rea.mjs");
317
+ ?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
200
318
 
201
319
  if (typeof node !== "string" || node.trim() === "") {
202
320
  return { ok: false, reason: "bundle.json has no paths.node" };
@@ -279,6 +397,18 @@ function main() {
279
397
 
280
398
  const root = bundleRoot();
281
399
  const loaded = readBundleConfig(root);
400
+ // Upstream can add a second row for the same serverName; the shim cannot stop
401
+ // DSH from loading both rows, but saying so is better than doubled tools with
402
+ // no stated cause. The status report carries the same fact for diagnostics.
403
+ const duplicate = foreignReaRegistration();
404
+ if (duplicate && !argv.includes("--shim-status")) {
405
+ process.stderr.write(
406
+ `\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
407
+ ` ${duplicate.patch} also registers serverName "rea" (id "${duplicate.id}").\n` +
408
+ ` This bundle's row is id "mcp-rea" with the same serverName; DSH rejects the later\n` +
409
+ ` entry as a duplicate server name, so only one survives. Keep exactly one of them.\n`,
410
+ );
411
+ }
282
412
 
283
413
  if (argv.includes("--shim-status")) {
284
414
  const report = {
@@ -288,6 +418,7 @@ function main() {
288
418
  reason: loaded.ok ? undefined : loaded.code,
289
419
  autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
290
420
  };
421
+ if (duplicate) report.duplicateRegistration = duplicate;
291
422
  if (loaded.ok) {
292
423
  report.paths = loaded.config.paths ?? {};
293
424
  report.versions = loaded.config.versions ?? {};
@@ -302,10 +433,31 @@ function main() {
302
433
  loaded.code === "missing"
303
434
  ? `No bundle state at ${loaded.file}.`
304
435
  : `Bundle state at ${loaded.file} could not be parsed.`;
305
- const logPath = loaded.code === "missing" ? maybeAutoProvision(root) : null;
306
- if (logPath) {
436
+ // Both "missing" and "malformed" start a run. A malformed file used to fail
437
+ // permanently: setup overwrites a bundle.json it cannot parse, but nothing
438
+ // ever started setup, so every restart reported the same EX_CONFIG until the
439
+ // operator deleted the file by hand. The lock stops a provisioning loop when
440
+ // writing keeps failing.
441
+ const provisioning = maybeAutoProvision(root);
442
+ if (provisioning.started) {
443
+ process.stderr.write(
444
+ `\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
445
+ );
446
+ } else if (provisioning.reason === "last-run-failed") {
447
+ process.stderr.write(
448
+ `\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
449
+ ` Nothing is running right now — read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
450
+ );
451
+ } else if (provisioning.reason === "in-flight") {
452
+ // A live run owns the lock. Saying nothing here is what made a wedged
453
+ // provisioning attempt look like a machine that simply has no bundle.
454
+ process.stderr.write(
455
+ `\n dsh-plugin-rea (bundle): provisioning is already running in the background.\n progress: ${provisioning.logPath}\n`,
456
+ );
457
+ } else if (provisioning.reason === "spawn-failed" || provisioning.reason === "unwritable") {
307
458
  process.stderr.write(
308
- `\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${logPath}\n`,
459
+ `\n dsh-plugin-rea (bundle): provisioning could not be started (${provisioning.reason}).\n` +
460
+ ` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}"\n`,
309
461
  );
310
462
  }
311
463
  return fail(message, loaded.detail);