dsh-plugin-rea 2.0.3 → 2.0.5

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,65 @@
2
2
 
3
3
  本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
4
4
 
5
+ ## 2.0.5 — 2026-10-18
6
+
7
+ **市场更新失败已定位并修复:清单里没有 `main`,市场因此在每次更新时判「新构建没有可加载入口」并回滚。**
8
+
9
+ ### 修复
10
+
11
+ - **应用内市场更新到 2.0.4 失败**:`dshmarket` 的更新路径用 `hasLoadableEntry` 判断新构建能不能加载
12
+ (实现是 `entryArtifactExists`:读 `main`/`exports`,两者都没有就回落 `index.js`)。本包是纯 bundle
13
+ (只有 `dsh.bundle.patch`),从未声明 `main`;而它挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
14
+ 构建、不在 `node_modules` 里,于是该检查要看的 profile / 包内 / workspace 三个位置全空。结果:2.0.1 → 2.0.4
15
+ 的更新连续三次报「更新后缺少入口文件……且未能验证恢复原版本文件」,而回滚其实成功了 —— 旧版同样没有入口,
16
+ 所以回滚后的复检依然为 false。现已声明 `main: ./index.mjs`,并让该文件进入 `files[]`。
17
+ - **`index.mjs` 是真空插件,不是占位**:导出 `name` 与空 `apply()`,不注册行、不起进程、不写状态;真正起作用的
18
+ 行仍由 `cordis.patch.yml` 插入。若 harness 某天真把它当插件加载,行为仍是「什么都不做」,因此不会造出第二条
19
+ `serverName: rea`。
20
+
21
+ ### 文档
22
+
23
+ - 两份 README 新增「在市场里更新 / Updating from the market」小节:说明该报错来自市场侧的入口判定、与 tarball
24
+ 完整性无关、profile 未受损,以及 2.0.5 起的做法。
25
+
26
+ ### 验证
27
+
28
+ - 用 `dshmarket` 自身的 `hasLoadableEntry` 实测:把本包按 profile 布局模拟安装,修复前 `false`、修复后 `true`;
29
+ 对仓库目录 `entryArtifactExists` 修复后为 `true`。
30
+ - 单测 42 条(新增 2 条:清单声明的入口必须存在且被 `files[]` 收录;入口可 `import`、导出的 `name` 与包名一致、
31
+ `apply()` 返回 `undefined`):Windows 39 过 + 3 平台跳过。
32
+ - `verify-branch.ps1` 新增 `main` 存在性与 `files[]` 收录检查;`validate.yml` 的清单检查与 npm 载荷检查加了同一条守卫。
33
+ - `npm pack --dry-run`:20 个文件,含 `index.mjs`。
34
+
35
+ ## 2.0.4 — 2026-10-17
36
+
37
+ **两处已核实的缺陷:只读 bundle 目录下 shim 启动即崩;与上游 `rea setup --client dsh` 的同名注册冲突现在会被明确提示。**
38
+
39
+ ### 修复
40
+
41
+ - **只读 bundle 根目录 + 陈旧 lock → shim 未捕获 EACCES 崩溃**:2.0.3 把删锁移出了 `if (fresh)`,却没包
42
+ try/catch。目录只读(`chmod 555`、系统级安装目录)且锁已陈旧时,`rmSync(lock)` 抛 `EACCES`,shim 以
43
+ 未捕获异常退出(exit 1 + 堆栈)—— 比原来的静默更吵,但同样起不来。沙箱已复现(WSL `chmod 555`)。
44
+ 现在删锁失败返回 `unwritable`,走既有的「无法写入,请手动跑 setup」路径(exit 78,行不崩)。
45
+ - **与上游 `rea setup --client dsh` 的同名注册冲突**:该命令往 `<DSH_HOME>/cordis.patch.yml` 写入
46
+ `id: rea-mcp`、`serverName: rea`,而本包是 `id: mcp-rea`、同一个 `serverName`;DSH 对同名条目只保留
47
+ 一个(后加载者报 duplicate server name)。已按上游 6.5.0 实测生成该文件确认。shim 现在启动时检测同
48
+ 文件的 `serverName: rea`(id 不是 `mcp-rea`)并向 stderr 提示「二选一」,`--shim-status` 也会在
49
+ `duplicateRegistration` 下报告。
50
+
51
+ ### 文档
52
+
53
+ - 两份 README 新增「只保留一条 REA 注册」小节:与 `rea setup --client dsh` 二选一,并说明两条路线的
54
+ 取舍(零依赖供给 vs 上游官方纯配置注册)。
55
+ - v1 分支 README 顶部加横幅:指向 2.x(`main`,默认推荐)与上游官方注册。
56
+
57
+ ### 验证
58
+
59
+ - 单测 40 条:Windows 37 过 + 3 平台跳过;WSL 39 过 + 1 跳过(本轮新增 3 条,其中只读目录一条仅 POSIX)。
60
+ - 反向验证:回退删锁的 try/catch → 只读目录测试在 WSL 必红(EACCES/exit 1);回退冲突检测 → 重复注册
61
+ 测试必红。
62
+ - `verify-branch.ps1` 全绿。
63
+
5
64
  ## 2.0.3 — 2026-10-16
6
65
 
7
66
  **修复 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
@@ -228,6 +262,27 @@ macOS/Linux.
228
262
 
229
263
  ## Updating — three independent things
230
264
 
265
+ ### Updating from the market
266
+
267
+ Update it where you installed it. Updating **to 2.0.5 or later** is routine; updating an installed 2.0.1–2.0.4
268
+ *through the in-app market* failed with this, three times on a healthy profile:
269
+
270
+ > dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
271
+
272
+ That message is not about a broken tarball. On every update the market asks whether the new build's
273
+ `package.json` points at an entry file that exists (`hasLoadableEntry` in `dshmarket`), and rolls the build back
274
+ when the answer is false. A bundle may legitimately own no entry — but only if a package it *mounts* has one, and
275
+ the package this bundle mounts (`@deepseek-ai/dsh-mcp-client`) ships inside the harness build rather than in
276
+ `node_modules`, so every location that test probes comes back empty. The 1.x and 2.0.x manifests also declared
277
+ no `main` at all, so the answer was false even though the profile was working; the rollback was then reported as
278
+ unverifiable for the same reason. Nothing in the profile was actually damaged — the update simply did not apply.
279
+
280
+ 2.0.5 declares `main: ./index.mjs`. That file is a real, deliberately empty Cordis plugin: it registers nothing,
281
+ spawns nothing, touches no state — the working row is still inserted by `cordis.patch.yml` — and it exists so the
282
+ question "can this package load?" has an honest answer, and so `import 'dsh-plugin-rea'` resolves. On an older
283
+ version, update to `>=2.0.5`; if the market refuses before that version exists, install `dsh-plugin-rea@2.0.5`
284
+ (Desktop: through the plugin manager) instead of updating in place.
285
+
231
286
  ### Upgrading from 1.x
232
287
 
233
288
  Install 2.x, then run provisioning **once** before restarting dsh:
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` 与
@@ -216,6 +246,25 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}
216
246
 
217
247
  ## 更新 —— 三件互相独立的事
218
248
 
249
+ ### 在市场里更新
250
+
251
+ 在哪儿装的就在哪儿更新。更新到 **2.0.5 及以上**是常规操作;而把已装的 2.0.1–2.0.4 通过应用内市场**更新到
252
+ 2.0.4**,在健康的 profile 上连续失败了三次:
253
+
254
+ > dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
255
+
256
+ 这条消息与 tarball 是否完整无关。市场在每次更新时都会问一句:新构建的 `package.json` 是否指向一个真实存在的
257
+ 入口文件(`dshmarket` 里的 `hasLoadableEntry`);答案是「否」,就把这次更新判为坏构建并回滚。bundle 本来允许
258
+ 自己没有入口 —— 前提是它**挂载**的某个包有入口;而本包挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
259
+ 构建,不在 `node_modules` 里,于是这项检查要看的每个位置都是空的。1.x 与 2.0.x 的清单又根本没有声明 `main`,
260
+ 所以答案必然是「否」,哪怕 profile 一直是好的;紧接着的回滚也因此被报成「无法验证」。实际上 profile 没有被
261
+ 破坏,只是这次更新没有生效。
262
+
263
+ 2.0.5 声明了 `main: ./index.mjs`。那是一个真实存在、刻意留空的 Cordis 插件:不注册任何行、不启动任何进程、
264
+ 不碰任何状态(真正起作用的行仍由 `cordis.patch.yml` 插入),它的作用只是让「这个包能不能加载」有一个诚实的
265
+ 答案,并让 `import 'dsh-plugin-rea'` 能解析。还停在旧版本时,更新到 `>=2.0.5` 即可;若市场在 2.0.5 之前就拒绝,
266
+ 就直接安装 `dsh-plugin-rea@2.0.5`(桌面端:在插件管理器里装),不要原地更新。
267
+
219
268
  ### 从 1.x 升级
220
269
 
221
270
  装上 2.x 后,**先跑一次供给**,再重启 dsh:
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/index.mjs ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Package entry — the smallest thing that makes this bundle a *loadable* package.
3
+ *
4
+ * Everything this plugin DOES lives in `cordis.patch.yml`: it inserts one row of
5
+ * `@deepseek-ai/dsh-mcp-client` that launches `bin/rea-shim.mjs`, and that row is
6
+ * what the harness loads. This file registers nothing, starts nothing and
7
+ * provisions nothing.
8
+ *
9
+ * It exists because a package that declares no `main`/`exports` is judged by its
10
+ * consumers as "has no entry file", whether or not the loader ever needs one:
11
+ *
12
+ * - The market's update path (`hasLoadableEntry` in dshmarket) asks that
13
+ * question on every update and, when the answer is false, treats the new
14
+ * build as broken and rolls back. A bundle with no entry of its own is
15
+ * *supposed* to survive the test through the packages it mounts — but the
16
+ * package this one mounts (`@deepseek-ai/dsh-mcp-client`) is part of the
17
+ * harness build, not of `node_modules`, so every location that test probes
18
+ * comes back empty. Measured on 2026-10-18: updating an installed, working
19
+ * 2.0.1 to 2.0.4 failed three times with "updated build has no loadable
20
+ * entry", and the rollback was then reported as unverifiable for the same
21
+ * reason.
22
+ * - `npm pack` metadata and `import 'dsh-plugin-rea'` both resolve `main`;
23
+ * absent, both fall back to `index.js`, which this repository never shipped.
24
+ *
25
+ * So this is a real module with a real Cordis plugin shape rather than a stub
26
+ * for a checker: if the harness ever does load this package as a plugin, it
27
+ * loads cleanly and does nothing.
28
+ */
29
+
30
+ /** Cordis plugin name. Also the package name, which is what the loader expects. */
31
+ export const name = 'dsh-plugin-rea'
32
+
33
+ /**
34
+ * Intentionally empty.
35
+ *
36
+ * The row that does the work is inserted by the bundle patch, not by this
37
+ * function: registering the MCP client from here would create a second
38
+ * `serverName: rea` entry, which the harness refuses as a duplicate (the same
39
+ * collision `bin/rea-shim.mjs` warns about for upstream's `rea setup --client
40
+ * dsh`). Doing nothing is the correct behaviour, and the function present keeps
41
+ * this a valid Cordis plugin instead of an arbitrary module.
42
+ */
43
+ export function apply() {}
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "dsh-plugin-rea",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
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
+ "main": "./index.mjs",
6
7
  "license": "MIT",
7
8
  "author": "czcdr",
8
9
  "homepage": "https://github.com/czcdr/dsh-plugin-rea",
@@ -33,6 +34,7 @@
33
34
  },
34
35
  "files": [
35
36
  "cordis.patch.yml",
37
+ "index.mjs",
36
38
  "bin/",
37
39
  "scripts/",
38
40
  "manifest/",
@@ -18,7 +18,7 @@ import { createServer } from "node:http";
18
18
  import { tmpdir } from "node:os";
19
19
  import path from "node:path";
20
20
  import test from "node:test";
21
- import { fileURLToPath } from "node:url";
21
+ import { fileURLToPath, pathToFileURL } from "node:url";
22
22
 
23
23
  /* ------------------------------------------------- probe token substitution */
24
24
 
@@ -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
 
@@ -1095,3 +1168,34 @@ test("--json releases the lock on success and marks it failed on failure", () =>
1095
1168
  rmSync(root, { recursive: true, force: true });
1096
1169
  }
1097
1170
  });
1171
+
1172
+ /* --------------------------------------------------------- the package entry */
1173
+
1174
+ // The market judges an update by asking whether the new build's package.json
1175
+ // points at an entry file that exists (`hasLoadableEntry` -> `entryArtifactExists`
1176
+ // in dshmarket). This package declared neither main nor exports, and the package
1177
+ // it mounts (`@deepseek-ai/dsh-mcp-client`) ships inside the harness build rather
1178
+ // than in node_modules, so every location that test probes came back empty:
1179
+ // updating a healthy 2.0.1 profile to 2.0.4 failed with "updated build has no
1180
+ // loadable entry", and the rollback was then reported unverifiable for the same
1181
+ // reason (measured 2026-10-18). These two tests are that regression.
1182
+ test("package.json declares an entry file that is actually shipped", () => {
1183
+ const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"));
1184
+ assert.equal(typeof pkg.main, "string", "package.json must declare main; without it consumers probe index.js, which does not exist");
1185
+ assert.equal(existsSync(path.join(REPO_ROOT, pkg.main)), true, `${pkg.main} is declared but missing`);
1186
+ // `main` alone is not enough: npm pack ships only what files[] lists, and a
1187
+ // tarball without the entry reproduces the very failure this fixes.
1188
+ const shipped = String(pkg.main).replace(/^\.\//, "");
1189
+ assert.equal(pkg.files.includes(shipped), true, `files[] must ship ${shipped}`);
1190
+ });
1191
+
1192
+ test("the entry loads as a cordis plugin and does nothing", async () => {
1193
+ const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"));
1194
+ const mod = await import(pathToFileURL(path.join(REPO_ROOT, pkg.main)).href);
1195
+ assert.equal(mod.name, pkg.name, "the loader resolves the plugin by this name");
1196
+ assert.equal(typeof mod.apply, "function", "a cordis plugin needs apply()");
1197
+ // Loading and applying must not register a row, spawn anything or touch state:
1198
+ // registering the MCP client here would add a second `serverName: rea` entry,
1199
+ // which the harness refuses. Returning undefined is the whole contract.
1200
+ assert.equal(mod.apply(), undefined);
1201
+ });
@@ -12,7 +12,7 @@ function Check($name, [bool]$ok, $detail) {
12
12
  }
13
13
 
14
14
  Write-Host "`n=== 1. JS syntax ===" -ForegroundColor Cyan
15
- foreach ($f in @('bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs')) {
15
+ foreach ($f in @('index.mjs','bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs')) {
16
16
  $out = & node --check $f 2>&1
17
17
  Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
18
18
  }
@@ -73,9 +73,16 @@ Write-Host "`n=== 6. package payload lists what the bundle needs ===" -Foregroun
73
73
  # -Encoding UTF8 is required: the package description contains CJK text, and
74
74
  # PowerShell 5.1 reads a BOM-less file as ANSI, which breaks ConvertFrom-Json.
75
75
  $pkg = Get-Content (Join-Path $root 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json
76
- foreach ($entry in @('bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
76
+ foreach ($entry in @('index.mjs', 'bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
77
77
  Check "files[] includes $entry" (@($pkg.files) -contains $entry) ''
78
78
  }
79
+ # The market's update path asks whether package.json's main/exports points at a
80
+ # file that exists, and rolls the update back when it does not (dshmarket's
81
+ # hasLoadableEntry). This package declared neither until 2.0.5, so every update of
82
+ # a healthy profile failed with "updated build has no loadable entry". Asserting
83
+ # the pointer resolves keeps that from returning silently.
84
+ Check 'package.json declares main' (-not [string]::IsNullOrWhiteSpace($pkg.main)) ''
85
+ Check 'main resolves to a shipped file' ($pkg.main -and (Test-Path (Join-Path $root $pkg.main))) "main=$($pkg.main)"
79
86
  foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test')) {
80
87
  Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
81
88
  }