dsh-plugin-rea 2.0.7 → 2.0.8

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,44 @@
2
2
 
3
3
  本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
4
4
 
5
+ ## 2.0.8 — 2026-10-18
6
+
7
+ **2.0.7 的启动检测在两种边界上会失控:一个包着本 shim 的 `rea` 会让探针递归成进程链;超时探针会漏掉一个孙进程。两条都已修,并补了复现测试。**
8
+
9
+ ### 修复
10
+
11
+ - **重复注册检测在自定义 `DSH_HOME` 下失效**:harness 会从 MCP 子进程里抹掉所有 `DSH_*`,镜像副本因此读
12
+ `~/.dsh` —— 那是错的文件,上游写进真实 home 的第二条 `serverName: rea` 永远报不出来。`setup.mjs` 现在把运行时
13
+ 的 home 记进 `mirror.json` 的 `dshHome`,shim 优先读环境、其次读这条记录。`check-updates.ps1` 的 profile 段也
14
+ 改用同一条 home 规则(此前硬编码 `$env:USERPROFILE`,自定义 home 时报「找不到 profile」)。
15
+ - **镜像启动器不会自己更新**:行执行的是 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 这份**副本**,而市场升级
16
+ 只换包、不跑 setup —— 副本会一直跑它被复制时的代码,于是新版本里的修复对老用户静默失效(旧副本照常工作,最难发现)。
17
+ 现在镜像启动时会与 `mirror.json` 记录的插件目录比对哈希,不同就转发执行插件里的那一份(一跳,带
18
+ `REA_BUNDLE_SHIM_REEXEC` 守卫,且只从镜像指向插件,不会反向)。`--shim-status` 新增 `mirror.stale`,
19
+ `check-updates.ps1` 也会报「镜像落后,重跑 setup」;`mirror.json` 增加 `shimSha256` 与 `pluginVersion` 便于追溯。
20
+ **2.0.8 之前的版本自身无法完成这一步**(副本里没有这段代码),从那些版本升级仍需手动跑一次 setup。
21
+ - **探针递归**:`probeRea` 派生候选时没带 `REA_BUNDLE_DELEGATED=1`,而防自委派的守卫只在**委派**前生效。若 PATH 上是「调用本 shim 的 wrapper」,
22
+ 探针 → wrapper → 新 shim → 再探针……实测并发 node 峰值 83+、杀掉后仍自我再生。现在探针环境同时带 `REA_BUNDLE_DELEGATED=1` 与
23
+ `REA_BUNDLE_AUTO_PROVISION=0`:嵌套运行直接走 bundle 并返回,实测 0.2 秒、零额外进程。
24
+ - **超时探针泄漏孙进程**:`spawnSync` 的超时只杀它自己启动的 cmd.exe,而 `taskkill /T` 在 `spawnSync` 回收之后调用已找不到父进程 ——
25
+ 每次超时留一个 REA/ping 在跑。探针改为异步 `spawn` + 自己的计时器:超时发生在父进程仍存活时,`taskkill /T /F` 能真正清掉整棵树。
26
+ 实测 8.3 秒放弃、`rejected=["timeout"]`、无残留进程。
27
+ - **Windows 信号处理器是死代码**:`child.kill("SIGTERM")` 在 Windows 上是 TerminateProcess,不可捕获;控制台的 Ctrl+C 又直达子进程。
28
+ 该分支已删除,并如实写明限制:**在 Windows 上强制结束 shim 不会带走被委派的 REA**(Node 没有 job object 可用)。
29
+ - **`comspec()` 不校验**:`ComSpec` 指向不存在的文件时,正常的 `.cmd` REA 被判 `spawn-failed`(`auto` 静默退回 bundle,`system` 直接 78)。
30
+ 现在校验为常规文件,否则回落到 `%SystemRoot%\System32\cmd.exe`;实测 `ComSpec` 指向不存在路径仍能识别 6.9.0。
31
+ - **文案**:显式路径是目录时报「does not exist」,改为「is not a file」。
32
+
33
+ ### 验证
34
+
35
+ - 单测 **67 条**(2.0.7 是 60 条,本轮 +7:wrapper 递归必须被判为 `exit 78` 且 20 秒内收敛;`ComSpec` 失效仍识别 `.cmd` REA;
36
+ 镜像自愈的转发/守卫/`stale` 上报/同内容不转发;`DSH_HOME` 被抹时从 `mirror.json` 找到真实 home 并报出重复注册):
37
+ Windows 64 过 + 3 平台跳过。
38
+ - `verify-branch.ps1` 全绿(39 项),含「两份 README 必须写出 package.json 里的 `engines.dsh` 原文」;`audit-manifest` clean;
39
+ `host-compat` 10 covered / 5 correctly outside。
40
+ - 手工复现三例:wrapper 递归 0.2 秒收敛、0 残留进程;挂起候选 8.3 秒放弃且无 `PING` 残留;`ComSpec` 指向空路径仍识别系统 REA。
41
+
42
+
5
43
  ## 2.0.7 — 2026-10-18
6
44
 
7
45
  **机器上已有可用的 REA 时,2.x 直接按 1.x 的方式跑它(环境检测 + 委派),不再供给;同时把 2.0.6 的宿主声明收紧到真的能证明的范围。**
package/README.md CHANGED
@@ -127,7 +127,7 @@ by the shim itself, which provisions in the background.
127
127
 
128
128
  ### Which DSH versions
129
129
 
130
- `package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0"`, and that declaration is the answer the plugin market
130
+ `package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`, and that declaration is the answer the plugin market
131
131
  reads: with it the card says **compatible**; without it the market can only say *unknown*. The range is not a guess —
132
132
  it is the dsh release list this plugin is checked against, and `node scripts/host-compat.mjs` fails the build when the
133
133
  two disagree:
@@ -230,9 +230,9 @@ Which one? Upstream's is the configuration-only route: it expects Node on REA's
230
230
  zero-dependency route: it fetches Node, REA, JDK and Ghidra on first run. The patch shape and the
231
231
  `mcp__rea__*` surface are the same either way.
232
232
 
233
- The shim reports the conflict on startup when it can see that file — a custom `DSH_HOME` is hidden
234
- from an MCP child, so it falls back to `~/.dsh` there — and `node bin/rea-shim.mjs --shim-status`
235
- lists it under `duplicateRegistration`.
233
+ The shim reports the conflict on startup — `setup.mjs` records the DSH home it ran under in
234
+ `mirror.json`, and the mirrored copy reads that when the harness has stripped `DSH_*` from its
235
+ environment; `node bin/rea-shim.mjs --shim-status` lists it under `duplicateRegistration`.
236
236
 
237
237
  ## Verify
238
238
 
@@ -337,6 +337,12 @@ back on the next start.
337
337
 
338
338
  - **This plugin** never updates itself. Installed from a market? Update it there. Installed from a `file:`
339
339
  checkout? Overwrite the directory and reinstall.
340
+ - **The row runs a COPY of this plugin's launcher.** `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` is a mirror
341
+ made by setup, so a market upgrade replaces the package but not the file the row executes — until setup runs
342
+ again. From **2.0.8** the mirror notices that the installed plugin ships a different launcher and re-execs it
343
+ (one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`), so upgrades carry. On an older version the mirror stays behind
344
+ silently; `--shim-status` now reports `mirror.stale`, and `check-updates.ps1` says to re-run setup. If you are
345
+ upgrading from 2.0.0–2.0.7, run `node scripts/setup.mjs` once after the upgrade.
340
346
  - **Upgrading from `2.0.0-bundle.1`**: that release mirrored the shim and `setup.mjs` but not the manifest,
341
347
  and recorded nothing about the plugin checkout, so a bundle it provisioned cannot repair itself — its
342
348
  background provisioning dies on the missing manifest, invisibly, and its own lock suppresses retries for
package/README.zh-CN.md CHANGED
@@ -118,7 +118,7 @@ node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
118
118
 
119
119
  ### 支持哪些 DSH 版本
120
120
 
121
- `package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0"`,这就是插件市场读的那句话:有它,卡片显示
121
+ `package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`,这就是插件市场读的那句话:有它,卡片显示
122
122
  **兼容**;没有它,市场只能说*未声明*。这个范围不是猜的——它对应本插件实际核对过的 dsh 发布列表,
123
123
  `node scripts/host-compat.mjs` 会在两者不一致时让构建失败:
124
124
 
@@ -216,8 +216,8 @@ duplicate server name 报错并加载失败,最终留下哪一行取决于加
216
216
  自己准备。本包是零依赖路线:首次运行自动取 Node、REA、JDK、Ghidra。两者的 patch 形态与
217
217
  `mcp__rea__*` 工具面相同。
218
218
 
219
- shim 在能看到该文件时会在启动时提示冲突 —— MCP 子进程里拿不到自定义 `DSH_HOME`,那种情况下它会
220
- 退回 `~/.dsh`;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
219
+ shim 会在启动时提示冲突 —— `setup.mjs` 会把运行时的 DSH home 记进 `mirror.json`,镜像副本在 harness 抹掉
220
+ `DSH_*` 之后就用这条记录去找那份文件;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
221
221
 
222
222
  ## 验证
223
223
 
@@ -314,6 +314,11 @@ node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-boot
314
314
  工具就回来了。
315
315
 
316
316
  - **本插件**从不自更新。从市场装的就在市场更新;从 `file:` 检出的就覆盖目录重装。
317
+ - **行跑的是本插件启动器的「镜像副本」**:`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 是 setup 复制过去的,
318
+ 所以市场升级只换包、不换行执行的那个文件 —— 直到再跑一次 setup。从 **2.0.8** 起,镜像会发现自己落后于已安装的
319
+ 插件,并转发执行插件里的那一份(一跳,带 `REA_BUNDLE_SHIM_REEXEC` 守卫),升级因此能生效。更早的版本里镜像会
320
+ 静默地停在旧代码;`--shim-status` 现在会报 `mirror.stale`,`check-updates.ps1` 也会提示重跑 setup。**从
321
+ 2.0.0–2.0.7 升级的话,升级后跑一次 `node scripts/setup.mjs`。**
317
322
  - **从 `2.0.0-bundle.1` 升级**:那一版镜像了 shim 与 `setup.mjs`,却漏了 manifest,也没有留下插件 checkout
318
323
  的记录,所以它供给出来的 bundle **无法自愈** —— 后台供给会因缺 manifest 而崩溃且完全不可见,而它自己写下的
319
324
  lock 又会让重试被抑制六小时。请**从插件目录**重跑一次 `node scripts/setup.mjs`:它会重新镜像三个文件,
package/bin/rea-shim.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
  * and the message is the operator's remediation.
16
16
  */
17
17
  import { spawn, spawnSync } from "node:child_process";
18
+ import { createHash } from "node:crypto";
18
19
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
19
20
  import { homedir } from "node:os";
20
21
  import path from "node:path";
@@ -32,6 +33,36 @@ function dshHome() {
32
33
  return path.join(homedir(), ".dsh");
33
34
  }
34
35
 
36
+ /**
37
+ * The DSH home recorded by setup, or null.
38
+ *
39
+ * Only the mirrored copy needs this: the harness strips `DSH_*` from an MCP
40
+ * child, so with a custom home the environment cannot answer. Read once per
41
+ * process — there is one bundle root per process and this is a startup query.
42
+ */
43
+ let recordedDshHomeCache = null;
44
+ function recordedDshHome(root) {
45
+ if (recordedDshHomeCache !== null) return recordedDshHomeCache;
46
+ let value = null;
47
+ try {
48
+ const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
49
+ if (typeof record?.dshHome === "string" && record.dshHome.trim() !== "" && path.isAbsolute(record.dshHome)) {
50
+ value = path.resolve(record.dshHome);
51
+ }
52
+ } catch {
53
+ value = null;
54
+ }
55
+ recordedDshHomeCache = value;
56
+ return value;
57
+ }
58
+
59
+ /** The home to look in when the environment has been scrubbed. */
60
+ function dshHomeFor(root) {
61
+ const fromEnv = process.env.DSH_HOME;
62
+ if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
63
+ return recordedDshHome(root) ?? path.join(homedir(), ".dsh");
64
+ }
65
+
35
66
  /**
36
67
  * Where setup writes the machine-local truth.
37
68
  *
@@ -104,13 +135,16 @@ function provisioningScript(root) {
104
135
  *
105
136
  * The parse is deliberately small: the two files it cares about are block
106
137
  * 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.
138
+ * ignored — a missed hint is acceptable, a false alarm is not.
139
+ *
140
+ * Finding the file is the part that needed care: the harness strips every DSH_*
141
+ * variable from an MCP child, so a mirrored copy with a custom DSH_HOME used to
142
+ * read `~/.dsh` — the wrong file, and therefore a conflict it could never report.
143
+ * `setup.mjs` writes the home it ran under into `mirror.json`, and that record is
144
+ * what this uses when the environment has nothing to say.
111
145
  */
112
- function foreignReaRegistration() {
113
- const patch = path.join(dshHome(), "cordis.patch.yml");
146
+ function foreignReaRegistration(root) {
147
+ const patch = path.join(dshHomeFor(root), "cordis.patch.yml");
114
148
  let text;
115
149
  try {
116
150
  text = readFileSync(patch, "utf8");
@@ -516,7 +550,11 @@ function cmdQuote(value) {
516
550
  */
517
551
  function comspec() {
518
552
  const fromEnv = process.env.ComSpec ?? process.env.COMSPEC;
519
- if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv;
553
+ // A ComSpec that names nothing is a real (if unusual) state, and trusting it
554
+ // would judge every `.cmd` REA "spawn-failed" — i.e. report the machine as
555
+ // having no REA because its shell pointer is broken. Fall back to the path
556
+ // Windows itself would use.
557
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "" && isFileSync(fromEnv)) return fromEnv;
520
558
  const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
521
559
  return path.join(systemRoot, "System32", "cmd.exe");
522
560
  }
@@ -527,50 +565,77 @@ function comspec() {
527
565
  * A file existing on PATH is not the same as a working install: npx leftovers,
528
566
  * half-removed globals and a shim whose Node is gone all sit on PATH and all
529
567
  * fail at once. One bounded `--version` separates them, and a slow or hanging
530
- * answer counts as "not usable" 鈥?the fallback (the bundle) still works, while
568
+ * answer counts as "not usable" — the fallback (the bundle) still works, while
531
569
  * delegating to a broken binary would take the tools down with it.
570
+ *
571
+ * Two things here are not incidental:
572
+ *
573
+ * * The probe runs with `REA_BUNDLE_DELEGATED=1`. A candidate that is itself a
574
+ * wrapper around this shim would otherwise be probed by a nested shim that
575
+ * probes the wrapper — measured: a process chain that keeps respawning
576
+ * itself. The flag makes the nested run take the bundle path and return.
577
+ * * It is asynchronous, not `spawnSync` with a timeout. `spawnSync` reaps the
578
+ * child it started, and its timeout kills only that child: for a `.cmd` shim
579
+ * that means cmd.exe dies while the REA inside it keeps running and holds
580
+ * the pipes. Killing the TREE is only possible while the parent is alive,
581
+ * which means owning the wait ourselves.
532
582
  */
533
583
  function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_MS } = {}) {
534
- // `existsSync` says nothing about the shape: a DIRECTORY named `rea.cmd` exists,
535
- // fails to spawn, and would be reported as a broken install instead of as a
536
- // name collision. Only a regular file is a candidate.
537
- try {
538
- if (!statSync(file).isFile()) return { ok: false, reason: "not a file" };
539
- } catch {
540
- return { ok: false, reason: "missing" };
541
- }
542
- const command = [file, "--version"];
543
- // `cmd /d /s /c "<line>"`: Windows only. Two details are load-bearing.
544
- // `/s` strips the first and last quote, so the whole line is quoted; and the
545
- // arguments are passed VERBATIM, because Node otherwise backslash-escapes the
546
- // quotes it sees and cmd.exe does not understand `\"` — every candidate then
547
- // answers "exit 1", which reads as "no REA on this machine".
548
- const result = platform === "win32" && /\.(cmd|bat)$/i.test(file)
549
- ? spawnSync(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(file), "--version"].join(" ")}"`], {
550
- encoding: "utf8",
551
- timeout,
552
- windowsHide: true,
553
- windowsVerbatimArguments: true,
554
- })
555
- : spawnSync(command[0], command.slice(1), {
556
- encoding: "utf8",
557
- timeout,
558
- windowsHide: true,
559
- });
560
- if (result.error) {
561
- // A timed-out probe leaves its grandchild running: cmd.exe is killed, the
562
- // REA inside it is not, and it keeps the inherited pipes open. Clean the tree
563
- // before answering, or a hung candidate leaves a process behind per start.
564
- if (result.error.code === "ETIMEDOUT") killTree(result.pid, platform);
565
- return { ok: false, reason: result.error.code === "ETIMEDOUT" ? "timeout" : "spawn-failed" };
566
- }
567
- if (result.status !== 0) return { ok: false, reason: `exit ${String(result.status)}` };
568
- const text = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
569
- const version = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(text)?.[1] ?? null;
570
- // A REA install prints its version. Anything else (a shim that swallowed the
571
- // flag, an unrelated program that shares the name) is not a usable CLI.
572
- if (version === null) return { ok: false, reason: "no version in --version output" };
573
- return { ok: true, version };
584
+ return new Promise((resolve) => {
585
+ // `existsSync` says nothing about the shape: a DIRECTORY named `rea.cmd`
586
+ // exists, fails to spawn, and would be reported as a broken install instead
587
+ // of as a name collision. Only a regular file is a candidate.
588
+ if (!isFileSync(file)) {
589
+ resolve({ ok: false, reason: "not a file" });
590
+ return;
591
+ }
592
+ const useShell = platform === "win32" && /\.(cmd|bat)$/i.test(file);
593
+ // `REA_BUNDLE_AUTO_PROVISION=0` is part of the same guard: if the candidate
594
+ // turns out to be a wrapper around THIS shim, the nested run must not start
595
+ // an 800 MB provisioning job just because a probe asked it for a version.
596
+ const env = { ...process.env, REA_BUNDLE_DELEGATED: "1", REA_BUNDLE_AUTO_PROVISION: "0" };
597
+ // `cmd /d /s /c "<line>"`: Windows only. Two details are load-bearing.
598
+ // `/s` strips the first and last quote, so the whole line is quoted; and the
599
+ // arguments are passed VERBATIM, because Node otherwise backslash-escapes the
600
+ // quotes it sees and cmd.exe does not understand `\"` — every candidate then
601
+ // answers "exit 1", which reads as "no REA on this machine".
602
+ const child = useShell
603
+ ? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(file), "--version"].join(" ")}"`], {
604
+ env,
605
+ stdio: ["ignore", "pipe", "pipe"],
606
+ windowsHide: true,
607
+ windowsVerbatimArguments: true,
608
+ })
609
+ : spawn(file, ["--version"], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
610
+ let output = "";
611
+ let settled = false;
612
+ let timer = null;
613
+ const finish = (result) => {
614
+ if (settled) return;
615
+ settled = true;
616
+ if (timer !== null) clearTimeout(timer);
617
+ resolve(result);
618
+ };
619
+ timer = setTimeout(() => {
620
+ killTree(child.pid, platform);
621
+ finish({ ok: false, reason: "timeout" });
622
+ }, timeout);
623
+ child.stdout.on("data", (chunk) => { output += String(chunk); });
624
+ child.stderr.on("data", (chunk) => { output += String(chunk); });
625
+ child.on("error", () => finish({ ok: false, reason: "spawn-failed" }));
626
+ child.on("close", (code) => {
627
+ if (code !== 0) {
628
+ finish({ ok: false, reason: `exit ${String(code)}` });
629
+ return;
630
+ }
631
+ // A REA install prints its version. Anything else (a shim that swallowed
632
+ // the flag, an unrelated program that shares the name) is not a usable CLI.
633
+ const version = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(output.trim())?.[1] ?? null;
634
+ finish(version === null
635
+ ? { ok: false, reason: "no version in --version output" }
636
+ : { ok: true, version });
637
+ });
638
+ });
574
639
  }
575
640
 
576
641
  /**
@@ -580,7 +645,7 @@ function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_M
580
645
  * provisioner installed is the `bundle` mode, and delegating to it would be a
581
646
  * loop through a path the bundle already covers.
582
647
  */
583
- function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
648
+ async function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
584
649
  const rejected = [];
585
650
  let profiled = 0;
586
651
  const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
@@ -609,7 +674,7 @@ function detectSystemRea({ env = process.env, platform = process.platform, root,
609
674
  continue;
610
675
  }
611
676
  profiled += 1;
612
- const probe = probeRea(candidate, { platform });
677
+ const probe = await probeRea(candidate, { platform });
613
678
  if (probe.ok) return { found: true, file: candidate, version: probe.version };
614
679
  if (rejected.length < 8) rejected.push({ file: candidate, reason: probe.reason });
615
680
  }
@@ -624,7 +689,9 @@ function detectSystemRea({ env = process.env, platform = process.platform, root,
624
689
  if (explicitPath !== null && explicitRejection !== null) {
625
690
  reason = `REA_BUNDLE_SYSTEM_REA was rejected (${explicitRejection.reason}): ${explicitPath}`;
626
691
  } else if (explicitPath !== null && !isFileSync(explicitPath)) {
627
- reason = `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
692
+ reason = existsSync(explicitPath)
693
+ ? `REA_BUNDLE_SYSTEM_REA is not a file: ${explicitPath}`
694
+ : `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
628
695
  } else {
629
696
  reason = profiled === 0 ? "no rea on PATH" : "no usable rea on PATH";
630
697
  }
@@ -660,7 +727,7 @@ function compareDotted(a, b) {
660
727
  * honoured literally 鈥?`system` fails loudly rather than provisioning 800 MB
661
728
  * behind an operator who asked for their own REA.
662
729
  */
663
- function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
730
+ async function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
664
731
  const preference = launchPreference(env);
665
732
  if (preference.value === "bundle") {
666
733
  return { preference: preference.value, invalid: preference.invalid, mode: "bundle", reason: "pinned by REA_BUNDLE_LAUNCH=bundle" };
@@ -678,7 +745,7 @@ function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false,
678
745
  reason: "REA_BUNDLE_DELEGATED=1: already running under a delegated system REA, refusing to delegate again",
679
746
  };
680
747
  }
681
- const system = detectSystemRea({ env, platform, root });
748
+ const system = await detectSystemRea({ env, platform, root });
682
749
  if (system.found) {
683
750
  const order = bundleVersion === null ? null : compareDotted(system.version, bundleVersion);
684
751
  const pinned = preference.value === "system";
@@ -747,25 +814,14 @@ function launchSystemRea(system, argv) {
747
814
  windowsVerbatimArguments: true,
748
815
  })
749
816
  : spawn(system.file, args, { env, stdio: "inherit", windowsHide: true });
750
- // Signal handling, per platform, because the two are not the same problem.
751
- //
752
- // POSIX: stdio is inherited and the child is in this process's group, so a
753
- // terminal's Ctrl+C already reaches it. Forwarding again would deliver the
754
- // signal twice.
755
- //
756
- // Windows: there is no process group to deliver to, and `child.kill("SIGINT")`
757
- // is TerminateProcess on the cmd.exe WRAPPER — the REA inside it survives and
758
- // keeps the inherited pipes open, which looks exactly like "the tools still
759
- // work after I stopped the harness". `taskkill /T` is what actually ends the
760
- // tree, so that is what a signal asks for here.
761
- if (process.platform === "win32") {
762
- for (const signal of ["SIGINT", "SIGTERM"]) {
763
- process.on(signal, () => {
764
- killTree(child.pid, "win32");
765
- process.exit(signal === "SIGINT" ? 130 : 143);
766
- });
767
- }
768
- }
817
+ // No signal forwarding on Windows, deliberately. `child.kill()` there is
818
+ // TerminateProcess on the cmd.exe WRAPPER — the REA inside it survives, still
819
+ // holding the inherited pipes — and a console Ctrl+C is delivered to the whole
820
+ // process group anyway, so the child already has it. A hard kill of this shim
821
+ // (what a supervisor or Task Manager does) runs no JavaScript at all, so the
822
+ // wrapper must not pretend otherwise: on Windows the delegated REA outlives a
823
+ // forcibly killed shim. On POSIX the child is in this process's group and gets
824
+ // the terminal's signal directly; `process.exit` below then ends the shim.
769
825
  child.on("error", (error) => {
770
826
  process.stderr.write(`rea-shim: could not start ${system.file}\n ${String(error && error.message)}\n`);
771
827
  process.exit(EX_CONFIG);
@@ -780,6 +836,101 @@ function launchSystemRea(system, argv) {
780
836
  return null;
781
837
  }
782
838
 
839
+ /**
840
+ * The mirror problem.
841
+ *
842
+ * The published row launches `<bundle>/bin/rea-shim.mjs` — a COPY made by
843
+ * `setup.mjs`. Upgrading the plugin from the market replaces the package but runs
844
+ * no setup, so the copy keeps executing the code it was made from: every fix in
845
+ * a new plugin version reaches a user only if they happen to re-run setup. That
846
+ * is invisible (the old copy keeps working) and it is exactly the wrong
847
+ * direction for a safety fix.
848
+ *
849
+ * The repair has to live in the copy, because the copy is what runs. So the
850
+ * copy compares itself with the plugin's own `bin/rea-shim.mjs`, recorded in
851
+ * `mirror.json` by setup, and re-execs that file when the two differ. One hop,
852
+ * guarded by an environment flag, and only from mirror to checkout — the
853
+ * checkout copy has no `mirror.json` and would find itself anyway.
854
+ *
855
+ * A plugin version older than 2.0.8 cannot do this for its own upgrades: there
856
+ * is nothing in it to run. `--shim-status` reports `mirror.stale` so the
857
+ * condition is at least visible, and `check-updates.ps1` says to re-run setup.
858
+ */
859
+ function sha256(file) {
860
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
861
+ }
862
+
863
+ /** Where the installed plugin lives according to setup's record, or null. */
864
+ function pluginCheckout(root) {
865
+ try {
866
+ const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
867
+ const dir = record?.pluginRoot;
868
+ if (typeof dir !== "string" || dir.trim() === "") return null;
869
+ const shim = path.join(dir, "bin", "rea-shim.mjs");
870
+ return isFileSync(shim) ? shim : null;
871
+ } catch {
872
+ return null;
873
+ }
874
+ }
875
+
876
+ /**
877
+ * Whether this copy is behind the plugin it was mirrored from.
878
+ *
879
+ * `self` is this file, `source` the checkout's. A checkout copy (it ships
880
+ * `cordis.patch.yml`) is never stale against itself; a mirror with no record, or
881
+ * whose recorded checkout is gone, is not re-pointed anywhere.
882
+ */
883
+ function describeMirror(root, { self = fileURLToPath(import.meta.url) } = {}) {
884
+ let record = null;
885
+ try {
886
+ record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
887
+ } catch {
888
+ return null;
889
+ }
890
+ const source = pluginCheckout(root);
891
+ let stale = false;
892
+ let reason = "no usable plugin checkout in mirror.json";
893
+ if (source !== null) {
894
+ if (path.resolve(source) === path.resolve(self)) {
895
+ reason = "this copy is the plugin checkout";
896
+ } else {
897
+ try {
898
+ stale = sha256(source) !== sha256(self);
899
+ reason = stale ? "the plugin ships a different launcher than this mirrored copy" : "this copy matches the plugin";
900
+ } catch {
901
+ reason = "could not compare the two launchers";
902
+ }
903
+ }
904
+ }
905
+ return {
906
+ pluginRoot: typeof record?.pluginRoot === "string" ? record.pluginRoot : null,
907
+ mirroredAt: typeof record?.mirroredAt === "string" ? record.mirroredAt : null,
908
+ stale,
909
+ reason,
910
+ };
911
+ }
912
+
913
+ /** Run the checkout's launcher instead of this copy, once. */
914
+ function reexecFromCheckout(source, argv) {
915
+ const child = spawn(process.execPath, [source, ...argv], {
916
+ stdio: "inherit",
917
+ env: { ...process.env, REA_BUNDLE_SHIM_REEXEC: "1" },
918
+ windowsHide: true,
919
+ });
920
+ child.on("error", () => {
921
+ // Falling back is better than failing: the copy still knows how to launch.
922
+ process.stderr.write(`rea-shim: could not run ${source}; continuing with the mirrored copy.\n`);
923
+ });
924
+ child.on("exit", (code, signal) => {
925
+ if (signal) {
926
+ const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
927
+ process.exit(128 + (signals[signal] ?? 0));
928
+ }
929
+ process.exit(code ?? 0);
930
+ });
931
+ return null;
932
+ }
933
+
783
934
  /**
784
935
  * Usage text, also what a bare invocation prints.
785
936
  *
@@ -808,7 +959,7 @@ function usage() {
808
959
  ].join("\n");
809
960
  }
810
961
 
811
- function main() {
962
+ async function main() {
812
963
  const argv = process.argv.slice(2);
813
964
 
814
965
  if (argv.includes("--shim-help") || argv.length === 0) {
@@ -817,12 +968,21 @@ function main() {
817
968
  }
818
969
 
819
970
  const root = bundleRoot();
971
+ // Self-healing before anything else: a mirrored copy that predates the plugin
972
+ // it was made from would otherwise keep running that older code forever, and a
973
+ // market upgrade never re-runs setup. One hop, guarded, and never for the
974
+ // status query — there the staleness is reported instead.
975
+ const mirror = describeMirror(root);
976
+ if (mirror !== null && mirror.stale && process.env.REA_BUNDLE_SHIM_REEXEC !== "1" && !argv.includes("--shim-status")) {
977
+ const source = pluginCheckout(root);
978
+ if (source !== null) return reexecFromCheckout(source, argv);
979
+ }
820
980
  const loaded = readBundleConfig(root);
821
981
  // Decide what to run before asking whether the bundle is provisioned: a
822
982
  // machine with its own working REA must not be sent into an ~800 MB
823
983
  // provisioning run just because this bundle has no state yet.
824
984
  const bundleVersion = loaded.ok ? (loaded.config.versions?.reaAgents ?? null) : null;
825
- const launch = resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
985
+ const launch = await resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
826
986
  if (launch.invalid !== null) {
827
987
  process.stderr.write(
828
988
  `rea-shim: REA_BUNDLE_LAUNCH="${launch.invalid}" is not one of ${LAUNCH_MODES.join("/")}; using auto.\n`,
@@ -831,7 +991,7 @@ function main() {
831
991
  // Upstream can add a second row for the same serverName; the shim cannot stop
832
992
  // DSH from loading both rows, but saying so is better than doubled tools with
833
993
  // no stated cause. The status report carries the same fact for diagnostics.
834
- const duplicate = foreignReaRegistration();
994
+ const duplicate = foreignReaRegistration(root);
835
995
  if (duplicate && !argv.includes("--shim-status")) {
836
996
  process.stderr.write(
837
997
  `\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
@@ -860,6 +1020,10 @@ function main() {
860
1020
  },
861
1021
  };
862
1022
  if (launch.system?.rejected?.length) report.launch.rejected = launch.system.rejected;
1023
+ // A mirrored copy that lags the plugin keeps working, which is exactly why
1024
+ // nobody notices it: the row runs old code until the mirror is refreshed.
1025
+ // Reported here so `--shim-status` answers "am I running what I installed?".
1026
+ if (mirror !== null) report.mirror = mirror;
863
1027
  if (duplicate) report.duplicateRegistration = duplicate;
864
1028
  if (loaded.ok) {
865
1029
  report.paths = loaded.config.paths ?? {};
@@ -972,5 +1136,8 @@ function main() {
972
1136
  return null;
973
1137
  }
974
1138
 
975
- const status = main();
1139
+ // The launch decision awaits a probe, so `main` is async; the status it returns
1140
+ // is this process's exit code, and a launch path resolves to null while the
1141
+ // child (already wired to `process.exit`) keeps the loop alive.
1142
+ const status = await main();
976
1143
  if (typeof status === "number") process.exit(status);
package/check-updates.ps1 CHANGED
@@ -73,9 +73,13 @@ function Test-NodeEngines([string]$node, [string]$engines) {
73
73
  # bundle.json:把全局路径当默认是 1.x 的答案,在 2.x 机器上会让这一节指向错误的
74
74
  # 对象("全局包丢了"),并给出错误的升级动作(npm i -g)。
75
75
  $bundleRootEarly = $env:REA_BUNDLE_ROOT
76
+ # Resolve the home unconditionally: the profile section below needs it whether or
77
+ # not REA_BUNDLE_ROOT was set, and computing it only inside the fallback branch
78
+ # left it $null — `Join-Path $null 'profiles'` throws, so the section degraded to
79
+ # "no profile" on exactly the machines that use the override.
80
+ $homeEarly = $env:DSH_HOME
81
+ if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
76
82
  if (-not $bundleRootEarly) {
77
- $homeEarly = $env:DSH_HOME
78
- if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
79
83
  $bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
80
84
  }
81
85
  $bundleState = $null
@@ -106,8 +110,12 @@ $reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaP
106
110
  if (-not $ProfileDir) {
107
111
  if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
108
112
  else {
109
- $profiles = @(Get-ChildItem (Join-Path $env:USERPROFILE '.dsh\profiles') -Directory -ErrorAction SilentlyContinue)
110
- $ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $env:USERPROFILE '.dsh\profiles\desktop' }
113
+ # Same home rule the bundle uses everywhere else: DSH_HOME, then ~/.dsh.
114
+ # Hardcoding $env:USERPROFILE here was a second algorithm in the same script,
115
+ # so on a custom DSH_HOME the profile section reported "no profile found"
116
+ # while the rest of the run had already resolved that home correctly.
117
+ $profiles = @(Get-ChildItem (Join-Path $homeEarly 'profiles') -Directory -ErrorAction SilentlyContinue)
118
+ $ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $homeEarly 'profiles\desktop' }
111
119
  }
112
120
  }
113
121
 
@@ -340,7 +348,27 @@ if (-not (Test-Path $manifestPath)) {
340
348
  Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
341
349
  }
342
350
  $mirrored = Join-Path $bundleRoot 'bin\rea-shim.mjs'
343
- if (Test-Path $mirrored) { Ok 'MCP 行指向的 shim 已就位' }
351
+ if (Test-Path $mirrored) {
352
+ Ok 'MCP 行指向的 shim 已就位'
353
+ # The row launches this COPY, so a plugin upgrade that does not re-run
354
+ # setup leaves the old launcher in place — visible only as "the new
355
+ # version behaves like the old one". Compare it with the checkout the
356
+ # mirror record names.
357
+ $mirrorRecord = Join-Path $bundleRoot 'mirror.json'
358
+ if (Test-Path $mirrorRecord) {
359
+ try {
360
+ $record = Get-Content $mirrorRecord -Raw -Encoding UTF8 | ConvertFrom-Json
361
+ $pluginShim = if ($record.pluginRoot) { Join-Path $record.pluginRoot 'bin\rea-shim.mjs' } else { $null }
362
+ if ($pluginShim -and (Test-Path $pluginShim)) {
363
+ $same = (Get-FileHash $mirrored -Algorithm SHA256).Hash -eq (Get-FileHash $pluginShim -Algorithm SHA256).Hash
364
+ if ($same) { Ok '镜像 shim 与插件一致(行跑的就是这个版本)' }
365
+ else { Warn "镜像 shim 落后于插件($($record.pluginRoot))—— 行仍在跑旧副本;重跑 node scripts/setup.mjs 让它换成新版本" }
366
+ } else {
367
+ Warn "mirror.json 指向的插件目录已不存在($($record.pluginRoot))—— 无法比对镜像 shim 是否最新"
368
+ }
369
+ } catch { Warn "mirror.json 解析失败: $($_.Exception.Message)" }
370
+ }
371
+ }
344
372
  else { Bad "缺少 MCP 行指向的 shim: $mirrored —— 重跑 setup --reuse" }
345
373
  # 镜像副本的 setup 用它自己所在的位置推 plugin root,所以 manifest 必须
346
374
  # 一起镜像。旧版镜像缺这个文件:auto-provision 会以 ENOENT 崩掉且不可见,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-rea",
3
- "version": "2.0.7",
3
+ "version": "2.0.8",
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
  "main": "./index.mjs",
package/scripts/setup.mjs CHANGED
@@ -1636,6 +1636,31 @@ function mirrorLauncher(context) {
1636
1636
  */
1637
1637
  function writeMirrorRecord(context, source) {
1638
1638
  const record = { schema: 1, pluginRoot: source, mirroredAt: new Date().toISOString() };
1639
+ // Provenance for the self-healing check: the mirrored shim compares itself
1640
+ // with `<pluginRoot>/bin/rea-shim.mjs` and re-execs that one when the plugin
1641
+ // has moved on. These two fields are what `--shim-status` shows when the
1642
+ // comparison cannot be made (missing checkout, unreadable file).
1643
+ try {
1644
+ record.shimSha256 = createHash("sha256")
1645
+ .update(readFileSync(path.join(context.root, "bin", "rea-shim.mjs")))
1646
+ .digest("hex");
1647
+ } catch {
1648
+ /* the copy is missing or unreadable; the shim reports the comparison itself */
1649
+ }
1650
+ try {
1651
+ record.pluginVersion = JSON.parse(readFileSync(path.join(source, "package.json"), "utf8")).version ?? null;
1652
+ } catch {
1653
+ record.pluginVersion = null;
1654
+ }
1655
+ // The MCP child the harness starts has every DSH_* variable stripped, so the
1656
+ // mirrored shim cannot re-derive a CUSTOM home from its environment — it would
1657
+ // read `~/.dsh` and miss a duplicate `serverName: rea` living in the real one.
1658
+ // Record the home setup ran under; the shim prefers the environment, then this.
1659
+ try {
1660
+ record.dshHome = dshHome();
1661
+ } catch {
1662
+ record.dshHome = null;
1663
+ }
1639
1664
  try {
1640
1665
  mkdirSync(context.root, { recursive: true });
1641
1666
  writeFileSync(path.join(context.root, "mirror.json"), JSON.stringify(record, null, 2) + "\n");
@@ -1564,3 +1564,184 @@ test("--shim-print-env reports the system launch instead of a child environment"
1564
1564
  f.clean();
1565
1565
  }
1566
1566
  });
1567
+
1568
+ test("a candidate that wraps this shim is rejected instead of recursing", () => {
1569
+ // The probe asks the candidate for `--version`; a wrapper answers by running
1570
+ // this very shim, which would probe the wrapper again. The probe therefore runs
1571
+ // with REA_BUNDLE_DELEGATED=1, so the nested run takes the bundle path and
1572
+ // returns. Without that, one wrapper on PATH spawns a process chain that keeps
1573
+ // respawning after the parent is killed.
1574
+ const f = launchFixture();
1575
+ mkdirSync(f.bin, { recursive: true });
1576
+ const shim = path.join(REPO_ROOT, "bin", "rea-shim.mjs");
1577
+ const wrapper = path.join(f.bin, process.platform === "win32" ? "rea.cmd" : "rea");
1578
+ if (process.platform === "win32") {
1579
+ writeFileSync(wrapper, `@echo off\r\n"${process.execPath}" "${shim}" %*\r\n`);
1580
+ } else {
1581
+ writeFileSync(wrapper, `#!/bin/sh\nexec "${process.execPath}" "${shim}" "$@"\n`);
1582
+ chmodSync(wrapper, 0o755);
1583
+ }
1584
+ try {
1585
+ const started = Date.now();
1586
+ const run = runShimIsolated(
1587
+ { PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: wrapper, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
1588
+ ["--shim-status"],
1589
+ );
1590
+ const elapsed = Date.now() - started;
1591
+ const report = JSON.parse(run.stdout);
1592
+ assert.equal(report.launch.mode, "bundle");
1593
+ assert.deepEqual(report.launch.rejected.map(r => r.reason), ["exit 78"]);
1594
+ assert.ok(elapsed < 20000, `the probe must not recurse; it took ${elapsed} ms`);
1595
+ } finally {
1596
+ f.clean();
1597
+ }
1598
+ });
1599
+
1600
+ test("a ComSpec that points at nothing falls back to the real shell", () => {
1601
+ // Trusting ComSpec blindly would judge every .cmd REA "spawn-failed" — i.e.
1602
+ // report the machine as having no REA because its shell pointer is broken.
1603
+ const f = launchFixture();
1604
+ mkdirSync(f.bin, { recursive: true });
1605
+ const rea = writeFakeRea(f.bin, { version: "6.9.0" });
1606
+ try {
1607
+ const run = runShimIsolated(
1608
+ {
1609
+ PATH: f.emptyPath,
1610
+ ComSpec: path.join(f.root, "no-such-cmd.exe"),
1611
+ REA_BUNDLE_SYSTEM_REA: rea,
1612
+ REA_BUNDLE_ROOT: f.bundleRoot,
1613
+ REA_BUNDLE_AUTO_PROVISION: "0",
1614
+ REA_BUNDLE_LAUNCH: "",
1615
+ },
1616
+ ["--shim-status"],
1617
+ );
1618
+ const report = JSON.parse(run.stdout);
1619
+ if (process.platform === "win32") {
1620
+ assert.equal(report.launch.mode, "system", JSON.stringify(report.launch));
1621
+ assert.equal(report.launch.systemRea.version, "6.9.0");
1622
+ } else {
1623
+ // POSIX never consults ComSpec; the assertion above would be incidental.
1624
+ assert.equal(report.launch.mode, "system");
1625
+ }
1626
+ } finally {
1627
+ f.clean();
1628
+ }
1629
+ });
1630
+
1631
+ /* --------------------------------------------------- the mirrored launcher */
1632
+
1633
+ // The row launches `<bundle>/bin/rea-shim.mjs`, a COPY. A market upgrade replaces
1634
+ // the package and runs no setup, so without this the copy keeps running the code
1635
+ // it was made from — every fix silently stops at the old mirror. These tests pin
1636
+ // the repair: the copy re-execs the plugin's launcher when they differ, once.
1637
+
1638
+ function mirrorFixture() {
1639
+ const root = mkdtempSync(path.join(tmpdir(), "rea-mirror-"));
1640
+ const bundle = path.join(root, "bundle");
1641
+ const checkout = path.join(root, "checkout");
1642
+ mkdirSync(path.join(bundle, "bin"), { recursive: true });
1643
+ mkdirSync(path.join(checkout, "bin"), { recursive: true });
1644
+ copyFileSync(path.join(REPO_ROOT, "bin", "rea-shim.mjs"), path.join(bundle, "bin", "rea-shim.mjs"));
1645
+ writeFileSync(path.join(bundle, "mirror.json"), JSON.stringify({ schema: 1, pluginRoot: checkout, mirroredAt: "2026-10-18T00:00:00Z" }));
1646
+ return { root, bundle, checkout, clean() { rmSync(root, { recursive: true, force: true }); } };
1647
+ }
1648
+
1649
+ function checkoutStub(checkout, marker) {
1650
+ const file = path.join(checkout, "bin", "rea-shim.mjs");
1651
+ writeFileSync(
1652
+ file,
1653
+ 'import { writeFileSync } from "node:fs";\n' +
1654
+ `writeFileSync(${JSON.stringify(marker)}, "checkout");\n` +
1655
+ "process.exit(7);\n",
1656
+ );
1657
+ return file;
1658
+ }
1659
+
1660
+ test("a mirrored launcher re-execs the plugin's launcher when they differ", () => {
1661
+ const f = mirrorFixture();
1662
+ const marker = path.join(f.root, "checkout-ran");
1663
+ checkoutStub(f.checkout, marker);
1664
+ try {
1665
+ const run = runShimIsolated({ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" }, ["mcp"]);
1666
+ assert.equal(run.status, 7, `expected the checkout's status, got ${run.status}: ${run.stderr}`);
1667
+ assert.equal(existsSync(marker), true, "the checkout launcher must have run");
1668
+ } finally {
1669
+ f.clean();
1670
+ }
1671
+ });
1672
+
1673
+ test("the re-exec happens once, and a pinned guard stops it", () => {
1674
+ const f = mirrorFixture();
1675
+ const marker = path.join(f.root, "checkout-ran");
1676
+ checkoutStub(f.checkout, marker);
1677
+ try {
1678
+ const run = runShimIsolated(
1679
+ { REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", REA_BUNDLE_SHIM_REEXEC: "1" },
1680
+ ["mcp"],
1681
+ );
1682
+ // The guard is set, so the mirrored copy runs its own logic: bundle pinned and
1683
+ // nothing provisioned means EX_CONFIG, and the checkout never starts.
1684
+ assert.equal(run.status, 78, run.stderr);
1685
+ assert.equal(existsSync(marker), false, "the guard must prevent a second hop");
1686
+ } finally {
1687
+ f.clean();
1688
+ }
1689
+ });
1690
+
1691
+ test("--shim-status reports a stale mirror instead of re-execing it", () => {
1692
+ const f = mirrorFixture();
1693
+ const marker = path.join(f.root, "checkout-ran");
1694
+ checkoutStub(f.checkout, marker);
1695
+ try {
1696
+ const run = runShimIsolated({ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" }, ["--shim-status"]);
1697
+ const report = JSON.parse(run.stdout);
1698
+ assert.equal(report.mirror.stale, true, JSON.stringify(report.mirror));
1699
+ assert.match(report.mirror.reason, /different launcher/);
1700
+ assert.equal(existsSync(marker), false, "status is a query; it must not start anything");
1701
+ } finally {
1702
+ f.clean();
1703
+ }
1704
+ });
1705
+
1706
+ test("an identical mirror is not stale and is not re-exec'd", () => {
1707
+ const f = mirrorFixture();
1708
+ const same = path.join(f.checkout, "bin", "rea-shim.mjs");
1709
+ copyFileSync(path.join(f.bundle, "bin", "rea-shim.mjs"), same);
1710
+ try {
1711
+ const run = runShimIsolated({ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" }, ["--shim-status"]);
1712
+ const report = JSON.parse(run.stdout);
1713
+ assert.equal(report.mirror.stale, false, JSON.stringify(report.mirror));
1714
+ assert.equal(report.mirror.reason, "this copy matches the plugin");
1715
+ } finally {
1716
+ f.clean();
1717
+ }
1718
+ });
1719
+
1720
+ test("a mirrored shim finds the real DSH home when the environment is scrubbed", () => {
1721
+ // The harness strips DSH_* from an MCP child, so with a custom DSH_HOME the
1722
+ // old code read ~/.dsh — the wrong file — and could never report upstream's
1723
+ // duplicate `serverName: rea`. setup records the home; the shim must use it.
1724
+ const f = mirrorFixture();
1725
+ const home = path.join(f.root, "custom-home");
1726
+ mkdirSync(home, { recursive: true });
1727
+ writeFileSync(
1728
+ path.join(home, "cordis.patch.yml"),
1729
+ "- insert:\n - id: rea-mcp\n name: '@deepseek-ai/dsh-mcp-client'\n config:\n serverName: rea\n",
1730
+ );
1731
+ writeFileSync(
1732
+ path.join(f.bundle, "mirror.json"),
1733
+ JSON.stringify({ schema: 1, pluginRoot: f.checkout, mirroredAt: "2026-10-18T00:00:00Z", dshHome: home }),
1734
+ );
1735
+ try {
1736
+ const run = runShimIsolated(
1737
+ // DSH_HOME="" models the scrubbed environment: the variable exists, empty.
1738
+ { REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", DSH_HOME: "" },
1739
+ ["--shim-status"],
1740
+ );
1741
+ const report = JSON.parse(run.stdout);
1742
+ assert.equal(report.duplicateRegistration?.id, "rea-mcp", run.stdout);
1743
+ assert.equal(report.duplicateRegistration.patch, path.join(home, "cordis.patch.yml"));
1744
+ } finally {
1745
+ f.clean();
1746
+ }
1747
+ });
@@ -90,6 +90,14 @@ Check 'main resolves to a shipped file' ($pkg.main -and (Test-Path (Join-Path $r
90
90
  Check 'package.json declares engines.dsh' (-not [string]::IsNullOrWhiteSpace($pkg.engines.dsh)) ''
91
91
  $compat = & node scripts\host-compat.mjs 2>&1
92
92
  Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($compat | Select-Object -Last 2) -join ' ')
93
+ # Both READMEs quote the range verbatim, and a stale quote is invisible: JSON,
94
+ # host-compat and the audit all agree while the sentence a human reads names a
95
+ # different ceiling. That is exactly how `<0.3.0` survived in the docs after the
96
+ # declaration moved to `<0.3.0-0`, so the comparison lives here now.
97
+ foreach ($doc in @('README.md', 'README.zh-CN.md')) {
98
+ $text = Get-Content (Join-Path $root $doc) -Raw -Encoding UTF8
99
+ Check "$doc quotes the declared engines.dsh" $text.Contains([string]$pkg.engines.dsh) "expected $($pkg.engines.dsh)"
100
+ }
93
101
  foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test', 'bundle:host-compat')) {
94
102
  Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
95
103
  }