dsh-plugin-rea 2.0.1 → 2.0.3

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,104 @@
2
2
 
3
3
  本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
4
4
 
5
+ ## 2.0.3 — 2026-10-16
6
+
7
+ **修复 11 处已核实的缺陷(每条都先确认存在、并配了"回退即变红"的回归测试);上游 REA 钉子从 6.4.0 升到 6.5.0。**
8
+
9
+ ### 上游
10
+
11
+ - `pins.reaAgents` 与 `components.reaAgents.sources.npm.version` → **6.5.0**:engines 与 bin 入口未变
12
+ (`^22.19.0 || ^24.11.0 || >=26.0.0`、`scripts/rea.mjs`),工具数 170 → **207**(运行时 `rea doctor --json` 实测;上游源码静态清点为 206,多出的 1 个注册在 contracts 之外。新增:Frida 9、
13
+ GDB 4、Rizin/Cutter 6、pcap/packet 4、flutter 2、sqlite/rust/go/wasm/electron/harmony/hopper 各 1 等)。
14
+ 新增的 `optionalDependencies.frida` 在 `--ignore-scripts` 下不会执行安装脚本。REA 探针正则收紧为
15
+ `^6\.5\.0(\s|$)`,审计新增"下一个 patch/minor 不得通过"的断言。
16
+ - Ghidra 12.1.4 / apktool 3.0.3 / jadx-headless-mcp 0.7.1 **经核实仍是当前最新**;Node 24.22.0 虽已发布,
17
+ 但晚于本插件的实测矩阵,故保持 24.21.0(engines 完全满足)。
18
+
19
+ ### 修复
20
+
21
+ - **`--only` 抹掉其它组件的记录**:bundle.json 由本轮结果整份重写,未点名组件的 `paths.jdk/ghidra` 变
22
+ `null`,shim 不再导出 `JAVA_HOME`/`GHIDRA_INSTALL_DIR`——原生分析静默失效,下次完整跑还得重新解压
23
+ 750 MB。未点名的组件现在按旧记录结转(且只在记录的路径仍然存在时),缺依赖时优先用旧记录/机器/运行
24
+ setup 的解释器来满足,而不是整段跳过检查。
25
+ - **完整的 `.part` 不被当作已完成**:只打一行日志,然后发 `Range: bytes=<全长>-`(416;四次重试后把
26
+ 已经下好的文件报成失败)。现在校验通过即改名收下。
27
+ - **Windows 下载主路线没有续传**:PowerShell 路线每轮先删 `.part`;失败后 Node 兜底仍用进本轮前的
28
+ `have`,以 append 把 200 全量接在半成品后面(长度必然超标,只能重下)。两条路线现在都按盘上实际
29
+ 长度发 Range 续传。
30
+ - **POSIX 的 apktool wrapper 没有执行位**(copyFileSync 落 0644,而 REA 不加 shell 直接 spawn → EACCES):
31
+ manifest 里标了 `executable` 的条目落盘时 chmod 0755。
32
+ - **apktool wrapper 钉在 `master` 分支 + 硬校验 sha256**:上游对 wrapper 的任何一次提交都会让全网新装
33
+ hash mismatch。改钉不可变的 `v3.0.3` tag,哈希按该 tag 重新实测。
34
+ - **`--reuse` 不校验版本**:JDK 只看 `javac` 存在、Ghidra 只看 `analyzeHeadless` 存在(JDK 8/11、Ghidra 11
35
+ 照收)。现在候选必须通过该组件自己的 probe;同时修好"文件型 probe 从不检查 `expect`"(Ghidra 的
36
+ `application.version=` 断言此前是死配置)。
37
+ - **`bundle.json` 损坏即永久失败**:setup 读不了它、也没人启动 setup,用户只能手删文件。现在 setup 容忍
38
+ 它(并按需重写),shim 对 `malformed` 与 `missing` 一样触发供给。
39
+ - **失败后 shim 谎报 6 小时**:lock 现在记录状态与 pid;失败态如实报告"上一次失败了",持有者已消失的
40
+ lock 不再阻塞重试。
41
+ - **成功的 `--json` 轮次从不删 lock**:删除语句原先在非 `--json` 分支里,--json 成功也留下 lock。
42
+ - **shim 兜底 REA 路径少了 `node_modules`**:旧 bundle.json 缺 `paths.reaEntry` 时会把可用安装判成"CLI 丢了"。
43
+ - **`scripts/rea-bundle-bootstrap.ps1`**:`node -v` 输出非数字时 `[int]` 强转在 `$ErrorActionPreference='Stop'`
44
+ 下会炸掉整个 bootstrap(而它存在的意义就是这条路径);`&` 调用失败时 `$LASTEXITCODE` 可能为 `$null` →
45
+ `exit $null` = 假成功;engines 改从 manifest 的 `pins.nodeEngines` 读(不再抄第二份);`-DryRun` 不再创建
46
+ 目录(它承诺"什么都没改")。
47
+ - **`check-updates.ps1` 的 1.x 残留**:REA 节默认找 npm 全局包(2.x 是 vendored)、Ghidra 节读用户级
48
+ `GHIDRA_INSTALL_DIR`、`rea doctor` 拿不到 vendored CLI(整段自检被静默跳过)、一体包对账对"未记录版本"
49
+ 的组件静默 `continue` 却打印"全部一致"。现在一律先读 `bundle.json`,并如实报告无法对账的项。
50
+
51
+ ### 审计与 CI
52
+
53
+ - `audit-manifest.mjs` 新增 4 项:manifest 组件 ⇄ `INSTALL_ORDER` 对账、`pins.nodeEngines` ⇄ `package.json
54
+ engines`、REA pin 探针不得放行下一个 patch/minor、POSIX wrapper 必须标 `executable`。
55
+ - `validate.yml` 新增 `windows-latest` job:PowerShell 下载路线、zip 解压与 bootstrap 此前是零 CI 覆盖的
56
+ Windows 专属代码。
57
+ - 单测 33 条(32 过 + 1 条按平台跳过);`--reuse adopts a compatible node` 在 engines 之外的宿主上改为显式
58
+ skip(此前在 Node 20 上必红,而 CI 钉 22 看不出来)。
59
+
60
+ ### 第二轮修复(同一份**更新后**的清单,2026-10-16 晚)
61
+
62
+ - 🔴 **过期 lock 永久卡死自动供给(2.0.0–2.0.2 三版全带)**:`maybeAutoProvision()` 只在 lock 新鲜时
63
+ 处理它,过期的 lock 既不清除也不报告 → 落到独占创建 → `EEXIST` → 返回 `in-flight`,而 `main()` 对
64
+ `in-flight` **一个字都不打印**。于是「下载被 kill / 断电」这一常态,在 6 小时后再启动就变成**永久不再
65
+ 自动供给且完全静默**。已按清单复现(7 小时死 lock:不启动、lock 原样、无任何提示;5 分钟 lock 对照组
66
+ 正常删锁重启),修法:**不新鲜或持有者已死就一律清锁**(这正是注释里"只在新鲜时尊重它"的本意),
67
+ 并给 `in-flight` / `spawn-failed` / `unwritable` 补上如实提示。
68
+ - 🔴 **`examples/` 两个文件是 1.x 内容,却随 2.x 进了 npm 包**:`cordis.patch.posix.yml` 教人把整行换成
69
+ `command: rea`、`cordis.patch.with-env.yml` 给的是 `cmd.exe /c rea` 的 1.x 行——对 2.x 全错(2.x 的行是
70
+ 跨平台的 `process.execPath` + bundle shim,照抄会整行绕过 bundle)。两份都已是孤儿(README 不再引用),
71
+ 故从 `main` **删除**,并同步 `package.json files[]` 与 `validate.yml` 的载荷/YAML/机器路径三项检查
72
+ (v1 分支保留它们,在 1.x 语境里是对的)。npm 载荷由此 21 → 19 个文件。
73
+ - 🟡 **`--offline` 管不住 `reaAgents`**:`provisionReaAgents()` 里 0 处 offline 判断,`npm install` 永远
74
+ 联网——与「`--offline` is a promise, not a hint」直接矛盾。现在 `--offline` 且内置副本不可用时**直接
75
+ FAIL** 并说明原因(而可用的内置副本仍照常跳过)。
76
+ - 🟡 **npm 那一步没有代理/镜像兜底**:压缩包有 direct→proxy→PowerShell 三路,npm 只读环境变量。失败时
77
+ 现在会打印这句提示,两份 README 的能力/注意里也写明 `HTTPS_PROXY` / `NPM_CONFIG_REGISTRY`。
78
+ - 🟡 **POSIX 缺 `unzip`/`xz` 必现失败**:Ghidra 全平台只有 zip,而 POSIX 解压原先只有 `unzip` 一条路。
79
+ 现在 zip 依次尝试 `unzip` → `python3 -m zipfile` → **已供给 JDK 自带的 `jar`**(JDK 在 Ghidra 之前供给,
80
+ 等于保底),tar 依次 `tar` → `python3`;三者皆无时打印明确的「no zip extractor available」而不是干巴巴的
81
+ 「extraction failed」。
82
+ - 🟡 **manifest 的 arm64 注释是错的**:Google 的 linux/darwin platform-tools 都是 x86_64 二进制,
83
+ 「The same archive serves x86-64 and arm64」不成立。三个条目改成如实描述(macOS 需 Rosetta 2、
84
+ linux-arm64 上 probe 预期失败并被报为可选组件不可用)。
85
+ - 🟡 **`docs/bundle-design.md` 与实现漂移**:§3 的「row points at `<plugin>/bin/rea-shim.mjs`」改成
86
+ 「按 `DSH_HOME` 算出 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`(镜像副本)」;§7 的
87
+ `vendor/rea-agents` 改成 `vendor/node_modules/rea-agents`。
88
+ - 🟢 **`release.yml` 的 `workflow_dispatch` 可绕过 Release 直接发 npm**:publish job 现在先断言
89
+ `v<package.json version>` 的 tag 在 origin 上存在、且该 Release 挂着版本无关的 `dsh-plugin-rea.tgz`
90
+ 资产,否则拒绝发布(否则 npm 与市场兜底链接会岔开)。
91
+ - 🟢 **数值环境变量会变成 NaN**:`REA_BUNDLE_FETCH_RETRIES=abc` → `Math.max(1, NaN)` → 报
92
+ 「gave up after NaN attempt(s)」;NaN 的 socket 超时等价于 0(立即超时)。现在非正有限值一律回落到默认,
93
+ 且启动日志新增一行 `limits connect=… idle=… retries=…`,让覆盖生效可见。
94
+ - 🟢 注释与整洁:`downloadViaPowerShell` 的文档说自己是 fallback,实际在 Windows 上是主路线(已改口径);
95
+ 去掉 setup.mjs 里 3 处对已静态导入成员的重复 `await import("node:fs")`(`readdirSync` 补进静态导入)。
96
+
97
+ ## 2.0.2 — 2026-10-15
98
+
99
+ **仅文档,无行为变更。**
100
+
101
+ - 两份 README 的**末尾**(许可证之前)新增一节,说明**什么时候该用 1.x 线**以及它的安装方式
102
+ (`dsh-plugin-rea@1.0.6` 或 `github:czcdr/dsh-plugin-rea#v1.0.6`)—— 此前 1.x 只出现在开头的版本说明与升级章节里。
5
103
  ## 2.0.1 — 2026-10-15
6
104
 
7
105
  **仅文档与版式,无行为变更。**
package/README.md CHANGED
@@ -179,11 +179,11 @@ exact shim launch the MCP row will use.
179
179
 
180
180
  ## Verified against
181
181
 
182
- Author's machine, 2026-10-13 / 14:
182
+ Author's machine, 2026-10-13 / 14 / 16:
183
183
 
184
184
  | Component | Version |
185
185
  |---|---|
186
- | REA (`rea-agents`) | **6.4.0** (170 tools) |
186
+ | REA (`rea-agents`) | **6.5.0** (207 tools) |
187
187
  | DeepSeek Harness | desktop runtime **0.2.0-rc.2**, with its bundled dsh CLI 0.1.7-rc.2 |
188
188
  | `@deepseek-ai/dsh-mcp-client` (row provider) | bundled by the harness |
189
189
  | Ghidra | 12.1.4 |
@@ -192,7 +192,7 @@ Author's machine, 2026-10-13 / 14:
192
192
  | Host | Windows 11 x64 (build 26200) |
193
193
 
194
194
  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
195
+ **through the shim with no `rea` on PATH** answered `serverInfo {name: rea, version: 6.5.0}`; and the whole
196
196
  chain ran under the real desktop host — its own Electron binary launched
197
197
  `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`, the shim started the **vendored** REA, and a live tool
198
198
  call reported `path_source: environment` for a path only that machine's `bundle.json` knows.
@@ -203,19 +203,28 @@ macOS/Linux.
203
203
 
204
204
  ## Scope and caveats
205
205
 
206
- - REA exposes 170 tools at 6.4.0, so every session's tool list grows.
206
+ - REA exposes 207 tools at 6.5.0, so every session's tool list grows.
207
207
  - Provisioning is honest about checksums: where a vendor publishes one it is pinned and verified; where none
208
208
  exists (jadx-headless-mcp, the apktool wrapper, Android platform-tools, the rolling "latest" URLs) the
209
209
  hash is **measured once and recorded**, and later drift is reported rather than silently accepted.
210
210
  `bundle.json` lists which components are in that state under `unverifiedChecksums`.
211
211
  - 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.
212
+ alone takes ~25–45 min). Fetching is resumable on both routes: a partial `.part` is continued with a Range
213
+ request, and a `.part` that already holds the whole file is verified and kept instead of re-fetched.
214
214
  - Some REA tools launch processes, write files and access the network — DSH's permission policy applies.
215
215
  Analyse only artifacts you are authorized to inspect.
216
216
  - `scripts/setup.mjs` runs `npm install` for the REA CLI with `--ignore-scripts`: the published tarball
217
217
  carries no `node_modules`, so a dependency closure must be resolved, and package lifecycle scripts are
218
- skipped on purpose during provisioning.
218
+ skipped on purpose during provisioning. That step is also the **only** one that cannot use this bundle's
219
+ own route/proxy resolution — npm reads `HTTPS_PROXY`/`HTTP_PROXY` and `NPM_CONFIG_REGISTRY` from the
220
+ environment, so on a network that needs a mirror, set one of those before provisioning
221
+ (`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`). With `--offline` the step is refused outright
222
+ instead of quietly going online.
223
+ - On POSIX, extraction falls back when the usual tools are missing: a zip is opened with `unzip`, then
224
+ `python3 -m zipfile`, then the provisioned JDK's `jar`; a tarball with `tar`, then `python3`. A minimal
225
+ image that has none of them fails with an explicit "no zip extractor available" line rather than a bare
226
+ "extraction failed". This matters because Ghidra is a zip on every platform and `unzip` is not installed
227
+ everywhere by default.
219
228
 
220
229
  ## Updating — three independent things
221
230
 
@@ -248,6 +257,22 @@ back on the next start.
248
257
 
249
258
  `check-updates.ps1` (Windows PowerShell) reports the state of all of these at once.
250
259
 
260
+ ## Still prefer the configuration-only 1.x line?
261
+
262
+ 1.x is the adapter **without** provisioning: one row that launches `rea mcp` from PATH and expects Ghidra to be
263
+ installed by you. It is the right choice when you already manage REA and Ghidra yourself, when you do not want
264
+ the plugin fetching anything, or when you want the row to keep pointing at your own `rea` binary:
265
+
266
+ ```
267
+ dsh plugin --profile <profile> add dsh-plugin-rea@1.0.6
268
+ # or pin the branch instead of the version:
269
+ dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v1.0.6
270
+ ```
271
+
272
+ Both lines publish the same patch shape and the same `mcp__rea__*` surface. 1.x is maintained on the
273
+ [`v1` branch](https://github.com/czcdr/dsh-plugin-rea/tree/v1), where its own install notes live (Node on REA's
274
+ engines, `npm i -g rea-agents`, Ghidra 12.1.x for native analysis).
275
+
251
276
  ## License
252
277
 
253
278
  MIT (this adapter). REA is MIT and owned by its authors; Ghidra is Apache-2.0 and owned by the NSA; Temurin
package/README.zh-CN.md CHANGED
@@ -170,11 +170,11 @@ shim 启动**。
170
170
 
171
171
  ## 实测环境
172
172
 
173
- 作者机器,2026-10-13 / 14:
173
+ 作者机器,2026-10-13 / 14 / 16:
174
174
 
175
175
  | 组件 | 版本 |
176
176
  |---|---|
177
- | REA(`rea-agents`) | **6.4.0**(170 个工具) |
177
+ | REA(`rea-agents`) | **6.5.0**(207 个工具) |
178
178
  | DeepSeek Harness | desktop runtime **0.2.0-rc.2**,内含 dsh CLI 0.1.7-rc.2 |
179
179
  | `@deepseek-ai/dsh-mcp-client`(行 provider) | 随 harness 打包 |
180
180
  | Ghidra | 12.1.4 |
@@ -183,7 +183,7 @@ shim 启动**。
183
183
  | 宿主 | Windows 11 x64(build 26200) |
184
184
 
185
185
  实测跑过:一次干净的 `setup.mjs --reuse` 走到 `setup complete`;一次**不经 PATH 上的 `rea`、完全经由
186
- shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.4.0}`;以及**真实桌面宿主下的完整链路** ——
186
+ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}`;以及**真实桌面宿主下的完整链路** ——
187
187
  宿主自己的 Electron 二进制启动 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`,shim 拉起 **bundle 内置**
188
188
  的 REA,随后一次真实工具调用报出 `path_source: environment`,而那个路径只有该机的 `bundle.json` 知道。
189
189
 
@@ -192,18 +192,27 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.4.0}
192
192
 
193
193
  ## 边界与注意事项
194
194
 
195
- - REA 在 6.4.0 有 170 个工具,每个会话的工具列表都会变长。
195
+ - REA 在 6.5.0 有 207 个工具,每个会话的工具列表都会变长。
196
196
  - 校验和对得起良心:厂商发布了的就固定并验证;没发布的(jadx-headless-mcp、apktool 包装脚本、
197
197
  Android platform-tools、滚动的 `latest` 地址)则**实测一次并记录**,之后若漂移是**报告**而不是默默接受。
198
198
  `bundle.json` 的 `unverifiedChecksums` 会列出处于该状态的组件。
199
199
  - 某些网络下很慢(这里实测 GitHub release CDN 只有 0.35–0.54 MiB/s,光 Ghidra 就要 25–45 分钟)。
200
- 下载是**可续传**的:缓存保留并重新校验,被中断的一轮会继续而不是从头再来。
200
+ 下载是**可续传**的:两条路线都按盘上已有的字节发 Range 继续;若 `.part` 恰好已经完整,会校验后直接收下
201
+ 而不是重下。
201
202
  - **代理**:Node 的全局 `fetch` 不读系统代理,而 .NET/PowerShell 会读。在开着 WinINET 代理的机器上,
202
203
  同一个地址 `Invoke-WebRequest` 通、Node 却 `ECONNRESET`/超时。setup 自己解析代理
203
204
  (环境变量优先,Windows 上回退读注册表)并用 CONNECT 隧道,`NO_PROXY` 也照常生效。
204
205
  - REA 的部分工具会启动进程、写文件、访问网络 —— DSH 的权限策略照常适用。只分析你有权检视的样本。
205
206
  - `scripts/setup.mjs` 为 REA CLI 跑的是带 `--ignore-scripts` 的 `npm install`:官方 tarball 不带
206
- `node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。
207
+ `node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。这一步也是**唯一**用不上
208
+ 本插件自己的路由/代理回退的环节 —— npm 只从环境读 `HTTPS_PROXY`/`HTTP_PROXY` 与
209
+ `NPM_CONFIG_REGISTRY`,所以网络需要镜像时请先设其中一个(例如
210
+ `NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`)。带 `--offline` 时这一步会被**直接拒绝**,
211
+ 而不是悄悄联网。
212
+ - POSIX 上解压有兜底:zip 依次尝试 `unzip` → `python3 -m zipfile` → 已供给 JDK 自带的 `jar`;tar 包依次
213
+ 尝试 `tar` → `python3`。三者都没有的最小镜像会得到一句明确的「no zip extractor available」,而不是
214
+ 干巴巴的「extraction failed」。Ghidra 在所有平台都只有 zip,而 `unzip` 并非每个发行版默认安装,
215
+ 所以这条不是理论问题。
207
216
 
208
217
  ## 更新 —— 三件互相独立的事
209
218
 
@@ -233,6 +242,21 @@ node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-boot
233
242
 
234
243
  `check-updates.ps1`(Windows PowerShell)会一次性报告以上各项的状态。
235
244
 
245
+ ## 还想用纯配置的 1.x 线?
246
+
247
+ 1.x 是**不带供给**的适配器:一行配置,从 PATH 上启动 `rea mcp`,并假设 Ghidra 由你自己装好。这些情况它是更合适的选择 ——
248
+ 你已经自己管理 REA 与 Ghidra、不希望插件去下载任何东西、或者想让那一行继续指向你自己的 `rea`:
249
+
250
+ ```
251
+ dsh plugin --profile <profile> add dsh-plugin-rea@1.0.6
252
+ # 或钉住分支而不是版本号:
253
+ dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v1.0.6
254
+ ```
255
+
256
+ 两条线发布的 patch 形态与 `mcp__rea__*` 工具面完全相同。1.x 维护在
257
+ [`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),它自己的安装说明也在那里(Node 满足 REA engines、
258
+ `npm i -g rea-agents`、原生分析需要 Ghidra 12.1.x)。
259
+
236
260
  ## 许可证
237
261
 
238
262
  MIT(本适配器)。REA 是 MIT、归其作者;Ghidra 是 Apache-2.0、归 NSA;Temurin 是 GPLv2+CE;Node.js 是
@@ -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,40 @@ function provisioningScript(root) {
93
93
  return usableSetup(PLUGIN_ROOT);
94
94
  }
95
95
 
96
+ /**
97
+ * The provisioning lock is a small JSON record: { state, pid, at, exit? }.
98
+ * `state: "running"` is written when a run is started (setup's own pid once it
99
+ * exists), and the failing run rewrites it as `state: "failed"` — it is kept on
100
+ * purpose so a broken setup cannot be re-downloaded in a loop. A legacy
101
+ * plain-number lock is still honoured as "running".
102
+ */
103
+ const STALE_LOCK_MS = 6 * 60 * 60 * 1000;
104
+
105
+ function readLock(lock) {
106
+ try {
107
+ const raw = readFileSync(lock, "utf8").trim();
108
+ if (raw.startsWith("{")) {
109
+ const parsed = JSON.parse(raw);
110
+ if (parsed && typeof parsed === "object") return parsed;
111
+ }
112
+ const at = Number(raw);
113
+ return Number.isFinite(at) ? { state: "running", at } : null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /** Is the process recorded in the lock still alive? null means "cannot tell". */
120
+ function pidAlive(pid) {
121
+ if (!Number.isInteger(pid) || pid <= 0) return null;
122
+ try {
123
+ process.kill(pid, 0);
124
+ return true;
125
+ } catch (error) {
126
+ return error?.code === "ESRCH" ? false : null;
127
+ }
128
+ }
129
+
96
130
  /**
97
131
  * On first run there is nothing to launch. Provisioning is a long download, so
98
132
  * it is started detached: the harness may kill this row (or the whole session)
@@ -100,34 +134,58 @@ function provisioningScript(root) {
100
134
  * a killed attempt is simply continued on the next start.
101
135
  *
102
136
  * 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.
137
+ * Returns { started, reason, logPath } — the caller words its message from
138
+ * `reason`, because "provisioning started in the background" was also printed
139
+ * for a lock left behind by a run that had already failed, and for one whose
140
+ * process was long gone.
104
141
  */
105
142
  function maybeAutoProvision(root) {
106
- if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return null;
143
+ if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return { started: false, reason: "disabled" };
107
144
  const setupScript = provisioningScript(root);
108
- if (!setupScript) return null;
145
+ if (!setupScript) return { started: false, reason: "no-setup" };
109
146
  const lock = path.join(root, "setup.lock");
110
147
  const logPath = path.join(root, "logs", "setup.log");
111
148
  if (existsSync(lock)) {
112
149
  // A crashed run must not wedge provisioning forever: honour the lock only
113
150
  // while it is fresh; setup removes it itself when it finishes cleanly.
114
- let age = Infinity;
115
- try {
116
- age = Date.now() - Number(readFileSync(lock, "utf8").trim() || 0);
117
- } catch {
118
- age = Infinity;
151
+ const record = readLock(lock);
152
+ const age = record && Number.isFinite(record.at) ? Date.now() - record.at : Infinity;
153
+ const fresh = Number.isFinite(age) && age < STALE_LOCK_MS;
154
+ if (fresh && record?.state === "failed") {
155
+ // The run gave up and left the lock behind so it would not be retried in a
156
+ // loop. Claiming it is still provisioning was a six-hour lie: nothing runs,
157
+ // and every restart repeated the sentence.
158
+ return {
159
+ started: false,
160
+ reason: "last-run-failed",
161
+ exit: record.exit,
162
+ failedAt: record.at,
163
+ ageMs: age,
164
+ logPath,
165
+ };
166
+ }
167
+ if (fresh) {
168
+ const alive = pidAlive(record?.pid);
169
+ if (alive !== false) return { started: false, reason: "in-flight", logPath, ageMs: age };
119
170
  }
120
- if (Number.isFinite(age) && age < 6 * 60 * 60 * 1000) return logPath;
171
+ // Reached when the lock is older than the freshness window, or its holder is
172
+ // gone and never wrote an outcome (harness restart, power loss, a kill): what
173
+ // it records is not happening any more. This removal is the whole point of the
174
+ // freshness rule — without it the exclusive create below failed with EEXIST
175
+ // *forever*, auto-provisioning never ran again, and main() printed nothing at
176
+ // all for that outcome. The lock's own "honour it only while it is fresh"
177
+ // comment described behaviour the code did not have.
178
+ rmSync(lock, { force: true });
121
179
  }
122
180
  try {
123
181
  mkdirSync(path.join(root, "logs"), { recursive: true });
124
182
  // Exclusive create: check-then-write is not atomic, so two rows starting at
125
183
  // once could both decide to provision. EEXIST means another run owns it.
126
- writeFileSync(lock, String(Date.now()), { flag: "wx" });
184
+ writeFileSync(lock, JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }), { flag: "wx" });
127
185
  } catch (error) {
128
- if (error?.code === "EEXIST") return logPath;
186
+ if (error?.code === "EEXIST") return { started: false, reason: "in-flight", logPath };
129
187
  /* a read-only bundle root must never break the harness */
130
- return null;
188
+ return { started: false, reason: "unwritable" };
131
189
  }
132
190
  try {
133
191
  const child = spawn(process.execPath, [setupScript, "--root", root], {
@@ -136,9 +194,18 @@ function maybeAutoProvision(root) {
136
194
  windowsHide: true,
137
195
  });
138
196
  child.unref();
139
- return logPath;
197
+ // Hand the lock to the detached downloader, so a later start that finds this
198
+ // row dead still sees the run that is actually doing the work as alive.
199
+ if (Number.isInteger(child.pid)) {
200
+ try {
201
+ writeFileSync(lock, JSON.stringify({ state: "running", pid: child.pid, at: Date.now(), supervisor: process.pid }));
202
+ } catch {
203
+ /* the lock is advisory: the row keeps working without it */
204
+ }
205
+ }
206
+ return { started: true, reason: "started", logPath };
140
207
  } catch {
141
- return null;
208
+ return { started: false, reason: "spawn-failed", logPath };
142
209
  }
143
210
  }
144
211
 
@@ -195,8 +262,13 @@ function readBundleConfig(root) {
195
262
  function resolveRea(config, root) {
196
263
  const paths = config.paths ?? {};
197
264
  const node = paths.node;
265
+ // The fallback has to match where setup actually installs the package:
266
+ // `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
267
+ // fallback without that segment named a directory that never exists. It only
268
+ // fires when an older bundle.json has no paths.reaEntry — which is exactly when
269
+ // a wrong fallback turns a usable install into "the vendored REA CLI is gone".
198
270
  const reaEntry = paths.reaEntry
199
- ?? path.join(root, "vendor", "rea-agents", "scripts", "rea.mjs");
271
+ ?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
200
272
 
201
273
  if (typeof node !== "string" || node.trim() === "") {
202
274
  return { ok: false, reason: "bundle.json has no paths.node" };
@@ -302,10 +374,31 @@ function main() {
302
374
  loaded.code === "missing"
303
375
  ? `No bundle state at ${loaded.file}.`
304
376
  : `Bundle state at ${loaded.file} could not be parsed.`;
305
- const logPath = loaded.code === "missing" ? maybeAutoProvision(root) : null;
306
- if (logPath) {
377
+ // Both "missing" and "malformed" start a run. A malformed file used to fail
378
+ // permanently: setup overwrites a bundle.json it cannot parse, but nothing
379
+ // ever started setup, so every restart reported the same EX_CONFIG until the
380
+ // operator deleted the file by hand. The lock stops a provisioning loop when
381
+ // writing keeps failing.
382
+ const provisioning = maybeAutoProvision(root);
383
+ if (provisioning.started) {
384
+ process.stderr.write(
385
+ `\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
386
+ );
387
+ } else if (provisioning.reason === "last-run-failed") {
388
+ process.stderr.write(
389
+ `\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
390
+ ` Nothing is running right now — read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
391
+ );
392
+ } else if (provisioning.reason === "in-flight") {
393
+ // A live run owns the lock. Saying nothing here is what made a wedged
394
+ // provisioning attempt look like a machine that simply has no bundle.
395
+ process.stderr.write(
396
+ `\n dsh-plugin-rea (bundle): provisioning is already running in the background.\n progress: ${provisioning.logPath}\n`,
397
+ );
398
+ } else if (provisioning.reason === "spawn-failed" || provisioning.reason === "unwritable") {
307
399
  process.stderr.write(
308
- `\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${logPath}\n`,
400
+ `\n dsh-plugin-rea (bundle): provisioning could not be started (${provisioning.reason}).\n` +
401
+ ` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}"\n`,
309
402
  );
310
403
  }
311
404
  return fail(message, loaded.detail);
package/check-updates.ps1 CHANGED
@@ -69,13 +69,40 @@ function Test-NodeEngines([string]$node, [string]$engines) {
69
69
  }
70
70
 
71
71
  # ------------------------------------------------------- 定位 REA 与 profile
72
+ # 2.x 的 REA 在 <bundleRoot>\vendor\node_modules\rea-agents,不在 npm 全局。先读
73
+ # bundle.json:把全局路径当默认是 1.x 的答案,在 2.x 机器上会让这一节指向错误的
74
+ # 对象("全局包丢了"),并给出错误的升级动作(npm i -g)。
75
+ $bundleRootEarly = $env:REA_BUNDLE_ROOT
76
+ if (-not $bundleRootEarly) {
77
+ $homeEarly = $env:DSH_HOME
78
+ if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
79
+ $bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
80
+ }
81
+ $bundleState = $null
82
+ $bundleJsonEarly = Join-Path $bundleRootEarly 'bundle.json'
83
+ if (Test-Path $bundleJsonEarly) {
84
+ try { $bundleState = Get-Content $bundleJsonEarly -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $bundleState = $null }
85
+ }
86
+ $bundleReaPackageDir = $null
87
+ $bundleReaEntry = $null
88
+ if ($bundleState -and $bundleState.paths.reaEntry) {
89
+ $bundleReaEntry = [string]$bundleState.paths.reaEntry
90
+ # <vendor>\node_modules\rea-agents\scripts\rea.mjs -> <vendor>\node_modules\rea-agents
91
+ $bundleReaPackageDir = Split-Path -Parent (Split-Path -Parent $bundleReaEntry)
92
+ }
93
+
72
94
  if (-not $ReaPackageDir) {
73
- $npmRoot = $null
74
- if (Get-Command npm -ErrorAction SilentlyContinue) {
75
- try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
95
+ if ($bundleReaPackageDir -and (Test-Path (Join-Path $bundleReaPackageDir 'package.json'))) {
96
+ $ReaPackageDir = $bundleReaPackageDir
97
+ } else {
98
+ $npmRoot = $null
99
+ if (Get-Command npm -ErrorAction SilentlyContinue) {
100
+ try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
101
+ }
102
+ $ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
76
103
  }
77
- $ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
78
104
  }
105
+ $reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaPackageDir)
79
106
  if (-not $ProfileDir) {
80
107
  if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
81
108
  else {
@@ -94,11 +121,21 @@ if (Test-Path $pkgFile) {
94
121
  $installed = $pkg.version; $engines = $pkg.engines.node
95
122
  $binPath = ($pkg.bin.PSObject.Properties | Select-Object -First 1).Value
96
123
  if ($binPath) { $reaEntry = Join-Path $ReaPackageDir ($binPath -replace '/', '\') }
97
- Ok "本地已安装: $installed"
98
- Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
124
+ if ($reaFromBundle) {
125
+ Ok "一体包内置(vendored): $installed"
126
+ Info "位置: $ReaPackageDir"
127
+ Info "入口(bin): $binPath"
128
+ } else {
129
+ Ok "本地已安装: $installed"
130
+ Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
131
+ }
99
132
  if ($engines) { Info "Node 要求: $engines" }
100
133
  } catch { Bad "package.json 解析失败: $($_.Exception.Message)" }
101
- } else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
134
+ } else {
135
+ if ($reaFromBundle) { Bad "找不到 $pkgFile —— 内置副本不完整?重跑 setup.mjs" }
136
+ elseif ($bundleReaPackageDir) { Bad "找不到 $pkgFile —— 一体包内置副本也缺($bundleReaPackageDir);记录里没有 reaEntry" }
137
+ else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
138
+ }
102
139
 
103
140
  $latest = $null
104
141
  foreach ($r in @($Registry, 'https://registry.npmjs.org')) {
@@ -110,7 +147,11 @@ if ($installed -and $latest) {
110
147
  $vi = Get-NumericVersion $installed; $vl = Get-NumericVersion $latest
111
148
  if ($vi -and $vl -and $vl -gt $vi) {
112
149
  Warn "有新版本:$installed → $latest"
113
- $actions.Add("升级上游 REA: npm i -g rea-agents@$latest (只更新 REA;本插件不会自动更新——从市场装的在市场里更新,file: 装的覆盖目录后重装)")
150
+ if ($reaFromBundle) {
151
+ $actions.Add("升级一体包内置 REA:把 manifest 的 pins.reaAgents / components.reaAgents.sources.npm.version 改成 $latest,然后重跑 node scripts\setup.mjs(内置副本不会自己更新)")
152
+ } else {
153
+ $actions.Add("升级上游 REA: npm i -g rea-agents@$latest (只更新 REA;本插件不会自动更新——从市场装的在市场里更新,file: 装的覆盖目录后重装)")
154
+ }
114
155
  } elseif ($vi -and $vl -and $vl -eq $vi -and $installed -ne $latest) {
115
156
  Warn "同版本号但通道不同:$installed → $latest(预发布/正式)"
116
157
  } else { Ok '已是最新版本' }
@@ -139,7 +180,14 @@ if (Get-Command node -ErrorAction SilentlyContinue) {
139
180
 
140
181
  # ------------------------------------------------------------ Ghidra 引擎
141
182
  Head 'Ghidra 引擎'
142
- $gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User')
183
+ # bundle.json 是 2.x 的真源:shim 只在 MCP 子进程里设 GHIDRA_INSTALL_DIR,用户级
184
+ # 变量是 1.x 的写法,没手工设过的机器会被误报"未设置"。
185
+ $gd = $null
186
+ if ($bundleState -and $bundleState.paths.ghidra) {
187
+ $bundleGhidra = [string]$bundleState.paths.ghidra
188
+ if (Test-Path $bundleGhidra) { $gd = $bundleGhidra }
189
+ }
190
+ if (-not $gd) { $gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User') }
143
191
  if (-not $gd) { $gd = $env:GHIDRA_INSTALL_DIR }
144
192
  if ($gd -and (Test-Path $gd)) {
145
193
  Ok "GHIDRA_INSTALL_DIR = $gd"
@@ -162,11 +210,28 @@ try {
162
210
  # --------------------------------------------------------- REA 引擎自检
163
211
  Head 'REA 引擎自检(rea doctor)'
164
212
  $env:GHIDRA_INSTALL_DIR = $gd
165
- # 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
166
- # 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
167
- $reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
168
- if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
213
+ # 2.x:优先用 bundle 内置的 CLI 和它自己的 Node。PATH 上的 rea 可能是另一个,
214
+ # 甚至完全不在 PATH —— 那会让整段自检被静默跳过。这正是 1.x 口径的残留。
215
+ $reaCmd = $null
169
216
  $reaArgs = @()
217
+ if ($bundleReaEntry -and (Test-Path $bundleReaEntry)) {
218
+ $bundleNode = $null
219
+ if ($bundleState -and $bundleState.paths.node) {
220
+ $candidateNode = [string]$bundleState.paths.node
221
+ if (Test-Path $candidateNode) { $bundleNode = $candidateNode }
222
+ }
223
+ if (-not $bundleNode) { $bundleNode = (Get-Command node -ErrorAction SilentlyContinue).Source }
224
+ if ($bundleNode) {
225
+ $reaCmd = $bundleNode; $reaArgs = @($bundleReaEntry)
226
+ Info '使用一体包内置 REA(node <bundle 入口>),不依赖 PATH 上的 rea'
227
+ }
228
+ }
229
+ if (-not $reaCmd) {
230
+ # 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
231
+ # 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
232
+ $reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
233
+ if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
234
+ }
170
235
  if (-not $reaCmd) {
171
236
  if ($reaEntry -and (Test-Path $reaEntry) -and (Get-Command node -ErrorAction SilentlyContinue)) {
172
237
  $reaCmd = 'node'; $reaArgs = @($reaEntry)
@@ -247,6 +312,8 @@ if (-not (Test-Path $manifestPath)) {
247
312
  $state = Get-Content $bundleJson -Raw -Encoding UTF8 | ConvertFrom-Json
248
313
  Ok "已供给:$($state.provisionedAt)"
249
314
  $drift = 0
315
+ $compared = 0
316
+ $unrecorded = 0
250
317
  foreach ($name in $manifest.pins.PSObject.Properties.Name) {
251
318
  if ($name -eq 'nodeEngines') { continue }
252
319
  $pinned = [string]$manifest.pins.$name
@@ -254,13 +321,21 @@ if (-not (Test-Path $manifestPath)) {
254
321
  if ($state.versions -and ($state.versions.PSObject.Properties.Name -contains $name)) {
255
322
  $installed = [string]$state.versions.$name
256
323
  }
257
- if (-not $installed) { continue }
324
+ if (-not $installed) {
325
+ # 被 --reuse 采纳的组件没有 version 字段(bundle.json 里是 null)。静默
326
+ # continue 会让这一项从未被比对,却在下面打印"全部一致"——那是假绿。
327
+ $unrecorded++
328
+ Warn " $name 未记录版本(reuse 采纳的组件?)—— 无法与 pin $pinned 对账"
329
+ continue
330
+ }
331
+ $compared++
258
332
  # Node 的 pin 是引擎范围,不是版本号;其它组件比前缀即可
259
333
  $same = $installed.Equals($pinned) -or $installed.StartsWith(($pinned -split '\.')[0] + '.')
260
334
  if ($same) { Info " $name = $installed(pin $pinned)" }
261
335
  else { Warn "$name 盘上为 $installed,manifest 已钉 $pinned —— 重跑 setup 对齐"; $drift++ }
262
336
  }
263
- if ($drift -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
337
+ if ($drift -eq 0 -and $unrecorded -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
338
+ elseif ($drift -eq 0) { Warn "已比对的 $compared 项与 pin 一致,但 $unrecorded 项没有记录版本(见上)—— 不是全绿" }
264
339
  if ($state.unverifiedChecksums -and @($state.unverifiedChecksums).Count -gt 0) {
265
340
  Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
266
341
  }