dsh-plugin-rea 2.0.3 → 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,35 @@
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
+
5
34
  ## 2.0.3 — 2026-10-16
6
35
 
7
36
  **修复 11 处已核实的缺陷(每条都先确认存在、并配了"回退即变红"的回归测试);上游 REA 钉子从 6.4.0 升到 6.5.0。**
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
@@ -213,6 +242,11 @@ macOS/Linux.
213
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
252
  skipped on purpose during provisioning. That step is also the **only** one that cannot use this bundle's
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` 会报引擎各项检查
@@ -203,6 +229,10 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}
203
229
  同一个地址 `Invoke-WebRequest` 通、Node 却 `ECONNRESET`/超时。setup 自己解析代理
204
230
  (环境变量优先,Windows 上回退读注册表)并用 CONNECT 隧道,`NO_PROXY` 也照常生效。
205
231
  - REA 的部分工具会启动进程、写文件、访问网络 —— DSH 的权限策略照常适用。只分析你有权检视的样本。
232
+ - MCP 子进程与它拉起的引擎都是以**你的身份**运行的普通进程。DSH 的工具审批管的是模型发起的调用,
233
+ 管不到这个子进程自己去读、写或发送什么;请把插件当作你安装的第三方代码来对待。
234
+ - Linux 上,上游的进程归属检查依赖 PATH 上**兼容 procps 的 `ps`**;Ghidra/JADX 与固件工具会用到它。
235
+ 极简镜像里缺它只会让这些工具失败,不影响整体安装。
206
236
  - `scripts/setup.mjs` 为 REA CLI 跑的是带 `--ignore-scripts` 的 `npm install`:官方 tarball 不带
207
237
  `node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。这一步也是**唯一**用不上
208
238
  本插件自己的路由/代理回退的环节 —— npm 只从环境读 `HTTPS_PROXY`/`HTTP_PROXY` 与
package/bin/rea-shim.mjs CHANGED
@@ -93,6 +93,43 @@ 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
+
96
133
  /**
97
134
  * The provisioning lock is a small JSON record: { state, pid, at, exit? }.
98
135
  * `state: "running"` is written when a run is started (setup's own pid once it
@@ -175,7 +212,16 @@ function maybeAutoProvision(root) {
175
212
  // *forever*, auto-provisioning never ran again, and main() printed nothing at
176
213
  // all for that outcome. The lock's own "honour it only while it is fresh"
177
214
  // comment described behaviour the code did not have.
178
- rmSync(lock, { force: true });
215
+ try {
216
+ rmSync(lock, { force: true });
217
+ } catch {
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 };
224
+ }
179
225
  }
180
226
  try {
181
227
  mkdirSync(path.join(root, "logs"), { recursive: true });
@@ -351,6 +397,18 @@ function main() {
351
397
 
352
398
  const root = bundleRoot();
353
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
+ }
354
412
 
355
413
  if (argv.includes("--shim-status")) {
356
414
  const report = {
@@ -360,6 +418,7 @@ function main() {
360
418
  reason: loaded.ok ? undefined : loaded.code,
361
419
  autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
362
420
  };
421
+ if (duplicate) report.duplicateRegistration = duplicate;
363
422
  if (loaded.ok) {
364
423
  report.paths = loaded.config.paths ?? {};
365
424
  report.versions = loaded.config.versions ?? {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-rea",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "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。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -813,9 +813,17 @@ function makeBundleTree(root, { marker, lock }) {
813
813
  return bundle;
814
814
  }
815
815
 
816
- function runShim(bundle, args) {
817
- const env = { ...process.env, REA_BUNDLE_ROOT: bundle };
816
+ function runShim(bundle, args, extraEnv = {}) {
817
+ // A fresh home keeps the duplicate-registration check from reading this
818
+ // machine's real ~/.dsh/cordis.patch.yml and making the suite depend on it.
819
+ const env = {
820
+ ...process.env,
821
+ DSH_HOME: path.join(path.dirname(bundle), "dsh-home"),
822
+ REA_BUNDLE_ROOT: bundle,
823
+ ...extraEnv,
824
+ };
818
825
  delete env.REA_BUNDLE_AUTO_PROVISION;
826
+ mkdirSync(env.DSH_HOME, { recursive: true });
819
827
  return spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), ...args], { encoding: "utf8", timeout: 60000, env });
820
828
  }
821
829
 
@@ -896,6 +904,71 @@ test("an expired lock is cleared instead of wedging provisioning forever", async
896
904
  }
897
905
  });
898
906
 
907
+ test("a read-only bundle root reports unwritable instead of crashing on the stale lock", { skip: process.platform === "win32" ? "POSIX directory permissions only" : false }, () => {
908
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-readonly-"));
909
+ try {
910
+ const stale = Date.now() - 7 * 60 * 60 * 1000;
911
+ const lock = JSON.stringify({ state: "running", pid: 999999, at: stale });
912
+ const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock });
913
+ chmodSync(bundle, 0o555);
914
+ try {
915
+ const run = runShim(bundle, ["mcp"], { DSH_HOME: path.join(root, "dsh-home") });
916
+ assert.equal(run.status, 78, run.stderr);
917
+ // Before the fix this was an uncaught EACCES stack trace and exit 1: the
918
+ // stale lock could not be removed, and nothing caught the throw.
919
+ assert.match(run.stderr, /provisioning could not be started \(unwritable\)/);
920
+ assert.doesNotMatch(run.stderr, /EACCES/);
921
+ } finally {
922
+ chmodSync(bundle, 0o755);
923
+ }
924
+ } finally {
925
+ rmSync(root, { recursive: true, force: true });
926
+ }
927
+ });
928
+
929
+ test("a second REA registration is reported instead of silently duplicated", () => {
930
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-duplicate-"));
931
+ try {
932
+ const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock: undefined });
933
+ const home = path.join(root, "dsh-home");
934
+ mkdirSync(home, { recursive: true });
935
+ writeFileSync(
936
+ path.join(home, "cordis.patch.yml"),
937
+ ["- insert:", " - id: rea-mcp", " name: '@deepseek-ai/dsh-mcp-client'", " config:", " serverName: rea", " transport: stdio", ""].join("\n"),
938
+ );
939
+ const run = runShim(bundle, ["mcp"], { DSH_HOME: home });
940
+ assert.equal(run.status, 78, run.stderr);
941
+ assert.match(run.stderr, /duplicate REA registration/);
942
+ assert.match(run.stderr, /id "rea-mcp"/);
943
+
944
+ const status = spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), "--shim-status"], {
945
+ encoding: "utf8",
946
+ env: { ...process.env, DSH_HOME: home, REA_BUNDLE_ROOT: bundle },
947
+ });
948
+ assert.equal(JSON.parse(status.stdout).duplicateRegistration.id, "rea-mcp");
949
+ } finally {
950
+ rmSync(root, { recursive: true, force: true });
951
+ }
952
+ });
953
+
954
+ test("this bundle's own row is not reported as a duplicate", () => {
955
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-own-row-"));
956
+ try {
957
+ const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock: undefined });
958
+ const home = path.join(root, "dsh-home");
959
+ mkdirSync(home, { recursive: true });
960
+ writeFileSync(
961
+ path.join(home, "cordis.patch.yml"),
962
+ ["- insert:", " - id: mcp-rea", " name: '@deepseek-ai/dsh-mcp-client'", " config:", " serverName: rea", ""].join("\n"),
963
+ );
964
+ const run = runShim(bundle, ["mcp"], { DSH_HOME: home });
965
+ assert.equal(run.status, 78, run.stderr);
966
+ assert.doesNotMatch(run.stderr, /duplicate REA registration/);
967
+ } finally {
968
+ rmSync(root, { recursive: true, force: true });
969
+ }
970
+ });
971
+
899
972
  /* ------------------------------- --offline is a promise, and npm is the one step
900
973
  * that cannot keep it: it is an install, not a download. -------------------- */
901
974