dsh-plugin-rea 2.0.7 → 2.0.9

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/bin/rea-shim.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * rea-shim 鈥?the process the DSH MCP row launches.
3
+ * rea-shim — the process the DSH MCP row launches.
4
4
  *
5
5
  * Why this exists: the published `cordis.patch.yml` must carry no machine path,
6
6
  * yet REA needs absolute paths for every dependency. The shim is the seam: it
@@ -10,11 +10,12 @@
10
10
  * It downloads nothing, writes nothing, and never touches PATH.
11
11
  *
12
12
  * Exit codes: 0 child exited normally * child's own status
13
- * 78 EX_CONFIG bundle not provisioned (or unusable) 鈥?the row has
13
+ * 78 EX_CONFIG bundle not provisioned (or unusable) — the row has
14
14
  * `failOnStartupError: false`, so the harness stays up
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";
@@ -25,6 +26,19 @@ const EX_CONFIG = 78;
25
26
  const HERE = path.dirname(fileURLToPath(import.meta.url));
26
27
  const PLUGIN_ROOT = path.resolve(HERE, "..");
27
28
 
29
+ /**
30
+ * JSON from disk, tolerating the UTF-8 BOM a Windows editor writes.
31
+ *
32
+ * `JSON.parse` rejects a leading BOM, and every caller here reads a parse
33
+ * failure as "no record": a BOM would silently downgrade mirror.json to "no
34
+ * plugin checkout" and bundle.json to "not provisioned". Measured while testing
35
+ * this file — a hand-edited mirror.json written by Windows PowerShell behaved
36
+ * exactly that way.
37
+ */
38
+ function readJson(file) {
39
+ return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
40
+ }
41
+
28
42
  /** DSH home, by the same rule setup.mjs and the published patch use. */
29
43
  function dshHome() {
30
44
  const fromEnv = process.env.DSH_HOME;
@@ -32,6 +46,36 @@ function dshHome() {
32
46
  return path.join(homedir(), ".dsh");
33
47
  }
34
48
 
49
+ /**
50
+ * The DSH home recorded by setup, or null.
51
+ *
52
+ * Only the mirrored copy needs this: the harness strips `DSH_*` from an MCP
53
+ * child, so with a custom home the environment cannot answer. Read once per
54
+ * process — there is one bundle root per process and this is a startup query.
55
+ */
56
+ let recordedDshHomeCache = null;
57
+ function recordedDshHome(root) {
58
+ if (recordedDshHomeCache !== null) return recordedDshHomeCache;
59
+ let value = null;
60
+ try {
61
+ const record = readJson(path.join(root, "mirror.json"));
62
+ if (typeof record?.dshHome === "string" && record.dshHome.trim() !== "" && path.isAbsolute(record.dshHome)) {
63
+ value = path.resolve(record.dshHome);
64
+ }
65
+ } catch {
66
+ value = null;
67
+ }
68
+ recordedDshHomeCache = value;
69
+ return value;
70
+ }
71
+
72
+ /** The home to look in when the environment has been scrubbed. */
73
+ function dshHomeFor(root) {
74
+ const fromEnv = process.env.DSH_HOME;
75
+ if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
76
+ return recordedDshHome(root) ?? path.join(homedir(), ".dsh");
77
+ }
78
+
35
79
  /**
36
80
  * Where setup writes the machine-local truth.
37
81
  *
@@ -43,11 +87,11 @@ function dshHome() {
43
87
  * DSH_HOME: the harness strips every `DSH_*` name from an MCP child's
44
88
  * environment (case-insensitively, because Windows is), so a custom DSH_HOME
45
89
  * would send it to `~/.dsh`, where it would report "not provisioned" for a
46
- * bundle sitting right next to it 鈥?and start a second ~800 MB run there.
90
+ * bundle sitting right next to it — and start a second ~800 MB run there.
47
91
  * * The **checkout** copy (`<plugin>/bin/rea-shim.mjs`) is what a human runs, e.g.
48
92
  * `npm run bundle:status`, from a shell where DSH_HOME *is* visible. Its own
49
93
  * directory is the plugin, not the bundle, so resolving to itself would report
50
- * "not provisioned" on a fully provisioned machine 鈥?and a bare
94
+ * "not provisioned" on a fully provisioned machine — and a bare
51
95
  * `node bin/rea-shim.mjs mcp` would provision ~800 MB into the checkout.
52
96
  *
53
97
  * Only the checkout ships `cordis.patch.yml`, so that file is the discriminator.
@@ -80,11 +124,11 @@ function usableSetup(dir) {
80
124
  * The mirrored copy is a snapshot: after a plugin upgrade it still holds the
81
125
  * previous manifest, so it would provision the previous versions forever. setup
82
126
  * writes <bundle>/mirror.json naming the real checkout; prefer that while it is
83
- * usable, and fall back to the local copy 鈥?which is also all a first run has.
127
+ * usable, and fall back to the local copy — which is also all a first run has.
84
128
  */
85
129
  function provisioningScript(root) {
86
130
  try {
87
- const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
131
+ const record = readJson(path.join(root, "mirror.json"));
88
132
  const recorded = usableSetup(record && record.pluginRoot);
89
133
  if (recorded) return recorded;
90
134
  } catch {
@@ -104,13 +148,16 @@ function provisioningScript(root) {
104
148
  *
105
149
  * The parse is deliberately small: the two files it cares about are block
106
150
  * 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.
151
+ * ignored — a missed hint is acceptable, a false alarm is not.
152
+ *
153
+ * Finding the file is the part that needed care: the harness strips every DSH_*
154
+ * variable from an MCP child, so a mirrored copy with a custom DSH_HOME used to
155
+ * read `~/.dsh` — the wrong file, and therefore a conflict it could never report.
156
+ * `setup.mjs` writes the home it ran under into `mirror.json`, and that record is
157
+ * what this uses when the environment has nothing to say.
111
158
  */
112
- function foreignReaRegistration() {
113
- const patch = path.join(dshHome(), "cordis.patch.yml");
159
+ function foreignReaRegistration(root) {
160
+ const patch = path.join(dshHomeFor(root), "cordis.patch.yml");
114
161
  let text;
115
162
  try {
116
163
  text = readFileSync(patch, "utf8");
@@ -133,7 +180,7 @@ function foreignReaRegistration() {
133
180
  /**
134
181
  * The provisioning lock is a small JSON record: { state, pid, at, exit? }.
135
182
  * `state: "running"` is written when a run is started (setup's own pid once it
136
- * exists), and the failing run rewrites it as `state: "failed"` 鈥?it is kept on
183
+ * exists), and the failing run rewrites it as `state: "failed"` — it is kept on
137
184
  * purpose so a broken setup cannot be re-downloaded in a loop. A legacy
138
185
  * plain-number lock is still honoured as "running".
139
186
  */
@@ -171,7 +218,7 @@ function pidAlive(pid) {
171
218
  * a killed attempt is simply continued on the next start.
172
219
  *
173
220
  * Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
174
- * Returns { started, reason, logPath } 鈥?the caller words its message from
221
+ * Returns { started, reason, logPath } — the caller words its message from
175
222
  * `reason`, because "provisioning started in the background" was also printed
176
223
  * for a lock left behind by a run that had already failed, and for one whose
177
224
  * process was long gone.
@@ -208,7 +255,7 @@ function maybeAutoProvision(root) {
208
255
  // Reached when the lock is older than the freshness window, or its holder is
209
256
  // gone and never wrote an outcome (harness restart, power loss, a kill): what
210
257
  // it records is not happening any more. This removal is the whole point of the
211
- // freshness rule 鈥?without it the exclusive create below failed with EEXIST
258
+ // freshness rule — without it the exclusive create below failed with EEXIST
212
259
  // *forever*, auto-provisioning never ran again, and main() printed nothing at
213
260
  // all for that outcome. The lock's own "honour it only while it is fresh"
214
261
  // comment described behaviour the code did not have.
@@ -265,7 +312,7 @@ function fail(message, detail) {
265
312
  if (detail) lines.push("", " detail: " + detail);
266
313
  // Point at the real checkout when the record allows it: running the mirrored
267
314
  // copy is fine, but it can only ever provision the manifest it was mirrored
268
- // with 鈥?and a bundle mirrored before the manifest travelled with it has no
315
+ // with — and a bundle mirrored before the manifest travelled with it has no
269
316
  // usable copy at all, so the honest answer is then "run the plugin's own script".
270
317
  const root = bundleRoot();
271
318
  const setupScript = provisioningScript(root);
@@ -299,7 +346,7 @@ function readBundleConfig(root) {
299
346
  if (!existsSync(file)) return { ok: false, code: "missing", file };
300
347
  let parsed;
301
348
  try {
302
- parsed = JSON.parse(readFileSync(file, "utf8"));
349
+ parsed = readJson(file);
303
350
  } catch (error) {
304
351
  return { ok: false, code: "malformed", file, detail: String(error && error.message) };
305
352
  }
@@ -316,7 +363,7 @@ function resolveRea(config, root) {
316
363
  // The fallback has to match where setup actually installs the package:
317
364
  // `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
318
365
  // fallback without that segment named a directory that never exists. It only
319
- // fires when an older bundle.json has no paths.reaEntry 鈥?which is exactly when
366
+ // fires when an older bundle.json has no paths.reaEntry — which is exactly when
320
367
  // a wrong fallback turns a usable install into "the vendored REA CLI is gone".
321
368
  const reaEntry = paths.reaEntry
322
369
  ?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
@@ -373,7 +420,7 @@ function childEnvironment(config) {
373
420
  * the machine already has one, the honest thing to do is run it.
374
421
  *
375
422
  * The 1.x line was exactly that: `rea mcp` off PATH, user-managed Node and
376
- * Ghidra. So "use the system install" is not a new code path invented here 鈥?it
423
+ * Ghidra. So "use the system install" is not a new code path invented here — it
377
424
  * is the 1.x contract, reachable from the 2.x row. `auto` (the default) prefers
378
425
  * the system install and falls back to the provisioned bundle; `bundle` pins the
379
426
  * old behaviour; `system` insists (and fails loudly instead of provisioning
@@ -432,7 +479,7 @@ function systemReaNames(platform = process.platform) {
432
479
  /**
433
480
  * Every path worth probing, most explicit first.
434
481
  *
435
- * `REA_BUNDLE_SYSTEM_REA` names one binary outright 鈥?that is both the test
482
+ * `REA_BUNDLE_SYSTEM_REA` names one binary outright — that is both the test
436
483
  * seam and a real operator need (an install that is not on the harness's PATH,
437
484
  * which on Desktop is not the user's shell PATH).
438
485
  */
@@ -454,7 +501,9 @@ function systemReaCandidates({ env = process.env, platform = process.platform }
454
501
  }
455
502
  const seen = new Set();
456
503
  return candidates.filter((candidate) => {
457
- const key = process.platform === "win32" ? candidate.toLowerCase() : candidate;
504
+ // Keyed on the platform being RESOLVED, not on the one this process happens
505
+ // to run: the tests pass `platform` to exercise both shapes on one machine.
506
+ const key = platform === "win32" ? candidate.toLowerCase() : candidate;
458
507
  if (seen.has(key)) return false;
459
508
  seen.add(key);
460
509
  return true;
@@ -516,7 +565,11 @@ function cmdQuote(value) {
516
565
  */
517
566
  function comspec() {
518
567
  const fromEnv = process.env.ComSpec ?? process.env.COMSPEC;
519
- if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv;
568
+ // A ComSpec that names nothing is a real (if unusual) state, and trusting it
569
+ // would judge every `.cmd` REA "spawn-failed" — i.e. report the machine as
570
+ // having no REA because its shell pointer is broken. Fall back to the path
571
+ // Windows itself would use.
572
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "" && isFileSync(fromEnv)) return fromEnv;
520
573
  const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
521
574
  return path.join(systemRoot, "System32", "cmd.exe");
522
575
  }
@@ -527,50 +580,77 @@ function comspec() {
527
580
  * A file existing on PATH is not the same as a working install: npx leftovers,
528
581
  * half-removed globals and a shim whose Node is gone all sit on PATH and all
529
582
  * 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
583
+ * answer counts as "not usable" — the fallback (the bundle) still works, while
531
584
  * delegating to a broken binary would take the tools down with it.
585
+ *
586
+ * Two things here are not incidental:
587
+ *
588
+ * * The probe runs with `REA_BUNDLE_DELEGATED=1`. A candidate that is itself a
589
+ * wrapper around this shim would otherwise be probed by a nested shim that
590
+ * probes the wrapper — measured: a process chain that keeps respawning
591
+ * itself. The flag makes the nested run take the bundle path and return.
592
+ * * It is asynchronous, not `spawnSync` with a timeout. `spawnSync` reaps the
593
+ * child it started, and its timeout kills only that child: for a `.cmd` shim
594
+ * that means cmd.exe dies while the REA inside it keeps running and holds
595
+ * the pipes. Killing the TREE is only possible while the parent is alive,
596
+ * which means owning the wait ourselves.
532
597
  */
533
598
  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 };
599
+ return new Promise((resolve) => {
600
+ // `existsSync` says nothing about the shape: a DIRECTORY named `rea.cmd`
601
+ // exists, fails to spawn, and would be reported as a broken install instead
602
+ // of as a name collision. Only a regular file is a candidate.
603
+ if (!isFileSync(file)) {
604
+ resolve({ ok: false, reason: "not a file" });
605
+ return;
606
+ }
607
+ const useShell = platform === "win32" && /\.(cmd|bat)$/i.test(file);
608
+ // `REA_BUNDLE_AUTO_PROVISION=0` is part of the same guard: if the candidate
609
+ // turns out to be a wrapper around THIS shim, the nested run must not start
610
+ // an 800 MB provisioning job just because a probe asked it for a version.
611
+ const env = { ...process.env, REA_BUNDLE_DELEGATED: "1", REA_BUNDLE_AUTO_PROVISION: "0" };
612
+ // `cmd /d /s /c "<line>"`: Windows only. Two details are load-bearing.
613
+ // `/s` strips the first and last quote, so the whole line is quoted; and the
614
+ // arguments are passed VERBATIM, because Node otherwise backslash-escapes the
615
+ // quotes it sees and cmd.exe does not understand `\"` — every candidate then
616
+ // answers "exit 1", which reads as "no REA on this machine".
617
+ const child = useShell
618
+ ? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(file), "--version"].join(" ")}"`], {
619
+ env,
620
+ stdio: ["ignore", "pipe", "pipe"],
621
+ windowsHide: true,
622
+ windowsVerbatimArguments: true,
623
+ })
624
+ : spawn(file, ["--version"], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
625
+ let output = "";
626
+ let settled = false;
627
+ let timer = null;
628
+ const finish = (result) => {
629
+ if (settled) return;
630
+ settled = true;
631
+ if (timer !== null) clearTimeout(timer);
632
+ resolve(result);
633
+ };
634
+ timer = setTimeout(() => {
635
+ killTree(child.pid, platform);
636
+ finish({ ok: false, reason: "timeout" });
637
+ }, timeout);
638
+ child.stdout.on("data", (chunk) => { output += String(chunk); });
639
+ child.stderr.on("data", (chunk) => { output += String(chunk); });
640
+ child.on("error", () => finish({ ok: false, reason: "spawn-failed" }));
641
+ child.on("close", (code) => {
642
+ if (code !== 0) {
643
+ finish({ ok: false, reason: `exit ${String(code)}` });
644
+ return;
645
+ }
646
+ // A REA install prints its version. Anything else (a shim that swallowed
647
+ // the flag, an unrelated program that shares the name) is not a usable CLI.
648
+ const version = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(output.trim())?.[1] ?? null;
649
+ finish(version === null
650
+ ? { ok: false, reason: "no version in --version output" }
651
+ : { ok: true, version });
652
+ });
653
+ });
574
654
  }
575
655
 
576
656
  /**
@@ -580,7 +660,7 @@ function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_M
580
660
  * provisioner installed is the `bundle` mode, and delegating to it would be a
581
661
  * loop through a path the bundle already covers.
582
662
  */
583
- function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
663
+ async function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
584
664
  const rejected = [];
585
665
  let profiled = 0;
586
666
  const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
@@ -609,7 +689,7 @@ function detectSystemRea({ env = process.env, platform = process.platform, root,
609
689
  continue;
610
690
  }
611
691
  profiled += 1;
612
- const probe = probeRea(candidate, { platform });
692
+ const probe = await probeRea(candidate, { platform });
613
693
  if (probe.ok) return { found: true, file: candidate, version: probe.version };
614
694
  if (rejected.length < 8) rejected.push({ file: candidate, reason: probe.reason });
615
695
  }
@@ -624,7 +704,9 @@ function detectSystemRea({ env = process.env, platform = process.platform, root,
624
704
  if (explicitPath !== null && explicitRejection !== null) {
625
705
  reason = `REA_BUNDLE_SYSTEM_REA was rejected (${explicitRejection.reason}): ${explicitPath}`;
626
706
  } else if (explicitPath !== null && !isFileSync(explicitPath)) {
627
- reason = `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
707
+ reason = existsSync(explicitPath)
708
+ ? `REA_BUNDLE_SYSTEM_REA is not a file: ${explicitPath}`
709
+ : `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
628
710
  } else {
629
711
  reason = profiled === 0 ? "no rea on PATH" : "no usable rea on PATH";
630
712
  }
@@ -657,10 +739,10 @@ function compareDotted(a, b) {
657
739
  * the one this bundle provisioned would silently take the tool count back
658
740
  * (6.4.0 ships 170 tools, 6.5.0 ships 207), so the newer of the two wins and
659
741
  * the older is only used when asked for by name. `bundle` and `system` are
660
- * honoured literally 鈥?`system` fails loudly rather than provisioning 800 MB
742
+ * honoured literally — `system` fails loudly rather than provisioning 800 MB
661
743
  * behind an operator who asked for their own REA.
662
744
  */
663
- function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
745
+ async function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
664
746
  const preference = launchPreference(env);
665
747
  if (preference.value === "bundle") {
666
748
  return { preference: preference.value, invalid: preference.invalid, mode: "bundle", reason: "pinned by REA_BUNDLE_LAUNCH=bundle" };
@@ -678,7 +760,7 @@ function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false,
678
760
  reason: "REA_BUNDLE_DELEGATED=1: already running under a delegated system REA, refusing to delegate again",
679
761
  };
680
762
  }
681
- const system = detectSystemRea({ env, platform, root });
763
+ const system = await detectSystemRea({ env, platform, root });
682
764
  if (system.found) {
683
765
  const order = bundleVersion === null ? null : compareDotted(system.version, bundleVersion);
684
766
  const pinned = preference.value === "system";
@@ -747,25 +829,14 @@ function launchSystemRea(system, argv) {
747
829
  windowsVerbatimArguments: true,
748
830
  })
749
831
  : 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
- }
832
+ // No signal forwarding on Windows, deliberately. `child.kill()` there is
833
+ // TerminateProcess on the cmd.exe WRAPPER — the REA inside it survives, still
834
+ // holding the inherited pipes — and a console Ctrl+C is delivered to the whole
835
+ // process group anyway, so the child already has it. A hard kill of this shim
836
+ // (what a supervisor or Task Manager does) runs no JavaScript at all, so the
837
+ // wrapper must not pretend otherwise: on Windows the delegated REA outlives a
838
+ // forcibly killed shim. On POSIX the child is in this process's group and gets
839
+ // the terminal's signal directly; `process.exit` below then ends the shim.
769
840
  child.on("error", (error) => {
770
841
  process.stderr.write(`rea-shim: could not start ${system.file}\n ${String(error && error.message)}\n`);
771
842
  process.exit(EX_CONFIG);
@@ -780,11 +851,145 @@ function launchSystemRea(system, argv) {
780
851
  return null;
781
852
  }
782
853
 
854
+ /**
855
+ * The mirror problem.
856
+ *
857
+ * The published row launches `<bundle>/bin/rea-shim.mjs` — a COPY made by
858
+ * `setup.mjs`. Upgrading the plugin from the market replaces the package but runs
859
+ * no setup, so the copy keeps executing the code it was made from: every fix in
860
+ * a new plugin version reaches a user only if they happen to re-run setup. That
861
+ * is invisible (the old copy keeps working) and it is exactly the wrong
862
+ * direction for a safety fix.
863
+ *
864
+ * The repair has to live in the copy, because the copy is what runs. So the
865
+ * copy compares itself with the plugin's own `bin/rea-shim.mjs`, recorded in
866
+ * `mirror.json` by setup, and re-execs that file when the two differ. One hop,
867
+ * guarded by an environment flag, and only from mirror to checkout — the
868
+ * checkout copy has no `mirror.json` and would find itself anyway.
869
+ *
870
+ * A plugin version older than 2.0.8 cannot do this for its own upgrades: there
871
+ * is nothing in it to run. `--shim-status` reports `mirror.stale` so the
872
+ * condition is at least visible, and `check-updates.ps1` says to re-run setup.
873
+ */
874
+ function sha256(file) {
875
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
876
+ }
877
+
878
+ /** Where the installed plugin lives according to setup's record, or null. */
879
+ function pluginCheckout(root) {
880
+ try {
881
+ const record = readJson(path.join(root, "mirror.json"));
882
+ const dir = record?.pluginRoot;
883
+ if (typeof dir !== "string" || dir.trim() === "") return null;
884
+ const shim = path.join(dir, "bin", "rea-shim.mjs");
885
+ return isFileSync(shim) ? shim : null;
886
+ } catch {
887
+ return null;
888
+ }
889
+ }
890
+
891
+ /**
892
+ * Whether this copy is behind the plugin it was mirrored from.
893
+ *
894
+ * `self` is this file, `source` the checkout's. A checkout copy (it ships
895
+ * `cordis.patch.yml`) is never stale against itself; a mirror with no record, or
896
+ * whose recorded checkout is gone, is not re-pointed anywhere.
897
+ */
898
+ function describeMirror(root, { self = fileURLToPath(import.meta.url) } = {}) {
899
+ let record = null;
900
+ try {
901
+ record = readJson(path.join(root, "mirror.json"));
902
+ } catch {
903
+ return null;
904
+ }
905
+ const source = pluginCheckout(root);
906
+ let stale = false;
907
+ let reason = "no usable plugin checkout in mirror.json";
908
+ if (source !== null) {
909
+ if (path.resolve(source) === path.resolve(self)) {
910
+ reason = "this copy is the plugin checkout";
911
+ } else {
912
+ try {
913
+ stale = sha256(source) !== sha256(self);
914
+ reason = stale ? "the plugin ships a different launcher than this mirrored copy" : "this copy matches the plugin";
915
+ } catch {
916
+ reason = "could not compare the two launchers";
917
+ }
918
+ }
919
+ }
920
+ return {
921
+ pluginRoot: typeof record?.pluginRoot === "string" ? record.pluginRoot : null,
922
+ mirroredAt: typeof record?.mirroredAt === "string" ? record.mirroredAt : null,
923
+ stale,
924
+ reason,
925
+ };
926
+ }
927
+
928
+ /**
929
+ * Run the checkout's launcher instead of this copy, once.
930
+ *
931
+ * The promise resolves ONLY when the re-exec did not take over, and the caller
932
+ * must then continue with its own launch path. While the child is alive the
933
+ * promise stays pending and the child's own exit becomes this process's exit —
934
+ * settling it at the `spawn` event was wrong: the event fires before the child
935
+ * has done anything, so a later "it actually failed" could no longer be seen, and
936
+ * the two handlers disagreed about who owns the exit code. That mistake made the
937
+ * file-vanishes-mid-flight case exit 0 silently.
938
+ *
939
+ * The first version of this printed "continuing with the mirrored copy" on
940
+ * `error` and returned: nothing continued, no `exit` event followed a failed
941
+ * spawn, the loop drained, and the row saw exit 0 with no tools and no message.
942
+ * A fallback that is announced but not performed is worse than none, because it
943
+ * hides the failure it was written for.
944
+ */
945
+ function reexecFromCheckout(source, argv) {
946
+ const child = spawn(process.execPath, [source, ...argv], {
947
+ stdio: "inherit",
948
+ env: { ...process.env, REA_BUNDLE_SHIM_REEXEC: "1" },
949
+ windowsHide: true,
950
+ });
951
+ return new Promise((resolve) => {
952
+ let started = false;
953
+ child.once("spawn", () => {
954
+ started = true;
955
+ });
956
+ child.once("error", () => {
957
+ if (started) return;
958
+ resolve(false);
959
+ });
960
+ child.once("exit", (code, signal) => {
961
+ if (!started) {
962
+ // Exit without a spawn event: the child never ran. Let the caller fall
963
+ // back rather than exiting 0 on its behalf.
964
+ resolve(false);
965
+ return;
966
+ }
967
+ if (signal) {
968
+ const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
969
+ process.exit(128 + (signals[signal] ?? 0));
970
+ }
971
+ // A non-zero exit is normally the child's own answer and must be passed
972
+ // through untouched. One case is not the child's answer: an upgrade in
973
+ // flight replaced the file BETWEEN the check and node reading it, so node
974
+ // starts fine and dies with MODULE_NOT_FOUND. Passing that on turns a
975
+ // millisecond race into a dead row; if the source is gone by now, fall back
976
+ // to this copy instead. The window is small, the cost of covering it is one
977
+ // stat, and the alternative is a row that reports failure for a file that
978
+ // still exists a directory up.
979
+ if ((code ?? 0) !== 0 && !isFileSync(source)) {
980
+ resolve(false);
981
+ return;
982
+ }
983
+ process.exit(code ?? 0);
984
+ });
985
+ });
986
+ }
987
+
783
988
  /**
784
989
  * Usage text, also what a bare invocation prints.
785
990
  *
786
991
  * `package.json` exposes this file as the `rea-bundle-status` bin, and npm's bin
787
- * shim can only forward the arguments a user typed 鈥?it cannot append a flag. So a
992
+ * shim can only forward the arguments a user typed — it cannot append a flag. So a
788
993
  * bare `rea-bundle-status`, which is exactly what that name invites, used to fall
789
994
  * through to the launch path: it started REA, and on an unprovisioned machine it
790
995
  * began an ~800 MB download. Reporting usage instead makes the command's behaviour
@@ -808,7 +1013,7 @@ function usage() {
808
1013
  ].join("\n");
809
1014
  }
810
1015
 
811
- function main() {
1016
+ async function main() {
812
1017
  const argv = process.argv.slice(2);
813
1018
 
814
1019
  if (argv.includes("--shim-help") || argv.length === 0) {
@@ -817,12 +1022,36 @@ function main() {
817
1022
  }
818
1023
 
819
1024
  const root = bundleRoot();
1025
+ // Self-healing before anything else: a mirrored copy that predates the plugin
1026
+ // it was made from would otherwise keep running that older code forever, and a
1027
+ // market upgrade never re-runs setup. One hop, guarded, and never for the
1028
+ // status query — there the staleness is reported instead.
1029
+ const mirror = describeMirror(root);
1030
+ // A CHECKOUT copy is the source, not a mirror: hopping from it into whatever
1031
+ // the bundle's record names would silently run a different plugin — a developer
1032
+ // testing a local edit would be testing the installed one instead. The
1033
+ // discriminator is the one bundleRoot() already uses: only the checkout ships
1034
+ // cordis.patch.yml.
1035
+ const isCheckout = existsSync(path.join(PLUGIN_ROOT, "cordis.patch.yml"));
1036
+ if (!isCheckout && mirror !== null && mirror.stale && process.env.REA_BUNDLE_SHIM_REEXEC !== "1" && !argv.includes("--shim-status")) {
1037
+ const source = pluginCheckout(root);
1038
+ if (source !== null) {
1039
+ // It resolves only when the re-exec did not take over; while the child
1040
+ // runs, this process is parked and the child's exit is the exit.
1041
+ await reexecFromCheckout(source, argv);
1042
+ // The re-exec did not take over: a spawn failure, a child that exited
1043
+ // before its spawn event, or one that started and found its own file gone
1044
+ // (see the exit handler). Say so and run this copy for real — the point of
1045
+ // the fallback.
1046
+ process.stderr.write(`rea-shim: could not run ${source}; running the mirrored copy instead.\n`);
1047
+ }
1048
+ }
820
1049
  const loaded = readBundleConfig(root);
821
1050
  // Decide what to run before asking whether the bundle is provisioned: a
822
1051
  // machine with its own working REA must not be sent into an ~800 MB
823
1052
  // provisioning run just because this bundle has no state yet.
824
1053
  const bundleVersion = loaded.ok ? (loaded.config.versions?.reaAgents ?? null) : null;
825
- const launch = resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
1054
+ const launch = await resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
826
1055
  if (launch.invalid !== null) {
827
1056
  process.stderr.write(
828
1057
  `rea-shim: REA_BUNDLE_LAUNCH="${launch.invalid}" is not one of ${LAUNCH_MODES.join("/")}; using auto.\n`,
@@ -831,7 +1060,7 @@ function main() {
831
1060
  // Upstream can add a second row for the same serverName; the shim cannot stop
832
1061
  // DSH from loading both rows, but saying so is better than doubled tools with
833
1062
  // no stated cause. The status report carries the same fact for diagnostics.
834
- const duplicate = foreignReaRegistration();
1063
+ const duplicate = foreignReaRegistration(root);
835
1064
  if (duplicate && !argv.includes("--shim-status")) {
836
1065
  process.stderr.write(
837
1066
  `\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
@@ -860,6 +1089,10 @@ function main() {
860
1089
  },
861
1090
  };
862
1091
  if (launch.system?.rejected?.length) report.launch.rejected = launch.system.rejected;
1092
+ // A mirrored copy that lags the plugin keeps working, which is exactly why
1093
+ // nobody notices it: the row runs old code until the mirror is refreshed.
1094
+ // Reported here so `--shim-status` answers "am I running what I installed?".
1095
+ if (mirror !== null) report.mirror = mirror;
863
1096
  if (duplicate) report.duplicateRegistration = duplicate;
864
1097
  if (loaded.ok) {
865
1098
  report.paths = loaded.config.paths ?? {};
@@ -909,12 +1142,12 @@ function main() {
909
1142
  const provisioning = maybeAutoProvision(root);
910
1143
  if (provisioning.started) {
911
1144
  process.stderr.write(
912
- `\n dsh-plugin-rea (bundle): first run 鈥?provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
1145
+ `\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
913
1146
  );
914
1147
  } else if (provisioning.reason === "last-run-failed") {
915
1148
  process.stderr.write(
916
1149
  `\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
917
- ` Nothing is running right now 鈥?read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
1150
+ ` Nothing is running right now — read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
918
1151
  );
919
1152
  } else if (provisioning.reason === "in-flight") {
920
1153
  // A live run owns the lock. Saying nothing here is what made a wedged
@@ -972,5 +1205,8 @@ function main() {
972
1205
  return null;
973
1206
  }
974
1207
 
975
- const status = main();
1208
+ // The launch decision awaits a probe, so `main` is async; the status it returns
1209
+ // is this process's exit code, and a launch path resolves to null while the
1210
+ // child (already wired to `process.exit`) keeps the loop alive.
1211
+ const status = await main();
976
1212
  if (typeof status === "number") process.exit(status);