dsh-plugin-rea 2.0.5 → 2.0.7

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,12 +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
- import { spawn } from "node:child_process";
18
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { spawn, spawnSync } from "node:child_process";
18
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import path from "node:path";
21
21
  import process from "node:process";
@@ -43,11 +43,11 @@ function dshHome() {
43
43
  * DSH_HOME: the harness strips every `DSH_*` name from an MCP child's
44
44
  * environment (case-insensitively, because Windows is), so a custom DSH_HOME
45
45
  * 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.
46
+ * bundle sitting right next to it 鈥?and start a second ~800 MB run there.
47
47
  * * The **checkout** copy (`<plugin>/bin/rea-shim.mjs`) is what a human runs, e.g.
48
48
  * `npm run bundle:status`, from a shell where DSH_HOME *is* visible. Its own
49
49
  * directory is the plugin, not the bundle, so resolving to itself would report
50
- * "not provisioned" on a fully provisioned machine — and a bare
50
+ * "not provisioned" on a fully provisioned machine 鈥?and a bare
51
51
  * `node bin/rea-shim.mjs mcp` would provision ~800 MB into the checkout.
52
52
  *
53
53
  * Only the checkout ships `cordis.patch.yml`, so that file is the discriminator.
@@ -80,7 +80,7 @@ function usableSetup(dir) {
80
80
  * The mirrored copy is a snapshot: after a plugin upgrade it still holds the
81
81
  * previous manifest, so it would provision the previous versions forever. setup
82
82
  * 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.
83
+ * usable, and fall back to the local copy 鈥?which is also all a first run has.
84
84
  */
85
85
  function provisioningScript(root) {
86
86
  try {
@@ -104,7 +104,7 @@ function provisioningScript(root) {
104
104
  *
105
105
  * The parse is deliberately small: the two files it cares about are block
106
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
107
+ * ignored 鈥?a missed hint is acceptable, a false alarm is not. The mirrored shim
108
108
  * cannot see a custom DSH_HOME (the harness strips DSH_* from an MCP child), so
109
109
  * this falls back to `~/.dsh` like the rest of the bundle; the row has the same
110
110
  * limitation.
@@ -133,7 +133,7 @@ function foreignReaRegistration() {
133
133
  /**
134
134
  * The provisioning lock is a small JSON record: { state, pid, at, exit? }.
135
135
  * `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
136
+ * exists), and the failing run rewrites it as `state: "failed"` 鈥?it is kept on
137
137
  * purpose so a broken setup cannot be re-downloaded in a loop. A legacy
138
138
  * plain-number lock is still honoured as "running".
139
139
  */
@@ -171,7 +171,7 @@ function pidAlive(pid) {
171
171
  * a killed attempt is simply continued on the next start.
172
172
  *
173
173
  * Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
174
- * Returns { started, reason, logPath } — the caller words its message from
174
+ * Returns { started, reason, logPath } 鈥?the caller words its message from
175
175
  * `reason`, because "provisioning started in the background" was also printed
176
176
  * for a lock left behind by a run that had already failed, and for one whose
177
177
  * process was long gone.
@@ -208,7 +208,7 @@ function maybeAutoProvision(root) {
208
208
  // Reached when the lock is older than the freshness window, or its holder is
209
209
  // gone and never wrote an outcome (harness restart, power loss, a kill): what
210
210
  // 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
211
+ // freshness rule 鈥?without it the exclusive create below failed with EEXIST
212
212
  // *forever*, auto-provisioning never ran again, and main() printed nothing at
213
213
  // all for that outcome. The lock's own "honour it only while it is fresh"
214
214
  // comment described behaviour the code did not have.
@@ -265,15 +265,20 @@ function fail(message, detail) {
265
265
  if (detail) lines.push("", " detail: " + detail);
266
266
  // Point at the real checkout when the record allows it: running the mirrored
267
267
  // 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
268
+ // with 鈥?and a bundle mirrored before the manifest travelled with it has no
269
269
  // usable copy at all, so the honest answer is then "run the plugin's own script".
270
- const setupScript = provisioningScript(bundleRoot());
270
+ const root = bundleRoot();
271
+ const setupScript = provisioningScript(root);
271
272
  lines.push(
272
273
  "",
273
274
  " Finish setup with:",
274
275
  setupScript
275
- ? " node \"" + setupScript + "\""
276
- : " node \"<installed plugin directory>/scripts/setup.mjs\"",
276
+ // `--root` is not decoration: a shim started with REA_BUNDLE_ROOT (or with
277
+ // DSH_HOME unset in a row's scrubbed environment) resolves a root that
278
+ // setup's own default need not agree with, and a remediation command that
279
+ // provisions into another root leaves this row failing exactly as before.
280
+ ? " node \"" + setupScript + "\" --root \"" + root + "\""
281
+ : " node \"<installed plugin directory>/scripts/setup.mjs\" --root \"" + root + "\"",
277
282
  );
278
283
  if (!setupScript) {
279
284
  lines.push("", " (Run it from the installed plugin directory: the copy in this bundle has no manifest.)");
@@ -311,7 +316,7 @@ function resolveRea(config, root) {
311
316
  // The fallback has to match where setup actually installs the package:
312
317
  // `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
313
318
  // fallback without that segment named a directory that never exists. It only
314
- // fires when an older bundle.json has no paths.reaEntry — which is exactly when
319
+ // fires when an older bundle.json has no paths.reaEntry 鈥?which is exactly when
315
320
  // a wrong fallback turns a usable install into "the vendored REA CLI is gone".
316
321
  const reaEntry = paths.reaEntry
317
322
  ?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
@@ -363,11 +368,423 @@ function childEnvironment(config) {
363
368
  return env;
364
369
  }
365
370
 
371
+ /**
372
+ * Launch modes. 2.x exists to supply a runtime the machine does not have; when
373
+ * the machine already has one, the honest thing to do is run it.
374
+ *
375
+ * 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
377
+ * is the 1.x contract, reachable from the 2.x row. `auto` (the default) prefers
378
+ * the system install and falls back to the provisioned bundle; `bundle` pins the
379
+ * old behaviour; `system` insists (and fails loudly instead of provisioning
380
+ * 800 MB behind an operator who asked for their own REA).
381
+ */
382
+ const LAUNCH_MODES = ["auto", "system", "bundle"];
383
+
384
+ /**
385
+ * How long a candidate may take to answer `--version`.
386
+ *
387
+ * Measured on a real npm-global install: `rea --version` through its `.cmd`
388
+ * shim takes ~2.4 s cold (cmd.exe -> node -> the CLI's own module graph), and
389
+ * 2.8-3.0 s with four probes in flight. A 3 s budget was therefore a coin flip:
390
+ * half the runs rejected a perfectly good REA and silently fell back to the
391
+ * bundle. 8 s leaves room for a slow disk and a loaded machine while still
392
+ * bounding the one-off cost at startup.
393
+ */
394
+ const PROBE_TIMEOUT_MS = 8_000;
395
+
396
+ /**
397
+ * What the environment says about the launch mode.
398
+ *
399
+ * An unknown value is reported and treated as `auto`: the row is launched by
400
+ * the harness with nobody watching stderr, so refusing to start over a typo
401
+ * would read as "the plugin is broken". `auto` is the mode that still works.
402
+ */
403
+ function launchPreference(env = process.env) {
404
+ const raw = (env.REA_BUNDLE_LAUNCH ?? "").trim().toLowerCase();
405
+ if (raw === "") return { value: "auto", invalid: null };
406
+ return LAUNCH_MODES.includes(raw)
407
+ ? { value: raw, invalid: null }
408
+ : { value: "auto", invalid: raw };
409
+ }
410
+
411
+ /** True when `child` is `parent` or lives under it (case-insensitive on Windows). */
412
+ function isInside(child, parent) {
413
+ const normalise = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
414
+ const target = normalise(path.resolve(child));
415
+ const base = normalise(path.resolve(parent));
416
+ if (target === base) return true;
417
+ const relative = path.relative(base, target);
418
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
419
+ }
420
+
421
+ /**
422
+ * Executable names a REA install provides on this platform.
423
+ *
424
+ * npm's global install writes a shell script plus `.cmd`/`.ps1` shims on
425
+ * Windows, and only the `.cmd` one is spawnable without a shell. `rea.exe` is
426
+ * accepted for a native/standalone install.
427
+ */
428
+ function systemReaNames(platform = process.platform) {
429
+ return platform === "win32" ? ["rea.cmd", "rea.exe", "rea.bat"] : ["rea"];
430
+ }
431
+
432
+ /**
433
+ * Every path worth probing, most explicit first.
434
+ *
435
+ * `REA_BUNDLE_SYSTEM_REA` names one binary outright 鈥?that is both the test
436
+ * seam and a real operator need (an install that is not on the harness's PATH,
437
+ * which on Desktop is not the user's shell PATH).
438
+ */
439
+ function systemReaCandidates({ env = process.env, platform = process.platform } = {}) {
440
+ const candidates = [];
441
+ const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
442
+ if (explicit !== "") candidates.push(path.resolve(explicit));
443
+ const searchPath = env.PATH ?? env.Path ?? "";
444
+ for (const raw of searchPath.split(path.delimiter)) {
445
+ // A quoted PATH entry (`PATH="C:\Program Files\v66";…`) is legal in Windows
446
+ // environments and `path.join` would treat the quotes as part of the name, so
447
+ // the install would be invisible. A relative entry is skipped rather than
448
+ // resolved: its meaning depends on the cwd THIS process happens to have, and
449
+ // a candidate found that way would be launched by a path that means something
450
+ // else to whoever reads the status report.
451
+ const dir = raw.trim().replace(/^"(.*)"$/, "$1");
452
+ if (dir === "" || !path.isAbsolute(dir)) continue;
453
+ for (const name of systemReaNames(platform)) candidates.push(path.join(dir, name));
454
+ }
455
+ const seen = new Set();
456
+ return candidates.filter((candidate) => {
457
+ const key = process.platform === "win32" ? candidate.toLowerCase() : candidate;
458
+ if (seen.has(key)) return false;
459
+ seen.add(key);
460
+ return true;
461
+ });
462
+ }
463
+
464
+ /**
465
+ * Kill a process and its children.
466
+ *
467
+ * `spawnSync`'s timeout kills only the process it started — for a `.cmd` shim
468
+ * that is cmd.exe, and the `node` running inside it keeps the inherited stdio
469
+ * handles open and keeps working. Taskkill's `/T` is the one portable-enough way
470
+ * to get the tree on Windows; POSIX uses the process itself (the candidate is a
471
+ * shebang script there, so there is no intermediate shell).
472
+ */
473
+ function killTree(pid, platform = process.platform) {
474
+ if (typeof pid !== "number" || pid <= 0) return;
475
+ try {
476
+ if (platform === "win32") {
477
+ const taskkill = path.join(process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows", "System32", "taskkill.exe");
478
+ spawnSync(taskkill, ["/pid", String(pid), "/T", "/F"], {
479
+ stdio: "ignore",
480
+ windowsHide: true,
481
+ timeout: 5_000,
482
+ });
483
+ return;
484
+ }
485
+ try {
486
+ process.kill(pid, "SIGKILL");
487
+ } catch {
488
+ /* already gone */
489
+ }
490
+ } catch {
491
+ /* best effort: a failed cleanup must not change the verdict */
492
+ }
493
+ }
494
+
495
+ /** True when the path is a regular file (a directory named `rea.cmd` is not). */
496
+ function isFileSync(file) {
497
+ try {
498
+ return statSync(file).isFile();
499
+ } catch {
500
+ return false;
501
+ }
502
+ }
503
+
504
+ /** A command line that survives `cmd /c` on Windows: quoted, quotes doubled. */
505
+ function cmdQuote(value) {
506
+ return `"${String(value).replace(/"/g, '""')}"`;
507
+ }
508
+
509
+ /**
510
+ * The shell used to run a `.cmd`/`.bat` candidate.
511
+ *
512
+ * Absolute on purpose: `spawn(comspec())` is resolved against the CHILD's PATH,
513
+ * and an MCP row inherits a scrubbed environment. A machine with a working REA
514
+ * but a PATH that does not include System32 would then be judged "no REA",
515
+ * which is precisely the wrong answer for the feature that exists to find it.
516
+ */
517
+ function comspec() {
518
+ const fromEnv = process.env.ComSpec ?? process.env.COMSPEC;
519
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv;
520
+ const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
521
+ return path.join(systemRoot, "System32", "cmd.exe");
522
+ }
523
+
524
+ /**
525
+ * Ask a candidate whether it is a working REA.
526
+ *
527
+ * A file existing on PATH is not the same as a working install: npx leftovers,
528
+ * half-removed globals and a shim whose Node is gone all sit on PATH and all
529
+ * 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
531
+ * delegating to a broken binary would take the tools down with it.
532
+ */
533
+ 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 };
574
+ }
575
+
576
+ /**
577
+ * The system REA this machine already has, or why it was rejected.
578
+ *
579
+ * Candidates inside this bundle or this checkout are skipped: a REA that the
580
+ * provisioner installed is the `bundle` mode, and delegating to it would be a
581
+ * loop through a path the bundle already covers.
582
+ */
583
+ function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
584
+ const rejected = [];
585
+ let profiled = 0;
586
+ const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
587
+ // Probing costs a process start per candidate, and a row has to answer before
588
+ // its tools exist. The first existing entry is what the shell would run, so a
589
+ // couple of fallbacks is plenty; a machine whose first three `rea`s are all
590
+ // broken is a repair job, not something this decision should keep paying for.
591
+ const MAX_PROBES = 3;
592
+ for (const candidate of systemReaCandidates({ env, platform })) {
593
+ if (isInside(candidate, root) || isInside(candidate, pluginRoot)) {
594
+ // A REA the provisioner installed is `bundle` mode; delegating to it would
595
+ // be a loop back into the thing this decision is choosing between.
596
+ if (rejected.length < 8) rejected.push({ file: candidate, reason: "inside the bundle" });
597
+ continue;
598
+ }
599
+ // The candidate list is PATH x every executable name: one missing name per
600
+ // directory is normal and is not a finding. Only a regular file that EXISTS
601
+ // and then fails the probe is worth reporting.
602
+ try {
603
+ if (!statSync(candidate).isFile()) continue;
604
+ } catch {
605
+ continue;
606
+ }
607
+ if (profiled >= MAX_PROBES) {
608
+ if (rejected.length < 8) rejected.push({ file: candidate, reason: "not probed (budget)" });
609
+ continue;
610
+ }
611
+ profiled += 1;
612
+ const probe = probeRea(candidate, { platform });
613
+ if (probe.ok) return { found: true, file: candidate, version: probe.version };
614
+ if (rejected.length < 8) rejected.push({ file: candidate, reason: probe.reason });
615
+ }
616
+ // Say what actually happened, not what a flag says: an explicit path that was
617
+ // skipped because it lives inside the bundle is a different answer from one
618
+ // that does not exist.
619
+ const explicitPath = explicit === "" ? null : path.resolve(explicit);
620
+ const explicitRejection = explicitPath === null
621
+ ? null
622
+ : (rejected.find(entry => entry.file === explicitPath) ?? null);
623
+ let reason;
624
+ if (explicitPath !== null && explicitRejection !== null) {
625
+ reason = `REA_BUNDLE_SYSTEM_REA was rejected (${explicitRejection.reason}): ${explicitPath}`;
626
+ } else if (explicitPath !== null && !isFileSync(explicitPath)) {
627
+ reason = `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
628
+ } else {
629
+ reason = profiled === 0 ? "no rea on PATH" : "no usable rea on PATH";
630
+ }
631
+ return { found: false, reason, rejected };
632
+ }
633
+
634
+ /**
635
+ * Numeric dotted comparison (6.4.0 vs 6.5.0). null when either side is not a
636
+ * plain x.y.z — a prerelease or a vendor tag is not something this decision
637
+ * should pretend to order.
638
+ */
639
+ function compareDotted(a, b) {
640
+ const parse = (value) => {
641
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value ?? "").trim());
642
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
643
+ };
644
+ const left = parse(a);
645
+ const right = parse(b);
646
+ if (left === null || right === null) return null;
647
+ for (let index = 0; index < 3; index += 1) {
648
+ if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1;
649
+ }
650
+ return 0;
651
+ }
652
+
653
+ /**
654
+ * Which REA this invocation should run.
655
+ *
656
+ * `auto` prefers the system install, but not blindly: a global REA older than
657
+ * the one this bundle provisioned would silently take the tool count back
658
+ * (6.4.0 ships 170 tools, 6.5.0 ships 207), so the newer of the two wins and
659
+ * 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
661
+ * behind an operator who asked for their own REA.
662
+ */
663
+ function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
664
+ const preference = launchPreference(env);
665
+ if (preference.value === "bundle") {
666
+ return { preference: preference.value, invalid: preference.invalid, mode: "bundle", reason: "pinned by REA_BUNDLE_LAUNCH=bundle" };
667
+ }
668
+ // A delegated child that ends up back in this shim (a wrapper script named
669
+ // `rea` that calls us, a PATH that resolves to a launcher) would recurse
670
+ // forever: the probe cannot break it, because the grandchild starts a fresh
671
+ // one. One flag, written into the child's environment, ends the loop — the
672
+ // nested run takes the bundle path, which either works or fails loudly.
673
+ if ((env.REA_BUNDLE_DELEGATED ?? "") === "1") {
674
+ return {
675
+ preference: preference.value,
676
+ invalid: preference.invalid,
677
+ mode: "bundle",
678
+ reason: "REA_BUNDLE_DELEGATED=1: already running under a delegated system REA, refusing to delegate again",
679
+ };
680
+ }
681
+ const system = detectSystemRea({ env, platform, root });
682
+ if (system.found) {
683
+ const order = bundleVersion === null ? null : compareDotted(system.version, bundleVersion);
684
+ const pinned = preference.value === "system";
685
+ // No provisioned build at all: there is nothing to compare against, and the
686
+ // machine's own REA is exactly what this mode exists to use.
687
+ if (!bundleProvisioned || pinned || (order !== null && order >= 0)) {
688
+ return {
689
+ preference: preference.value,
690
+ invalid: preference.invalid,
691
+ mode: "system",
692
+ reason: pinned
693
+ ? `pinned by REA_BUNDLE_LAUNCH=system (system ${system.version}, bundle ${bundleVersion ?? "none"})`
694
+ : !bundleProvisioned
695
+ ? `a working REA is on PATH (${system.version}) and this machine has no provisioned bundle`
696
+ : `the REA on PATH (${system.version}) is at least as new as the provisioned one (${bundleVersion})`,
697
+ system,
698
+ };
699
+ }
700
+ return {
701
+ preference: preference.value,
702
+ invalid: preference.invalid,
703
+ mode: "bundle",
704
+ reason: order === null
705
+ ? `the REA on PATH (${system.version}) cannot be compared with the provisioned one (${bundleVersion}); keeping the bundle — set REA_BUNDLE_LAUNCH=system to use the system install`
706
+ : `the REA on PATH (${system.version}) is older than the provisioned one (${bundleVersion}); set REA_BUNDLE_LAUNCH=system to use it anyway`,
707
+ system,
708
+ };
709
+ }
710
+ if (preference.value === "system") {
711
+ return {
712
+ preference: preference.value,
713
+ invalid: preference.invalid,
714
+ mode: "system",
715
+ reason: `REA_BUNDLE_LAUNCH=system, but no usable REA was found (${system.reason})`,
716
+ system,
717
+ unusable: true,
718
+ };
719
+ }
720
+ return {
721
+ preference: preference.value,
722
+ invalid: preference.invalid,
723
+ mode: "bundle",
724
+ reason: `no usable system REA (${system.reason})`,
725
+ system,
726
+ };
727
+ }
728
+
729
+ /** Launch the system REA exactly the way the 1.x row did. */
730
+ function launchSystemRea(system, argv) {
731
+ const args = argv.length > 0 ? argv : ["mcp"];
732
+ const useShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(system.file);
733
+ // `REA_BUNDLE_DELEGATED` marks the child as "already the system REA". Without
734
+ // it a wrapper on PATH that calls this shim would delegate to itself forever.
735
+ const env = { ...process.env, REA_BUNDLE_DELEGATED: "1" };
736
+ // Only the PATH is quoted. Quoting the arguments too would hand them to a
737
+ // `.cmd`'s `%*` WITH their quotes (`"mcp"` instead of `mcp`) — measured — which
738
+ // is a different command line than the 1.x row produced. The row passes `mcp`
739
+ // and nothing else, so plain tokens are the contract here; an argument that
740
+ // needs shell quoting is not something this path supports (cmd would also
741
+ // expand `%NAME%` inside it).
742
+ const child = useShell
743
+ ? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(system.file), ...args].join(" ")}"`], {
744
+ env,
745
+ stdio: "inherit",
746
+ windowsHide: true,
747
+ windowsVerbatimArguments: true,
748
+ })
749
+ : 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
+ }
769
+ child.on("error", (error) => {
770
+ process.stderr.write(`rea-shim: could not start ${system.file}\n ${String(error && error.message)}\n`);
771
+ process.exit(EX_CONFIG);
772
+ });
773
+ child.on("exit", (code, signal) => {
774
+ if (signal) {
775
+ const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
776
+ process.exit(128 + (signals[signal] ?? 0));
777
+ }
778
+ process.exit(code ?? 0);
779
+ });
780
+ return null;
781
+ }
782
+
366
783
  /**
367
784
  * Usage text, also what a bare invocation prints.
368
785
  *
369
786
  * `package.json` exposes this file as the `rea-bundle-status` bin, and npm's bin
370
- * shim can only forward the arguments a user typed — it cannot append a flag. So a
787
+ * shim can only forward the arguments a user typed 鈥?it cannot append a flag. So a
371
788
  * bare `rea-bundle-status`, which is exactly what that name invites, used to fall
372
789
  * through to the launch path: it started REA, and on an unprovisioned machine it
373
790
  * began an ~800 MB download. Reporting usage instead makes the command's behaviour
@@ -375,15 +792,19 @@ function childEnvironment(config) {
375
792
  */
376
793
  function usage() {
377
794
  return [
378
- "rea-shim: launches the vendored REA MCP server for a DSH MCP row.",
795
+ "rea-shim: launches a REA MCP server for a DSH MCP row.",
379
796
  "",
380
797
  " node bin/rea-shim.mjs [rea arguments...] (normal use: 'mcp')",
381
- " node bin/rea-shim.mjs --shim-status provisioning status, no launch",
798
+ " node bin/rea-shim.mjs --shim-status launch decision and provisioning status, no launch",
382
799
  " node bin/rea-shim.mjs --shim-print-env the environment it would export",
383
800
  "",
384
801
  ` bundle root: ${bundleRoot()}`,
385
802
  ` plugin root: ${PLUGIN_ROOT}`,
386
803
  "",
804
+ " REA_BUNDLE_LAUNCH=auto|system|bundle (default auto: use the REA already",
805
+ " on PATH when it answers --version, otherwise the provisioned bundle)",
806
+ " REA_BUNDLE_SYSTEM_REA=<path> probe this binary instead of PATH",
807
+ "",
387
808
  ].join("\n");
388
809
  }
389
810
 
@@ -397,6 +818,16 @@ function main() {
397
818
 
398
819
  const root = bundleRoot();
399
820
  const loaded = readBundleConfig(root);
821
+ // Decide what to run before asking whether the bundle is provisioned: a
822
+ // machine with its own working REA must not be sent into an ~800 MB
823
+ // provisioning run just because this bundle has no state yet.
824
+ const bundleVersion = loaded.ok ? (loaded.config.versions?.reaAgents ?? null) : null;
825
+ const launch = resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
826
+ if (launch.invalid !== null) {
827
+ process.stderr.write(
828
+ `rea-shim: REA_BUNDLE_LAUNCH="${launch.invalid}" is not one of ${LAUNCH_MODES.join("/")}; using auto.\n`,
829
+ );
830
+ }
400
831
  // Upstream can add a second row for the same serverName; the shim cannot stop
401
832
  // DSH from loading both rows, but saying so is better than doubled tools with
402
833
  // no stated cause. The status report carries the same fact for diagnostics.
@@ -417,7 +848,18 @@ function main() {
417
848
  provisioned: loaded.ok,
418
849
  reason: loaded.ok ? undefined : loaded.code,
419
850
  autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
851
+ launch: {
852
+ preference: launch.preference,
853
+ mode: launch.mode,
854
+ reason: launch.reason,
855
+ // Reported, not implied: `mode: "system"` with `unusable: true` is a
856
+ // request that cannot be met, and a caller reading only the mode would
857
+ // draw the opposite conclusion.
858
+ unusable: launch.unusable === true,
859
+ systemRea: launch.system?.found ? { file: launch.system.file, version: launch.system.version } : null,
860
+ },
420
861
  };
862
+ if (launch.system?.rejected?.length) report.launch.rejected = launch.system.rejected;
421
863
  if (duplicate) report.duplicateRegistration = duplicate;
422
864
  if (loaded.ok) {
423
865
  report.paths = loaded.config.paths ?? {};
@@ -425,7 +867,33 @@ function main() {
425
867
  report.rea = resolveRea(loaded.config, root);
426
868
  }
427
869
  process.stdout.write(JSON.stringify(report, null, 2) + "\n");
428
- return loaded.ok ? 0 : EX_CONFIG;
870
+ // The row works either way round — a system launch needs no bundle, a bundle
871
+ // launch needs a provisioned one — but a PINNED system launch with nothing to
872
+ // launch is a failure whether or not the bundle happens to be provisioned,
873
+ // and reporting 0 there is how a broken row looks healthy until it starts.
874
+ const usable = launch.mode === "system" ? launch.unusable !== true : loaded.ok;
875
+ return usable ? 0 : EX_CONFIG;
876
+ }
877
+
878
+ if (launch.mode === "system") {
879
+ if (launch.unusable) {
880
+ process.stderr.write(
881
+ `\n dsh-plugin-rea (bundle): REA_BUNDLE_LAUNCH=system was requested, but no usable REA was found.\n` +
882
+ ` ${launch.reason}\n\n` +
883
+ ` Install REA (\`npm install -g rea-agents\`), point REA_BUNDLE_SYSTEM_REA at it,\n` +
884
+ ` or unset REA_BUNDLE_LAUNCH to use the provisioned bundle instead.\n`,
885
+ );
886
+ return EX_CONFIG;
887
+ }
888
+ if (argv.includes("--shim-print-env")) {
889
+ process.stdout.write(
890
+ JSON.stringify({ launch: "system", rea: launch.system.file, version: launch.system.version }, null, 2) + "\n",
891
+ );
892
+ return 0;
893
+ }
894
+ // The 1.x contract, from the 2.x row: the machine's own REA, its own Node,
895
+ // its own PATH. Nothing is provisioned and no bundle state is required.
896
+ return launchSystemRea(launch.system, argv);
429
897
  }
430
898
 
431
899
  if (!loaded.ok) {
@@ -441,12 +909,12 @@ function main() {
441
909
  const provisioning = maybeAutoProvision(root);
442
910
  if (provisioning.started) {
443
911
  process.stderr.write(
444
- `\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
912
+ `\n dsh-plugin-rea (bundle): first run 鈥?provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
445
913
  );
446
914
  } else if (provisioning.reason === "last-run-failed") {
447
915
  process.stderr.write(
448
916
  `\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
449
- ` Nothing is running right now — read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
917
+ ` Nothing is running right now 鈥?read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
450
918
  );
451
919
  } else if (provisioning.reason === "in-flight") {
452
920
  // A live run owns the lock. Saying nothing here is what made a wedged
@@ -457,7 +925,7 @@ function main() {
457
925
  } else if (provisioning.reason === "spawn-failed" || provisioning.reason === "unwritable") {
458
926
  process.stderr.write(
459
927
  `\n dsh-plugin-rea (bundle): provisioning could not be started (${provisioning.reason}).\n` +
460
- ` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}"\n`,
928
+ ` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}" --root "${root}"\n`,
461
929
  );
462
930
  }
463
931
  return fail(message, loaded.detail);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-rea",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
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",
@@ -58,10 +58,12 @@
58
58
  "bundle:status": "node ./bin/rea-shim.mjs --shim-status",
59
59
  "bundle:dry-run": "node ./scripts/setup.mjs --reuse --dry-run",
60
60
  "bundle:audit": "node ./scripts/audit-manifest.mjs",
61
- "bundle:test": "node --test ./scripts/setup.test.mjs"
61
+ "bundle:test": "node --test ./scripts/setup.test.mjs",
62
+ "bundle:host-compat": "node ./scripts/host-compat.mjs"
62
63
  },
63
64
  "engines": {
64
- "node": "^22.19.0 || ^24.11.0 || >=26.0.0"
65
+ "node": "^22.19.0 || ^24.11.0 || >=26.0.0",
66
+ "dsh": ">=0.1.7-rc.2 <0.3.0-0"
65
67
  },
66
68
  "publishConfig": {
67
69
  "registry": "https://registry.npmjs.org/"